From 8521bf4753e9e10550e5496a871c885295234312 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 8 Sep 2026 16:52:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=88=86=E9=94=80=E6=A8=A1=E5=BC=8F=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=85=A5=E8=B4=A6=EF=BC=8C=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E6=9C=AA=E7=A1=AE=E8=AE=A4=E5=88=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。 结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。 --- .../modules/distribution/auth_onboard_test.go | 3 + .../internal/modules/distribution/handler.go | 3 +- .../modules/distribution/lifecycle.go | 185 ++++++++---- .../modules/distribution/lifecycle_test.go | 206 +++++++++++-- .../internal/modules/distribution/pricing.go | 10 +- .../modules/distribution/pricing_test.go | 3 + .../modules/distribution/repository.go | 131 +++++--- .../modules/distribution/repository_test.go | 281 ++++++++++++++++-- 8 files changed, 662 insertions(+), 160 deletions(-) diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go index fc26082..0c5fac7 100644 --- a/backend/internal/modules/distribution/auth_onboard_test.go +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -51,6 +51,9 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) { if resp.Rule.DistributorReceiveRatio != 3.8 { t.Fatalf("expected rule ratio 3.8, got %f", resp.Rule.DistributorReceiveRatio) } + if resp.Rule.Status != "draft" { + t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status) + } // 2. Duplicate site code or domain check dupReq := req diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index c26aee6..e118abe 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -698,7 +698,6 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) { response.OK(c, analytics) } - func isHTTPSRequest(c *gin.Context) bool { if c.Request.TLS != nil { return true @@ -742,6 +741,8 @@ func writeError(c *gin.Context, err error) { response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用") case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch): response.BadRequest(c, "分销规则参数不正确") + case errors.Is(err, ErrLiveMoneyMovementFrozen): + response.Error(c, http.StatusForbidden, "settlement_frozen", "分销结算与提现已冻结,待分成口径确认后再开放") case errors.Is(err, ErrSiteAccessDenied): response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点") case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index c041aad..b2b2c9d 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -24,74 +24,149 @@ func distributionTablesPresent(tx *gorm.DB) bool { return distributionTablePresent } -// ResolveOrderSourceTx 校验收号方站点与售出方站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。 -func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) { +// resolveDistributionMode 只根据买卖双方站点判定模式,不查库、不猜规则。 +// buySiteID / listingSiteID 为 0 表示主站。 +func resolveDistributionMode(buySiteID, listingSiteID uint64) string { + switch { + case buySiteID == 0 && listingSiteID == 0: + return "" + case buySiteID == 0: + return ModeTwoPartyDistributorReceive + case listingSiteID == 0: + return ModeTwoPartyPlatformReceive + case buySiteID == listingSiteID: + return ModeSiteSelfSell + default: + return ModeThreeParty + } +} + +func derefSiteID(id *uint64) uint64 { + if id == nil || *id == 0 { + return 0 + } + return *id +} + +func loadListingSiteID(tx *gorm.DB, listingID uint64) (uint64, error) { + if listingID == 0 { + return 0, nil + } var listing model.RentalListing - var listingSiteID *uint64 - if listingID > 0 { - if err := tx.Select("id, distribution_site_id").First(&listing, listingID).Error; err == nil { - listingSiteID = listing.DistributionSiteID + if err := tx.Select("id, distribution_site_id").First(&listing, listingID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil } + return 0, err + } + return derefSiteID(listing.DistributionSiteID), nil +} + +func loadActiveSiteByID(tx *gorm.DB, siteID uint64) (*model.DistributionSite, error) { + var site model.DistributionSite + if err := tx.Where("id = ? AND status = 'active'", siteID).First(&site).Error; err != nil { + return nil, ErrSiteNotFound + } + return &site, nil +} + +func loadActiveSellSiteByCode(tx *gorm.DB, siteCode string) (*model.DistributionSite, error) { + var site model.DistributionSite + if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&site).Error; err != nil { + return nil, ErrSiteNotFound + } + return &site, nil +} + +func ensureAccountDisplayTx(tx *gorm.DB, siteID, listingID, accountID uint64) error { + var relation model.DistributionSiteAccount + if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", siteID, listingID, accountID).First(&relation).Error; err != nil { + return ErrAccountDisplayNotFound + } + return nil +} + +func findActiveRuleTx(tx *gorm.DB, siteID uint64, mode string, at time.Time) (*model.DistributionRuleVersion, error) { + var rule model.DistributionRuleVersion + err := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", mode, at, at). + Where("site_id = ? OR site_id IS NULL", siteID). + Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC"). + First(&rule).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRuleNotFound + } + return nil, err + } + return &rule, nil +} + +// ResolveOrderSourceTx 先按买卖站点判定模式,再查该 mode 的生效规则,不再在两方规则之间猜优先级。 +func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) { + listingSiteID, err := loadListingSiteID(tx, listingID) + if err != nil { + return nil, nil, err } - // 1. 两方均无分销站:纯主站交易 - if siteCode == "" && listingSiteID == nil { + var buySite *model.DistributionSite + buySiteID := uint64(0) + if siteCode != "" { + buySite, err = loadActiveSellSiteByCode(tx, siteCode) + if err != nil { + return nil, nil, err + } + if err := ensureAccountDisplayTx(tx, buySite.ID, listingID, accountID); err != nil { + return nil, nil, err + } + buySiteID = buySite.ID + } + + mode := resolveDistributionMode(buySiteID, listingSiteID) + if mode == "" { return nil, nil, nil } - // 2. 模式一:分销网收号、主站卖出 (ModeTwoPartyDistributorReceive) - if siteCode == "" && listingSiteID != nil { - var site model.DistributionSite - if err := tx.Where("id = ? AND status = 'active'", *listingSiteID).First(&site).Error; err != nil { + var anchor *model.DistributionSite + switch mode { + case ModeTwoPartyDistributorReceive: + anchor, err = loadActiveSiteByID(tx, listingSiteID) + if err != nil { + return nil, nil, err + } + default: + if buySite == nil { return nil, nil, ErrSiteNotFound } - var rule model.DistributionRuleVersion - query := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", ModeTwoPartyDistributorReceive, at, at). - Where("site_id = ? OR site_id IS NULL", site.ID). - Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC") - if err := query.First(&rule).Error; err != nil { - return nil, nil, ErrRuleNotFound - } - return &site, &rule, nil + anchor = buySite } - // 3. 买家在分销站下单:校验售出站点状态与账号展示关系 - var sellSite model.DistributionSite - if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&sellSite).Error; err != nil { - return nil, nil, ErrSiteNotFound - } - var relation model.DistributionSiteAccount - if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", sellSite.ID, listingID, accountID).First(&relation).Error; err != nil { - return nil, nil, ErrAccountDisplayNotFound + rule, err := findActiveRuleTx(tx, anchor.ID, mode, at) + if err != nil { + return nil, nil, err } + return anchor, rule, nil +} - // 4. 解析生效规则:优先匹配有效规则版本 - targetMode := ModeTwoPartyDistributorReceive - if listingSiteID != nil && *listingSiteID != sellSite.ID { - targetMode = ModeThreeParty +func ruleFromVersion(rule model.DistributionRuleVersion) Rule { + return Rule{ + Mode: rule.Mode, + PlatformReceiveRatio: rule.PlatformReceiveRatio, + PlatformSellRatio: rule.PlatformSellRatio, + DistributorReceiveRatio: rule.DistributorReceiveRatio, + DistributorSellRatio: rule.DistributorSellRatio, + DistributorARatio: rule.DistributorARatio, + DistributorBRatio: rule.DistributorBRatio, + ServiceFeeValue: rule.ServiceFeeValue, + ServiceFeeUnit: rule.ServiceFeeUnit, + PaymentFeeBPS: rule.PaymentFeeBPS, + TaxEnabled: rule.TaxEnabled, + TaxRateBPS: rule.TaxRateBPS, } - var rule model.DistributionRuleVersion - query := tx.Where("status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", at, at). - Where("site_id = ? OR site_id IS NULL", sellSite.ID) - if targetMode == ModeThreeParty { - query = query.Where("mode = ?", ModeThreeParty) - } else { - query = query.Where("mode IN (?, ?)", ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive). - Order("CASE WHEN mode = '" + ModeTwoPartyDistributorReceive + "' THEN 0 ELSE 1 END") - } - query = query.Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC") - if err := query.First(&rule).Error; err != nil { - return nil, nil, ErrRuleNotFound - } - return &sellSite, &rule, nil } // calculateForRule 按规则快照计算手续费、服务费和分成基数。 func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) { - return CalculateSettlement(Rule{ - Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, - PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, - }, input) + return CalculateSettlement(ruleFromVersion(rule), input) } // SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。 @@ -116,7 +191,7 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error if err != nil { return err } - splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, markup) + splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup) financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, splits), SettlementStatus: "pending"} if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil { return err @@ -130,12 +205,12 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error } parties := make([]model.DistributionOrderParty, 0, len(splits)) for _, split := range splits { - partyType := "site" + partyType := PartyTypeSite if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" { - partyType = "platform" + partyType = PartyTypePlatform } partyID := *order.DistributionSiteID - if partyType == "platform" { + if partyType == PartyTypePlatform { partyID = 0 } else if split.PartyRole == "distributor_a" { // 三方模式下 distributor_a 为商品收号站 @@ -175,7 +250,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { if err != nil { return err } - splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, financial.MarkupAmountCent) + splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent) if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRemainderCent(result.SplitBaseAmountCent, splits), "settlement_status": "pending"}).Error; err != nil { return err } diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go index 189607f..06923cb 100644 --- a/backend/internal/modules/distribution/lifecycle_test.go +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -5,61 +5,83 @@ import ( "time" "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) - // 空 siteCode 正常返回 nil 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", + 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: 10, + ListingID: listing.ID, DisplayStatus: "enabled", } if err := db.Create(&accountDisplay).Error; err != nil { t.Fatal(err) } - // 规则不存在 - _, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now) + _, _, err = ResolveOrderSourceTx(db, "site_test", listing.ID, 20, now) if err != ErrRuleNotFound { t.Fatalf("expected ErrRuleNotFound, got %v", err) } - // 创建有效规则 - ruleVersion := model.DistributionRuleVersion{ + wrongMode := model.DistributionRuleVersion{ SiteID: &activeSite.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, @@ -71,32 +93,162 @@ func TestResolveOrderSourceTx(t *testing.T) { 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", 10, 20, now) + 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 { + 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", + SiteCode: "site_pay", + SiteName: "支付测试站", + Domain: "pay.dist.com", + Status: "active", DisplayAccountEnabled: true, - TaxEnabled: true, - TaxRateBPS: 100, + TaxEnabled: true, + TaxRateBPS: 100, } if err := db.Create(&site).Error; err != nil { t.Fatal(err) @@ -150,9 +302,15 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { } var parties []model.DistributionOrderParty - if err := db.Where("order_id = ?", order.ID).Find(&parties).Error; err != nil || len(parties) != 2 { + 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 != 340 { + t.Fatalf("distributor party = %+v", parties[0]) + } + if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 688 { + t.Fatalf("platform party = %+v", parties[1]) + } // 幂等写入测试:重复调用不报错且不重复追加 party if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil { diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index 07a6830..a38d848 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -9,6 +9,11 @@ const ( ModeTwoPartyDistributorReceive = "two_party_distributor_receive" ModeTwoPartyPlatformReceive = "two_party_platform_receive" ModeThreeParty = "three_party" + // ModeSiteSelfSell 是分站卖本站货,不复用两方默认规则。 + ModeSiteSelfSell = "site_self_sell" + + PartyTypeSite = "site" + PartyTypePlatform = "platform" ServiceFeeUnitPercent = "percent" ServiceFeeUnitFixedAmount = "fixed_amount" @@ -70,7 +75,8 @@ type SettlementResult struct { TaxByParty []PartyTaxResult } -// DefaultRule 返回文档中约定的第一版默认值;调用方应将其写入规则表而非写死到业务流程。 +// DefaultRule 返回文档中的第一版占位值,仅供对照,禁止当作已手算确认的生效分成。 +// 本站自卖没有默认比例,必须单独配置。 func DefaultRule(mode string) (Rule, error) { base := Rule{ Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio, @@ -86,6 +92,8 @@ func DefaultRule(mode string) (Rule, error) { case ModeThreeParty: base.DistributorARatio = 3.5 base.DistributorBRatio = 4 + case ModeSiteSelfSell: + return Rule{}, ErrUnsupportedMode default: return Rule{}, ErrUnsupportedMode } diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index 06afa42..191ff89 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -17,6 +17,9 @@ func TestDefaultRules(t *testing.T) { t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err) } } + if _, err := DefaultRule(ModeSiteSelfSell); err != ErrUnsupportedMode { + t.Fatalf("site self-sell must not have a default split, err=%v", err) + } } func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 0ed6a08..b80c47e 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strings" "time" @@ -21,17 +22,17 @@ import ( ) var ( - ErrSiteNotFound = errors.New("distribution site not found") - ErrRuleNotFound = errors.New("distribution rule not found") - ErrOrderNotFound = errors.New("distribution order not found") - ErrInvalidOrderKind = errors.New("invalid distribution order kind") - ErrAccountDisplayNotFound = errors.New("distribution account display not found") - ErrPaymentChannelNotFound = errors.New("distribution payment channel not found") - ErrInvalidPeriod = errors.New("invalid distribution settlement period") - ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found") - ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled") - ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty") - ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch") + ErrSiteNotFound = errors.New("distribution site not found") + ErrRuleNotFound = errors.New("distribution rule not found") + ErrOrderNotFound = errors.New("distribution order not found") + ErrInvalidOrderKind = errors.New("invalid distribution order kind") + ErrAccountDisplayNotFound = errors.New("distribution account display not found") + ErrPaymentChannelNotFound = errors.New("distribution payment channel not found") + ErrInvalidPeriod = errors.New("invalid distribution settlement period") + ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found") + ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled") + ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty") + ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch") ErrPaymentConfigNotFound = errors.New("distribution payment config not found") ErrOrderSnapshotExists = errors.New("distribution order snapshot exists") ErrSiteAccessDenied = errors.New("distribution site access denied") @@ -39,8 +40,14 @@ var ( ErrAdminDisabled = errors.New("admin disabled") ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists") ErrDuplicateUsername = errors.New("username already exists") + ErrLiveMoneyMovementFrozen = errors.New("distribution settlement and withdrawal frozen until split rules are verified") ) +func liveMoneyMovementEnabled() bool { + env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) + return env != "production" && env != "prod" +} + type Repository struct { db *gorm.DB redis *redis.Client @@ -644,27 +651,27 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, if err := tx.Create(&batch).Error; err != nil { return err } - // 聚合周期内尚未进入任何批次的待结算订单财务快照。 - var financials []model.DistributionOrderFinancial - if err := tx.Where("site_id = ? AND settlement_status = 'pending'", req.SiteID). - Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd). - Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.order_id = distribution_order_financials.order_id)"). - Order("id ASC").Find(&financials).Error; err != nil { + // 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。 + var parties []model.DistributionOrderParty + if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID). + Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)"). + Where("order_id IN (?)", + tx.Model(&model.DistributionOrderFinancial{}). + Select("order_id"). + Where("settlement_status = 'pending'"). + Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd), + ). + Order("id ASC"). + Find(&parties).Error; err != nil { return err } total := int64(0) - for _, financial := range financials { - var parties []model.DistributionOrderParty - if err := tx.Where("order_id = ?", financial.OrderID).Find(&parties).Error; err != nil { + for _, party := range parties { + item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"} + if err := tx.Create(&item).Error; err != nil { return err } - for _, party := range parties { - item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: financial.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"} - if err := tx.Create(&item).Error; err != nil { - return err - } - total += party.IncomeAmountCent - } + total += party.IncomeAmountCent } batch.TotalAmountCent = total return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID). @@ -677,6 +684,9 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, } func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) { + if !liveMoneyMovementEnabled() { + return nil, ErrLiveMoneyMovementFrozen + } var row model.DistributionSettlementBatch err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { @@ -710,10 +720,8 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64 Update("status", "settled").Error; err != nil { return err } - if err := tx.Model(&model.DistributionOrderFinancial{}). - Where("order_id IN (?)", tx.Model(&model.DistributionSettlementItem{}). - Select("order_id").Where("batch_id = ?", row.ID)). - Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil { + orderIDs := uniqueSettlementOrderIDs(items) + if err := markFinancialsSettledIfSitePartiesDone(tx, orderIDs, now); err != nil { return err } if err := wallet.AppendEntries(tx, wallet.Entry{ @@ -738,6 +746,40 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64 return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil } +func uniqueSettlementOrderIDs(items []model.DistributionSettlementItem) []uint64 { + seen := make(map[uint64]struct{}, len(items)) + ids := make([]uint64, 0, len(items)) + for _, item := range items { + if _, ok := seen[item.OrderID]; ok { + continue + } + seen[item.OrderID] = struct{}{} + ids = append(ids, item.OrderID) + } + return ids +} + +func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now time.Time) error { + for _, orderID := range orderIDs { + var remaining int64 + if err := tx.Model(&model.DistributionOrderParty{}). + Where("order_id = ? AND party_type = ?", orderID, PartyTypeSite). + Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id AND i.status = 'settled')"). + Count(&remaining).Error; err != nil { + return err + } + if remaining > 0 { + continue + } + if err := tx.Model(&model.DistributionOrderFinancial{}). + Where("order_id = ?", orderID). + Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil { + return err + } + } + return nil +} + func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) { if req.SiteID == 0 || orderID == 0 { return nil, ErrInvalidAmount @@ -1035,28 +1077,16 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* return err } - distReceive := req.DistributorReceiveRatio - if distReceive == 0 { - distReceive = 3.5 - } - platSell := req.PlatformSellRatio - if platSell == 0 { - platSell = 4.0 - } - serviceFee := req.ServiceFeeValue - if serviceFee == 0 { - serviceFee = 2.5 - } - + // 一键开通只落草稿,禁止把文档占位 3.5/4 当成已生效分成。 rule := model.DistributionRuleVersion{ SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, - Status: "active", - DistributorReceiveRatio: distReceive, - PlatformSellRatio: platSell, - ServiceFeeValue: serviceFee, - ServiceFeeUnit: ServiceFeeUnitFixedAmount, + Status: "draft", + DistributorReceiveRatio: req.DistributorReceiveRatio, + PlatformSellRatio: req.PlatformSellRatio, + ServiceFeeValue: req.ServiceFeeValue, + ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, @@ -1554,6 +1584,9 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) } func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) { + if !liveMoneyMovementEnabled() { + return nil, ErrLiveMoneyMovementFrozen + } if siteID == 0 { return nil, ErrSiteNotFound } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 4c34607..760f445 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -118,13 +118,16 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) { if err != nil { t.Fatal(err) } - if batch.TotalAmountCent != 750 { - t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent) + if batch.TotalAmountCent != 350 { + t.Fatalf("batch total = %d, want 350 (site share only)", batch.TotalAmountCent) } var batchItems []model.DistributionSettlementItem - if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 { + if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { t.Fatalf("batch items = %d, err=%v", len(batchItems), err) } + if batchItems[0].AmountCent != 350 || batchItems[0].PartyID != parties[0].ID { + t.Fatalf("batch item should be site party only: %+v", batchItems[0]) + } // 空批次不允许结算。 emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)}) if err != nil { @@ -145,6 +148,13 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) { if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled { t.Fatalf("second settle err = %v", err) } + walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatal(err) + } + if walletDTO.AvailableBalanceCent != 350 || walletDTO.TotalSettledCent != 350 { + t.Fatalf("site wallet should only receive site share, got %+v", walletDTO) + } } func TestScopeSiteIDRestrictsBoundMember(t *testing.T) { @@ -349,6 +359,9 @@ func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) { if displays[0].Title != "王者荣耀国服账号" || displays[0].ListingNo != "L20001" { t.Fatalf("unexpected auto-synced display: %+v", displays[0]) } + if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 { + t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule) + } } func TestGetPublicSiteInfo(t *testing.T) { @@ -532,34 +545,43 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { t.Fatalf("UpdateReceivedListingStatus failed: %v", err) } - // 2. Settlement batch settles and credits wallet - rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now()} + // 2. Settlement batch only credits the site party, never the platform share. + rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } - batch := model.DistributionSettlementBatch{ - SiteID: site.ID, - BatchNo: "B-WALLET-01", - PeriodStart: time.Now().Add(-24 * time.Hour), - PeriodEnd: time.Now(), - TotalAmountCent: 10000, - Status: "pending", - } - if err := db.Create(&batch).Error; err != nil { + order := model.RentalOrder{OrderNo: "RECV-WALLET-1", ListingID: listing.ID, AccountID: ga.ID, OwnerID: user.ID, RenterID: 9, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID} + if err := db.Create(&order).Error; err != nil { t.Fatal(err) } - batchItem := model.DistributionSettlementItem{ - BatchID: batch.ID, - OrderID: 888, - PartyID: site.ID, - AmountCent: 10000, - Status: "pending", + financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"} + if err := db.Create(&financial).Error; err != nil { + t.Fatal(err) } - if err := db.Create(&batchItem).Error; err != nil { + parties := []model.DistributionOrderParty{ + {OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 3500, ProfitAmountCent: 3500}, + {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 4000, ProfitAmountCent: 4000}, + } + if err := db.Create(&parties).Error; err != nil { t.Fatal(err) } - // Mark batch settled -> auto-credits merchant wallet + batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ + SiteID: site.ID, + PeriodStart: time.Now().Add(-24 * time.Hour), + PeriodEnd: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatalf("CreateSettlementBatch failed: %v", err) + } + if batch.TotalAmountCent != 3500 { + t.Fatalf("expected site share 3500, got %d", batch.TotalAmountCent) + } + var batchItems []model.DistributionSettlementItem + if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { + t.Fatalf("platform party must not enter site batch, items=%d err=%v", len(batchItems), err) + } + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil { t.Fatalf("MarkSettlementBatch failed: %v", err) } @@ -568,14 +590,14 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if err != nil { t.Fatalf("GetSiteWallet failed: %v", err) } - if walletDTO.AvailableBalanceCent != 10000 || walletDTO.TotalSettledCent != 10000 { - t.Fatalf("expected available balance 10000, got: %+v", walletDTO) + if walletDTO.AvailableBalanceCent != 3500 || walletDTO.TotalSettledCent != 3500 { + t.Fatalf("expected available balance 3500, got: %+v", walletDTO) } // 3. Apply withdrawal wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ SiteID: site.ID, - AmountCent: 4000, + AmountCent: 1000, AccountType: "alipay", AccountName: "分销商李四", AccountNo: "13800005678", @@ -583,17 +605,16 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if err != nil { t.Fatalf("ApplySiteWithdrawal failed: %v", err) } - if wd.AmountCent != 4000 || wd.Status != "pending" { + if wd.AmountCent != 1000 || wd.Status != "pending" { t.Fatalf("unexpected withdrawal: %+v", wd) } - // Check balance after freeze walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetSiteWallet after WD failed: %v", err) } - if walletAfterWD.AvailableBalanceCent != 6000 || walletAfterWD.FrozenBalanceCent != 4000 { - t.Fatalf("expected available 6000 frozen 4000, got: %+v", walletAfterWD) + if walletAfterWD.AvailableBalanceCent != 2500 || walletAfterWD.FrozenBalanceCent != 1000 { + t.Fatalf("expected available 2500 frozen 1000, got: %+v", walletAfterWD) } // 4. List withdrawals @@ -610,9 +631,209 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if err != nil { t.Fatalf("GetDashboardAnalytics failed: %v", err) } - if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 6000 { + if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 2500 { t.Fatalf("unexpected analytics: %+v", analytics) } } +func TestPlatformSellSettlementCreditsOnlySiteShare(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := model.DistributionSite{SiteCode: "sell_site", SiteName: "卖出站", Domain: "sell.example.com", Status: "active", DisplayAccountEnabled: true} + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + listing := model.RentalListing{ListingNo: "L-MAIN-SELL", AccountID: 301, OwnerID: 1, Status: "published"} + if err := db.Create(&listing).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionSiteAccount{SiteID: site.ID, AccountID: listing.AccountID, ListingID: listing.ID, DisplayStatus: "enabled"}).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active", + PlatformReceiveRatio: 3.5, DistributorSellRatio: 4, + ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, + EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + + foundSite, foundRule, err := ResolveOrderSourceTx(db, "sell_site", listing.ID, listing.AccountID, now) + if err != nil { + t.Fatal(err) + } + if foundRule.Mode != ModeTwoPartyPlatformReceive { + t.Fatalf("mode = %s, want %s", foundRule.Mode, ModeTwoPartyPlatformReceive) + } + + order := model.RentalOrder{ + OrderNo: "ORD-PLATFORM-SELL", ListingID: listing.ID, AccountID: listing.AccountID, + OwnerID: 1, RenterID: 2, Status: "completed", + DistributionSiteID: &foundSite.ID, DistributionRuleVersionID: &foundRule.ID, DistributionMode: foundRule.Mode, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatalf("SnapshotPaidOrderTx failed: %v", err) + } + + 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("parties = %d, err=%v", len(parties), err) + } + var siteIncome, platformIncome int64 + for _, party := range parties { + switch party.PartyRole { + case "distributor_sell": + if party.PartyType != PartyTypeSite || party.PartyID != site.ID { + t.Fatalf("site party = %+v", party) + } + siteIncome = party.IncomeAmountCent + case "platform_receive": + if party.PartyType != PartyTypePlatform || party.PartyID != 0 { + t.Fatalf("platform party_id must be 0, got %+v", party) + } + platformIncome = party.IncomeAmountCent + default: + t.Fatalf("unexpected party %+v", party) + } + } + if siteIncome <= 0 || platformIncome <= 0 { + t.Fatalf("expected both site and platform income, site=%d platform=%d", siteIncome, platformIncome) + } + + batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ + SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if batch.TotalAmountCent != siteIncome { + t.Fatalf("batch total = %d, want site income %d", batch.TotalAmountCent, siteIncome) + } + var batchItems []model.DistributionSettlementItem + if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { + t.Fatalf("platform party must not appear in site batch, items=%d err=%v", len(batchItems), err) + } + + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil { + t.Fatal(err) + } + walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatal(err) + } + if walletDTO.AvailableBalanceCent != siteIncome { + t.Fatalf("wallet Δ = %d, want %d", walletDTO.AvailableBalanceCent, siteIncome) + } +} + +func TestSettlementBatchSplitsThreePartyBySite(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + + siteA := model.DistributionSite{SiteCode: "party_a", SiteName: "A", Domain: "a.example.com", Status: "active", DisplayAccountEnabled: true} + siteB := model.DistributionSite{SiteCode: "party_b", SiteName: "B", Domain: "b.example.com", Status: "active", DisplayAccountEnabled: true} + if err := db.Create(&siteA).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&siteB).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{SiteID: &siteB.ID, Mode: ModeThreeParty, Version: 1, Status: "active", EffectiveFrom: now.Add(-time.Hour)} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + order := model.RentalOrder{OrderNo: "ORD-3P", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &siteB.ID, DistributionRuleVersionID: &rule.ID} + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: siteB.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending"} + if err := db.Create(&financial).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&[]model.DistributionOrderParty{ + {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 350}, + {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 400}, + }).Error; err != nil { + t.Fatal(err) + } + + period := CreateSettlementBatchRequest{PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour)} + batchA, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteA.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd}) + if err != nil || batchA.TotalAmountCent != 350 { + t.Fatalf("site A batch = %+v err=%v", batchA, err) + } + batchB, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteB.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd}) + if err != nil || batchB.TotalAmountCent != 400 { + t.Fatalf("site B batch = %+v err=%v", batchB, err) + } + + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchA.ID); err != nil { + t.Fatal(err) + } + var financialAfterA model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financialAfterA).Error; err != nil { + t.Fatal(err) + } + if financialAfterA.SettlementStatus != "pending" { + t.Fatalf("financial must stay pending until all site parties settle, got %s", financialAfterA.SettlementStatus) + } + + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchB.ID); err != nil { + t.Fatal(err) + } + var financialAfterB model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financialAfterB).Error; err != nil { + t.Fatal(err) + } + if financialAfterB.SettlementStatus != "settled" { + t.Fatalf("financial status = %s, want settled", financialAfterB.SettlementStatus) + } + + walletA, err := repo.GetSiteWallet(t.Context(), 0, siteA.ID) + if err != nil { + t.Fatal(err) + } + walletB, err := repo.GetSiteWallet(t.Context(), 0, siteB.ID) + if err != nil { + t.Fatal(err) + } + if walletA.AvailableBalanceCent != 350 || walletB.AvailableBalanceCent != 400 { + t.Fatalf("wallets A=%d B=%d", walletA.AvailableBalanceCent, walletB.AvailableBalanceCent) + } +} + +func TestLiveMoneyMovementFrozenInProduction(t *testing.T) { + t.Setenv("APP_ENV", "production") + db := distributionTestDB(t) + repo := NewRepository(db) + site := model.DistributionSite{SiteCode: "frozen", SiteName: "冻结站", Domain: "frozen.example.com", Status: "active"} + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + batch := model.DistributionSettlementBatch{ + SiteID: site.ID, BatchNo: "B-FROZEN", PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now(), + TotalAmountCent: 100, Status: "draft", + } + if err := db.Create(&batch).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionSettlementItem{BatchID: batch.ID, OrderID: 1, PartyID: 1, AmountCent: 100, Status: "pending"}).Error; err != nil { + t.Fatal(err) + } + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrLiveMoneyMovementFrozen { + t.Fatalf("settle err = %v", err) + } + if _, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ + SiteID: site.ID, AmountCent: 1, AccountType: "alipay", AccountName: "x", AccountNo: "1", + }); err != ErrLiveMoneyMovementFrozen { + t.Fatalf("withdraw err = %v", err) + } +}