"""cards.csv / devices.csv 加载与跨文件一致性校验。 新设计:CSV 只承载"业务方决策"—— - cards.csv 只读 iccid + 可选覆盖列(target_shop_code、is_industry) - devices.csv 读设备元数据 + sim_iccid_* - 卡的运营商、套餐、msisdn、agent_id 等元数据全部从奇成查(见 legacy_query.fetch_card_meta) """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Optional CARD_HEADERS_REQUIRED = ("iccid_19",) # 设备列至少包含 imei 或 virtual_no 之一(运行时校验,允许只填一列) DEVICE_HEADERS_AT_LEAST_ONE = ("imei", "virtual_no") @dataclass class CardRow: """cards.csv 单行的解析结果(只含业务方决策字段)。 业务方至少提供 iccid_19 / iccid_20 之一: - iccid_19:前 19 位,可作为奇成主表查询键 - iccid_20:20 位完整 ICCID,固定 20 位运营商只填这一列即可 - 运营商类型与 iccid_20 的一致性在查完奇成后由 sql_builder 校验 新系统 iccid 唯一键 = iccid_20 优先,否则 iccid_19。 """ line_no: int # 原始 CSV 行号(从 1 开始,含表头) iccid_19: str iccid_20: str = "" # 留空表示 CTCC(查完奇成会再校验) allow_iccid_19_lookup: bool = True # iccid_19 是否为业务方显式提供,可用于奇成主表兜底查询 is_industry: bool = False # 默认 False(普通卡) # 跨文件校验后填充 bound_device_virtual_no: str = "" @property def iccid_full(self) -> str: """完整 ICCID:有 iccid_20 用 iccid_20,否则用 iccid_19。""" return self.iccid_20 or self.iccid_19 @dataclass class DeviceRow: """devices.csv 单行的解析结果。 简化为业务方核心关心的列:imei / virtual_no / device_* / max_sim_slots / sim_iccid_*。 店铺归属由"主卡 sim_iccid_1 的 agent_id → mapping.agents.target_shop_code"推导。 其他字段(manufacturer 等)直接用默认值,不暴露给业务方。 """ line_no: int imei: str virtual_no: str # 留空时用 imei 兜底,见 _load_devices sim_iccids: dict[int, str] # slot → iccid(只保留非空) device_name: str = "" device_model: str = "" device_type: str = "" max_sim_slots: int = 1 # 不填时按非空 sim_iccid_* 的最大槽位推导 current_slot: int = 1 # 默认 slot 1 为当前生效 @dataclass class ErrorRow: """异常行汇总(写入 output/errors.csv)。""" source_file: str line_no: int field: str value: str error_code: str message: str def to_csv_row(self) -> list[str]: return [self.source_file, str(self.line_no), self.field, self.value, self.error_code, self.message] @dataclass class LoadResult: cards: list[CardRow] = field(default_factory=list) devices: list[DeviceRow] = field(default_factory=list) errors: list[ErrorRow] = field(default_factory=list) # ---------------- 字段清洗 ---------------- def _normalize_cell(raw: str) -> str: if raw is None: return "" s = raw.strip() if s.startswith(""): s = s[1:] if s.startswith("'"): s = s[1:] s = s.replace(" ", "") return s.strip() def _parse_bool(raw: str) -> Optional[bool]: v = _normalize_cell(raw).lower() if v == "": return False if v in ("true", "1", "yes", "y", "t", "是", "行业卡"): return True if v in ("false", "0", "no", "n", "f", "否", "普通卡"): return False return None def _parse_int(raw: str, default: int = 0) -> int: v = _normalize_cell(raw) if not v: return default try: return int(v) except ValueError: return default # ---------------- 主入口 ---------------- def load_assets(resources_dir: Path) -> tuple[list[CardRow], list[DeviceRow], list[ErrorRow]]: """读 cards.csv + devices.csv,做格式校验与跨文件一致性校验。 返回:(合法卡列表, 合法设备列表, 异常行列表)。 元数据(carrier/agent_id/msisdn 等)由调用方再用 legacy_query 补充。 """ result = LoadResult() cards_path = resources_dir / "cards.csv" if not cards_path.exists(): raise FileNotFoundError(f"缺少 {cards_path},请按 resources/README.md 准备数据") _load_cards(cards_path, result) devices_path = resources_dir / "devices.csv" if devices_path.exists(): _load_devices(devices_path, result) _cross_check(result) return result.cards, result.devices, result.errors def _load_cards(path: Path, result: LoadResult) -> None: import csv from . import iccid_utils with path.open("r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) header = [_normalize_cell(h) for h in (reader.fieldnames or [])] missing = [k for k in CARD_HEADERS_REQUIRED if k not in header] if missing: raise ValueError(f"{path} 缺少必备列: {missing}") if "iccid_20" not in header: raise ValueError(f"{path} 必须包含 iccid_20 列(20 位运营商卡填写,CTCC 留空)") seen_iccid_19: dict[str, int] = {} for idx, raw in enumerate(reader, start=2): iccid_19 = _normalize_cell(raw.get("iccid_19", "")) iccid_20 = _normalize_cell(raw.get("iccid_20", "")) allow_iccid_19_lookup = bool(iccid_19) if iccid_20 and len(iccid_20) != 20: result.errors.append( ErrorRow("cards.csv", idx, "iccid_20", iccid_20, "iccid_20_length", f"iccid_20 必须是 20 位,当前 {len(iccid_20)} 位") ) continue if not iccid_19 and iccid_20: iccid_19 = iccid_20[:19] err = iccid_utils.validate_iccid_19(iccid_19) if err is not None: result.errors.append(ErrorRow("cards.csv", idx, "iccid_19", iccid_19, err.code, err.message)) continue err = iccid_utils.validate_iccid_20(iccid_20, iccid_19) if err is not None: result.errors.append(ErrorRow("cards.csv", idx, "iccid_20", iccid_20, err.code, err.message)) continue if iccid_19 in seen_iccid_19: result.errors.append( ErrorRow("cards.csv", idx, "iccid_19", iccid_19, "duplicate", f"与第 {seen_iccid_19[iccid_19]} 行 iccid_19 重复") ) continue seen_iccid_19[iccid_19] = idx is_industry = _parse_bool(raw.get("is_industry", "")) if is_industry is None: result.errors.append( ErrorRow("cards.csv", idx, "is_industry", raw.get("is_industry", ""), "invalid_bool", "is_industry 必须是 true/false") ) continue result.cards.append( CardRow( line_no=idx, iccid_19=iccid_19, iccid_20=iccid_20, allow_iccid_19_lookup=allow_iccid_19_lookup, is_industry=is_industry, ) ) def _load_devices(path: Path, result: LoadResult) -> None: import csv with path.open("r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) header = [_normalize_cell(h) for h in (reader.fieldnames or [])] if not any(k in header for k in DEVICE_HEADERS_AT_LEAST_ONE): raise ValueError(f"{path} 必须至少包含 imei 或 virtual_no 列") seen_virtual_no: dict[str, int] = {} for idx, raw in enumerate(reader, start=2): imei = _normalize_cell(raw.get("imei", "")) virtual_no = _normalize_cell(raw.get("virtual_no", "")) device_name = _normalize_cell(raw.get("device_name", "")) device_model = _normalize_cell(raw.get("device_model", "")) device_type = _normalize_cell(raw.get("device_type", "")) # virtual_no 留空时用 imei 兜底(业务方说"虚拟号一般会提供,没有就用 imei") effective_vno = virtual_no or imei if not effective_vno: result.errors.append( ErrorRow("devices.csv", idx, "virtual_no", "", "missing_identity", "imei 和 virtual_no 不能同时为空") ) continue if effective_vno in seen_virtual_no: result.errors.append( ErrorRow("devices.csv", idx, "virtual_no", effective_vno, "duplicate", f"与第 {seen_virtual_no[effective_vno]} 行的设备号重复") ) continue seen_virtual_no[effective_vno] = idx sim_iccids: dict[int, str] = {} for slot in (1, 2, 3, 4): v = _normalize_cell(raw.get(f"sim_iccid_{slot}", "")) if v: sim_iccids[slot] = v max_sim_slots_raw = _normalize_cell(raw.get("max_sim_slots", "")) if max_sim_slots_raw: try: max_sim_slots = int(max_sim_slots_raw) except ValueError: result.errors.append( ErrorRow("devices.csv", idx, "max_sim_slots", max_sim_slots_raw, "invalid", "最大卡槽数必须是 1-4 的整数") ) continue if max_sim_slots < 1 or max_sim_slots > 4: result.errors.append( ErrorRow("devices.csv", idx, "max_sim_slots", max_sim_slots_raw, "invalid", "最大卡槽数必须在 1-4 之间") ) continue else: max_sim_slots = max(sim_iccids.keys(), default=1) overflow_slots = [slot for slot in sim_iccids if slot > max_sim_slots] if overflow_slots: result.errors.append( ErrorRow("devices.csv", idx, "max_sim_slots", str(max_sim_slots), "slot_overflow", f"已填写的卡槽 {max(overflow_slots)} 超出最大卡槽数") ) continue result.devices.append( DeviceRow( line_no=idx, imei=imei, virtual_no=effective_vno, device_name=device_name, device_model=device_model, device_type=device_type, sim_iccids=sim_iccids, max_sim_slots=max_sim_slots, ) ) def _cross_check(result: LoadResult) -> None: """校验设备与卡之间的一致性,并把 bound_device_virtual_no 回填到 CardRow。 devices.csv 里 sim_iccid_X 业务方可能填 19 位也可能填 20 位, 统一截取前 19 位回查 cards.iccid_19。 """ from . import iccid_utils cards_by_iccid_19: dict[str, CardRow] = {c.iccid_19: c for c in result.cards} device_card_refs: dict[str, tuple[str, int]] = {} for device in result.devices: for slot, raw_iccid in device.sim_iccids.items(): key19 = iccid_utils.prefix_19(raw_iccid) if key19 not in cards_by_iccid_19: result.errors.append( ErrorRow( "devices.csv", device.line_no, f"sim_iccid_{slot}", raw_iccid, "card_not_found", "引用的 ICCID(前 19 位)在 cards.csv 中不存在", ) ) continue if key19 in device_card_refs: prev_vno, prev_line = device_card_refs[key19] result.errors.append( ErrorRow( "devices.csv", device.line_no, f"sim_iccid_{slot}", raw_iccid, "card_used_twice", f"该 ICCID 已被设备 {prev_vno}(第 {prev_line} 行)使用", ) ) continue device_card_refs[key19] = (device.virtual_no, device.line_no) cards_by_iccid_19[key19].bound_device_virtual_no = device.virtual_no # 标准化:slot 里的值统一改写为 iccid_19,sql_builder 直接用它查 cards / card_metas device.sim_iccids[slot] = key19