fix(distribution): 修正分销模式判定与结算入账,冻结未确认分成
按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。 结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user