Files
hfb_sys/backend/internal/modules/distribution/lifecycle.go
T
yml2213 4aab9e7e33 fix(distribution): 分成改为分主站提价利润,分站自主提价溢价全额归卖方
- 文档:升级至 v0.7,固化号主保底、主站提价利润分账(35/40/25)与分站加价溢价独享逻辑
- 定价:修正货架兑换比例调价方向为调减比例提价(site_ratio = base_ratio - delta)
- 分账:分成基数剔除号主成本与分站加价,提价利润按规则切分,分站自主加价溢价 100% 归卖出方
- 前端:更新分销后台提价说明文案,提示调减比例提高售价且溢价归本站
2026-09-08 19:45:05 +08:00

290 lines
10 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 {
parsed := 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,
}
normalizeRulePercents(&parsed)
return parsed
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
return CalculateSettlement(ruleFromVersion(rule), input)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 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
}
grossRent := order.RentAmountCent
if grossRent <= 0 {
grossRent = grossPaidCent
}
markup := order.ShelfMarkupCent
if markup < 0 {
markup = 0
}
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossRent, OwnerRentAmountCent: ownerRent, 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: grossRent, 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: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, markup, 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 {
return ErrInvalidAmount
}
if refundAmountCent > financial.GrossPaidAmountCent {
refundAmountCent = financial.GrossPaidAmountCent
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
var order model.RentalOrder
ownerRent := int64(0)
if err := tx.Select("owner_rent_amount_cent").First(&order, orderID).Error; err == nil {
ownerRent = order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, OwnerRentAmountCent: ownerRent, 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": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, 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
}