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