Files
junhong_cmp_fiber/scripts/migration/lib/csv_loader.py
2026-06-12 18:10:22 +08:00

436 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""cards.csv / devices.csv 加载与跨文件一致性校验。
新设计:CSV 只承载"资产清单和行级槽位决策"——
- cards.csv 只读 iccid + is_industry
- devices.csv 读设备元数据 + sim_iccid_*
- 卡的运营商、套餐、msisdn、agent_id 等元数据全部从奇成查(见 legacy_query.fetch_card_meta)
- 归属、默认当前槽位、默认旧套餐读取槽位从 mapping.yaml 解析
"""
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_*。
店铺归属、批量默认 current_slot 和旧套餐读取槽位 package_source_slot 由 mapping.yaml 决定。
其他字段(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 为当前生效
package_source_slot: int = 1 # 设备旧套餐读取槽位
current_slot_source: str = "default"
package_source_slot_source: str = "default"
@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, mapping=None) -> 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)
if mapping is not None:
_apply_device_slot_rules(result, mapping)
_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_full: 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
iccid_full = iccid_20 or iccid_19
if iccid_full in seen_iccid_full:
result.errors.append(
ErrorRow("cards.csv", idx, "iccid", iccid_full, "duplicate",
f"与第 {seen_iccid_full[iccid_full]} 行 ICCID 重复")
)
continue
seen_iccid_full[iccid_full] = 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
current_slot, current_slot_source = _parse_slot_with_source(raw.get("current_slot", ""), 1)
if current_slot < 1 or current_slot > 4:
result.errors.append(
ErrorRow("devices.csv", idx, "current_slot", raw.get("current_slot", ""), "invalid_slot",
"current_slot 必须是 1-4 的整数")
)
continue
package_source_slot, package_source_slot_source = _parse_slot_with_source(
raw.get("package_source_slot", ""), current_slot
)
if package_source_slot < 1 or package_source_slot > 4:
result.errors.append(
ErrorRow("devices.csv", idx, "package_source_slot", raw.get("package_source_slot", ""), "invalid_slot",
"package_source_slot 必须是 1-4 的整数")
)
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,
current_slot=current_slot,
package_source_slot=package_source_slot,
current_slot_source=current_slot_source,
package_source_slot_source=package_source_slot_source,
)
)
def _parse_slot_with_source(raw: str, default: int) -> tuple[int, str]:
"""解析槽位并返回来源。"""
v = _normalize_cell(raw)
if not v:
return default, "default"
try:
return int(v), "row"
except ValueError:
return 0, "row"
def _apply_device_slot_rules(result: LoadResult, mapping) -> None:
"""按 行配置 > 覆盖项 > 批量默认值 解析设备槽位。"""
for device in result.devices:
override = mapping.lookup_device_override(device.virtual_no)
if device.current_slot_source != "row":
if override and override.current_slot is not None:
device.current_slot = override.current_slot
device.current_slot_source = "override"
elif mapping.ownership_rules.device.current_slot is not None:
device.current_slot = mapping.ownership_rules.device.current_slot
device.current_slot_source = "ownership_rules.device"
if device.package_source_slot_source != "row":
if override and override.package_source_slot is not None:
device.package_source_slot = override.package_source_slot
device.package_source_slot_source = "override"
elif mapping.ownership_rules.device.package_source_slot is not None:
device.package_source_slot = mapping.ownership_rules.device.package_source_slot
device.package_source_slot_source = "ownership_rules.device"
else:
device.package_source_slot = device.current_slot
device.package_source_slot_source = device.current_slot_source
for field_name, slot in (
("current_slot", device.current_slot),
("package_source_slot", device.package_source_slot),
):
if slot < 1 or slot > 4:
result.errors.append(
ErrorRow("devices.csv", device.line_no, field_name, str(slot), "invalid_slot",
f"{field_name} 必须是 1-4 的整数")
)
elif slot > device.max_sim_slots:
result.errors.append(
ErrorRow("devices.csv", device.line_no, field_name, str(slot), "slot_overflow",
f"{field_name}={slot} 超出 max_sim_slots={device.max_sim_slots}")
)
def _cross_check(result: LoadResult) -> None:
"""校验设备与卡之间的一致性,并把 bound_device_virtual_no 回填到 CardRow。
devices.csv 里 sim_iccid_X 业务方可能填 19 位也可能填 20 位。
20 位按完整 ICCID 精确匹配;19 位只有在 cards.csv 中唯一对应一张卡时才允许。
"""
from . import iccid_utils
cards_by_full: dict[str, CardRow] = {c.iccid_full: c for c in result.cards}
cards_by_i19: dict[str, list[CardRow]] = {}
for card in result.cards:
cards_by_i19.setdefault(card.iccid_19, []).append(card)
device_card_refs: dict[str, tuple[str, int]] = {}
for device in result.devices:
for slot, raw_iccid in device.sim_iccids.items():
resolved_card: Optional[CardRow] = None
if len(raw_iccid) == 20:
resolved_card = cards_by_full.get(raw_iccid)
if resolved_card is None:
prefix = iccid_utils.prefix_19(raw_iccid)
candidates = cards_by_i19.get(prefix, [])
if len(candidates) == 1 and not candidates[0].iccid_20:
resolved_card = candidates[0]
elif len(raw_iccid) == 19:
candidates = cards_by_i19.get(raw_iccid, [])
if len(candidates) > 1:
result.errors.append(
ErrorRow(
"devices.csv", device.line_no, f"sim_iccid_{slot}", raw_iccid,
"ambiguous_iccid_19", "该 19 位前缀对应多张 20 位卡,请填写完整 20 位 ICCID",
)
)
continue
if candidates:
resolved_card = candidates[0]
else:
result.errors.append(
ErrorRow(
"devices.csv", device.line_no, f"sim_iccid_{slot}", raw_iccid,
"sim_iccid_length", "设备槽位 ICCID 必须是 19 或 20 位",
)
)
continue
if resolved_card is None:
result.errors.append(
ErrorRow(
"devices.csv", device.line_no, f"sim_iccid_{slot}", raw_iccid,
"card_not_found", "引用的 ICCID 在 cards.csv 中不存在",
)
)
continue
card_key = resolved_card.iccid_full
if card_key in device_card_refs:
prev_vno, prev_line = device_card_refs[card_key]
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[card_key] = (device.virtual_no, device.line_no)
resolved_card.bound_device_virtual_no = device.virtual_no
# 标准化:slot 里的值统一改写为完整 ICCID,sql_builder 直接用它查 cards / card_metas
device.sim_iccids[slot] = card_key