aps-agent/server/aps_domain/reports.py

438 lines
21 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.

# ============================================================
# 报告生成 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_plan_report(world: dict[str, Any], order_no: str | None = None) -> dict[str, Any]:
"""排产方案报告:优先柔性最新版本(含工序列表),无柔性则回退固定轨日报口径。
同时产出 Excel 工作计划表(xlsxBytes / 落盘用),前端下载 .xlsx。
"""
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
flex_versions = world.get("flexScheduleVersions") or []
if not flex_versions:
daily = build_daily_report(world)
if not daily.get("reportId"):
return {
"reportId": None, "title": "排产方案报告",
"markdown": "尚无排产版本。请先说「跑一版柔性排产」或「试排一版」。",
"snapshot": None, "xlsxBytes": None, "filename": None,
}
daily["title"] = daily["title"].replace("排产日报", "排产方案报告")
daily["markdown"] = daily["markdown"].replace("# 排产日报", "# 排产方案报告(固定轨)", 1)
daily["xlsxBytes"] = None
daily["filename"] = None
return daily
ver = flex_versions[-1]
vid = ver["id"]
order_no = (order_no or "").strip() or None
vls = [v for v in (world.get("flexVirtualLines") or []) if v.get("versionId") == vid]
wos = [w for w in (world.get("flexWorkOrders") or []) if w.get("versionId") == vid]
cfs = [c for c in (world.get("flexConflicts") or [])
if c.get("versionId") == vid and not c.get("isResolved")]
if order_no:
vls = [v for v in vls if str(v.get("orderNo")) == order_no]
wos = [w for w in wos if str(w.get("flexOrderNo") or w.get("orderNo")) == order_no]
cfs = [c for c in cfs if str(c.get("orderNo") or "") == order_no]
orders_by_no = {o["orderNo"]: o for o in (world.get("flexOrders") or [])}
mode = ver.get("sortMode") or "BOTTLENECK"
mode_cn = {"ASC": "正排", "DESC": "倒排", "BOTTLENECK": "瓶颈锚"}.get(mode, mode)
today = fmt_dt(datetime.now())[:10]
scope = f"订单 {order_no}" if order_no else "全部订单"
wo_rows: list[dict[str, Any]] = []
for w in sorted(wos, key=lambda x: (x.get("flexOrderNo") or "", x.get("seq") or 0,
x.get("plannedStartTime") or "")):
ono = w.get("flexOrderNo") or w.get("orderNo")
o = orders_by_no.get(ono) or {}
wo_rows.append({
"orderNo": ono,
"productCode": w.get("productCode") or o.get("productCode"),
"quantity": w.get("quantity") or o.get("quantity"),
"dueDate": o.get("dueDate"),
"vlId": w.get("vlId"),
"seq": w.get("seq"),
"operationCode": w.get("operationCode"),
"operationName": w.get("operationName"),
"equipmentCode": w.get("equipmentCode"),
"equipmentName": w.get("equipmentName") or w.get("equipmentCode"),
"zone": w.get("zone"),
"moldCode": w.get("moldCode"),
"changeoverMin": w.get("changeoverMin") or 0,
"moveMin": w.get("moveMin") or 0,
"runMin": w.get("runMin") or 0,
"start": w.get("plannedStartTime"),
"end": w.get("plannedEndTime"),
"isBottleneck": bool(w.get("isBottleneck")),
"status": w.get("status") or "PLANNED",
})
snap = {
"track": "flex", "versionNo": ver.get("versionNo"), "sortMode": mode,
"vlCount": len(vls), "woCount": len(wos), "conflictCount": len(cfs),
"orderNo": order_no, "woSample": wo_rows,
}
if not wos:
missing = f"订单 {order_no}" if order_no else "工单"
return {
"reportId": None, "title": f"排产方案报告 {scope}",
"markdown": f"最新柔性版本 {ver.get('versionNo')} 中没有{missing},请先说「跑一版柔性排产」。",
"snapshot": snap, "xlsxBytes": None, "filename": None,
}
# ---- Markdown 预览(聊天内) ----
lines = [
f"# 排产方案报告 · {scope}",
"",
f"> 生成日 **{today}** · 版本 **{snap['versionNo']}**(柔性 · {mode_cn})",
"",
"## 一、方案概览",
"",
f"- 虚拟产线:**{snap['vlCount']}** 条 · 工单:**{snap['woCount']}** 道",
f"- 未解决冲突:**{snap['conflictCount']}** 项",
"",
"## 二、订单与交期",
"",
]
seen: set[str] = set()
for v in vls:
ono = str(v.get("orderNo") or "")
if not ono or ono in seen:
continue
seen.add(ono)
o = orders_by_no.get(ono) or {}
on_time = ""
if o.get("dueDate") and v.get("plannedEnd"):
on_time = (" · 按期" if str(v["plannedEnd"])[:10] <= str(o["dueDate"])
else " · 有延期风险")
lines.append(
f"- `{ono}` 产品 {v.get('productCode')} ×{v.get('quantity')} "
f"计划 {v.get('plannedStart')} → {v.get('plannedEnd')} "
f"交期 {o.get('dueDate') or '—'}{on_time}"
)
lines += [
"",
"## 三、工序计划明细(完整表见 Excel)",
"",
f"共 **{len(wo_rows)}** 道工序,请下载 Excel 工作计划表查看/打印。",
"",
"## 四、冲突与风险",
"",
]
if cfs:
lines += [f"- ⚠ {c.get('description') or c.get('conflictType')}" for c in cfs[:10]]
else:
lines.append("- 无未解决冲突")
lines += [
"", "---",
f"*数字取自柔性版本快照 {snap['versionNo']}({fmt_dt(datetime.now())});Excel 为完整工作计划表。*",
]
# ---- Excel 工作计划表 ----
wb = Workbook()
thin = Border(
left=Side(style="thin", color="D0D5DD"),
right=Side(style="thin", color="D0D5DD"),
top=Side(style="thin", color="D0D5DD"),
bottom=Side(style="thin", color="D0D5DD"),
)
head_fill = PatternFill("solid", fgColor="1F4E79")
head_font = Font(color="FFFFFF", bold=True, size=11)
title_font = Font(bold=True, size=14, color="1F4E79")
# Sheet1: 工作计划(主表)
ws = wb.active
ws.title = "工作计划"
ws["A1"] = f"排产工作计划表 · {scope}"
ws["A1"].font = title_font
ws.merge_cells("A1:N1")
ws["A2"] = f"版本 {snap['versionNo']}({mode_cn}) · 生成 {fmt_dt(datetime.now())}"
ws.merge_cells("A2:N2")
headers = [
"订单号", "产品编码", "数量", "交期", "序", "工序编码", "工序名称",
"工位/设备", "设备编码", "区域", "模具", "换型(分)", "移栽(分)", "加工(分)",
"计划开始", "计划结束", "瓶颈", "状态",
]
for col, h in enumerate(headers, 1):
cell = ws.cell(4, col, h)
cell.fill = head_fill
cell.font = head_font
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = thin
for i, w in enumerate(wo_rows, 5):
vals = [
w["orderNo"], w.get("productCode"), w.get("quantity"), w.get("dueDate"),
w.get("seq"), w.get("operationCode"), w.get("operationName"),
w.get("equipmentName"), w.get("equipmentCode"), w.get("zone"), w.get("moldCode"),
w.get("changeoverMin"), w.get("moveMin"), w.get("runMin"),
w.get("start"), w.get("end"),
"是" if w.get("isBottleneck") else "",
w.get("status"),
]
for col, val in enumerate(vals, 1):
cell = ws.cell(i, col, val if val is not None else "")
cell.border = thin
cell.alignment = Alignment(vertical="center")
from openpyxl.utils import get_column_letter
widths = [14, 16, 8, 12, 6, 12, 22, 22, 12, 10, 10, 10, 10, 10, 18, 18, 6, 10]
for i, wth in enumerate(widths, 1):
ws.column_dimensions[get_column_letter(i)].width = wth
ws.auto_filter.ref = f"A4:{get_column_letter(len(headers))}{4 + len(wo_rows)}"
ws.freeze_panes = "A5"
# Sheet2: 方案概览
ws2 = wb.create_sheet("方案概览")
ws2["A1"] = "方案概览"
ws2["A1"].font = title_font
overview = [
("版本号", snap["versionNo"]),
("排产模式", mode_cn),
("范围", scope),
("虚拟产线数", snap["vlCount"]),
("工单数", snap["woCount"]),
("冲突数", snap["conflictCount"]),
("生成时间", fmt_dt(datetime.now())),
]
for r, (k, v) in enumerate(overview, 3):
ws2.cell(r, 1, k).font = Font(bold=True)
ws2.cell(r, 2, v)
ws2.column_dimensions["A"].width = 14
ws2.column_dimensions["B"].width = 28
ws2["A11"] = "订单交期"
ws2["A11"].font = Font(bold=True)
ws2["A12"] = "订单号"
ws2["B12"] = "产品"
ws2["C12"] = "数量"
ws2["D12"] = "计划开始"
ws2["E12"] = "计划结束"
ws2["F12"] = "交期"
ws2["G12"] = "是否按期"
for col in range(1, 8):
ws2.cell(12, col).fill = head_fill
ws2.cell(12, col).font = head_font
row = 13
seen2: set[str] = set()
for v in vls:
ono = str(v.get("orderNo") or "")
if not ono or ono in seen2:
continue
seen2.add(ono)
o = orders_by_no.get(ono) or {}
on_time = ""
if o.get("dueDate") and v.get("plannedEnd"):
on_time = "按期" if str(v["plannedEnd"])[:10] <= str(o["dueDate"]) else "延期风险"
ws2.cell(row, 1, ono)
ws2.cell(row, 2, v.get("productCode"))
ws2.cell(row, 3, v.get("quantity"))
ws2.cell(row, 4, v.get("plannedStart"))
ws2.cell(row, 5, v.get("plannedEnd"))
ws2.cell(row, 6, o.get("dueDate"))
ws2.cell(row, 7, on_time)
row += 1
for col, wth in enumerate([14, 16, 8, 18, 18, 12, 10], 1):
ws2.column_dimensions[get_column_letter(col)].width = wth
# Sheet3: 冲突
ws3 = wb.create_sheet("冲突")
ws3["A1"] = "冲突与风险"
ws3["A1"].font = title_font
cf_headers = ["类型", "订单号", "严重度", "描述", "建议"]
for col, h in enumerate(cf_headers, 1):
cell = ws3.cell(3, col, h)
cell.fill = head_fill
cell.font = head_font
if cfs:
for i, c in enumerate(cfs, 4):
ws3.cell(i, 1, c.get("conflictType") or c.get("type"))
ws3.cell(i, 2, c.get("orderNo"))
ws3.cell(i, 3, c.get("severity"))
ws3.cell(i, 4, c.get("description"))
ws3.cell(i, 5, c.get("suggestedSolution") or c.get("suggestion"))
else:
ws3["A4"] = "无未解决冲突"
for col, wth in enumerate([14, 14, 10, 40, 30], 1):
ws3.column_dimensions[get_column_letter(col)].width = wth
buf = BytesIO()
wb.save(buf)
xlsx_bytes = buf.getvalue()
report_id = uuid.uuid4().hex[:10]
safe_scope = (order_no or "全部").replace("/", "-")
filename = f"排产工作计划_{safe_scope}_{snap['versionNo']}.xlsx"
return {
"reportId": report_id,
"title": f"排产方案报告 {scope} {snap['versionNo']}",
"markdown": "\n".join(lines),
"snapshot": snap,
"xlsxBytes": xlsx_bytes,
"filename": filename,
"format": "xlsx",
}
def persist_report_xlsx(report_id: str, xlsx_bytes: bytes, filename: str | None = None) -> dict[str, str]:
"""Persist a report inside the current tenant/project world directory."""
import os
from server.state.store import get_store
root = os.path.join(os.path.dirname(get_store().path), "exports")
os.makedirs(root, exist_ok=True)
fname = filename or f"{report_id}.xlsx"
# 文件名安全:仅保留 basename
fname = os.path.basename(fname).replace(" ", "_")
path = os.path.join(root, f"{report_id}__{fname}")
with open(path, "wb") as f:
f.write(xlsx_bytes)
return {"path": path, "filename": fname, "reportId": report_id}
def build_report(world: dict[str, Any], report_type: str, *, order_no: str | None = None) -> dict[str, Any]:
"""报告工厂:按类型分发(daily / version-diff / plan;未知类型给引导文案)。"""
if report_type == "daily":
return build_daily_report(world)
if report_type == "version-diff":
return build_version_diff_report(world)
if report_type in ("plan", "schedule-plan", "flex-plan"):
return build_plan_report(world, order_no=order_no)
return {"reportId": None, "title": "报告",
"markdown": "暂支持:说「生成排产方案报告」「生成日报」或「生成版本对比报告」。",
"snapshot": None}