迁移脚本的一些问题修复

This commit is contained in:
Break
2026-05-28 17:22:52 +08:00
parent 1550ef3cb1
commit 872769e69e
13 changed files with 328 additions and 114 deletions

View File

@@ -42,7 +42,7 @@ scripts/migration/
把两个 CSV 放到 `resources/` 下(命名为 `cards.csv` / `devices.csv`): 把两个 CSV 放到 `resources/` 下(命名为 `cards.csv` / `devices.csv`):
- **cards.csv**:`iccid_19` 必填(所有卡前 19 位),`iccid_20` 视运营商必填或留空,`is_industry` 可选 - **cards.csv**:`iccid_19`/`iccid_20` 至少填一个;仅填 `iccid_20` 时脚本自动派生前 19 位,但奇成主表只按 20 位精确查询;`is_industry` 可选
- **devices.csv**:`virtual_no` 必填,`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位) - **devices.csv**:`virtual_no` 必填,`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位)
字段说明详见 [`resources/README.md`](resources/README.md)。 字段说明详见 [`resources/README.md`](resources/README.md)。
@@ -112,7 +112,9 @@ migration_user_id: 0 # 后台超级管理员 ID,作为
migration_batch_no: "MIGRATION-QICHENG-YYYYMMDD" # 本次迁移批次号,线上每批必须唯一 migration_batch_no: "MIGRATION-QICHENG-YYYYMMDD" # 本次迁移批次号,线上每批必须唯一
carriers: # 由 scan_legacy 自动发现 legacy_*,你只填 target_* carriers: # 由 scan_legacy 自动发现 legacy_*,你只填 target_*
- legacy_category: 9 - legacy_account_id: "72493DB64A2A4F01B5E59E49D58A2A34"
legacy_account_name: "联通10-1"
legacy_category: 9 # 奇成运营商大类,仅作辅助判断
legacy_category_name: "中国联通" legacy_category_name: "中国联通"
target_carrier_id: 1585 ← 必填:新库 tb_carrier.id target_carrier_id: 1585 ← 必填:新库 tb_carrier.id
target_carrier_type: CUCC ← 必填:CMCC/CUCC/CTCC/CBN target_carrier_type: CUCC ← 必填:CMCC/CUCC/CTCC/CBN
@@ -203,6 +205,10 @@ real_used_mb = total_bytes_cnt / (1 + flow_add_discount / 100)
| `data_limit_mb` | 始终 `real_data_mb`(真总量) | | `data_limit_mb` | 始终 `real_data_mb`(真总量) |
| `data_usage_mb` | `LEAST(real_used_mb_floor, virtual_total_mb_snapshot)`(防卫上限) | | `data_usage_mb` | `LEAST(real_used_mb_floor, virtual_total_mb_snapshot)`(防卫上限) |
### 6.5 设备绑定卡的套餐归属
设备上的卡在新系统运行态会按设备维度判断有效套餐和复机条件,因此迁移时只用设备当前槽位卡(默认 `sim_iccid_1`)作为奇成套餐来源,生成一条 `usage_type=device``device_id=设备ID` 的套餐使用记录。其他槽位绑定卡不重复生成主套餐,避免同一设备出现多条并列生效主套餐。
## 7. 重要约束 ## 7. 重要约束
- ❗ 所有奇成查询走 `SET TRANSACTION READ ONLY`,严禁误写。 - ❗ 所有奇成查询走 `SET TRANSACTION READ ONLY`,严禁误写。
@@ -215,7 +221,7 @@ real_used_mb = total_bytes_cnt / (1 + flow_add_discount / 100)
| 现象 | 排查 | | 现象 | 排查 |
|------|------| |------|------|
| `scan_legacy.py``pymysql 缺失` | `pip install -r requirements.txt` | | `scan_legacy.py``pymysql 缺失` | `pip install -r requirements.txt` |
| `errors.csv` 出现 `carrier_not_in_mapping` | `mapping.yaml.carriers` 没覆盖到这个 category,重跑 `scan_legacy.py` 自动补 | | `errors.csv` 出现 `carrier_not_in_mapping` | `mapping.yaml.carriers` 没覆盖到这个 account_id/account_name,重跑 `scan_legacy.py` 自动补 |
| `errors.csv` 出现 `carrier_target_missing` | `mapping.yaml.carriers` 里有这一项但 `target_*` 还是 null,手动填 | | `errors.csv` 出现 `carrier_target_missing` | `mapping.yaml.carriers` 里有这一项但 `target_*` 还是 null,手动填 |
| `errors.csv` 出现 `not_in_legacy` | 该 ICCID 在奇成 `tbl_card` 查不到,业务方确认 | | `errors.csv` 出现 `not_in_legacy` | 该 ICCID 在奇成 `tbl_card` 查不到,业务方确认 |
| `errors.csv` 出现 `legacy_duplicate` | 同一 `iccid_19` 在奇成命中多条 `tbl_card`,业务方人工确认是哪张物理卡 | | `errors.csv` 出现 `legacy_duplicate` | 同一 `iccid_19` 在奇成命中多条 `tbl_card`,业务方人工确认是哪张物理卡 |

View File

@@ -16,9 +16,12 @@ migration_user_id: 0
migration_batch_no: "MIGRATION-QICHENG-YYYYMMDD" migration_batch_no: "MIGRATION-QICHENG-YYYYMMDD"
# ============== 运营商映射 ============== # ============== 运营商映射 ==============
# scan_legacy 会按"奇成 tbl_card.category"分组写入,你只需填 target_* # scan_legacy 会按"奇成 tbl_card.account_id/account_name"分组写入,你只需填 target_*
# legacy_category 只是奇成运营商大类,用于人工判断 target_carrier_type,不要把它当具体运营商。
carriers: carriers:
- legacy_category: 9 - legacy_account_id: "72493DB64A2A4F01B5E59E49D58A2A34"
legacy_account_name: "联通10-1"
legacy_category: 9
legacy_category_name: "中国联通" legacy_category_name: "中国联通"
target_carrier_id: null # 必填:新库 tb_carrier.id target_carrier_id: null # 必填:新库 tb_carrier.id
target_carrier_type: null # 必填:CMCC / CUCC / CTCC / CBN target_carrier_type: null # 必填:CMCC / CUCC / CTCC / CBN

View File

@@ -20,9 +20,9 @@ DEVICE_HEADERS_AT_LEAST_ONE = ("imei", "virtual_no")
class CardRow: class CardRow:
"""cards.csv 单行的解析结果(只含业务方决策字段)。 """cards.csv 单行的解析结果(只含业务方决策字段)。
业务方必须同时提供 iccid_19 iccid_20(20 位运营商): 业务方至少提供 iccid_19 / iccid_20 之一:
- iccid_19:前 19 位,所有卡必填,作为跨文件稳定 ID - iccid_19:前 19 位,可作为奇成主表查询键
- iccid_20:20 位完整 ICCID,CMCC/CUCC/CBN 必填、CTCC 必须留空 - iccid_20:20 位完整 ICCID,固定 20 位运营商只填这一列即可
- 运营商类型与 iccid_20 的一致性在查完奇成后由 sql_builder 校验 - 运营商类型与 iccid_20 的一致性在查完奇成后由 sql_builder 校验
新系统 iccid 唯一键 = iccid_20 优先,否则 iccid_19。 新系统 iccid 唯一键 = iccid_20 优先,否则 iccid_19。
""" """
@@ -30,6 +30,7 @@ class CardRow:
line_no: int # 原始 CSV 行号(从 1 开始,含表头) line_no: int # 原始 CSV 行号(从 1 开始,含表头)
iccid_19: str iccid_19: str
iccid_20: str = "" # 留空表示 CTCC(查完奇成会再校验) iccid_20: str = "" # 留空表示 CTCC(查完奇成会再校验)
allow_iccid_19_lookup: bool = True # iccid_19 是否为业务方显式提供,可用于奇成主表兜底查询
is_industry: bool = False # 默认 False(普通卡) is_industry: bool = False # 默认 False(普通卡)
# 跨文件校验后填充 # 跨文件校验后填充
@@ -159,6 +160,16 @@ def _load_cards(path: Path, result: LoadResult) -> None:
for idx, raw in enumerate(reader, start=2): for idx, raw in enumerate(reader, start=2):
iccid_19 = _normalize_cell(raw.get("iccid_19", "")) iccid_19 = _normalize_cell(raw.get("iccid_19", ""))
iccid_20 = _normalize_cell(raw.get("iccid_20", "")) 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) err = iccid_utils.validate_iccid_19(iccid_19)
if err is not None: if err is not None:
@@ -186,7 +197,13 @@ def _load_cards(path: Path, result: LoadResult) -> None:
continue continue
result.cards.append( result.cards.append(
CardRow(line_no=idx, iccid_19=iccid_19, iccid_20=iccid_20, is_industry=is_industry) CardRow(
line_no=idx,
iccid_19=iccid_19,
iccid_20=iccid_20,
allow_iccid_19_lookup=allow_iccid_19_lookup,
is_industry=is_industry,
)
) )

View File

@@ -99,7 +99,10 @@ def prefix_19(iccid: str) -> str:
def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]: def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]:
"""iccid_19 必须 19 位纯数字。""" """iccid_19 必须 19 位
奇成历史数据里存在带字母的 ICCID,迁移脚本只校验长度,不强制纯数字。
"""
if not iccid_19: if not iccid_19:
return ValidationError(code="iccid_19_required", message="iccid_19 不能为空") return ValidationError(code="iccid_19_required", message="iccid_19 不能为空")
if len(iccid_19) != 19: if len(iccid_19) != 19:
@@ -107,13 +110,11 @@ def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]:
code="iccid_19_length", code="iccid_19_length",
message=f"iccid_19 必须是 19 位,当前 {len(iccid_19)}", message=f"iccid_19 必须是 19 位,当前 {len(iccid_19)}",
) )
if not iccid_19.isdigit():
return ValidationError(code="iccid_19_format", message="iccid_19 必须是纯数字")
return None return None
def validate_iccid_20(iccid_20: str, iccid_19: str) -> Optional[ValidationError]: def validate_iccid_20(iccid_20: str, iccid_19: str) -> Optional[ValidationError]:
"""iccid_20 可空(CTCC 卡);非空时必须 20 位纯数字且前 19 位 == iccid_19。""" """iccid_20 可空(CTCC 卡);非空时必须 20 位且前 19 位 == iccid_19。"""
if not iccid_20: if not iccid_20:
return None return None
if len(iccid_20) != 20: if len(iccid_20) != 20:
@@ -121,8 +122,6 @@ def validate_iccid_20(iccid_20: str, iccid_19: str) -> Optional[ValidationError]
code="iccid_20_length", code="iccid_20_length",
message=f"iccid_20 必须是 20 位,当前 {len(iccid_20)}", message=f"iccid_20 必须是 20 位,当前 {len(iccid_20)}",
) )
if not iccid_20.isdigit():
return ValidationError(code="iccid_20_format", message="iccid_20 必须是纯数字")
if iccid_20[:19] != iccid_19: if iccid_20[:19] != iccid_19:
return ValidationError( return ValidationError(
code="iccid_20_prefix_mismatch", code="iccid_20_prefix_mismatch",

View File

@@ -14,6 +14,23 @@ from typing import Any, Iterable, Iterator, Optional
BATCH_SIZE = 500 BATCH_SIZE = 500
def _normalize_iccid_pairs(iccid_pairs: Iterable[tuple]) -> list[tuple[str, str, bool]]:
"""标准化 ICCID 查询参数。
tuple 第三项表示是否允许用 iccid_19 查询奇成 tbl_card 主表:
- 只填 iccid_20 的固定 20 位卡:false,避免 19 位前缀误撞其他卡
- 显式填了 iccid_19:true,允许作为主表查询键或 20 位缺失时兜底
"""
out: list[tuple[str, str, bool]] = []
for item in iccid_pairs:
i19 = str(item[0] or "").strip() if len(item) > 0 else ""
i20 = str(item[1] or "").strip() if len(item) > 1 else ""
allow_i19_lookup = bool(item[2]) if len(item) > 2 else True
if i19:
out.append((i19, i20, allow_i19_lookup))
return out
def _import_pymysql(): def _import_pymysql():
"""延迟导入 pymysql,这样脚本1(纯离线)不需要安装 pymysql 也能运行。""" """延迟导入 pymysql,这样脚本1(纯离线)不需要安装 pymysql 也能运行。"""
try: try:
@@ -110,6 +127,8 @@ class LegacyCardMeta:
""" """
legacy_raw_iccid: str # 奇成实际存的 iccid_mark(可能 19 或 20 位) legacy_raw_iccid: str # 奇成实际存的 iccid_mark(可能 19 或 20 位)
account_id: str # tbl_card.account_id(开卡公司/运营商账号 ID)
account_name: str # tbl_card.account_name(开卡公司/运营商账号名称)
category: int # tbl_card.category(运营商分类编号) category: int # tbl_card.category(运营商分类编号)
category_name: str # tbl_vendor_category.category_name category_name: str # tbl_vendor_category.category_name
agent_id: str agent_id: str
@@ -242,7 +261,7 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
return out return out
def fetch_card_usage(conn, iccid_pairs: Iterable[tuple[str, str]]) -> dict[str, LegacyCardUsage]: def fetch_card_usage(conn, iccid_pairs: Iterable[tuple]) -> dict[str, LegacyCardUsage]:
"""查每张卡的累计已用流量,并同步取当前生效套餐的 flow_add_discount。 """查每张卡的累计已用流量,并同步取当前生效套餐的 flow_add_discount。
奇成 ``tbl_card.total_bytes_cnt`` 是虚用量(已被套餐虚比加成)。 奇成 ``tbl_card.total_bytes_cnt`` 是虚用量(已被套餐虚比加成)。
@@ -251,21 +270,27 @@ def fetch_card_usage(conn, iccid_pairs: Iterable[tuple[str, str]]) -> dict[str,
上层通过 ``real_used_mb_floor`` 反算真用量写入新系统。 上层通过 ``real_used_mb_floor`` 反算真用量写入新系统。
Args: Args:
iccid_pairs: (iccid_19, iccid_20) 列表;iccid_20 可为空字符串 iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表
Returns: Returns:
{iccid_19: LegacyCardUsage}。 {iccid_19: LegacyCardUsage}。
""" """
pairs = [(p[0], p[1]) for p in iccid_pairs if p[0]] pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, LegacyCardUsage] = {} out: dict[str, LegacyCardUsage] = {}
if not pairs: if not pairs:
return out return out
full_to_19: dict[str, str] = {} full_to_19: dict[str, tuple[str, int]] = {}
for i19, i20 in pairs: related_keys_by_19: dict[str, set[str]] = {}
full_to_19[i19] = i19 for i19, i20, allow_i19_lookup in pairs:
# tbl_card 主表同时查优先键和兜底键,结果选择时按 rank:
# 0=业务方完整 ICCID 精确命中,1=20 位卡在奇成主表只存 19 位时兜底。
full_to_19[i20 or i19] = (i19, 0)
if i20 and allow_i19_lookup:
full_to_19[i19] = (i19, 1)
related_keys_by_19.setdefault(i19, set()).add(i19)
if i20: if i20:
full_to_19[i20] = i19 related_keys_by_19[i19].add(i20)
full_set = sorted(full_to_19.keys()) full_set = sorted(full_to_19.keys())
# tbl_card 主表用 iccid_mark IN 全长精确匹配;tbl_card_life 从表用 # tbl_card 主表用 iccid_mark IN 全长精确匹配;tbl_card_life 从表用
@@ -293,25 +318,30 @@ def fetch_card_usage(conn, iccid_pairs: Iterable[tuple[str, str]]) -> dict[str,
LEFT JOIN tbl_set_meal sm ON sm.id = cl.meal_id LEFT JOIN tbl_set_meal sm ON sm.id = cl.meal_id
WHERE c.iccid_mark IN %s WHERE c.iccid_mark IN %s
""" """
hits: dict[str, list[tuple[int, dict]]] = {}
with conn.cursor() as cur: with conn.cursor() as cur:
for batch in _chunks(full_set): for batch in _chunks(full_set):
cur.execute(sql, (tuple(batch), tuple(batch))) batch_i19s = {full_to_19[x][0] for x in batch}
related_batch = sorted({x for i19 in batch_i19s for x in related_keys_by_19[i19]})
cur.execute(sql, (tuple(related_batch), tuple(batch)))
for row in cur.fetchall(): for row in cur.fetchall():
legacy_iccid = row["iccid_mark"] legacy_iccid = row["iccid_mark"]
if not legacy_iccid: if not legacy_iccid:
continue continue
i19 = full_to_19.get(legacy_iccid) matched = full_to_19.get(legacy_iccid)
if i19 is None: if matched is None:
continue continue
# 取第一个匹配,后续 fetch_card_meta 已经会把重复命中的卡标记为 errors i19, rank = matched
if i19 in out: hits.setdefault(i19, []).append((rank, row))
continue for i19, rows in hits.items():
out[i19] = LegacyCardUsage( best_rank = min(rank for rank, _row in rows)
iccid=i19, row = next(row for rank, row in rows if rank == best_rank)
virtual_used_mb=_to_mb_decimal(row.get("total_bytes_cnt")), out[i19] = LegacyCardUsage(
flow_add_discount_pct=_to_mb_decimal(row.get("flow_add_discount")), iccid=i19,
has_active_package=bool(row.get("has_active_package")), virtual_used_mb=_to_mb_decimal(row.get("total_bytes_cnt")),
) flow_add_discount_pct=_to_mb_decimal(row.get("flow_add_discount")),
has_active_package=bool(row.get("has_active_package")),
)
return out return out
@@ -350,18 +380,19 @@ def fetch_commission_accounts(conn, agent_ids: Iterable[str]) -> dict[str, Legac
def fetch_card_meta( def fetch_card_meta(
conn, conn,
iccid_pairs: Iterable[tuple[str, str]], iccid_pairs: Iterable[tuple],
) -> tuple[dict[str, LegacyCardMeta], set[str]]: ) -> tuple[dict[str, LegacyCardMeta], set[str]]:
"""查每张卡的元数据(卡本体 + 当前生效套餐 + 虚拟号)。 """查每张卡的元数据(卡本体 + 当前生效套餐 + 虚拟号)。
匹配策略: 匹配策略:
- tbl_card 主表用 `iccid_mark IN (iccid_19 iccid_20)` 全长精确; - tbl_card 主表同时查优先键和显式允许的兜底键:有 iccid_20 时优先取
这样不会因前 19 位相同(校验位不同的两张 20 位卡)而误匹配多张 20 位精确命中;只有业务方也显式填了 iccid_19 时,才允许退回 19 位。
只填 iccid_20 的固定 20 位卡绝不拿 19 位前缀查主表。
- tbl_card_life / tbl_virtual_number 从表用 LEFT(iccid_mark, 19) 兜底, - tbl_card_life / tbl_virtual_number 从表用 LEFT(iccid_mark, 19) 兜底,
应对奇成两张表 iccid 长度不一致的脏数据 应对奇成两张表 iccid 长度不一致的脏数据
Args: Args:
iccid_pairs: (iccid_19, iccid_20) 列表;iccid_20 可为空字符串(CTCC 卡) iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表
Returns: Returns:
(metas, duplicate_iccid_19): (metas, duplicate_iccid_19):
@@ -369,23 +400,27 @@ def fetch_card_meta(
- duplicate_iccid_19: 一份业务方 iccid_19 命中奇成 tbl_card 多条记录的 set, - duplicate_iccid_19: 一份业务方 iccid_19 命中奇成 tbl_card 多条记录的 set,
这些卡不进 metas,sql_builder 会写 errors.csv 阻断 这些卡不进 metas,sql_builder 会写 errors.csv 阻断
""" """
pairs = [(p[0], p[1]) for p in iccid_pairs if p[0]] pairs = _normalize_iccid_pairs(iccid_pairs)
if not pairs: if not pairs:
return {}, set() return {}, set()
# 全长 ICCID → 业务方 iccid_19。同时维护 iccid_20 优先级: by_full: dict[str, tuple[str, int]] = {} # tbl_card 查询键 → (iccid_19, rank)
# 奇成 iccid_mark 命中时,优先取等于 iccid_20 的映射(20 位精确), related_keys_by_19: dict[str, set[str]] = {}
# 找不到再退到 iccid_19(奇成存了 19 位的兜底)。 for i19, i20, allow_i19_lookup in pairs:
by_full: dict[str, str] = {} # 任意长度 ICCID → iccid_19 by_full[i20 or i19] = (i19, 0)
for i19, i20 in pairs: if i20 and allow_i19_lookup:
by_full[i19] = i19 by_full[i19] = (i19, 1)
related_keys_by_19.setdefault(i19, set()).add(i19)
if i20: if i20:
by_full[i20] = i19 related_keys_by_19[i19].add(i20)
full_set = sorted(by_full.keys()) full_set = sorted(by_full.keys())
sql = """ sql = """
SELECT SELECT
c.id AS card_id,
c.iccid_mark AS iccid, c.iccid_mark AS iccid,
COALESCE(c.account_id, '') AS account_id,
COALESCE(c.account_name, '') AS account_name,
COALESCE(c.category, 0) AS category, COALESCE(c.category, 0) AS category,
COALESCE(vc.category_name, '') AS category_name, COALESCE(vc.category_name, '') AS category_name,
COALESCE(c.agent_id, '') AS agent_id, COALESCE(c.agent_id, '') AS agent_id,
@@ -415,33 +450,42 @@ def fetch_card_meta(
""" """
# 收集每个 iccid_19 命中的奇成行(可能多条 = 脏数据) # 收集每个 iccid_19 命中的奇成行(可能多条 = 脏数据)
hits: dict[str, list[dict]] = {} hits: dict[str, list[tuple[int, dict]]] = {}
with conn.cursor() as cur: with conn.cursor() as cur:
for batch in _chunks(full_set): for batch in _chunks(full_set):
cur.execute(sql, (tuple(batch), tuple(batch))) batch_i19s = {by_full[x][0] for x in batch}
related_batch = sorted({x for i19 in batch_i19s for x in related_keys_by_19[i19]})
cur.execute(sql, (tuple(related_batch), tuple(batch)))
for row in cur.fetchall(): for row in cur.fetchall():
legacy_iccid = row["iccid"] legacy_iccid = row["iccid"]
if not legacy_iccid: if not legacy_iccid:
continue continue
i19 = by_full.get(legacy_iccid) matched = by_full.get(legacy_iccid)
if i19 is None: if matched is None:
# WHERE 已经限定 IN,理论不会出现;保险起见再按前 19 位反查 continue
i19 = by_full.get(legacy_iccid[:19]) i19, rank = matched
if i19 is None: hits.setdefault(i19, []).append((rank, row))
continue
hits.setdefault(i19, []).append(row)
metas: dict[str, LegacyCardMeta] = {} metas: dict[str, LegacyCardMeta] = {}
duplicates: set[str] = set() duplicates: set[str] = set()
for i19, rows in hits.items(): for i19, rows in hits.items():
if len(rows) > 1: best_rank = min(rank for rank, _row in rows)
best_rows = [row for rank, row in rows if rank == best_rank]
# 同一张 tbl_card 可能因 card_life / virtual_number 前缀兜底 JOIN 出多行,
# 这不是主表重复;只有不同 card_id 才算同一业务 ICCID 命中多张卡。
rows_by_card_id: dict[str, dict] = {}
for row in best_rows:
rows_by_card_id[str(row.get("card_id") or row.get("iccid") or "")] = row
if len(rows_by_card_id) > 1:
duplicates.add(i19) duplicates.add(i19)
continue continue
row = rows[0] row = next(iter(rows_by_card_id.values()))
expire = row.get("expire_date") expire = row.get("expire_date")
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
metas[i19] = LegacyCardMeta( metas[i19] = LegacyCardMeta(
legacy_raw_iccid=row["iccid"], legacy_raw_iccid=row["iccid"],
account_id=row.get("account_id") or "",
account_name=row.get("account_name") or "",
category=int(row.get("category") or 0), category=int(row.get("category") or 0),
category_name=row.get("category_name") or "", category_name=row.get("category_name") or "",
agent_id=row.get("agent_id") or "", agent_id=row.get("agent_id") or "",

View File

@@ -29,6 +29,8 @@ class MissingTargetError(Exception):
@dataclass(frozen=True) @dataclass(frozen=True)
class CarrierMapping: class CarrierMapping:
legacy_account_id: str
legacy_account_name: str
legacy_category: int legacy_category: int
legacy_category_name: str legacy_category_name: str
target_carrier_id: Optional[int] target_carrier_id: Optional[int]
@@ -44,7 +46,7 @@ class CarrierMapping:
) if v in (None, "", 0) ) if v in (None, "", 0)
] ]
if missing: if missing:
raise MissingTargetError("carrier", str(self.legacy_category), self.legacy_category_name, missing) raise MissingTargetError("carrier", self.legacy_account_id, self.legacy_account_name, missing)
return self return self
@@ -80,13 +82,13 @@ class Mapping:
migration_user_id: int = 0 migration_user_id: int = 0
migration_batch_no: str = "MIGRATION-QICHENG" migration_batch_no: str = "MIGRATION-QICHENG"
carriers: dict[int, CarrierMapping] = field(default_factory=dict) carriers: dict[str, CarrierMapping] = field(default_factory=dict)
packages: dict[str, PackageMapping] = field(default_factory=dict) packages: dict[str, PackageMapping] = field(default_factory=dict)
series: dict[str, SeriesMapping] = field(default_factory=dict) series: dict[str, SeriesMapping] = field(default_factory=dict)
agents: dict[str, AgentMapping] = field(default_factory=dict) agents: dict[str, AgentMapping] = field(default_factory=dict)
def lookup_carrier(self, legacy_category: int) -> Optional[CarrierMapping]: def lookup_carrier(self, legacy_account_id: str) -> Optional[CarrierMapping]:
return self.carriers.get(int(legacy_category)) return self.carriers.get(str(legacy_account_id or "").strip())
def lookup_package(self, legacy_meal_id: str) -> Optional[PackageMapping]: def lookup_package(self, legacy_meal_id: str) -> Optional[PackageMapping]:
return self.packages.get(str(legacy_meal_id).strip()) return self.packages.get(str(legacy_meal_id).strip())
@@ -130,14 +132,20 @@ def load_mapping(config_dir: Path) -> Mapping:
) )
for item in raw.get("carriers") or []: for item in raw.get("carriers") or []:
legacy_account_id = _opt_str(item.get("legacy_account_id"))
# 旧版按 category 映射的配置会把多个实际运营商账号合成一项,不能安全复用。
if not legacy_account_id:
continue
c = CarrierMapping( c = CarrierMapping(
legacy_category=int(item["legacy_category"]), legacy_account_id=legacy_account_id,
legacy_account_name=str(item.get("legacy_account_name") or ""),
legacy_category=int(item.get("legacy_category") or 0),
legacy_category_name=str(item.get("legacy_category_name") or ""), legacy_category_name=str(item.get("legacy_category_name") or ""),
target_carrier_id=_opt_int(item.get("target_carrier_id")), target_carrier_id=_opt_int(item.get("target_carrier_id")),
target_carrier_type=_opt_str(item.get("target_carrier_type")), target_carrier_type=_opt_str(item.get("target_carrier_type")),
target_carrier_name=_opt_str(item.get("target_carrier_name")), target_carrier_name=_opt_str(item.get("target_carrier_name")),
) )
m.carriers[c.legacy_category] = c m.carriers[c.legacy_account_id] = c
for item in raw.get("packages") or []: for item in raw.get("packages") or []:
p = PackageMapping( p = PackageMapping(
@@ -178,13 +186,15 @@ def merge_and_save(config_dir: Path, mapping: Mapping) -> Path:
"migration_batch_no": mapping.migration_batch_no, "migration_batch_no": mapping.migration_batch_no,
"carriers": [ "carriers": [
{ {
"legacy_account_id": c.legacy_account_id,
"legacy_account_name": c.legacy_account_name,
"legacy_category": c.legacy_category, "legacy_category": c.legacy_category,
"legacy_category_name": c.legacy_category_name, "legacy_category_name": c.legacy_category_name,
"target_carrier_id": c.target_carrier_id, "target_carrier_id": c.target_carrier_id,
"target_carrier_type": c.target_carrier_type, "target_carrier_type": c.target_carrier_type,
"target_carrier_name": c.target_carrier_name, "target_carrier_name": c.target_carrier_name,
} }
for c in sorted(mapping.carriers.values(), key=lambda x: x.legacy_category) for c in sorted(mapping.carriers.values(), key=lambda x: (x.legacy_account_name, x.legacy_account_id))
], ],
"packages": [ "packages": [
{ {

View File

@@ -43,6 +43,8 @@ from .mapping_loader import Mapping, MissingTargetError
ORDER_NO_PREFIX_CARD = "MIG-CARD-" ORDER_NO_PREFIX_CARD = "MIG-CARD-"
ALLOCATION_NO_PREFIX = "MIG-ALLOC-" ALLOCATION_NO_PREFIX = "MIG-ALLOC-"
METADATA_SOURCE = "qicheng" METADATA_SOURCE = "qicheng"
STOP_REASON_NOT_REALNAME = "not_realname"
STOP_REASON_NO_PACKAGE = "no_package"
# ---------------- 通用工具 ---------------- # ---------------- 通用工具 ----------------
@@ -115,6 +117,53 @@ def _resolve_device_shop_code(
return mapping.lookup_agent_shop_code(meta.agent_id) or "" return mapping.lookup_agent_shop_code(meta.agent_id) or ""
def _initial_polling_stop_reason(card: CardRow) -> str:
"""生成迁移卡初始停机原因。
导入时卡默认 network_status=0。停复机服务只会自动复机轮询系统写入的停机原因
因此不能留空;普通卡先等待实名轮询确认,行业卡无需实名则交给套餐轮询重评估。
"""
if card.is_industry:
return STOP_REASON_NO_PACKAGE
return STOP_REASON_NOT_REALNAME
def _device_package_source_iccid(device: DeviceRow) -> str:
"""返回设备级套餐迁移使用的来源卡。
设备套餐在新系统挂到 device_id奇成套餐仍来自卡生命周期表。
优先取当前槽位,缺失时回退 slot1再回退任意已绑定卡。
"""
if not device.sim_iccids:
return ""
return (
device.sim_iccids.get(device.current_slot)
or device.sim_iccids.get(1)
or next(iter(device.sim_iccids.values()), "")
)
def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]:
return {d.virtual_no: d for d in devices}
def _runtime_asset_for_card(card: CardRow, devices_by_virtual_no: dict[str, DeviceRow]) -> tuple[str, str] | None:
"""返回运行态套餐/订单归属:(asset_type, asset_identifier)。
独立卡归属 iot_card设备绑定卡只用设备当前槽位卡生成一条设备级套餐
其他槽位不重复生成,避免同一设备出现多条并列主套餐。
"""
if not card.bound_device_virtual_no:
return "iot_card", card.iccid_full
device = devices_by_virtual_no.get(card.bound_device_virtual_no)
if device is None:
return None
if card.iccid_19 != _device_package_source_iccid(device):
return None
return "device", device.virtual_no
# ============================================================ # ============================================================
# 脚本1:资产导入 # 脚本1:资产导入
# ============================================================ # ============================================================
@@ -181,24 +230,26 @@ def _filter_cards_by_carrier(
"not_in_legacy", "奇成 tbl_card 中找不到该 ICCID(iccid_19/iccid_20 都没命中)", "not_in_legacy", "奇成 tbl_card 中找不到该 ICCID(iccid_19/iccid_20 都没命中)",
)) ))
continue continue
if not meta.category: if not meta.account_id:
errors.append(ErrorRow( errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid_19", c.iccid_19, "cards.csv", c.line_no, "iccid_19", c.iccid_19,
"no_category", "奇成 tbl_card.category 为空,无法定位运营商映射", "no_carrier_account", "奇成 tbl_card.account_id 为空,无法定位具体运营商账号映射",
)) ))
continue continue
carrier = mapping.lookup_carrier(meta.category) carrier = mapping.lookup_carrier(meta.account_id)
if carrier is None: if carrier is None:
errors.append(ErrorRow( errors.append(ErrorRow(
"cards.csv", c.line_no, "category", str(meta.category), "cards.csv", c.line_no, "account_id", meta.account_id,
"carrier_not_in_mapping", f"mapping.yaml.carriers 未配置 category={meta.category} ({meta.category_name})", "carrier_not_in_mapping",
f"mapping.yaml.carriers 未配置 account_id={meta.account_id} "
f"({meta.account_name}, category={meta.category}/{meta.category_name})",
)) ))
continue continue
try: try:
carrier.require_target() carrier.require_target()
except MissingTargetError as exc: except MissingTargetError as exc:
errors.append(ErrorRow( errors.append(ErrorRow(
"cards.csv", c.line_no, "category", str(meta.category), "cards.csv", c.line_no, "account_id", meta.account_id,
"carrier_target_missing", str(exc), "carrier_target_missing", str(exc),
)) ))
continue continue
@@ -226,7 +277,7 @@ def _write_iot_cards(
f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)")) f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)"))
for c in cards: for c in cards:
meta = card_metas[c.iccid_19] meta = card_metas[c.iccid_19]
carrier = mapping.lookup_carrier(meta.category) carrier = mapping.lookup_carrier(meta.account_id)
assert carrier is not None # 已被 _filter_cards_by_carrier 保证 assert carrier is not None # 已被 _filter_cards_by_carrier 保证
# 业务方直接给的两列;iccid 唯一键 = 有 iccid_20 用 iccid_20,否则 iccid_19 # 业务方直接给的两列;iccid 唯一键 = 有 iccid_20 用 iccid_20,否则 iccid_19
@@ -254,7 +305,7 @@ def _write_iot_cards(
f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n" f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n"
" 1, 0, 0, 0,\n" " 1, 0, 0, 0,\n"
" 1, 1, TRUE,\n" " 1, 1, TRUE,\n"
" 'after_order', '',\n" f" 'after_order', {_sql_str(_initial_polling_stop_reason(c))},\n"
f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, " f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, "
f"{_sql_str(meta.virtual_no) if meta.virtual_no else 'NULL'}, " f"{_sql_str(meta.virtual_no) if meta.virtual_no else 'NULL'}, "
f"{_shop_id_sub(shop_code)}, {series_id_expr},\n" f"{_shop_id_sub(shop_code)}, {series_id_expr},\n"
@@ -509,6 +560,7 @@ def write_step2(
output_dir: Path, output_dir: Path,
*, *,
cards: list[CardRow], cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping, mapping: Mapping,
card_metas: dict[str, LegacyCardMeta], card_metas: dict[str, LegacyCardMeta],
usage_snapshots: dict[str, LegacyCardUsage], usage_snapshots: dict[str, LegacyCardUsage],
@@ -518,6 +570,7 @@ def write_step2(
"""生成 step2_* SQL + warnings.csv + summary.txt。""" """生成 step2_* SQL + warnings.csv + summary.txt。"""
user_id = mapping.migration_user_id user_id = mapping.migration_user_id
warnings: list[tuple[str, str, str]] = [] warnings: list[tuple[str, str, str]] = []
devices_by_virtual_no = _device_by_virtual_no(devices)
# 卡 → package(从 card_meta.current_meal_id 经 mapping.packages 查 target) # 卡 → package(从 card_meta.current_meal_id 经 mapping.packages 查 target)
resolved_packages: dict[str, tuple[int, str]] = {} # iccid_19 → (target_package_id, expire_date) resolved_packages: dict[str, tuple[int, str]] = {} # iccid_19 → (target_package_id, expire_date)
for c in cards: for c in cards:
@@ -549,9 +602,9 @@ def write_step2(
"(status=1),无法精确反算真用量,按 flow_add_discount=0 处理(真=虚)", "(status=1),无法精确反算真用量,按 flow_add_discount=0 处理(真=虚)",
)) ))
_write_migration_orders(output_dir / "step2_01_migration_orders.sql", cards, resolved_packages, user_id) _write_migration_orders(output_dir / "step2_01_migration_orders.sql", cards, resolved_packages, devices_by_virtual_no, user_id)
_write_package_usages( _write_package_usages(
output_dir / "step2_02_package_usages.sql", cards, resolved_packages, usage_snapshots, user_id, output_dir / "step2_02_package_usages.sql", cards, resolved_packages, devices_by_virtual_no, usage_snapshots, user_id,
) )
_write_card_data_usage(output_dir / "step2_03_card_data_usage_update.sql", cards, usage_snapshots) _write_card_data_usage(output_dir / "step2_03_card_data_usage_update.sql", cards, usage_snapshots)
@@ -571,6 +624,7 @@ def write_step2(
output_dir / "summary.txt", output_dir / "summary.txt",
cards=cards, cards=cards,
resolved=resolved_packages, resolved=resolved_packages,
devices_by_virtual_no=devices_by_virtual_no,
agents=eligible_agents, agents=eligible_agents,
warnings=warnings, warnings=warnings,
) )
@@ -590,6 +644,7 @@ def _write_migration_orders(
path: Path, path: Path,
cards: list[CardRow], cards: list[CardRow],
resolved_packages: dict[str, tuple[int, str]], resolved_packages: dict[str, tuple[int, str]],
devices_by_virtual_no: dict[str, DeviceRow],
user_id: int, user_id: int,
) -> None: ) -> None:
"""伪订单关键字段(详见方案文档 10.2):source=admin / buyer_type=personal / """伪订单关键字段(详见方案文档 10.2):source=admin / buyer_type=personal /
@@ -600,7 +655,36 @@ def _write_migration_orders(
for c in cards: for c in cards:
if c.iccid_19 not in resolved_packages: if c.iccid_19 not in resolved_packages:
continue continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, asset_identifier = asset
order_no = _make_card_order_no(c.iccid_full) order_no = _make_card_order_no(c.iccid_full)
if asset_type == "device":
f.write(
"INSERT INTO tb_order (\n"
" order_no, order_type, buyer_type, buyer_id,\n"
" device_id, asset_identifier,\n"
" total_amount, actual_paid_amount,\n"
" payment_method, payment_status, paid_at,\n"
" commission_status, commission_result, commission_config_version,\n"
" source, generation,\n"
" operator_account_type, operator_account_name,\n"
" creator, updater, created_at, updated_at\n"
")\n"
f"SELECT {_sql_str(order_no)}, 'device', 'personal', 0,\n"
f" d.id, {_sql_str(asset_identifier)},\n"
" 0, 0,\n"
" 'offline', 2, NOW(),\n"
" 2, 2, 0,\n"
" 'admin', 1,\n"
" 'platform', '奇成数据迁移',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
"FROM tb_device d\n"
f"WHERE d.virtual_no = {_sql_str(asset_identifier)} AND d.deleted_at IS NULL\n"
"ON CONFLICT (order_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
continue
f.write( f.write(
"INSERT INTO tb_order (\n" "INSERT INTO tb_order (\n"
" order_no, order_type, buyer_type, buyer_id,\n" " order_no, order_type, buyer_type, buyer_id,\n"
@@ -630,6 +714,7 @@ def _write_package_usages(
path: Path, path: Path,
cards: list[CardRow], cards: list[CardRow],
resolved_packages: dict[str, tuple[int, str]], resolved_packages: dict[str, tuple[int, str]],
devices_by_virtual_no: dict[str, DeviceRow],
usage_snapshots: dict[str, LegacyCardUsage], usage_snapshots: dict[str, LegacyCardUsage],
user_id: int, user_id: int,
) -> None: ) -> None:
@@ -638,6 +723,10 @@ def _write_package_usages(
for c in cards: for c in cards:
if c.iccid_19 not in resolved_packages: if c.iccid_19 not in resolved_packages:
continue continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, _asset_identifier = asset
target_pkg_id, expire = resolved_packages[c.iccid_19] target_pkg_id, expire = resolved_packages[c.iccid_19]
order_no = _make_card_order_no(c.iccid_full) order_no = _make_card_order_no(c.iccid_full)
expires_expr = f"{_sql_str(expire)}::timestamp" if expire else "NULL" expires_expr = f"{_sql_str(expire)}::timestamp" if expire else "NULL"
@@ -648,10 +737,13 @@ def _write_package_usages(
"THEN p.virtual_data_mb ELSE p.real_data_mb END" "THEN p.virtual_data_mb ELSE p.real_data_mb END"
) )
usage_expr = f"LEAST({used_mb}, {effective_limit_expr})" usage_expr = f"LEAST({used_mb}, {effective_limit_expr})"
carrier_column = "device_id" if asset_type == "device" else "iot_card_id"
carrier_select = "o.device_id" if asset_type == "device" else "o.iot_card_id"
usage_type = "device" if asset_type == "device" else "single_card"
f.write( f.write(
"INSERT INTO tb_package_usage (\n" "INSERT INTO tb_package_usage (\n"
" order_id, order_no, package_id, usage_type,\n" " order_id, order_no, package_id, usage_type,\n"
" iot_card_id,\n" f" {carrier_column},\n"
" data_limit_mb, data_usage_mb,\n" " data_limit_mb, data_usage_mb,\n"
" virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot,\n" " virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot,\n"
" status, priority, generation,\n" " status, priority, generation,\n"
@@ -661,8 +753,8 @@ def _write_package_usages(
" data_reset_cycle,\n" " data_reset_cycle,\n"
" creator, updater, created_at, updated_at\n" " creator, updater, created_at, updated_at\n"
")\n" ")\n"
"SELECT o.id, o.order_no, p.id, 'single_card',\n" f"SELECT o.id, o.order_no, p.id, {_sql_str(usage_type)},\n"
" o.iot_card_id,\n" f" {carrier_select},\n"
f" p.real_data_mb, {usage_expr},\n" f" p.real_data_mb, {usage_expr},\n"
f" {effective_limit_expr},\n" f" {effective_limit_expr},\n"
" CASE WHEN p.enable_virtual_data AND p.virtual_data_mb > 0 THEN p.virtual_ratio ELSE 1.0 END,\n" " CASE WHEN p.enable_virtual_data AND p.virtual_data_mb > 0 THEN p.virtual_ratio ELSE 1.0 END,\n"
@@ -832,13 +924,18 @@ def _write_warnings(path: Path, warnings: list[tuple[str, str, str]]) -> None:
writer.writerow([kind, key, message]) writer.writerow([kind, key, message])
def _write_summary_step2(path: Path, *, cards, resolved, agents, warnings) -> None: def _write_summary_step2(path: Path, *, cards, resolved, devices_by_virtual_no, agents, warnings) -> None:
generated_count = sum(
1
for c in cards
if c.iccid_19 in resolved and _runtime_asset_for_card(c, devices_by_virtual_no) is not None
)
lines = [ lines = [
"奇成迁移 - 脚本2 关联数据导入摘要", "奇成迁移 - 脚本2 关联数据导入摘要",
"=" * 48, "=" * 48,
f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"卡总数 : {len(cards)}", f"卡总数 : {len(cards)}",
f" 生成伪订单+套餐 : {len(resolved)}", f" 生成伪订单+套餐 : {generated_count}",
f"代理钱包初始化 : {len(agents)}", f"代理钱包初始化 : {len(agents)}",
f"警告条目 : {len(warnings)} (详见 warnings.csv)", f"警告条目 : {len(warnings)} (详见 warnings.csv)",
] ]

View File

@@ -43,7 +43,7 @@ def main() -> int:
cards, devices, errors = csv_loader.load_assets(resources_dir) cards, devices, errors = csv_loader.load_assets(resources_dir)
# 连奇成查卡元数据(运营商分类、agent_id、msisdn、virtual_no、当前生效套餐) # 连奇成查卡元数据(运营商分类、agent_id、msisdn、virtual_no、当前生效套餐)
iccid_pairs = [(c.iccid_19, c.iccid_20) for c in cards] iccid_pairs = [(c.iccid_19, c.iccid_20, c.allow_iccid_19_lookup) for c in cards]
dsn = mapping_loader.load_legacy_dsn(config_dir) dsn = mapping_loader.load_legacy_dsn(config_dir)
with legacy_query.connect_readonly(dsn) as conn: with legacy_query.connect_readonly(dsn) as conn:
card_metas, dup_iccid_19 = legacy_query.fetch_card_meta(conn, iccid_pairs) card_metas, dup_iccid_19 = legacy_query.fetch_card_meta(conn, iccid_pairs)

View File

@@ -37,9 +37,9 @@ def main() -> int:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
mapping = mapping_loader.load_mapping(config_dir) mapping = mapping_loader.load_mapping(config_dir)
cards, _devices, _errors = csv_loader.load_assets(resources_dir) cards, devices, _errors = csv_loader.load_assets(resources_dir)
iccid_pairs = [(c.iccid_19, c.iccid_20) for c in cards] iccid_pairs = [(c.iccid_19, c.iccid_20, c.allow_iccid_19_lookup) for c in cards]
agent_ids = [a.legacy_agent_id for a in mapping.agents.values() if (a.target_shop_code or "").strip()] agent_ids = [a.legacy_agent_id for a in mapping.agents.values() if (a.target_shop_code or "").strip()]
dsn = mapping_loader.load_legacy_dsn(config_dir) dsn = mapping_loader.load_legacy_dsn(config_dir)
@@ -52,6 +52,7 @@ def main() -> int:
warnings = sql_builder.write_step2( warnings = sql_builder.write_step2(
output_dir, output_dir,
cards=cards, cards=cards,
devices=devices,
mapping=mapping, mapping=mapping,
card_metas=card_metas, card_metas=card_metas,
usage_snapshots=usage_snapshots, usage_snapshots=usage_snapshots,

View File

@@ -7,16 +7,19 @@
## cards.csv 卡资产清单 ## cards.csv 卡资产清单
每张要迁移的卡一行。**必须同时提供 iccid_19iccid_20**(因为奇成历史数据 每张要迁移的卡一行。`iccid_19` / `iccid_20` 至少填一个。只有 20 位完整 ICCID
长度不可信,业务方提供两列作为权威值) 时可只填 `iccid_20`,脚本会自动派生前 19 位作为内部稳定 ID
只填 `iccid_20` 的卡,脚本查询奇成主表时只按 20 位精确匹配;只有同时显式填写
`iccid_19``iccid_20` 时,才允许 20 位未命中后退回 19 位查询。
| 列名 | 必填 | 说明 | | 列名 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `iccid_19` | | ICCID 前 19 位(所有卡都有,作为跨文件稳定 ID;必须 19 位纯数字) | | `iccid_19` | 条件必填 | ICCID 前 19 位(内部稳定 ID;必须 19 位;允许奇成历史字母位)。`iccid_20` 已填时可留空 |
| `iccid_20` | 视运营商 | 完整 20 位 ICCID;CMCC/CUCC/CBN(20 位运营商) **必填**,CTCC(19 位)**必须留空**;非空时前 19 位必须等于 `iccid_19` | | `iccid_20` | 视运营商 | 完整 20 位 ICCID;CMCC/CUCC/CBN(20 位运营商) **必填**,CTCC(19 位)**必须留空**;非空时前 19 位必须等于 `iccid_19` |
| `is_industry` | 否 | 是否行业卡,留空 = `false`(普通卡) | | `is_industry` | 否 | 是否行业卡,留空 = `false`(普通卡) |
运营商类型从奇成 `tbl_card.category` 自动推断,在查完奇成后会做一致性校验: 具体运营商从奇成 `tbl_card.account_id/account_name` 扫描后写入 `mapping.yaml.carriers`;
`tbl_card.category` 只是奇成大类,仅辅助人工填写 `target_carrier_type`。填完映射后会做一致性校验:
- CTCC 卡填了 `iccid_20``errors.csv: carrier_length_conflict` 阻断 - CTCC 卡填了 `iccid_20``errors.csv: carrier_length_conflict` 阻断
- 非 CTCC 卡 `iccid_20` 留空 → 同上阻断 - 非 CTCC 卡 `iccid_20` 留空 → 同上阻断
- `iccid_19` 在奇成 `tbl_card` 命中多条记录 → `errors.csv: legacy_duplicate` 阻断,请业务方人工确认 - `iccid_19` 在奇成 `tbl_card` 命中多条记录 → `errors.csv: legacy_duplicate` 阻断,请业务方人工确认

View File

@@ -1,4 +0,0 @@
iccid_19,iccid_20,is_industry
8985200026333877243,89852000263338772439,
8986062300000000001,,true
8986006654000000000,89860066540000000002,

View File

@@ -1,4 +0,0 @@
imei,virtual_no,sim_iccid_1,sim_iccid_2,sim_iccid_3,sim_iccid_4
860000000000001,DEV-202604-0001,89852000263338772439,8986062300000000001,,
,DEV-202604-0002,89860066540000000002,,,
860000000000003,,89860000000000004,,,

View File

@@ -3,7 +3,7 @@
工作流: 工作流:
1. 读 resources/cards.csv 拿到 iccid 列表 1. 读 resources/cards.csv 拿到 iccid 列表
2. 连奇成只读查询每张卡的 category / agent_id / 当前生效套餐 / 套餐系列 2. 连奇成只读查询每张卡的 account/category / agent_id / 当前生效套餐 / 套餐系列
3. 加载已有的 config/mapping.yaml(若没有则创建空的) 3. 加载已有的 config/mapping.yaml(若没有则创建空的)
4. 把扫描到的 legacy_* 增量补到 mapping 里(已有项的 target_* 字段保留,新项的 target_* 为 null) 4. 把扫描到的 legacy_* 增量补到 mapping 里(已有项的 target_* 字段保留,新项的 target_* 为 null)
5. 写回 config/mapping.yaml 5. 写回 config/mapping.yaml
@@ -31,14 +31,17 @@ from lib.mapping_loader import ( # noqa: E402
) )
def _read_iccid_pairs(cards_csv: Path) -> list[tuple[str, str]]: def _read_iccid_pairs(cards_csv: Path) -> list[tuple[str, str, bool]]:
"""从 cards.csv 读 iccid_19 + iccid_20 列(只关心这两列,其他列忽略)。 """从 cards.csv 读 iccid_19 + iccid_20 列(只关心这两列,其他列忽略)。
返回 (iccid_19, iccid_20) 列表;iccid_20 可为空字符串(CTCC 卡留空) 返回 (iccid_19, iccid_20, allow_iccid_19_lookup) 列表
只填 iccid_20 时,iccid_19 从前 19 位派生,但不用于奇成主表兜底查询。
""" """
if not cards_csv.exists(): if not cards_csv.exists():
raise FileNotFoundError(f"找不到 {cards_csv},请按 resources/README.md 准备") raise FileNotFoundError(f"找不到 {cards_csv},请按 resources/README.md 准备")
pairs: dict[str, str] = {} pairs: dict[str, tuple[str, bool]] = {}
first_seen_line: dict[str, int] = {}
errors: list[str] = []
with cards_csv.open("r", encoding="utf-8-sig", newline="") as f: with cards_csv.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
headers = reader.fieldnames or [] headers = reader.fieldnames or []
@@ -46,12 +49,37 @@ def _read_iccid_pairs(cards_csv: Path) -> list[tuple[str, str]]:
raise ValueError(f"{cards_csv} 必须包含 iccid_19 列") raise ValueError(f"{cards_csv} 必须包含 iccid_19 列")
if "iccid_20" not in headers: if "iccid_20" not in headers:
raise ValueError(f"{cards_csv} 必须包含 iccid_20 列(CTCC 卡留空)") raise ValueError(f"{cards_csv} 必须包含 iccid_20 列(CTCC 卡留空)")
for row in reader: for line_no, row in enumerate(reader, start=2):
i19 = (row.get("iccid_19") or "").strip() i19 = (row.get("iccid_19") or "").strip()
i20 = (row.get("iccid_20") or "").strip() i20 = (row.get("iccid_20") or "").strip()
if i19: allow_i19_lookup = bool(i19)
pairs[i19] = i20 if not i19 and not i20 and not any((v or "").strip() for v in row.values()):
return sorted(pairs.items()) continue
if i20 and len(i20) != 20:
errors.append(f"{line_no} 行 iccid_20 必须是 20 位: {i20!r}")
continue
if not i19:
if not i20:
errors.append(f"{line_no} 行 iccid_19 和 iccid_20 不能同时为空")
continue
i19 = i20[:19]
if len(i19) != 19:
errors.append(f"{line_no} 行 iccid_19 必须是 19 位: {i19!r}")
continue
if i20 and i20[:19] != i19:
errors.append(f"{line_no} 行 iccid_20 前 19 位必须等于 iccid_19: {i20!r}")
continue
if i19 in pairs:
errors.append(f"{line_no} 行 iccid_19 与第 {first_seen_line[i19]} 行重复: {i19!r}")
continue
first_seen_line[i19] = line_no
pairs[i19] = (i20, allow_i19_lookup)
if errors:
preview = "\n".join(f" - {msg}" for msg in errors[:20])
if len(errors) > 20:
preview += f"\n - 其余 {len(errors) - 20} 个错误已省略"
raise ValueError(f"{cards_csv} 存在无效 ICCID,请先修正后再扫描:\n{preview}")
return [(i19, i20, allow_i19_lookup) for i19, (i20, allow_i19_lookup) in sorted(pairs.items())]
def main() -> int: def main() -> int:
@@ -64,9 +92,13 @@ def main() -> int:
config_dir = (base / args.config_dir).resolve() config_dir = (base / args.config_dir).resolve()
resources_dir = (base / args.resources_dir).resolve() resources_dir = (base / args.resources_dir).resolve()
iccid_pairs = _read_iccid_pairs(resources_dir / "cards.csv") try:
iccid_pairs = _read_iccid_pairs(resources_dir / "cards.csv")
except (FileNotFoundError, ValueError) as exc:
print(str(exc), file=sys.stderr)
return 1
if not iccid_pairs: if not iccid_pairs:
print("cards.csv 中没有 iccid_19,无需扫描", file=sys.stderr) print("cards.csv 中没有有效 ICCID,无需扫描", file=sys.stderr)
return 1 return 1
print(f"读到 {len(iccid_pairs)} 个 ICCID,连接奇成扫描元数据...") print(f"读到 {len(iccid_pairs)} 个 ICCID,连接奇成扫描元数据...")
@@ -107,21 +139,28 @@ def _merge(
) )
for meta in card_metas.values(): for meta in card_metas.values():
# carrier # carrier:按开卡公司/运营商账号映射,category 仅作为辅助信息。
if meta.category and meta.category not in merged.carriers: if meta.account_id and meta.account_id not in merged.carriers:
merged.carriers[meta.category] = CarrierMapping( merged.carriers[meta.account_id] = CarrierMapping(
legacy_account_id=meta.account_id,
legacy_account_name=meta.account_name,
legacy_category=meta.category, legacy_category=meta.category,
legacy_category_name=meta.category_name, legacy_category_name=meta.category_name,
target_carrier_id=None, target_carrier_id=None,
target_carrier_type=None, target_carrier_type=None,
target_carrier_name=None, target_carrier_name=None,
) )
elif meta.category in merged.carriers and not merged.carriers[meta.category].legacy_category_name: elif meta.account_id in merged.carriers and (
# 补全 legacy_category_name(如果之前是空的) not merged.carriers[meta.account_id].legacy_account_name
old = merged.carriers[meta.category] or not merged.carriers[meta.account_id].legacy_category_name
merged.carriers[meta.category] = CarrierMapping( ):
# 补全 legacy_* 快照(如果之前是空的)
old = merged.carriers[meta.account_id]
merged.carriers[meta.account_id] = CarrierMapping(
legacy_account_id=old.legacy_account_id,
legacy_account_name=meta.account_name or old.legacy_account_name,
legacy_category=old.legacy_category, legacy_category=old.legacy_category,
legacy_category_name=meta.category_name, legacy_category_name=meta.category_name or old.legacy_category_name,
target_carrier_id=old.target_carrier_id, target_carrier_id=old.target_carrier_id,
target_carrier_type=old.target_carrier_type, target_carrier_type=old.target_carrier_type,
target_carrier_name=old.target_carrier_name, target_carrier_name=old.target_carrier_name,
@@ -154,9 +193,12 @@ def _merge(
def _collect_todo(mapping: Mapping) -> dict[str, list[str]]: def _collect_todo(mapping: Mapping) -> dict[str, list[str]]:
"""收集所有待填写的项,按类别分组。""" """收集所有待填写的项,按类别分组。"""
todo: dict[str, list[str]] = {"carriers": [], "packages": [], "agents": []} todo: dict[str, list[str]] = {"carriers": [], "packages": [], "agents": []}
for c in sorted(mapping.carriers.values(), key=lambda x: x.legacy_category): for c in sorted(mapping.carriers.values(), key=lambda x: (x.legacy_account_name, x.legacy_account_id)):
if not c.target_carrier_id or not c.target_carrier_type or not c.target_carrier_name: if not c.target_carrier_id or not c.target_carrier_type or not c.target_carrier_name:
todo["carriers"].append(f"category={c.legacy_category} ({c.legacy_category_name})") todo["carriers"].append(
f"account_id={c.legacy_account_id} ({c.legacy_account_name}, "
f"category={c.legacy_category}/{c.legacy_category_name})"
)
for p in sorted(mapping.packages.values(), key=lambda x: x.legacy_meal_id): for p in sorted(mapping.packages.values(), key=lambda x: x.legacy_meal_id):
if not p.target_package_id: if not p.target_package_id:
todo["packages"].append(f"meal_id={p.legacy_meal_id} ({p.legacy_meal_name})") todo["packages"].append(f"meal_id={p.legacy_meal_id} ({p.legacy_meal_name})")