fix(invitation): 返利归因统一为「订单所属站点」,买卖两端一致
- 买家端不再强制记主站(原 buyerSiteID=0),卖家端不再记拆分方站点,两端统一取 orderSiteID - orderSiteID = 订单所属站点(>0 时);订单未记站点但存在分销拆分时回退到拆分方站点,避免主站替分站订单买单 - 该站点同时用于取返利配置(GetRebateConfig)、返利流水与钱包流水的 distribution_site_id,分站后台由此能看到本站返利支出 - 基数口径未改动,非分销订单行为与改前一致;历史 9 笔数据不订正,规则自此生效 - 测试:更新跨站用例断言;新增订单站点归因用例(校验站点专属比例 0.5 与钱包流水站点维度)
This commit is contained in:
@@ -386,27 +386,30 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
// 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。
|
||||||
|
// 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它),
|
||||||
|
// 避免再次出现「主站替分站订单买单」的倒贴。
|
||||||
|
orderSiteID := distributorSiteID
|
||||||
|
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||||
|
orderSiteID = *order.DistributionSiteID
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 买家端处理(租客 RenterID)
|
// 1. 买家端处理(租客 RenterID)
|
||||||
var buyerInvitation model.UserInvitation
|
var buyerInvitation model.UserInvitation
|
||||||
if err := tx.Where("invitee_id = ? AND status = ?", order.RenterID, StatusActive).First(&buyerInvitation).Error; err == nil {
|
if err := tx.Where("invitee_id = ? AND status = ?", order.RenterID, StatusActive).First(&buyerInvitation).Error; err == nil {
|
||||||
if buyerInvitation.VerifiedAt != nil {
|
if buyerInvitation.VerifiedAt != nil {
|
||||||
var inviter model.User
|
var inviter model.User
|
||||||
if err := tx.First(&inviter, buyerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.RenterID {
|
if err := tx.First(&inviter, buyerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.RenterID {
|
||||||
// 买家端基数:若有分销售卖拆分按 platformSellCent,否则按整单毛利对半拆
|
// 买家端基数:有分销售卖拆分按 platformSellCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID
|
||||||
var buyerBasisCent int64
|
var buyerBasisCent int64
|
||||||
var buyerSiteID uint64
|
|
||||||
if platformSellCent > 0 {
|
if platformSellCent > 0 {
|
||||||
buyerBasisCent = int64(math.Floor(float64(platformSellCent) / 2.0))
|
buyerBasisCent = int64(math.Floor(float64(platformSellCent) / 2.0))
|
||||||
buyerSiteID = 0
|
|
||||||
} else {
|
} else {
|
||||||
buyerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
|
buyerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
|
||||||
if order.DistributionSiteID != nil {
|
|
||||||
buyerSiteID = *order.DistributionSiteID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if buyerBasisCent > 0 {
|
if buyerBasisCent > 0 {
|
||||||
cfg, _ := r.GetRebateConfig(ctx, buyerSiteID)
|
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
|
||||||
if cfg == nil || cfg.Enabled {
|
if cfg == nil || cfg.Enabled {
|
||||||
rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg)
|
rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg)
|
||||||
rebateCent := int64(math.Round(float64(buyerBasisCent) * rate))
|
rebateCent := int64(math.Round(float64(buyerBasisCent) * rate))
|
||||||
@@ -415,7 +418,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
ledger := model.InvitationRebateLedger{
|
ledger := model.InvitationRebateLedger{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
OrderNo: order.OrderNo,
|
OrderNo: order.OrderNo,
|
||||||
DistributionSiteID: buyerSiteID,
|
DistributionSiteID: orderSiteID,
|
||||||
InviterID: buyerInvitation.InviterID,
|
InviterID: buyerInvitation.InviterID,
|
||||||
InviteeID: order.RenterID,
|
InviteeID: order.RenterID,
|
||||||
TradeRole: "buyer",
|
TradeRole: "buyer",
|
||||||
@@ -432,7 +435,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
||||||
_ = wallet.AppendEntries(tx, wallet.Entry{
|
_ = wallet.AppendEntries(tx, wallet.Entry{
|
||||||
UserID: buyerInvitation.InviterID,
|
UserID: buyerInvitation.InviterID,
|
||||||
DistributionSiteID: buyerSiteID,
|
DistributionSiteID: orderSiteID,
|
||||||
Direction: "in",
|
Direction: "in",
|
||||||
AmountCent: rebateCent,
|
AmountCent: rebateCent,
|
||||||
BalanceType: "available",
|
BalanceType: "available",
|
||||||
@@ -454,21 +457,16 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
if sellerInvitation.VerifiedAt != nil {
|
if sellerInvitation.VerifiedAt != nil {
|
||||||
var inviter model.User
|
var inviter model.User
|
||||||
if err := tx.First(&inviter, sellerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.OwnerID {
|
if err := tx.First(&inviter, sellerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.OwnerID {
|
||||||
// 卖家端基数:若有分站收号拆分按 distributorReceiveCent(从分站出),否则按整单毛利对半拆
|
// 卖家端基数:有分站收号拆分按 distributorReceiveCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID
|
||||||
var sellerBasisCent int64
|
var sellerBasisCent int64
|
||||||
var sellerSiteID uint64
|
|
||||||
if distributorReceiveCent > 0 {
|
if distributorReceiveCent > 0 {
|
||||||
sellerBasisCent = int64(math.Floor(float64(distributorReceiveCent) / 2.0))
|
sellerBasisCent = int64(math.Floor(float64(distributorReceiveCent) / 2.0))
|
||||||
sellerSiteID = distributorSiteID
|
|
||||||
} else {
|
} else {
|
||||||
sellerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
|
sellerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
|
||||||
if order.DistributionSiteID != nil {
|
|
||||||
sellerSiteID = *order.DistributionSiteID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sellerBasisCent > 0 {
|
if sellerBasisCent > 0 {
|
||||||
cfg, _ := r.GetRebateConfig(ctx, sellerSiteID)
|
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
|
||||||
if cfg == nil || cfg.Enabled {
|
if cfg == nil || cfg.Enabled {
|
||||||
rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg)
|
rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg)
|
||||||
rebateCent := int64(math.Round(float64(sellerBasisCent) * rate))
|
rebateCent := int64(math.Round(float64(sellerBasisCent) * rate))
|
||||||
@@ -477,7 +475,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
ledger := model.InvitationRebateLedger{
|
ledger := model.InvitationRebateLedger{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
OrderNo: order.OrderNo,
|
OrderNo: order.OrderNo,
|
||||||
DistributionSiteID: sellerSiteID,
|
DistributionSiteID: orderSiteID,
|
||||||
InviterID: sellerInvitation.InviterID,
|
InviterID: sellerInvitation.InviterID,
|
||||||
InviteeID: order.OwnerID,
|
InviteeID: order.OwnerID,
|
||||||
TradeRole: "seller",
|
TradeRole: "seller",
|
||||||
@@ -494,7 +492,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
|||||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
|
||||||
_ = wallet.AppendEntries(tx, wallet.Entry{
|
_ = wallet.AppendEntries(tx, wallet.Entry{
|
||||||
UserID: sellerInvitation.InviterID,
|
UserID: sellerInvitation.InviterID,
|
||||||
DistributionSiteID: sellerSiteID,
|
DistributionSiteID: orderSiteID,
|
||||||
Direction: "in",
|
Direction: "in",
|
||||||
AmountCent: rebateCent,
|
AmountCent: rebateCent,
|
||||||
BalanceType: "available",
|
BalanceType: "available",
|
||||||
|
|||||||
@@ -271,16 +271,17 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
|
|||||||
t.Fatalf("跨站分销返利结算失败: %v", err)
|
t.Fatalf("跨站分销返利结算失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200,站点为 0 (主站)
|
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200;
|
||||||
|
// 本单未记录归属站点,归因回退到拆分方站点(买卖两端统一,不再记主站)
|
||||||
var buyerLedger model.InvitationRebateLedger
|
var buyerLedger model.InvitationRebateLedger
|
||||||
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
|
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
|
||||||
t.Fatalf("未找到买家端流水: %v", err)
|
t.Fatalf("未找到买家端流水: %v", err)
|
||||||
}
|
}
|
||||||
if buyerLedger.DistributionSiteID != 0 || buyerLedger.RebateAmountCent != 1200 {
|
if buyerLedger.DistributionSiteID != distributorSiteID || buyerLedger.RebateAmountCent != 1200 {
|
||||||
t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent)
|
t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点为 88 (分站)
|
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点同为拆分方站点
|
||||||
var sellerLedger model.InvitationRebateLedger
|
var sellerLedger model.InvitationRebateLedger
|
||||||
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
|
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
|
||||||
t.Fatalf("未找到卖家端流水: %v", err)
|
t.Fatalf("未找到卖家端流水: %v", err)
|
||||||
@@ -290,6 +291,112 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分销订单的返利归因必须统一到「订单所属站点」并使用该站自己的比例。
|
||||||
|
// 修复前买家端被强制记主站(用主站比例),基数却取分站售卖额,导致主站替分站订单倒贴。
|
||||||
|
func TestOrderRebateAttrToOrderSiteForDistributionOrder(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
verifiedAt := now.AddDate(0, 0, -1).Truncate(time.Second)
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: "site_attr", SiteName: "归因测试站", Domain: "attr.example.com", Status: "active",
|
||||||
|
}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatalf("创建站点失败: %v", err)
|
||||||
|
}
|
||||||
|
// 站点专属比例 0.5(主站默认 0.6),用于证明取的是站点配置而非主站配置
|
||||||
|
if err := db.Create(&model.InvitationRebateConfig{
|
||||||
|
DistributionSiteID: site.ID,
|
||||||
|
FirstMonthRate: 0.5,
|
||||||
|
SecondMonthRate: 0.3,
|
||||||
|
SubsequentRate: 0.15,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("创建站点返利配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buyerInviter := model.User{Phone: "13800000401", RealnameStatus: "verified", Status: "active"}
|
||||||
|
buyer := model.User{Phone: "13800000402", RealnameStatus: "verified", Status: "active"}
|
||||||
|
sellerInviter := model.User{Phone: "13800000403", RealnameStatus: "verified", Status: "active"}
|
||||||
|
seller := model.User{Phone: "13800000404", RealnameStatus: "verified", Status: "active"}
|
||||||
|
for _, user := range []*model.User{&buyerInviter, &buyer, &sellerInviter, &seller} {
|
||||||
|
if err := db.Create(user).Error; err != nil {
|
||||||
|
t.Fatalf("创建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.UserInvitation{
|
||||||
|
InviterID: buyerInviter.ID, InviteeID: buyer.ID, DistributionSiteID: site.ID,
|
||||||
|
Status: StatusActive, VerifiedAt: &verifiedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("创建买家邀请关系失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.UserInvitation{
|
||||||
|
InviterID: sellerInviter.ID, InviteeID: seller.ID, DistributionSiteID: site.ID,
|
||||||
|
Status: StatusActive, VerifiedAt: &verifiedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("创建卖家邀请关系失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: fmt.Sprintf("ORD-SITE-%d", now.UnixNano()),
|
||||||
|
RenterID: buyer.ID,
|
||||||
|
OwnerID: seller.ID,
|
||||||
|
Status: "completed",
|
||||||
|
SettlementStatus: "settled",
|
||||||
|
RentAmountCent: 30000,
|
||||||
|
OwnerRentAmountCent: 20000, // 整单毛利 10000
|
||||||
|
DistributionSiteID: &site.ID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("创建订单失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分站自售拆分:distributor_sell 6000(进入 platformSellCent)+ distributor_receive 4000
|
||||||
|
parties := []model.DistributionOrderParty{
|
||||||
|
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_sell", PartyType: "site", PartyID: site.ID, ProfitAmountCent: 6000, IncomeAmountCent: 6000},
|
||||||
|
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, ProfitAmountCent: 4000, IncomeAmountCent: 4000},
|
||||||
|
}
|
||||||
|
if err := db.Create(&parties).Error; err != nil {
|
||||||
|
t.Fatalf("创建分销拆分行失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
|
||||||
|
t.Fatalf("分销订单返利结算失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 买家端:基数 6000/2 = 3000,站点比例 0.5 => 1500,归因必须为订单站点
|
||||||
|
var buyerLedger model.InvitationRebateLedger
|
||||||
|
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
|
||||||
|
t.Fatalf("未找到买家端流水: %v", err)
|
||||||
|
}
|
||||||
|
if buyerLedger.DistributionSiteID != site.ID || buyerLedger.RateApplied != 0.5 || buyerLedger.RebateAmountCent != 1500 {
|
||||||
|
t.Fatalf("买家端归因/比例错误: site=%d rate=%.2f amount=%d",
|
||||||
|
buyerLedger.DistributionSiteID, buyerLedger.RateApplied, buyerLedger.RebateAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卖家端:基数 4000/2 = 2000,站点比例 0.5 => 1000,归因同为订单站点
|
||||||
|
var sellerLedger model.InvitationRebateLedger
|
||||||
|
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
|
||||||
|
t.Fatalf("未找到卖家端流水: %v", err)
|
||||||
|
}
|
||||||
|
if sellerLedger.DistributionSiteID != site.ID || sellerLedger.RateApplied != 0.5 || sellerLedger.RebateAmountCent != 1000 {
|
||||||
|
t.Fatalf("卖家端归因/比例错误: site=%d rate=%.2f amount=%d",
|
||||||
|
sellerLedger.DistributionSiteID, sellerLedger.RateApplied, sellerLedger.RebateAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钱包流水同样记在站点维度,分站后台才能看到这笔返利支出
|
||||||
|
var walletLedger model.WalletLedger
|
||||||
|
if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", order.OrderNo).
|
||||||
|
Order("id ASC").First(&walletLedger).Error; err != nil {
|
||||||
|
t.Fatalf("未找到返利钱包流水: %v", err)
|
||||||
|
}
|
||||||
|
if walletLedger.DistributionSiteID != site.ID {
|
||||||
|
t.Fatalf("钱包流水站点维度错误: site=%d, want %d", walletLedger.DistributionSiteID, site.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变,
|
// cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变,
|
||||||
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
|
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
|
||||||
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
|
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user