"""mapping.yaml 单文件加载与增量合并。 设计要点: - 一个文件搞定所有映射(migration_user_id / carriers / packages / series / agents) - 由 scan_legacy.py 增量补全 legacy_* 字段;人工只需填 target_* - lookup 时检查 target_* 是否已填,未填则抛 MissingTargetError 让调用方写入 errors.csv """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional import yaml class MissingTargetError(Exception): """target_* 字段未填的具体错误,带 legacy 标识信息。""" def __init__(self, kind: str, legacy_key: str, legacy_name: str, missing_fields: list[str]): self.kind = kind self.legacy_key = legacy_key self.legacy_name = legacy_name self.missing_fields = missing_fields super().__init__( f"{kind} 映射未填 target_*: legacy_key={legacy_key!r} ({legacy_name!r}), 缺失={missing_fields}" ) @dataclass(frozen=True) class CarrierMapping: legacy_category: int legacy_category_name: str target_carrier_id: Optional[int] target_carrier_type: Optional[str] target_carrier_name: Optional[str] def require_target(self) -> "CarrierMapping": missing = [ n for n, v in ( ("target_carrier_id", self.target_carrier_id), ("target_carrier_type", self.target_carrier_type), ("target_carrier_name", self.target_carrier_name), ) if v in (None, "", 0) ] if missing: raise MissingTargetError("carrier", str(self.legacy_category), self.legacy_category_name, missing) return self @dataclass(frozen=True) class PackageMapping: legacy_meal_id: str legacy_meal_name: str target_package_id: Optional[int] def require_target(self) -> "PackageMapping": if self.target_package_id in (None, 0): raise MissingTargetError("package", self.legacy_meal_id, self.legacy_meal_name, ["target_package_id"]) return self @dataclass(frozen=True) class SeriesMapping: legacy_series_id: str legacy_series_name: str target_series_id: Optional[int] # 允许 null,表示不写入新库 series_id @dataclass(frozen=True) class AgentMapping: legacy_agent_id: str legacy_agent_name: str target_shop_code: Optional[str] # 允许 null,表示该代理的卡进平台库存 @dataclass class Mapping: """整体映射(对应单个 mapping.yaml)。""" migration_user_id: int = 0 migration_batch_no: str = "MIGRATION-QICHENG" carriers: dict[int, CarrierMapping] = field(default_factory=dict) packages: dict[str, PackageMapping] = field(default_factory=dict) series: dict[str, SeriesMapping] = field(default_factory=dict) agents: dict[str, AgentMapping] = field(default_factory=dict) def lookup_carrier(self, legacy_category: int) -> Optional[CarrierMapping]: return self.carriers.get(int(legacy_category)) def lookup_package(self, legacy_meal_id: str) -> Optional[PackageMapping]: return self.packages.get(str(legacy_meal_id).strip()) def lookup_series(self, legacy_series_id: str) -> Optional[SeriesMapping]: if not legacy_series_id: return None return self.series.get(str(legacy_series_id).strip()) def lookup_agent_shop_code(self, legacy_agent_id: str) -> Optional[str]: """按 agent_id 查 target_shop_code,空字符串视为 None(进平台库存)。""" if not legacy_agent_id: return None agent = self.agents.get(str(legacy_agent_id).strip()) if agent is None: return None code = (agent.target_shop_code or "").strip() return code or None # ---------------- 加载 ---------------- MAPPING_FILE = "mapping.yaml" DSN_FILE = "legacy_dsn.yaml" def load_mapping(config_dir: Path) -> Mapping: """加载 config/mapping.yaml。文件不存在时返回空 Mapping(供 scan_legacy 首次创建)。""" path = config_dir / MAPPING_FILE if not path.exists(): return Mapping() with path.open("r", encoding="utf-8") as f: raw = yaml.safe_load(f) or {} if not isinstance(raw, dict): raise ValueError(f"{path} 顶层必须是 mapping 类型") m = Mapping( migration_user_id=int(raw.get("migration_user_id", 0)), migration_batch_no=_opt_str(raw.get("migration_batch_no")) or "MIGRATION-QICHENG", ) for item in raw.get("carriers") or []: c = CarrierMapping( legacy_category=int(item["legacy_category"]), legacy_category_name=str(item.get("legacy_category_name") or ""), target_carrier_id=_opt_int(item.get("target_carrier_id")), target_carrier_type=_opt_str(item.get("target_carrier_type")), target_carrier_name=_opt_str(item.get("target_carrier_name")), ) m.carriers[c.legacy_category] = c for item in raw.get("packages") or []: p = PackageMapping( legacy_meal_id=str(item["legacy_meal_id"]).strip(), legacy_meal_name=str(item.get("legacy_meal_name") or ""), target_package_id=_opt_int(item.get("target_package_id")), ) m.packages[p.legacy_meal_id] = p for item in raw.get("series") or []: s = SeriesMapping( legacy_series_id=str(item["legacy_series_id"]).strip(), legacy_series_name=str(item.get("legacy_series_name") or ""), target_series_id=_opt_int(item.get("target_series_id")), ) m.series[s.legacy_series_id] = s for item in raw.get("agents") or []: a = AgentMapping( legacy_agent_id=str(item["legacy_agent_id"]).strip(), legacy_agent_name=str(item.get("legacy_agent_name") or ""), target_shop_code=_opt_str(item.get("target_shop_code")), ) m.agents[a.legacy_agent_id] = a return m def merge_and_save(config_dir: Path, mapping: Mapping) -> Path: """把 mapping 写回 config/mapping.yaml,保留人工已填的 target_* 字段。 由 scan_legacy 调用:先 load,再用扫描结果 _补充_ legacy_* 项(不存在的追加),最后调用本函数写回。 """ config_dir.mkdir(parents=True, exist_ok=True) path = config_dir / MAPPING_FILE data = { "migration_user_id": mapping.migration_user_id, "migration_batch_no": mapping.migration_batch_no, "carriers": [ { "legacy_category": c.legacy_category, "legacy_category_name": c.legacy_category_name, "target_carrier_id": c.target_carrier_id, "target_carrier_type": c.target_carrier_type, "target_carrier_name": c.target_carrier_name, } for c in sorted(mapping.carriers.values(), key=lambda x: x.legacy_category) ], "packages": [ { "legacy_meal_id": p.legacy_meal_id, "legacy_meal_name": p.legacy_meal_name, "target_package_id": p.target_package_id, } for p in sorted(mapping.packages.values(), key=lambda x: x.legacy_meal_id) ], "series": [ { "legacy_series_id": s.legacy_series_id, "legacy_series_name": s.legacy_series_name, "target_series_id": s.target_series_id, } for s in sorted(mapping.series.values(), key=lambda x: x.legacy_series_id) ], "agents": [ { "legacy_agent_id": a.legacy_agent_id, "legacy_agent_name": a.legacy_agent_name, "target_shop_code": a.target_shop_code, } for a in sorted(mapping.agents.values(), key=lambda x: x.legacy_agent_id) ], } with path.open("w", encoding="utf-8") as f: yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False, default_flow_style=False) return path def load_legacy_dsn(config_dir: Path) -> dict: """加载奇成 MySQL 只读连接配置。""" path = config_dir / DSN_FILE if not path.exists(): raise FileNotFoundError(f"找不到 {path},请基于 legacy_dsn.yaml.example 创建并填写真实 DSN") with path.open("r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} for required in ("host", "port", "user", "password", "database"): if required not in data: raise ValueError(f"{path} 缺少必填项 {required}") return data # ---------------- 小工具 ---------------- def _opt_int(v: Any) -> Optional[int]: if v is None or v == "": return None return int(v) def _opt_str(v: Any) -> Optional[str]: if v is None: return None s = str(v).strip() return s or None