2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 订单分解 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]]:
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"""确定分解目标:指定订单号或全部已批准订单(OR-03)。"""
|
|
|
|
|
|
from server.aps_domain.orders import SCHEDULABLE_STATUSES
|
|
|
|
|
|
orders = [so for so in world["salesOrders"] if so["status"] in SCHEDULABLE_STATUSES]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if order_no:
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 显式点名单号时放宽:允许未批准单做分解试算
|
|
|
|
|
|
orders = [so for so in world["salesOrders"]
|
|
|
|
|
|
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 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
|
|
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'])} 项成品 → 执行试排后生成生产订单与工单")
|
|
|
|
|
|
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 条(工艺路线无外协步骤)")
|
2026-07-23 13:38:43 +08:00
|
|
|
|
lines.append("建议单可在订单面板「分解」查看;说「追溯 SO…」或打开「追溯」页可看 BOM/库存/工单/负荷全链。")
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return "\n".join(lines)
|