aps-agent/server/aps_domain/trace.py

458 lines
22 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.

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