462 lines
19 KiB
Python
462 lines
19 KiB
Python
"""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 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(),
|
||
}
|
||
|
||
|
||
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()
|
||
]
|