# ============================================================ # 报告生成 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 _deterministic_xlsx_bytes(payload: bytes) -> bytes: """Normalize ZIP metadata so identical workbooks have identical bytes.""" from io import BytesIO from xml.etree import ElementTree from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo source = BytesIO(payload) output = BytesIO() with ZipFile(source, "r") as archive, ZipFile( output, "w", compression=ZIP_DEFLATED, compresslevel=9, ) as normalized: for member in archive.infolist(): content = archive.read(member.filename) if member.filename == "docProps/core.xml": namespaces = { "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "dc": "http://purl.org/dc/elements/1.1/", "dcterms": "http://purl.org/dc/terms/", "dcmitype": "http://purl.org/dc/dcmitype/", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } for prefix, uri in namespaces.items(): ElementTree.register_namespace(prefix, uri) root = ElementTree.fromstring(content) fixed = "2000-01-01T00:00:00Z" for tag in ("created", "modified"): node = root.find(f"{{{namespaces['dcterms']}}}{tag}") if node is not None: node.text = fixed content = ElementTree.tostring( root, encoding="utf-8", xml_declaration=True, ) info = ZipInfo(member.filename, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = ZIP_DEFLATED info.create_system = 0 info.external_attr = member.external_attr info.internal_attr = member.internal_attr info.comment = member.comment normalized.writestr( info, content, compress_type=ZIP_DEFLATED, compresslevel=9, ) return output.getvalue() 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} _SCHEDULE_MODE_CN = { "ASC": "正排", "DESC": "倒排", "BOTTLENECK": "瓶颈锚", "KITTING_FIRST": "齐套优先", "SKILL_FIRST": "技能优先", } class ScheduleExportVersionError(ValueError): """Raised when an explicitly requested schedule export version is invalid.""" def _same_schedule_id(left: Any, right: Any) -> bool: try: return int(left) == int(right) except (TypeError, ValueError): return str(left) == str(right) def _select_flex_schedule_version( world: dict[str, Any], version_id: int | None, ) -> dict[str, Any] | None: versions = world.get("flexScheduleVersions") or [] if version_id is None: return versions[-1] if versions else None for version in versions: if _same_schedule_id(version.get("id"), version_id): return version if any( _same_schedule_id(version.get("id"), version_id) for version in (world.get("scheduleVersions") or []) ): raise ScheduleExportVersionError( f"排产版本 {version_id} 属于固定轨,当前导出仅支持柔性轨版本。" ) raise ScheduleExportVersionError(f"柔性轨排产版本 {version_id} 不存在。") def _schedule_scope(world: dict[str, Any], version_id: int, order_no: str | None) -> tuple[list[dict[str, Any]], dict[str, Any]]: """取指定排产版本的工单子集(确定性排序)与订单索引;order_no 过滤时只留该订单。""" orders = {o["orderNo"]: o for o in (world.get("flexOrders") or [])} wos = [w for w in (world.get("flexWorkOrders") or []) if w.get("versionId") == version_id] target = (order_no or "").strip() or None if target: wos = [w for w in wos if str(w.get("flexOrderNo") or w.get("orderNo")) == target] wos.sort(key=lambda x: (str(x.get("flexOrderNo") or ""), int(x.get("seq") or 0), str(x.get("plannedStartTime") or ""), str(x.get("orderNo") or ""))) return wos, orders def build_schedule_order_export( world: dict[str, Any], order_no: str | None = None, version_id: int | None = None, ) -> dict[str, Any]: """工单角度调度方案 xlsx:订单/工单/工序/资源/起止/状态/预计数量/实际数量。""" from io import BytesIO from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from server.timeutil import today0 ver = _select_flex_schedule_version(world, version_id) if ver is None: return {"reportId": None, "title": "排产工单方案", "markdown": "尚无排产版本,请先跑一版柔性排产再导出。", "snapshot": None, "xlsxBytes": None, "filename": None, "format": None} vid = ver["id"] wos, _ = _schedule_scope(world, vid, order_no) scope = f"订单 {(order_no or '').strip()}" if (order_no or "").strip() else "全部订单" mode = ver.get("sortMode") or "BOTTLENECK" mode_cn = _SCHEDULE_MODE_CN.get(mode, mode) wb = Workbook() wb.properties.created = wb.properties.modified = today0() 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") ws = wb.active ws.title = "排产工单" ws["A1"] = f"排产工单方案 · {scope}" ws["A1"].font = title_font ws.merge_cells("A1:L1") ws["A2"] = f"版本 {ver.get('versionNo')}({mode_cn}) · 工单 {len(wos)} 道" ws.merge_cells("A2:L2") 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(wos, 5): vals = [ w.get("flexOrderNo") or w.get("orderNo"), w.get("orderNo"), w.get("productCode"), w.get("operationCode"), w.get("operationName"), w.get("equipmentCode"), w.get("equipmentName") or w.get("equipmentCode"), w.get("plannedStartTime"), w.get("plannedEndTime"), w.get("status") or "PLANNED", w.get("quantity") or 0, w.get("qtyDone") or 0, ] 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") for col, wth in enumerate([14, 18, 14, 12, 22, 12, 22, 18, 18, 12, 10, 10], 1): ws.column_dimensions[get_column_letter(col)].width = wth ws.auto_filter.ref = f"A4:{get_column_letter(len(headers))}{4 + len(wos)}" ws.freeze_panes = "A5" ws2 = wb.create_sheet("方案概览") ws2["A1"] = "方案概览" ws2["A1"].font = title_font order_count = len({w.get("flexOrderNo") for w in wos}) for r, (k, v) in enumerate([ ("版本号", ver.get("versionNo")), ("排产模式", mode_cn), ("范围", scope), ("工单数", len(wos)), ("订单数", order_count), ], 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 = 30 buf = BytesIO() wb.save(buf) report_id = uuid.uuid4().hex[:10] safe_scope = ((order_no or "全部").strip().replace("/", "-") or "全部") filename = f"排产工单方案_{safe_scope}_{ver.get('versionNo')}.xlsx" return { "reportId": report_id, "title": f"排产工单方案 {scope} {ver.get('versionNo')}", "markdown": f"# 排产工单方案\n\n版本 {ver.get('versionNo')}({mode_cn})· 工单 {len(wos)} 道。", "snapshot": {"versionId": vid, "versionNo": ver.get("versionNo"), "sortMode": mode, "woCount": len(wos), "orderCount": order_count, "orderNo": (order_no or "").strip() or None}, "xlsxBytes": _deterministic_xlsx_bytes(buf.getvalue()), "filename": filename, "format": "xlsx", } def build_schedule_equipment_export( world: dict[str, Any], order_no: str | None = None, version_id: int | None = None, ) -> dict[str, Any]: """设备角度调度方案 xlsx:设备/工序/占用起止/负荷分钟/利用率。""" from datetime import timedelta from io import BytesIO from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from server.timeutil import parse_dt, today0 ver = _select_flex_schedule_version(world, version_id) if ver is None: return {"reportId": None, "title": "排产设备方案", "markdown": "尚无排产版本,请先跑一版柔性排产再导出。", "snapshot": None, "xlsxBytes": None, "filename": None, "format": None} vid = ver["id"] wos, _ = _schedule_scope(world, vid, order_no) scope = f"订单 {(order_no or '').strip()}" if (order_no or "").strip() else "全部订单" mode = ver.get("sortMode") or "BOTTLENECK" mode_cn = _SCHEDULE_MODE_CN.get(mode, mode) eq_by_id = {e["id"]: e for e in (world.get("flexEquipment") or [])} cal = (world.get("flexCalendar") or [{}])[0] shift_min = 480.0 try: s0 = parse_dt(f"2000-01-01 {cal.get('startTime', '08:00')}") s1 = parse_dt(f"2000-01-01 {cal.get('endTime', '17:00')}") shift_min = (s1 - s0).total_seconds() / 60 for br in cal.get("breaks") or []: b0 = parse_dt(f"2000-01-01 {br['start']}") b1 = parse_dt(f"2000-01-01 {br['end']}") shift_min -= (b1 - b0).total_seconds() / 60 except (ValueError, TypeError, KeyError, IndexError): shift_min = 480.0 shift_min = max(shift_min, 60.0) workdays = set(cal.get("workdays") or [1, 2, 3, 4, 5]) load_by_eq: dict[int, float] = {} rows = [] for w in wos: minutes = float(w.get("changeoverMin") or 0) + float(w.get("moveMin") or 0) + float(w.get("runMin") or 0) eid = w.get("equipmentId") load_by_eq[eid] = load_by_eq.get(eid, 0.0) + minutes rows.append({ "equipmentCode": w.get("equipmentCode"), "equipmentName": w.get("equipmentName") or w.get("equipmentCode"), "operationCode": w.get("operationCode"), "operationName": w.get("operationName"), "start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"), "minutes": round(minutes, 1), "equipmentId": eid, }) rows.sort(key=lambda x: (str(x["equipmentCode"] or ""), str(x["start"] or ""), str(x["operationCode"] or ""))) start_dates = [str(r["start"])[:10] for r in rows if r.get("start")] end_dates = [str(r["end"])[:10] for r in rows if r.get("end")] days = 1 if start_dates and end_dates: try: d0 = parse_dt(min(start_dates) + " 00:00") d1 = parse_dt(max(end_dates) + " 00:00") span = (d1 - d0).days + 1 days = max(1, sum(1 for i in range(span) if (d0 + timedelta(days=i)).isoweekday() in workdays)) except (ValueError, TypeError, KeyError, IndexError): days = 1 util_by_eq: dict[int, float] = {} for eid, minutes in load_by_eq.items(): avail = shift_min * float((eq_by_id.get(eid) or {}).get("availabilityRate") or 1) * days util_by_eq[eid] = round((minutes / avail * 100) if avail > 0 else 0.0, 1) wb = Workbook() wb.properties.created = wb.properties.modified = today0() 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") ws = wb.active ws.title = "设备负荷" ws["A1"] = f"排产设备方案 · {scope}" ws["A1"].font = title_font ws.merge_cells("A1:H1") ws["A2"] = f"版本 {ver.get('versionNo')}({mode_cn}) · 工单 {len(rows)} 道" ws.merge_cells("A2:H2") 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, r in enumerate(rows, 5): vals = [ r["equipmentCode"], r["equipmentName"], r["operationCode"], r["operationName"], r["start"], r["end"], r["minutes"], util_by_eq.get(r["equipmentId"], 0.0), ] 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") for col, wth in enumerate([14, 24, 12, 22, 18, 18, 12, 10], 1): ws.column_dimensions[get_column_letter(col)].width = wth ws.auto_filter.ref = f"A4:{get_column_letter(len(headers))}{4 + len(rows)}" ws.freeze_panes = "A5" ws2 = wb.create_sheet("方案概览") ws2["A1"] = "方案概览" ws2["A1"].font = title_font for r, (k, v) in enumerate([ ("版本号", ver.get("versionNo")), ("排产模式", mode_cn), ("范围", scope), ("工单数", len(rows)), ("设备数", len({r["equipmentCode"] for r in rows})), ("负荷分钟合计", round(sum(r["minutes"] for r in rows), 1)), ], 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 = 30 buf = BytesIO() wb.save(buf) report_id = uuid.uuid4().hex[:10] safe_scope = ((order_no or "全部").strip().replace("/", "-") or "全部") filename = f"排产设备方案_{safe_scope}_{ver.get('versionNo')}.xlsx" return { "reportId": report_id, "title": f"排产设备方案 {scope} {ver.get('versionNo')}", "markdown": f"# 排产设备方案\n\n版本 {ver.get('versionNo')}({mode_cn})· 工单 {len(rows)} 道。", "snapshot": {"versionId": vid, "versionNo": ver.get("versionNo"), "sortMode": mode, "woCount": len(rows), "equipmentCount": len({r["equipmentCode"] for r in rows}), "loadMinutes": round(sum(r["minutes"] for r in rows), 1), "orderNo": (order_no or "").strip() or None}, "xlsxBytes": _deterministic_xlsx_bytes(buf.getvalue()), "filename": filename, "format": "xlsx", } 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}