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
+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("尚未生成下料方案")