# ============================================================ # 订单分解 MRP 首切片(moduleId: domain-mrp, 可重生 ✅) # 对标西门子 Opcenter APS 的订单驱动分解:销售订单 → 按 BOM/工艺路线展开 → # 自制(进排产引擎)/ 采购建议(缺料 + 前置期倒排)/ 委外建议(外协工序)。 # 权力等级:order.decompose 为 P1——只产出 DRAFT 建议单(purchaseOrders / # outsourceOrders 两张建议表),不改主数据、不改销售订单; # 建议单的确认下达(转正式采购/委外)留后续切片(P2/P3)。 # 幂等:同一订单重复分解会替换其旧 DRAFT 建议,不会堆积重复行。 # ============================================================ from __future__ import annotations # 前向类型引用 import math # 需求量向上取整 from datetime import datetime # 时间戳 from typing import Any # 类型标注 from server.engines.queries import find_bom_items, find_routing_steps # BOM/工艺查询(P0) from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt # 日期工具 World = dict[str, Any] # 世界状态类型别名 # 采购建议的安全缓冲天数(需求日 = 交期 - 前置期 - 缓冲) _PURCHASE_BUFFER_DAYS = 1 def _now() -> str: """当前时间字符串。""" return fmt_dt(datetime.now()) def _ensure_tables(world: World) -> None: """兼容旧世界文件:确保建议表存在(P0 结构自愈,不改业务数据)。""" world.setdefault("purchaseOrders", []) world.setdefault("outsourceOrders", []) def list_mrp(world: World) -> dict[str, Any]: """MRP 建议单投影(P0 只读;订单分解视图数据源)。""" _ensure_tables(world) return { "purchaseOrders": list(world["purchaseOrders"]), "outsourceOrders": list(world["outsourceOrders"]), } def _target_orders(world: World, order_no: str | None) -> list[dict[str, Any]]: """确定分解目标:指定订单号或全部可排产订单(未取消/未完成)。""" orders = [so for so in world["salesOrders"] if so["status"] not in ("CANCELLED", "COMPLETED")] if order_no: orders = [so for so in orders if so["orderNo"].lower() == order_no.lower()] if not orders: raise ValueError(f"没找到可分解的订单:{order_no}") return orders def decompose_orders(world: World, next_id, order_no: str | None = None) -> dict[str, Any]: """按 BOM/工艺路线分解订单(P1:只写 DRAFT 建议表)。 Returns: {orders, make, purchase, outsource} 分解结果摘要(前端/对话共用)。 """ _ensure_tables(world) targets = _target_orders(world, order_no) target_ids = {so["id"] for so in targets} # 幂等:清掉目标订单的旧 DRAFT 建议(已确认的建议保留,后续切片处理) world["purchaseOrders"] = [p for p in world["purchaseOrders"] if not (p["salesOrderId"] in target_ids and p["status"] == "DRAFT")] world["outsourceOrders"] = [o for o in world["outsourceOrders"] if not (o["salesOrderId"] in target_ids and o["status"] == "DRAFT")] make_rows: list[dict[str, Any]] = [] # 自制部分(进排产) purchase_rows: list[dict[str, Any]] = [] # 采购建议 outsource_rows: list[dict[str, Any]] = [] # 委外建议 # 逐订单逐明细展开(净需求 = 毛需求 - 库存 - 在途;负数记 0) # 简化口径:本切片不做跨订单库存分配(同一物料多订单各自算净需求),后续切片引入分配 for so in targets: for item in so.get("items", []): if item["status"] in ("CANCELLED", "COMPLETED"): continue qty = item["quantity"] # ---- 自制:成品本体 → 由排产引擎生成生产订单/工单 ---- make_rows.append({ "salesOrderNo": so["orderNo"], "productId": item["productId"], "productName": item["productName"], "quantity": qty, "note": "由排产引擎生成生产订单与工单", }) # ---- 采购:BOM 展开净需求 ---- for bi in find_bom_items(world, item["productId"]): mat = next(m for m in world["materials"] if m["id"] == bi["materialId"]) gross = bi["quantity"] * qty # 毛需求 shortage = gross - mat.get("stock", 0) - mat.get("inTransit", 0) # 净缺口 if shortage <= 0: continue lead = mat.get("procurementLeadTime", 0) due = parse_dt(so["deliveryDate"] + " 08:00") suggest_date = fmt_date(add_minutes(due, -(lead + _PURCHASE_BUFFER_DAYS) * 24 * 60)) # 前置期倒排 po_id = next_id("purchaseOrder") row = { "id": po_id, "orderNo": f"PUR{po_id:05d}", "salesOrderId": so["id"], "salesOrderNo": so["orderNo"], "materialId": mat["id"], "materialCode": mat["code"], "materialName": mat["name"], "quantity": math.ceil(shortage), "unit": mat["unit"], "leadTimeDays": lead, "suggestedOrderDate": suggest_date, "requiredDate": so["deliveryDate"], "isKeyMaterial": bi.get("isKeyMaterial", False), "status": "DRAFT", # 建议草稿(下达转正式为后续切片) "createdAt": _now(), } world["purchaseOrders"].append(row) purchase_rows.append(row) # ---- 委外:外协工艺步骤 ---- for step in find_routing_steps(world, item["productId"]): if not step.get("isExternal", False): continue op = next((o for o in world["operations"] if o["id"] == step["operationId"]), {}) oo_id = next_id("outsourceOrder") row = { "id": oo_id, "orderNo": f"OUT{oo_id:05d}", "salesOrderId": so["id"], "salesOrderNo": so["orderNo"], "productId": item["productId"], "productName": item["productName"], "operationId": step["operationId"], "operationName": op.get("name", f"OP#{step['operationId']}"), "sequenceNo": step["sequenceNo"], "quantity": qty, "unit": item.get("unit", "件"), "requiredDate": so["deliveryDate"], "status": "DRAFT", "createdAt": _now(), } world["outsourceOrders"].append(row) outsource_rows.append(row) return { "orders": [so["orderNo"] for so in targets], "make": make_rows, "purchase": purchase_rows, "outsource": outsource_rows, } def summarize_decomposition(result: dict[str, Any]) -> str: """把分解结果压成对话回执文案(页面与对话共用口径)。""" lines = [f"已分解 {len(result['orders'])} 张订单({ '、'.join(result['orders'][:5]) }" + ("…" if len(result["orders"]) > 5 else "") + "):"] lines.append(f"· 自制:{len(result['make'])} 项成品 → 执行试排后生成生产订单与工单") if result["purchase"]: top = "、".join(f"{p['materialName']}×{p['quantity']}" for p in result["purchase"][:4]) lines.append(f"· 采购建议:{len(result['purchase'])} 条({top}" + ("…" if len(result["purchase"]) > 4 else "") + "),已按采购前置期倒排建议下单日") else: lines.append("· 采购建议:0 条(库存与在途可覆盖 BOM 需求)") if result["outsource"]: top = "、".join(f"{o['productName']}·{o['operationName']}" for o in result["outsource"][:3]) lines.append(f"· 委外建议:{len(result['outsource'])} 条({top}" + ("…" if len(result["outsource"]) > 3 else "") + "),来自外协工艺步骤") else: lines.append("· 委外建议:0 条(工艺路线无外协步骤)") lines.append("建议单为草稿,可在订单面板「分解」视图查看;确认下达为后续能力。") return "\n".join(lines)