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