临时备份一次
This commit is contained in:
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 需求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`,有值则展示,无值(无套餐)展示"—"。
|
||||
Reference in New Issue
Block a user