2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 报告生成 v1(moduleId: domain-reports, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# plan.md §9.10:M3 落地——排产日报 + 版本对比 + 排产方案报告。
|
2026-07-21 11:05:57 +08:00
|
|
|
|
# 生成管线:冻结数据快照 → 结构化章节模板(确定性,数字全部来自快照)
|
|
|
|
|
|
# → 证据链脚注 → 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}}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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]
|
2026-08-20 11:39:21 +08:00
|
|
|
|
all_cfs = [c for c in (world.get("flexConflicts") or [])
|
|
|
|
|
|
if c.get("versionId") == vid and not c.get("isResolved")]
|
|
|
|
|
|
cfs = list(all_cfs)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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]
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if not wos and not cfs:
|
|
|
|
|
|
cfs = list(all_cfs)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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,
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"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),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
if not wos:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
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"
|
2026-07-23 13:38:43 +08:00
|
|
|
|
return {
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"reportId": report_id,
|
|
|
|
|
|
"title": f"排产阻断分析报告 {scope} {snap['versionNo']}",
|
|
|
|
|
|
"markdown": "\n".join(lines),
|
|
|
|
|
|
"snapshot": snap,
|
|
|
|
|
|
"xlsxBytes": buffer.getvalue(),
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"format": "xlsx",
|
|
|
|
|
|
"blocked": True,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 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]:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""Persist a report inside the current tenant/project world directory."""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
import os
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.state.store import get_store
|
|
|
|
|
|
root = os.path.join(os.path.dirname(get_store().path), "exports")
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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":
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return build_daily_report(world)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if report_type == "version-diff":
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return build_version_diff_report(world)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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}
|