迭代计划准备

This commit is contained in:
2026-07-16 15:07:59 +08:00
parent 1a9db9328e
commit c4f430ccb3
22 changed files with 2969 additions and 1569 deletions

View File

@@ -1,6 +1,8 @@
# 需求04退款中资产禁止换货
# 需求04退款中资产禁止换货
# 需求06资产最后到期时间
> 状态:待评审
---
## 需求04退款中禁止换货
@@ -9,8 +11,13 @@
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
未结束状态1=待审批、4=已退回(退回给提交人修改中)。
终态不拦截2=已通过、3=已拒绝。
拦截范围:
- `status=1` 待审批。
- `status=4` 已退回,等待提交人修改。
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
### 数据模型
@@ -41,10 +48,17 @@ RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
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",
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
}
@@ -53,10 +67,17 @@ func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (b
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",
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
}
@@ -94,9 +115,11 @@ func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *res
### 业务规则
资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。
资产详情页展示:**当前生效套餐 + 所有待生效套餐按队列接续后的预计最后到期时间**
(即所有排队套餐依次激活完毕后,资产最终什么时候到期)
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
### 接口
@@ -115,69 +138,66 @@ GET /api/admin/assets/resolve/:identifier
**DTO 新增字段**`internal/model/dto/asset_dto.go``AssetResolveResponse`
```go
// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 null
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐(生效中+待生效)的最后到期时间,无套餐时为 null"`
// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 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) {
// 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 status IN (?, ?) AND deleted_at IS NULL", cardID,
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("created_at ASC").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐
func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
// 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 status IN (?, ?) AND deleted_at IS NULL", deviceID,
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("created_at ASC").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
```
> 注:待生效套餐的 `expires_at` 可能为 null实名即生效尚未实名取已知 expires_at 中的最大值
新建 `PackageUsage` 必须快照 `calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容
**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
}
// 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
预计最后到期时间2027-01-01
```
读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。