"""ICCID 19/20 位拆分与校验工具。 移植自 pkg/utils/iccid.go,并按运营商类型增加长度合规校验。 """ from __future__ import annotations from dataclasses import dataclass from typing import Optional # 运营商标准 ICCID 长度 CARRIER_ICCID_LENGTH = { "CTCC": 19, # 电信 "CMCC": 20, # 移动 "CUCC": 20, # 联通 "CBN": 20, # 广电 } VALID_CARRIER_TYPES = set(CARRIER_ICCID_LENGTH.keys()) @dataclass(frozen=True) class SplitResult: """ICCID 拆分结果。""" iccid: str # 原始 ICCID(去空格) iccid_19: str # 前 19 位(所有卡必填) iccid_20: Optional[str] # 完整 20 位(仅 20 位运营商卡有值,19 位卡为 None → SQL NULL) @dataclass(frozen=True) class ValidationError: """ICCID 校验失败信息。""" code: str # 错误码:length_invalid / length_mismatch message: str # 中文描述 def split_iccid(iccid: str) -> Optional[SplitResult]: """按 ICCID 长度拆分双列存储值。 与 Go 版本一致: 19 位 → (iccid, iccid, None) 20 位 → (iccid, iccid[:19], iccid) 其他 → None(调用方按异常处理) """ iccid = iccid.strip() if len(iccid) == 19: return SplitResult(iccid=iccid, iccid_19=iccid, iccid_20=None) if len(iccid) == 20: return SplitResult(iccid=iccid, iccid_19=iccid[:19], iccid_20=iccid) return None def validate_and_split(iccid: str, carrier_type: str) -> tuple[Optional[SplitResult], Optional[ValidationError]]: """按运营商类型校验 ICCID 长度并拆分。 返回 (拆分结果, 错误信息) 二元组: - 长度不是 19/20:返回 (None, length_invalid 错误) - 长度与 carrier_type 不匹配:返回 (拆分结果, length_mismatch 警告) → 调用方决定要不要放行 - 合法:返回 (拆分结果, None) 设计说明:不直接拒绝长度不匹配的情况(电信卡居然是 20 位 / 非电信卡是 19 位), 保留拆分结果让脚本写入 errors.csv 让业务方确认。 """ iccid = iccid.strip() if carrier_type not in VALID_CARRIER_TYPES: return None, ValidationError( code="carrier_type_invalid", message=f"无效的运营商类型 {carrier_type!r},应为 {sorted(VALID_CARRIER_TYPES)} 之一", ) result = split_iccid(iccid) if result is None: return None, ValidationError( code="length_invalid", message=f"ICCID 长度 {len(iccid)} 不合法,只支持 19/20 位", ) expected_len = CARRIER_ICCID_LENGTH[carrier_type] if len(iccid) != expected_len: return result, ValidationError( code="length_mismatch", message=f"{carrier_type} 应为 {expected_len} 位但 ICCID 是 {len(iccid)} 位,请人工确认", ) return result, None def prefix_19(iccid: str) -> str: """ICCID 前 19 位(稳定 ID,无校验位)。 用于 devices.csv 引用 cards 时的兼容查找:业务方在 devices 里填 19 还是 20 位都接受, 内部统一截取前 19 位回查 cards.iccid_19。 """ s = (iccid or "").strip() if len(s) >= 19: return s[:19] return s def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]: """iccid_19 必须是 19 位。 奇成历史数据里存在带字母的 ICCID,迁移脚本只校验长度,不强制纯数字。 """ if not iccid_19: return ValidationError(code="iccid_19_required", message="iccid_19 不能为空") if len(iccid_19) != 19: return ValidationError( code="iccid_19_length", message=f"iccid_19 必须是 19 位,当前 {len(iccid_19)} 位", ) return None def validate_iccid_20(iccid_20: str, iccid_19: str) -> Optional[ValidationError]: """iccid_20 可空(CTCC 卡);非空时必须 20 位且前 19 位 == iccid_19。""" if not iccid_20: return None if len(iccid_20) != 20: return ValidationError( code="iccid_20_length", message=f"iccid_20 必须是 20 位,当前 {len(iccid_20)} 位", ) if iccid_20[:19] != iccid_19: return ValidationError( code="iccid_20_prefix_mismatch", message=f"iccid_20 前 19 位 ({iccid_20[:19]!r}) 与 iccid_19 ({iccid_19!r}) 不一致", ) return None def validate_carrier_iccid_match( iccid_20: str, carrier_type: str ) -> Optional[ValidationError]: """业务方填写的 iccid_20 与奇成查到的 carrier_type 是否匹配。 - CTCC(19 位运营商):iccid_20 必须为空 - CMCC/CUCC/CBN(20 位运营商):iccid_20 必填 """ if carrier_type not in VALID_CARRIER_TYPES: return ValidationError( code="carrier_type_invalid", message=f"未知运营商类型 {carrier_type!r}", ) expected_len = CARRIER_ICCID_LENGTH[carrier_type] if expected_len == 19 and iccid_20: return ValidationError( code="carrier_length_conflict", message=f"{carrier_type} 是 19 位运营商,iccid_20 应留空,实际填了 {iccid_20!r}", ) if expected_len == 20 and not iccid_20: return ValidationError( code="carrier_length_conflict", message=f"{carrier_type} 是 {expected_len} 位运营商,iccid_20 必填", ) return None