- 文档:升级至 v0.7,固化号主保底、主站提价利润分账(35/40/25)与分站加价溢价独享逻辑 - 定价:修正货架兑换比例调价方向为调减比例提价(site_ratio = base_ratio - delta) - 分账:分成基数剔除号主成本与分站加价,提价利润按规则切分,分站自主加价溢价 100% 归卖出方 - 前端:更新分销后台提价说明文案,提示调减比例提高售价且溢价归本站
290 lines
10 KiB
Go
290 lines
10 KiB
Go
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
|
||
}
|