"""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 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() ]