## ADDED Requirements

### Requirement: ICCID 双列存储
`tb_iot_card` SHALL 新增 `iccid_19 varchar(19)` 和 `iccid_20 varchar(20)` 两列。

- `iccid_19`：存储 ICCID 前 19 位（所有卡均有值，对应 19 位运营商卡即为完整 ICCID）
- `iccid_20`：存储完整 20 位 ICCID（仅 20 位运营商卡有值，19 位运营商卡为 **SQL NULL**）
- 两列均建立 Partial Index（过滤 `deleted_at IS NULL`），`iccid_20` 额外过滤 NULL

**Go Model 类型约束**：
- `ICCID19` 字段类型为 `string`（非指针，所有卡必填）
- `ICCID20` 字段类型为 **`*string`**（指针，19 位卡赋值 `nil` → GORM 写入 NULL；禁止赋空字符串 `""`，GORM 不会将其转为 NULL，会破坏 Partial Index 语义）

#### Scenario: 导入 19 位运营商卡
- **WHEN** 导入 ICCID 长度为 19 位的卡
- **THEN** `iccid_19` = ICCID 原值，`iccid_20` = NULL

#### Scenario: 导入 20 位运营商卡
- **WHEN** 导入 ICCID 长度为 20 位的卡
- **THEN** `iccid_19` = ICCID 前 19 位，`iccid_20` = ICCID 原值

---

### Requirement: ICCID 按长度路由查询
系统 SHALL 根据上游传入 ICCID 的字符串长度，路由到对应列进行精确查询。

- 上游传入 19 位 → 查询 `WHERE iccid_19 = ?`
- 上游传入 20 位 → 查询 `WHERE iccid_20 = ?`
- 上游传入其他长度 → 记录 Error 日志，返回未找到，不执行查询
- 禁止跨列降级：任意列 Miss 时直接记录 Warn 日志，不再查另一列

#### Scenario: 上游传入 19 位命中 19 位卡
- **WHEN** 上游传入 19 位 ICCID，数据库存在对应 19 位卡
- **THEN** 系统查询 `iccid_19` 列，精确命中，返回卡记录

#### Scenario: 上游传入 20 位命中 20 位卡
- **WHEN** 上游传入 20 位 ICCID，数据库存在对应 20 位卡
- **THEN** 系统查询 `iccid_20` 列，精确命中，返回卡记录

#### Scenario: 上游传入 19 位但数据库无对应记录
- **WHEN** 上游传入 19 位 ICCID，数据库中不存在匹配的 `iccid_19`
- **THEN** 系统记录 Warn 日志（含 ICCID 和 deviceID），返回未找到，不修改任何数据

#### Scenario: 上游传入 20 位但数据库无对应记录
- **WHEN** 上游传入 20 位 ICCID，数据库中不存在匹配的 `iccid_20`
- **THEN** 系统记录 Warn 日志（含 ICCID 和 deviceID），返回未找到，不修改任何数据

#### Scenario: 上游传入异常长度 ICCID
- **WHEN** 上游传入长度既非 19 也非 20 的 ICCID
- **THEN** 系统记录 Error 日志，直接返回未找到，不执行数据库查询

---

### Requirement: IotCard 精确查询方法适配
`IotCardStore` 的精确查询方法 SHALL 全部适配双列查询路由策略。

受影响方法：`GetByICCID`、`GetByICCIDs`、`ExistsByICCID`、`ExistsByICCIDBatch`。

#### Scenario: GetByICCID 单卡精确查询
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 且 ICCID 长度为 19 或 20 位
- **THEN** 系统 SHALL 根据长度路由到 `iccid_19` 或 `iccid_20` 列查询，走 Partial Index

#### Scenario: GetByICCIDs 批量精确查询
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中所有 ICCID 长度一致（均为 19 或均为 20）
- **THEN** 系统 SHALL 路由到对应列使用 `IN ?` 查询

#### Scenario: GetByICCIDs 混合长度输入
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中同时包含 19 位和 20 位 ICCID
- **THEN** 系统 SHALL 按长度分组，分别查询 `iccid_19 IN ?` 和 `iccid_20 IN ?`，合并结果返回

---

### Requirement: DeviceSimBinding 按 ICCID 查询适配
`DeviceSimBindingStore` 中涉及 ICCID 查询的方法 SHALL 适配双列路由策略。

#### Scenario: UpdateIsCurrentByDeviceID 子查询适配
- **WHEN** 调用 `UpdateIsCurrentByDeviceID(ctx, deviceID, currentIccid)` 且 currentIccid 非空
- **THEN** 子查询 SHALL 根据 currentIccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询 iot_card_id
- **AND** Miss 时记录 Warn 日志，不更新 is_current，不降级

#### Scenario: GetBoundICCIDs JOIN 查询适配
- **WHEN** 调用 `GetBoundICCIDs(ctx, iccids)` 查询已绑定设备的 ICCID 列表
- **THEN** JOIN 条件 SHALL 按传入 iccids 长度路由到对应列

---

### Requirement: PersonalCustomerICCIDStore 查询适配
`PersonalCustomerICCIDStore` 的精确查询方法 SHALL 适配双列查询路由策略。

`tb_personal_customer_iccid` 表 SHALL 新增 `iccid_19 varchar(19)` 列及对应索引。

受影响方法：`GetByICCID`、`GetByCustomerAndICCID`、`ExistsByCustomerAndICCID`、`CreateOrUpdateLastUsed`。

#### Scenario: 个人客户 ICCID 记录写入时同步回填
- **WHEN** 系统创建或更新 PersonalCustomerICCID 记录
- **THEN** `iccid_19` SHALL 同步赋值为 ICCID 前 19 位

#### Scenario: 个人客户 ICCID 查询按长度路由
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 或 `GetByCustomerAndICCID(ctx, customerID, iccid)`
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19` 列（19 位）或原 `iccid` 列（20 位）查询

#### Scenario: 个人客户 ICCID 绑定存在性校验按长度路由
- **WHEN** 调用 `ExistsByCustomerAndICCID(ctx, customerID, iccid)` 检查客户是否已绑定该 ICCID
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19` 列（19 位）或原 `iccid` 列（20 位）查询，与 `GetByCustomerAndICCID` 保持一致的路由策略
- **AND** 若路由结果 Miss，返回 `false`（未绑定），不降级查另一列

> **设计说明**：`tb_personal_customer_iccid` 仅新增 `iccid_19` 列而不添加 `iccid_20` 列。原因：该表的 ICCID 来自**个人客户手动输入**（扫码/手动填写），而非上游 IoT 平台回调。上游平台才是 19/20 位格式不一致问题的来源；个人客户输入的格式与数据库存储格式一致，因此 20 位查询走原 `iccid` 列即可，无需双列适配。

---

### Requirement: 内联 ICCID SQL 归还 Store 层
`enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL SHALL 重构为调用 `IotCardStore.GetByICCIDs()`，消除架构违规并恢复数据权限过滤。

该重构需要同步变更：
1. `enterprise_card.Service` struct 新增 `iotCardStore *postgres.IotCardStore` 字段
2. `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
3. `internal/bootstrap/services.go` 调用处传入 `s.IotCard`

#### Scenario: 企业卡分配预览调用 Store 层查询
- **WHEN** 执行企业卡分配预览或分配操作，需要按 ICCIDs 查询卡列表
- **THEN** 系统 SHALL 通过 `IotCardStore.GetByICCIDs()` 查询，不允许 Service 层直接拼写 ICCID 过滤 SQL
