feat: P0-4 按库存优化+切割准备清单(缺料告警)

This commit is contained in:
HHY
2026-09-25 03:16:09 -07:00
parent 87f9869829
commit cc5b3de6b2
4 changed files with 143 additions and 24 deletions
+2
View File
@@ -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)
+9
View File
@@ -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)
+57 -7
View File
@@ -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>
+75 -17
View File
@@ -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("尚未生成下料方案")