aps-agent/server/aps_domain/trace.py

458 lines
22 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 计划追溯(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)