feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变, 回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。 - 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束 (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。 - 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常 标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、 分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差; 终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。 - 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影 并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、 审批异常转人工。 - 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点, refund.invalidate_commission 仅保留常量与注册供历史审计读取。 - 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作 末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。 - 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource 与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。 - 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) / Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。 Refs: AUG26-012
This commit is contained in:
213
internal/exporter/commission_record_scene.go
Normal file
213
internal/exporter/commission_record_scene.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// CommissionRecordDataSource 佣金明细导出数据源。
|
||||
// 粒度为佣金记录:原佣金与回溯明细各占一行,金额保持分并在展示层转元,
|
||||
// 负数金额与可为负的余额原样导出,不因符号或余额不足被裁剪。
|
||||
type CommissionRecordDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCommissionRecordDataSource 创建佣金明细导出数据源。
|
||||
func NewCommissionRecordDataSource(db *gorm.DB) *CommissionRecordDataSource {
|
||||
return &CommissionRecordDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *CommissionRecordDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneCommissionRecord
|
||||
}
|
||||
|
||||
// Count 统计原佣金与回溯明细的合并行数。
|
||||
func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var originalTotal int64
|
||||
if err := s.originalBranch(ctx, params).Count(&originalTotal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var clawbackTotal int64
|
||||
if err := s.clawbackBranch(ctx, params).Count(&clawbackTotal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(originalTotal + clawbackTotal), nil
|
||||
}
|
||||
|
||||
// Headers 返回佣金明细导出表头。
|
||||
func (s *CommissionRecordDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"记录来源", "记录ID", "代理店铺名称", "关联订单号", "资产标识", "佣金来源",
|
||||
"金额(元)", "是否可提现", "状态", "回溯后佣金余额(元)",
|
||||
"原佣金记录ID", "来源退款单号", "佣金入账时间", "生成时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询合并后的佣金明细导出数据。
|
||||
func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
union := s.db.WithContext(ctx).
|
||||
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback",
|
||||
s.originalBranch(ctx, params), s.clawbackBranch(ctx, params))
|
||||
var items []commissionRecordExportRow
|
||||
query := s.db.WithContext(ctx).Table("(?) AS ledger", union).
|
||||
Select(`
|
||||
ledger.source,
|
||||
ledger.id,
|
||||
COALESCE(sh.shop_name, '') AS shop_name,
|
||||
ledger.order_no,
|
||||
COALESCE(NULLIF(ledger.iccid, ''), ledger.virtual_no, '') AS asset_identifier,
|
||||
ledger.commission_source,
|
||||
ledger.amount,
|
||||
ledger.withdrawable,
|
||||
ledger.status,
|
||||
ledger.balance_after,
|
||||
ledger.original_commission_id,
|
||||
ledger.refund_no,
|
||||
ledger.released_at,
|
||||
ledger.created_at
|
||||
`).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = ledger.shop_id").
|
||||
// 合并后统一排序并分页,保证两类记录落在同一结果集,任一条不缺失也不重复。
|
||||
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
|
||||
Limit(limit).Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
formatCommissionLedgerSource(item.Source),
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.ShopName,
|
||||
item.OrderNo,
|
||||
item.AssetIdentifier,
|
||||
formatCommissionSource(item.CommissionSource),
|
||||
formatMoneyYuan(item.Amount),
|
||||
formatCommissionWithdrawable(item.Source, item.Withdrawable),
|
||||
constants.GetCommissionRecordStatusName(item.Status),
|
||||
formatMoneyYuan(item.BalanceAfter),
|
||||
formatOptionalUint(item.OriginalCommissionID),
|
||||
item.RefundNo,
|
||||
formatOptionalTime(item.ReleasedAt),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// originalBranch 构造原佣金导出分支:自带场景筛选与数据范围。
|
||||
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) *gorm.DB {
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
|
||||
Where("c.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_iot_card ic ON c.iot_card_id = ic.id AND ic.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_device d ON c.device_id = d.id AND d.deleted_at IS NULL").
|
||||
Select(`'` + sourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, o.order_no, ` +
|
||||
`ic.iccid, d.virtual_no, c.commission_source, c.amount, c.balance_after, c.status, ` +
|
||||
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
|
||||
`NULL::boolean AS withdrawable`)
|
||||
query = applyExportShopScope(query, params, "c.shop_id")
|
||||
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.status", "o.order_no")
|
||||
}
|
||||
|
||||
// clawbackBranch 构造回溯明细导出分支:资产维度取原佣金关联的卡或设备,保持与原佣金同一口径。
|
||||
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) *gorm.DB {
|
||||
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
|
||||
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
|
||||
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_iot_card ic ON oc.iot_card_id = ic.id AND ic.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_device d ON oc.device_id = d.id AND oc.deleted_at IS NULL").
|
||||
Select(`'` + sourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
|
||||
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, ic.iccid, d.virtual_no, ` +
|
||||
`g.commission_source, g.amount, g.balance_after, g.status, ` +
|
||||
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
|
||||
query = applyExportShopScope(query, params, "g.shop_id")
|
||||
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.status", "g.order_no")
|
||||
}
|
||||
|
||||
// 导出分支来源标识与后台列表保持一致,便于导出结果与列表逐行核对。
|
||||
const (
|
||||
sourceOriginal = "original"
|
||||
sourceClawback = "clawback"
|
||||
)
|
||||
|
||||
// applyCommissionExportFilters 把佣金明细导出的筛选条件应用到单个分支。
|
||||
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, statusColumn, orderNoColumn string) *gorm.DB {
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where(shopColumn+" = ?", shopID)
|
||||
}
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where(statusColumn+" = ?", status)
|
||||
}
|
||||
if source, ok := filterString(params.Filters, "commission_source"); ok {
|
||||
query = query.Where(sourceColumn+" = ?", source)
|
||||
}
|
||||
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
|
||||
query = query.Where(orderNoColumn+" = ?", orderNo)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// commissionRecordExportRow 是佣金明细导出的合并行投影,金额一律保持分。
|
||||
type commissionRecordExportRow struct {
|
||||
Source string `gorm:"column:source"`
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
CommissionSource string `gorm:"column:commission_source"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
Withdrawable *bool `gorm:"column:withdrawable"`
|
||||
Status int `gorm:"column:status"`
|
||||
BalanceAfter int64 `gorm:"column:balance_after"`
|
||||
OriginalCommissionID *uint `gorm:"column:original_commission_id"`
|
||||
RefundNo string `gorm:"column:refund_no"`
|
||||
ReleasedAt *time.Time `gorm:"column:released_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
// formatCommissionLedgerSource 把记录来源转为导出用中文描述。
|
||||
func formatCommissionLedgerSource(source string) string {
|
||||
if source == sourceClawback {
|
||||
return "回溯明细"
|
||||
}
|
||||
return "原佣金"
|
||||
}
|
||||
|
||||
// formatCommissionSource 把佣金来源转为导出用中文描述。
|
||||
func formatCommissionSource(source string) string {
|
||||
switch source {
|
||||
case model.CommissionSourceCostDiff:
|
||||
return "成本价差"
|
||||
case model.CommissionSourceOneTime:
|
||||
return "一次性佣金"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// formatCommissionWithdrawable 把可提现标识转为导出用中文描述。
|
||||
// 原佣金不参与可提现判定,留空;回溯明细恒为不可提现。
|
||||
func formatCommissionWithdrawable(source string, withdrawable *bool) string {
|
||||
if source != sourceClawback || withdrawable == nil {
|
||||
return ""
|
||||
}
|
||||
if *withdrawable {
|
||||
return "可提现"
|
||||
}
|
||||
return "不可提现"
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
|
||||
NewAgentRechargeDataSource(db),
|
||||
NewRefundDataSource(db),
|
||||
NewExchangeDataSource(db),
|
||||
NewCommissionRecordDataSource(db),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,7 +72,8 @@ func IsSupportedScene(scene string) bool {
|
||||
constants.ExportTaskSceneAgentWalletTransaction,
|
||||
constants.ExportTaskSceneAgentRecharge,
|
||||
constants.ExportTaskSceneRefund,
|
||||
constants.ExportTaskSceneExchange:
|
||||
constants.ExportTaskSceneExchange,
|
||||
constants.ExportTaskSceneCommissionRecord:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user