aps-agent/server/aps_domain/mrp.py

210 lines
11 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]]:
"""确定分解目标:指定订单号或全部已批准订单(OR-03)。"""
from server.aps_domain.orders import SCHEDULABLE_STATUSES
orders = [so for so in world["salesOrders"] if so["status"] in SCHEDULABLE_STATUSES]
if order_no:
# 显式点名单号时放宽:允许未批准单做分解试算
orders = [so for so in world["salesOrders"]
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 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 _release_targets(world: World, order_no: str | None, kind: str) -> tuple[list[dict], list[dict]]:
"""筛选待下达的 DRAFT 建议单(kind ∈ all/purchase/outsource;可按订单号限定)。"""
_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]]:
"""生成 MRP 下达 P2 确认卡标题与影响摘要。"""
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]:
"""把 DRAFT 建议单确认下达为 RELEASED(P2 动作业务侧;调用方负责门禁/快照/审计/落盘)。"""
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 "") + "),来自外协工艺步骤")
else:
lines.append("· 委外建议:0 条(工艺路线无外协步骤)")
lines.append("建议单可在订单面板「分解」查看;说「追溯 SO…」或打开「追溯」页可看 BOM/库存/工单/负荷全链。")
return "\n".join(lines)