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) } }