# ============================================================ # 订单分解 MRP(moduleId: domain-mrp, 可重生 ✅) # 对标西门子 Opcenter:销售订单 → 沿工艺路线 + BOM 多层展开 → # 自制(MAKE,进排产)/ 采购(BUY,原料毛坯)/ 委外(OUTSOURCE,WZ*/外协工序)。 # 权力:order.decompose = P1,只写 DRAFT 建议表;下达另走 P2。 # ============================================================ from __future__ import annotations import math from datetime import datetime from typing import Any from server.aps_domain.sourcing import ( annotate_world_sourcing, infer_material_sourcing, is_outsource_step, ) from server.engines.queries import find_bom_items, find_routing_steps from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt World = dict[str, Any] _PURCHASE_BUFFER_DAYS = 1 _MAX_BOM_DEPTH = 10 def _now() -> str: return fmt_dt(datetime.now()) def _ensure_tables(world: World) -> None: world.setdefault("makeSuggestions", []) world.setdefault("purchaseOrders", []) world.setdefault("outsourceOrders", []) def list_mrp(world: World) -> dict[str, Any]: """MRP 自制/采购/委外建议与已排生产订单投影(P0 只读)。""" _ensure_tables(world) try: from server.aps_domain.orders import sync_flex_orders_to_sales if world.get("flexOrders"): sync_flex_orders_to_sales(world) except Exception: pass annotate_world_sourcing(world) make = list(world["makeSuggestions"]) purchases = list(world["purchaseOrders"]) outs = list(world["outsourceOrders"]) if not make and (world.get("flexBom") or world.get("flexOrders")): preview_make, preview_purchases, preview_outs = _flex_gap_preview(world) make = preview_make if not purchases and not outs: purchases, outs = preview_purchases, preview_outs return { "make": make, "purchaseOrders": purchases, "outsourceOrders": outs, "productionOrders": _list_flex_make(world), } def _list_flex_make(world: World) -> list[dict[str, Any]]: try: from server.aps_domain.orders import list_production_orders return list_production_orders(world) except Exception: return [] def _flex_gap_preview(world: World) -> tuple[list, list, list]: """柔性 BOM/工艺只读预览(含多层自制、路线质量与委外推断)。""" _ensure_tables(world) annotate_world_sourcing(world) make_rows: list[dict[str, Any]] = [] make_agg: dict[tuple[str, str], dict[str, Any]] = {} purchase_rows: list[dict[str, Any]] = [] outsource_rows: list[dict[str, Any]] = [] all_materials = (world.get("flexMaterials") or []) + (world.get("materials") or []) mats = {str(m.get("code") or ""): m for m in all_materials if m.get("code")} demand: dict[str, float] = {} classic_by_id = { m.get("id"): str(m.get("code") or "") for m in (world.get("materials") or []) if isinstance(m.get("id"), int) and m.get("code") } flex_routes_by_code: dict[str, list[dict[str, Any]]] = {} for row in world.get("flexRoutings") or []: code = str(row.get("productCode") or "") if code: flex_routes_by_code.setdefault(code, []).append(row) pcs_with_route = set(flex_routes_by_code) pcs_with_route.update( classic_by_id.get(r.get("productId"), "") for r in (world.get("routings") or []) if r.get("isDefault") ) pcs_with_route.discard("") template_route_codes = { code for code, rows in flex_routes_by_code.items() if rows and all( str(row.get("stdTimeSource") or "").strip().upper() in ("模板", "TEMPLATE", "DEFAULT") for row in rows ) } bom_parent_codes = { str(b.get("productCode") or "") for b in (world.get("flexBom") or []) if b.get("productCode") } def _routing_quality(product_code: str) -> str: if product_code not in pcs_with_route: return "MISSING" if product_code in template_route_codes: return "TEMPLATE" return "READY" def _record_make( *, order_no: str, product_code: str, product_name: str, quantity: float, unit: str, depth: int, routing_quality: str, ) -> None: key = (order_no, product_code) existing = make_agg.get(key) if existing: existing["quantity"] += quantity existing["bomDepth"] = min(int(existing.get("bomDepth") or depth), depth) return has_routing = routing_quality != "MISSING" missing_routing = routing_quality == "MISSING" template_routing = routing_quality == "TEMPLATE" row = { "id": f"MAKE-PREV:{order_no}:{product_code}", "salesOrderNo": order_no, "productCode": product_code, "productName": product_name or product_code, "quantity": quantity, "unit": unit or "件", "status": "DRAFT", "sourcingType": "MAKE", "hasRouting": has_routing, "missingRouting": missing_routing, "templateRouting": template_routing, "routingStatus": routing_quality, "bomDepth": depth, "note": ( "半成品有 BOM,按自制继续分解;缺工艺路线,补齐后才能排产" if missing_routing else ( "当前仅有模板工艺,允许试排但节拍/工序必须确认" if template_routing else "自制项 · 由排产引擎生成生产订单与工单" ) ), } make_rows.append(row) make_agg[key] = row for fo in world.get("flexOrders") or []: if (fo.get("status") or "") in ("DONE", "CANCELLED", "COMPLETED"): continue pc = str(fo.get("productCode") or "") qty = float(fo.get("quantity") or 0) if not pc or qty <= 0: continue order_no = str(fo.get("orderNo") or "") _record_make( order_no=order_no, product_code=pc, product_name=str(fo.get("productName") or pc), quantity=qty, unit=str(fo.get("unit") or "件"), depth=0, routing_quality=_routing_quality(pc), ) for r in world.get("flexRoutings") or []: if r.get("productCode") != pc or not r.get("isExternal"): continue outsource_rows.append({ "id": -(len(outsource_rows) + 1), "orderNo": f"OUT-PREV-{len(outsource_rows)+1:04d}", "status": "DRAFT", "salesOrderNo": order_no, "productName": fo.get("productName") or pc, "operationName": r.get("operationName") or r.get("operationCode"), "sequenceNo": r.get("seq"), "quantity": qty, "unit": "件", "requiredDate": fo.get("dueDate") or "", "note": "WZ*/外协工序预览,点「重新分解」落正式建议", }) stack = [(pc, qty, 0)] seen_path: set[str] = set() while stack: cur, need, depth = stack.pop() if depth >= _MAX_BOM_DEPTH or cur in seen_path: continue seen_path.add(cur) for b in world.get("flexBom") or []: if b.get("productCode") != cur: continue mc = str(b.get("materialCode") or "") if not mc: continue child_need = float(b.get("quantity") or 0) * need if child_need <= 0: continue mat = mats.get(mc) or {} routing_quality = _routing_quality(mc) has_rt = routing_quality != "MISSING" has_bom = mc in bom_parent_codes sourcing = infer_material_sourcing( mat, has_routing=has_rt, has_bom=has_bom, ) if sourcing == "MAKE": _record_make( order_no=order_no, product_code=mc, product_name=str(mat.get("name") or mc), quantity=child_need, unit=str(mat.get("unit") or "件"), depth=depth + 1, routing_quality=routing_quality, ) if has_bom: stack.append((mc, child_need, depth + 1)) continue demand[mc] = demand.get(mc, 0) + child_need seen_path.discard(cur) nid = -1 for mc, need in sorted(demand.items()): mat = mats.get(mc) or {} have = float(mat.get("stock") or 0) + float(mat.get("inTransit") or 0) gap = need - have if gap <= 0: continue lead = float(mat.get("procurementLeadTime") or 0) purchase_rows.append({ "id": nid, "orderNo": f"PR-PREV-{abs(nid):04d}", "status": "DRAFT", "materialCode": mc, "materialName": mat.get("name") or mc, "quantity": math.ceil(gap), "unit": mat.get("unit") or "件", "salesOrderNo": "(多单汇总)", "suggestedOrderDate": "", "requiredDate": "", "leadTimeDays": lead, "note": "多层 BOM 原材料缺口预览,点「重新分解」可落正式建议", "sourcingType": "BUY", }) nid -= 1 return make_rows, purchase_rows, outsource_rows def _target_orders(world: World, order_no: str | None) -> list[dict[str, Any]]: from server.aps_domain.orders import SCHEDULABLE_STATUSES, sync_flex_orders_to_sales if world.get("flexOrders"): sync_flex_orders_to_sales(world) orders = [so for so in world.get("salesOrders") or [] if so["status"] in SCHEDULABLE_STATUSES] if order_no: orders = [so for so in world.get("salesOrders") or [] if so["orderNo"].lower() == order_no.lower() and so["status"] not in ("CANCELLED", "COMPLETED")] if not orders: raise ValueError(f"没找到可分解的订单:{order_no}") return orders def _mat_by_id(world: World) -> dict[int, dict]: return {m["id"]: m for m in world.get("materials") or [] if isinstance(m.get("id"), int)} def _ops_by_id(world: World) -> dict[int, dict]: return {o["id"]: o for o in world.get("operations") or [] if isinstance(o.get("id"), int)} def _product_has_routing(world: World, product_id: int) -> bool: return any(r.get("productId") == product_id and r.get("isDefault") for r in world.get("routings") or []) def _product_routing_quality(world: World, product_id: int, product_code: str = "") -> str: """返回 READY | TEMPLATE | MISSING;模板路线可试排但必须向用户标明待确认。""" has_routing = _product_has_routing(world, product_id) code = str(product_code or "") flex_rows = [ row for row in (world.get("flexRoutings") or []) if code and str(row.get("productCode") or "") == code ] if not has_routing and not flex_rows: return "MISSING" if flex_rows and all( str(row.get("stdTimeSource") or "").strip().upper() in ("模板", "TEMPLATE", "DEFAULT") for row in flex_rows ): return "TEMPLATE" return "READY" def _append_outsource_for_product( world: World, next_id, so: dict, product_id: int, product_name: str, qty: float, unit: str, outsource_rows: list, seen_keys: set[tuple], ) -> None: ops = _ops_by_id(world) for step in find_routing_steps(world, product_id): op = ops.get(step.get("operationId")) or {} if not is_outsource_step(step, op): continue key = (so["id"], product_id, step.get("operationId"), step.get("sequenceNo")) if key in seen_keys: continue seen_keys.add(key) oo_id = next_id("outsourceOrder") row = { "id": oo_id, "orderNo": f"OUT{oo_id:05d}", "salesOrderId": so["id"], "salesOrderNo": so["orderNo"], "productId": product_id, "productName": product_name, "operationId": step.get("operationId"), "operationName": op.get("name") or f"OP#{step.get('operationId')}", "operationCode": op.get("code") or "", "sequenceNo": step.get("sequenceNo"), "quantity": qty, "unit": unit, "requiredDate": so.get("deliveryDate"), "status": "DRAFT", "sourcingType": "OUTSOURCE", "createdAt": _now(), } world["outsourceOrders"].append(row) outsource_rows.append(row) def _append_purchase( world: World, next_id, so: dict, mat: dict, shortage: float, is_key: bool, purchase_rows: list, purchase_agg: dict[tuple, dict], ) -> None: key = (so["id"], mat["id"]) if key in purchase_agg: purchase_agg[key]["quantity"] = math.ceil( purchase_agg[key]["quantity"] + shortage ) return lead = mat.get("procurementLeadTime", 0) or 0 due = parse_dt(str(so.get("deliveryDate") or today_fallback()) + " 08:00") suggest_date = fmt_date(add_minutes(due, -(float(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.get("code"), "materialName": mat.get("name"), "quantity": math.ceil(shortage), "unit": mat.get("unit") or "件", "leadTimeDays": lead, "suggestedOrderDate": suggest_date, "requiredDate": so.get("deliveryDate"), "isKeyMaterial": is_key, "status": "DRAFT", "sourcingType": "BUY", "createdAt": _now(), } world["purchaseOrders"].append(row) purchase_rows.append(row) purchase_agg[key] = row def today_fallback() -> str: return fmt_date(datetime.now()) def decompose_orders(world: World, next_id, order_no: str | None = None) -> dict[str, Any]: """按供应类型 + 多层 BOM 分解订单(P1:DRAFT 自制/采购/委外建议)。""" _ensure_tables(world) annotate_world_sourcing(world) targets = _target_orders(world, order_no) target_ids = {so["id"] for so in targets} world["makeSuggestions"] = [m for m in world["makeSuggestions"] if not (m.get("salesOrderId") in target_ids and m.get("status") == "DRAFT")] world["purchaseOrders"] = [p for p in world["purchaseOrders"] if not (p.get("salesOrderId") in target_ids and p.get("status") == "DRAFT")] world["outsourceOrders"] = [o for o in world["outsourceOrders"] if not (o.get("salesOrderId") in target_ids and o.get("status") == "DRAFT")] make_rows: list[dict[str, Any]] = [] purchase_rows: list[dict[str, Any]] = [] outsource_rows: list[dict[str, Any]] = [] mats = _mat_by_id(world) bom_parent_ids = { b.get("productId") for b in (world.get("boms") or []) if b.get("isDefault", True) } out_seen: set[tuple] = set() make_agg: dict[tuple[int, int], dict[str, Any]] = {} purchase_agg: dict[tuple, dict] = {} purchase_demand: dict[tuple[int, int], dict[str, Any]] = {} def _record_make( so: dict[str, Any], *, product_id: int, product_code: str, product_name: str, quantity: float, unit: str, depth: int, routing_quality: str, note: str, ) -> None: key = (so["id"], product_id) existing = make_agg.get(key) if existing: existing["quantity"] += quantity existing["bomDepth"] = min(int(existing.get("bomDepth") or depth), depth) return has_routing = routing_quality != "MISSING" missing_routing = routing_quality == "MISSING" template_routing = routing_quality == "TEMPLATE" row = { "id": f"MAKE:{so['id']}:{product_id}", "salesOrderId": so["id"], "salesOrderNo": so["orderNo"], "productId": product_id, "productCode": product_code, "productName": product_name or product_code or f"#{product_id}", "quantity": quantity, "unit": unit or "件", "status": "DRAFT", "sourcingType": "MAKE", "hasRouting": has_routing, "missingRouting": missing_routing, "templateRouting": template_routing, "routingStatus": routing_quality, "bomDepth": depth, "note": ( f"{note};缺工艺路线,补齐后才能排产" if missing_routing else note ), "createdAt": _now(), } make_rows.append(row) make_agg[key] = row for so in targets: for item in so.get("items", []): if item.get("status") in ("CANCELLED", "COMPLETED"): continue qty = float(item.get("quantity") or 0) if qty <= 0: continue pid = item.get("productId") pname = str(item.get("productName") or "") unit = str(item.get("unit") or "件") if not isinstance(pid, int): continue product = mats.get(pid) or {} product_code = str(item.get("productCode") or product.get("code") or "") root_routing_quality = _product_routing_quality(world, pid, product_code) has_root_routing = root_routing_quality != "MISSING" # 根成品:自制建议;实际生产订单仍由排产引擎生成。 _record_make( so, product_id=pid, product_code=product_code, product_name=pname or str(product.get("name") or product_code), quantity=qty, unit=unit, depth=0, routing_quality=root_routing_quality, note="成品自制 · 排产后生成生产订单与工单", ) if has_root_routing: _append_outsource_for_product( world, next_id, so, pid, pname, qty, unit, outsource_rows, out_seen, ) # 多层 BOM:半成品即使暂缺工艺也继续下钻;只有 BUY 叶子生成采购建议。 stack: list[tuple[int, float, int]] = [(pid, qty, 0)] path: set[int] = set() while stack: cur_id, cur_qty, depth = stack.pop() if depth >= _MAX_BOM_DEPTH or cur_id in path: continue path.add(cur_id) parent = mats.get(cur_id) or {} parent_name = str(parent.get("name") or parent.get("code") or cur_id) for bi in find_bom_items(world, cur_id): mid = bi.get("materialId") mat = mats.get(mid) if isinstance(mid, int) else None if not mat: continue child_qty = float(bi.get("quantity") or 0) * cur_qty if child_qty <= 0: continue product_code = str(mat.get("code") or "") routing_quality = _product_routing_quality(world, mid, product_code) has_rt = routing_quality != "MISSING" has_bom = mid in bom_parent_ids sourcing = infer_material_sourcing( mat, has_routing=has_rt, has_bom=has_bom, ) if sourcing == "MAKE": _record_make( so, product_id=mid, product_code=product_code, product_name=str(mat.get("name") or product_code), quantity=child_qty, unit=str(mat.get("unit") or "件"), depth=depth + 1, routing_quality=routing_quality, note=f"半成品自制(来自 {parent_name} BOM)", ) if has_rt: _append_outsource_for_product( world, next_id, so, mid, str(mat.get("name") or product_code), child_qty, str(mat.get("unit") or "件"), outsource_rows, out_seen, ) if has_bom: stack.append((mid, child_qty, depth + 1)) continue key = (so["id"], mid) demand = purchase_demand.get(key) if demand: demand["quantity"] += child_qty demand["isKeyMaterial"] = bool(demand["isKeyMaterial"] or bi.get("isKeyMaterial")) else: purchase_demand[key] = { "so": so, "mat": mat, "quantity": child_qty, "isKeyMaterial": bool(bi.get("isKeyMaterial")), } path.discard(cur_id) # 同一销售单/物料先汇总毛需求,再只扣一次库存与在途。 # 已下达(非草稿)的采购单视作已覆盖供应:重跑分解/排产前置净算时 # 不再为同一需求重复生成 DRAFT 建议,避免闭环把重复草稿判为 # UNTRUSTED_DRAFT_SUPPLY 硬阻断。 covered_supply: dict[tuple, float] = {} for row in world["purchaseOrders"]: if not isinstance(row, dict) or row.get("status") == "DRAFT": continue ckey = (row.get("salesOrderId"), row.get("materialId")) covered_supply[ckey] = covered_supply.get(ckey, 0.0) + float(row.get("quantity") or 0) for demand in purchase_demand.values(): mat = demand["mat"] ckey = (demand["so"]["id"], mat.get("id")) shortage = ( float(demand["quantity"]) - float(mat.get("stock") or 0) - float(mat.get("inTransit") or 0) - covered_supply.get(ckey, 0.0) ) if shortage <= 0: continue _append_purchase( world, next_id, demand["so"], mat, shortage, bool(demand["isKeyMaterial"]), purchase_rows, purchase_agg, ) world["makeSuggestions"].extend(make_rows) return { "orders": [so["orderNo"] for so in targets], "make": make_rows, "purchase": purchase_rows, "outsource": outsource_rows, } def _release_targets(world: World, order_no: str | None, kind: str) -> tuple[list[dict], list[dict]]: _ensure_tables(world) so_ids = None if order_no: so = next((s for s in world["salesOrders"] if s["orderNo"].lower() == order_no.lower()), None) if so is None: raise ValueError(f"没找到订单:{order_no}") so_ids = {so["id"]} def _match(row: dict) -> bool: return row["status"] == "DRAFT" and (so_ids is None or row["salesOrderId"] in so_ids) pur = [p for p in world["purchaseOrders"] if _match(p)] if kind in ("all", "purchase") else [] out = [o for o in world["outsourceOrders"] if _match(o)] if kind in ("all", "outsource") else [] return pur, out def confirmation_for_mrp_release(world: World, order_no: str | None, kind: str = "all") -> tuple[str, list[str]]: pur, out = _release_targets(world, order_no, kind) if not pur and not out: raise ValueError("没有可下达的草稿建议单(请先执行订单分解)") scope = f"订单 {order_no}" if order_no else "全部订单" title = f"下达 MRP 建议单({scope})" lines = [] if pur: lines.append(f"采购建议 {len(pur)} 条 → 转为正式采购单(RELEASED)") if out: lines.append(f"委外建议 {len(out)} 条 → 转为正式委外单(RELEASED)") lines.append("下达后建议单状态置为 RELEASED,进入采购/委外执行(P2 写主干,执行前自动建档)") return title, lines def apply_mrp_release(world: World, order_no: str | None = None, kind: str = "all") -> dict[str, Any]: pur, out = _release_targets(world, order_no, kind) released_at = _now() release_day = released_at[:10] for row in pur + out: row["status"] = "RELEASED" row["releasedAt"] = released_at # 闭环净算按预计到料日判断可信供应;草稿只有 requiredDate(=订单交期), # 不补到料日会被判为 MATERIAL_NOT_READY 硬违规。到料 = 下达日 + 采购前置期。 if not row.get("expectedArrivalDate"): lead = float(row.get("leadTimeDays") or 0) row["expectedArrivalDate"] = fmt_date( add_minutes(parse_dt(release_day + " 08:00"), lead * 24 * 60) ) return {"purchase": pur, "outsource": out, "purchaseCount": len(pur), "outsourceCount": len(out)} def summarize_decomposition(result: dict[str, Any]) -> str: lines = [f"已分解 {len(result['orders'])} 张订单({ '、'.join(result['orders'][:5]) }" + ("…" if len(result["orders"]) > 5 else "") + "):"] ready_make = sum(1 for row in result["make"] if row.get("routingStatus") == "READY") template_routing = sum(1 for row in result["make"] if row.get("templateRouting")) missing_routing = sum(1 for row in result["make"] if row.get("missingRouting")) details: list[str] = [] if ready_make: details.append(f"{ready_make} 项正式工艺") if template_routing: details.append(f"{template_routing} 项模板工艺待确认") if missing_routing: details.append(f"{missing_routing} 项缺工艺路线但仍按半成品自制") lines.append( f"· 自制建议:{len(result['make'])} 项({';'.join(details) or '待补工艺'})→ 排产后生成真实生产订单" ) 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 "") + "),来自 WZ*/外协工序") else: lines.append("· 委外建议:0 条(工艺无 WZ*/外协步骤)") lines.append("订单面板分别展示自制建议、采购建议、委外建议;完成排产后再查看已排生产订单。") return "\n".join(lines)