aps-agent/server/aps_domain/reports.py

1092 lines
55 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 报告生成 v1(moduleId: domain-reports, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py)
# 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 _cross_platform_row_height(text: Any) -> int:
"""Height that keeps wrapped Chinese notes fully readable on Windows/Excel."""
width = sum(1 if ord(char) < 128 else 2 for char in str(text or ""))
return min(105, max(15, (width // 80 + 1) * 15))
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 _contract_style(*, header: bool = True, blocked: bool = False) -> tuple[Any, ...]:
"""Shared workbook styling for every contract-declared sheet."""
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from server.aps_domain.report_contracts import BLOCKED_HEADER_STYLE, TEMPLATE_HEADER_STYLE
palette = BLOCKED_HEADER_STYLE if blocked else TEMPLATE_HEADER_STYLE
thin = Border(left=Side(style="thin", color=palette["borderColor"]),
right=Side(style="thin", color=palette["borderColor"]),
top=Side(style="thin", color=palette["borderColor"]),
bottom=Side(style="thin", color=palette["borderColor"]))
head_fill = PatternFill("solid", fgColor=palette["fillColor"])
head_font = Font(color=palette["fontColor"], bold=True, size=palette["fontSize"])
title_font = Font(bold=True, size=14, color="1F4E79")
return thin, head_fill, head_font, title_font, Alignment
def _write_contract_header(sheet: Any, spec: Any, *, blocked: bool = False) -> None:
thin, head_fill, head_font, _, Alignment = _contract_style(blocked=blocked)
for column, header in enumerate(spec.headers, 1):
cell = sheet.cell(spec.header_row, column, header)
cell.fill = head_fill
cell.font = head_font
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = thin
def _write_contract_sheet(wb: Any, spec: Any, rows: list[dict[str, Any]], *,
blocked: bool = False) -> Any:
from openpyxl.utils import get_column_letter
thin, _, _, title_font, Alignment = _contract_style(blocked=blocked)
sheet = wb.active if wb.active.max_row == 1 and wb.active.max_column == 1 else wb.create_sheet()
sheet.title = spec.name
sheet.cell(spec.title_row, 1, spec.title).font = title_font
if spec.title_row >= 2:
sheet.cell(spec.title_row + 1, 1, "")
_write_contract_header(sheet, spec, blocked=blocked)
last_column = max(1, spec.last_column_index)
for offset, row in enumerate(rows):
for column_index, column in enumerate(spec.columns, 1):
value = row.get(column.key)
cell = sheet.cell(spec.data_start_row + offset, column_index, value if value is not None else "")
cell.border = thin
cell.alignment = Alignment(vertical="center")
for column_index in range(1, last_column + 1):
longest = 0
for row_index in range(1, spec.data_start_row + len(rows)):
value = sheet.cell(row_index, column_index).value
if value is None:
continue
longest = max(longest, sum(1 if ord(char) < 128 else 2 for char in str(value)))
sheet.column_dimensions[get_column_letter(column_index)].width = min(42, max(10, longest + 4))
if spec.auto_filter:
sheet.auto_filter.ref = (
f"A{spec.header_row}:{get_column_letter(last_column)}{spec.header_row + len(rows)}"
)
if spec.freeze_panes is not None:
sheet.freeze_panes = spec.freeze_panes
return sheet
def _write_overview_sheet(wb: Any, spec: Any, title: str, rows: list[tuple[str, Any]], *,
title_row: int = 1) -> Any:
from openpyxl.styles import Alignment, Font
from openpyxl.utils import get_column_letter
sheet = wb.create_sheet(spec.name)
if title:
sheet.cell(title_row, 1, title).font = Font(bold=True, size=14, color="1F4E79")
for index, (label, value) in enumerate(rows, spec.data_start_row):
sheet.cell(index, 1, label).font = Font(bold=True)
sheet.cell(index, 2, value if value is not None else "")
longest_label = max((sum(1 if ord(char) < 128 else 2 for char in str(label)) for label, _ in rows), default=8)
longest_value = max((sum(1 if ord(char) < 128 else 2 for char in str(value)) for _, value in rows), default=8)
sheet.column_dimensions[get_column_letter(1)].width = min(30, max(10, longest_label + 4))
sheet.column_dimensions[get_column_letter(2)].width = min(42, max(12, longest_value + 4))
for index in range(spec.data_start_row, spec.data_start_row + len(rows)):
sheet.cell(index, 1).alignment = Alignment(vertical="center")
return sheet
def _contract_rows(source_rows: list[dict[str, Any]], mappings: dict[str, Any]) -> list[dict[str, Any]]:
"""Project business rows onto the contract's physical columns."""
projected = []
for row in source_rows:
projected.append({key: mapper(row) for key, mapper in mappings.items()})
return projected
2026-07-21 11:05:57 +08:00
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 Font
from openpyxl.utils import get_column_letter
from server.aps_domain.report_contracts import (
PLAN_EMPTY_STATE, PLAN_REPORT_CONTRACT, SCHEDULE_BLOCKED_CONTRACT,
validate_workbook_contract,
)
from server.timeutil import today0
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": PLAN_EMPTY_STATE["message"],
"snapshot": None, "xlsxBytes": None, "filename": None,
"contract": PLAN_EMPTY_STATE,
}
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 = _write_contract_sheet(
workbook,
SCHEDULE_BLOCKED_CONTRACT.sheets["blocked"],
_contract_rows(
blocker_rows,
{
"conflictType": lambda row: row.get("conflictType") or row.get("type"),
"subject": lambda row: (row.get("orderNo") or row.get("resourceName")
or row.get("resourceType")),
"severity": lambda row: row.get("severity") or "HARD",
"description": lambda row: row.get("description"),
"suggestion": lambda row: row.get("suggestedSolution") or row.get("suggestion"),
},
),
blocked=True,
)
sheet["A2"] = f"版本 {snap['versionNo']} · {scope} · {snap.get('solveStatus') or 'BLOCKED'}"
buffer = BytesIO()
workbook.save(buffer)
payload = _deterministic_xlsx_bytes(buffer.getvalue())
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": payload,
"filename": filename,
"format": "xlsx",
"blocked": True,
"templateId": SCHEDULE_BLOCKED_CONTRACT.id,
"templateSchemaVersion": SCHEDULE_BLOCKED_CONTRACT.schema_version,
}
# ---- 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 工作计划表(模板合同 schedule-plan.v1) ----
wb = Workbook()
plan_spec = PLAN_REPORT_CONTRACT.sheets["plan"]
plan_rows = _contract_rows(
wo_rows,
{
"orderNo": lambda row: row.get("orderNo"),
"productCode": lambda row: row.get("productCode"),
"quantity": lambda row: row.get("quantity"),
"dueDate": lambda row: row.get("dueDate"),
"seq": lambda row: row.get("seq"),
"operationCode": lambda row: row.get("operationCode"),
"operationName": lambda row: row.get("operationName"),
"equipmentName": lambda row: row.get("equipmentName"),
"equipmentCode": lambda row: row.get("equipmentCode"),
"zone": lambda row: row.get("zone"),
"moldCode": lambda row: row.get("moldCode"),
"changeoverMin": lambda row: row.get("changeoverMin"),
"moveMin": lambda row: row.get("moveMin"),
"runMin": lambda row: row.get("runMin"),
"start": lambda row: row.get("start"),
"end": lambda row: row.get("end"),
"isBottleneck": lambda row: "是" if row.get("isBottleneck") else "",
"status": lambda row: row.get("status"),
},
)
ws = _write_contract_sheet(wb, plan_spec, plan_rows)
ws["A1"] = f"排产工作计划表 · {scope}"
ws["A2"] = f"版本 {snap['versionNo']}({mode_cn}) · 生成 {fmt_dt(datetime.now())}"
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=plan_spec.last_column_index)
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=plan_spec.last_column_index)
overview_spec = PLAN_REPORT_CONTRACT.sheets["overview"]
ws2 = wb.create_sheet("方案概览")
ws2["A1"] = "方案概览"
ws2["A1"].font = Font(bold=True, size=14, color="1F4E79")
overview_rows = [
("版本号", snap["versionNo"]),
("排产模式", mode_cn),
("范围", scope),
("虚拟产线数", snap["vlCount"]),
("工单数", snap["woCount"]),
("冲突数", snap["conflictCount"]),
("生成时间", fmt_dt(datetime.now())),
]
for row_index, (label, value) in enumerate(overview_rows, 3):
ws2.cell(row_index, 1, label).font = Font(bold=True)
ws2.cell(row_index, 2, value if value is not None else "")
ws2["A11"] = "订单交期"
ws2["A11"].font = Font(bold=True)
_write_contract_header(ws2, overview_spec)
order_rows = []
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 "延期风险"
order_rows.append({
"orderNo": ono, "productCode": v.get("productCode"), "quantity": v.get("quantity"),
"start": v.get("plannedStart"), "end": v.get("plannedEnd"),
"dueDate": o.get("dueDate"), "onTime": on_time,
})
order_row_index = overview_spec.data_start_row
for row in order_rows:
for column_index, column in enumerate(overview_spec.columns, 1):
value = row.get(column.key)
ws2.cell(order_row_index, column_index, value if value is not None else "")
order_row_index += 1
for column_index, width in enumerate([14, 16, 8, 18, 18, 12, 10], 1):
ws2.column_dimensions[get_column_letter(column_index)].width = width
conflicts_spec = PLAN_REPORT_CONTRACT.sheets["conflicts"]
conflict_rows = _contract_rows(
cfs,
{
"conflictType": lambda row: row.get("conflictType") or row.get("type"),
"orderNo": lambda row: row.get("orderNo"),
"severity": lambda row: row.get("severity"),
"description": lambda row: row.get("description"),
"suggestion": lambda row: row.get("suggestedSolution") or row.get("suggestion"),
},
)
ws3 = _write_contract_sheet(wb, conflicts_spec, conflict_rows)
if not conflict_rows:
ws3.cell(conflicts_spec.data_start_row, 1, "无未解决冲突")
wb.properties.created = wb.properties.modified = today0()
buf = BytesIO()
wb.save(buf)
xlsx_bytes = _deterministic_xlsx_bytes(buf.getvalue())
defects = validate_workbook_contract(xlsx_bytes, PLAN_REPORT_CONTRACT)
if defects:
raise RuntimeError("排产工作计划表不符合 schedule-plan.v1:" + ";".join(defects))
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",
"templateId": PLAN_REPORT_CONTRACT.id,
"templateSchemaVersion": PLAN_REPORT_CONTRACT.schema_version,
}
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.aps_domain.report_contracts import SCHEDULE_ORDER_CONTRACT, validate_workbook_contract
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
order_spec = SCHEDULE_ORDER_CONTRACT.sheets["orders"]
ws.title = order_spec.name
ws["A1"] = f"排产工单方案 · {scope}"
ws["A1"].font = title_font
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=order_spec.last_column_index)
ws["A2"] = f"版本 {ver.get('versionNo')}({mode_cn}) · 工单 {len(wos)} 道"
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=order_spec.last_column_index)
headers = list(order_spec.headers)
_write_contract_header(ws, order_spec)
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(SCHEDULE_ORDER_CONTRACT.sheets["overview"].name)
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)
xlsx_bytes = _deterministic_xlsx_bytes(buf.getvalue())
defects = validate_workbook_contract(xlsx_bytes, SCHEDULE_ORDER_CONTRACT)
if defects:
raise RuntimeError("排产工单方案不符合 schedule-order.v1:" + ";".join(defects))
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,
"templateId": SCHEDULE_ORDER_CONTRACT.id,
"templateSchemaVersion": SCHEDULE_ORDER_CONTRACT.schema_version,
"orderNo": (order_no or "").strip() or None},
"xlsxBytes": xlsx_bytes,
"filename": filename,
"format": "xlsx",
"templateId": SCHEDULE_ORDER_CONTRACT.id,
"templateSchemaVersion": SCHEDULE_ORDER_CONTRACT.schema_version,
}
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.aps_domain.report_contracts import (
SCHEDULE_EQUIPMENT_CONTRACT, validate_workbook_contract,
)
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
equipment_spec = SCHEDULE_EQUIPMENT_CONTRACT.sheets["equipment"]
ws.title = equipment_spec.name
ws["A1"] = f"排产设备方案 · {scope}"
ws["A1"].font = title_font
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=equipment_spec.last_column_index)
ws["A2"] = f"版本 {ver.get('versionNo')}({mode_cn}) · 工单 {len(rows)} 道"
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=equipment_spec.last_column_index)
headers = list(equipment_spec.headers)
_write_contract_header(ws, equipment_spec)
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(SCHEDULE_EQUIPMENT_CONTRACT.sheets["overview"].name)
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)
xlsx_bytes = _deterministic_xlsx_bytes(buf.getvalue())
defects = validate_workbook_contract(xlsx_bytes, SCHEDULE_EQUIPMENT_CONTRACT)
if defects:
raise RuntimeError("排产设备方案不符合 schedule-equipment.v1:" + ";".join(defects))
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),
"templateId": SCHEDULE_EQUIPMENT_CONTRACT.id,
"templateSchemaVersion": SCHEDULE_EQUIPMENT_CONTRACT.schema_version,
"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": xlsx_bytes,
"filename": filename,
"format": "xlsx",
"templateId": SCHEDULE_EQUIPMENT_CONTRACT.id,
"templateSchemaVersion": SCHEDULE_EQUIPMENT_CONTRACT.schema_version,
}
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)
if report_type == "version-diff":
2026-07-21 11:05:57 +08:00
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}