fix(distribution): 修正分销模式判定与结算入账,冻结未确认分成

按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。
结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。
This commit is contained in:
yml2213
2026-09-08 16:52:09 +08:00
parent 2c4e87475a
commit 8521bf4753
8 changed files with 662 additions and 160 deletions
@@ -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
@@ -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):
@@ -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
}
@@ -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 {
@@ -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
}
@@ -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) {
@@ -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
}
@@ -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)
}
}