# 需求04：退款中资产禁止换货
# 需求06/11：资产预计最终到期时间

> 状态：原需求独立稿；最终口径以标准评审稿为准。

---

## 需求04：退款中禁止换货

### 业务规则

资产存在**未结束**的退款申请时，不允许操作换货，提示"该资产存在退款申请，无法操作换货"。

拦截范围：

- `status=1` 待审批。
- `status=4` 已退回，等待提交人修改。
- `status=2` 已通过但 `processing_status!=2`，实际退款仍在处理、等待处理或失败重试。

不拦截：`status=3` 已拒绝，或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。

### 数据模型

退款模型：`RefundRequest`（表 `tb_refund_request`）

资产字段为两个独立字段（无 asset_type/asset_id）：
- `iot_card_id *uint`：IoT卡ID（卡类资产）
- `device_id *uint`：设备ID（设备类资产）

状态常量（`internal/model/refund.go`）：
```go
RefundStatusPending  = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回（退回给提交人，仍拦截换货）
```

### 实现位置

换货单创建入口：`internal/service/exchange/service.go` 创建前校验。

### 后端

**Store 新增方法**（`internal/store/postgres/refund_store.go`）：

```go
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
    var count int64
    err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
        Where(`iot_card_id = ?
            AND deleted_at IS NULL
            AND (
                status IN (?, ?)
                OR (status = ? AND processing_status <> ?)
            )`,
            cardID,
            model.RefundStatusPending,  // 1=待审批
            model.RefundStatusReturned, // 4=已退回（拦截）
            model.RefundStatusApproved, // 2=审批已通过
            constants.ProcessingStatusSucceeded,
        ).Count(&count).Error
    return count > 0, err
}

// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
    var count int64
    err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
        Where(`device_id = ?
            AND deleted_at IS NULL
            AND (
                status IN (?, ?)
                OR (status = ? AND processing_status <> ?)
            )`,
            deviceID,
            model.RefundStatusPending,  // 1=待审批
            model.RefundStatusReturned, // 4=已退回（拦截）
            model.RefundStatusApproved, // 2=审批已通过
            constants.ProcessingStatusSucceeded,
        ).Count(&count).Error
    return count > 0, err
}
```

**Service 校验**（`internal/service/exchange/service.go` 创建换货单前调用）：

```go
// validateNoActiveRefund 校验资产是否有未结束的退款申请
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
    var hasActive bool
    var err error
    if asset.CardID != nil {
        hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
    } else if asset.DeviceID != nil {
        hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
    }
    if err != nil {
        return err
    }
    if hasActive {
        return errors.New(errors.CodeForbidden, "该资产存在退款申请，无法操作换货")
    }
    return nil
}
```

### 前端

无需改动。换货申请时后端返回错误，前端展示错误信息即可。

---

## 需求06/11：资产详情-预计最终到期时间

### 业务规则

需求06与需求11是同一个业务需求的两种描述，不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示：**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。

不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活，`expires_at` 为空，但其购买时的周期和时长已经确定，正常情况下仍可推算最终到期时间。

加油包不延长主套餐服务周期，不参与本字段计算。已失效、已退款或已过期的使用记录不参与。

### 接口

后台资产详情页实际调用的是：

```
GET /api/admin/assets/resolve/:identifier
```

响应 DTO：`AssetResolveResponse`（`internal/model/dto/asset_dto.go`）

该 DTO 目前只有当前套餐到期时间，需新增统一的最终到期响应，并由前端替代原资产汇总展示。

### 后端

**DTO 新增字段**（`internal/model/dto/asset_dto.go` → `AssetResolveResponse`）：

```go
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
DaysUntilFinalExpiry    *int       `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
ExpiryEstimateStatus   string     `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
```

**Store 新增方法**（`internal/store/postgres/package_usage_store.go`）：

```go
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
    var usages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
            constants.PackageUsageStatusActive,  // 1=生效中
            constants.PackageUsageStatusPending, // 0=待生效
            constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
        ).
        Order("priority ASC, created_at ASC, id ASC").
        Find(&usages).Error
    return usages, err
}

// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
    var usages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
            constants.PackageUsageStatusActive,  // 1=生效中
            constants.PackageUsageStatusPending, // 0=待生效
            constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
        ).
        Order("priority ASC, created_at ASC, id ASC").
        Find(&usages).Error
    return usages, err
}
```

新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`，与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐，且仅作为历史兼容。

**Service 计算逻辑**（在 `ResolveAsset` 结果组装处添加）：

```go
// 1. 找当前主套餐的 expires_at 作为 cursor。
// 2. 按 priority、created_at、id 遍历排队主套餐。
// 3. 每个排队套餐以 cursor 为预计激活点，使用其购买时长快照计算新的 cursor。
// 4. cursor 即预计最后到期时间。
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
if err != nil {
    return nil, err
}
resp.EstimatedFinalExpiresAt = lastExpiry
```

`ProjectLastMainPackageExpiry` 是 Query 计算，不写快照表：当前主套餐到期时间变化、排队套餐新增/退款失效后，下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件（例如尚未实名），返回 `null`，前端显示“—”，不伪造日期。

### 前端

资产详情“套餐信息”板块只保留一个资产汇总展示：

```
预计套餐到期时间：2027-01-01
```

读取 `estimated_final_expires_at`。`exact` 时展示日期；`waiting_activation` 时展示“待激活后起算”；`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示，不再作为第二个资产汇总字段。

高亮和临期提醒统一使用 `days_until_final_expiry`，前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。
