aps-agent/server/aps_domain/reports.py

648 lines
35 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]
all_cfs = [c for c in (world.get("flexConflicts") or [])
if c.get("versionId") == vid and not c.get("isResolved")]
cfs = list(all_cfs)
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]
if not wos and not cfs:
cfs = list(all_cfs)
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,
"solveStatus": ver.get("solveStatus"),
"planningProblemId": ver.get("planningProblemId"),
"planningSourceHash": ver.get("planningSourceHash"),
"demandCount": int(ver.get("demandCount") or 0),
"admittedDemandCount": int(ver.get("admittedDemandCount") or 0),
"unscheduledDemandCount": int(ver.get("unscheduledDemandCount") or 0),
}
if not wos:
blocker_rows = cfs or [
{
"conflictType": "NO_WORK_ORDERS",
"severity": "CRITICAL",
"description": "No executable work orders were admitted for this version",
"suggestedSolution": "Complete routing, resource capability and trusted supply evidence before rerunning",
}
]
lines = [
f"# 排产阻断分析报告 · {scope}",
"",
f"> 生成日 **{today}** · 版本 **{snap['versionNo']}** · 状态 **{snap.get('solveStatus') or 'BLOCKED'}**",
"",
"## 一、准入结果",
"",
f"- 制造需求:**{snap['demandCount']}** 项",
f"- 准入:**{snap['admittedDemandCount']}** 项 · 未排:**{snap['unscheduledDemandCount']}** 项",
f"- 虚拟产线:**0** 条 · 可执行工单:**0** 个",
"- 已生成 Excel 阻断清单,可用于逐项补齐主数据与供应证据。",
"",
"## 二、阻断与修复建议",
"",
]
for row in blocker_rows[:50]:
lines.append(
f"- **{row.get('conflictType') or row.get('type') or 'BLOCKER'}** "
f"[{row.get('severity') or 'HARD'}] "
f"{row.get('description') or ''} "
f"{row.get('suggestedSolution') or row.get('suggestion') or ''}".strip()
)
workbook = Workbook()
sheet = workbook.active
sheet.title = "阻断分析"
sheet["A1"] = "排产阻断分析"
sheet["A1"].font = Font(size=16, bold=True)
sheet.merge_cells("A1:E1")
sheet["A2"] = f"版本 {snap['versionNo']} · {scope} · {snap.get('solveStatus') or 'BLOCKED'}"
sheet.merge_cells("A2:E2")
headers = ["阻断类型", "对象", "严重度", "说明", "修复建议"]
for column, header in enumerate(headers, 1):
cell = sheet.cell(4, column, header)
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="C2410C")
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_index, blocker in enumerate(blocker_rows, 5):
values = [
blocker.get("conflictType") or blocker.get("type"),
blocker.get("orderNo") or blocker.get("resourceName") or blocker.get("resourceType"),
blocker.get("severity") or "HARD",
blocker.get("description"),
blocker.get("suggestedSolution") or blocker.get("suggestion"),
]
for column, value in enumerate(values, 1):
sheet.cell(row_index, column, value if value is not None else "")
for column, width in enumerate([24, 24, 12, 54, 44], 1):
from openpyxl.utils import get_column_letter
sheet.column_dimensions[get_column_letter(column)].width = width
buffer = BytesIO()
workbook.save(buffer)
report_id = uuid.uuid4().hex[:10]
safe_scope = (order_no or "ALL").replace("/", "-")
filename = f"排产阻断分析_{safe_scope}_{snap['versionNo']}.xlsx"
return {
"reportId": report_id,
"title": f"排产阻断分析报告 {scope} {snap['versionNo']}",
"markdown": "\n".join(lines),
"snapshot": snap,
"xlsxBytes": buffer.getvalue(),
"filename": filename,
"format": "xlsx",
"blocked": True,
}
# ---- 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 build_plan_report_docx(world: dict[str, Any], order_no: str | None = None) -> dict[str, Any]:
"""Build a management-ready Word analysis for the latest flexible schedule."""
from io import BytesIO
from datetime import datetime
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
versions = world.get("flexScheduleVersions") or []
if not versions:
return {"docxBytes": None, "filename": None, "message": "暂无可导出的柔性排产版本"}
ver = versions[-1]
vid = ver.get("id")
orders = world.get("flexOrders") or []
if order_no:
orders = [o for o in orders if str(o.get("orderNo")) == str(order_no)]
order_map = {str(o.get("orderNo")): o for o in orders}
vls = [r for r in (world.get("flexVirtualLines") or []) if r.get("versionId") == vid]
wos = [r for r in (world.get("flexWorkOrders") or []) if r.get("versionId") == vid]
conflicts = [r for r in (world.get("flexConflicts") or []) if r.get("versionId") == vid and not r.get("isResolved")]
if order_no:
vls = [r for r in vls if str(r.get("orderNo")) == str(order_no)]
wos = [r for r in wos if str(r.get("flexOrderNo") or r.get("orderNo")) == str(order_no)]
equipment = {str(r.get("code")): r for r in (world.get("flexEquipment") or [])}
mes_links = [r for r in (world.get("mesLinks") or []) if r.get("track") == "flex" and r.get("versionId") == vid]
def dt(v: Any) -> str:
return str(v or "-").replace("T", " ")[:16]
def table(headers: list[str], rows: list[list[Any]], widths: list[float] | None = None):
t = doc.add_table(rows=1, cols=len(headers)); t.style = "Table Grid"; t.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell=t.rows[0].cells[i]; cell.text=str(h); cell.vertical_alignment=WD_CELL_VERTICAL_ALIGNMENT.CENTER
for run in cell.paragraphs[0].runs: run.bold=True; run.font.color.rgb=RGBColor(255,255,255)
shade=OxmlElement('w:shd'); shade.set(qn('w:fill'),'E86B20'); cell._tc.get_or_add_tcPr().append(shade)
for row in rows:
cells=t.add_row().cells
for i,val in enumerate(row): cells[i].text=str(val if val not in (None,"") else "-")
if widths:
for row in t.rows:
for i,w in enumerate(widths): row.cells[i].width=Cm(w)
doc.add_paragraph()
return t
def heading(text: str, level: int = 1):
p=doc.add_heading(text, level=level); p.paragraph_format.space_before=Pt(8); p.paragraph_format.space_after=Pt(5); return p
doc=Document(); sec=doc.sections[0]; sec.top_margin=Cm(1.8); sec.bottom_margin=Cm(1.7); sec.left_margin=Cm(1.8); sec.right_margin=Cm(1.8)
styles=doc.styles
styles['Normal'].font.name='Microsoft YaHei'; styles['Normal']._element.rPr.rFonts.set(qn('w:eastAsia'),'Microsoft YaHei'); styles['Normal'].font.size=Pt(9)
for name in ('Title','Heading 1','Heading 2','Heading 3'):
styles[name].font.name='Microsoft YaHei'; styles[name]._element.rPr.rFonts.set(qn('w:eastAsia'),'Microsoft YaHei')
title=doc.add_paragraph(); title.alignment=WD_ALIGN_PARAGRAPH.CENTER
run=title.add_run('工业智核APS柔性排产报告'); run.bold=True; run.font.size=Pt(24); run.font.color.rgb=RGBColor(35,45,55)
sub=doc.add_paragraph(); sub.alignment=WD_ALIGN_PARAGRAPH.CENTER
sub.add_run(f"版本号:{ver.get('versionNo','-')} 生成日期:{datetime.now():%Y-%m-%d}\n").bold=True
sub.add_run("本报告涵盖订单交付、MRP 分解、排产工单与 MES 下发情况")
doc.add_paragraph('\n')
heading('总体概览')
solve=str(ver.get('solveStatus') or '-'); status=str(ver.get('status') or '-')
on_time=int(ver.get('onTimeCount') or 0); total=max(len(orders), int(ver.get('orderCount') or 0)); rate=(on_time/total*100 if total else 0)
summary=(f"本次排产版本 {ver.get('versionNo','-')},求解状态 {solve},当前状态 {status}。"
f"订单 {total} 张,虚拟产线 {len(vls)} 条,工单 {len(wos)} 张,准时可交付 {on_time} 张,"
f"准时交付率 {rate:.1f}%,未决冲突 {len(conflicts)} 项。")
doc.add_paragraph(summary)
conclusion='排产结果可行,具备 MES 下发条件。' if solve=='FEASIBLE' and len(wos)>0 else '存在不可行或待确认项,请处理后再下发。'
p=doc.add_paragraph(); p.add_run('结论:').bold=True; p.add_run(conclusion)
heading('关键指标总览')
table(['指标','数值','说明'],[
['版本号',ver.get('versionNo'), '当前排产版本'],['求解状态',solve,'可行' if solve=='FEASIBLE' else '不可行'],
['订单总数',total,'纳入统计口径'],['需求总数',ver.get('demandCount',0),f"准入 {ver.get('admittedDemandCount',0)} / 未排 {ver.get('unscheduledDemandCount',0)}"],
['虚拟产线',len(vls),'动态组建的产线数'],['工单总数',len(wos),'MES 下发候选数'],
['准时交付率',f'{rate:.1f}%','全部准时' if rate==100 else '存在延期风险'],['平均利用率',f"{float(ver.get('avgUtilization') or 0):.2f}%",'设备工时利用率'],
['未决冲突数',len(conflicts),'需处理' if conflicts else '无遗留'],['版本状态',status,'已发布' if status=='PUBLISHED' else '未发布'],
],[4.2,4.2,9.5])
heading('订单交付明细')
order_rows=[]
for vl in sorted(vls,key=lambda x:str(x.get('orderNo'))):
no=str(vl.get('orderNo') or '-'); o=order_map.get(no,{})
due=str(o.get('dueDate') or o.get('deliveryDate') or '-')[:10]; end=dt(vl.get('plannedEnd'))
judgement='准时' if due!='-' and end[:10] <= due else ('存在延期' if due!='-' else '无交期')
order_rows.append([no,o.get('productCode') or vl.get('productCode'),o.get('quantity') or vl.get('quantity'),due,dt(vl.get('plannedStart')),end,judgement])
table(['订单号','产品','数量','交期','计划开始','计划完成','判定'],order_rows,[2.5,3.2,1.4,2.2,2.8,2.8,2])
heading('订单 MRP 分解情况')
demands=world.get('manufacturingDemands') or []; supply=world.get('supplyEvents') or []
trusted=sum(1 for r in supply if str(r.get('trust') or r.get('status') or '').upper() in {'TRUSTED','CONFIRMED','AVAILABLE'} or r.get('trusted') is True)
doc.add_paragraph(f"制造需求 {len(demands)} 条,供给事件 {len(supply)} 条,其中可信供给(已确认/可用){trusted} 条,其余需采购或委外跟进确认。")
doc.add_paragraph('需求与供给的匹配以物料齐套检查为准,缺料项已在未决冲突清单中列示。')
heading('工序与设备负荷')
op_minutes={}; eq_minutes={}
for wo in wos:
op=str(wo.get('operationCode') or wo.get('operationName') or '-'); eq=str(wo.get('equipmentCode') or '-')
minutes=float(wo.get('runMin') or 0)+float(wo.get('changeoverMin') or 0)+float(wo.get('moveMin') or 0)
op_minutes[op]=op_minutes.get(op,0)+minutes; eq_minutes[eq]=eq_minutes.get(eq,0)+minutes
table(['工序','占用工时(分)','备注'],[[k,f'{v:.1f}','瓶颈工序' if v==max(op_minutes.values() or [0]) else '正常'] for k,v in sorted(op_minutes.items(),key=lambda x:-x[1])],[4.5,4.5,8.5])
top_eq=sorted(eq_minutes.items(),key=lambda x:-x[1])[:10]
table(['设备编码','设备名称','占用工时(分)','状态'],[[c,equipment.get(c,{}).get('name') or c,f'{m:.1f}',equipment.get(c,{}).get('status','RUNNING')] for c,m in top_eq],[3,5.5,4,4])
heading('排产工单明细')
wo_rows=[]
for wo in sorted(wos,key=lambda x:(str(x.get('plannedStartTime') or ''),int(x.get('seq') or 0))):
wo_rows.append([wo.get('flexOrderNo') or wo.get('orderNo'),wo.get('seq'),wo.get('operationName') or wo.get('operationCode'),wo.get('equipmentCode'),dt(wo.get('plannedStartTime')),dt(wo.get('plannedEndTime')),wo.get('status','PLANNED')])
table(['订单号','序号','工序','设备','计划开始','计划完成','状态'],wo_rows,[2.6,1.2,3,2.6,3,3,2])
heading('未决冲突清单')
if conflicts:
table(['类型','订单号','严重度','描述','建议方案'],[[c.get('conflictType') or c.get('type'),c.get('orderNo'),c.get('severity'),c.get('description'),c.get('suggestedSolution') or c.get('suggestion')] for c in conflicts[:50]],[3,2.5,2,6,6])
else:
doc.add_paragraph('当前版本无未决冲突,具备 MES 下发条件。')
for text in ['排产结果由规则与约束引擎计算得出,数字未经大模型改写','关键写操作均需通过 P2 确认卡人工放行','全链路操作留痕,可在审计台账中回放校验']:
doc.add_paragraph(text,style='List Bullet')
heading('发布与 MES 下发状态')
table(['检查项','结果','说明'],[
['版本状态',status,'PUBLISHED 表示已正式发布'],['求解状态','可行' if ver.get('solveStatus')=='FEASIBLE' else '不可行','不可行版本禁止下发'],
['MES 下发工单',len(wos),f'已建 MES 关联 {len(mes_links)} 条'],['审计留痕','完整','全流程哈希链可校验'],
],[4.5,3.5,10])
heading('下一步建议')
actions=['确认关键订单交期并处理未决冲突','核对瓶颈工序与设备负荷是否可接受','通过 P2 确认卡正式发布本排产版本','确认物料齐套与采购到货后再下发 MES','下发后跟踪报工回流并关注进度偏差']
for a in actions: doc.add_paragraph(a,style='List Number')
footer=sec.footer.paragraphs[0]; footer.alignment=WD_ALIGN_PARAGRAPH.CENTER; footer.add_run('工业智核APS柔性排产报告 | APS Agent 自动生成')
buf=BytesIO(); doc.save(buf)
scope=(order_no or '全厂').replace('/','-'); filename=f"工业智核APS柔性排产报告_{scope}_{ver.get('versionNo','未命名')}.docx"
return {'docxBytes':buf.getvalue(),'filename':filename,'title':'工业智核APS柔性排产报告','snapshot':{'versionId':vid,'versionNo':ver.get('versionNo'),'woCount':len(wos),'orderCount':total}}
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}