更新一版

This commit is contained in:
Break
2026-06-12 18:10:22 +08:00
parent e139f5e227
commit 1c492fe8db
10 changed files with 1735 additions and 258 deletions

View File

@@ -43,23 +43,43 @@ def _import_pymysql():
@dataclass(frozen=True)
class LegacyPackage:
"""奇成当前生效套餐快照(取自 tbl_card_life)。"""
"""奇成当前生效正式套餐快照(取自 tbl_card_life)。"""
iccid: str
meal_id: str
meal_name: str
meal_type: str
start_date: Optional[str]
expire_date: Optional[str]
flow_size_mb: int # 套餐总量,单位 MB
status: int
@dataclass(frozen=True)
class LegacyPackageLifecycle:
"""奇成正式套餐生命周期记录。"""
iccid: str
source_table: str
life_id: str
meal_id: str
meal_name: str
meal_type: str
status: int
start_date: Optional[str]
expire_date: Optional[str]
flow_size_mb: int
stable_sort_key: str
migration_status: str
skip_reason: str = ""
@dataclass(frozen=True)
class LegacyCardUsage:
"""奇成卡累计流量快照。
奇成 ``tbl_card.total_bytes_cnt`` 存的是经过当前套餐 ``flow_add_discount``
加成后的"虚用量",我们这里同时拉取当前生效套餐(``tbl_card_life`` status=1
加成后的"虚用量",我们这里同时拉取当前生效正式套餐(``tbl_card_life`` status=1,type!=1
且 ``expire_date`` 最大)的 ``flow_add_discount``,反算回新系统期望的"真用量"
精度说明:跨多个套餐周期累计时,``total_bytes_cnt`` 是按各时段对应套餐
@@ -70,7 +90,7 @@ class LegacyCardUsage:
iccid: str # 业务方视角的完整 ICCID
virtual_used_mb: Decimal # 奇成 total_bytes_cnt 原始值(虚 MB)
flow_add_discount_pct: Decimal # 当前生效套餐虚比百分比(0~100)
has_active_package: bool # 是否在 tbl_card_life 中找到当前生效套餐
has_active_package: bool # 是否在 tbl_card_life 中找到当前生效正式套餐
@property
def real_used_mb(self) -> Decimal:
@@ -135,8 +155,9 @@ class LegacyCardMeta:
agent_name: str
msisdn: str # tbl_card.phone
virtual_no: str # tbl_virtual_number.vcode(如有)
current_meal_id: str # 当前生效套餐 ID(可空,取自 tbl_card_life status=1 expire_date 最大)
current_meal_id: str # 当前生效正式套餐 ID(可空,取自 tbl_card_life status=1,type!=1,expire_date 最大)
current_meal_name: str
current_meal_type: str # tbl_card_life.type,1=叠加包/加油包,2=月卡,3=季卡,4=半年卡,5=年卡
current_expire_date: Optional[str] # ISO 字符串
@@ -217,7 +238,7 @@ def _to_mb_decimal(value) -> Decimal:
def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPackage]:
"""查每张卡的当前生效套餐:status=1 且 expire_date 最大。
"""查每张卡的当前生效套餐:status=1、type!=1 且 expire_date 最大。
返回: iccid → LegacyPackage(找不到的 iccid 不在结果中)
"""
@@ -231,6 +252,7 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
iccid_mark,
meal_id,
meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
start_date,
expire_date,
COALESCE(flow_size, 0) AS flow_size,
@@ -238,6 +260,7 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC
"""
seen: set[str] = set()
@@ -253,6 +276,7 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
iccid=iccid,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=str(row.get("meal_type") or ""),
start_date=row["start_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("start_date") else None,
expire_date=row["expire_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("expire_date") else None,
flow_size_mb=int(row.get("flow_size") or 0),
@@ -261,11 +285,172 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
return out
def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, list[LegacyPackageLifecycle]]:
"""查当前生效和未生效待生效正式套餐生命周期。
只返回可迁移范围(active/pending)和需要在审核文件中说明的跳过记录。
加油包(type=1)和已过期记录不生成套餐 SQL,但保留 skip_reason 供审核产物输出。
"""
pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, list[LegacyPackageLifecycle]] = {}
if not pairs:
return out
query_to_full: dict[str, str] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
query_to_full[card_key] = card_key
if i20 and allow_i19_lookup:
query_to_full[i19] = card_key
if not i20:
query_to_full[i19] = card_key
iccid_list = sorted(query_to_full.keys())
sql = """
SELECT
'tbl_card_life' AS source_table,
COALESCE(CAST(id AS CHAR), '') AS life_id,
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
status,
start_date,
expire_date,
COALESCE(flow_size, 0) AS flow_size
FROM tbl_card_life
WHERE iccid_mark IN %s
ORDER BY
iccid_mark ASC,
COALESCE(start_date, '1970-01-01') ASC,
COALESCE(expire_date, '9999-12-31') ASC,
id ASC
"""
with conn.cursor() as cur:
for batch in _chunks(iccid_list):
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
if not legacy_iccid:
continue
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
meal_type = str(row.get("meal_type") or "")
raw_status = int(row.get("status") or 0)
migration_status, skip_reason = _classify_package_lifecycle(meal_type, raw_status, start, expire)
life_id = str(row.get("life_id") or "")
stable_sort_key = "|".join([start_str or "", expire_str or "", life_id])
item = LegacyPackageLifecycle(
iccid=iccid,
source_table=row.get("source_table") or "tbl_card_life",
life_id=life_id,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=meal_type,
status=raw_status,
start_date=start_str,
expire_date=expire_str,
flow_size_mb=int(row.get("flow_size") or 0),
stable_sort_key=stable_sort_key,
migration_status=migration_status,
skip_reason=skip_reason,
)
out.setdefault(iccid, []).append(item)
next_sql = """
SELECT
'tbl_next_month_card_life' AS source_table,
COALESCE(CAST(id AS CHAR), '') AS life_id,
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(order_type AS CHAR), '') AS meal_type,
status,
effect_begin_time AS start_date,
effect_complete_time AS expire_date,
COALESCE(flow_size, 0) AS flow_size
FROM tbl_next_month_card_life
WHERE iccid_mark IN %s
ORDER BY
iccid_mark ASC,
COALESCE(effect_begin_time, '1970-01-01') ASC,
COALESCE(effect_complete_time, '9999-12-31') ASC,
id ASC
"""
with conn.cursor() as cur:
for batch in _chunks(iccid_list):
cur.execute(next_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
if not legacy_iccid:
continue
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
raw_status = int(row.get("status") or 0)
migration_status, skip_reason = _classify_next_month_lifecycle(raw_status)
life_id = str(row.get("life_id") or "")
stable_sort_key = "|".join([start_str or "", expire_str or "", f"next:{life_id}"])
out.setdefault(iccid, []).append(LegacyPackageLifecycle(
iccid=iccid,
source_table=row.get("source_table") or "tbl_next_month_card_life",
life_id=life_id,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=str(row.get("meal_type") or ""),
status=raw_status,
start_date=start_str,
expire_date=expire_str,
flow_size_mb=int(row.get("flow_size") or 0),
stable_sort_key=stable_sort_key,
migration_status=migration_status,
skip_reason=skip_reason,
))
return out
def _classify_package_lifecycle(meal_type: str, status: int, start_date, expire_date) -> tuple[str, str]:
"""把奇成生命周期记录分类为 active/pending/skipped。"""
if meal_type == "1":
return "skipped", "加油包不迁移"
from datetime import datetime
now = datetime.now()
if expire_date and expire_date <= now:
return "skipped", "已过期套餐不迁移"
if status == 1:
if start_date and start_date > now:
return "pending", ""
return "active", ""
if start_date and start_date > now:
return "pending", ""
return "skipped", f"奇成状态 {status} 不在迁移范围"
def _classify_next_month_lifecycle(status: int) -> tuple[str, str]:
"""把奇成次月待生效套餐记录分类为 pending/skipped。"""
if status == 1:
return "pending", ""
if status == 2:
return "skipped", "次月待生效记录已插入 tbl_card_life,避免重复迁移"
return "skipped", f"奇成次月待生效状态 {status} 不在迁移范围"
def fetch_card_usage(conn, iccid_pairs: Iterable[tuple]) -> dict[str, LegacyCardUsage]:
"""查每张卡的累计已用流量,并同步取当前生效套餐的 flow_add_discount。
"""查每张卡的累计已用流量,并同步取当前生效正式套餐的 flow_add_discount。
奇成 ``tbl_card.total_bytes_cnt`` 是虚用量(已被套餐虚比加成)。
这里 JOIN 当前生效套餐(``tbl_card_life`` status=1 且 ``expire_date`` 最大)
这里 JOIN 当前生效正式套餐(``tbl_card_life`` status=1,type!=1 且 ``expire_date`` 最大)
+ ``tbl_set_meal`` 取 ``flow_add_discount``,放进 ``LegacyCardUsage``;
上层通过 ``real_used_mb_floor`` 反算真用量写入新系统。
@@ -312,12 +497,15 @@ def fetch_card_usage(conn, iccid_pairs: Iterable[tuple]) -> dict[str, LegacyCard
INNER JOIN (
SELECT iccid_mark, MAX(expire_date) AS max_expire
FROM tbl_card_life
WHERE status = 1 AND iccid_mark IN %s
WHERE status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
AND iccid_mark IN %s
GROUP BY iccid_mark
) latest
ON latest.iccid_mark = cl1.iccid_mark
AND latest.max_expire = cl1.expire_date
WHERE cl1.status = 1
AND COALESCE(CAST(cl1.type AS CHAR), '') <> '1'
) cl ON (
cl.iccid_mark = c.iccid_mark
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(cl.iccid_mark, 19) = c.iccid_mark)
@@ -440,6 +628,7 @@ def fetch_card_meta(
COALESCE(vn.vcode, '') AS vcode,
COALESCE(cl.meal_id, '') AS meal_id,
COALESCE(cl.meal_name, '') AS meal_name,
COALESCE(CAST(cl.meal_type AS CHAR), '') AS meal_type,
cl.expire_date AS expire_date
FROM tbl_card c
LEFT JOIN tbl_vendor_category vc ON vc.category = c.category
@@ -448,17 +637,20 @@ def fetch_card_meta(
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(vn.iccid_mark, 19) = c.iccid_mark)
)
LEFT JOIN (
SELECT cl1.iccid_mark, cl1.meal_id, cl1.meal_name, cl1.expire_date
SELECT cl1.iccid_mark, cl1.meal_id, cl1.meal_name, cl1.type AS meal_type, cl1.expire_date
FROM tbl_card_life cl1
INNER JOIN (
SELECT iccid_mark, MAX(expire_date) AS max_expire
FROM tbl_card_life
WHERE status = 1 AND iccid_mark IN %s
WHERE status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
AND iccid_mark IN %s
GROUP BY iccid_mark
) latest
ON latest.iccid_mark = cl1.iccid_mark
AND latest.max_expire = cl1.expire_date
WHERE cl1.status = 1
AND COALESCE(CAST(cl1.type AS CHAR), '') <> '1'
) cl ON (
cl.iccid_mark = c.iccid_mark
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(cl.iccid_mark, 19) = c.iccid_mark)
@@ -511,6 +703,7 @@ def fetch_card_meta(
virtual_no=row.get("vcode") or "",
current_meal_id=row.get("meal_id") or "",
current_meal_name=row.get("meal_name") or "",
current_meal_type=str(row.get("meal_type") or ""),
current_expire_date=expire_str,
)
return metas, duplicates