Files
hfb_sys/backend/internal/modules/distribution/lifecycle.go
T
yml2213 8521bf4753 fix(distribution): 修正分销模式判定与结算入账,冻结未确认分成
按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。
结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。
2026-09-08 16:52:09 +08:00

266 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package distribution
import (
"errors"
"sync"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
// 兼容尚未执行分销迁移的旧环境,避免每次支付/退款事务都做系统表查询。
var (
distributionTablePresent bool
distributionTableCheckOnce sync.Once
)
func distributionTablesPresent(tx *gorm.DB) bool {
distributionTableCheckOnce.Do(func() {
distributionTablePresent = tx.Migrator().HasTable(&model.DistributionOrderFinancial{})
})
return distributionTablePresent
}
// 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
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
}
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
}
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
}
anchor = buySite
}
rule, err := findActiveRuleTx(tx, anchor.ID, mode, at)
if err != nil {
return nil, nil, err
}
return anchor, rule, nil
}
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,
}
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
return CalculateSettlement(ruleFromVersion(rule), input)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 参与方金额按规则比例对分成基数拆分,剩余(含尾差)记入 RoundingAdjustmentCent 归平台。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !distributionTablesPresent(tx) {
return nil
}
var order model.RentalOrder
if err := tx.First(&order, orderID).Error; err != nil {
return err
}
if order.DistributionSiteID == nil || order.DistributionRuleVersionID == nil {
return nil
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil {
return err
}
markup := order.PlatformFeeCent
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup})
if err != nil {
return err
}
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
}
var count int64
if err := tx.Model(&model.DistributionOrderParty{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
parties := make([]model.DistributionOrderParty, 0, len(splits))
for _, split := range splits {
partyType := PartyTypeSite
if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" {
partyType = PartyTypePlatform
}
partyID := *order.DistributionSiteID
if partyType == PartyTypePlatform {
partyID = 0
} else if split.PartyRole == "distributor_a" {
// 三方模式下 distributor_a 为商品收号站
var listing model.RentalListing
if err := tx.Select("distribution_site_id").First(&listing, order.ListingID).Error; err == nil && listing.DistributionSiteID != nil {
partyID = *listing.DistributionSiteID
}
}
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
}
if len(parties) == 0 {
return nil
}
return tx.Create(&parties).Error
}
// ApplyRefundTx 按累计成功退款金额重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if !distributionTablesPresent(tx) {
return nil
}
var financial model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if refundAmountCent < 0 || refundAmountCent > financial.GrossPaidAmountCent {
return ErrInvalidAmount
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
if err != nil {
return err
}
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
}
for _, split := range splits {
if err := tx.Model(&model.DistributionOrderParty{}).
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
Updates(map[string]any{"income_amount_cent": split.AmountCent, "profit_amount_cent": split.AmountCent}).Error; err != nil {
return err
}
}
return nil
}