aps-agent/server/aps_domain/mrp.py

415 lines
17 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 订单分解 MRP(moduleId: domain-mrp, 可重生 ✅)
# 对标西门子 Opcenter:销售订单 → 沿工艺路线 + BOM 多层展开 →
# 自制(MAKE,进排产)/ 采购(BUY,原料毛坯)/ 委外(OUTSOURCE,WZ*/外协工序)。
# 权力:order.decompose = P1,只写 DRAFT 建议表;下达另走 P2。
2026-07-21 11:05:57 +08:00
# ============================================================
from __future__ import annotations
2026-07-21 11:05:57 +08:00
import math
from datetime import datetime
from typing import Any
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
World = dict[str, Any]
2026-07-21 11:05:57 +08:00
_PURCHASE_BUFFER_DAYS = 1
_MAX_BOM_DEPTH = 10
2026-07-21 11:05:57 +08:00
def _now() -> str:
return fmt_dt(datetime.now())
def _ensure_tables(world: World) -> None:
world.setdefault("purchaseOrders", [])
world.setdefault("outsourceOrders", [])
def list_mrp(world: World) -> dict[str, Any]:
"""MRP 建议单投影(P0 只读)。"""
2026-07-21 11:05:57 +08:00
_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)
purchases = list(world["purchaseOrders"])
outs = list(world["outsourceOrders"])
make = []
if not purchases and not outs and (world.get("flexBom") or world.get("flexOrders")):
make, purchases, outs = _flex_gap_preview(world)
2026-07-21 11:05:57 +08:00
return {
"purchaseOrders": purchases,
"outsourceOrders": outs,
"make": make,
"productionOrders": _list_flex_make(world),
2026-07-21 11:05:57 +08:00
}
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]] = []
purchase_rows: list[dict[str, Any]] = []
outsource_rows: list[dict[str, Any]] = []
mats = {m.get("code"): m for m in (world.get("flexMaterials") or []) + (world.get("materials") or [])}
demand: dict[str, float] = {}
pcs_with_route = {str(r.get("productCode") or "") for r in (world.get("flexRoutings") or [])}
for fo in world.get("flexOrders") or []:
if (fo.get("status") or "") in ("DONE", "CANCELLED", "COMPLETED"):
continue
pc = fo.get("productCode")
qty = float(fo.get("quantity") or 0)
if not pc or qty <= 0:
continue
make_rows.append({
"salesOrderNo": fo.get("orderNo"), "productCode": pc,
"productName": fo.get("productName") or pc, "quantity": qty,
"note": "柔性/SQL 订单 · 自制进排产",
})
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": fo.get("orderNo"),
"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 = b.get("materialCode")
if not mc:
continue
child_need = float(b.get("quantity") or 0) * need
mat = mats.get(mc) or {}
has_rt = mc in pcs_with_route
if infer_material_sourcing(mat, has_routing=has_rt) == "MAKE" and has_rt:
stack.append((mc, child_need, depth + 1))
else:
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
2026-07-21 11:05:57 +08:00
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]
2026-07-21 11:05:57 +08:00
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")]
2026-07-21 11:05:57 +08:00
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)}
2026-07-21 11:05:57 +08:00
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 _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 建议)。"""
2026-07-21 11:05:57 +08:00
_ensure_tables(world)
annotate_world_sourcing(world)
2026-07-21 11:05:57 +08:00
targets = _target_orders(world, order_no)
target_ids = {so["id"] for so in targets}
world["purchaseOrders"] = [p for p in world["purchaseOrders"]
if not (p.get("salesOrderId") in target_ids and p.get("status") == "DRAFT")]
2026-07-21 11:05:57 +08:00
world["outsourceOrders"] = [o for o in world["outsourceOrders"]
if not (o.get("salesOrderId") in target_ids and o.get("status") == "DRAFT")]
2026-07-21 11:05:57 +08:00
make_rows: list[dict[str, Any]] = []
purchase_rows: list[dict[str, Any]] = []
outsource_rows: list[dict[str, Any]] = []
mats = _mat_by_id(world)
out_seen: set[tuple] = set()
purchase_agg: dict[tuple, dict] = {}
2026-07-21 11:05:57 +08:00
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 = item.get("productName") or ""
unit = item.get("unit") or "件"
if not isinstance(pid, int):
2026-07-21 11:05:57 +08:00
continue
# 根成品:自制 + 委外工序
2026-07-21 11:05:57 +08:00
make_rows.append({
"salesOrderNo": so["orderNo"], "productId": pid,
"productName": pname, "quantity": qty,
"sourcingType": "MAKE",
"note": "成品自制 · 由排产引擎生成生产订单与工单",
2026-07-21 11:05:57 +08:00
})
_append_outsource_for_product(
world, next_id, so, pid, pname, qty, unit, outsource_rows, out_seen,
)
# 多层 BOM:自制半成品继续下钻;采购件出采购建议
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:
2026-07-21 11:05:57 +08:00
continue
path.add(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
has_rt = _product_has_routing(world, mid)
sourcing = infer_material_sourcing(mat, has_routing=has_rt)
if sourcing == "MAKE" and has_rt:
make_rows.append({
"salesOrderNo": so["orderNo"], "productId": mid,
"productName": mat.get("name") or mat.get("code"),
"quantity": child_qty,
"sourcingType": "MAKE",
"note": f"半成品自制(来自 {pname or pid} BOM)",
})
_append_outsource_for_product(
world, next_id, so, mid,
mat.get("name") or mat.get("code") or "",
child_qty, mat.get("unit") or "件",
outsource_rows, out_seen,
)
stack.append((mid, child_qty, depth + 1))
continue
# 采购
shortage = child_qty - float(mat.get("stock") or 0) - float(mat.get("inTransit") or 0)
if shortage <= 0:
continue
_append_purchase(
world, next_id, so, mat, shortage,
bool(bi.get("isKeyMaterial")), purchase_rows, purchase_agg,
)
path.discard(cur_id)
2026-07-21 11:05:57 +08:00
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()
for row in pur + out:
row["status"] = "RELEASED"
row["releasedAt"] = released_at
return {"purchase": pur, "outsource": out,
"purchaseCount": len(pur), "outsourceCount": len(out)}
2026-07-21 11:05:57 +08:00
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'])} 项(成品/半成品)→ 排产生成生产订单")
2026-07-21 11:05:57 +08:00
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 "") + "),按前置期倒排")
2026-07-21 11:05:57 +08:00
else:
lines.append("· 采购建议:0 条(库存与在途可覆盖,或 BOM 未挂原料)")
2026-07-21 11:05:57 +08:00
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*/外协工序")
2026-07-21 11:05:57 +08:00
else:
lines.append("· 委外建议:0 条(工艺无 WZ*/外协步骤)")
lines.append("可在订单面板查看生产/采购/委外;「追溯」可看 BOM/库存/工单全链。")
2026-07-21 11:05:57 +08:00
return "\n".join(lines)