458 lines
22 KiB
Python
458 lines
22 KiB
Python
# ============================================================
|
||
# 计划追溯(moduleId: domain-trace, 可重生 ✅)
|
||
# 对齐聚制云 §4.9:销售订单 → 主数据依据(BOM/路线/产线绑定) → 分解(采购/委外)
|
||
# → 排产产物(PO/WO) → 负荷占用 → 库存齐套;缺项显式列出。
|
||
# 见 docs/product/master-data-and-plan-chain.md
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from server.engines.queries import find_bom_items, find_product_lines, find_routing_steps
|
||
from server.timeutil import parse_dt
|
||
|
||
World = dict[str, Any]
|
||
|
||
|
||
def _latest_version(world: World) -> dict | None:
|
||
versions = world.get("scheduleVersions") or []
|
||
return versions[-1] if versions else None
|
||
|
||
|
||
def _product(world: World, product_id: int) -> dict | None:
|
||
return next((m for m in world.get("materials", []) if m["id"] == product_id), None)
|
||
|
||
|
||
def plan_trace(world: World, order_no: str | None = None, track: str | None = None) -> dict[str, Any]:
|
||
"""计划追溯入口:按 track 或订单号前缀路由固定/柔性链(OR-06)。"""
|
||
tr = (track or "").lower().strip()
|
||
if not tr or tr == "auto":
|
||
if order_no and re_flex_order(order_no):
|
||
tr = "flex"
|
||
else:
|
||
tr = "fixed"
|
||
if tr == "flex":
|
||
return flex_plan_trace(world, order_no)
|
||
if tr == "both":
|
||
fixed = plan_trace_fixed(world, None if (order_no and re_flex_order(order_no)) else order_no)
|
||
flex = flex_plan_trace(world, order_no if (not order_no or re_flex_order(order_no)) else None)
|
||
return {
|
||
"track": "both",
|
||
"fixed": fixed,
|
||
"flex": flex,
|
||
"count": fixed.get("count", 0) + flex.get("count", 0),
|
||
}
|
||
return plan_trace_fixed(world, order_no)
|
||
|
||
|
||
def re_flex_order(order_no: str) -> bool:
|
||
no = (order_no or "").upper()
|
||
return no.startswith("FO-") or no.startswith("RUSH-") or "SAP" in no
|
||
|
||
|
||
def plan_trace_fixed(world: World, order_no: str | None = None) -> dict[str, Any]:
|
||
"""对一张(或全部活跃)销售订单给出完整计划追溯投影(P0 只读)。"""
|
||
orders = [so for so in world.get("salesOrders", []) if so.get("status") not in ("CANCELLED",)]
|
||
if order_no:
|
||
orders = [so for so in orders if so["orderNo"].lower() == order_no.lower()]
|
||
if not orders:
|
||
raise ValueError(f"没找到订单:{order_no}")
|
||
|
||
latest = _latest_version(world)
|
||
latest_id = latest["id"] if latest else None
|
||
op_name = {o["id"]: o["name"] for o in world.get("operations", [])}
|
||
line_name = {ln["id"]: ln["name"] for ln in world.get("lines", [])}
|
||
mat_by_id = {m["id"]: m for m in world.get("materials", [])}
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
for so in orders:
|
||
items = [it for it in so.get("items", []) if it.get("status") not in ("CANCELLED", "COMPLETED")]
|
||
if not items:
|
||
items = so.get("items", [])[:1]
|
||
item = items[0] if items else {}
|
||
pid = item.get("productId")
|
||
product = _product(world, pid) if pid else None
|
||
qty = item.get("quantity") or 0
|
||
|
||
# ---- 主数据依据 ----
|
||
missing: list[str] = []
|
||
bom = next((b for b in world.get("boms", []) if b.get("productId") == pid and b.get("isDefault")), None)
|
||
routing = next((r for r in world.get("routings", []) if r.get("productId") == pid and r.get("isDefault")), None)
|
||
if not product:
|
||
missing.append("成品物料不存在")
|
||
if not bom:
|
||
missing.append("无默认 BOM")
|
||
if not routing:
|
||
missing.append("无默认工艺路线")
|
||
line_bindings = find_product_lines(world, pid) if pid else []
|
||
if pid and not line_bindings:
|
||
missing.append("未做产线-产品绑定(排产前必需)")
|
||
|
||
bom_lines = []
|
||
for bi in (find_bom_items(world, pid) if pid else []):
|
||
mat = mat_by_id.get(bi["materialId"], {})
|
||
gross = float(bi.get("quantity", 0)) * qty
|
||
stock = float(mat.get("stock", 0))
|
||
transit = float(mat.get("inTransit", 0))
|
||
shortage = max(0.0, gross - stock - transit)
|
||
bom_lines.append({
|
||
"materialId": bi["materialId"],
|
||
"materialCode": mat.get("code", ""),
|
||
"materialName": mat.get("name", f"#{bi['materialId']}"),
|
||
"unitQty": bi.get("quantity"),
|
||
"grossQty": round(gross, 3),
|
||
"stock": stock,
|
||
"inTransit": transit,
|
||
"shortage": round(shortage, 3),
|
||
"isKeyMaterial": bi.get("isKeyMaterial", False),
|
||
"procurementLeadTime": mat.get("procurementLeadTime", 0),
|
||
})
|
||
if any(b["shortage"] > 0 for b in bom_lines):
|
||
missing.append("库存不足以覆盖 BOM 毛需求(见物料缺口)")
|
||
|
||
route_steps = []
|
||
for s in (find_routing_steps(world, pid) if pid else []):
|
||
route_steps.append({
|
||
"sequenceNo": s["sequenceNo"],
|
||
"operationName": op_name.get(s["operationId"], f"OP#{s['operationId']}"),
|
||
"setupTime": s.get("setupTime"),
|
||
"runTimePerUnit": s.get("runTimePerUnit"),
|
||
"isExternal": s.get("isExternal", False),
|
||
})
|
||
|
||
# ---- 分解(采购/委外) ----
|
||
purchases = [p for p in world.get("purchaseOrders", []) if p.get("salesOrderId") == so["id"]]
|
||
outsources = [o for o in world.get("outsourceOrders", []) if o.get("salesOrderId") == so["id"]]
|
||
|
||
# ---- 排产产物 ----
|
||
pos = []
|
||
wos = []
|
||
load_by_line: dict[str, float] = {}
|
||
conflicts = []
|
||
if latest_id:
|
||
pos = [p for p in world.get("productionOrders", [])
|
||
if p.get("salesOrderId") == so["id"] and p.get("schedulingVersionId") == latest_id]
|
||
po_ids = {p["id"] for p in pos}
|
||
for wo in world.get("workOrders", []):
|
||
if wo.get("productionOrderId") not in po_ids:
|
||
continue
|
||
wos.append({
|
||
"id": wo["id"], "orderNo": wo.get("orderNo"),
|
||
"operationName": wo.get("operationName"),
|
||
"lineId": wo.get("lineId"), "lineName": line_name.get(wo.get("lineId"), ""),
|
||
"workstationName": wo.get("workstationName"),
|
||
"start": wo.get("plannedStartTime"), "end": wo.get("plannedEndTime"),
|
||
"kitStatus": wo.get("kitStatus"),
|
||
"conflictCount": wo.get("conflictCount", 0),
|
||
})
|
||
try:
|
||
mins = (parse_dt(wo["plannedEndTime"]) - parse_dt(wo["plannedStartTime"])).total_seconds() / 60
|
||
except Exception:
|
||
mins = 0
|
||
ln = line_name.get(wo.get("lineId"), f"#{wo.get('lineId')}")
|
||
load_by_line[ln] = load_by_line.get(ln, 0.0) + mins
|
||
conflicts = [c for c in world.get("conflicts", [])
|
||
if c.get("versionId") == latest_id and (
|
||
c.get("orderNo") == so["orderNo"]
|
||
or c.get("salesOrderId") == so["id"]
|
||
or any(str(c.get("resourceName", "")).startswith(so["orderNo"]) for _ in [0])
|
||
)]
|
||
# 冲突若未挂订单号,用 PO 关联兜底
|
||
if not conflicts and pos:
|
||
po_nos = {p.get("orderNo") for p in pos}
|
||
conflicts = [c for c in world.get("conflicts", [])
|
||
if c.get("versionId") == latest_id and (
|
||
c.get("orderNo") in po_nos
|
||
or c.get("productionOrderId") in po_ids
|
||
)]
|
||
|
||
if not latest_id:
|
||
missing.append("尚无排产版本(请先试排)")
|
||
elif not pos:
|
||
missing.append("最新版本未包含本订单的生产订单")
|
||
|
||
rows.append({
|
||
"order": {
|
||
"id": so["id"], "orderNo": so["orderNo"],
|
||
"customerName": so.get("customerName"), "customerLevel": so.get("customerLevel"),
|
||
"deliveryDate": so.get("deliveryDate"), "priority": so.get("priority"),
|
||
"status": so.get("status"), "isRush": so.get("isRush", False),
|
||
"productId": pid, "productCode": item.get("productCode") or (product or {}).get("code"),
|
||
"productName": item.get("productName") or (product or {}).get("name"),
|
||
"quantity": qty, "unit": item.get("unit") or (product or {}).get("unit", "件"),
|
||
},
|
||
"master": {
|
||
"bomVersion": bom.get("version") if bom else None,
|
||
"routingVersion": routing.get("version") if routing else None,
|
||
"lineBindings": [{"lineId": lp["lineId"], "lineName": line_name.get(lp["lineId"], ""),
|
||
"priority": lp.get("priority")} for lp in line_bindings],
|
||
"bom": bom_lines,
|
||
"routing": route_steps,
|
||
},
|
||
"decompose": {
|
||
"purchase": [{
|
||
"orderNo": p.get("orderNo"), "materialName": p.get("materialName"),
|
||
"quantity": p.get("quantity"), "unit": p.get("unit"),
|
||
"suggestedOrderDate": p.get("suggestedOrderDate"),
|
||
"requiredDate": p.get("requiredDate"), "status": p.get("status"),
|
||
"isKeyMaterial": p.get("isKeyMaterial", False),
|
||
} for p in purchases],
|
||
"outsource": [{
|
||
"orderNo": o.get("orderNo"), "operationName": o.get("operationName"),
|
||
"quantity": o.get("quantity"), "requiredDate": o.get("requiredDate"),
|
||
"status": o.get("status"),
|
||
} for o in outsources],
|
||
},
|
||
"schedule": {
|
||
"versionNo": latest.get("versionNo") if latest else None,
|
||
"versionStatus": latest.get("status") if latest else None,
|
||
"productionOrders": [{
|
||
"id": p["id"], "orderNo": p.get("orderNo"), "status": p.get("status"),
|
||
"plannedStartDate": p.get("plannedStartDate"), "plannedEndDate": p.get("plannedEndDate"),
|
||
"lineId": p.get("lineId"), "lineName": line_name.get(p.get("lineId"), ""),
|
||
"conflictCount": p.get("conflictCount", 0),
|
||
} for p in pos],
|
||
"workOrders": wos,
|
||
"loadMinutesByLine": {k: round(v, 1) for k, v in load_by_line.items()},
|
||
"conflicts": [{
|
||
"type": c.get("conflictType"), "severity": c.get("severity"),
|
||
"description": c.get("description"), "resourceName": c.get("resourceName"),
|
||
} for c in conflicts],
|
||
},
|
||
"missing": missing,
|
||
"readyToSchedule": len([m for m in missing if m not in (
|
||
"尚无排产版本(请先试排)",
|
||
"最新版本未包含本订单的生产订单",
|
||
"库存不足以覆盖 BOM 毛需求(见物料缺口)",
|
||
)]) == 0,
|
||
})
|
||
|
||
return {
|
||
"track": "fixed",
|
||
"latestVersion": {
|
||
"versionNo": latest.get("versionNo"), "status": latest.get("status"),
|
||
"woCount": latest.get("woCount"), "poCount": latest.get("poCount"),
|
||
"conflictCount": latest.get("conflictCount"),
|
||
} if latest else None,
|
||
"orders": rows,
|
||
"count": len(rows),
|
||
}
|
||
|
||
|
||
def flex_plan_trace(world: World, order_no: str | None = None) -> dict[str, Any]:
|
||
"""柔性轨钉扎:FO → flexBom/flexRouting → 虚拟产线 → 工单/设备负荷(OR-06)。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(world)
|
||
|
||
orders = [o for o in world.get("flexOrders", [])
|
||
if o.get("status") not in ("CANCELLED",)]
|
||
if order_no:
|
||
orders = [o for o in orders if o["orderNo"].lower() == order_no.lower()]
|
||
if not orders:
|
||
raise ValueError(f"没找到柔性订单:{order_no}")
|
||
|
||
versions = world.get("flexScheduleVersions") or []
|
||
latest = versions[-1] if versions else None
|
||
latest_id = latest["id"] if latest else None
|
||
mats = {m["code"]: m for m in world.get("flexMaterials", [])}
|
||
ops = {o["code"]: o for o in world.get("flexOperations", [])}
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
for fo in orders:
|
||
code = fo.get("productCode") or ""
|
||
qty = int(fo.get("quantity") or 0)
|
||
missing: list[str] = []
|
||
|
||
bom_src = [b for b in world.get("flexBom", []) if b.get("productCode") == code]
|
||
route_src = [r for r in world.get("flexRoutings", []) if r.get("productCode") == code]
|
||
route_src = sorted(route_src, key=lambda r: r.get("seq") or r.get("sequenceNo") or 0)
|
||
if not mats.get(code):
|
||
missing.append("成品不在柔性物料表")
|
||
if not bom_src:
|
||
missing.append("无柔性 BOM")
|
||
if not route_src:
|
||
missing.append("无柔性工艺路线")
|
||
|
||
bom_lines = []
|
||
for bi in bom_src:
|
||
mat = mats.get(bi["materialCode"], {})
|
||
gross = float(bi.get("quantity") or 0) * qty
|
||
stock = float(mat.get("stock") or 0)
|
||
transit = float(mat.get("inTransit") or 0)
|
||
shortage = max(0.0, gross - stock - transit)
|
||
bom_lines.append({
|
||
"materialCode": bi["materialCode"],
|
||
"materialName": mat.get("name", bi["materialCode"]),
|
||
"unitQty": bi.get("quantity"),
|
||
"grossQty": round(gross, 3),
|
||
"stock": stock, "inTransit": transit,
|
||
"shortage": round(shortage, 3),
|
||
"isKeyMaterial": bool(bi.get("isKey")),
|
||
"consumeOp": bi.get("consumeOp"),
|
||
"procurementLeadTime": mat.get("procurementLeadTime", 0),
|
||
})
|
||
if any(b["shortage"] > 0 for b in bom_lines):
|
||
missing.append("库存不足以覆盖柔性 BOM 毛需求")
|
||
|
||
route_steps = []
|
||
for s in route_src:
|
||
op = ops.get(s.get("operationCode") or "", {})
|
||
route_steps.append({
|
||
"sequenceNo": s.get("seq") or s.get("sequenceNo"),
|
||
"operationCode": s.get("operationCode"),
|
||
"operationName": op.get("name") or s.get("operationCode"),
|
||
"setupTime": s.get("setupTime") or op.get("setupTime") or 0,
|
||
"runTimePerUnit": s.get("stdTimePerUnit") or s.get("runTimePerUnit")
|
||
or op.get("stdTimePerUnit") or op.get("runTimePerUnit") or 0,
|
||
"requireMold": bool(s.get("requireMold")),
|
||
"isExternal": False,
|
||
})
|
||
|
||
# 虚拟产线 + 工单
|
||
vls, wos = [], []
|
||
load_by_eq: dict[str, float] = {}
|
||
conflicts = []
|
||
if latest_id:
|
||
vls = [v for v in world.get("flexVirtualLines", [])
|
||
if v.get("versionId") == latest_id and v.get("orderNo") == fo["orderNo"]]
|
||
for wo in world.get("flexWorkOrders", []):
|
||
if wo.get("versionId") != latest_id or wo.get("flexOrderNo") != fo["orderNo"]:
|
||
continue
|
||
eq = wo.get("equipmentCode") or "?"
|
||
wos.append({
|
||
"id": wo["id"], "orderNo": fo["orderNo"],
|
||
"operationCode": wo.get("operationCode"),
|
||
"operationName": wo.get("operationName") or wo.get("operationCode"),
|
||
"equipmentCode": eq,
|
||
"moldCode": wo.get("moldCode"),
|
||
"start": wo.get("plannedStartTime"), "end": wo.get("plannedEndTime"),
|
||
"status": wo.get("status"),
|
||
"progressPct": wo.get("progressPct", 0),
|
||
"mesExternalId": wo.get("mesExternalId"),
|
||
"frozen": bool(wo.get("frozen")),
|
||
})
|
||
try:
|
||
mins = (parse_dt(wo["plannedEndTime"]) - parse_dt(wo["plannedStartTime"])).total_seconds() / 60
|
||
except Exception:
|
||
mins = 0
|
||
load_by_eq[eq] = load_by_eq.get(eq, 0.0) + mins
|
||
conflicts = [c for c in world.get("flexConflicts", [])
|
||
if c.get("versionId") == latest_id
|
||
and c.get("orderNo") == fo["orderNo"]
|
||
and not c.get("isResolved")]
|
||
|
||
if not latest_id:
|
||
missing.append("尚无柔性排产版本(请先柔性排产)")
|
||
elif not vls and not wos:
|
||
missing.append("最新柔性版本未包含本订单")
|
||
|
||
# SAP 链接
|
||
sap_link = next((l for l in world.get("sapLinks", [])
|
||
if l.get("kind") == "order" and l.get("orderNo") == fo["orderNo"]), None)
|
||
|
||
rows.append({
|
||
"order": {
|
||
"id": fo["id"], "orderNo": fo["orderNo"],
|
||
"customerName": fo.get("productionController") or fo.get("source") or "柔性订单",
|
||
"customerLevel": "FLEX",
|
||
"deliveryDate": fo.get("dueDate"), "priority": fo.get("priority"),
|
||
"status": fo.get("status"), "isRush": bool(fo.get("isRush")),
|
||
"productId": 0, "productCode": code,
|
||
"productName": fo.get("productName") or (mats.get(code) or {}).get("name") or code,
|
||
"quantity": qty, "unit": (mats.get(code) or {}).get("unit", "套"),
|
||
"externalAufnr": fo.get("externalAufnr"),
|
||
"source": fo.get("source"), "kitOk": fo.get("kitOk"),
|
||
},
|
||
"master": {
|
||
"bomVersion": "flex", "routingVersion": "flex",
|
||
"lineBindings": [], # 柔性无固定产线绑定
|
||
"capabilityHint": "按工序能力池选设备(非固定产线)",
|
||
"bom": bom_lines,
|
||
"routing": route_steps,
|
||
},
|
||
"decompose": {"purchase": [], "outsource": []}, # 柔性轨暂无 MRP 分解
|
||
"schedule": {
|
||
"versionNo": latest.get("versionNo") if latest else None,
|
||
"versionStatus": latest.get("status") if latest else None,
|
||
"sortMode": latest.get("sortMode") if latest else None,
|
||
"virtualLines": [{
|
||
"id": v["id"], "vlNo": v.get("vlNo"), "orderNo": v.get("orderNo"),
|
||
"plannedStart": v.get("plannedStart"), "plannedEnd": v.get("plannedEnd"),
|
||
"onTime": v.get("onTime"),
|
||
} for v in vls],
|
||
"productionOrders": [], # 柔性无 PO
|
||
"workOrders": wos,
|
||
"loadMinutesByEquipment": {k: round(v, 1) for k, v in load_by_eq.items()},
|
||
"loadMinutesByLine": {k: round(v, 1) for k, v in load_by_eq.items()}, # UI 复用字段
|
||
"conflicts": [{
|
||
"type": c.get("conflictType"), "severity": c.get("severity"),
|
||
"description": c.get("description"), "resourceName": c.get("resourceName"),
|
||
} for c in conflicts],
|
||
},
|
||
"integration": {
|
||
"sapAufnr": (sap_link or {}).get("aufnr") or fo.get("externalAufnr"),
|
||
"mesDispatched": sum(1 for w in wos if w.get("mesExternalId")),
|
||
"mesCompleted": sum(1 for w in wos
|
||
if w.get("status") == "COMPLETED"
|
||
or (w.get("progressPct") or 0) >= 100),
|
||
},
|
||
"missing": missing,
|
||
"readyToSchedule": len([m for m in missing if m not in (
|
||
"尚无柔性排产版本(请先柔性排产)",
|
||
"最新柔性版本未包含本订单",
|
||
"库存不足以覆盖柔性 BOM 毛需求",
|
||
)]) == 0,
|
||
})
|
||
|
||
return {
|
||
"track": "flex",
|
||
"latestVersion": {
|
||
"versionNo": latest.get("versionNo"), "status": latest.get("status"),
|
||
"woCount": latest.get("woCount"), "vlCount": latest.get("vlCount"),
|
||
"conflictCount": latest.get("conflictCount"),
|
||
"sortMode": latest.get("sortMode"),
|
||
} if latest else None,
|
||
"orders": rows,
|
||
"count": len(rows),
|
||
}
|
||
|
||
|
||
def summarize_trace(result: dict[str, Any]) -> str:
|
||
"""对话短摘要。"""
|
||
if result.get("track") == "both":
|
||
a = summarize_trace(result.get("fixed") or {"orders": [], "count": 0, "track": "fixed"})
|
||
b = summarize_trace(result.get("flex") or {"orders": [], "count": 0, "track": "flex"})
|
||
return f"【固定轨】\n{a}\n\n【柔性轨】\n{b}"
|
||
|
||
track = result.get("track") or "fixed"
|
||
track_cn = "柔性钉扎" if track == "flex" else "固定追溯"
|
||
if not result.get("orders"):
|
||
return f"{track_cn}:没有可追溯的订单。"
|
||
ver = result.get("latestVersion")
|
||
lines = [f"{track_cn}:共 {result['count']} 张订单" + (
|
||
f"(最新版本 {ver['versionNo']})" if ver else "(尚无排产版本)"
|
||
)]
|
||
for row in result["orders"][:5]:
|
||
o = row["order"]
|
||
miss = ";缺:" + "、".join(row["missing"][:3]) if row["missing"] else ";主数据齐全"
|
||
wo_n = len(row["schedule"]["workOrders"])
|
||
if track == "flex":
|
||
vl_n = len(row["schedule"].get("virtualLines") or [])
|
||
integ = row.get("integration") or {}
|
||
lines.append(
|
||
f"· {o['orderNo']} {o['productName']}×{o['quantity']} → "
|
||
f"虚拟产线 {vl_n} / 工单 {wo_n}"
|
||
+ (f" / MES {integ.get('mesDispatched', 0)}" if integ.get("mesDispatched") else "")
|
||
+ miss
|
||
)
|
||
else:
|
||
pur_n = len(row["decompose"]["purchase"])
|
||
lines.append(
|
||
f"· {o['orderNo']} {o['productName']}×{o['quantity']} → "
|
||
f"工单 {wo_n} / 采购建议 {pur_n}{miss}"
|
||
)
|
||
if result["count"] > 5:
|
||
hint = "柔性工作台「钉扎」" if track == "flex" else "订单面板「追溯」"
|
||
lines.append(f"· …其余 {result['count'] - 5} 张见{hint}")
|
||
return "\n".join(lines)
|