diff --git a/CHANGELOG b/CHANGELOG index fe95ddf..5644261 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/doc/API.md b/doc/API.md index f85bae9..83d2195 100644 --- a/doc/API.md +++ b/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) diff --git a/frontend/src/views/CutSolutionView.vue b/frontend/src/views/CutSolutionView.vue index 16082ac..d17bfd8 100644 --- a/frontend/src/views/CutSolutionView.vue +++ b/frontend/src/views/CutSolutionView.vue @@ -1,18 +1,51 @@ @@ -40,19 +76,26 @@ const contracts = ref([]) const contractId = ref() const contractNo = ref('') const kerf = ref(5) +const useStock = ref(false) +const stockLength = ref(6000) const loading = ref(false) const result = ref(null) async function run() { loading.value = true try { - const d = await api('post', '/api/cut_solution', { contract_id: contractId.value, kerf: kerf.value }) + const d = await api('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('get', '/api/contracts?page=1&page_size=100') contracts.value = d.contracts?.data ?? [] @@ -60,5 +103,12 @@ onMounted(async () => { diff --git a/src/huamenchuang/api/platform.py b/src/huamenchuang/api/platform.py index 7a5bc68..4d91685 100644 --- a/src/huamenchuang/api/platform.py +++ b/src/huamenchuang/api/platform.py @@ -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("尚未生成下料方案")