fix(distribution): 分成改为分主站提价利润,分站自主提价溢价全额归卖方

- 文档:升级至 v0.7,固化号主保底、主站提价利润分账(35/40/25)与分站加价溢价独享逻辑
- 定价:修正货架兑换比例调价方向为调减比例提价(site_ratio = base_ratio - delta)
- 分账:分成基数剔除号主成本与分站加价,提价利润按规则切分,分站自主加价溢价 100% 归卖出方
- 前端:更新分销后台提价说明文案,提示调减比例提高售价且溢价归本站
This commit is contained in:
yml2213
2026-09-08 19:45:05 +08:00
parent d762b33f54
commit 4aab9e7e33
11 changed files with 176 additions and 132 deletions
@@ -204,8 +204,8 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent)
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, splits), SettlementStatus: "pending"}
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
}
@@ -274,8 +274,8 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent)
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, splits), "settlement_status": "pending"}).Error; err != nil {
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 {
@@ -146,7 +146,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
profit := netPaid - input.OwnerRentAmountCent - paymentFee
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent - paymentFee
if profit < 0 {
profit = 0
}
@@ -212,8 +212,9 @@ type PartySplit struct {
AmountCent int64
}
// SplitSettlement 按百分数切利润:收号 35%、卖出 40%。服务费 25% 记在财务快照,不并进参与方。
func SplitSettlement(rule Rule, profitCent int64) []PartySplit {
// SplitSettlement 按百分数切主站提价利润(收号 35%、卖出 40%),并将分站自主加价溢价(markupCent)100% 独立全额归入卖出方。
// 平台服务费 25% 记在财务快照,不并进参与方。
func SplitSettlement(rule Rule, profitCent int64, markupCent int64) []PartySplit {
roles := []struct {
role string
ratio float64
@@ -242,19 +243,34 @@ func SplitSettlement(rule Rule, profitCent int64) []PartySplit {
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) int64 {
// SplitRemainderCent 返回主站提价利润扣除各方提价分成后的剩余(未扣服务费)。
func SplitRemainderCent(splitBase int64, splits []PartySplit, markupCent int64) int64 {
sum := int64(0)
for _, item := range splits {
sum += item.AmountCent
}
return splitBase - sum
profitSplitSum := sum - markupCent
return splitBase - profitSplitSum
}
// SplitRoundingCent 返回利润扣完收号、卖出和服务费后的四舍五入尾差。
func SplitRoundingCent(profitCent, serviceFeeCent int64, splits []PartySplit) int64 {
return SplitRemainderCent(profitCent, splits) - serviceFeeCent
// SplitRoundingCent 返回提价利润扣完收号、卖出和服务费后的四舍五入尾差。
func SplitRoundingCent(profitCent, serviceFeeCent, markupCent int64, splits []PartySplit) int64 {
return SplitRemainderCent(profitCent, splits, markupCent) - serviceFeeCent
}
@@ -75,7 +75,7 @@ func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) {
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 35, PlatformSellRatio: 40}
splits := SplitSettlement(rule, 3950)
splits := SplitSettlement(rule, 3950, 0)
if len(splits) != 2 {
t.Fatalf("splits = %#v", splits)
}
@@ -85,26 +85,34 @@ func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) {
if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 1580 {
t.Fatalf("platform split = %#v", splits[1])
}
if got := SplitRoundingCent(3950, 988, splits); got != -1 {
if got := SplitRoundingCent(3950, 988, 0, splits); got != -1 {
t.Fatalf("rounding = %d, want -1", got)
}
}
func TestSplitSettlementDoesNotAddYuanMarkup(t *testing.T) {
func TestSplitSettlementAddsShelfMarkupDirectlyToSeller(t *testing.T) {
rule := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 40, PlatformReceiveRatio: 35}
splits := SplitSettlement(rule, 10000)
if splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 4000 {
t.Fatalf("distributor sell = %#v", splits[0])
// 提价利润 10000 分,分站自主加价 2000 分
splits := SplitSettlement(rule, 10000, 2000)
// 卖方分站获得 40% (4000) + 2000 (加价) = 6000
if splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 6000 {
t.Fatalf("distributor sell = %#v, want 6000", splits[0])
}
// 收号方获得 35% = 3500
if splits[1].AmountCent != 3500 {
t.Fatalf("platform receive = %#v", splits[1])
t.Fatalf("platform receive = %#v, want 3500", splits[1])
}
// 尾差校验:平台服务费 2500,提价利润 10000 - (3500 + 4000) - 2500 = 0
if got := SplitRoundingCent(10000, 2500, 2000, splits); got != 0 {
t.Fatalf("rounding = %d, want 0", got)
}
}
func TestSplitSettlementThreeParty(t *testing.T) {
rule := Rule{Mode: ModeThreeParty, DistributorARatio: 35, DistributorBRatio: 40}
splits := SplitSettlement(rule, 10000)
if len(splits) != 2 || splits[0].AmountCent != 3500 || splits[1].AmountCent != 4000 {
// 提价利润 10000,分站B自主加价 1500
splits := SplitSettlement(rule, 10000, 1500)
if len(splits) != 2 || splits[0].AmountCent != 3500 || splits[1].AmountCent != 5500 {
t.Fatalf("splits = %#v", splits)
}
}
@@ -934,8 +934,8 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
return nil, err
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
splits := SplitSettlement(parsed, result.SplitBaseAmountCent)
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), SettlementStatus: "pending"}
splits := SplitSettlement(parsed, result.SplitBaseAmountCent, result.MarkupAmountCent)
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits), SettlementStatus: "pending"}
if err := tx.Create(&financial).Error; err != nil {
return err
}
@@ -701,7 +701,7 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) {
site := model.DistributionSite{SiteCode: "site_alpha", SiteName: "分销A", Domain: "a.com", Status: "active", DisplayAccountEnabled: true}
db.Create(&site)
dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", ShelfRatioDelta: 25}
dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", ShelfRatioDelta: 20}
db.Create(&dsa)
res, err := repo.ListPublic(context.Background(), PublicListQuery{
@@ -713,16 +713,17 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) {
if len(res.Items) != 1 || res.Items[0].ListingNo != "L101" {
t.Fatalf("expected only L101, got %+v", res.Items)
}
if res.Items[0].PriceCent != 8000 {
t.Fatalf("expected public price 8000 with ratio delta, got %d", res.Items[0].PriceCent)
// 主站 100 元 (10000 分,100 比例);分站提价减 20 比例 (80 比例) → 售价 125 元 (12500 分)
if res.Items[0].PriceCent != 12500 {
t.Fatalf("expected public price 12500 with ratio delta, got %d", res.Items[0].PriceCent)
}
detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha")
if err != nil {
t.Fatalf("FindPublic err: %v", err)
}
if detail.PriceCent != 8000 {
t.Fatalf("expected detail price 8000 with ratio delta, got %d", detail.PriceCent)
if detail.PriceCent != 12500 {
t.Fatalf("expected detail price 12500 with ratio delta, got %d", detail.PriceCent)
}
mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "")
if err != nil {
@@ -13,7 +13,9 @@ func BaseExchangeRatio(hafCoinAmount, priceCent int64) float64 {
return roundRatio(CoinWan(hafCoinAmount) / centToYuan(priceCent))
}
// ApplyShelfRatio 按分站加价比例重算售价。delta=0 时保持原价,避免四舍五入漂移。
// ApplyShelfRatio 按分站加价比例重算售价。
// 在「1 元兑换多少万哈夫币」的比例机制下:比例越小,售价越高(提价);比例越大,售价越低(打折)。
// 分站货架自主提价应调小兑换比例(siteRatio = baseRatio - ratioDelta),使分站售价高于主站基准价。
func ApplyShelfRatio(hafCoinAmount, basePriceCent int64, ratioDelta float64) (priceCent int64, baseRatio, siteRatio float64) {
baseRatio = BaseExchangeRatio(hafCoinAmount, basePriceCent)
if ratioDelta < 0 {
@@ -22,13 +24,13 @@ func ApplyShelfRatio(hafCoinAmount, basePriceCent int64, ratioDelta float64) (pr
if baseRatio <= 0 || ratioDelta == 0 {
return basePriceCent, baseRatio, baseRatio
}
siteRatio = roundRatio(baseRatio + ratioDelta)
if siteRatio <= 0 {
return basePriceCent, baseRatio, baseRatio
siteRatio = roundRatio(baseRatio - ratioDelta)
if siteRatio <= 0.01 {
siteRatio = 0.01
}
priceCent = yuanToCent(CoinWan(hafCoinAmount) / siteRatio)
if priceCent < 0 {
priceCent = 0
if priceCent < basePriceCent {
priceCent = basePriceCent
}
return priceCent, baseRatio, siteRatio
}
@@ -2,17 +2,18 @@ package listing
import "testing"
func TestApplyShelfRatioIncreasesExchangeRatio(t *testing.T) {
// 100M = 10000 万,主站 100 元 → 1 元 = 100w
price, base, site := ApplyShelfRatio(100_000_000, 10000, 25)
func TestApplyShelfRatioIncreasesPriceByLoweringRatio(t *testing.T) {
// 100M = 10000 万,主站 100 元 → 1 元 = 100w 比例
// 分站提价减 20 比例 → 1 元 = 80w 比例 → 售价 125 元 (12500 分)
price, base, site := ApplyShelfRatio(100_000_000, 10000, 20)
if base != 100 {
t.Fatalf("base ratio = %v, want 100", base)
}
if site != 125 {
t.Fatalf("site ratio = %v, want 125", site)
if site != 80 {
t.Fatalf("site ratio = %v, want 80", site)
}
if price != 8000 {
t.Fatalf("site price = %d, want 8000", price)
if price != 12500 {
t.Fatalf("site price = %d, want 12500", price)
}
}
@@ -326,7 +326,7 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) {
t.Fatal(err)
}
if err := db.Create(&model.DistributionSiteAccount{
SiteID: site.ID, AccountID: account.ID, ListingID: listing.ID, DisplayStatus: "enabled", ShelfRatioDelta: 25,
SiteID: site.ID, AccountID: account.ID, ListingID: listing.ID, DisplayStatus: "enabled", ShelfRatioDelta: 20,
}).Error; err != nil {
t.Fatal(err)
}
@@ -344,10 +344,11 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) {
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if dto.RentAmountCent == nil || *dto.RentAmountCent != 8000 {
t.Fatalf("paid rent = %v, want 8000", dto.RentAmountCent)
// 100M 币,主站标价 100 元 (10000 分,比例 100);分站提价减 20 比例 (比例 80),分站实付售价 125 元 (12500 分)
if dto.RentAmountCent == nil || *dto.RentAmountCent != 12500 {
t.Fatalf("paid rent = %v, want 12500", dto.RentAmountCent)
}
if dto.BasePriceCent != 10000 || dto.ShelfMarkupCent != -2000 || dto.MarkupSiteID != site.ID || dto.ShelfRatioDelta != 25 {
if dto.BasePriceCent != 10000 || dto.ShelfMarkupCent != 2500 || dto.MarkupSiteID != site.ID || dto.ShelfRatioDelta != 20 {
t.Fatalf("ratio snapshot = base %d deltaCent %d site %d ratioDelta %v", dto.BasePriceCent, dto.ShelfMarkupCent, dto.MarkupSiteID, dto.ShelfRatioDelta)
}
@@ -355,6 +356,7 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) {
if err := db.First(&saved, dto.ID).Error; err != nil {
t.Fatal(err)
}
// 号主本金保底 80 元 (8000 分),不受分站加价影响
if saved.OwnerRentAmountCent != 8000 {
t.Fatalf("owner rent = %d, want 8000", saved.OwnerRentAmountCent)
}
@@ -366,7 +368,7 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) {
if err := db.First(&after, dto.ID).Error; err != nil {
t.Fatal(err)
}
if after.ShelfRatioDelta != 25 || after.RentAmountCent != 8000 {
if after.ShelfRatioDelta != 20 || after.RentAmountCent != 12500 {
t.Fatalf("historical order must keep ratio snapshot, got delta %v rent %d", after.ShelfRatioDelta, after.RentAmountCent)
}
}