aps-agent/server/aps_domain/readiness.py

324 lines
15 KiB
Python
Raw Permalink 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-readiness, 可重生 ✅)
# 排产前置校验:对每个待排订单检查
# 产品 → 工艺路线 → 每步工时(含来源) → 可用资源 → 班次日历
# 输出缺失清单 + 严重度;供 /api/readiness、排产前置校验、
# 引导式排产向导(M-F)与「工时维护情况」口令共用。
# 严重度:error=会阻塞或严重失真(引擎会默默用 1 分钟兜底);
# warn=可排但结果可信度低(推断工时/缺料)。
# ============================================================
from __future__ import annotations
from typing import Any
World = dict[str, Any]
# 工时来源常量(与导入器/维护视图对齐)
SRC_MEASURED = "实测"
SRC_INFERRED = "推断"
SRC_TEMPLATE = "模板"
SRC_EQUIPMENT = "设备默认"
SRC_PENDING = "待维护"
def resolve_step_time(step: dict, equipment: list[dict]) -> dict[str, Any]:
"""解析一条柔性路线步骤的单件工时与来源。
优先级:路线覆盖值 → 设备 opStdTime → 无(引擎会用 1 分钟兜底=待维护)。
"""
op_code = step.get("operationCode")
std = step.get("stdTimePerUnit")
if std:
return {"stdMin": float(std), "source": step.get("stdTimeSource") or SRC_MEASURED}
eq_times = [
float(e["opStdTime"][op_code])
for e in equipment
if op_code in (e.get("opStdTime") or {}) and e["opStdTime"][op_code]
]
if eq_times:
return {"stdMin": min(eq_times), "source": SRC_EQUIPMENT}
return {"stdMin": None, "source": SRC_PENDING}
def time_matrix(world: World) -> list[dict[str, Any]]:
"""产品×工序工时矩阵(工时维护视图数据源)。"""
equipment = world.get("flexEquipment") or []
rows: list[dict[str, Any]] = []
for step in world.get("flexRoutings") or []:
info = resolve_step_time(step, equipment)
rows.append({
"productCode": step.get("productCode"),
"productName": step.get("productName") or step.get("productCode"),
"seq": step.get("seq"),
"operationCode": step.get("operationCode"),
"stdMin": info["stdMin"],
"source": info["source"],
"requireMold": bool(step.get("requireMold")),
})
return rows
def check_order(world: World, order: dict) -> dict[str, Any]:
"""单订单齐备度:返回 {orderNo, productCode, issues[], ready}。"""
from server.aps_domain.masterdata_consumption import as_datetime, bom_requirement, order_execution_issues, uses_masterdata_constraints
issues: list[dict[str, str]] = order_execution_issues(world, order)
material_severity = "error" if uses_masterdata_constraints(world) else "warn"
pc = order.get("productCode") or ""
equipment = [e for e in world.get("flexEquipment") or [] if e.get("status") == "RUNNING"]
materials = {m["code"]: m for m in world.get("flexMaterials") or []}
# ① 产品主数据
if pc not in materials:
issues.append({"type": "NO_PRODUCT", "severity": "error",
"detail": f"产品 {pc} 不在物料主数据中"})
# ② 工艺路线
steps = sorted([r for r in world.get("flexRoutings") or [] if r.get("productCode") == pc],
key=lambda r: r.get("seq") or 0)
if not steps:
issues.append({"type": "NO_ROUTING", "severity": "error",
"detail": f"产品 {pc} 无工艺路线(可用行业模板生成)"})
# ③ 每步工时 + 能力设备
for step in steps:
op = step.get("operationCode")
info = resolve_step_time(step, world.get("flexEquipment") or [])
if info["source"] == SRC_PENDING:
issues.append({"type": "TIME_UNMAINTAINED", "severity": "error",
"detail": f"工序 {op} 无工时(引擎将按 1 分钟/件失真兜底)"})
elif info["source"] in ("demo", "演示", "演示标准工时"):
issues.append({"type": "TIME_DEMO", "severity": "warn",
"detail": f"工序 {op} 工时为演示补充 {info['stdMin']} 分钟/件,尚未现场确认,仅用于试排"})
elif info["source"] == SRC_INFERRED:
issues.append({"type": "TIME_INFERRED", "severity": "warn",
"detail": f"工序 {op} 工时为推断值 {info['stdMin']} 分钟/件,建议实测后维护"})
if not any(op in (e.get("capabilities") or []) for e in equipment):
issues.append({"type": "NO_CAPABLE_EQUIPMENT", "severity": "error",
"detail": f"工序 {op} 无运行中的能力设备"})
# ④ 物料齐套(软性提示)
qty = float(order.get("quantity") or 0)
for b in world.get("flexBom") or []:
if b.get("productCode") != pc:
continue
mat = materials.get(b.get("materialCode"))
if not mat:
issues.append({"type": "MATERIAL_GAP", "severity": material_severity,
"detail": f"BOM 物料 {b.get('materialCode')} 缺主数据"})
continue
need = bom_requirement(b, qty)
have = float(mat.get("stock") or 0) + float(mat.get("inTransit") or 0)
if (uses_masterdata_constraints(world) and need > float(mat.get("stock") or 0)
and float(mat.get("inTransit") or 0) > 0 and as_datetime(mat.get("expectedArrivalDate")) is None):
issues.append({"type": "MATERIAL_ETA_UNKNOWN", "severity": "error",
"detail": f"物料 {mat.get('name')} 需要在途补料,但未确认到货日期"})
if need > have:
issues.append({"type": "MATERIAL_GAP", "severity": material_severity,
"detail": f"物料 {mat.get('name')} 缺口 {need - have:.0f} {mat.get('unit') or ''}"})
errors = [i for i in issues if i["severity"] == "error"]
return {
"orderNo": order.get("orderNo"),
"productCode": pc,
"quantity": order.get("quantity"),
"dueDate": order.get("dueDate"),
"issues": issues,
"ready": not errors,
"hasWarnings": any(i["severity"] == "warn" for i in issues),
}
def check_readiness(world: World, order_ids: list[int] | None = None) -> dict[str, Any]:
"""全量/指定订单齐备度检查(P0 只读)。"""
orders = [o for o in world.get("flexOrders") or []
if (o.get("status") or "") not in ("DONE", "CANCELLED")]
if order_ids:
orders = [o for o in orders if o.get("id") in set(order_ids)]
results = [check_order(world, o) for o in orders]
# 全局项:班次日历
global_issues: list[dict[str, str]] = []
if not any(s.get("enabled", True) for s in world.get("flexCalendar") or []) and not world.get("flexCalendarOverrides"):
global_issues.append({"type": "NO_CALENDAR", "severity": "error",
"detail": "无班次日历(不知道每天几班、几点开工)"})
# 工时维护总览
matrix = time_matrix(world)
pending = [r for r in matrix if r["source"] == SRC_PENDING]
inferred = [r for r in matrix if r["source"] == SRC_INFERRED]
return {
"orders": results,
"globalIssues": global_issues,
"summary": {
"total": len(results),
"ready": sum(1 for r in results if r["ready"]) if not global_issues else 0,
"withWarnings": sum(1 for r in results if r["hasWarnings"]),
"blocked": len(results) if global_issues else sum(1 for r in results if not r["ready"]),
"timePending": len(pending),
"timeInferred": len(inferred),
"timeDemo": sum(r["source"] in ("demo", "演示", "演示标准工时") for r in matrix),
"timeTotal": len(matrix),
},
}
def readiness_text(report: dict[str, Any]) -> str:
"""齐备度报告:结论、关键指标、阻断项与下一步。"""
s = report["summary"]
if not s["total"]:
conclusion = "当前没有待排订单,暂时无法生成试排方案。"
elif s["blocked"]:
conclusion = f"当前有 {s['blocked']} 张订单存在阻断项,暂不建议直接排产。"
else:
conclusion = f"当前 {s['total']} 张订单已通过基础齐备度检查,可以生成试排方案。"
lines = [
"## 数据齐备度",
f"**结论:{conclusion}**",
"",
f"- 待排订单:{s['total']} 张;可直接排产:{s['ready']} 张;存在阻断:{s['blocked']} 张。",
f"- 标准工时:共 {s['timeTotal']} 道工序;待维护 {s['timePending']} 道;推断值 {s['timeInferred']} 道。",
]
if s["withWarnings"]:
lines.append(f"- 风险提示:{s['withWarnings']} 张订单包含推断工时或缺料告警。")
for gi in report["globalIssues"]:
lines.append(f"- **阻断项**:{gi['detail']}")
shown = 0
for r in report["orders"]:
if not r["issues"] or shown >= 6:
continue
mark = "阻断" if not r["ready"] else "风险"
heads = ";".join(i["detail"] for i in r["issues"][:3])
more = f"(另外还有 {len(r['issues']) - 3} 处)" if len(r["issues"]) > 3 else ""
lines.append(f"- **{mark}** · 订单 {r['orderNo']}:{heads}{more}")
shown += 1
rest = sum(1 for r in report["orders"] if r["issues"]) - shown
if rest > 0:
lines.append(f"- 另有 {rest} 张订单存在类似问题,详情可在订单齐备度明细中查看。")
if s["timePending"] or s["timeInferred"]:
lines.append("")
lines.append(
"**建议操作**:补充待维护工时,或提供实测工时(例如“将工序 OP-10 设置为 45 分钟/件”)。"
)
return "\n".join(lines)
def analyze_project_data(world: World, query: str = "") -> str:
"""分析当前项目/附件:结论 → 数据概览 → 阻断项 → 建议操作。"""
lines: list[str] = []
q = query or ""
if "【文件解析】" in q:
chunk = q.split("【文件解析】", 1)[1]
chunk = chunk.strip()
if "\n\n请" in chunk:
chunk = chunk.split("\n\n请", 1)[0].strip()
elif "\n\n给我" in chunk:
chunk = chunk.split("\n\n给我", 1)[0].strip()
lines.append("## 附件解析结果")
lines.append("已完成附件的初步解析:")
lines.extend(chunk.splitlines()[:40])
lines.append("")
if "可入库" in chunk:
lines.append("部分记录已通过校验,可以提交入库;错误记录需要修正后重新上传。")
lines.append("")
active = [o for o in (world.get("flexOrders") or [])
if (o.get("status") or "") not in ("DONE", "CANCELLED")]
mats = world.get("flexMaterials") or []
routes = world.get("flexRoutings") or []
equip = [e for e in (world.get("flexEquipment") or []) if e.get("status") == "RUNNING"]
cal = world.get("flexCalendar") or []
bom = world.get("flexBom") or []
lines.append("## 项目数据概览")
lines.append(
f"- 待排订单:**{len(active)}** 张;产品/物料:**{len(mats)}** 条;"
f"BOM 明细:**{len(bom)}** 条;工艺步骤:**{len(routes)}** 条;"
f"可用设备:**{len(equip)}** 台;班次日历:**{len(cal)}** 条。"
)
if active:
preview = "、".join(
f"{o.get('orderNo')}({o.get('productCode')} × {o.get('quantity')})"
for o in active[:5]
)
more = f"……一共 {len(active)} 张" if len(active) > 5 else ""
lines.append(f"- 订单示例:{preview}{more}")
else:
lines.append("- 待排订单为 0 张。请先导入订单表或新建订单。")
report = check_readiness(world)
lines.append("")
lines.append(readiness_text(report))
asks: list[str] = []
if not active:
asks.append("导入订单表(至少包含单号、产品、数量和交期)")
if not mats:
asks.append("补充产品/物料主数据(至少包含待排产品)")
if not routes:
asks.append("补充工艺路线和工序顺序,或请求系统推荐行业模板")
if not equip:
asks.append("补充可用设备及其工序能力")
if not cal:
asks.append("补充班次日历(工作日、班次和起止时间)")
s = report["summary"]
if s.get("timePending"):
asks.append(f"补充 {s['timePending']} 道工序的标准工时")
if s.get("blocked"):
asks.append(f"处理 {s['blocked']} 张订单的阻断项(产品、工艺或设备能力缺失)")
seen: set[str] = set()
uniq = []
for a in asks:
if a not in seen:
seen.add(a)
uniq.append(a)
lines.append("")
if uniq:
lines.append("## 阻断项与建议操作")
for i, a in enumerate(uniq, 1):
lines.append(f"{i}. {a}")
lines.append("完成上述补充后,请再次执行“检查数据齐备度”;通过后即可生成试排方案。")
else:
lines.append("## 建议操作")
lines.append("数据已满足基础排产条件。可以选择排产策略并生成试排方案。")
return "\n".join(lines)
def annotate_time_conflicts(world: World, version_id: int, next_id, order_nos: list[str] | None = None) -> int:
"""排产后置标注:把推断/待维护工时写成冲突(TIME_INFERRED/TIME_UNMAINTAINED),不再默默兜底。"""
equipment = world.get("flexEquipment") or []
seen: set[tuple[str, str]] = set()
count = 0
order_products = {o.get("productCode") for o in world.get("flexOrders") or []
if not order_nos or o.get("orderNo") in set(order_nos)}
for step in world.get("flexRoutings") or []:
pc = step.get("productCode")
if pc not in order_products:
continue
key = (pc, step.get("operationCode") or "")
if key in seen:
continue
seen.add(key)
info = resolve_step_time(step, equipment)
if info["source"] == SRC_PENDING:
ctype, sev = "TIME_UNMAINTAINED", "MAJOR"
desc = f"产品 {pc} 工序 {step.get('operationCode')} 工时未维护,排产按 1 分钟/件兜底(严重失真)"
fix = "在工时维护视图补录实测工时"
elif info["source"] == SRC_INFERRED:
ctype, sev = "TIME_INFERRED", "MINOR"
desc = f"产品 {pc} 工序 {step.get('operationCode')} 采用推断工时 {info['stdMin']} 分钟/件"
fix = "实测后更新工时(来源改为实测)"
else:
continue
world.setdefault("flexConflicts", []).append({
"id": next_id("flexConflict"), "versionId": version_id,
"conflictType": ctype, "severity": sev,
"resourceType": "TIME", "orderNo": "",
"description": desc, "suggestedSolution": fix, "isResolved": False,
})
count += 1
return count