147 lines
9.4 KiB
Python
147 lines
9.4 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 报告生成 v1(moduleId: domain-reports, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py)
|
|||
|
|
# plan.md §9.10:M3 落地两类——排产日报 + 版本对比报告。
|
|||
|
|
# 生成管线:冻结数据快照 → 结构化章节模板(确定性,数字全部来自快照)
|
|||
|
|
# → 证据链脚注 → Markdown 导出。
|
|||
|
|
# 硬规则:每个数字可溯源到快照字段;LLM 不参与算数(M3 甚至不参与叙事——纯模板,
|
|||
|
|
# LLM 润色留到接入密钥后开启,报告可复现性优先)。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations # 前向类型引用
|
|||
|
|
|
|||
|
|
import uuid # 报告 ID
|
|||
|
|
from datetime import datetime # 生成时间
|
|||
|
|
from typing import Any # 类型标注
|
|||
|
|
|
|||
|
|
from server.timeutil import fmt_dt # 时间格式化
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _freeze_version(world: dict[str, Any], version: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""冻结一个版本的口径快照(报告数字的唯一来源,§9.10 管线第一步)。"""
|
|||
|
|
# 工单经生产订单关联版本(数据模型:WO.productionOrderId → PO.schedulingVersionId)
|
|||
|
|
po_ids = {p["id"] for p in world["productionOrders"] if p["schedulingVersionId"] == version["id"]}
|
|||
|
|
wos = [w for w in world["workOrders"] if w["productionOrderId"] in po_ids] # 该版本工单
|
|||
|
|
conflicts = [c for c in world["conflicts"] if c.get("versionId") == version["id"]] # 该版本冲突
|
|||
|
|
return { # 快照:后续所有数字只许取自这里
|
|||
|
|
"versionNo": version["versionNo"], # 版本号
|
|||
|
|
"status": version["status"], # 状态
|
|||
|
|
"engineType": version["engineType"], # 引擎
|
|||
|
|
# 策略取自版本 note 留痕(rule_engine 写入 "strategy=XXX")
|
|||
|
|
"strategy": (version.get("note") or "").replace("strategy=", "") or "未记录",
|
|||
|
|
"createdAt": version["createdAt"], # 生成时间
|
|||
|
|
"poCount": version["poCount"], # 生产订单数
|
|||
|
|
"woCount": version["woCount"], # 工单数
|
|||
|
|
"conflictCount": version["conflictCount"], # 冲突数
|
|||
|
|
"totalTardiness": round(version["totalTardiness"], 1), # 总延迟
|
|||
|
|
"avgUtilization": round(version["avgUtilization"], 3), # 平均利用率
|
|||
|
|
"totalCost": version.get("totalCost", 0), # 预估成本
|
|||
|
|
"woSample": [{ # 工单样例(日报"今日计划"节选,最多 8 条)
|
|||
|
|
"orderNo": w["orderNo"], "start": w["plannedStartTime"], "end": w["plannedEndTime"],
|
|||
|
|
} for w in sorted(wos, key=lambda x: x["plannedStartTime"])[:8]],
|
|||
|
|
"conflictSample": [c["description"] for c in conflicts[:5]], # 冲突样例(最多 5 条)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_daily_report(world: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""排产日报(§9.10 表第 1 行:今日计划/变更/冲突;P0 只读产出文档)。
|
|||
|
|
|
|||
|
|
Returns: {reportId, title, markdown, snapshot}——markdown 即交付物,snapshot 即证据。
|
|||
|
|
"""
|
|||
|
|
versions = world["scheduleVersions"] # 版本表
|
|||
|
|
if not versions: # 无版本无从报告
|
|||
|
|
return {"reportId": None, "title": "排产日报", "markdown": "尚无排产版本,无法生成日报。", "snapshot": None}
|
|||
|
|
snap = _freeze_version(world, versions[-1]) # 冻结最新版本口径
|
|||
|
|
today = fmt_dt(datetime.now())[:10] # 报告日期
|
|||
|
|
lines = [ # 结构化章节模板(纯确定性)
|
|||
|
|
f"# 排产日报 · {today}",
|
|||
|
|
"",
|
|||
|
|
f"> 依据版本快照 **{snap['versionNo']}**({snap['status']} · {snap['engineType']} · {snap['createdAt']})",
|
|||
|
|
"",
|
|||
|
|
"## 一、计划概览",
|
|||
|
|
"",
|
|||
|
|
f"- 生产订单:**{snap['poCount']}** 个 · 工单:**{snap['woCount']}** 个",
|
|||
|
|
f"- 未解决冲突:**{snap['conflictCount']}** 项",
|
|||
|
|
f"- 总延迟:**{snap['totalTardiness']}h** · 平均利用率:**{round(snap['avgUtilization'] * 100)}%**",
|
|||
|
|
f"- 预估成本:**¥{int(snap['totalCost']):,}**",
|
|||
|
|
"",
|
|||
|
|
"## 二、今日计划节选(按开始时间)",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
if snap["woSample"]: # 有工单则列表
|
|||
|
|
lines += [f"- `{w['orderNo']}`:{w['start']} → {w['end']}" for w in snap["woSample"]]
|
|||
|
|
else:
|
|||
|
|
lines.append("- (该版本无工单)")
|
|||
|
|
lines += ["", "## 三、冲突与风险", ""]
|
|||
|
|
if snap["conflictSample"]: # 冲突样例
|
|||
|
|
lines += [f"- ⚠ {c}" for c in snap["conflictSample"]]
|
|||
|
|
else:
|
|||
|
|
lines.append("- 无未解决冲突 ✅")
|
|||
|
|
lines += ["", "---", f"*本报告全部数字取自版本快照 {snap['versionNo']}(生成于 {fmt_dt(datetime.now())});"
|
|||
|
|
"模板化生成,未经 LLM 改写,可复现。*"]
|
|||
|
|
return {"reportId": uuid.uuid4().hex[:10], "title": f"排产日报 {today}",
|
|||
|
|
"markdown": "\n".join(lines), "snapshot": snap}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_version_diff_report(world: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""版本对比报告(§9.10 表第 2 行:新旧 KPI diff;P0 只读产出文档)。"""
|
|||
|
|
versions = world["scheduleVersions"] # 版本表
|
|||
|
|
if len(versions) < 2: # 不足两版无从对比
|
|||
|
|
return {"reportId": None, "title": "版本对比报告",
|
|||
|
|
"markdown": "当前排产版本少于两个,无法生成版本对比报告。请先生成至少两个排产草案。", "snapshot": None}
|
|||
|
|
new, old = _freeze_version(world, versions[-1]), _freeze_version(world, versions[-2]) # 冻结新旧两版
|
|||
|
|
|
|||
|
|
def delta(key: str, unit: str = "", pct: bool = False) -> str:
|
|||
|
|
"""格式化差值(新-旧;含涨跌符号,数字全部来自快照)。"""
|
|||
|
|
d = new[key] - old[key] # 差值
|
|||
|
|
v = round(d * 100) if pct else round(d, 1) # 百分比换算
|
|||
|
|
sign = "+" if v > 0 else "" # 涨跌符号
|
|||
|
|
return f"{sign}{v}{'%' if pct else unit}"
|
|||
|
|
|
|||
|
|
lines = [ # 模板章节
|
|||
|
|
f"# 版本对比报告 · {new['versionNo']} vs {old['versionNo']}",
|
|||
|
|
"",
|
|||
|
|
f"> 新版 **{new['versionNo']}**({new['strategy']} · {new['createdAt']})"
|
|||
|
|
f" ← 旧版 **{old['versionNo']}**({old['strategy']} · {old['createdAt']})",
|
|||
|
|
"",
|
|||
|
|
"## KPI 对比",
|
|||
|
|
"",
|
|||
|
|
"| 指标 | 旧版 | 新版 | 变化 |",
|
|||
|
|
"| --- | --- | --- | --- |",
|
|||
|
|
f"| 工单数 | {old['woCount']} | {new['woCount']} | {delta('woCount')} |",
|
|||
|
|
f"| 冲突数 | {old['conflictCount']} | {new['conflictCount']} | {delta('conflictCount')} |",
|
|||
|
|
f"| 总延迟(h) | {old['totalTardiness']} | {new['totalTardiness']} | {delta('totalTardiness', 'h')} |",
|
|||
|
|
f"| 平均利用率 | {round(old['avgUtilization'] * 100)}% | {round(new['avgUtilization'] * 100)}% | {delta('avgUtilization', pct=True)} |",
|
|||
|
|
f"| 预估成本 | ¥{int(old['totalCost']):,} | ¥{int(new['totalCost']):,} | {delta('totalCost')} |",
|
|||
|
|
"",
|
|||
|
|
"## 结论要点(规则生成)",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
# 确定性结论:按 KPI 变化方向给要点(不是 LLM 叙事,可复现)
|
|||
|
|
verdicts = [] # 结论列表
|
|||
|
|
if new["conflictCount"] < old["conflictCount"]:
|
|||
|
|
verdicts.append(f"冲突减少 {old['conflictCount'] - new['conflictCount']} 项,稳定性改善。")
|
|||
|
|
elif new["conflictCount"] > old["conflictCount"]:
|
|||
|
|
verdicts.append(f"冲突增加 {new['conflictCount'] - old['conflictCount']} 项,需人工复核。")
|
|||
|
|
if new["totalTardiness"] < old["totalTardiness"]:
|
|||
|
|
verdicts.append(f"总延迟下降 {round(old['totalTardiness'] - new['totalTardiness'], 1)}h,交付表现更好。")
|
|||
|
|
elif new["totalTardiness"] > old["totalTardiness"]:
|
|||
|
|
verdicts.append(f"总延迟上升 {round(new['totalTardiness'] - old['totalTardiness'], 1)}h,注意交付风险。")
|
|||
|
|
if abs(new["avgUtilization"] - old["avgUtilization"]) >= 0.02:
|
|||
|
|
verdicts.append(f"利用率变化 {round((new['avgUtilization'] - old['avgUtilization']) * 100)}%。")
|
|||
|
|
if not verdicts: # 变化不显著
|
|||
|
|
verdicts.append("两版 KPI 无显著差异。")
|
|||
|
|
lines += [f"- {v}" for v in verdicts]
|
|||
|
|
lines += ["", "---", f"*数字全部取自两版快照;生成于 {fmt_dt(datetime.now())},模板化可复现。*"]
|
|||
|
|
return {"reportId": uuid.uuid4().hex[:10],
|
|||
|
|
"title": f"版本对比 {new['versionNo']} vs {old['versionNo']}",
|
|||
|
|
"markdown": "\n".join(lines), "snapshot": {"new": new, "old": old}}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_report(world: dict[str, Any], report_type: str) -> dict[str, Any]:
|
|||
|
|
"""报告工厂:按类型分发(daily / version-diff;未知类型给引导文案)。"""
|
|||
|
|
if report_type == "daily": # 排产日报
|
|||
|
|
return build_daily_report(world)
|
|||
|
|
if report_type == "version-diff": # 版本对比
|
|||
|
|
return build_version_diff_report(world)
|
|||
|
|
return {"reportId": None, "title": "报告", # 未知类型引导
|
|||
|
|
"markdown": "暂支持两类报告:说「生成日报」或「生成版本对比报告」。", "snapshot": None}
|