aps-agent/server/aps_domain/mrp.py

161 lines
8.4 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 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)