Files
junhong_cmp_fiber/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
2026-07-13 12:01:18 +09:00

184 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 需求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`,有值则展示,无值(无套餐)展示"—"。