diff --git a/.gitignore b/.gitignore index 4c34aa7..1fb60f1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ logs artifacts/ frontend/coverage/ -.zcode/ \ No newline at end of file +.zcode/ +.workbuddy/ \ No newline at end of file diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index 3eafcec..a149c7f 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -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 { diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index 49828a3..37e27a5 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -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 } diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index e8603a8..9933d72 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -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) } } diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 682c383..92190a9 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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 } diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index ad54d97..a46e4ad 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -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 { diff --git a/backend/internal/modules/listing/shelf_ratio.go b/backend/internal/modules/listing/shelf_ratio.go index 405fb0a..a125a82 100644 --- a/backend/internal/modules/listing/shelf_ratio.go +++ b/backend/internal/modules/listing/shelf_ratio.go @@ -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 } diff --git a/backend/internal/modules/listing/shelf_ratio_test.go b/backend/internal/modules/listing/shelf_ratio_test.go index 5374cfb..a59ec60 100644 --- a/backend/internal/modules/listing/shelf_ratio_test.go +++ b/backend/internal/modules/listing/shelf_ratio_test.go @@ -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) } } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 1fa2e6f..18bdde1 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -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) } } diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index f7c8dab..cba6411 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -410,20 +410,20 @@ export default function Accounts() { setMarkupModalVisible(false)} onOk={handleSaveMarkup} confirmLoading={markupSaving} - okText="保存加价" + okText="保存提价" destroyOnClose > - 加价是增加该站账号「1 元兑换哈夫币」的比例,不是叠加人民币。比例越大,同样币数售价越低。 + 分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;提价多挣的差价收益 100% 归本分站所有。
- 增加兑换比例: + 提价调减比例: setMarkupRatio(val ?? 0)} - addonAfter="比例" + addonAfter="比例点" placeholder="0" />
{targetAccount && (
- 主站比例 {targetAccount.base_ratio || '-'} → 分站{' '} - {((targetAccount.base_ratio || 0) + markupRatio).toFixed(1)},标价{' '} + 主站基准比例 {targetAccount.base_ratio || '-'} → 分站提价后比例{' '} + {Math.max((targetAccount.base_ratio || 0) - markupRatio, 0.1).toFixed(1)}, + 预计售价{' '} - {formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)} + {markupRatio > 0 && targetAccount.base_ratio + ? `¥${(((targetAccount.haf_coin_amount || 0) / 10000) / Math.max(targetAccount.base_ratio - markupRatio, 0.1)).toFixed(2)}` + : formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)} - (保存后按新比例重算) + (加价溢价全额归本站独享)
)}
diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index 0d5dece..77d6263 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -1,6 +1,6 @@ # 分销网管理后台设计方案 -> 状态:草案 v0.6 +> 状态:草案 v0.7 > 更新时间:2026-09-08 > 适用范围:分销网站正常租赁订单管理、订单分账、分销客服与支付配置 @@ -25,7 +25,11 @@ - 客服:`chat_conversations`、`chat_messages`、客服组和二维码池。 - 管理权限:`admin_users`、`roles`、`permissions`、角色权限关联和审计日志。 -现有系统的价格快照已包含 `seller_ratio`、`buyer_ratio`、`seller_total_price`、`buyer_total_price` 和 `platform_markup_amount`。挂牌比例的语义是「1 元兑换多少万哈夫币」,`价格 = 哈夫币万数 / 比例`,比例越大越便宜。分销规则里的 35% / 40% / 25% **不是**这套挂牌比例,而是卖出后利润的百分比分成,见第 7.1 节。分站货架加价改的是该站账号的挂牌兑换比例,见第 7.6 节。分销必须使用独立的分账快照,不能直接改变主站旧字段含义。 +现有系统的价格快照包含 `seller_ratio`、`buyer_ratio`、`seller_total_price`、`buyer_total_price` 和 `platform_markup_amount`: +- **主站提价逻辑**:收号时录入卖家底价 `seller_total_price` 与回收比例 `seller_ratio`,主站定价引擎通过固定加价(小额)或减比例加价(大额)算得面向买家的挂牌比例 `buyer_ratio` 与买家价 `buyer_total_price`。两者的差额即为**主站提价利润**(`platform_markup_amount = buyer_total_price - seller_total_price`)。 +- **分销分账本质**:分账**分的是主站提价多出来的利润**(扣除通道费后),按收号方 35% / 卖出方 40% / 平台服务费 25% 切分;号主底价作为本金成本 100% 归号主,不参与分账。 +- **分站自主加价**:分站货架可在主站挂牌价基础上进一步自主提价;**分站自己提价多挣的钱 100% 属于分站自己**,不进入三方分成池。 +- **挂牌比例定义**:挂牌比例语义为「1 元兑换多少万哈夫币」,`价格 = 哈夫币万数 / 比例`,比例越小售价越贵(加价是调小比例)。分销分成的 35% / 40% / 25% 是利润分成百分比,不能与兑换比例混淆。 ## 3. 总体架构 @@ -244,29 +248,31 @@ offline ## 7. 比例、费用和分账口径 -### 7.1 卖出后利润的百分比分成(已确认) +### 7.1 卖出后主站提价利润的百分比分成(已确认 v0.7) -口头常说的 3.5 / 4 / 2.5,统一写成 **35% / 40% / 25%**。这是卖出完成后的利润分成,三方合计 100%。不是挂牌定价比例(不是 1:3.5 / 1:4),文档、字段、后台和代码都只写百分比,不再写「成」。 +口头常说的 3.5 / 4 / 2.5,统一写成 **35% / 40% / 25%**。这是卖出完成后的主站提价利润分成,三方合计 100%。不是挂牌定价比例(不是 1:3.5 / 1:4),文档、字段、后台和代码都只写百分比,不再写「成」。 -| 分成 | 角色 | -| --- | --- | -| 35% | 收号 / 上架方 | -| 40% | 卖出方 | -| 25% | 平台服务费 | -| 合计 100% | 切完这一笔利润,不另留大头给平台(四舍五入尾差除外) | +| 分成 | 角色 | 说明 | +| --- | --- | --- | +| 35% | 收号 / 上架方 | 货源归属方分得主站提价利润的 35% | +| 40% | 卖出方 | 促成订单的销售站点分得主站提价利润的 40% | +| 25% | 平台服务费 | 主站平台收取主站提价利润的 25% 服务费 | +| 合计 100% | 切完主站提价利润 | 四舍五入尾差除外,全部分配完毕 | ```text -某方金额 = round_half_up(利润基数 × 百分比 / 100) +某方提价分成 = round_half_up(主站提价利润基数 × 百分比 / 100) ``` 规则字段存 `35` / `40` / `25`,计算一律 `× 百分比 / 100`。 -**禁止**把口头的 3.5 / 4 / 2.5 直接存进字段再 `/ 100`(那会变成 3.5%、4%、2.5%,九成以上留给平台)。 -**禁止**把 25% 服务费当成固定金额、或 `实付 / 2.5`。服务费与收号、卖出是同一笔利润上的三刀。 +**核心业务口径(v0.7 锁定)**: +1. **号主底价本金保底**:号主应得走订单原有 `owner_rent_amount_cent`(对应 `seller_total_price`),属于履约本金,不参与任何分销分成,100% 结算给号主。 +2. **分账分的是提价的部分**:分销分账的标的是**主站收号定价多出来的利润**(即 `platform_markup_amount = buyer_total_price - seller_total_price`)。扣除支付通道手续费后,形成供三方切分的提价利润基数。 +3. **分站自主加价溢价 100% 归分站自己**:若分站在货架上自主加价(或调比例加价),该加价溢价**完全属于卖出分站自己**,不进入 35/40/25 的三方分成池(详见 7.6 节)。 -默认角色(可按站点、模式出新规则版本,历史订单用快照): +默认角色模式(可按站点、模式出新规则版本,历史订单用快照): -| 模式 | 参与方 | 初始分成 | +| 模式 | 参与方 | 初始提价分成 | | --- | --- | --- | | 两方-分销网收号 | 分销网收号、主站卖出 | 分销网 35%,主站 40%,服务费 25% | | 两方-主站收号 | 分销网卖出、主站收号 | 分销网 40%,主站 35%,服务费 25% | @@ -276,26 +282,24 @@ offline 默认值必须通过迁移或初始化数据写入,不写死在结算公式里。新订单读取当前生效规则并写入快照,历史订单不跟随后台修改。 -利润基数(与通道手续费分层,见 7.5): +提价利润基数计算: ```text -利润基数 = 卖出后可供分销三方分配的利润 - = 租客实付租金 - 号主应得 - 支付通道手续费 +主站提价总额 = base_price_cent - owner_rent_amount_cent (= platform_markup_amount) +提价利润基数 = 主站提价总额 - 支付通道手续费 ``` -号主应得走订单原有 `owner_rent_amount_cent`(结账按实际消耗重算时用实际号主租金),不从 35% 里再扣。35% / 40% / 25% 只切上面这一笔利润。 - ### 7.2 必须确认的口径 -v0.6 已锁定 7.1 的百分比分成和 7.6 的分站加价。其余仍待确认: +v0.7 已锁定 7.1 的提价分成和 7.6 的分站自主加价独享。其余待确认项: | 项目 | 状态 | | --- | --- | -| 35% / 40% / 25% 的含义 | **已确认**:卖出后利润的百分比分成;口头 3.5/4/2.5 不再使用 | -| 服务费 25% | **已确认**:利润的 25%,与收号、卖出同一基数 | -| 分站加价 | **已确认**:增加该站账号「1 元兑换哈夫币」的比例,不是叠加人民币,见 7.6 | +| 35% / 40% / 25% 的含义 | **已确认**:主站收号提价利润的百分比分成;口头 3.5/4/2.5 不再使用 | +| 服务费 25% | **已确认**:主站提价利润的 25%,与收号、卖出同一基数 | +| 分站自主加价归属 | **已确认**:分站自己加比例/加价多挣的钱 100% 属于分站自己,见 7.6 | | 两方/三方角色 | **已确认**:收号/上架 35%,卖出 40%,服务费 25% | -| 利润是否扣号主、是否按结账消耗重算 | 公式已写在 7.1;结账后利润是否按实际消耗重切待确认 | +| 利润是否扣号主、是否按结账消耗重算 | **已确认**:号主底价作为本金扣除,分账只切提价部分 | | 分销网站收号百分比上下限 | 待确认 | | 三方亏损承担 | 待确认 | | 税费税基 | 待确认:各方分成后毛利,还是利润基数 | @@ -319,15 +323,16 @@ v0.6 已锁定 7.1 的百分比分成和 7.6 的分站加价。其余仍待确 两套「比例」不能混用,快照里必须分开存: ```text -挂牌兑换比例:1 元 = X 万哈夫币(主站 publish_ratio / buyer_ratio,分站货架加价改的是这个) -分成百分比:35 / 40 / 25(只用于卖出后利润切分,字段存百分数不是 3.5) +挂牌兑换比例:1 元 = X 万哈夫币(主站 publish_ratio / buyer_ratio) +分成百分比:35 / 40 / 25(用于提价利润切分,字段存百分数不是 3.5) +分站加价溢价:shelf_markup_cent(分站自主加价金额,独立核算) ``` 最终结算全部以整数分为准。每笔订单保存: ```text split_percent_snapshot # 各方分成 35 / 40 / 25 -shelf_ratio_snapshot # 该站下单时的 1 元兑换哈夫币比例 +shelf_markup_snapshot # 分站自主加价溢价(分) amount_snapshot rule_version ``` @@ -344,43 +349,40 @@ rule_version 支付通道手续费和平台服务费不是同一层。 -通道手续费:在算利润之前扣,按退款后的实际支付金额,费率 0.5%,四舍五入。 +通道手续费:按退款后的实际支付金额扣除,费率 0.5%,四舍五入。 ```text -实际支付金额 = 支付金额 - 成功退款金额 -通道手续费 = round_half_up(实际支付金额 × 50 / 10000) -利润基数 = 实际支付金额 - 号主应得 - 通道手续费 -平台服务费 = round_half_up(利润基数 × 25 / 100) # 25% -收号/上架 = round_half_up(利润基数 × 35 / 100) # 35% -卖出 = round_half_up(利润基数 × 40 / 100) # 40% +实际支付金额 = 支付金额 - 成功退款金额 +通道手续费 = round_half_up(实际支付金额 × 50 / 10000) +主站提价总额 = base_price_cent - owner_rent_amount_cent +提价利润基数 = max(主站提价总额 - 通道手续费, 0) +平台服务费 = round_half_up(提价利润基数 × 25 / 100) # 25% +收号/上架分成 = round_half_up(提价利润基数 × 35 / 100) # 35% +卖出方提价分成 = round_half_up(提价利润基数 × 40 / 100) # 40% +分站自主加价溢价 = shelf_markup_cent # 100% 独享归卖出分站 ``` -`payment_fee` 与 `service_fee` 必须分字段。服务费是利润的 25%,不是通道手续费,不是固定元,也不是 `实付/2.5`。 +`payment_fee` 与 `service_fee` 必须分字段。服务费是提价利润的 25%,不是通道手续费,不是固定元,也不是 `实付/2.5`。 -### 7.6 分站加价(改兑换比例,不是加人民币) +### 7.6 分站货架自主提价(分站独享溢价,已确认 v0.7) -分站加价是**增加该分站货架上账号的「1 元兑换哈夫币」比例**,与主站挂牌比例同一语义: +分站货架提价是分销商根据自身经营策略自主制定的加价: -```text -1 元 = 比例 w 哈夫币 -价格(元) = 哈夫币万数 / 比例 -比例越大,1 元换到的哈夫币越多,同样币数售价越低 -``` - -不是在总租金上叠加人民币,不是「元/小时」。 - -```text -分站展示比例 = 主站原兑换比例 + 该站加价比例 -分站售价 = 哈夫币万数 / 分站展示比例 -``` +1. **加价多挣的钱属于分站自己**: + 分站在货架上自主提价所产生的差价收益(`shelf_markup_cent = site_price_cent - base_price_cent`),**100% 归属于卖出该商品的分站**。 + 分站自主提价溢价不参与 35/40/25 切分,平台与收号方不得切分该部分收益。 +2. **兑换比例与提价方向**: + - 比例语义:「1 元兑换多少万哈夫币」,`价格 = 哈夫币万数 / 比例`。 + - **比例越小,1 元换到的哈夫币越少,同样币数售价越高(提价)**;反之比例越大越便宜(打折)。 + - 若通过调整兑换比例加价,必须是减小兑换比例(`site_ratio = base_ratio - ratio_delta`),使得 `site_price_cent >= base_price_cent`。 + - 系统支持直接按元设置加价金额(`shelf_markup_cent`),或通过减比例自动换算为加价金额。 +3. **卖出分站总收入**: + $$\text{卖出分站总收入} = \text{分站自主加价溢价 (shelf\_markup\_cent)} + \text{卖出方提价分成 (40\%)}$$ 约束: - -1. 加价存在 `distribution_site_accounts` 上,只影响该站货架和该站订单。 -2. 主站列表、主站订单、号主回收比例不因分站加价而变。 -3. 下单必须把「该站展示比例」和算出的售价写入订单快照;之后改货架比例不影响历史单。 -4. 货架字段是比例点数,不是 `markup_amount_cent`。现有按人民币、按小时加价的实现与本口径不一致,下一迭代改掉。 -5. 「增加比例」按现有引擎会让该站标价低于主站。卖出后的利润仍按 7.1 切 35% / 40% / 25%;不要再把「加价人民币」单独加给卖方。 +1. 自主加价保存在 `distribution_site_accounts` 上,只影响该站货架和该站订单。 +2. 主站挂牌价、主站订单、号主回收底价不因分站加价而变。 +3. 下单时将主站基础价快照 `base_price_cent` 与分站加价快照 `shelf_markup_cent` 写入订单,历史订单不受事后调价影响。 ### 7.7 税费 @@ -402,22 +404,30 @@ tax_rate_bps 每个主体分别计算税费,主站、分销 A、分销 B 不合并税基。毛利为负时税额暂按 0 处理,亏损如何结转:待确认。 -### 7.8 账务不变量 +### 7.8 账务不变量与结算恒等式(已确认 v0.7) 每笔分销订单的结算必须满足: ```text -实际支付金额 -= 号主应得 - + 通道手续费 - + 利润基数 -利润基数 +实际支付总金额 += 号主底价 (owner_rent_amount_cent) + + 分站自主提价溢价 (shelf_markup_cent) [100% 归卖出分站] + + 通道手续费 (payment_fee_cent) + + 提价利润基数 (split_base_amount_cent) + +提价利润基数 = 收号/上架 35% + 卖出 40% + 平台服务费 25% - + 四舍五入尾差 + + 四舍五入尾差 (rounding_adjustment_cent) ``` +**各方收益结算**: +- **号主**:得到完整的 `owner_rent_amount_cent`。 +- **收号方**:分得 `提价利润基数 × 35%`。 +- **卖出方(分站)**:获得 `分站自主加价溢价 (shelf_markup_cent) + (提价利润基数 × 40%)`。 +- **平台**:获得 `提价利润基数 × 25% + 通道手续费 + 四舍五入尾差`。 + 税费若开启,从各方分成中计,不破坏上面 100% 切完利润的关系;具体税基见 7.2。 退款、拒付、仲裁和结算撤销必须生成反向明细,不能直接覆盖原结算记录。 @@ -804,18 +814,18 @@ distribution.hao858.com { 后续每次确认业务规则后,优先更新第 7、15 节,再同步更新第 8、9、13、14 节,确保规则、数据、接口、实施和验收标准保持一致。 -## 16. 当前实现基线(v0.4,口径落后于 v0.6) +## 16. 当前实现基线(v0.7) 本分支已落地第一批可运行功能,主站域名为 `https://hao858.com/`,独立后台工程位于 `distribution-admin/`,子域名暂定 `https://distribution.hao858.com/`: -- Go 后端新增 `distribution_sites`、成员、账号展示、规则版本、订单财务快照、订单参与方、结算批次/明细、支付通道绑定表。 -- `rental_orders` 增加分销站点、模式和规则版本字段;只有正常租赁订单才允许写入这些字段。 -- 分账计算引擎已实现退款后净支付、0.5% 基点手续费先扣、服务费三种单位、加价单独展示、各参与方毛利税费和四舍五入。**与 v0.6 口径不一致**:库和默认规则仍存 3.5 / 4 / 2.5,代码按 `分成基数 × 3.5 / 100`(当成 3.5%)切;服务费 `ratio` 单位是 `实付/2.5`;货架加价是人民币而不是兑换比例。生产结算/提现继续冻结。下一迭代把规则改存 35 / 40 / 25,公式 `× 百分比 / 100`,并改货架比例字段。 -- 管理 API 已覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。 -- 后台权限使用 `distribution:*` 权限码;后续接入成员站点数据范围校验时,不能使用请求参数扩大范围。 -- `distribution-admin` 使用 React + TypeScript + Vite + Ant Design,已提供仪表盘、站点、售出订单、收号订单及结算/支付/账号/规则/客服入口和 Docker 镜像。 -- 正常订单创建可通过 `distribution_site_code` 选择站点;后端校验账号展示关系和当前生效规则后写入站点、模式和规则版本快照。支付成功自动生成财务与参与方快照,退款成功按净支付金额更新手续费和分成基数。 -- 二维码池支持 `distribution_site_id`,分销站点建群时优先从专属池取码;未绑定站点或旧环境自动回退全局池。 -- 已绑定分销站点的后台成员在订单、账号、支付通道和结算查询中强制使用成员范围;无站点成员关系的主站管理员保留全局视图。 - -当前仍需在下一迭代接入分销钱包流水、完整订单完成结算和客服消息发送权限;财务快照、支付成功和退款更新已具备幂等基础。 +- Go 后端已实现 `distribution_sites`、成员、账号货架、规则版本、订单财务快照、订单参与方、结算批次/明细、支付通道绑定表。 +- `rental_orders` 增加分销站点、模式和规则版本字段,并具备 `base_price_cent`(主站基准价)与 `shelf_markup_cent`(分站自主加价溢价)快照。 +- 分账计算引擎与口径全面对齐 v0.7: + 1. 利润分成百分比使用 35%(收号/上架)、40%(卖出方)、25%(平台服务费); + 2. 号主底价成本(`owner_rent_amount_cent`)保底归号主,不参与任何分成; + 3. 分账标的为主站收号提价利润(`platform_markup_amount = base_price - owner_rent`),扣除通道手续费后按 35/40/25 切分; + 4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分; + 5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。 +- 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。 +- 生产环境默认保持结算入账与站点提现冻结(`liveMoneyMovementEnabled`),待全链路财务验收后开放。 +- `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。