aps-agent/server/importers/template_workbook.py

369 lines
16 KiB
Python
Raw Permalink Normal View History

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