Files
hfb_sys/backend/internal/modules/distribution/lifecycle_test.go
T
yml2213andCursor 768902f2f7 feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则
退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:13:46 +08:00

531 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package distribution
import (
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func TestResolveDistributionMode(t *testing.T) {
cases := []struct {
name string
buySiteID uint64
listingSiteID uint64
want string
}{
{name: "主站买+主站货", buySiteID: 0, listingSiteID: 0, want: ""},
{name: "主站买+分站货", buySiteID: 0, listingSiteID: 11, want: ModeTwoPartyDistributorReceive},
{name: "分站卖+主站货", buySiteID: 22, listingSiteID: 0, want: ModeTwoPartyPlatformReceive},
{name: "分站B买+分站A货", buySiteID: 22, listingSiteID: 11, want: ModeThreeParty},
{name: "本站自卖", buySiteID: 11, listingSiteID: 11, want: ModeSiteSelfSell},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
got := resolveDistributionMode(item.buySiteID, item.listingSiteID)
if got != item.want {
t.Fatalf("resolveDistributionMode(%d, %d) = %q, want %q", item.buySiteID, item.listingSiteID, got, item.want)
}
})
}
}
func TestResolveOrderSourceTx(t *testing.T) {
db := distributionTestDB(t)
site, rule, err := ResolveOrderSourceTx(db, "", 1, 1, time.Now())
if err != nil || site != nil || rule != nil {
t.Fatalf("expected nil for empty siteCode, got site=%v, rule=%v, err=%v", site, rule, err)
}
_, _, err = ResolveOrderSourceTx(db, "unknown_site", 1, 1, time.Now())
if err != ErrSiteNotFound {
t.Fatalf("expected ErrSiteNotFound, got %v", err)
}
now := time.Now()
activeSite := model.DistributionSite{
SiteCode: "site_test",
SiteName: "测试分销站",
Domain: "test.dist.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&activeSite).Error; err != nil {
t.Fatal(err)
}
_, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now)
if err != ErrAccountDisplayNotFound {
t.Fatalf("expected ErrAccountDisplayNotFound, got %v", err)
}
listing := model.RentalListing{ListingNo: "L-MAIN-10", AccountID: 20, OwnerID: 1, Status: "published"}
if err := db.Create(&listing).Error; err != nil {
t.Fatal(err)
}
accountDisplay := model.DistributionSiteAccount{
SiteID: activeSite.ID,
AccountID: 20,
ListingID: listing.ID,
DisplayStatus: "enabled",
}
if err := db.Create(&accountDisplay).Error; err != nil {
t.Fatal(err)
}
_, _, err = ResolveOrderSourceTx(db, "site_test", listing.ID, 20, now)
if err != ErrRuleNotFound {
t.Fatalf("expected ErrRuleNotFound, got %v", err)
}
wrongMode := model.DistributionRuleVersion{
SiteID: &activeSite.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 3.5,
PlatformSellRatio: 4.0,
ServiceFeeValue: 2.5,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-1 * time.Hour),
}
if err := db.Create(&wrongMode).Error; err != nil {
t.Fatal(err)
}
_, _, err = ResolveOrderSourceTx(db, "site_test", listing.ID, 20, now)
if err != ErrRuleNotFound {
t.Fatalf("分站卖主站货不得命中 two_party_distributor_receive, got %v", err)
}
ruleVersion := model.DistributionRuleVersion{
SiteID: &activeSite.ID,
Mode: ModeTwoPartyPlatformReceive,
Version: 1,
Status: "active",
PlatformReceiveRatio: 3.5,
DistributorSellRatio: 4.0,
ServiceFeeValue: 2.5,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-1 * time.Hour),
}
if err := db.Create(&ruleVersion).Error; err != nil {
t.Fatal(err)
}
foundSite, foundRule, err := ResolveOrderSourceTx(db, "site_test", listing.ID, 20, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if foundSite.ID != activeSite.ID || foundRule.ID != ruleVersion.ID || foundRule.Mode != ModeTwoPartyPlatformReceive {
t.Fatalf("unexpected result: site=%v, rule=%v", foundSite, foundRule)
}
}
func TestResolveOrderSourceTxModes(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
siteA := mustCreateDistSite(t, db, "site_a", "收号站A", "a.dist.com")
siteB := mustCreateDistSite(t, db, "site_b", "卖出站B", "b.dist.com")
listingA := model.RentalListing{ListingNo: "L-A-1", AccountID: 101, OwnerID: 1, Status: "published", DistributionSiteID: &siteA.ID}
if err := db.Create(&listingA).Error; err != nil {
t.Fatal(err)
}
listingMain := model.RentalListing{ListingNo: "L-MAIN-1", AccountID: 201, OwnerID: 1, Status: "published"}
if err := db.Create(&listingMain).Error; err != nil {
t.Fatal(err)
}
for _, rel := range []model.DistributionSiteAccount{
{SiteID: siteA.ID, AccountID: listingA.AccountID, ListingID: listingA.ID, DisplayStatus: "enabled"},
{SiteID: siteB.ID, AccountID: listingA.AccountID, ListingID: listingA.ID, DisplayStatus: "enabled"},
{SiteID: siteB.ID, AccountID: listingMain.AccountID, ListingID: listingMain.ID, DisplayStatus: "enabled"},
} {
if err := db.Create(&rel).Error; err != nil {
t.Fatal(err)
}
}
recvRule := mustCreateActiveRule(t, db, siteA.ID, ModeTwoPartyDistributorReceive, now)
sellRule := mustCreateActiveRule(t, db, siteB.ID, ModeTwoPartyPlatformReceive, now)
threeRule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now)
selfRule := mustCreateActiveRule(t, db, siteA.ID, ModeSiteSelfSell, now)
t.Run("主站买+分站货", func(t *testing.T) {
site, rule, err := ResolveOrderSourceTx(db, "", listingA.ID, listingA.AccountID, now)
if err != nil {
t.Fatal(err)
}
if site.ID != siteA.ID || rule.ID != recvRule.ID || rule.Mode != ModeTwoPartyDistributorReceive {
t.Fatalf("got site=%d mode=%s", site.ID, rule.Mode)
}
})
t.Run("分站卖+主站货", func(t *testing.T) {
site, rule, err := ResolveOrderSourceTx(db, "site_b", listingMain.ID, listingMain.AccountID, now)
if err != nil {
t.Fatal(err)
}
if site.ID != siteB.ID || rule.ID != sellRule.ID || rule.Mode != ModeTwoPartyPlatformReceive {
t.Fatalf("got site=%d mode=%s", site.ID, rule.Mode)
}
})
t.Run("分站B买+分站A货", func(t *testing.T) {
site, rule, err := ResolveOrderSourceTx(db, "site_b", listingA.ID, listingA.AccountID, now)
if err != nil {
t.Fatal(err)
}
if site.ID != siteB.ID || rule.ID != threeRule.ID || rule.Mode != ModeThreeParty {
t.Fatalf("got site=%d mode=%s", site.ID, rule.Mode)
}
})
t.Run("本站自卖", func(t *testing.T) {
site, rule, err := ResolveOrderSourceTx(db, "site_a", listingA.ID, listingA.AccountID, now)
if err != nil {
t.Fatal(err)
}
if site.ID != siteA.ID || rule.ID != selfRule.ID || rule.Mode != ModeSiteSelfSell {
t.Fatalf("got site=%d mode=%s", site.ID, rule.Mode)
}
})
t.Run("本站自卖不回落两方规则", func(t *testing.T) {
if err := db.Model(&model.DistributionRuleVersion{}).Where("id = ?", selfRule.ID).Update("status", "draft").Error; err != nil {
t.Fatal(err)
}
_, _, err := ResolveOrderSourceTx(db, "site_a", listingA.ID, listingA.AccountID, now)
if err != ErrRuleNotFound {
t.Fatalf("expected ErrRuleNotFound without self-sell rule, got %v", err)
}
})
}
func mustCreateDistSite(t *testing.T, db *gorm.DB, code, name, domain string) model.DistributionSite {
t.Helper()
site := model.DistributionSite{SiteCode: code, SiteName: name, Domain: domain, Status: "active", DisplayAccountEnabled: true}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
return site
}
func mustCreateActiveRule(t *testing.T, db *gorm.DB, siteID uint64, mode string, now time.Time) model.DistributionRuleVersion {
t.Helper()
rule := model.DistributionRuleVersion{
SiteID: &siteID,
Mode: mode,
Version: 1,
Status: "active",
DistributorReceiveRatio: 3.5,
PlatformSellRatio: 4,
PlatformReceiveRatio: 3.5,
DistributorSellRatio: 4,
DistributorARatio: 3.5,
DistributorBRatio: 4,
ServiceFeeValue: 0,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 0,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
return rule
}
func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{
SiteCode: "site_pay",
SiteName: "支付测试站",
Domain: "pay.dist.com",
Status: "active",
DisplayAccountEnabled: true,
TaxEnabled: true,
TaxRateBPS: 100,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 35,
PlatformSellRatio: 40,
ServiceFeeValue: 25,
ServiceFeeUnit: ServiceFeeUnitPercent,
PaymentFeeBPS: 50,
TaxEnabled: true,
TaxRateBPS: 100,
EffectiveFrom: now.Add(-1 * time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-DIST-1",
ListingID: 101,
AccountID: 201,
OwnerID: 1,
RenterID: 2,
Status: "renting",
RentAmountCent: 10000,
OwnerRentAmountCent: 6000,
PlatformFeeCent: 4000,
DistributionSiteID: &site.ID,
DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
// 1. SnapshotPaidOrderTx
grossPaid := int64(10000)
if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil {
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
}
var financial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
t.Fatalf("find financial failed: %v", err)
}
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
t.Fatalf("unexpected financial snapshot: %+v", financial)
}
if financial.SplitBaseAmountCent != 4000 || financial.ServiceFeeCent != 1000 || financial.PaymentFeeCent != 50 {
t.Fatalf("profit snapshot = %+v", financial)
}
var parties []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err)
}
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1400 {
t.Fatalf("distributor party = %+v", parties[0])
}
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1600 {
t.Fatalf("platform party = %+v", parties[1])
}
// 幂等写入测试:重复调用不报错且不重复追加 party
if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil {
t.Fatalf("idempotent SnapshotPaidOrderTx failed: %v", err)
}
var partiesAfter []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Find(&partiesAfter).Error; err != nil || len(partiesAfter) != 2 {
t.Fatalf("idempotency broken: len=%d", len(partiesAfter))
}
// 2. ApplyRefundTx
// 非法金额
if err := ApplyRefundTx(db, order.ID, -10); err != ErrInvalidAmount {
t.Fatalf("expected ErrInvalidAmount for negative refund, got %v", err)
}
// 合法退款
refundAmount := int64(2000)
if err := ApplyRefundTx(db, order.ID, refundAmount); err != nil {
t.Fatalf("ApplyRefundTx failed: %v", err)
}
var updatedFinancial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updatedFinancial).Error; err != nil {
t.Fatalf("find updated financial failed: %v", err)
}
if updatedFinancial.RefundAmountCent != refundAmount {
t.Fatalf("expected refund amount %d, got %d", refundAmount, updatedFinancial.RefundAmountCent)
}
if updatedFinancial.NetPaidAmountCent != grossPaid-refundAmount {
t.Fatalf("expected net paid %d, got %d", grossPaid-refundAmount, updatedFinancial.NetPaidAmountCent)
}
}
func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{SiteCode: "sell_markup", SiteName: "卖出加价站", Domain: "sell-markup.dist.com", Status: "active", DisplayAccountEnabled: true}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-MARKUP-SELL", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting",
RentAmountCent: 10000, OwnerRentAmountCent: 6000, PlatformFeeCent: 4000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := SnapshotPaidOrderTx(db, order.ID, 20000); err != nil {
t.Fatal(err)
}
var parties []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Find(&parties).Error; err != nil {
t.Fatal(err)
}
var seller, platform *model.DistributionOrderParty
for i := range parties {
switch parties[i].PartyRole {
case "distributor_sell":
seller = &parties[i]
case "platform_receive":
platform = &parties[i]
}
}
if seller == nil || platform == nil {
t.Fatalf("missing parties: %+v", parties)
}
if seller.IncomeAmountCent != 1600 {
t.Fatalf("seller 40%% of profit = %d, want 1600", seller.IncomeAmountCent)
}
if platform.IncomeAmountCent != 1400 || platform.PartyID != 0 {
t.Fatalf("platform receive = %+v", platform)
}
}
func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
db := distributionTestDB(t)
if err := database.AutoMigrateIfMissing(db, &model.OrderCheckout{}); err != nil {
t.Fatal(err)
}
now := time.Now()
site := model.DistributionSite{SiteCode: "checkout_site", SiteName: "结账测试站", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active",
DistributorReceiveRatio: 35, PlatformSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-CHECKOUT-REFUND", ListingID: 10, AccountID: 20, OwnerID: 30, RenterID: 40, Status: "renting",
RentAmountCent: 63130, DepositAmountCent: 15500, OwnerRentAmountCent: 55950,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyDistributorReceive,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := SnapshotPaidOrderTx(db, order.ID, 63130); err != nil {
t.Fatal(err)
}
checkout := model.OrderCheckout{
OrderID: order.ID,
Status: "accepted",
RentAmountCent: 53330,
OwnerRentAmountCent: 46150,
DepositAmountCent: 15500,
RenterRefundAmountCent: 25300,
OwnerIncomeAmountCent: 46150,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatal(err)
}
// 退款 25300(包含押金全退 15500 + 租金退款 9800)
if err := ApplyRefundTx(db, order.ID, 25300); err != nil {
t.Fatalf("ApplyRefundTx failed: %v", err)
}
var financial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
t.Fatal(err)
}
if financial.RefundAmountCent != 9800 {
t.Fatalf("financial.RefundAmountCent = %d, want 9800 (only rent refund)", financial.RefundAmountCent)
}
if financial.NetPaidAmountCent != 53330 {
t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent)
}
if financial.SplitBaseAmountCent != 7180 {
t.Fatalf("financial.SplitBaseAmountCent = %d, want 7180", financial.SplitBaseAmountCent)
}
var distributorParty model.DistributionOrderParty
if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil {
t.Fatal(err)
}
if distributorParty.IncomeAmountCent != 2513 {
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
}
}
func TestApplyRefundTxClampsSellerMarkup(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{SiteCode: "markup_refund", SiteName: "加价回退站", Domain: "markup-refund.dist.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-MARKUP-REFUND", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed",
RentAmountCent: 10000, OwnerRentAmountCent: 6000, ShelfMarkupCent: 3000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
var seller model.DistributionOrderParty
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 3400 {
t.Fatalf("paid seller = %d, want 400+3000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 2000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 2000 {
t.Fatalf("partial refund seller markup = %d, want 2000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 0 {
t.Fatalf("full refund must return seller markup, got %d", seller.IncomeAmountCent)
}
}