661 lines
24 KiB
Go
661 lines
24 KiB
Go
package invitation
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"hfb_sys/backend/internal/database"
|
||
"hfb_sys/backend/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func setupTestDB(t *testing.T) *gorm.DB {
|
||
db := database.NewTestDB()
|
||
if err := database.AutoMigrateIfMissing(db,
|
||
&model.User{},
|
||
&model.UserInvitation{},
|
||
&model.InvitationRebateConfig{},
|
||
&model.InvitationRebateLedger{},
|
||
&model.RentalOrder{},
|
||
&model.OrderCheckout{},
|
||
&model.DistributionSite{},
|
||
&model.DistributionOrderParty{},
|
||
&model.WalletAccount{},
|
||
&model.WalletLedger{},
|
||
); err != nil {
|
||
t.Fatalf("数据库迁移失败: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
func TestCalculateRebateRate(t *testing.T) {
|
||
now := time.Now()
|
||
cfg := &model.InvitationRebateConfig{
|
||
FirstMonthRate: 0.60,
|
||
SecondMonthRate: 0.30,
|
||
SubsequentRate: 0.15,
|
||
Enabled: true,
|
||
}
|
||
|
||
// 10 天(首月)
|
||
rate, cycle, days := CalculateRebateRate(now.AddDate(0, 0, -10), now, cfg)
|
||
if cycle != CycleFirstMonth || rate != 0.60 || days != 10 {
|
||
t.Fatalf("首月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
|
||
}
|
||
|
||
// 30 天(首月边界)
|
||
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -30), now, cfg)
|
||
if cycle != CycleFirstMonth || rate != 0.60 || days != 30 {
|
||
t.Fatalf("首月30天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
|
||
}
|
||
|
||
// 45 天(次月)
|
||
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -45), now, cfg)
|
||
if cycle != CycleSecondMonth || rate != 0.30 || days != 45 {
|
||
t.Fatalf("次月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
|
||
}
|
||
|
||
// 60 天(次月边界)
|
||
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -60), now, cfg)
|
||
if cycle != CycleSecondMonth || rate != 0.30 || days != 60 {
|
||
t.Fatalf("次月60天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
|
||
}
|
||
|
||
// 90 天(往后长期)
|
||
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -90), now, cfg)
|
||
if cycle != CycleSubsequent || rate != 0.15 || days != 90 {
|
||
t.Fatalf("长期比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
|
||
}
|
||
}
|
||
|
||
func TestInvitationFlowAndOrderRebate(t *testing.T) {
|
||
db := setupTestDB(t)
|
||
repo := NewRepository(db)
|
||
ctx := context.Background()
|
||
|
||
// 1. 创建邀请人 Inviter (已实名)
|
||
inviterCode := "inv0001"
|
||
inviter := model.User{
|
||
Phone: "13800000001",
|
||
Nickname: "邀请人老张",
|
||
RealnameStatus: "verified",
|
||
Status: "active",
|
||
InviteCode: &inviterCode,
|
||
}
|
||
if err := db.Create(&inviter).Error; err != nil {
|
||
t.Fatalf("创建邀请人失败: %v", err)
|
||
}
|
||
|
||
// 2. 创建被邀请人 Invitee (未实名)
|
||
invitee := model.User{
|
||
Phone: "13800000002",
|
||
Nickname: "新人小李",
|
||
RealnameStatus: "unverified",
|
||
Status: "active",
|
||
}
|
||
if err := db.Create(&invitee).Error; err != nil {
|
||
t.Fatalf("创建被邀请人失败: %v", err)
|
||
}
|
||
|
||
// 3. 绑定邀请码
|
||
if err := repo.BindInvitation(ctx, invitee.ID, inviterCode, 0); err != nil {
|
||
t.Fatalf("绑定邀请码失败: %v", err)
|
||
}
|
||
|
||
// 校验处于 pending 状态(未实名尚未正式生效)
|
||
var invRecord model.UserInvitation
|
||
if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil {
|
||
t.Fatalf("查询邀请记录失败: %v", err)
|
||
}
|
||
if invRecord.Status != StatusPending || invRecord.VerifiedAt != nil {
|
||
t.Fatalf("邀请状态应为 pending,实际: %s", invRecord.Status)
|
||
}
|
||
|
||
// 4. 被邀请人完成实名认证
|
||
if err := db.Model(&model.User{}).Where("id = ?", invitee.ID).Update("realname_status", "verified").Error; err != nil {
|
||
t.Fatalf("更新实名失败: %v", err)
|
||
}
|
||
if err := repo.OnUserRealnameVerified(ctx, invitee.ID); err != nil {
|
||
t.Fatalf("触发实名生效钩子失败: %v", err)
|
||
}
|
||
|
||
if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil {
|
||
t.Fatalf("查询生效后邀请记录失败: %v", err)
|
||
}
|
||
if invRecord.Status != StatusActive || invRecord.VerifiedAt == nil {
|
||
t.Fatalf("邀请状态应为 active 且包含 verified_at,实际: %s", invRecord.Status)
|
||
}
|
||
|
||
// 检查被邀请人自身是否自动生成了邀请码
|
||
var updatedInvitee model.User
|
||
if err := db.First(&updatedInvitee, invitee.ID).Error; err != nil {
|
||
t.Fatalf("查询被邀请人失败: %v", err)
|
||
}
|
||
if updatedInvitee.InviteCode == nil || *updatedInvitee.InviteCode == "" {
|
||
t.Fatalf("实名通过后自身应自动生成邀请码")
|
||
}
|
||
|
||
// 5. 模拟订单完成终态结算
|
||
orderNo := fmt.Sprintf("ORD-%d", time.Now().UnixNano())
|
||
order := model.RentalOrder{
|
||
OrderNo: orderNo,
|
||
RenterID: invitee.ID, // 被邀请人作为买家
|
||
OwnerID: 99999, // 独立卖家(无邀请人)
|
||
Status: "completed",
|
||
SettlementStatus: "settled",
|
||
RentAmountCent: 20000, // 200元
|
||
OwnerRentAmountCent: 10000, // 100元号主成本
|
||
// 平台毛利: 10000 (100元)
|
||
// 单边基准: 5000 (50元)
|
||
// 首月 60% 返利: 5000 * 0.6 = 3000 (30元)
|
||
}
|
||
if err := db.Create(&order).Error; err != nil {
|
||
t.Fatalf("创建测试订单失败: %v", err)
|
||
}
|
||
|
||
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
|
||
t.Fatalf("订单返利处理失败: %v", err)
|
||
}
|
||
|
||
// 验证流水表
|
||
var ledgers []model.InvitationRebateLedger
|
||
if err := db.Where("order_id = ?", order.ID).Find(&ledgers).Error; err != nil {
|
||
t.Fatalf("查询返利记录失败: %v", err)
|
||
}
|
||
if len(ledgers) != 1 {
|
||
t.Fatalf("应生成 1 条买家端返利流水,实际: %d", len(ledgers))
|
||
}
|
||
l := ledgers[0]
|
||
if l.InviterID != inviter.ID || l.TradeRole != "buyer" || l.RebateAmountCent != 3000 {
|
||
t.Fatalf("返利金额错误: expected=3000, got=%d, role=%s", l.RebateAmountCent, l.TradeRole)
|
||
}
|
||
|
||
// 验证邀请人钱包余额
|
||
var walletAcct model.WalletAccount
|
||
if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&walletAcct).Error; err != nil {
|
||
t.Fatalf("查询邀请人钱包失败: %v", err)
|
||
}
|
||
if walletAcct.AvailableBalanceCent != 3000 {
|
||
t.Fatalf("邀请人可用余额错误: expected=3000, got=%d", walletAcct.AvailableBalanceCent)
|
||
}
|
||
|
||
// 幂等性测试:再次触发不应重复生成
|
||
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
|
||
t.Fatalf("再次调用不应报错: %v", err)
|
||
}
|
||
var count int64
|
||
db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&count)
|
||
if count != 1 {
|
||
t.Fatalf("流水重复写入! count=%d", count)
|
||
}
|
||
}
|
||
|
||
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 TestProcessOrderInvitationRebateFallsBackToCommittedCheckoutProfit(t *testing.T) {
|
||
db := setupTestDB(t)
|
||
inviter := model.User{Phone: "13800000021", RealnameStatus: "verified", Status: "active"}
|
||
if err := db.Create(&inviter).Error; err != nil {
|
||
t.Fatalf("创建邀请人失败: %v", err)
|
||
}
|
||
seller := model.User{Phone: "13800000022", 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-COMMITTED-%d", time.Now().UnixNano()),
|
||
OwnerID: seller.ID,
|
||
RenterID: 99999,
|
||
Status: "completed",
|
||
SettlementStatus: "settled",
|
||
RentAmountCent: 10000,
|
||
OwnerRentAmountCent: 8000,
|
||
}
|
||
if err := db.Create(&order).Error; err != nil {
|
||
t.Fatalf("创建订单失败: %v", err)
|
||
}
|
||
if err := db.Create(&model.OrderCheckout{
|
||
OrderID: order.ID,
|
||
Status: "accepted",
|
||
RentAmountCent: 2000,
|
||
OwnerRentAmountCent: 1700,
|
||
PlatformFeeCent: 300,
|
||
}).Error; err != nil {
|
||
t.Fatalf("创建已确认结账单失败: %v", err)
|
||
}
|
||
|
||
if err := NewRepository(db).ProcessOrderInvitationRebate(t.Context(), db, order.ID); 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 != 300 || ledger.SingleSideBasisCent != 150 || ledger.RebateAmountCent != 90 {
|
||
t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want 300/150/90",
|
||
ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent)
|
||
}
|
||
}
|
||
|
||
func TestCrossSiteDistributionRebate(t *testing.T) {
|
||
db := setupTestDB(t)
|
||
repo := NewRepository(db)
|
||
ctx := context.Background()
|
||
|
||
// 邀请人 1 (邀请了买家)
|
||
c1 := "code001"
|
||
buyerInviter := model.User{Phone: "13900000001", RealnameStatus: "verified", Status: "active", InviteCode: &c1}
|
||
db.Create(&buyerInviter)
|
||
|
||
// 邀请人 2 (邀请了卖家)
|
||
c2 := "code002"
|
||
sellerInviter := model.User{Phone: "13900000002", RealnameStatus: "verified", Status: "active", InviteCode: &c2}
|
||
db.Create(&sellerInviter)
|
||
|
||
// 买家
|
||
now := time.Now()
|
||
buyer := model.User{Phone: "13900000003", RealnameStatus: "verified", Status: "active"}
|
||
db.Create(&buyer)
|
||
db.Create(&model.UserInvitation{InviterID: buyerInviter.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &now})
|
||
|
||
// 卖家
|
||
seller := model.User{Phone: "13900000004", RealnameStatus: "verified", Status: "active"}
|
||
db.Create(&seller)
|
||
db.Create(&model.UserInvitation{InviterID: sellerInviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &now})
|
||
|
||
// 分销站点收号拆分:
|
||
// 主站卖出分站收的账号 (site)
|
||
// platformSellCent = 4000 (主站利润 40 元)
|
||
// distributorReceiveCent = 6000 (分站利润 60 元)
|
||
distSite := model.DistributionSite{
|
||
SiteName: "加盟分站88",
|
||
SiteCode: "site88",
|
||
Domain: "sub88.example.com",
|
||
Status: "active",
|
||
}
|
||
if err := db.Create(&distSite).Error; err != nil {
|
||
t.Fatalf("创建测试分销站点失败: %v", err)
|
||
}
|
||
|
||
distributorSiteID := distSite.ID
|
||
order := model.RentalOrder{
|
||
OrderNo: fmt.Sprintf("ORD-CROSS-%d", time.Now().UnixNano()),
|
||
RenterID: buyer.ID,
|
||
OwnerID: seller.ID,
|
||
Status: "completed",
|
||
SettlementStatus: "settled",
|
||
RentAmountCent: 30000,
|
||
OwnerRentAmountCent: 20000, // 总毛利 10000
|
||
}
|
||
db.Create(&order)
|
||
|
||
if err := db.Create(&model.DistributionOrderParty{
|
||
OrderID: order.ID,
|
||
SiteID: distributorSiteID,
|
||
PartyRole: "platform_sell",
|
||
PartyType: "platform",
|
||
PartyID: 0,
|
||
ProfitAmountCent: 4000,
|
||
IncomeAmountCent: 4000,
|
||
}).Error; err != nil {
|
||
t.Fatalf("创建平台分成方失败: %v", err)
|
||
}
|
||
if err := db.Create(&model.DistributionOrderParty{
|
||
OrderID: order.ID,
|
||
SiteID: distributorSiteID,
|
||
PartyRole: "distributor_receive",
|
||
PartyType: "site",
|
||
PartyID: distributorSiteID,
|
||
ProfitAmountCent: 6000,
|
||
IncomeAmountCent: 6000,
|
||
}).Error; err != nil {
|
||
t.Fatalf("创建分销分成方失败: %v", err)
|
||
}
|
||
|
||
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
|
||
t.Fatalf("跨站分销返利结算失败: %v", err)
|
||
}
|
||
|
||
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200;
|
||
// 本单未记录归属站点,归因回退到拆分方站点(买卖两端统一,不再记主站)
|
||
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 != distributorSiteID || buyerLedger.RebateAmountCent != 1200 {
|
||
t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent)
|
||
}
|
||
|
||
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点同为拆分方站点
|
||
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 != distributorSiteID || sellerLedger.RebateAmountCent != 1800 {
|
||
t.Fatalf("卖家端跨站返利错误: siteID=%d, amount=%d", sellerLedger.DistributionSiteID, sellerLedger.RebateAmountCent)
|
||
}
|
||
}
|
||
|
||
// 分销订单的返利归因必须统一到「订单所属站点」并使用该站自己的比例。
|
||
// 修复前买家端被强制记主站(用主站比例),基数却取分站售卖额,导致主站替分站订单倒贴。
|
||
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 的比例必须来自实际生效配置,改配置后文案要跟着变,
|
||
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
|
||
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
|
||
cases := []struct {
|
||
cycle string
|
||
rate float64
|
||
want string
|
||
}{
|
||
{CycleFirstMonth, 0.60, "首月奖励 60%"},
|
||
{CycleFirstMonth, 0.50, "首月奖励 50%"},
|
||
{CycleFirstMonth, 0.80, "首月奖励 80%"},
|
||
{CycleSecondMonth, 0.30, "次月奖励 30%"},
|
||
{CycleSecondMonth, 0.25, "次月奖励 25%"},
|
||
{CycleSubsequent, 0.15, "长期奖励 15%"},
|
||
{CycleSubsequent, 0.125, "长期奖励 12.5%"},
|
||
{CycleSubsequent, 0, "长期奖励 0%"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := cycleLabel(c.cycle, c.rate); got != c.want {
|
||
t.Fatalf("cycleLabel(%s, %v) = %q, want %q", c.cycle, c.rate, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 线下提号返利与租赁订单卖家侧共用同一个入口 AwardSellerSideRebateTx:
|
||
// 这里验证金额计算正确,且同一来源单据重复调用不会重复入账
|
||
// (唯一键 source+order_id+trade_role 保证幂等,补发脚本重跑安全)。
|
||
func TestAwardSellerSideRebateTxAwardsOncePerSource(t *testing.T) {
|
||
db := setupTestDB(t)
|
||
|
||
inviterCode := "inv9001"
|
||
inviter := model.User{
|
||
Phone: "13800009001",
|
||
Nickname: "提号邀请人",
|
||
RealnameStatus: "verified",
|
||
Status: "active",
|
||
InviteCode: &inviterCode,
|
||
}
|
||
if err := db.Create(&inviter).Error; err != nil {
|
||
t.Fatalf("创建邀请人失败: %v", err)
|
||
}
|
||
seller := model.User{
|
||
Phone: "13800009002",
|
||
Nickname: "提号号主",
|
||
RealnameStatus: "verified",
|
||
Status: "active",
|
||
}
|
||
if err := db.Create(&seller).Error; err != nil {
|
||
t.Fatalf("创建号主失败: %v", err)
|
||
}
|
||
|
||
// 距结算 2 天 => 首月 60%。
|
||
// Truncate 到整秒:DATETIME(0) 会把小数秒四舍五入,带小数时可能进位成不足 48 小时而算作 1 天。
|
||
verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second)
|
||
if err := db.Create(&model.UserInvitation{
|
||
InviterID: inviter.ID,
|
||
InviteeID: seller.ID,
|
||
InviteCode: inviterCode,
|
||
Status: StatusActive,
|
||
VerifiedAt: &verifiedAt,
|
||
}).Error; err != nil {
|
||
t.Fatalf("创建邀请关系失败: %v", err)
|
||
}
|
||
|
||
input := SellerSideRebateInput{
|
||
Source: RebateSourcePickup,
|
||
SourceID: 900001,
|
||
SourceNo: "PK-REBATE-TEST-1",
|
||
SellerID: seller.ID,
|
||
GrossBasisCent: 10000,
|
||
BasisCent: 5000,
|
||
SiteID: 0,
|
||
SettledAt: time.Now(),
|
||
RemarkPrefix: "邀请卖家提号返利",
|
||
}
|
||
|
||
apply := func() int64 {
|
||
t.Helper()
|
||
var amount int64
|
||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||
got, err := AwardSellerSideRebateTx(tx, input)
|
||
amount = got
|
||
return err
|
||
}); err != nil {
|
||
t.Fatalf("发放提号返利失败: %v", err)
|
||
}
|
||
return amount
|
||
}
|
||
|
||
// 单边基数 5000,首月 60% => 3000 分
|
||
if got := apply(); got != 3000 {
|
||
t.Fatalf("首次发放金额 = %d, want 3000", got)
|
||
}
|
||
|
||
var rebate model.InvitationRebateLedger
|
||
if err := db.Where("source = ? AND order_id = ? AND trade_role = 'seller'", RebateSourcePickup, input.SourceID).
|
||
First(&rebate).Error; err != nil {
|
||
t.Fatalf("未找到提号返利流水: %v", err)
|
||
}
|
||
if rebate.InviterID != inviter.ID || rebate.InviteeID != seller.ID || rebate.OrderNo != input.SourceNo {
|
||
t.Fatalf("返利流水字段错误: %+v", rebate)
|
||
}
|
||
if rebate.CycleStage != CycleFirstMonth || rebate.DaysSinceVerified != 2 {
|
||
t.Fatalf("返利档位错误: cycle=%s, days=%d", rebate.CycleStage, rebate.DaysSinceVerified)
|
||
}
|
||
|
||
// 同来源同角色重复调用必须跳过,不重复写流水、不重复入账
|
||
if got := apply(); got != 0 {
|
||
t.Fatalf("重复调用应幂等跳过, got %d", got)
|
||
}
|
||
|
||
var ledgerCount int64
|
||
if err := db.Model(&model.InvitationRebateLedger{}).
|
||
Where("source = ? AND order_id = ?", RebateSourcePickup, input.SourceID).
|
||
Count(&ledgerCount).Error; err != nil {
|
||
t.Fatalf("统计返利流水失败: %v", err)
|
||
}
|
||
if ledgerCount != 1 {
|
||
t.Fatalf("返利流水条数 = %d, want 1", ledgerCount)
|
||
}
|
||
|
||
var walletCount int64
|
||
if err := db.Model(&model.WalletLedger{}).
|
||
Where("biz_type = ? AND biz_no = ?", "invite_rebate", input.SourceNo).
|
||
Count(&walletCount).Error; err != nil {
|
||
t.Fatalf("统计钱包流水失败: %v", err)
|
||
}
|
||
if walletCount != 1 {
|
||
t.Fatalf("钱包流水条数 = %d, want 1", walletCount)
|
||
}
|
||
|
||
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 != 3000 {
|
||
t.Fatalf("邀请人可用余额 = %d, want 3000", account.AvailableBalanceCent)
|
||
}
|
||
}
|