aps-agent/server/aps_domain/mrp.py

415 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 订单分解 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("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)
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)
return {
"purchaseOrders": purchases,
"outsourceOrders": outs,
"make": make,
"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]] = []
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
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 _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["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)
out_seen: set[tuple] = set()
purchase_agg: dict[tuple, dict] = {}
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):
continue
# 根成品:自制 + 委外工序
make_rows.append({
"salesOrderNo": so["orderNo"], "productId": pid,
"productName": pname, "quantity": qty,
"sourcingType": "MAKE",
"note": "成品自制 · 由排产引擎生成生产订单与工单",
})
_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:
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)
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)}
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 "") + "),来自 WZ*/外协工序")
else:
lines.append("· 委外建议:0 条(工艺无 WZ*/外协步骤)")
lines.append("可在订单面板查看生产/采购/委外;「追溯」可看 BOM/库存/工单全链。")
return "\n".join(lines)