"""Generate the data-intake Excel template from the active workbook profile. Sheet names, column names, enum vocabularies and the mapping digest all come from the validated JSON profile. Nothing here is customer specific: switching ``APS_WORKBOOK_PROFILE_DIR`` or ``profile_id`` produces that profile's template and the template round-trips through ``preview_file`` unchanged. """ from __future__ import annotations import hashlib import json from collections.abc import Mapping from datetime import date, datetime from typing import Any from server.importers.workbook_profiles import ( ROLE_FIELDS, active_compatibility_profile, get_profile, ) TEMPLATE_EXAMPLE_MAX_ROWS = 3 TEMPLATE_SHEET_KEY = "intake" _ROLE_KINDS = {role: ("materials" if role == "products" else role) for role in ROLE_FIELDS} _ROLE_LABELS = { "sourceNotes": "资料说明", "factoryResources": "工厂资源", "equipment": "设备", "personnel": "人员技能", "calendar": "班次日历", "products": "产品", "materials": "物料", "bom": "BOM", "routing": "工艺路线", "inventory": "库存与在途", "partners": "客户供应商", "orders": "销售订单", "wip": "在制任务", "planningParameters": "排产参数", "sandboxScenario": "插单场景", "sourceValidation": "数据校验", } _PLACEHOLDERS = { "sourceValidation": { "key": ("示例:数量平衡", "Example: quantity balance"), "reportedActual": ("实际值由现场核对后填写", "Fill the verified actual value"), "reportedTarget": ("目标值由现场核对后填写", "Fill the verified target value"), "reportedResult": ("结果由现场核对后填写", "Fill the verified result"), } } # 采集说明里声明的允许取值来自解析器使用的同一份枚举配置,模板与读取逻辑不允许各写一套。 _ENUM_FIELDS: dict[tuple[str, str], tuple[str, str]] = { ("factoryResources", "resourceKind"): ("resourceKind", "资源类型:"), ("factoryResources", "status"): ("resourceStatus", "资源状态:"), ("equipment", "status"): ("equipmentStatus", "设备状态:"), ("calendar", "statusOrReason"): ("enabled", "班次状态:"), ("products", "type"): ("materialType", "物料类型:"), ("products", "sourcingType"): ("sourcingType", "供应方式:"), ("materials", "type"): ("materialType", "物料类型:"), ("materials", "sourcingType"): ("sourcingType", "供应方式:"), ("bom", "isKey"): ("yesNo", "是否关键:"), ("routing", "isExternal"): ("yesNo", "是否外协:"), ("partners", "partnerType"): ("partnerType", "伙伴类型:"), ("orders", "orderType"): ("orderType", "订单分类:"), ("orders", "status"): ("orderStatus", "订单状态:"), ("wip", "status"): ("wipStatus", "任务状态:"), ("planningParameters", "delivery"): ("enabled", "交期参数:"), ("planningParameters", "bottleneck"): ("enabled", "瓶颈参数:"), } DEFINITION_HEADERS = ( "角色", "角色名称", "工作表", "字段顺序", "业务字段", "是否必填", "类型", "填写要求", "来源说明", ) def _enum_rule(profile: dict, role: str, field: str) -> str | None: entry = _ENUM_FIELDS.get((role, field)) if not entry: return None enum_name, prefix = entry options = profile["enums"].get(enum_name) or {} if not options: return None rendered: list[str] = [] for source, canonical in options.items(): text = str(source) if str(canonical) not in ("", text): text = f"{text}→{canonical}" if text not in rendered: rendered.append(text) return prefix + ";".join(rendered) def _fill_rule(profile: dict, role: str, field: str) -> str: enum_rule = _enum_rule(profile, role, field) if enum_rule: return enum_rule kind = _field_kind(role, field) if kind == "日期/时间": return "日期 YYYY-MM-DD;时间 HH:MM" if kind == "数值": return "数字,不写单位" return "—" def intake_definition_rows(profile: dict) -> list[list[Any]]: """采集说明的逐字段定义:模板与解析器共用同一份 profile。""" rows: list[list[Any]] = [] for role, spec in profile["sheets"].items(): required = set(spec.get("requiredColumns") or ()) for column_index, field in enumerate(spec["columns"], 1): rows.append([ role, _ROLE_LABELS.get(role, role), spec["name"], column_index, field, "必填" if field in required else "可选", _field_kind(role, field), _fill_rule(profile, role, field), f"{spec['columns'][field]}(来源列)", ]) return rows def _style(): from openpyxl.styles import Alignment, Border, Font, PatternFill, Side 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") body = Alignment(vertical="center", wrap_text=False) return thin, head_fill, head_font, title_font, body def build_intake_template( profile_id: str | None = None, *, include_examples: bool = False, world: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Build one profile-driven intake workbook. ``include_examples`` writes the returned row examples next to the required source headings so planners can see the expected shape. Examples are never written unless the caller opts in, and never replace a blank template. """ profile = get_profile(profile_id) if profile_id else active_compatibility_profile() rows = _example_rows(profile, world or {}) if include_examples else {} return _build_workbook(profile, rows) def _reverse_enum(profile: dict, name: str, value: Any) -> str | None: for source, canonical in profile["enums"].get(name, {}).items(): if canonical == value: return source return None def _row_values(profile: dict, role: str, row: Mapping[str, Any]) -> dict[str, Any]: """Reverse-map one imported business row back to physical source columns.""" values: dict[str, Any] = {} spec = profile["sheets"][role] for field in spec["columns"]: value = row.get(field) if role == "factoryResources" and field == "resourceKind": value = _reverse_enum(profile, "resourceKind", row.get("resourceKind")) or row.get("resourceType") elif role == "factoryResources" and field == "status": value = _reverse_enum(profile, "resourceStatus", row.get("status")) elif role == "equipment" and field == "status": value = _reverse_enum(profile, "equipmentStatus", row.get("status")) elif role in ("products", "materials") and field == "type": value = _reverse_enum(profile, "materialType", row.get("type")) elif role in ("products", "materials") and field == "sourcingType": value = _reverse_enum(profile, "sourcingType", row.get("sourcingType")) elif role == "orders" and field == "orderType": value = _reverse_enum(profile, "orderType", row.get("orderType")) elif role == "orders" and field == "status": value = _reverse_enum(profile, "orderStatus", row.get("status")) elif role == "wip" and field == "status": value = _reverse_enum(profile, "wipStatus", row.get("status")) elif role == "partners" and field == "partnerType": value = _reverse_enum(profile, "partnerType", row.get("partnerType")) if isinstance(value, (list, tuple)): value = profile["separators"]["list"].join(str(item) for item in value) if isinstance(value, date) and not isinstance(value, datetime): value = value.isoformat() values[field] = value return values def _example_rows(profile: dict, world: Mapping[str, Any]) -> dict[str, list[dict[str, Any]]]: tables = { "factoryResources": world.get("flexFactoryResources") or [], "equipment": world.get("flexEquipment") or [], "personnel": world.get("flexPersonnel") or [], "calendar": world.get("flexCalendar") or [], "products": [row for row in (world.get("flexMaterials") or []) if row.get("type") == "FINISHED_PRODUCT"], "materials": [row for row in (world.get("flexMaterials") or []) if row.get("type") != "FINISHED_PRODUCT"], "bom": world.get("flexBom") or [], "routing": world.get("flexRoutings") or [], "inventory": world.get("flexMaterials") or [], "partners": world.get("flexPartners") or [], "orders": world.get("flexOrders") or [], "wip": world.get("flexWip") or [], } rows: dict[str, list[dict[str, Any]]] = {} for role in profile["sheets"]: if role in ("sourceNotes", "planningParameters", "sandboxScenario", "sourceValidation"): continue examples = [] for row in (tables.get(role) or [])[:TEMPLATE_EXAMPLE_MAX_ROWS]: examples.append(_row_values(profile, role, row)) rows[role] = examples return rows def _build_workbook(profile: dict, examples: Mapping[str, list[dict[str, Any]]]) -> dict[str, Any]: from io import BytesIO from openpyxl import Workbook from openpyxl.utils import get_column_letter from server.aps_domain.reports import _deterministic_xlsx_bytes from server.timeutil import today0 thin, head_fill, head_font, title_font, body = _style() workbook = Workbook() workbook.properties.created = workbook.properties.modified = today0() intake = workbook.active intake.title = "数据采集说明" intake["A1"] = "APS 排产数据采集模板" intake["A1"].font = title_font intake["A2"] = ( f"配置 {profile['id']} · schemaVersion {profile['schemaVersion']} · " f"映射摘要 {profile['profileDigest']}" ) intake["A3"] = "填写规则:所有工作表表头不要改动;未提供的行留空;缺项必须保持可见,系统不会自动补默认值。" intake["A4"] = "能力与限制:" + "、".join(profile["capabilities"]) for column, header in enumerate(DEFINITION_HEADERS, 1): cell = intake.cell(6, column, header) cell.fill = head_fill cell.font = head_font cell.border = thin for row_index, values in enumerate(intake_definition_rows(profile), 7): for column, value in enumerate(values, 1): cell = intake.cell(row_index, column, value) cell.border = thin for column, width in enumerate([16, 14, 16, 10, 22, 10, 12, 44, 26], 1): intake.column_dimensions[get_column_letter(column)].width = width intake.freeze_panes = "A7" for role, spec in profile["sheets"].items(): sheet = workbook.create_sheet(spec["name"]) for column, field in enumerate(spec["columns"], 1): cell = sheet.cell(1, column, spec["columns"][field]) cell.fill = head_fill cell.font = head_font cell.border = thin for offset, example in enumerate(examples.get(role) or [], 2): for column, field in enumerate(spec["columns"], 1): value = example.get(field) if value in (None, ""): value = _placeholder(profile, role, field) cell = sheet.cell(offset, column, value if value is not None else "") cell.border = thin cell.alignment = body for column, field in enumerate(spec["columns"], 1): sheet.column_dimensions[get_column_letter(column)].width = min( 32, max(10, len(str(spec["columns"][field])) * 2 + 4) ) sheet.freeze_panes = "A2" buffer = BytesIO() workbook.save(buffer) payload = _deterministic_xlsx_bytes(buffer.getvalue()) return { "templateId": f"intake-{profile['id']}.v1", "profileId": profile["id"], "profileDigest": profile["profileDigest"], "schemaVersion": profile["schemaVersion"], "capabilities": list(profile["capabilities"]), "sheetNames": [profile["sheets"][role]["name"] for role in profile["sheets"]], "roleOrder": list(profile["sheets"]), "intakeSheet": "数据采集说明", "rows": {role: len(examples.get(role) or []) for role in profile["sheets"]}, "xlsxBytes": payload, "filename": f"{profile['label']}_{profile['id']}_采集模板.xlsx", "format": "xlsx", "sha256": hashlib.sha256(payload).hexdigest(), } def _field_kind(role: str, field: str) -> str: if (role, field) in _ENUM_FIELDS: return "枚举" if field.lower().endswith(("date", "time")) or field in { "start", "end", "completionTime", "orderDate", "dueDate", "expectedArrivalDate", "shiftCode", }: return "日期/时间" if field in { "quantity", "stock", "inTransit", "safetyStock", "procurementLeadTime", "availabilityRate", "seq", "stdTimePerUnit", "lossRate", "completedQuantity", "priority", "horizonDays", "freezeHours", "reportedActual", "reportedTarget", }: return "数值" if field in {"isKey", "isExternal", "status", "type", "sourcingType", "resourceKind", "partnerType"}: return "枚举/布尔" return "文本" def _placeholder(profile: dict, role: str, field: str) -> str | None: schemas = _PLACEHOLDERS.get(role) if schemas and field in schemas: return schemas[field][0] return None def validate_intake_template(payload: bytes, profile: dict | None = None) -> list[str]: """Round-trip contract check: generated headings must match the profile.""" from io import BytesIO import openpyxl selected = profile or active_compatibility_profile() defects: list[str] = [] try: workbook = openpyxl.load_workbook(BytesIO(payload), read_only=True, data_only=False) except Exception as exc: # noqa: BLE001 - callers need the raw defect message return [f"模板无法打开:{exc}"] try: expected_sheets = [selected["sheets"][role]["name"] for role in selected["sheets"]] actual_sheets = list(workbook.sheetnames) if not actual_sheets or actual_sheets[0] != "数据采集说明": defects.append("模板第一张工作表必须是“数据采集说明”") for name in expected_sheets: if name not in actual_sheets: defects.append(f"模板缺少工作表:{name}") for spec in selected["sheets"].values(): name = spec["name"] if name not in actual_sheets: continue sheet = workbook[name] headers = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1))] expected = list(spec["columns"].values()) if [str(value or "") for value in headers[: len(expected)]] != expected: defects.append(f"{name} 表头与配置不一致") if any(value not in (None, "") for value in headers[len(expected):]): defects.append(f"{name} 存在配置外的列") finally: workbook.close() return defects def template_contract() -> dict[str, Any]: """Machine-readable template contract shared with docs and diagnostics.""" profile = active_compatibility_profile() return { "id": "intake-template.v1", "schemaVersion": 1, "intakeSheet": "数据采集说明", "definitionHeaders": list(DEFINITION_HEADERS), "profileId": profile["id"], "profileSchemaVersion": profile["schemaVersion"], "roleOrder": list(profile["sheets"]), "sheets": [ { "role": role, "name": spec["name"], "columns": list(spec["columns"].values()), "requiredColumns": list(spec.get("requiredColumns") or ()), } for role, spec in profile["sheets"].items() ], } def template_contract_json() -> str: return json.dumps(template_contract(), ensure_ascii=False, indent=2, sort_keys=True)