- 后端优化: - 引入 Redis + 内存 sync.Map 双重互斥锁,避免并发/连击导致唯一索引冲突 (ErrSyncInProgress) - 使用 CreateInBatches(500) 进行大数据量批量插入,杜绝超长 SQL 与锁表超时 - 增加 CleanStaleListings 支持,自动排查并联动下架主站非在售的货架商品,清理历史幽灵数据 - 增加 DefaultShelfRatioDelta 支持,新同步商品直接继承初始加价比例,免除二次手动改价 - 前端优化: - distribution-admin 货架管理新增一键同步配置弹窗(可勾选自动下架失效商品、配置初始加价) - 同步完成后展示结构化统计弹窗(新增、保持、联动下架、主站在售总量) - 前后端类型定义与 API 客户端对齐升级 - 测试覆盖: - 补充 TestSyncSiteAccountsOptimizations 单元测试与 handler 集成测试 - 全工程测试与门禁 check.sh 100% 通过
338 lines
11 KiB
Go
338 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")
|
|
ErrSyncInProgress = errors.New("distribution site accounts sync already in progress")
|
|
)
|
|
|
|
// 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
|
|
}
|