# 需求04：退款中资产禁止换货  
# 需求06：资产最后到期时间

---

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

### 业务规则

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

未结束状态：1=待审批、4=已退回（退回给提交人修改中）。  
终态（不拦截）：2=已通过、3=已拒绝。

### 数据模型

退款模型：`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 status IN (?, ?) AND deleted_at IS NULL",
            cardID,
            model.RefundStatusPending,  // 1=待审批
            model.RefundStatusReturned, // 4=已退回（拦截）
        ).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 status IN (?, ?) AND deleted_at IS NULL",
            deviceID,
            model.RefundStatusPending,  // 1=待审批
            model.RefundStatusReturned, // 4=已退回（拦截）
        ).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：资产详情-所有套餐的最后到期时间

### 业务规则

资产详情页展示：**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。

（即所有排队套餐依次激活完毕后，资产最终什么时候到期）

### 接口

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

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

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

该 DTO 目前**不含** `last_package_expires_at` 字段，需新增。

### 后端

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

```go
// 所有套餐（生效中+待生效）中最晚的到期时间，无套餐时为 null
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐（生效中+待生效）的最后到期时间，无套餐时为 null"`
```

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

```go
// GetActiveAndQueuedByCardID 获取卡的生效中和待生效套餐
func (s *PackageUsageStore) GetActiveAndQueuedByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
    var usages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID,
            constants.PackageUsageStatusActive,  // 1=生效中
            constants.PackageUsageStatusPending, // 0=待生效
        ).
        Order("created_at ASC").
        Find(&usages).Error
    return usages, err
}

// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐
func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
    var usages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", deviceID,
            constants.PackageUsageStatusActive,  // 1=生效中
            constants.PackageUsageStatusPending, // 0=待生效
        ).
        Order("created_at ASC").
        Find(&usages).Error
    return usages, err
}
```

> 注：待生效套餐的 `expires_at` 可能为 null（实名即生效尚未实名），取已知 expires_at 中的最大值。

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

```go
var lastExpiry *time.Time
var usages []*model.PackageUsage
if card != nil {
    usages, _ = s.packageUsageStore.GetActiveAndQueuedByCardID(ctx, card.ID)
} else if device != nil {
    usages, _ = s.packageUsageStore.GetActiveAndQueuedByDeviceID(ctx, device.ID)
}
for _, u := range usages {
    if u.ExpiresAt == nil {
        continue
    }
    if lastExpiry == nil || u.ExpiresAt.After(*lastExpiry) {
        lastExpiry = u.ExpiresAt
    }
}
resp.LastPackageExpiresAt = lastExpiry
```

### 前端

资产详情"套餐信息"板块新增展示：

```
最后到期时间：2027-01-01
```

读取 `resolve` 接口返回的 `last_package_expires_at`，有值则展示，无值（无套餐）展示"—"。
