feat(退款): AUG26-006 补充当前退款套餐已用量与总量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m33s

补齐 PRD §2.3.1「退款管理补充字段」:退款列表、详情与导出新增
「当前退款套餐已用量」与「当前退款套餐总量」两个纯展示字段。

- 套餐定位口径与退款套餐失效保持一致,按优先级取唯一一条:
  冻结的 package_usage_id(且属于该订单)→ 订单主套餐 → 订单任一套餐,
  同级按标识升序。不按当前世代或当前生效套餐推断;不按套餐状态过滤,
  使退款后套餐转已失效时仍能回看用量。
- 列表与详情用固定两次查询批量解析(按标识、按订单),查询次数不随条数增长;
  详情复用同一函数。解析不到套餐或记录已物理删除时返回 0,不阻断读取。
- 导出新增两列并改用同一优先级的 LATERAL 取法,不再依赖只按 r.package_usage_id
  的 join——生产库 1296 条退款仅 157 条带该字段,旧取法会让多数行显示零值。
- 不改变退款金额校验、冻结实收、套餐失效、接续、停机与佣金回溯任何规则。

验证:测试库 junhong_cmp_test 实测冻结记录、订单主套餐回退、记录缺失返回 0 三项
解析场景与「4 条退款固定 2 次查询」;并以同批 43 条退款对拍 Go 解析器与导出 SQL,
口径不一致 0 条;导出 43 行列数与表头一致。无迁移、无接口路径变化。
This commit is contained in:
2026-09-14 12:11:55 +08:00
parent 09abee9778
commit 67893617fe
12 changed files with 400 additions and 12 deletions

View File

@@ -2,6 +2,7 @@ package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
@@ -38,7 +39,8 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int,
// Headers 返回退款记录导出表头。
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称",
"当前退款套餐已用量(MB)", "当前退款套餐总量(MB)", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式",
"冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记",
"退款原因", "审批备注",
@@ -80,6 +82,10 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
o.total_amount AS original_amount,
o.actual_paid_amount AS refundable_amount,
COALESCE(pu.package_name, items.package_names, '') AS package_name,
-- 当前退款套餐用量:与展示口径一致,按冻结套餐记录 → 订单主套餐 → 订单任一套餐
-- 取唯一一条,且不按套餐状态过滤(退款后套餐已失效仍需展示其用量)。
COALESCE(usage.data_usage_mb, 0) AS refund_package_used_mb,
COALESCE(usage.data_limit_mb, 0) AS refund_package_total_mb,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
@@ -96,6 +102,21 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
FROM tb_order_item AS oi
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
) AS items ON TRUE`).
Joins(`LEFT JOIN LATERAL (
SELECT candidate.data_usage_mb, candidate.data_limit_mb
FROM tb_package_usage AS candidate
WHERE candidate.deleted_at IS NULL
AND (
(r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id AND candidate.order_id = r.order_id)
OR (candidate.order_id = r.order_id)
)
ORDER BY
CASE WHEN r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id THEN 0
WHEN candidate.master_usage_id IS NULL THEN 1
ELSE 2 END,
candidate.id ASC
LIMIT 1
) AS usage ON TRUE`).
Order("r.id ASC").
Limit(limit).
Offset(offset)
@@ -112,6 +133,8 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
formatRefundAssetType(item.OrderType),
item.AssetIdentifier,
item.PackageName,
strconv.FormatInt(item.RefundPackageUsedMB, 10),
strconv.FormatInt(item.RefundPackageTotalMB, 10),
formatOptionalMoneyYuan(item.OriginalAmount),
formatMoneyYuan(item.ActualReceivedAmount),
formatOptionalMoneyYuan(item.RefundableAmount),
@@ -167,6 +190,8 @@ type refundExportRow struct {
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
RefundPackageUsedMB int64 `gorm:"column:refund_package_used_mb"`
RefundPackageTotalMB int64 `gorm:"column:refund_package_total_mb"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`

View File

@@ -60,15 +60,19 @@ type RefundListRequest struct {
// RefundResponse 退款申请详情响应
type RefundResponse struct {
ID uint `json:"id" description:"退款申请ID"`
RefundNo string `json:"refund_no" description:"退款单号"`
OrderID uint `json:"order_id" description:"关联订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID设备优先使用 VirtualNo缺失时使用 IMEI"`
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
ID uint `json:"id" description:"退款申请ID"`
RefundNo string `json:"refund_no" description:"退款单号"`
OrderID uint `json:"order_id" description:"关联订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID设备优先使用 VirtualNo缺失时使用 IMEI"`
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
// 当前退款套餐用量:按冻结套餐使用记录 → 订单主套餐 → 订单任一套餐的优先级解析,
// 只用于展示、查询与导出,不参与退款金额校验、套餐失效或佣金回溯。
RefundPackageUsedMB int64 `json:"refund_package_used_mb" description:"当前退款套餐已用量MB真实流量解析不到套餐时为0"`
RefundPackageTotalMB int64 `json:"refund_package_total_mb" description:"当前退款套餐总量MB真实流量解析不到套餐时为0"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`

View File

@@ -0,0 +1,136 @@
package refund
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// refundPackageUsage 是退款展示所需的当前套餐用量。
type refundPackageUsage struct {
UsedMB int64
TotalMB int64
}
// loadRefundPackageUsages 批量解析每个退款申请的「当前退款套餐」用量。
//
// 定位口径与退款套餐失效保持一致,按优先级取唯一一条:
// 1. 退款申请冻结的套餐使用记录package_usage_id
// 2. 该退款关联订单下主套餐使用记录master_usage_id 为空);
// 3. 该退款关联订单下任一套餐使用记录。
//
// 同一优先级内按使用记录标识升序取第一条,保证同一退款每次返回相同结果。
// 不按当前生效套餐或当前世代推断,也不跨订单取套餐:换货后权益仍保留原订单关系,
// 按世代推断会指向错误的资产。
//
// 用量的性质是「当前可取得」:不按套餐状态过滤。退款完成后套餐已转已失效,
// 此时仍需展示其用量;若按状态过滤,退款完成后字段会集体归零,与用途矛盾。
//
// 实现固定为两次查询(按标识、按订单),与退款条数无关;解析不到时返回 0 而非报错,
// 使展示增强不阻断列表、详情或导出。
func (s *Service) loadRefundPackageUsages(ctx context.Context, refunds []*model.RefundRequest) (map[uint]refundPackageUsage, error) {
result := make(map[uint]refundPackageUsage, len(refunds))
usageIDs := make([]uint, 0, len(refunds))
orderIDs := make([]uint, 0, len(refunds))
seenUsage := make(map[uint]struct{}, len(refunds))
seenOrder := make(map[uint]struct{}, len(refunds))
for _, refund := range refunds {
if refund == nil || refund.ID == 0 {
continue
}
result[refund.ID] = refundPackageUsage{}
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
if _, exists := seenUsage[*refund.PackageUsageID]; !exists {
seenUsage[*refund.PackageUsageID] = struct{}{}
usageIDs = append(usageIDs, *refund.PackageUsageID)
}
}
if refund.OrderID > 0 {
if _, exists := seenOrder[refund.OrderID]; !exists {
seenOrder[refund.OrderID] = struct{}{}
orderIDs = append(orderIDs, refund.OrderID)
}
}
}
if len(usageIDs) == 0 && len(orderIDs) == 0 {
return result, nil
}
// 第一次查询:按套餐使用记录标识取冻结记录。
byUsageID := make(map[uint]*model.PackageUsage, len(usageIDs))
// 第二次查询:按订单取全部候选,再在内存中按优先级择一。
byOrderID := make(map[uint][]*model.PackageUsage, len(orderIDs))
if len(usageIDs) > 0 {
var usages []model.PackageUsage
if err := s.db.WithContext(ctx).
Select("id", "order_id", "master_usage_id", "data_usage_mb", "data_limit_mb").
Where("id IN ?", usageIDs).Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款套餐使用记录失败")
}
for index := range usages {
byUsageID[usages[index].ID] = &usages[index]
}
}
if len(orderIDs) > 0 {
var usages []model.PackageUsage
if err := s.db.WithContext(ctx).
Select("id", "order_id", "master_usage_id", "data_usage_mb", "data_limit_mb").
Where("order_id IN ?", orderIDs).
Order("id ASC").Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐使用记录失败")
}
for index := range usages {
usage := &usages[index]
byOrderID[usage.OrderID] = append(byOrderID[usage.OrderID], usage)
}
}
for _, refund := range refunds {
if refund == nil || refund.ID == 0 {
continue
}
usage := resolveRefundPackageUsage(refund, byUsageID, byOrderID)
if usage == nil {
continue
}
result[refund.ID] = refundPackageUsage{UsedMB: usage.DataUsageMB, TotalMB: usage.DataLimitMB}
}
return result, nil
}
// resolveRefundPackageUsage 按优先级为单个退款申请择一当前套餐使用记录。
func resolveRefundPackageUsage(
refund *model.RefundRequest,
byUsageID map[uint]*model.PackageUsage,
byOrderID map[uint][]*model.PackageUsage,
) *model.PackageUsage {
// 优先级一:冻结的套餐使用记录,且必须属于该退款关联订单(防止跨订单取值)。
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
if usage, exists := byUsageID[*refund.PackageUsageID]; exists && usage.OrderID == refund.OrderID {
return usage
}
}
// 优先级二:订单主套餐;优先级三:订单任一套餐。候选已按标识升序,取首个命中。
candidates := byOrderID[refund.OrderID]
if len(candidates) == 0 {
return nil
}
for _, usage := range candidates {
if usage.MasterUsageID == nil {
return usage
}
}
return candidates[0]
}
// applyRefundPackageUsage 把解析结果写入响应字段。
func applyRefundPackageUsage(response *dto.RefundResponse, usage refundPackageUsage) {
if response == nil {
return
}
response.RefundPackageUsedMB = usage.UsedMB
response.RefundPackageTotalMB = usage.TotalMB
}

View File

@@ -257,6 +257,10 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
if err != nil {
return nil, err
}
packageUsages, err := s.loadRefundPackageUsages(ctx, requests)
if err != nil {
return nil, err
}
items := make([]dto.RefundResponse, 0, len(requests))
for _, r := range requests {
@@ -264,6 +268,7 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
item.SubmitterName = submitterNames[r.Creator]
applyApprovalSummary(item, approvalSummaries, r)
item.Attempts = attemptResponses[r.ID]
applyRefundPackageUsage(item, packageUsages[r.ID])
items = append(items, *item)
}
@@ -314,6 +319,11 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er
return nil, err
}
resp.Attempts = attemptResponses[refund.ID]
packageUsages, err := s.loadRefundPackageUsages(ctx, []*model.RefundRequest{refund})
if err != nil {
return nil, err
}
applyRefundPackageUsage(resp, packageUsages[refund.ID])
return resp, nil
}