feat: P0-4 按库存优化+切割准备清单(缺料告警)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md)
|
||||
|
||||
- [2026-09-25 12:20] FEAT: v2.22 P0-3 打印模板体系。①OrderFormat 模型(blocks 区块 JSON: title/info/image/table/sign)+种子 2 套默认模板; ②/api/order-formats CRUD(区块类型校验+is_default 唯一默认); ③打印中心重构为模板驱动: A4 预览按 blocks 渲染(BOM 块自动拉取 /orders/{id}/bom), 模板管理对话框(区块增删/上下移/title 文本/info 字段多选/table kind 选择); 修复 move(i) v-for 索引 string|number 类型; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/router.py, src/huamenchuang/scripts/seed.py, frontend/src/views/PrinterView.vue, doc/API.md)
|
||||
|
||||
- [2026-09-25 11:40] FEAT: v2.21 P0-2 订单 BOM 计算单+Excel 导出。①quote_service.bom() 从 drawing_data 重放算料输出型材下料清单(calcbomOrder 语义); ②services/excel_service.py 通用 xlsx 工具(标题/带样式表格/列宽)+order_workbook 订单导出(信息+型材清单+玻璃五金汇总+签审行); ③GET /orders/{id}/bom + GET /orders/{id}/export(attachment 下载); ④前端订单详情抽屉加"BOM 单"弹窗(汇总+下料表+合计米数)+导出 Excel(apiDownload blob 带 token); openpyxl 入依赖; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/api/orders.py, frontend/src/api/index.ts, frontend/src/views/OrdersView.vue, doc/API.md)
|
||||
|
||||
@@ -405,3 +405,12 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
||||
| 删除模板 | DELETE | `/api/order-formats/{id}` | 路径 | |
|
||||
|
||||
种子模板 2 套:标准生产订单(默认)、生产订单+BOM 下料单。
|
||||
|
||||
### 12.18 合同下料方案升级(v2.20,P0-按库存优化)
|
||||
|
||||
`POST /api/cut_solution` 请求扩展:`{contract_id, kerf, use_stock?, stock_length?}`。
|
||||
|
||||
- `use_stock=true`:从仓库实际库存(material=profile 且 length>0 且 count>0)取原料优化;需求超出库存时输出 `shortage[]` 缺料清单(length/label/qty),不拦截计算
|
||||
- `use_stock=false`:按 `stock_length`(默认 6000mm)虚拟原料,数量按需求自动估算
|
||||
- 响应/保存均新增:`result`(完整优化方案)、`preparation[]`(切割准备/领料清单:原料×根数×切割件数×余料)、`shortage[]`(按库存模式的缺料清单)
|
||||
- `GET /api/cut_solution/{contract_id}` 返回完整保存方案(含 result/preparation/shortage)
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar no-print">
|
||||
<el-select v-model="contractId" filterable placeholder="选择合同" style="width:300px">
|
||||
<el-option v-for="c in contracts" :key="c.id" :label="`${c.contract_no} ${c.customer_name || ''}`" :value="c.id" />
|
||||
</el-select>
|
||||
<span>锯口</span>
|
||||
<el-input-number v-model="kerf" :min="0" :max="50" size="small" controls-position="right" style="width:90px" />
|
||||
<el-divider direction="vertical" />
|
||||
<el-checkbox v-model="useStock">使用仓库库存原料</el-checkbox>
|
||||
<template v-if="!useStock">
|
||||
<span>标准原料长度</span>
|
||||
<el-input-number v-model="stockLength" :min="1000" :max="12000" :step="500" size="small"
|
||||
controls-position="right" style="width:110px" />
|
||||
</template>
|
||||
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
|
||||
<el-button v-if="result" type="success" plain @click="print">打印方案</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="result">
|
||||
<el-alert type="success" :closable="false" show-icon
|
||||
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根`"
|
||||
<el-alert type="success" :closable="false" show-icon class="no-print"
|
||||
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根${result.use_stock ? ' · 按仓库库存' : ''}`"
|
||||
style="margin-bottom:12px" />
|
||||
|
||||
<!-- 切割准备/领料清单 -->
|
||||
<h4 class="sec-title">切割准备(领料单)<span id="print-title" v-if="false"></span></h4>
|
||||
<el-table :data="result.preparation" border size="small">
|
||||
<el-table-column label="原料" min-width="160">
|
||||
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bars" label="领料根数" width="100" />
|
||||
<el-table-column prop="pieces" label="切割件数" width="100" />
|
||||
<el-table-column label="合计余料(mm)" width="130">
|
||||
<template #default="{ row }">{{ row.waste }}(均 {{ Math.round(row.waste / row.bars) }})</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 缺料清单(按库存模式) -->
|
||||
<template v-if="result.use_stock && result.shortage?.length">
|
||||
<h4 class="sec-title" style="color:#e6a23c">缺料清单(需求超出库存,需补库)</h4>
|
||||
<el-table :data="result.shortage" border size="small" :row-style="{ background: '#fdf6ec' }">
|
||||
<el-table-column prop="label" label="缺料用途" min-width="140" />
|
||||
<el-table-column prop="length" label="单件长度(mm)" width="120" />
|
||||
<el-table-column prop="qty" label="缺口(件)" width="100" />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<h4 class="sec-title">切割方案明细</h4>
|
||||
<el-table :data="result.plans" border size="small">
|
||||
<el-table-column label="原料" width="180">
|
||||
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
||||
@@ -25,8 +58,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="waste" label="余料mm" width="90" />
|
||||
</el-table>
|
||||
<el-alert v-if="result.unplaced.length" type="warning" :closable="false"
|
||||
:title="`未排入 ${result.unplaced.length} 件(库存不足)`" style="margin-top:10px" />
|
||||
|
||||
<template v-if="!result.use_stock && result.unplaced.length">
|
||||
<el-alert type="warning" :closable="false" class="no-print"
|
||||
:title="`未排入 ${result.unplaced.length} 件`" style="margin-top:10px" />
|
||||
</template>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -40,19 +76,26 @@ const contracts = ref<any[]>([])
|
||||
const contractId = ref<number | undefined>()
|
||||
const contractNo = ref('')
|
||||
const kerf = ref(5)
|
||||
const useStock = ref(false)
|
||||
const stockLength = ref(6000)
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
|
||||
async function run() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<any>('post', '/api/cut_solution', { contract_id: contractId.value, kerf: kerf.value })
|
||||
const d = await api<any>('post', '/api/cut_solution', {
|
||||
contract_id: contractId.value, kerf: kerf.value,
|
||||
use_stock: useStock.value, stock_length: stockLength.value,
|
||||
})
|
||||
result.value = d
|
||||
contractNo.value = d.contract_no
|
||||
ElMessage.success(`已生成:利用率 ${(d.stats.utilization * 100).toFixed(1)}%`)
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function print() { window.print() }
|
||||
|
||||
onMounted(async () => {
|
||||
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
|
||||
contracts.value = d.contracts?.data ?? []
|
||||
@@ -60,5 +103,12 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.sec-title { margin: 16px 0 8px; color: #303133; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body * { visibility: hidden; }
|
||||
.el-card, .el-card * { visibility: visible; }
|
||||
.el-card { position: absolute; left: 0; top: 0; width: 100%; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,45 +54,103 @@ async def save_render_setting(body: RenderSettingBody, user: CurrentUser, db: Db
|
||||
class CutSolutionBody(BaseModel):
|
||||
contract_id: int
|
||||
kerf: int = Field(default=5, ge=0, le=50)
|
||||
use_stock: bool = Field(default=False, description="使用仓库实际库存原料优化(不足部分输出缺口清单)")
|
||||
stock_length: int = Field(default=6000, ge=1000, le=12000, description="非库存模式的标准原料长度(mm)")
|
||||
|
||||
|
||||
@router.post("/cut_solution")
|
||||
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
|
||||
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存参数。"""
|
||||
contract = await db.get(Contract, body.contract_id)
|
||||
if contract is None or contract.company_id != user.company_id:
|
||||
return fail("合同不存在")
|
||||
stmt = select(Order).where(Order.contract_id == body.contract_id,
|
||||
Order.company_id == user.company_id)
|
||||
orders = list((await db.execute(stmt)).scalars())
|
||||
demands: dict[tuple, float] = {}
|
||||
def _contract_demands(orders) -> dict[tuple, int]:
|
||||
"""汇总订单图纸的型材需求 {(length, note): qty}。"""
|
||||
from huamenchuang.core.drawing import parse_drawing
|
||||
from huamenchuang.services.quote_service import takeoff
|
||||
demands: dict[tuple, int] = {}
|
||||
for o in orders:
|
||||
if not o.drawing_data:
|
||||
continue
|
||||
from huamenchuang.core.drawing import parse_drawing
|
||||
from huamenchuang.services.quote_service import takeoff
|
||||
t = takeoff(parse_drawing(o.drawing_data))
|
||||
for b in t.bars:
|
||||
if b.kind == "glass":
|
||||
continue
|
||||
key = (b.length, b.note)
|
||||
demands[key] = demands.get(key, 0) + b.qty
|
||||
return demands
|
||||
|
||||
|
||||
async def _stock_bars(db, company_id: int) -> list:
|
||||
"""仓库型材库存 → StockBar 列表(按单根长度分组)。"""
|
||||
from huamenchuang.models.contract import WarehouseItem
|
||||
from huamenchuang.services.optimize_service import StockBar
|
||||
stmt = select(WarehouseItem).where(
|
||||
WarehouseItem.company_id == company_id,
|
||||
WarehouseItem.material == "profile",
|
||||
WarehouseItem.length > 0,
|
||||
WarehouseItem.count > 0)
|
||||
items = list((await db.execute(stmt)).scalars())
|
||||
return [StockBar(length=int(i.length), qty=int(float(i.count)), label=i.name or f"{int(i.length)}mm型材")
|
||||
for i in items]
|
||||
|
||||
|
||||
@router.post("/cut_solution")
|
||||
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
|
||||
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存(可选按库存)。"""
|
||||
contract = await db.get(Contract, body.contract_id)
|
||||
if contract is None or contract.company_id != user.company_id:
|
||||
return fail("合同不存在")
|
||||
stmt = select(Order).where(Order.contract_id == body.contract_id,
|
||||
Order.company_id == user.company_id)
|
||||
orders = list((await db.execute(stmt)).scalars())
|
||||
demands = _contract_demands(orders)
|
||||
if not demands:
|
||||
return fail("该合同下没有可算料的订单图纸")
|
||||
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
|
||||
dem = [BarDemand(length=k[0], qty=int(v), label=k[1]) for k, v in demands.items()]
|
||||
stocks = [StockBar(length=6000, qty=max(1, sum(d.qty for d in dem) // 3 + 1), label="6米型材")]
|
||||
if body.use_stock:
|
||||
stocks = await _stock_bars(db, user.company_id)
|
||||
if not stocks:
|
||||
return fail("仓库中没有可用的型材库存(需 material=profile 且 length>0)")
|
||||
else:
|
||||
total_pieces = sum(d.qty for d in dem)
|
||||
stocks = [StockBar(length=body.stock_length, qty=max(1, total_pieces // 3 + 1),
|
||||
label=f"{body.stock_length}mm型材")]
|
||||
result = optimize_1d(dem, stocks, body.kerf)
|
||||
# 按库存模式:缺料清单 = 未能排入的件(需求超出库存)
|
||||
shortage = []
|
||||
if body.use_stock and result["unplaced"]:
|
||||
agg: dict[tuple, int] = {}
|
||||
for u in result["unplaced"]:
|
||||
agg[(u["length"], u["label"])] = agg.get((u["length"], u["label"]), 0) + 1
|
||||
shortage = [{"length": k[0], "label": k[1], "qty": v} for k, v in
|
||||
sorted(agg.items(), key=lambda x: -x[0][0])]
|
||||
# 领料/切割准备清单:按原料长度聚合根数
|
||||
preparation = _preparation(result)
|
||||
payload = {"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
|
||||
"kerf": body.kerf, "use_stock": body.use_stock,
|
||||
"stock_length": body.stock_length, "result": result,
|
||||
"shortage": shortage, "preparation": preparation}
|
||||
await _save_param(db, user.company_id, f"cut_solution_{body.contract_id}",
|
||||
__import__("json").dumps({"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
|
||||
"kerf": body.kerf}, ensure_ascii=False))
|
||||
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders), **result},
|
||||
__import__("json").dumps(payload, ensure_ascii=False))
|
||||
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders),
|
||||
"use_stock": body.use_stock, **result,
|
||||
"shortage": shortage, "preparation": preparation},
|
||||
message="下料方案已生成")
|
||||
|
||||
|
||||
def _preparation(result: dict) -> list[dict]:
|
||||
"""切割准备清单:按原料(长度,标签)聚合根数 + 每根切割件数/余料。"""
|
||||
agg: dict[tuple, dict] = {}
|
||||
for plan in result["plans"]:
|
||||
key = (plan["stock_length"], plan["stock_label"])
|
||||
if key not in agg:
|
||||
agg[key] = {"stock_length": plan["stock_length"], "stock_label": plan["stock_label"],
|
||||
"bars": 0, "pieces": 0, "waste": 0}
|
||||
agg[key]["bars"] += 1
|
||||
agg[key]["pieces"] += len(plan["pieces"])
|
||||
agg[key]["waste"] += plan["waste"]
|
||||
return sorted(agg.values(), key=lambda x: -x["stock_length"])
|
||||
|
||||
|
||||
@router.get("/cut_solution/{contract_id}")
|
||||
async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
|
||||
"""读取已保存的下料方案参数。"""
|
||||
"""读取已保存的下料方案(含优化结果/缺口/领料清单)。"""
|
||||
p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
|
||||
if p is None:
|
||||
return fail("尚未生成下料方案")
|
||||
|
||||
Reference in New Issue
Block a user