Files
hfb_sys/backend/internal/modules/distribution/pricing.go
T
yml2213 ef91b24bf2 test(distribution): 补齐分销全场景E2E与HTTP接口全链路集成测试
- 新增端到端测试场景:
  - 场景 1: 主站收号分站加价售卖与批次结算全链路
  - 场景 2: 分站收号主站售卖、独立鉴权、大盘统计与提现全流程
  - 场景 3: 三方模式跨站流转、专属支付通道自动路由与双站批次独立结算
  - 场景 4: 分站自收自售、3天冷却期自动释放及幂等防护
  - 场景 5: 结算后退款回冲金库钱包、掏空后透支扣减与仲裁风险隔离
  - 场景 6: 一键开站、成员JWT鉴权、品牌个性化、三模解析与货架同步加价
- 新增 distribution/handler_integration_test.go:覆盖 30+ 分销 HTTP 接口(前台公开端点、分站管理员登录/信息端点、站长运营端点及总台管理端点)
- 修复并对齐底层逻辑:
  - FindOrder & ListOrders:多参与方按 PartyID 准确隔离各站收益与提价统计
  - SnapshotPaidOrderTx:规范多方模式下 PartyTypeSite 的 SiteID/PartyID
  - 增加 ErrInsufficientBalance 哨兵错误并在 handler 映射为 400
2026-09-11 11:20:07 +08:00

337 lines
11 KiB
Go

package distribution
import (
"errors"
"math"
)
const (
ModeTwoPartyDistributorReceive = "two_party_distributor_receive"
ModeTwoPartyPlatformReceive = "two_party_platform_receive"
ModeThreeParty = "three_party"
// ModeSiteSelfSell 是分站卖本站货,不复用两方默认规则。
ModeSiteSelfSell = "site_self_sell"
PartyTypeSite = "site"
PartyTypePlatform = "platform"
ServiceFeeUnitPercent = "percent"
ServiceFeeUnitFixedAmount = "fixed_amount"
ServiceFeeUnitRatio = "ratio"
DefaultReceivePercent = 35.0
DefaultSellPercent = 40.0
DefaultServicePercent = 25.0
)
var (
ErrInvalidAmount = errors.New("invalid distribution amount")
ErrInvalidRate = errors.New("invalid distribution rate")
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
ErrUnsupportedMode = errors.New("unsupported distribution mode")
ErrInsufficientBalance = errors.New("insufficient wallet balance")
)
// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。
type Rule struct {
Mode string
PlatformReceiveRatio float64
PlatformSellRatio float64
DistributorReceiveRatio float64
DistributorSellRatio float64
DistributorARatio float64
DistributorBRatio float64
ServiceFeeValue float64
ServiceFeeUnit string
PaymentFeeBPS int64
TaxEnabled bool
TaxRateBPS int64
}
// SettlementInput 使用支付单和退款单的整数分快照计算分销费用。
type SettlementInput struct {
GrossPaidAmountCent int64
RefundAmountCent int64
OwnerRentAmountCent int64
MarkupAmountCent int64
GrossProfitByParty []PartyProfitInput
}
type PartyProfitInput struct {
PartyRole string
IncomeAmountCent int64
CostAmountCent int64
}
type PartyTaxResult struct {
PartyRole string `json:"party_role"`
GrossProfitCent int64 `json:"gross_profit_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
}
type SettlementResult struct {
GrossPaidAmountCent int64
RefundAmountCent int64
NetPaidAmountCent int64
PaymentFeeCent int64
ServiceFeeCent int64
MarkupAmountCent int64
SplitBaseAmountCent int64
TaxAmountCent int64
TaxByParty []PartyTaxResult
}
// DefaultRule 返回已确认的默认百分比:收号 35%、卖出 40%、服务费 25%。
func DefaultRule(mode string) (Rule, error) {
base := Rule{
Mode: mode, ServiceFeeValue: DefaultServicePercent, ServiceFeeUnit: ServiceFeeUnitPercent,
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
}
switch mode {
case ModeTwoPartyDistributorReceive:
base.DistributorReceiveRatio = DefaultReceivePercent
base.PlatformSellRatio = DefaultSellPercent
case ModeTwoPartyPlatformReceive:
base.PlatformReceiveRatio = DefaultReceivePercent
base.DistributorSellRatio = DefaultSellPercent
case ModeThreeParty:
base.DistributorARatio = DefaultReceivePercent
base.DistributorBRatio = DefaultSellPercent
case ModeSiteSelfSell:
base.DistributorSellRatio = DefaultReceivePercent + DefaultSellPercent
default:
return Rule{}, ErrUnsupportedMode
}
return base, nil
}
// ValidateRule 校验模式,并在百分比服务费下要求收号+卖出+服务费合计为 100。
func ValidateRule(rule Rule) error {
normalizeRulePercents(&rule)
if _, err := DefaultRule(rule.Mode); err != nil {
return ErrUnsupportedMode
}
unit := rule.ServiceFeeUnit
if unit == "" || unit == ServiceFeeUnitRatio {
unit = ServiceFeeUnitPercent
}
if unit != ServiceFeeUnitPercent && unit != ServiceFeeUnitFixedAmount {
return ErrInvalidServiceUnit
}
if unit != ServiceFeeUnitPercent {
return nil
}
var sum float64
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
sum = rule.DistributorReceiveRatio + rule.PlatformSellRatio + rule.ServiceFeeValue
case ModeTwoPartyPlatformReceive:
sum = rule.PlatformReceiveRatio + rule.DistributorSellRatio + rule.ServiceFeeValue
case ModeThreeParty:
sum = rule.DistributorARatio + rule.DistributorBRatio + rule.ServiceFeeValue
case ModeSiteSelfSell:
sum = rule.DistributorSellRatio + rule.ServiceFeeValue
}
if math.Abs(sum-100) > 0.15 {
return ErrInvalidRate
}
return nil
}
// NormalizeSplitPercent 把口头 3.5/4/2.5 换成 35/40/25;已经是百分数的原样保留。
func NormalizeSplitPercent(value float64) float64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
if value <= 10 {
return math.Round(value*10*10) / 10
}
return math.Round(value*10) / 10
}
func normalizeRulePercents(row *Rule) {
if row == nil {
return
}
row.PlatformReceiveRatio = NormalizeSplitPercent(row.PlatformReceiveRatio)
row.PlatformSellRatio = NormalizeSplitPercent(row.PlatformSellRatio)
row.DistributorReceiveRatio = NormalizeSplitPercent(row.DistributorReceiveRatio)
row.DistributorSellRatio = NormalizeSplitPercent(row.DistributorSellRatio)
row.DistributorARatio = NormalizeSplitPercent(row.DistributorARatio)
row.DistributorBRatio = NormalizeSplitPercent(row.DistributorBRatio)
if row.ServiceFeeUnit == ServiceFeeUnitRatio || row.ServiceFeeUnit == "" {
row.ServiceFeeUnit = ServiceFeeUnitPercent
}
if row.ServiceFeeUnit == ServiceFeeUnitPercent {
row.ServiceFeeValue = NormalizeSplitPercent(row.ServiceFeeValue)
}
}
// CalculateSettlement 先扣号主应得与分站加价,得到利润后再按 35/40/25 切。
// 支付通道手续费由主站承担:只记录金额供对账,不再从分成基数中扣除。
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
return SettlementResult{}, ErrInvalidAmount
}
if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 {
return SettlementResult{}, ErrInvalidRate
}
if rule.ServiceFeeUnit != ServiceFeeUnitPercent && rule.ServiceFeeUnit != ServiceFeeUnitFixedAmount && rule.ServiceFeeUnit != ServiceFeeUnitRatio {
return SettlementResult{}, ErrInvalidServiceUnit
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
effectiveMarkup := EffectiveMarkupCent(netPaid, input.OwnerRentAmountCent, input.MarkupAmountCent)
profit := netPaid - input.OwnerRentAmountCent - effectiveMarkup
if profit < 0 {
profit = 0
}
serviceFee, err := serviceFeeCent(rule, profit, netPaid)
if err != nil {
return SettlementResult{}, err
}
if serviceFee > profit {
serviceFee = profit
}
result := SettlementResult{
GrossPaidAmountCent: input.GrossPaidAmountCent,
RefundAmountCent: input.RefundAmountCent,
NetPaidAmountCent: netPaid,
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: effectiveMarkup,
SplitBaseAmountCent: profit,
}
if rule.TaxEnabled {
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
for _, party := range input.GrossProfitByParty {
profitParty := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(maxInt64(profitParty, 0)) * float64(rule.TaxRateBPS) / 10000)
result.TaxAmountCent += tax
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profitParty, TaxAmountCent: tax})
}
}
return result, nil
}
func serviceFeeCent(rule Rule, profit, netPaid int64) (int64, error) {
switch rule.ServiceFeeUnit {
case ServiceFeeUnitPercent:
return roundHalfUp(float64(profit) * rule.ServiceFeeValue / 100), nil
case ServiceFeeUnitFixedAmount:
return roundHalfUp(rule.ServiceFeeValue * 100), nil
case ServiceFeeUnitRatio:
if rule.ServiceFeeValue <= 0 {
return 0, nil
}
return roundHalfUp(float64(netPaid) / rule.ServiceFeeValue), nil
default:
return 0, ErrInvalidServiceUnit
}
}
func roundHalfUp(value float64) int64 {
return int64(math.Floor(value + 0.5))
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
// EffectiveMarkupCent 按剩余净付款压缩加价:全额退租金后卖方不得再留加价。
func EffectiveMarkupCent(netPaid, ownerRent, markup int64) int64 {
if markup <= 0 {
return 0
}
remain := netPaid - ownerRent
if remain <= 0 {
return 0
}
return minInt64(markup, remain)
}
// PartySplit 是一次分成拆分中单个参与方的金额。
type PartySplit struct {
PartyRole string
Ratio float64
AmountCent int64
}
// SplitSettlement 按百分数切主站提价利润(收号 35%、卖出 40%),并将分站自主加价溢价(markupCent)100% 独立全额归入卖出方。
// 平台服务费 25% 记在财务快照,不并进参与方。
func SplitSettlement(rule Rule, profitCent int64, markupCent int64) []PartySplit {
roles := []struct {
role string
ratio float64
}{}
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
roles = []struct {
role string
ratio float64
}{{"distributor_receive", rule.DistributorReceiveRatio}, {"platform_sell", rule.PlatformSellRatio}}
case ModeTwoPartyPlatformReceive:
roles = []struct {
role string
ratio float64
}{{"distributor_sell", rule.DistributorSellRatio}, {"platform_receive", rule.PlatformReceiveRatio}}
case ModeThreeParty:
roles = []struct {
role string
ratio float64
}{{"distributor_a", rule.DistributorARatio}, {"distributor_b", rule.DistributorBRatio}}
case ModeSiteSelfSell:
roles = []struct {
role string
ratio float64
}{{"distributor_sell", rule.DistributorSellRatio}}
default:
return nil
}
splits := make([]PartySplit, 0, len(roles))
for _, item := range roles {
amount := roundHalfUp(float64(profitCent) * item.ratio / 100)
splits = append(splits, PartySplit{PartyRole: item.role, Ratio: item.ratio, AmountCent: maxInt64(amount, 0)})
}
if markupCent > 0 {
sellerRole := "distributor_sell"
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
sellerRole = "platform_sell"
case ModeThreeParty:
sellerRole = "distributor_b"
}
for i := range splits {
if splits[i].PartyRole == sellerRole {
splits[i].AmountCent += markupCent
}
}
}
return splits
}
// SplitRemainderCent 返回主站提价利润扣除各方提价分成后的剩余(未扣服务费)。
func SplitRemainderCent(splitBase int64, splits []PartySplit, markupCent int64) int64 {
sum := int64(0)
for _, item := range splits {
sum += item.AmountCent
}
profitSplitSum := sum - markupCent
return splitBase - profitSplitSum
}
// SplitRoundingCent 返回提价利润扣完收号、卖出和服务费后的四舍五入尾差。
func SplitRoundingCent(profitCent, serviceFeeCent, markupCent int64, splits []PartySplit) int64 {
return SplitRemainderCent(profitCent, splits, markupCent) - serviceFeeCent
}