fix: 仲裁实际结算发放邀请返利
This commit is contained in:
@@ -344,8 +344,20 @@ func CalculateRebateRate(verifiedAt time.Time, now time.Time, cfg *model.Invitat
|
||||
return subsequentRate, CycleSubsequent, days
|
||||
}
|
||||
|
||||
// OrderRebateOptions 允许调用方覆盖订单毛利基数与返利计算时刻。
|
||||
// 仲裁按实际消耗结算后,订单快照金额仍是原预收金额,必须传入实际平台毛利。
|
||||
type OrderRebateOptions struct {
|
||||
GrossProfitCent *int64
|
||||
RateReferenceAt time.Time
|
||||
SettledAt time.Time
|
||||
}
|
||||
|
||||
// ProcessOrderInvitationRebate 在订单终态结算时计算并分发买卖双边邀请返利。
|
||||
func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64) error {
|
||||
return r.processOrderInvitationRebate(ctx, tx, orderID, OrderRebateOptions{})
|
||||
}
|
||||
|
||||
func (r *Repository) processOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64, opts OrderRebateOptions) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
@@ -355,6 +367,9 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
}
|
||||
|
||||
orderGrossProfitCent := order.RentAmountCent - order.OwnerRentAmountCent
|
||||
if opts.GrossProfitCent != nil {
|
||||
orderGrossProfitCent = *opts.GrossProfitCent
|
||||
}
|
||||
if orderGrossProfitCent <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -385,6 +400,14 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rateReferenceAt := now
|
||||
if !opts.RateReferenceAt.IsZero() {
|
||||
rateReferenceAt = opts.RateReferenceAt
|
||||
}
|
||||
settledAt := now
|
||||
if !opts.SettledAt.IsZero() {
|
||||
settledAt = opts.SettledAt
|
||||
}
|
||||
|
||||
// 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。
|
||||
// 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它),
|
||||
@@ -411,7 +434,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
if buyerBasisCent > 0 {
|
||||
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
|
||||
if cfg == nil || cfg.Enabled {
|
||||
rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg)
|
||||
rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, rateReferenceAt, cfg)
|
||||
rebateCent := int64(math.Round(float64(buyerBasisCent) * rate))
|
||||
if rebateCent > 0 {
|
||||
ledgerNo := fmt.Sprintf("REB-B-%s-%d", order.OrderNo, buyerInvitation.InviterID)
|
||||
@@ -430,7 +453,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
RebateAmountCent: rebateCent,
|
||||
WalletLedgerNo: ledgerNo,
|
||||
Status: "settled",
|
||||
SettledAt: now,
|
||||
SettledAt: settledAt,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
||||
_ = wallet.AppendEntries(tx, wallet.Entry{
|
||||
@@ -468,7 +491,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
if sellerBasisCent > 0 {
|
||||
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
|
||||
if cfg == nil || cfg.Enabled {
|
||||
rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg)
|
||||
rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, rateReferenceAt, cfg)
|
||||
rebateCent := int64(math.Round(float64(sellerBasisCent) * rate))
|
||||
if rebateCent > 0 {
|
||||
ledgerNo := fmt.Sprintf("REB-S-%s-%d", order.OrderNo, sellerInvitation.InviterID)
|
||||
@@ -487,7 +510,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
RebateAmountCent: rebateCent,
|
||||
WalletLedgerNo: ledgerNo,
|
||||
Status: "settled",
|
||||
SettledAt: now,
|
||||
SettledAt: settledAt,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
||||
_ = wallet.AppendEntries(tx, wallet.Entry{
|
||||
@@ -1018,11 +1041,16 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
|
||||
// ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用
|
||||
func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error {
|
||||
return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{})
|
||||
}
|
||||
|
||||
// ProcessOrderInvitationRebateWithOptionsTx 支持覆盖实际结算基数与计算时刻。
|
||||
func ProcessOrderInvitationRebateWithOptionsTx(tx *gorm.DB, orderID uint64, opts OrderRebateOptions) error {
|
||||
if tx == nil || orderID == 0 {
|
||||
return nil
|
||||
}
|
||||
repo := NewRepository(tx)
|
||||
return repo.ProcessOrderInvitationRebate(context.Background(), tx, orderID)
|
||||
return repo.processOrderInvitationRebate(context.Background(), tx, orderID, opts)
|
||||
}
|
||||
|
||||
// OnUserRealnameVerifiedTx 便捷事务入口,供实名认证通过时调用
|
||||
|
||||
@@ -192,6 +192,76 @@ func TestInvitationFlowAndOrderRebate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOrderInvitationRebateUsesActualProfitBasis(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
inviter := model.User{Phone: "13800000011", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&inviter).Error; err != nil {
|
||||
t.Fatalf("创建邀请人失败: %v", err)
|
||||
}
|
||||
seller := model.User{Phone: "13800000012", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&seller).Error; err != nil {
|
||||
t.Fatalf("创建卖家失败: %v", err)
|
||||
}
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second)
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
InviterID: inviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &verifiedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建邀请关系失败: %v", err)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: fmt.Sprintf("ORD-ACTUAL-%d", time.Now().UnixNano()),
|
||||
OwnerID: seller.ID,
|
||||
RenterID: 99999,
|
||||
Status: "completed",
|
||||
SettlementStatus: "arbitrated",
|
||||
RentAmountCent: 10670,
|
||||
OwnerRentAmountCent: 7270,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
|
||||
actualPlatformFeeCent := int64(933)
|
||||
if err := ProcessOrderInvitationRebateWithOptionsTx(db, order.ID, OrderRebateOptions{
|
||||
GrossProfitCent: &actualPlatformFeeCent,
|
||||
RateReferenceAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("按实际毛利处理返利失败: %v", err)
|
||||
}
|
||||
|
||||
var ledger model.InvitationRebateLedger
|
||||
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil {
|
||||
t.Fatalf("查询返利流水失败: %v", err)
|
||||
}
|
||||
if ledger.OrderProfitBasisCent != 933 || ledger.SingleSideBasisCent != 466 || ledger.RebateAmountCent != 280 {
|
||||
t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want 933/466/280",
|
||||
ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent)
|
||||
}
|
||||
|
||||
var account model.WalletAccount
|
||||
if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&account).Error; err != nil {
|
||||
t.Fatalf("查询邀请人钱包失败: %v", err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 280 {
|
||||
t.Fatalf("邀请人余额 = %d, want 280", account.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
if err := ProcessOrderInvitationRebateWithOptionsTx(db, order.ID, OrderRebateOptions{
|
||||
GrossProfitCent: &actualPlatformFeeCent,
|
||||
RateReferenceAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("重复调用返利失败: %v", err)
|
||||
}
|
||||
var ledgerCount, walletCount int64
|
||||
db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount)
|
||||
db.Model(&model.WalletLedger{}).Where("biz_no = ? AND biz_type = 'invite_rebate'", order.OrderNo).Count(&walletCount)
|
||||
if ledgerCount != 1 || walletCount != 1 {
|
||||
t.Fatalf("重复调用产生重复流水: rebate=%d wallet=%d", ledgerCount, walletCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossSiteDistributionRebate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
Reference in New Issue
Block a user