244 lines
14 KiB
Python
244 lines
14 KiB
Python
"""Validated, data-only workbook adapter registry.
|
||
|
||
APS_WORKBOOK_PROFILE_DIR replaces the built-in directory. Profiles describe
|
||
physical layout and vocabularies; supported business roles remain code contracts.
|
||
No profile can load Python, relax confirmation, or declare production readiness.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
BUILTIN_PROFILE_DIR = Path(__file__).parent / "profiles" / "workbooks"
|
||
ROLE_FIELDS = {
|
||
"sourceNotes": ("key", "value"),
|
||
"factoryResources": ("resourceKind", "code", "name", "parentCode", "description", "status"),
|
||
"equipment": ("code", "name", "internalCode", "assetCode", "spec", "capabilities", "zone", "status", "availabilityRate", "sourceText"),
|
||
"personnel": ("code", "name", "department", "role", "teamName", "skills", "skillLevel", "shiftCode", "sourceText"),
|
||
"calendar": ("eventCode", "name", "start", "end", "breaks", "workdaysOrEquipment", "statusOrReason"),
|
||
"products": ("code", "name", "spec", "unit", "type", "sourcingType", "stock", "sourceText"),
|
||
"materials": ("code", "name", "spec", "type", "unit", "sourcingType", "safetyStock", "procurementLeadTime", "sourceText"),
|
||
"bom": ("productCode", "productName", "materialCode", "materialName", "quantity", "isKey", "lossRate"),
|
||
"routing": ("productCode", "productName", "seq", "operationCode", "operationName", "stdTimePerUnit", "sourceText", "requiredCapability", "isExternal"),
|
||
"inventory": ("code", "name", "stock", "inTransit", "safetyStock", "procurementLeadTime", "expectedArrivalDate", "inventoryScenario"),
|
||
"partners": ("partnerType", "code", "name", "level", "contactOrCategory", "phone", "leadTimeText"),
|
||
"orders": ("orderType", "orderNo", "customerName", "customerLevel", "productCode", "productName", "quantity", "orderDate", "dueDate", "priority", "status", "scenario"),
|
||
"wip": ("taskNo", "orderNo", "productCode", "operationName", "equipmentCode", "status", "completedQuantity", "completionTime"),
|
||
"planningParameters": ("key", "delivery", "bottleneck", "description"),
|
||
"sandboxScenario": ("key", "value"),
|
||
"sourceValidation": ("key", "reportedActual", "reportedTarget", "reportedResult"),
|
||
}
|
||
ENUM_VALUES = {
|
||
"yesNo": {True, False}, "enabled": {True, False},
|
||
"resourceKind": {"FACTORY", "WORKSHOP", "ZONE", "OPERATION"},
|
||
"resourceStatus": {"ACTIVE", "INACTIVE"},
|
||
"equipmentStatus": {"RUNNING", "DOWN", "MAINTENANCE", "DISABLED", "INACTIVE"},
|
||
"materialType": {"FINISHED_PRODUCT", "SEMI_FINISHED", "RAW_MATERIAL"},
|
||
"sourcingType": {"MAKE", "PURCHASE", "OUTSOURCE"},
|
||
"orderType": {"FORMAL", "SANDBOX"}, "orderStatus": {"RELEASED", "PENDING_EVALUATION"},
|
||
"wipStatus": {"DONE", "RUNNING", "WAITING", "MATERIAL_BLOCKED", "NOT_STARTED"},
|
||
"partnerType": {"CUSTOMER", "SUPPLIER"}, "calendarEvent": {"MAINTENANCE"},
|
||
}
|
||
METADATA_KEYS = {
|
||
"sourceNotes": {"dataDate", "boundary"},
|
||
"planningParameters": {"sortMode", "horizonDays", "freezeHours", "nightShiftEnabled", "comparisonMetrics"},
|
||
"sandboxScenario": {"orderNo", "productCode", "productName", "quantity", "dueDate", "priority",
|
||
"customerName", "customerLevel", "scenario", "activation"},
|
||
}
|
||
CAPABILITIES = {"complete-planning-workbook", "adoption-review"}
|
||
|
||
|
||
def _unique_object(pairs):
|
||
obj = {}
|
||
for key, value in pairs:
|
||
if key in obj:
|
||
raise ValueError(f"工作簿配置含重复字段:{key}")
|
||
obj[key] = value
|
||
return obj
|
||
|
||
|
||
def _strings(values: Any, label: str, *, allow_empty: bool = False) -> None:
|
||
if not isinstance(values, list) or (not allow_empty and not values) or any(
|
||
not isinstance(v, str) or not v.strip() or v != v.strip() for v in values
|
||
) or len(values) != len(set(values)):
|
||
raise ValueError(f"工作簿配置{label}必须是无重复的非空字符串列表")
|
||
|
||
|
||
def _validate_profile(profile: Any, path: Path) -> dict:
|
||
allowed = {"schemaVersion", "id", "label", "compatibilityDefault", "capabilities", "recognition",
|
||
"planning", "separators", "provenance", "enums", "metadataKeys", "sheets"}
|
||
if not isinstance(profile, dict) or set(profile) != allowed or profile.get("schemaVersion") != 1:
|
||
raise ValueError(f"工作簿配置结构或版本无效:{path.name}")
|
||
if not isinstance(profile["id"], str) or not profile["id"].strip() or not isinstance(profile["label"], str):
|
||
raise ValueError("工作簿配置缺少id或label")
|
||
if not isinstance(profile["compatibilityDefault"], bool):
|
||
raise ValueError("compatibilityDefault必须是布尔值") # noqa: TRY004 - configuration errors share a public ValueError contract
|
||
_strings(profile["capabilities"], "capabilities")
|
||
if set(profile["capabilities"]) != CAPABILITIES:
|
||
raise ValueError("工作簿配置请求了尚未实现的能力")
|
||
sheets = profile["sheets"]
|
||
if not isinstance(sheets, dict) or set(sheets) != set(ROLE_FIELDS):
|
||
raise ValueError("完整工作簿配置必须覆盖全部已支持的业务角色")
|
||
names = []
|
||
for role, fields in ROLE_FIELDS.items():
|
||
spec = sheets[role]
|
||
if not isinstance(spec, dict) or not {"name", "columns"} <= set(spec) <= {"name", "columns", "requiredColumns"}:
|
||
raise ValueError(f"工作簿配置角色{role}结构无效")
|
||
if not isinstance(spec["name"], str) or not spec["name"].strip():
|
||
raise ValueError(f"工作簿配置角色{role}缺工作表名称")
|
||
names.append(spec["name"])
|
||
if not isinstance(spec["columns"], dict) or set(spec["columns"]) != set(fields):
|
||
raise ValueError(f"工作簿配置角色{role}的标准字段不完整或不受支持")
|
||
_strings(list(spec["columns"].values()), role + ".columns")
|
||
# requiredColumns 是 schemaVersion 1 后续补充的合同字段:缺失按“全部可选”加载,
|
||
# 显式声明时逐项校验,避免已部署的旧配置因新增字段整体无法加载。
|
||
spec.setdefault("requiredColumns", [])
|
||
_strings(spec["requiredColumns"], role + ".requiredColumns", allow_empty=True)
|
||
if not set(spec["requiredColumns"]) <= set(spec["columns"]):
|
||
raise ValueError(f"工作簿配置角色{role}的必填列不是已声明字段")
|
||
if len(names) != len(set(names)):
|
||
raise ValueError("多个业务角色不能使用同一张物理工作表")
|
||
recognition = profile["recognition"]
|
||
if not isinstance(recognition, dict) or set(recognition) != {"anchorRoles", "minimumAnchors", "minimumRoles"}:
|
||
raise ValueError("工作簿配置recognition无效")
|
||
_strings(recognition["anchorRoles"], "anchorRoles")
|
||
if not set(recognition["anchorRoles"]) <= set(ROLE_FIELDS):
|
||
raise ValueError("识别锚点包含未知业务角色")
|
||
for key, maximum in (("minimumAnchors", len(recognition["anchorRoles"])), ("minimumRoles", len(ROLE_FIELDS))):
|
||
if type(recognition[key]) is not int or not 1 <= recognition[key] <= maximum:
|
||
raise ValueError(f"识别阈值{key}无效")
|
||
planning = profile["planning"]
|
||
if not isinstance(planning, dict) or set(planning) != {"timeZone", "skillLevelOrder", "trialOnly", "sortModeAliases"}:
|
||
raise ValueError("工作簿配置planning无效")
|
||
try:
|
||
ZoneInfo(planning["timeZone"])
|
||
except (ZoneInfoNotFoundError, TypeError, ValueError) as exc:
|
||
raise ValueError("工作簿配置时区无效") from exc
|
||
_strings(planning["skillLevelOrder"], "skillLevelOrder")
|
||
if planning["trialOnly"] is not True:
|
||
raise ValueError("工作簿配置不能绕过试排和现场确认边界")
|
||
modes = planning["sortModeAliases"]
|
||
if not isinstance(modes, dict) or not modes or any(v not in {"ASC", "BOTTLENECK"} for v in modes.values()):
|
||
raise ValueError("工作簿配置排序模式映射无效")
|
||
enums = profile["enums"]
|
||
if not isinstance(enums, dict) or set(enums) != set(ENUM_VALUES):
|
||
raise ValueError("工作簿配置枚举类型不完整")
|
||
for name, targets in ENUM_VALUES.items():
|
||
aliases = enums[name]
|
||
if not isinstance(aliases, dict) or not aliases or any(
|
||
not isinstance(k, str) or not k.strip() or type(v) not in (bool, str)
|
||
or v not in targets for k, v in aliases.items()
|
||
):
|
||
raise ValueError(f"工作簿配置枚举{name}含未知标准值")
|
||
metadata = profile["metadataKeys"]
|
||
if not isinstance(metadata, dict) or set(metadata) != set(METADATA_KEYS):
|
||
raise ValueError("工作簿配置元数据角色不完整")
|
||
for role, keys in METADATA_KEYS.items():
|
||
aliases = metadata[role]
|
||
if not isinstance(aliases, dict) or set(aliases.values()) != keys or len(aliases) != len(keys):
|
||
raise ValueError(f"工作簿配置元数据{role}字段缺失或有歧义")
|
||
_strings(list(aliases), "metadataKeys." + role)
|
||
if not isinstance(profile["separators"], dict) or set(profile["separators"]) != {"list", "interval"}:
|
||
raise ValueError("工作簿配置分隔符无效")
|
||
_strings(list(profile["separators"].values()), "separators")
|
||
provenance = profile["provenance"]
|
||
if not isinstance(provenance, dict) or set(provenance) != {"demoMarkers", "inventorySource", "defaultTimeSource", "bottleneckMarkers"}:
|
||
raise ValueError("工作簿配置来源规则无效")
|
||
_strings(provenance["demoMarkers"], "demoMarkers", allow_empty=True)
|
||
_strings(provenance["bottleneckMarkers"], "bottleneckMarkers", allow_empty=True)
|
||
if provenance["inventorySource"] not in {"demo", "imported_unconfirmed"} or provenance["defaultTimeSource"] != "imported_unconfirmed":
|
||
raise ValueError("工作簿配置不能将导入资料标成现场已确认")
|
||
digest = hashlib.sha256(json.dumps(profile, sort_keys=True, ensure_ascii=False,
|
||
separators=(",", ":")).encode()).hexdigest()
|
||
return {**profile, "profileDigest": digest}
|
||
|
||
|
||
def load_profiles(directory: str | Path | None = None) -> dict[str, dict]:
|
||
configured = directory if directory is not None else os.environ.get("APS_WORKBOOK_PROFILE_DIR")
|
||
root = Path(configured) if configured is not None else BUILTIN_PROFILE_DIR
|
||
if not root.is_dir():
|
||
raise ValueError(f"工作簿配置目录不存在:{root}")
|
||
profiles = {}
|
||
for file in sorted(root.glob("*.json")):
|
||
if not file.resolve().is_relative_to(root.resolve()) or file.stat().st_size > 1024 * 1024:
|
||
raise ValueError("工作簿配置文件越界或过大")
|
||
try:
|
||
spec = json.loads(file.read_text(encoding="utf-8-sig"), object_pairs_hook=_unique_object)
|
||
profile = _validate_profile(spec, file)
|
||
except (json.JSONDecodeError, UnicodeError) as exc:
|
||
raise ValueError(f"无法解析工作簿配置:{file.name}") from exc
|
||
if profile["id"] in profiles:
|
||
raise ValueError(f"工作簿配置id重复:{profile['id']}")
|
||
profiles[profile["id"]] = profile
|
||
if not profiles:
|
||
raise ValueError("工作簿配置目录没有有效的JSON配置")
|
||
return profiles
|
||
|
||
|
||
def get_profile(profile_id: str) -> dict:
|
||
profile = load_profiles().get(profile_id)
|
||
if profile is None:
|
||
raise ValueError(f"未注册工作簿格式:{profile_id}")
|
||
return profile
|
||
|
||
|
||
def is_profile_supported(profile_id: str | None) -> bool:
|
||
return bool(profile_id and profile_id in load_profiles())
|
||
|
||
|
||
def has_adoption_flow(profile_id: str | None) -> bool:
|
||
return bool(profile_id and "adoption-review" in load_profiles().get(profile_id, {}).get("capabilities", []))
|
||
|
||
|
||
def normalize_resource_kind(value: Any, profile_id: str | None) -> str | None:
|
||
if value in ENUM_VALUES["resourceKind"]:
|
||
return value
|
||
profile = load_profiles().get(profile_id)
|
||
return profile["enums"]["resourceKind"].get(str(value)) if profile else None
|
||
|
||
|
||
def builtin_compatibility_profile() -> dict:
|
||
matches = [p for p in load_profiles(BUILTIN_PROFILE_DIR).values() if p["compatibilityDefault"]]
|
||
if len(matches) != 1:
|
||
raise ValueError("内置兼容工作簿配置必须唯一")
|
||
return matches[0]
|
||
|
||
|
||
def active_compatibility_profile() -> dict:
|
||
"""无 profileId 的调用(模板下载、合同诊断)也必须跟随部署配置目录。
|
||
|
||
唯一标记 ``compatibilityDefault`` 的配置优先;目录里只注册了一份配置时,
|
||
该配置即为默认。多份配置但默认标记缺失或重复时不猜测,直接报错。
|
||
"""
|
||
profiles = load_profiles()
|
||
defaults = [profile for profile in profiles.values() if profile["compatibilityDefault"]]
|
||
if len(defaults) == 1:
|
||
return defaults[0]
|
||
if not defaults and len(profiles) == 1:
|
||
return next(iter(profiles.values()))
|
||
raise ValueError("工作簿配置目录必须恰好声明一份兼容默认配置(compatibilityDefault)")
|
||
|
||
|
||
def select_profile(sheet_headers: dict[str, list[str]]) -> dict | None:
|
||
candidates = []
|
||
for profile in load_profiles().values():
|
||
sheets = profile["sheets"]
|
||
matched_roles = {role for role, spec in sheets.items() if spec["name"] in sheet_headers}
|
||
recognition = profile["recognition"]
|
||
if len(matched_roles & set(recognition["anchorRoles"])) >= recognition["minimumAnchors"] \
|
||
or len(matched_roles) >= recognition["minimumRoles"]:
|
||
candidates.append(profile)
|
||
if len(candidates) > 1:
|
||
# Identical physical sheets can still be disambiguated by complete
|
||
# column contracts; equally plausible or partial matches fail closed.
|
||
complete = [p for p in candidates if all(spec["name"] in sheet_headers and
|
||
set(spec["columns"].values()) <= set(sheet_headers[spec["name"]]) for spec in p["sheets"].values())]
|
||
if len(complete) == 1:
|
||
return complete[0]
|
||
raise ValueError("工作簿同时匹配多个配置,请明确配置范围后重新核对")
|
||
return candidates[0] if candidates else None
|