feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s

主要变更:
- 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等)
- 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束
- 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析
- 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合
- 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝
- Excel 工具函数优化,前端导入指引文档同步更新
- 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no
- 更新 OpenAPI 文档及相关 DTO

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-07 17:39:36 +08:00
parent 7e489a19fb
commit 80c6f6c756
69 changed files with 3039 additions and 851 deletions

View File

@@ -0,0 +1,106 @@
### Requirement: 查询资产本代历史订单
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
**API 端点**`GET /api/admin/assets/:identifier/orders`
**请求参数**
- `:identifier`路径资产标识符ICCID 或 VirtualNo必填
- `page`query页码默认 1
- `page_size`query每页数量默认 20最大 100
- `include_previous`query是否包含前代订单布尔值默认 false
**权限规则**
- 代理用户:只能查看数据权限范围内资产的订单
- 平台/超管:可查看所有资产订单
- 企业账号:不支持此接口(返回 403
#### Scenario: 查询本代订单(默认)
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
#### Scenario: 资产无订单
- **WHEN** 管理员查询一个从未购买过套餐的资产
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
#### Scenario: identifier 不存在
- **WHEN** 请求的 identifier 无法解析到任何资产
- **THEN** 返回 HTTP 404错误消息"资产不存在"
#### Scenario: 代理查询无权限资产
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
- **THEN** 返回 HTTP 403错误消息"无权限操作该资源或资源不存在"
### Requirement: 查询资产跨代历史订单(含前代)
`include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
**追溯逻辑**
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
3. 递归追溯,最多向前 10 代(安全上限)
**响应结构AssetOrdersResponse**
```json
{
"current_generation": {
"generation": 2,
"identifier": "DEV-001",
"asset_type": "device",
"total": 5,
"page": 1,
"page_size": 20,
"items": [ ...... ]
},
"previous_generations": [
{
"generation": 1,
"identifier": "DEV-OLD-001",
"asset_type": "device",
"exchange_no": "EXC20260101XXXXXX",
"exchanged_at": "2026-01-01T00:00:00Z",
"total": 3,
"items": [ ...20... ]
}
],
"truncated": false
}
```
**每条订单项AssetOrderItem包含**
- `order_no`:订单号
- `order_type`订单类型single_card / device
- `payment_status`:支付状态
- `payment_status_text`:支付状态文本
- `total_amount`:订单金额(分)
- `payment_method`:支付方式
- `paid_at`:支付时间(可空)
- `generation`:订单所属资产世代
- `items`:套餐明细列表
- `created_at`:订单创建时间
#### Scenario: 查询换货后资产的全代际订单
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`DEV-001 是换货后的新设备第2代原设备为 DEV-OLD-001第1代
- **THEN** `current_generation` 包含 DEV-001 本代的订单generation=2
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单generation=1附带换货单号和换货时间
- **THEN** `truncated=false`(未超出追溯上限)
#### Scenario: 资产本身就是第一代(无前代)
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
- **THEN** `previous_generations` 为空数组 `[]`
- **THEN** `current_generation` 正常返回本代订单
#### Scenario: 换货链超过追溯上限
- **WHEN** 换货链深度超过 10 代
- **THEN** 追溯在第 10 代截断,`truncated=true`
- **THEN** 已追溯到的前代数据正常返回
#### Scenario: 前代订单分页
- **WHEN** 请求带 `include_previous=true`
- **THEN** 分页参数page/page_size只对 `current_generation` 的订单生效
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
#### Scenario: 无 include_previous 时响应不含前代字段
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽