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