308 lines
14 KiB
Python
308 lines
14 KiB
Python
"""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"),
|
|
}
|
|
}
|
|
|
|
|
|
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"])
|
|
headers = ["角色", "角色名称", "工作表", "字段顺序", "业务字段", "是否必填", "类型", "来源说明"]
|
|
for column, header in enumerate(headers, 1):
|
|
cell = intake.cell(6, column, header)
|
|
cell.fill = head_fill
|
|
cell.font = head_font
|
|
cell.border = thin
|
|
row_index = 7
|
|
for role, spec in profile["sheets"].items():
|
|
required = set(spec.get("requiredColumns") or ())
|
|
for column_index, field in enumerate(spec["columns"], 1):
|
|
values = [
|
|
role, _ROLE_LABELS.get(role, role), spec["name"], column_index, field,
|
|
"必填" if field in required else "可选", _field_kind(role, field),
|
|
f"{spec['columns'][field]}(来源列)",
|
|
]
|
|
for column, value in enumerate(values, 1):
|
|
cell = intake.cell(row_index, column, value)
|
|
cell.border = thin
|
|
row_index += 1
|
|
for column, width in enumerate([16, 14, 16, 10, 22, 10, 12, 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 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": "数据采集说明",
|
|
"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)
|