迁移脚本的一些问题修复
This commit is contained in:
@@ -20,9 +20,9 @@ DEVICE_HEADERS_AT_LEAST_ONE = ("imei", "virtual_no")
|
||||
class CardRow:
|
||||
"""cards.csv 单行的解析结果(只含业务方决策字段)。
|
||||
|
||||
业务方必须同时提供 iccid_19 和 iccid_20(20 位运营商):
|
||||
- iccid_19:前 19 位,所有卡必填,作为跨文件稳定 ID
|
||||
- iccid_20:20 位完整 ICCID,CMCC/CUCC/CBN 必填、CTCC 必须留空
|
||||
业务方至少提供 iccid_19 / iccid_20 之一:
|
||||
- iccid_19:前 19 位,可作为奇成主表查询键
|
||||
- iccid_20:20 位完整 ICCID,固定 20 位运营商只填这一列即可
|
||||
- 运营商类型与 iccid_20 的一致性在查完奇成后由 sql_builder 校验
|
||||
新系统 iccid 唯一键 = iccid_20 优先,否则 iccid_19。
|
||||
"""
|
||||
@@ -30,6 +30,7 @@ class CardRow:
|
||||
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(普通卡)
|
||||
|
||||
# 跨文件校验后填充
|
||||
@@ -159,6 +160,16 @@ def _load_cards(path: Path, result: LoadResult) -> None:
|
||||
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:
|
||||
@@ -186,7 +197,13 @@ def _load_cards(path: Path, result: LoadResult) -> None:
|
||||
continue
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -99,7 +99,10 @@ def prefix_19(iccid: str) -> str:
|
||||
|
||||
|
||||
def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]:
|
||||
"""iccid_19 必须 19 位纯数字。"""
|
||||
"""iccid_19 必须是 19 位。
|
||||
|
||||
奇成历史数据里存在带字母的 ICCID,迁移脚本只校验长度,不强制纯数字。
|
||||
"""
|
||||
if not iccid_19:
|
||||
return ValidationError(code="iccid_19_required", message="iccid_19 不能为空")
|
||||
if len(iccid_19) != 19:
|
||||
@@ -107,13 +110,11 @@ def validate_iccid_19(iccid_19: str) -> Optional[ValidationError]:
|
||||
code="iccid_19_length",
|
||||
message=f"iccid_19 必须是 19 位,当前 {len(iccid_19)} 位",
|
||||
)
|
||||
if not iccid_19.isdigit():
|
||||
return ValidationError(code="iccid_19_format", message="iccid_19 必须是纯数字")
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
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",
|
||||
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:
|
||||
return ValidationError(
|
||||
code="iccid_20_prefix_mismatch",
|
||||
|
||||
@@ -14,6 +14,23 @@ from typing import Any, Iterable, Iterator, Optional
|
||||
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():
|
||||
"""延迟导入 pymysql,这样脚本1(纯离线)不需要安装 pymysql 也能运行。"""
|
||||
try:
|
||||
@@ -110,6 +127,8 @@ class LegacyCardMeta:
|
||||
"""
|
||||
|
||||
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_name: str # tbl_vendor_category.category_name
|
||||
agent_id: str
|
||||
@@ -242,7 +261,7 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
|
||||
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。
|
||||
|
||||
奇成 ``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`` 反算真用量写入新系统。
|
||||
|
||||
Args:
|
||||
iccid_pairs: (iccid_19, iccid_20) 列表;iccid_20 可为空字符串。
|
||||
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
|
||||
|
||||
Returns:
|
||||
{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] = {}
|
||||
if not pairs:
|
||||
return out
|
||||
|
||||
full_to_19: dict[str, str] = {}
|
||||
for i19, i20 in pairs:
|
||||
full_to_19[i19] = i19
|
||||
full_to_19: dict[str, tuple[str, int]] = {}
|
||||
related_keys_by_19: dict[str, set[str]] = {}
|
||||
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:
|
||||
full_to_19[i20] = i19
|
||||
related_keys_by_19[i19].add(i20)
|
||||
full_set = sorted(full_to_19.keys())
|
||||
|
||||
# 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
|
||||
WHERE c.iccid_mark IN %s
|
||||
"""
|
||||
hits: dict[str, list[tuple[int, dict]]] = {}
|
||||
with conn.cursor() as cur:
|
||||
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():
|
||||
legacy_iccid = row["iccid_mark"]
|
||||
if not legacy_iccid:
|
||||
continue
|
||||
i19 = full_to_19.get(legacy_iccid)
|
||||
if i19 is None:
|
||||
matched = full_to_19.get(legacy_iccid)
|
||||
if matched is None:
|
||||
continue
|
||||
# 取第一个匹配,后续 fetch_card_meta 已经会把重复命中的卡标记为 errors
|
||||
if i19 in out:
|
||||
continue
|
||||
out[i19] = LegacyCardUsage(
|
||||
iccid=i19,
|
||||
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")),
|
||||
)
|
||||
i19, rank = matched
|
||||
hits.setdefault(i19, []).append((rank, row))
|
||||
for i19, rows in hits.items():
|
||||
best_rank = min(rank for rank, _row in rows)
|
||||
row = next(row for rank, row in rows if rank == best_rank)
|
||||
out[i19] = LegacyCardUsage(
|
||||
iccid=i19,
|
||||
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
|
||||
|
||||
|
||||
@@ -350,18 +380,19 @@ def fetch_commission_accounts(conn, agent_ids: Iterable[str]) -> dict[str, Legac
|
||||
|
||||
def fetch_card_meta(
|
||||
conn,
|
||||
iccid_pairs: Iterable[tuple[str, str]],
|
||||
iccid_pairs: Iterable[tuple],
|
||||
) -> tuple[dict[str, LegacyCardMeta], set[str]]:
|
||||
"""查每张卡的元数据(卡本体 + 当前生效套餐 + 虚拟号)。
|
||||
|
||||
匹配策略:
|
||||
- tbl_card 主表用 `iccid_mark IN (iccid_19 ∪ iccid_20)` 全长精确;
|
||||
这样不会因前 19 位相同(校验位不同的两张 20 位卡)而误匹配多张
|
||||
- tbl_card 主表同时查优先键和显式允许的兜底键:有 iccid_20 时优先取
|
||||
20 位精确命中;只有业务方也显式填了 iccid_19 时,才允许退回 19 位。
|
||||
只填 iccid_20 的固定 20 位卡绝不拿 19 位前缀查主表。
|
||||
- tbl_card_life / tbl_virtual_number 从表用 LEFT(iccid_mark, 19) 兜底,
|
||||
应对奇成两张表 iccid 长度不一致的脏数据
|
||||
|
||||
Args:
|
||||
iccid_pairs: (iccid_19, iccid_20) 列表;iccid_20 可为空字符串(CTCC 卡)。
|
||||
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
|
||||
|
||||
Returns:
|
||||
(metas, duplicate_iccid_19):
|
||||
@@ -369,23 +400,27 @@ def fetch_card_meta(
|
||||
- duplicate_iccid_19: 一份业务方 iccid_19 命中奇成 tbl_card 多条记录的 set,
|
||||
这些卡不进 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:
|
||||
return {}, set()
|
||||
|
||||
# 全长 ICCID → 业务方 iccid_19。同时维护 iccid_20 优先级:
|
||||
# 奇成 iccid_mark 命中时,优先取等于 iccid_20 的映射(20 位精确),
|
||||
# 找不到再退到 iccid_19(奇成存了 19 位的兜底)。
|
||||
by_full: dict[str, str] = {} # 任意长度 ICCID → iccid_19
|
||||
for i19, i20 in pairs:
|
||||
by_full[i19] = i19
|
||||
by_full: dict[str, tuple[str, int]] = {} # tbl_card 查询键 → (iccid_19, rank)
|
||||
related_keys_by_19: dict[str, set[str]] = {}
|
||||
for i19, i20, allow_i19_lookup in pairs:
|
||||
by_full[i20 or i19] = (i19, 0)
|
||||
if i20 and allow_i19_lookup:
|
||||
by_full[i19] = (i19, 1)
|
||||
related_keys_by_19.setdefault(i19, set()).add(i19)
|
||||
if i20:
|
||||
by_full[i20] = i19
|
||||
related_keys_by_19[i19].add(i20)
|
||||
full_set = sorted(by_full.keys())
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
c.id AS card_id,
|
||||
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(vc.category_name, '') AS category_name,
|
||||
COALESCE(c.agent_id, '') AS agent_id,
|
||||
@@ -415,33 +450,42 @@ def fetch_card_meta(
|
||||
"""
|
||||
|
||||
# 收集每个 iccid_19 命中的奇成行(可能多条 = 脏数据)
|
||||
hits: dict[str, list[dict]] = {}
|
||||
hits: dict[str, list[tuple[int, dict]]] = {}
|
||||
with conn.cursor() as cur:
|
||||
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():
|
||||
legacy_iccid = row["iccid"]
|
||||
if not legacy_iccid:
|
||||
continue
|
||||
i19 = by_full.get(legacy_iccid)
|
||||
if i19 is None:
|
||||
# WHERE 已经限定 IN,理论不会出现;保险起见再按前 19 位反查
|
||||
i19 = by_full.get(legacy_iccid[:19])
|
||||
if i19 is None:
|
||||
continue
|
||||
hits.setdefault(i19, []).append(row)
|
||||
matched = by_full.get(legacy_iccid)
|
||||
if matched is None:
|
||||
continue
|
||||
i19, rank = matched
|
||||
hits.setdefault(i19, []).append((rank, row))
|
||||
|
||||
metas: dict[str, LegacyCardMeta] = {}
|
||||
duplicates: set[str] = set()
|
||||
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)
|
||||
continue
|
||||
row = rows[0]
|
||||
row = next(iter(rows_by_card_id.values()))
|
||||
expire = row.get("expire_date")
|
||||
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
|
||||
metas[i19] = LegacyCardMeta(
|
||||
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_name=row.get("category_name") or "",
|
||||
agent_id=row.get("agent_id") or "",
|
||||
|
||||
@@ -29,6 +29,8 @@ class MissingTargetError(Exception):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CarrierMapping:
|
||||
legacy_account_id: str
|
||||
legacy_account_name: str
|
||||
legacy_category: int
|
||||
legacy_category_name: str
|
||||
target_carrier_id: Optional[int]
|
||||
@@ -44,7 +46,7 @@ class CarrierMapping:
|
||||
) if v in (None, "", 0)
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
@@ -80,13 +82,13 @@ class Mapping:
|
||||
|
||||
migration_user_id: int = 0
|
||||
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)
|
||||
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_carrier(self, legacy_account_id: str) -> Optional[CarrierMapping]:
|
||||
return self.carriers.get(str(legacy_account_id or "").strip())
|
||||
|
||||
def lookup_package(self, legacy_meal_id: str) -> Optional[PackageMapping]:
|
||||
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 []:
|
||||
legacy_account_id = _opt_str(item.get("legacy_account_id"))
|
||||
# 旧版按 category 映射的配置会把多个实际运营商账号合成一项,不能安全复用。
|
||||
if not legacy_account_id:
|
||||
continue
|
||||
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 ""),
|
||||
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
|
||||
m.carriers[c.legacy_account_id] = c
|
||||
|
||||
for item in raw.get("packages") or []:
|
||||
p = PackageMapping(
|
||||
@@ -178,13 +186,15 @@ def merge_and_save(config_dir: Path, mapping: Mapping) -> Path:
|
||||
"migration_batch_no": mapping.migration_batch_no,
|
||||
"carriers": [
|
||||
{
|
||||
"legacy_account_id": c.legacy_account_id,
|
||||
"legacy_account_name": c.legacy_account_name,
|
||||
"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)
|
||||
for c in sorted(mapping.carriers.values(), key=lambda x: (x.legacy_account_name, x.legacy_account_id))
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
|
||||
@@ -43,6 +43,8 @@ from .mapping_loader import Mapping, MissingTargetError
|
||||
ORDER_NO_PREFIX_CARD = "MIG-CARD-"
|
||||
ALLOCATION_NO_PREFIX = "MIG-ALLOC-"
|
||||
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 ""
|
||||
|
||||
|
||||
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:资产导入
|
||||
# ============================================================
|
||||
@@ -181,24 +230,26 @@ def _filter_cards_by_carrier(
|
||||
"not_in_legacy", "奇成 tbl_card 中找不到该 ICCID(iccid_19/iccid_20 都没命中)",
|
||||
))
|
||||
continue
|
||||
if not meta.category:
|
||||
if not meta.account_id:
|
||||
errors.append(ErrorRow(
|
||||
"cards.csv", c.line_no, "iccid_19", c.iccid_19,
|
||||
"no_category", "奇成 tbl_card.category 为空,无法定位运营商映射",
|
||||
"no_carrier_account", "奇成 tbl_card.account_id 为空,无法定位具体运营商账号映射",
|
||||
))
|
||||
continue
|
||||
carrier = mapping.lookup_carrier(meta.category)
|
||||
carrier = mapping.lookup_carrier(meta.account_id)
|
||||
if carrier is None:
|
||||
errors.append(ErrorRow(
|
||||
"cards.csv", c.line_no, "category", str(meta.category),
|
||||
"carrier_not_in_mapping", f"mapping.yaml.carriers 未配置 category={meta.category} ({meta.category_name})",
|
||||
"cards.csv", c.line_no, "account_id", meta.account_id,
|
||||
"carrier_not_in_mapping",
|
||||
f"mapping.yaml.carriers 未配置 account_id={meta.account_id} "
|
||||
f"({meta.account_name}, category={meta.category}/{meta.category_name})",
|
||||
))
|
||||
continue
|
||||
try:
|
||||
carrier.require_target()
|
||||
except MissingTargetError as exc:
|
||||
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),
|
||||
))
|
||||
continue
|
||||
@@ -226,7 +277,7 @@ def _write_iot_cards(
|
||||
f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)"))
|
||||
for c in cards:
|
||||
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 保证
|
||||
|
||||
# 业务方直接给的两列;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"
|
||||
" 1, 0, 0, 0,\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.virtual_no) if meta.virtual_no else 'NULL'}, "
|
||||
f"{_shop_id_sub(shop_code)}, {series_id_expr},\n"
|
||||
@@ -509,6 +560,7 @@ def write_step2(
|
||||
output_dir: Path,
|
||||
*,
|
||||
cards: list[CardRow],
|
||||
devices: list[DeviceRow],
|
||||
mapping: Mapping,
|
||||
card_metas: dict[str, LegacyCardMeta],
|
||||
usage_snapshots: dict[str, LegacyCardUsage],
|
||||
@@ -518,6 +570,7 @@ def write_step2(
|
||||
"""生成 step2_* SQL + warnings.csv + summary.txt。"""
|
||||
user_id = mapping.migration_user_id
|
||||
warnings: list[tuple[str, str, str]] = []
|
||||
devices_by_virtual_no = _device_by_virtual_no(devices)
|
||||
# 卡 → package(从 card_meta.current_meal_id 经 mapping.packages 查 target)
|
||||
resolved_packages: dict[str, tuple[int, str]] = {} # iccid_19 → (target_package_id, expire_date)
|
||||
for c in cards:
|
||||
@@ -549,9 +602,9 @@ def write_step2(
|
||||
"(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(
|
||||
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)
|
||||
|
||||
@@ -571,6 +624,7 @@ def write_step2(
|
||||
output_dir / "summary.txt",
|
||||
cards=cards,
|
||||
resolved=resolved_packages,
|
||||
devices_by_virtual_no=devices_by_virtual_no,
|
||||
agents=eligible_agents,
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -590,6 +644,7 @@ def _write_migration_orders(
|
||||
path: Path,
|
||||
cards: list[CardRow],
|
||||
resolved_packages: dict[str, tuple[int, str]],
|
||||
devices_by_virtual_no: dict[str, DeviceRow],
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""伪订单关键字段(详见方案文档 10.2):source=admin / buyer_type=personal /
|
||||
@@ -600,7 +655,36 @@ def _write_migration_orders(
|
||||
for c in cards:
|
||||
if c.iccid_19 not in resolved_packages:
|
||||
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)
|
||||
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(
|
||||
"INSERT INTO tb_order (\n"
|
||||
" order_no, order_type, buyer_type, buyer_id,\n"
|
||||
@@ -630,6 +714,7 @@ def _write_package_usages(
|
||||
path: Path,
|
||||
cards: list[CardRow],
|
||||
resolved_packages: dict[str, tuple[int, str]],
|
||||
devices_by_virtual_no: dict[str, DeviceRow],
|
||||
usage_snapshots: dict[str, LegacyCardUsage],
|
||||
user_id: int,
|
||||
) -> None:
|
||||
@@ -638,6 +723,10 @@ def _write_package_usages(
|
||||
for c in cards:
|
||||
if c.iccid_19 not in resolved_packages:
|
||||
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]
|
||||
order_no = _make_card_order_no(c.iccid_full)
|
||||
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"
|
||||
)
|
||||
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(
|
||||
"INSERT INTO tb_package_usage (\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"
|
||||
" virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot,\n"
|
||||
" status, priority, generation,\n"
|
||||
@@ -661,8 +753,8 @@ def _write_package_usages(
|
||||
" data_reset_cycle,\n"
|
||||
" creator, updater, created_at, updated_at\n"
|
||||
")\n"
|
||||
"SELECT o.id, o.order_no, p.id, 'single_card',\n"
|
||||
" o.iot_card_id,\n"
|
||||
f"SELECT o.id, o.order_no, p.id, {_sql_str(usage_type)},\n"
|
||||
f" {carrier_select},\n"
|
||||
f" p.real_data_mb, {usage_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"
|
||||
@@ -832,13 +924,18 @@ def _write_warnings(path: Path, warnings: list[tuple[str, str, str]]) -> None:
|
||||
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 = [
|
||||
"奇成迁移 - 脚本2 关联数据导入摘要",
|
||||
"=" * 48,
|
||||
f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f"卡总数 : {len(cards)}",
|
||||
f" 生成伪订单+套餐 : {len(resolved)}",
|
||||
f" 生成伪订单+套餐 : {generated_count}",
|
||||
f"代理钱包初始化 : {len(agents)}",
|
||||
f"警告条目 : {len(warnings)} (详见 warnings.csv)",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user