aps-agent/server/aps_domain/report_contracts.py

365 lines
15 KiB
Python
Raw Normal View History

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