Files
junhong_cmp_fiber/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
2026-07-16 15:07:59 +08:00

204 lines
7.7 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退款中禁止换货
### 业务规则
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
拦截范围:
- `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资产详情-所有套餐的最后到期时间
### 业务规则
资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
### 接口
后台资产详情页实际调用的是:
```
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
// 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.LastPackageExpiresAt = lastExpiry
```
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
### 前端
资产详情"套餐信息"板块新增展示:
```
预计最后到期时间2027-01-01
```
读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。