Files
hfb_sys/backend/internal/modules/distribution/lifecycle.go
T
yml2213 96bc6a4db6 fix(distribution): 货架加价打通前台、下单与卖方分成
分站公开列表和详情展示当前货架加价;下单写入 base_price / shelf_markup / markup_site_id 快照并计入实付。
支付分账改用快照加价,不再误用 PlatformFeeCent;结账不退加价,事后改价不影响历史订单。
2026-09-08 17:07:17 +08:00

269 lines
9.9 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.ShelfMarkupCent
if markup < 0 {
markup = 0
}
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
}