# PRD：UR#46 资产预计最终到期时间

Status: ready-for-agent

---

## Problem Statement

资产详情目前只能展示当前套餐自身的到期时间。排队主套餐通常尚未写入 `expires_at`，简单取最大到期时间无法回答“该资产按当前队列连续使用后最终何时到期”，也会让详情、列表、导出和临期提醒产生不同口径。

预计结果会随当前套餐到期、购买新套餐、退款失效和队列接续实时变化，不适合维护第二个资产汇总快照字段。

## Solution

在 `internal/query/packageexpiry`（或等价独立 Query 包）建立统一预计最终到期 Query。它读取当前生效主套餐和全部有效排队主套餐，按稳定队列顺序使用 UR#55 的购买时长快照逐段推演，仅返回一个资产层预计最终到期结果。

资产详情、卡/设备列表、C 端、临期和导出都复用该 Query；当前套餐自身到期时间只保留在套餐明细，不再作为资产汇总口径。

## User Stories

1. 作为运营人员，我希望在卡和设备详情看到全部主套餐连续使用后的预计最终到期时间。
2. 作为运营人员，我希望列表与详情显示同一结果，且普通列表原排序不被改变。
3. 作为客户，我希望 C 端显示的到期时间与后台一致。
4. 作为运营人员，我希望无套餐和等待未知实名激活能展示明确状态，而不是伪造日期。
5. 作为维护人员，我希望退款、续费和队列变化后下一次查询立即得到新结果。

## Implementation Decisions

### 参与计算的记录

- 只计算 `master_usage_id IS NULL` 的主套餐；加油包不延长主套餐服务周期。
- 参与记录为未软删除且仍处于当前或排队生命周期的主套餐：待生效、生效中，以及仍占用当前有效周期的已用完记录。
- 已过期、已失效、已退款或软删除记录不参与。
- 使用记录按业务 `priority ASC, created_at ASC, id ASC` 稳定排序；相同优先级不得依赖数据库自然顺序。
- 当前生效记录的真实 `expires_at` 是游标起点；后续排队记录从前一段结束后的下一时刻连续接续，禁止重复计算同一自然日。
- 每个排队记录使用自身 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot` 推演；只对 UR#55 之前形成的历史缺失快照记录兼容回退套餐当前值。
- 自然月和按天计算复用套餐生命周期现有日期函数，但必须统一修正边界语义并以 `Asia/Shanghai` 解释业务自然日。

### 推算状态

- `exact`：存在可确定的最终日期。
- `waiting_activation`：没有可作为起点的当前套餐，队首又因尚未满足实名等外部前置条件而无法确定激活时刻。
- `none`：没有任何参与计算的主套餐。
- `invalid_data`：队列、快照或日期存在无法安全推算的异常；不得伪造日期，也不得把异常当成 `none`。
- `estimated_final_expires_at` 在非 `exact` 时为 `null`；`days_until_final_expiry` 同时为 `null`。
- `days_until_final_expiry` 按上海时区的日期差计算，不按 24 小时向下取整；已过期可返回负数供普通详情显示，但不会被 UR#33 判为临期。
- `is_expiring` 是共享派生字段：仅当 `exact` 且剩余自然日为 0～15 时为 true。

### API 契约

- `GET /api/admin/assets/resolve/{identifier}` 增加 `estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`expiry_estimate_status_name`、`is_expiring`。
- `GET /api/admin/iot-cards` 和 `GET /api/admin/devices` 的每项增加同组字段。
- `GET /api/c/v1/asset/info` 增加同组字段；C 端仍保留套餐明细中的当前套餐到期时间，但不把它当成最终日期。
- 日期使用项目统一 RFC3339 序列化；所有 nullable 字段明确返回 `null`，不能通过缺字段表达状态。
- 卡、设备和 C 端不得各自实现一套推算函数。
- 本需求不在普通资产列表增加临期排序；列表只返回字段供 UR#33 高亮。

### Query 架构与性能

- 这是复杂只读逻辑，按触碰式 DDD 放入 Query 层，不经过聚合根，也不写资产汇总表。
- Query 提供单资产和批量资产两种入口，二者共享同一纯计算器。
- 列表批量加载本页所有资产的参与使用记录和必要历史套餐兜底数据，按资产分组计算；禁止逐资产 N+1。
- 详情查询也调用相同 Query，不继续在旧 Asset Service 中拼装另一套规则；旧 Service 可作为只读门面转发。
- 为 `iot_card_id/device_id + master_usage_id + status + priority` 的未软删除查询建立合适的非唯一/部分索引，具体列序以开发库 `EXPLAIN` 为准。
- Count 与 Fetch 不因新增投影产生不同过滤条件；批量结果必须与单资产结果一致。
- 查询错误向上返回统一业务/数据库错误，不能将失败降级成 `none`。

### 前端

- 字段统一命名为“预计套餐到期时间”。
- `exact` 展示日期；`waiting_activation` 展示“待激活后起算”；`none` 展示“—”；`invalid_data` 展示“数据异常”，并允许运营排查。
- 当前套餐自身到期时间仅在套餐明细中展示，资产摘要不再并列展示第二个汇总到期字段。
- `is_expiring=true` 时使用 UR#33 的颜色；普通资产列表不改变原排序。
- 前端不得叠加套餐时长或自行计算剩余天数。

### 依赖与发布

- 本需求依赖 UR#55 对新购买记录写入完整计时快照；没有该前置不得以读取套餐当前配置作为新方案上线。
- 先完成 Query 与后端字段，再同步发布后台、代理端和 C 端展示；旧当前套餐字段保留给套餐明细兼容。

## Testing Decisions

- 纯计算测试覆盖无套餐、仅当前套餐、多个排队套餐、自然月、按天、跨月末、跨年和夏令时无关的上海自然日边界。
- 覆盖等待实名激活、历史快照回退、快照异常、重复优先级、退款/失效/软删除和加油包排除。
- 明确验证前一套餐结束后的下一时刻接续，不多算或少算一天。
- 单资产与批量 Query 对同一数据必须返回完全一致结果。
- HTTP 集成测试使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App，覆盖后台详情、卡列表、设备列表和 C 端。
- 对本页 100 个资产验证固定查询数量，无逐资产 SQL；使用代表性数据执行 `EXPLAIN ANALYZE` 并满足项目性能目标。
- 验证新增/退款/失效排队套餐后无需定时刷新，下一次查询立即变化。
- 前端验收四种状态、nullable 字段、当前套餐明细和普通列表不改排序。

## Out of Scope

- 不维护资产级最终到期快照字段。
- 不计算加油包的独立到期。
- 不在本需求创建临期独立列表、Dashboard 汇总或通知任务。
- 不修改套餐购买顺序和退款业务规则。
- 不为历史记录猜测并回填购买时配置。

## Further Notes

- UR#33、`scene=expiring_asset` 及 `scene=iot_card` 的 30 天筛选必须复用本 Query。
- 预计结果是当前事实和购买承诺的实时投影，不是上游运营商承诺的绝对到期日期。

