2026-09-14 15:40:10 +08:00
|
|
|
|
"""Versioned contracts for every scheduling workbook and intake template.
|
|
|
|
|
|
|
|
|
|
|
|
The contracts are data, not prose: sheet names, header rows, data offsets,
|
|
|
|
|
|
column order, units, required state and the value source of each column are
|
|
|
|
|
|
declared once and reused by the Excel builders and by the verifiers/tests.
|
|
|
|
|
|
Business values never live here.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
TEMPLATE_HEADER_STYLE = {
|
|
|
|
|
|
"fillColor": "1F4E79",
|
|
|
|
|
|
"fontColor": "FFFFFF",
|
|
|
|
|
|
"fontSize": 11,
|
|
|
|
|
|
"borderColor": "D0D5DD",
|
|
|
|
|
|
}
|
|
|
|
|
|
BLOCKED_HEADER_STYLE = {
|
|
|
|
|
|
"fillColor": "C2410C",
|
|
|
|
|
|
"fontColor": "FFFFFF",
|
|
|
|
|
|
"fontSize": 11,
|
|
|
|
|
|
"borderColor": "D0D5DD",
|
|
|
|
|
|
}
|
|
|
|
|
|
OVERVIEW_TITLE_STYLE = {"fontColor": "1F4E79", "fontSize": 14}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class ColumnSpec:
|
|
|
|
|
|
"""One physical column: header text plus its declared meaning."""
|
|
|
|
|
|
|
|
|
|
|
|
key: str
|
|
|
|
|
|
header: str
|
|
|
|
|
|
kind: str
|
|
|
|
|
|
unit: str
|
|
|
|
|
|
required_state: str
|
|
|
|
|
|
source: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SheetSpec:
|
|
|
|
|
|
name: str
|
|
|
|
|
|
title: str
|
|
|
|
|
|
title_row: int
|
|
|
|
|
|
header_row: int
|
|
|
|
|
|
data_start_row: int
|
|
|
|
|
|
freeze_panes: str | None
|
|
|
|
|
|
auto_filter: bool
|
|
|
|
|
|
columns: tuple[ColumnSpec, ...]
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def headers(self) -> tuple[str, ...]:
|
|
|
|
|
|
return tuple(column.header for column in self.columns)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def last_column_index(self) -> int:
|
|
|
|
|
|
return len(self.columns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class ReportContract:
|
|
|
|
|
|
id: str
|
|
|
|
|
|
schema_version: int
|
|
|
|
|
|
report_type: str
|
|
|
|
|
|
label: str
|
|
|
|
|
|
filename_template: str
|
|
|
|
|
|
sheets: Mapping[str, SheetSpec]
|
|
|
|
|
|
primary_sheet: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _columns(*specs: tuple[str, str, str, str, str, str]) -> tuple[ColumnSpec, ...]:
|
|
|
|
|
|
return tuple(ColumnSpec(*spec) for spec in specs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_PLAN_COLUMNS = _columns(
|
|
|
|
|
|
("orderNo", "订单号", "string", "-", "always", "flexWorkOrders.flexOrderNo|orderNo"),
|
|
|
|
|
|
("productCode", "产品编码", "string", "-", "always", "flexWorkOrders.productCode"),
|
|
|
|
|
|
("quantity", "数量", "number", "件", "always", "flexOrders.quantity"),
|
|
|
|
|
|
("dueDate", "交期", "date", "-", "always", "flexOrders.dueDate"),
|
|
|
|
|
|
("seq", "序", "number", "-", "always", "flexWorkOrders.seq"),
|
|
|
|
|
|
("operationCode", "工序编码", "string", "-", "always", "flexWorkOrders.operationCode"),
|
|
|
|
|
|
("operationName", "工序名称", "string", "-", "always", "flexWorkOrders.operationName"),
|
|
|
|
|
|
("equipmentName", "工位/设备", "string", "-", "always", "flexWorkOrders.equipmentName|equipmentCode"),
|
|
|
|
|
|
("equipmentCode", "设备编码", "string", "-", "always", "flexWorkOrders.equipmentCode"),
|
|
|
|
|
|
("zone", "区域", "string", "-", "optional", "flexWorkOrders.zone"),
|
|
|
|
|
|
("moldCode", "模具", "string", "-", "optional", "flexWorkOrders.moldCode"),
|
|
|
|
|
|
("changeoverMin", "换型(分)", "number", "分钟", "always", "flexWorkOrders.changeoverMin"),
|
|
|
|
|
|
("moveMin", "移栽(分)", "number", "分钟", "always", "flexWorkOrders.moveMin"),
|
|
|
|
|
|
("runMin", "加工(分)", "number", "分钟", "always", "flexWorkOrders.runMin"),
|
|
|
|
|
|
("start", "计划开始", "datetime", "-", "always", "flexWorkOrders.plannedStartTime"),
|
|
|
|
|
|
("end", "计划结束", "datetime", "-", "always", "flexWorkOrders.plannedEndTime"),
|
|
|
|
|
|
("isBottleneck", "瓶颈", "boolean", "-", "always", "flexWorkOrders.isBottleneck"),
|
|
|
|
|
|
("status", "状态", "string", "-", "always", "flexWorkOrders.status"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_ORDER_COLUMNS = _columns(
|
|
|
|
|
|
("orderNo", "订单号", "string", "-", "always", "flexWorkOrders.flexOrderNo|orderNo"),
|
|
|
|
|
|
("workOrderNo", "工单号", "string", "-", "always", "flexWorkOrders.orderNo"),
|
|
|
|
|
|
("productCode", "产品编码", "string", "-", "always", "flexWorkOrders.productCode"),
|
|
|
|
|
|
("operationCode", "工序编码", "string", "-", "always", "flexWorkOrders.operationCode"),
|
|
|
|
|
|
("operationName", "工序名称", "string", "-", "always", "flexWorkOrders.operationName"),
|
|
|
|
|
|
("resourceCode", "资源编码", "string", "-", "always", "flexWorkOrders.equipmentCode"),
|
|
|
|
|
|
("resourceName", "资源名称", "string", "-", "always", "flexWorkOrders.equipmentName|equipmentCode"),
|
|
|
|
|
|
("start", "计划开始", "datetime", "-", "always", "flexWorkOrders.plannedStartTime"),
|
|
|
|
|
|
("end", "计划结束", "datetime", "-", "always", "flexWorkOrders.plannedEndTime"),
|
|
|
|
|
|
("status", "状态", "string", "-", "always", "flexWorkOrders.status"),
|
|
|
|
|
|
("plannedQuantity", "预计数量", "number", "件", "always", "flexWorkOrders.quantity"),
|
|
|
|
|
|
("doneQuantity", "实际数量", "number", "件", "always", "flexWorkOrders.qtyDone"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_EQUIPMENT_COLUMNS = _columns(
|
|
|
|
|
|
("equipmentCode", "设备编码", "string", "-", "always", "flexWorkOrders.equipmentCode"),
|
|
|
|
|
|
("equipmentName", "设备名称", "string", "-", "always", "flexWorkOrders.equipmentName|equipmentCode"),
|
|
|
|
|
|
("operationCode", "工序编码", "string", "-", "always", "flexWorkOrders.operationCode"),
|
|
|
|
|
|
("operationName", "工序名称", "string", "-", "always", "flexWorkOrders.operationName"),
|
|
|
|
|
|
("start", "占用开始", "datetime", "-", "always", "flexWorkOrders.plannedStartTime"),
|
|
|
|
|
|
("end", "占用结束", "datetime", "-", "always", "flexWorkOrders.plannedEndTime"),
|
|
|
|
|
|
("loadMinutes", "负荷分钟", "number", "分钟", "always", "derived.runMin+moldMin+moveMin"),
|
|
|
|
|
|
("utilization", "利用率%", "number", "%", "always", "derived.loadMinutes/availableMinutes"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_BLOCKED_COLUMNS = _columns(
|
|
|
|
|
|
("conflictType", "阻断类型", "string", "-", "always", "flexConflicts.conflictType|type"),
|
|
|
|
|
|
("subject", "对象", "string", "-", "optional", "flexConflicts.orderNo|resourceName|resourceType"),
|
|
|
|
|
|
("severity", "严重度", "string", "-", "always", "flexConflicts.severity"),
|
|
|
|
|
|
("description", "说明", "string", "-", "always", "flexConflicts.description"),
|
|
|
|
|
|
("suggestion", "修复建议", "string", "-", "always", "flexConflicts.suggestedSolution|suggestion"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sheet(
|
|
|
|
|
|
name: str,
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
columns: tuple[ColumnSpec, ...],
|
|
|
|
|
|
*,
|
|
|
|
|
|
title_row: int = 1,
|
|
|
|
|
|
header_row: int = 4,
|
|
|
|
|
|
auto_filter: bool = True,
|
|
|
|
|
|
) -> SheetSpec:
|
|
|
|
|
|
return SheetSpec(
|
|
|
|
|
|
name=name,
|
|
|
|
|
|
title=title,
|
|
|
|
|
|
title_row=title_row,
|
|
|
|
|
|
header_row=header_row,
|
|
|
|
|
|
data_start_row=header_row + 1,
|
|
|
|
|
|
freeze_panes=f"A{header_row + 1}",
|
|
|
|
|
|
auto_filter=auto_filter,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PLAN_REPORT_CONTRACT = ReportContract(
|
|
|
|
|
|
id="schedule-plan.v1",
|
|
|
|
|
|
schema_version=1,
|
|
|
|
|
|
report_type="plan",
|
|
|
|
|
|
label="排产工作计划表",
|
|
|
|
|
|
filename_template="排产工作计划_{scope}_{versionNo}.xlsx",
|
|
|
|
|
|
primary_sheet="工作计划",
|
|
|
|
|
|
sheets={
|
|
|
|
|
|
"plan": _sheet("工作计划", "排产工作计划表", _PLAN_COLUMNS),
|
|
|
|
|
|
"overview": SheetSpec(
|
|
|
|
|
|
name="方案概览", title="方案概览", title_row=1, header_row=12, data_start_row=13,
|
|
|
|
|
|
freeze_panes=None, auto_filter=False,
|
|
|
|
|
|
columns=_columns(
|
|
|
|
|
|
("orderNo", "订单号", "string", "-", "always", "flexVirtualLines.orderNo"),
|
|
|
|
|
|
("productCode", "产品", "string", "-", "always", "flexVirtualLines.productCode"),
|
|
|
|
|
|
("quantity", "数量", "number", "件", "always", "flexVirtualLines.quantity"),
|
|
|
|
|
|
("start", "计划开始", "datetime", "-", "always", "flexVirtualLines.plannedStart"),
|
|
|
|
|
|
("end", "计划结束", "datetime", "-", "always", "flexVirtualLines.plannedEnd"),
|
|
|
|
|
|
("dueDate", "交期", "date", "-", "always", "flexOrders.dueDate"),
|
|
|
|
|
|
("onTime", "是否按期", "enum", "-", "always", "derived.end<=dueDate"),
|
|
|
|
|
|
)),
|
|
|
|
|
|
"conflicts": _sheet(
|
|
|
|
|
|
"冲突", "冲突与风险",
|
|
|
|
|
|
_columns(
|
|
|
|
|
|
("conflictType", "类型", "string", "-", "always", "flexConflicts.conflictType|type"),
|
|
|
|
|
|
("orderNo", "订单号", "string", "-", "optional", "flexConflicts.orderNo"),
|
|
|
|
|
|
("severity", "严重度", "string", "-", "always", "flexConflicts.severity"),
|
|
|
|
|
|
("description", "描述", "string", "-", "always", "flexConflicts.description"),
|
|
|
|
|
|
("suggestion", "建议", "string", "-", "always", "flexConflicts.suggestedSolution|suggestion"),
|
|
|
|
|
|
),
|
|
|
|
|
|
header_row=3,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
SCHEDULE_ORDER_CONTRACT = ReportContract(
|
|
|
|
|
|
id="schedule-order.v1",
|
|
|
|
|
|
schema_version=1,
|
|
|
|
|
|
report_type="schedule-order",
|
|
|
|
|
|
label="排产工单方案",
|
|
|
|
|
|
filename_template="排产工单方案_{scope}_{versionNo}.xlsx",
|
|
|
|
|
|
primary_sheet="排产工单",
|
|
|
|
|
|
sheets={
|
|
|
|
|
|
"orders": _sheet("排产工单", "排产工单方案", _ORDER_COLUMNS),
|
|
|
|
|
|
"overview": SheetSpec(
|
|
|
|
|
|
name="方案概览", title="方案概览", title_row=1, header_row=0, data_start_row=3,
|
|
|
|
|
|
freeze_panes=None, auto_filter=False, columns=()),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
SCHEDULE_EQUIPMENT_CONTRACT = ReportContract(
|
|
|
|
|
|
id="schedule-equipment.v1",
|
|
|
|
|
|
schema_version=1,
|
|
|
|
|
|
report_type="schedule-equipment",
|
|
|
|
|
|
label="排产设备方案",
|
|
|
|
|
|
filename_template="排产设备方案_{scope}_{versionNo}.xlsx",
|
|
|
|
|
|
primary_sheet="设备负荷",
|
|
|
|
|
|
sheets={
|
|
|
|
|
|
"equipment": _sheet("设备负荷", "排产设备方案", _EQUIPMENT_COLUMNS),
|
|
|
|
|
|
"overview": SheetSpec(
|
|
|
|
|
|
name="方案概览", title="方案概览", title_row=1, header_row=0, data_start_row=3,
|
|
|
|
|
|
freeze_panes=None, auto_filter=False, columns=()),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
SCHEDULE_BLOCKED_CONTRACT = ReportContract(
|
|
|
|
|
|
id="schedule-blocked.v1",
|
|
|
|
|
|
schema_version=1,
|
|
|
|
|
|
report_type="plan",
|
|
|
|
|
|
label="排产阻断分析",
|
|
|
|
|
|
filename_template="排产阻断分析_{scope}_{versionNo}.xlsx",
|
|
|
|
|
|
primary_sheet="阻断分析",
|
|
|
|
|
|
sheets={"blocked": _sheet("阻断分析", "排产阻断分析", _BLOCKED_COLUMNS)},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
PLAN_EMPTY_STATE = {
|
|
|
|
|
|
"id": "schedule-plan-empty",
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"report_type": "plan",
|
|
|
|
|
|
"label": "排产工作计划表空态",
|
|
|
|
|
|
"xlsxBytes": None,
|
|
|
|
|
|
"filename": None,
|
|
|
|
|
|
"reason": "no-flexible-version",
|
|
|
|
|
|
"message": "尚无排产版本。请先生成柔性排产方案或交期优先试排方案。",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
REPORT_CONTRACTS: Mapping[str, ReportContract] = {
|
|
|
|
|
|
PLAN_REPORT_CONTRACT.id: PLAN_REPORT_CONTRACT,
|
|
|
|
|
|
SCHEDULE_ORDER_CONTRACT.id: SCHEDULE_ORDER_CONTRACT,
|
|
|
|
|
|
SCHEDULE_EQUIPMENT_CONTRACT.id: SCHEDULE_EQUIPMENT_CONTRACT,
|
|
|
|
|
|
SCHEDULE_BLOCKED_CONTRACT.id: SCHEDULE_BLOCKED_CONTRACT,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def contract_for_report_type(report_type: str) -> ReportContract | None:
|
|
|
|
|
|
"""Resolve the primary workbook contract for a report type."""
|
|
|
|
|
|
aliases = {
|
|
|
|
|
|
"plan": PLAN_REPORT_CONTRACT.id,
|
|
|
|
|
|
"schedule-plan": PLAN_REPORT_CONTRACT.id,
|
|
|
|
|
|
"flex-plan": PLAN_REPORT_CONTRACT.id,
|
|
|
|
|
|
"schedule-order": SCHEDULE_ORDER_CONTRACT.id,
|
|
|
|
|
|
"schedule-equipment": SCHEDULE_EQUIPMENT_CONTRACT.id,
|
|
|
|
|
|
}
|
|
|
|
|
|
key = aliases.get(report_type)
|
|
|
|
|
|
return REPORT_CONTRACTS.get(key) if key else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 20:34:48 +08:00
|
|
|
|
def build_contract_template(report_type: str) -> dict[str, Any]:
|
|
|
|
|
|
"""把合同本身导出成一份表头级空白工作簿,供现场核对导出格式。
|
|
|
|
|
|
|
|
|
|
|
|
模板只写标题、表头、冻结窗格与筛选区域,不含任何业务行;生成后立刻用
|
|
|
|
|
|
``validate_workbook_contract`` 复核,因此下载到的模板与真正导出的文件由
|
|
|
|
|
|
同一份声明驱动,不会出现模板和导出对不上的情况。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
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.reports import _deterministic_xlsx_bytes
|
|
|
|
|
|
from server.timeutil import today0
|
|
|
|
|
|
|
|
|
|
|
|
contract = contract_for_report_type(report_type) or REPORT_CONTRACTS.get(report_type)
|
|
|
|
|
|
if contract is None:
|
|
|
|
|
|
raise ValueError(f"未注册的排产模板:{report_type}")
|
|
|
|
|
|
|
|
|
|
|
|
thin = Border(
|
|
|
|
|
|
left=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
|
|
|
|
|
|
right=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
|
|
|
|
|
|
top=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
|
|
|
|
|
|
bottom=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
head_fill = PatternFill("solid", fgColor=TEMPLATE_HEADER_STYLE["fillColor"])
|
|
|
|
|
|
head_font = Font(
|
|
|
|
|
|
color=TEMPLATE_HEADER_STYLE["fontColor"], bold=True, size=TEMPLATE_HEADER_STYLE["fontSize"]
|
|
|
|
|
|
)
|
|
|
|
|
|
title_font = Font(
|
|
|
|
|
|
bold=True, size=OVERVIEW_TITLE_STYLE["fontSize"], color=OVERVIEW_TITLE_STYLE["fontColor"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
workbook = Workbook()
|
|
|
|
|
|
for index, spec in enumerate(contract.sheets.values()):
|
|
|
|
|
|
sheet = workbook.active if index == 0 else workbook.create_sheet()
|
|
|
|
|
|
sheet.title = spec.name
|
|
|
|
|
|
if spec.title_row and spec.title:
|
|
|
|
|
|
sheet.cell(spec.title_row, 1, spec.title).font = title_font
|
|
|
|
|
|
note_row = (spec.title_row or 0) + 1
|
|
|
|
|
|
if note_row and (not spec.header_row or note_row < spec.header_row):
|
|
|
|
|
|
sheet.cell(
|
|
|
|
|
|
note_row, 1,
|
|
|
|
|
|
f"模板 {contract.id} · 表头行 {spec.header_row or '—'} · "
|
|
|
|
|
|
f"数据起始行 {spec.data_start_row} · 表头与列顺序不可改动",
|
|
|
|
|
|
)
|
|
|
|
|
|
if spec.header_row:
|
|
|
|
|
|
for column_index, column in enumerate(spec.columns, 1):
|
|
|
|
|
|
cell = sheet.cell(spec.header_row, column_index, column.header)
|
|
|
|
|
|
cell.fill = head_fill
|
|
|
|
|
|
cell.font = head_font
|
|
|
|
|
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
|
|
|
|
cell.border = thin
|
|
|
|
|
|
sheet.column_dimensions[get_column_letter(column_index)].width = min(
|
|
|
|
|
|
28, max(10, len(column.header) * 2 + 4)
|
|
|
|
|
|
)
|
|
|
|
|
|
if spec.freeze_panes:
|
|
|
|
|
|
sheet.freeze_panes = spec.freeze_panes
|
|
|
|
|
|
if spec.auto_filter:
|
|
|
|
|
|
sheet.auto_filter.ref = (
|
|
|
|
|
|
f"A{spec.header_row}:"
|
|
|
|
|
|
f"{get_column_letter(spec.last_column_index)}{spec.header_row}"
|
|
|
|
|
|
)
|
|
|
|
|
|
workbook.properties.created = workbook.properties.modified = today0()
|
|
|
|
|
|
buffer = BytesIO()
|
|
|
|
|
|
workbook.save(buffer)
|
|
|
|
|
|
payload = _deterministic_xlsx_bytes(buffer.getvalue())
|
|
|
|
|
|
defects = validate_workbook_contract(payload, contract)
|
|
|
|
|
|
if defects:
|
|
|
|
|
|
raise RuntimeError(f"排产模板不符合 {contract.id}:" + ";".join(defects))
|
|
|
|
|
|
return {
|
|
|
|
|
|
"templateId": contract.id,
|
|
|
|
|
|
"contractId": contract.id,
|
|
|
|
|
|
"contractDigest": contract_digest(),
|
|
|
|
|
|
"schemaVersion": contract.schema_version,
|
|
|
|
|
|
"reportType": contract.report_type,
|
|
|
|
|
|
"label": f"{contract.label}模板",
|
|
|
|
|
|
"primarySheet": contract.primary_sheet,
|
|
|
|
|
|
"sheets": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"name": spec.name,
|
|
|
|
|
|
"headerRow": spec.header_row,
|
|
|
|
|
|
"dataStartRow": spec.data_start_row,
|
|
|
|
|
|
"headers": list(spec.headers),
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, spec in contract.sheets.items()
|
|
|
|
|
|
],
|
|
|
|
|
|
"xlsxBytes": payload,
|
|
|
|
|
|
"filename": f"{contract.label}模板_{contract.id}.xlsx",
|
|
|
|
|
|
"format": "xlsx",
|
|
|
|
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
def validate_workbook_contract(payload: bytes, contract: ReportContract) -> list[str]:
|
|
|
|
|
|
"""Verify a generated workbook against the declared contract.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a list of human-readable defects; an empty list means every sheet,
|
|
|
|
|
|
header cell, row offset, freeze pane and auto-filter matches the contract.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
import openpyxl
|
|
|
|
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
|
|
|
|
|
|
|
|
defects: list[str] = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
workbook = openpyxl.load_workbook(BytesIO(payload), read_only=False, data_only=False)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - callers need the raw defect message
|
|
|
|
|
|
return [f"工作簿无法打开:{exc}"]
|
|
|
|
|
|
try:
|
|
|
|
|
|
expected_names = {spec.name for spec in contract.sheets.values()}
|
|
|
|
|
|
actual_names = set(workbook.sheetnames)
|
|
|
|
|
|
for missing in sorted(expected_names - actual_names):
|
|
|
|
|
|
defects.append(f"缺少工作表:{missing}")
|
|
|
|
|
|
for extra in sorted(actual_names - expected_names):
|
|
|
|
|
|
defects.append(f"存在合同外工作表:{extra}")
|
|
|
|
|
|
for key, spec in contract.sheets.items():
|
|
|
|
|
|
if spec.name not in actual_names:
|
|
|
|
|
|
continue
|
|
|
|
|
|
sheet = workbook[spec.name]
|
|
|
|
|
|
if spec.header_row:
|
|
|
|
|
|
for column_index, column in enumerate(spec.columns, 1):
|
|
|
|
|
|
actual = sheet.cell(spec.header_row, column_index).value
|
|
|
|
|
|
if actual != column.header:
|
|
|
|
|
|
defects.append(
|
|
|
|
|
|
f"{key}!{get_column_letter(column_index)}{spec.header_row} "
|
|
|
|
|
|
f"表头应为“{column.header}”,实际为“{actual}”"
|
|
|
|
|
|
)
|
|
|
|
|
|
if spec.freeze_panes is not None and sheet.freeze_panes != spec.freeze_panes:
|
|
|
|
|
|
defects.append(f"{key} 冻结窗格应为 {spec.freeze_panes},实际为 {sheet.freeze_panes}")
|
|
|
|
|
|
if spec.auto_filter and sheet.auto_filter.ref is None:
|
|
|
|
|
|
defects.append(f"{key} 缺少自动筛选区域")
|
|
|
|
|
|
if spec.title_row and spec.title:
|
|
|
|
|
|
actual_title = str(sheet.cell(spec.title_row, 1).value or "")
|
|
|
|
|
|
if not actual_title.startswith(spec.title):
|
|
|
|
|
|
defects.append(f"{key}!A{spec.title_row} 标题应以“{spec.title}”开头,实际为“{actual_title}”")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
workbook.close()
|
|
|
|
|
|
return defects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def contract_digest() -> str:
|
|
|
|
|
|
"""Stable digest of the declared scheduling report contracts."""
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
"schemaVersion": contract.schema_version,
|
|
|
|
|
|
"reportType": contract.report_type,
|
|
|
|
|
|
"primarySheet": contract.primary_sheet,
|
|
|
|
|
|
"filenameTemplate": contract.filename_template,
|
|
|
|
|
|
"sheets": {
|
|
|
|
|
|
sheet_key: {
|
|
|
|
|
|
"name": spec.name,
|
|
|
|
|
|
"title": spec.title,
|
|
|
|
|
|
"titleRow": spec.title_row,
|
|
|
|
|
|
"headerRow": spec.header_row,
|
|
|
|
|
|
"dataStartRow": spec.data_start_row,
|
|
|
|
|
|
"freezePanes": spec.freeze_panes,
|
|
|
|
|
|
"autoFilter": spec.auto_filter,
|
|
|
|
|
|
"columns": [
|
|
|
|
|
|
{"key": column.key, "header": column.header, "kind": column.kind,
|
|
|
|
|
|
"unit": column.unit, "required": column.required_state, "source": column.source}
|
|
|
|
|
|
for column in spec.columns
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
for sheet_key, spec in contract.sheets.items()
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, contract in REPORT_CONTRACTS.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
return hashlib.sha256(
|
|
|
|
|
|
json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()
|
|
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def contract_summary() -> Sequence[dict[str, Any]]:
|
|
|
|
|
|
"""Machine-readable summary used by docs, diagnostics and tests."""
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": contract.id,
|
|
|
|
|
|
"schemaVersion": contract.schema_version,
|
|
|
|
|
|
"reportType": contract.report_type,
|
|
|
|
|
|
"label": contract.label,
|
|
|
|
|
|
"primarySheet": contract.primary_sheet,
|
|
|
|
|
|
"sheets": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": spec.name,
|
|
|
|
|
|
"headerRow": spec.header_row,
|
|
|
|
|
|
"dataStartRow": spec.data_start_row,
|
|
|
|
|
|
"columns": [column.header for column in spec.columns],
|
|
|
|
|
|
}
|
|
|
|
|
|
for spec in contract.sheets.values()
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
for contract in REPORT_CONTRACTS.values()
|
|
|
|
|
|
]
|