251 lines
12 KiB
Python
251 lines
12 KiB
Python
"""The same canonical pipeline must work through independent JSON mappings."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import io
|
|
import json
|
|
|
|
import openpyxl
|
|
import pytest
|
|
|
|
from server.aps_domain.importers import apply_import_commit, preview_file
|
|
from server.importers.workbook_profiles import (
|
|
BUILTIN_PROFILE_DIR,
|
|
builtin_compatibility_profile,
|
|
has_adoption_flow,
|
|
is_profile_supported,
|
|
load_profiles,
|
|
normalize_resource_kind,
|
|
)
|
|
from server.state.seed import empty_world
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def original_bytes():
|
|
from tests.workbook_acceptance import source_for_test
|
|
return source_for_test().read_bytes()
|
|
|
|
|
|
def _config():
|
|
profile = copy.deepcopy(builtin_compatibility_profile())
|
|
profile.pop("profileDigest")
|
|
return profile
|
|
|
|
|
|
def _write(directory, config, filename="adapter.json"):
|
|
directory.mkdir(exist_ok=True)
|
|
destination = directory / filename
|
|
destination.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return destination
|
|
|
|
|
|
def _preview(raw):
|
|
return preview_file("any-name.xlsx", raw, empty_world())
|
|
|
|
|
|
def _alternative(raw, config):
|
|
"""Change every physical table/column plus vocabularies and business codes."""
|
|
profile = copy.deepcopy(config)
|
|
profile["id"] = "independent-factory-layout-v2"
|
|
profile["compatibilityDefault"] = False
|
|
profile["planning"]["timeZone"] = "Europe/Berlin"
|
|
profile["planning"]["skillLevelOrder"] = ["trainee", "qualified", "senior", "expert"]
|
|
workbook = openpyxl.load_workbook(io.BytesIO(raw))
|
|
code_map = {}
|
|
# All references are rewritten by exact source values, including sandbox
|
|
# metadata; no special customer order/equipment codes occur in this test.
|
|
for role, key in (("equipment", "code"), ("orders", "orderNo")):
|
|
spec = config["sheets"][role]
|
|
sheet = workbook[spec["name"]]
|
|
column = list(spec["columns"].keys()).index(key) + 1
|
|
for index, cells in enumerate(sheet.iter_rows(min_row=2), 1):
|
|
code_map[cells[column - 1].value] = f"{role}-ALT-{index}"
|
|
enum_raw_map = {}
|
|
for kind, aliases in profile["enums"].items():
|
|
transformed = {}
|
|
for index, (raw_value, canonical) in enumerate(aliases.items()):
|
|
alias = f"value_{kind}_{index}"
|
|
enum_raw_map[(kind, raw_value)] = alias
|
|
transformed[alias] = canonical
|
|
profile["enums"][kind] = transformed
|
|
enum_fields = {("factoryResources", "resourceKind"): "resourceKind", ("factoryResources", "status"): "resourceStatus", ("equipment", "status"): "equipmentStatus",
|
|
("products", "type"): "materialType", ("materials", "type"): "materialType",
|
|
("products", "sourcingType"): "sourcingType", ("materials", "sourcingType"): "sourcingType",
|
|
("bom", "isKey"): "yesNo", ("routing", "isExternal"): "yesNo", ("partners", "partnerType"): "partnerType",
|
|
("orders", "orderType"): "orderType", ("orders", "status"): "orderStatus", ("wip", "status"): "wipStatus"}
|
|
for role_index, (role, original_spec) in enumerate(config["sheets"].items()):
|
|
sheet = workbook[original_spec["name"]]
|
|
fields = list(original_spec["columns"])
|
|
metadata = profile["metadataKeys"].get(role)
|
|
for cells in sheet.iter_rows(min_row=2):
|
|
original_values = {field: cells[index].value for index, field in enumerate(fields)}
|
|
for index, field in enumerate(fields):
|
|
value = cells[index].value
|
|
if value in code_map:
|
|
cells[index].value = code_map[value]
|
|
enum = enum_fields.get((role, field))
|
|
if enum and (enum, value) in enum_raw_map:
|
|
cells[index].value = enum_raw_map[(enum, value)]
|
|
if field == "sourceText" and isinstance(value, str):
|
|
for marker in config["provenance"]["demoMarkers"]:
|
|
cells[index].value = value.replace(marker, "SYNTHETIC")
|
|
if role == "personnel" and field == "skillLevel":
|
|
cells[index].value = profile["planning"]["skillLevelOrder"][config["planning"]["skillLevelOrder"].index(value)]
|
|
if role == "calendar" and field == "eventCode" and ("calendarEvent", value) in enum_raw_map:
|
|
cells[index].value = enum_raw_map[("calendarEvent", value)]
|
|
if role == "calendar" and field == "statusOrReason" and ("enabled", value) in enum_raw_map:
|
|
cells[index].value = enum_raw_map[("enabled", value)]
|
|
if metadata and field == "key" and value in metadata:
|
|
cells[index].value = "meta_" + metadata[value]
|
|
if (role == "planningParameters" and field in ("delivery", "bottleneck")
|
|
and config["metadataKeys"][role].get(original_values["key"]) == "nightShiftEnabled"):
|
|
cells[index].value = enum_raw_map[("enabled", value)]
|
|
spec = profile["sheets"][role]
|
|
spec["name"] = f"Data_{role_index}"
|
|
sheet.title = spec["name"]
|
|
for index, field in enumerate(fields):
|
|
spec["columns"][field] = f"Column_{role_index}_{index}"
|
|
sheet.cell(1, index + 1).value = spec["columns"][field]
|
|
if metadata:
|
|
profile["metadataKeys"][role] = {"meta_" + canonical: canonical for canonical in metadata.values()}
|
|
profile["provenance"]["demoMarkers"] = ["SYNTHETIC"]
|
|
stream = io.BytesIO()
|
|
workbook.save(stream)
|
|
workbook.close()
|
|
return stream.getvalue(), profile, code_map
|
|
|
|
|
|
def test_alternative_mapping_changes_all_layout_names_vocabularies_and_identifiers(original_bytes, tmp_path, monkeypatch):
|
|
original = _preview(original_bytes)
|
|
raw, config, mapping = _alternative(original_bytes, _config())
|
|
_write(tmp_path, config)
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
|
|
preview = _preview(raw)
|
|
assert preview["profile"] == config["id"]
|
|
assert preview["canCommit"], preview["diagnostics"]
|
|
assert preview["entityCounts"] == original["entityCounts"]
|
|
assert {b["physicalSheet"] for b in preview["batches"]} == {s["name"] for s in config["sheets"].values()}
|
|
assert is_profile_supported(config["id"]) and has_adoption_flow(config["id"])
|
|
assert not is_profile_supported(original["profile"])
|
|
world = empty_world()
|
|
apply_import_commit(world, lambda kind: 1, preview["batches"])
|
|
assert len(world["flexMaterials"]) == 45 and len(world["flexRoutings"]) == 15
|
|
assert len(world["flexOrders"]) == 5 and len(world["flexSandboxOrders"]) == 1
|
|
assert all(row["orderNo"].startswith("orders-ALT-") for row in world["flexOrders"])
|
|
assert all(row["code"].startswith("equipment-ALT-") for row in world["flexEquipment"])
|
|
assert world["flexWip"][0]["equipmentCode"] in mapping.values()
|
|
assert sum(m.get("inTransit", 0) for m in world["flexMaterials"]) == 420
|
|
assert sum(row["isKey"] for row in world["flexBom"]) == 12
|
|
assert {r["stdTimeSource"] for r in world["flexRoutings"]} == {"demo"}
|
|
assert world["planningContext"]["timeZone"] == "Europe/Berlin"
|
|
assert world["planningContext"]["skillLevelOrder"] == config["planning"]["skillLevelOrder"]
|
|
assert world["planningContext"]["defaultSortMode"] == "ASC"
|
|
assert {r["resourceKind"] for r in world["flexFactoryResources"]} == {"FACTORY", "WORKSHOP", "ZONE", "OPERATION"}
|
|
assert world["intakeSources"][0]["profileDigest"] == preview["profileDigest"]
|
|
before = copy.deepcopy(world)
|
|
assert apply_import_commit(world, lambda kind: 1, preview["batches"])["unchanged"]
|
|
assert world == before
|
|
|
|
|
|
@pytest.mark.parametrize("mutate", [
|
|
lambda p: p["sheets"]["orders"]["columns"].pop("orderNo"),
|
|
lambda p: p["sheets"].__setitem__("unknown", {}),
|
|
lambda p: p["sheets"]["orders"].__setitem__("name", p["sheets"]["equipment"]["name"]),
|
|
lambda p: p["sheets"]["orders"]["columns"].__setitem__("orderNo", p["sheets"]["orders"]["columns"]["productCode"]),
|
|
lambda p: p["enums"]["orderType"].__setitem__("other", "AUTOMATIC_RELEASE"),
|
|
lambda p: p["planning"].__setitem__("trialOnly", False),
|
|
lambda p: p["planning"].__setitem__("timeZone", "Unknown/Location"),
|
|
lambda p: p["planning"].__setitem__("skillLevelOrder", ["same", "same"]),
|
|
lambda p: p["metadataKeys"]["sourceNotes"].__setitem__("duplicate", "dataDate"),
|
|
lambda p: p.__setitem__("loader", "arbitrary.module:function"),
|
|
lambda p: p["sheets"]["orders"]["requiredColumns"].append("unregisteredField"),
|
|
lambda p: p["sheets"]["orders"].__setitem__("requiredColumns", "orderNo"),
|
|
])
|
|
def test_invalid_configuration_is_rejected(mutate, tmp_path, monkeypatch):
|
|
config = _config()
|
|
mutate(config)
|
|
_write(tmp_path, config)
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
|
|
with pytest.raises(ValueError):
|
|
load_profiles()
|
|
|
|
|
|
def test_duplicate_json_keys_duplicate_ids_and_ambiguous_profiles_fail_closed(original_bytes, tmp_path, monkeypatch):
|
|
config = _config()
|
|
target = _write(tmp_path, config)
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
|
|
target.write_text('{"id":"one", "id":"two"}', encoding="utf-8")
|
|
with pytest.raises(ValueError, match="重复"):
|
|
load_profiles()
|
|
_write(tmp_path, config)
|
|
_write(tmp_path, config, "another.json")
|
|
with pytest.raises(ValueError, match="id重复"):
|
|
load_profiles()
|
|
config["id"] = "same-layout-another-id"
|
|
_write(tmp_path, config, "another.json")
|
|
with pytest.raises(ValueError, match="多个配置"):
|
|
_preview(original_bytes)
|
|
|
|
|
|
def test_config_version_is_frozen_and_adopted_identity_includes_mapping(original_bytes, tmp_path, monkeypatch):
|
|
config = _config()
|
|
_write(tmp_path, config)
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
|
|
preview = _preview(original_bytes)
|
|
world = empty_world()
|
|
apply_import_commit(world, lambda kind: 1, preview["batches"])
|
|
before = copy.deepcopy(world)
|
|
config["planning"]["timeZone"] = "Europe/Berlin"
|
|
_write(tmp_path, config)
|
|
with pytest.raises(ValueError, match="配置已变化"):
|
|
apply_import_commit(world, lambda kind: 1, preview["batches"])
|
|
assert world == before
|
|
fresh = _preview(original_bytes)
|
|
assert fresh["source"]["sha256"] == preview["source"]["sha256"]
|
|
assert fresh["profileDigest"] != preview["profileDigest"]
|
|
with pytest.raises(ValueError, match="映射版本|配置已变化"):
|
|
apply_import_commit(world, lambda kind: 1, fresh["batches"])
|
|
assert world == before
|
|
|
|
|
|
def test_unknown_profile_cannot_bypass_generic_commit_and_helpers_are_safe(original_bytes):
|
|
assert has_adoption_flow(None) is False and has_adoption_flow("unregistered") is False
|
|
assert is_profile_supported(None) is False
|
|
assert normalize_resource_kind("unknown", None) is None
|
|
assert normalize_resource_kind("unknown", "unregistered") is None
|
|
assert normalize_resource_kind("FACTORY", None) == "FACTORY"
|
|
preview = _preview(original_bytes)
|
|
for batch in preview["batches"]:
|
|
batch["sourceProfile"] = "unregistered"
|
|
world = empty_world()
|
|
before = copy.deepcopy(world)
|
|
with pytest.raises(ValueError, match="未注册"):
|
|
apply_import_commit(world, lambda kind: 1, preview["batches"])
|
|
assert world == before
|
|
|
|
|
|
def test_missing_profile_directory_is_not_silently_defaulted(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "absent"))
|
|
with pytest.raises(ValueError, match="目录不存在"):
|
|
load_profiles()
|
|
assert BUILTIN_PROFILE_DIR.is_dir()
|
|
|
|
|
|
def test_required_columns_are_optional_and_absent_means_all_optional(tmp_path, monkeypatch):
|
|
omitted = _config()
|
|
for spec in omitted["sheets"].values():
|
|
spec.pop("requiredColumns")
|
|
declared = _config()
|
|
for spec in declared["sheets"].values():
|
|
spec["requiredColumns"] = []
|
|
_write(tmp_path / "omitted", omitted)
|
|
_write(tmp_path / "declared", declared)
|
|
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "omitted"))
|
|
defaulted = load_profiles()[omitted["id"]]
|
|
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "declared"))
|
|
explicit = load_profiles()[declared["id"]]
|
|
|
|
assert all(spec["requiredColumns"] == [] for spec in defaulted["sheets"].values())
|
|
assert defaulted["profileDigest"] == explicit["profileDigest"]
|