修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s

This commit is contained in:
Break
2026-05-29 15:08:25 +08:00
parent 1ee554daec
commit 6c8352491c
4 changed files with 200 additions and 20 deletions

View File

@@ -43,7 +43,7 @@ scripts/migration/
把两个 CSV 放到 `resources/` 下(命名为 `cards.csv` / `devices.csv`):
- **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**:`imei` / `virtual_no` 至少填一个;`device_name` / `device_model` / `device_type` / `max_sim_slots` 可选;`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位)
字段说明详见 [`resources/README.md`](resources/README.md)。
@@ -145,7 +145,7 @@ CSV 里**没有** `target_shop_code` 列,所有归属都从 `mapping.agents` 推
|------|---------|
| 独立卡 | 奇成 `tbl_card.agent_id``mapping.agents[].target_shop_code` |
| 设备 | 主卡(`sim_iccid_1`)的 agent_id → 同上 |
| 设备上的卡 | 跟随设备(`shop_id=NULL`,由触发器+绑定关系维护) |
| 设备上的卡 | 跟随设备所属店铺(`shop_id` 与设备一致,设备分配时卡状态同步为已分销) |
如果 agent 在 `mapping.agents``target_shop_code` 留空 → 该资产进平台库存。

View File

@@ -46,16 +46,19 @@ class CardRow:
class DeviceRow:
"""devices.csv 单行的解析结果。
简化为业务方核心关心的列:imei / virtual_no / sim_iccid_*。
简化为业务方核心关心的列:imei / virtual_no / device_* / max_sim_slots / sim_iccid_*。
店铺归属由"主卡 sim_iccid_1 的 agent_id → mapping.agents.target_shop_code"推导。
其他字段(device_name/device_model/manufacturer 等)直接用默认值,不暴露给业务方。
其他字段(manufacturer 等)直接用默认值,不暴露给业务方。
"""
line_no: int
imei: str
virtual_no: str # 留空时用 imei 兜底,见 _load_devices
sim_iccids: dict[int, str] # slot → iccid(只保留非空)
max_sim_slots: int = 4 # 默认 4 槽位
device_name: str = ""
device_model: str = ""
device_type: str = ""
max_sim_slots: int = 1 # 不填时按非空 sim_iccid_* 的最大槽位推导
current_slot: int = 1 # 默认 slot 1 为当前生效
@@ -220,6 +223,9 @@ def _load_devices(path: Path, result: LoadResult) -> None:
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:
@@ -242,12 +248,43 @@ def _load_devices(path: Path, result: LoadResult) -> None:
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
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,
)
)

View File

@@ -171,6 +171,23 @@ def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]:
return {d.virtual_no: d for d in devices}
def _device_virtual_no_set(devices: list[DeviceRow]) -> set[str]:
return {d.virtual_no for d in devices if d.virtual_no}
def _card_virtual_no_for_import(meta: LegacyCardMeta, device_virtual_nos: set[str]) -> str:
"""返回可安全写入卡表的 virtual_no。
奇成里绑定设备的主卡可能把设备号放在卡 virtual_no 字段。新系统全局标识符要求
设备号归属 device不能再注册成 iot_card否则资产解析会变成卡视角。
"""
if not meta.virtual_no:
return ""
if meta.virtual_no in device_virtual_nos:
return ""
return meta.virtual_no
def _runtime_asset_for_card(card: CardRow, devices_by_virtual_no: dict[str, DeviceRow]) -> tuple[str, str] | None:
"""返回运行态套餐/订单归属:(asset_type, asset_identifier)。
@@ -218,7 +235,7 @@ def write_step1(
cards_by_iccid_19 = {c.iccid_19: c for c in valid_cards}
_write_iot_cards(output_dir / "step1_01_iot_cards.sql", valid_cards, card_metas, mapping, user_id)
_write_iot_cards(output_dir / "step1_01_iot_cards.sql", valid_cards, devices, card_metas, mapping, user_id)
_write_devices(output_dir / "step1_02_devices.sql", devices, card_metas, mapping, user_id)
_write_asset_identifiers(output_dir / "step1_03_asset_identifiers.sql", valid_cards, devices, card_metas)
_write_asset_wallets(output_dir / "step1_04_asset_wallets.sql", valid_cards, devices)
@@ -292,11 +309,14 @@ def _filter_cards_by_carrier(
def _write_iot_cards(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
user_id: int,
) -> None:
"""写 tb_iot_card。元数据从奇成 card_metas + mapping 推导,iccid_19/iccid_20 用业务方两列。"""
device_virtual_nos = _device_virtual_no_set(devices)
devices_by_virtual_no = _device_by_virtual_no(devices)
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)"))
for c in cards:
@@ -309,7 +329,15 @@ def _write_iot_cards(
card_category = "industry" if c.is_industry else "normal"
shop_code = _resolve_card_shop_code(c, meta, mapping)
if c.bound_device_virtual_no:
device = devices_by_virtual_no.get(c.bound_device_virtual_no)
shop_code = _resolve_device_shop_code(device, card_metas, mapping) if device else ""
series_id_expr = _target_series_id_expr_for_iccid_19(c.iccid_19, card_metas, mapping)
card_virtual_no = _card_virtual_no_for_import(meta, device_virtual_nos)
is_standalone = "FALSE" if c.bound_device_virtual_no else "TRUE"
device_virtual_no = c.bound_device_virtual_no or ""
card_status = 2 if shop_code else 1
shop_id_expr = _shop_id_sub(shop_code)
f.write(
"INSERT INTO tb_iot_card (\n"
@@ -326,17 +354,35 @@ def _write_iot_cards(
f" {_sql_str(c.iccid_full)}, {_sql_str(c.iccid_19)}, {iccid_20_expr},\n"
f" {carrier.target_carrier_id}, {_sql_str(carrier.target_carrier_type)}, {_sql_str(carrier.target_carrier_name)},\n"
f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n"
" 1, 0, 0, 0,\n"
" 1, 1, TRUE,\n"
f" {card_status}, 0, 0, 0,\n"
f" 1, 1, {is_standalone},\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"
" '', '',\n"
f"{_sql_str(card_virtual_no) if card_virtual_no else 'NULL'}, "
f"{shop_id_expr}, {series_id_expr},\n"
f" {_sql_str(device_virtual_no)}, '',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
")\n"
"ON CONFLICT (iccid) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_iot_card\n"
"SET\n"
f" status = {card_status},\n"
f" shop_id = {shop_id_expr},\n"
f" virtual_no = {_sql_str(card_virtual_no) if card_virtual_no else 'NULL'},\n"
f" is_standalone = {is_standalone},\n"
f" device_virtual_no = {_sql_str(device_virtual_no)},\n"
" updated_at = NOW()\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
" AND (\n"
f" status IS DISTINCT FROM {card_status}\n"
f" OR shop_id IS DISTINCT FROM {shop_id_expr}\n"
f" OR virtual_no IS DISTINCT FROM {_sql_str(card_virtual_no) if card_virtual_no else 'NULL'}\n"
f" OR is_standalone IS DISTINCT FROM {is_standalone}\n"
f" OR device_virtual_no IS DISTINCT FROM {_sql_str(device_virtual_no)}\n"
" );\n\n"
)
def _write_devices(
@@ -349,9 +395,9 @@ def _write_devices(
"""设备表 INSERT。
业务方不关心的字段都写默认值:
device_name/device_model/manufacturer = NULL(可后台编辑)
manufacturer = NULL(可后台编辑)
max_sim_slots = 4
status / asset_status / online_status = 1 / 1 / 0
status = 有店铺归属则已分销,否则在库;asset_status / online_status = 1 / 0
enable_polling = TRUE,realname_policy = 'after_order'
software_version = '',switch_mode = '0'(自动切卡)
"""
@@ -359,11 +405,14 @@ def _write_devices(
f.write(_file_header("step1_02 设备资产导入 (tb_device)"))
for d in devices:
shop_code = _resolve_device_shop_code(d, card_metas, mapping)
device_status = 2 if shop_code else 1
shop_id_expr = _shop_id_sub(shop_code)
source_iccid = _device_package_source_iccid(d)
series_id_expr = _target_series_id_expr_for_iccid_19(source_iccid, card_metas, mapping)
f.write(
"INSERT INTO tb_device (\n"
" virtual_no, max_sim_slots, imei,\n"
" device_name, device_model, device_type,\n"
" status, asset_status, generation, online_status,\n"
" enable_polling, realname_policy,\n"
" software_version, switch_mode,\n"
@@ -372,15 +421,53 @@ def _write_devices(
") VALUES (\n"
f" {_sql_str(d.virtual_no)}, {d.max_sim_slots}, "
f"{_sql_str(d.imei) if d.imei else 'NULL'},\n"
" 1, 1, 1, 0,\n"
f" {_sql_str(d.device_name) if d.device_name else 'NULL'}, "
f"{_sql_str(d.device_model) if d.device_model else 'NULL'}, "
f"{_sql_str(d.device_type) if d.device_type else 'NULL'},\n"
f" {device_status}, 1, 1, 0,\n"
" TRUE, 'after_order',\n"
" '', '0',\n"
f" {_sql_str(mapping.migration_batch_no)}, {_shop_id_sub(shop_code)}, {series_id_expr},\n"
f" {_sql_str(mapping.migration_batch_no)}, {shop_id_expr}, {series_id_expr},\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
")\n"
"ON CONFLICT (virtual_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
metadata_updates = [
(column, value)
for column, value in (
("max_sim_slots", str(d.max_sim_slots)),
("status", str(device_status)),
("shop_id", shop_id_expr),
("device_name", d.device_name),
("device_model", d.device_model),
("device_type", d.device_type),
)
if value or column in {"max_sim_slots", "status", "shop_id"}
]
if metadata_updates:
sql_literal_columns = {"max_sim_slots", "status", "shop_id"}
set_lines = [
f"{column} = {value}" if column in sql_literal_columns else f"{column} = {_sql_str(value)}"
for column, value in metadata_updates
]
diff_checks = [
f"{column} IS DISTINCT FROM {value}"
if column in sql_literal_columns
else f"{column} IS DISTINCT FROM {_sql_str(value)}"
for column, value in metadata_updates
]
set_sql = ",\n ".join(set_lines)
diff_sql = " OR ".join(diff_checks)
f.write(
"UPDATE tb_device\n"
"SET\n"
f" {set_sql},\n"
" updated_at = NOW()\n"
f"WHERE virtual_no = {_sql_str(d.virtual_no)} AND deleted_at IS NULL\n"
f" AND ({diff_sql});\n\n"
)
def _write_asset_identifiers(
path: Path,
@@ -388,21 +475,23 @@ def _write_asset_identifiers(
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
) -> None:
"""卡的 iccid + 虚拟号(来自奇成)和设备 virtual_no 都注册。"""
"""卡的 iccid + 安全卡虚拟号和设备 virtual_no 都注册。"""
device_virtual_nos = _device_virtual_no_set(devices)
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_03 资产标识符注册 (tb_asset_identifier)"))
for c in cards:
meta = card_metas[c.iccid_19]
card_virtual_no = _card_virtual_no_for_import(meta, device_virtual_nos)
f.write(
"INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n"
f"SELECT {_sql_str(c.iccid_full)}, 'iot_card', id FROM tb_iot_card\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (identifier) DO NOTHING;\n\n"
)
if meta.virtual_no:
if card_virtual_no:
f.write(
"INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n"
f"SELECT {_sql_str(meta.virtual_no)}, 'iot_card', id FROM tb_iot_card\n"
f"SELECT {_sql_str(card_virtual_no)}, 'iot_card', id FROM tb_iot_card\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (identifier) DO NOTHING;\n\n"
)
@@ -433,6 +522,17 @@ def _write_asset_wallets(path: Path, cards: list[CardRow], devices: list[DeviceR
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(c.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_iot_card c\n"
"WHERE w.resource_type = 'iot_card'\n"
" AND w.resource_id = c.id\n"
" AND w.deleted_at IS NULL\n"
f" AND c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(c.shop_id, 0);\n\n"
)
for d in devices:
f.write(
"INSERT INTO tb_asset_wallet (\n"
@@ -447,6 +547,17 @@ def _write_asset_wallets(path: Path, cards: list[CardRow], devices: list[DeviceR
f"WHERE virtual_no = {_sql_str(d.virtual_no)} AND deleted_at IS NULL\n"
"ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(d.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_device d\n"
"WHERE w.resource_type = 'device'\n"
" AND w.resource_id = d.id\n"
" AND w.deleted_at IS NULL\n"
f" AND d.virtual_no = {_sql_str(d.virtual_no)} AND d.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(d.shop_id, 0);\n\n"
)
def _write_sim_bindings(
@@ -475,6 +586,34 @@ def _write_sim_bindings(
f" AND c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
"ON CONFLICT (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_iot_card c\n"
"SET is_standalone = FALSE,\n"
" device_virtual_no = d.virtual_no,\n"
" shop_id = d.shop_id,\n"
" status = CASE WHEN d.shop_id IS NULL THEN 1 ELSE 2 END,\n"
" updated_at = NOW()\n"
"FROM tb_device d\n"
f"WHERE d.virtual_no = {_sql_str(d.virtual_no)} AND d.deleted_at IS NULL\n"
f" AND c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
" AND (\n"
" c.is_standalone IS DISTINCT FROM FALSE\n"
" OR c.device_virtual_no IS DISTINCT FROM d.virtual_no\n"
" OR c.shop_id IS DISTINCT FROM d.shop_id\n"
" OR c.status IS DISTINCT FROM CASE WHEN d.shop_id IS NULL THEN 1 ELSE 2 END\n"
" );\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(c.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_iot_card c\n"
f"WHERE c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
" AND w.resource_type = 'iot_card'\n"
" AND w.resource_id = c.id\n"
" AND w.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(c.shop_id, 0);\n\n"
)
def _write_shop_allocations(

View File

@@ -36,13 +36,17 @@
|------|------|------|
| `imei` | 否 | 设备 IMEI(非蜂窝设备可留空) |
| `virtual_no` | 否 | 设备虚拟号/设备号。留空时用 `imei` 兜底;两者都为空则该行报错 |
| `device_name` | 否 | 设备名称,写入 `tb_device.device_name` |
| `device_model` | 否 | 设备型号,写入 `tb_device.device_model` |
| `device_type` | 否 | 设备类型,写入 `tb_device.device_type` |
| `max_sim_slots` | 否 | 最大卡槽数,范围 1-4。不填时按非空 `sim_iccid_*` 的最大槽位推导 |
| `sim_iccid_1` ~ `sim_iccid_4` | 否 | 各插槽 ICCID,引用 `cards.csv` 的卡。可填 19 或 20 位,内部统一按前 19 位回查 `cards.iccid_19`(至少要有 1 个) |
**店铺归属规则**:用 `sim_iccid_1`(主卡)的 agent_id 走 `mapping.agents`
推导,与卡归属同口径。
**其他字段自动取默认值**:`device_name` / `device_model` / `manufacturer` 留空,
`max_sim_slots=4`,`current_slot=1`(以 `sim_iccid_1` 为当前生效卡)。
**其他字段自动取默认值**:`manufacturer` 留空,`max_sim_slots` 按已填写卡槽推导,
`current_slot=1`(以 `sim_iccid_1` 为当前生效卡)。
## 一致性约束