feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则

退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 18:13:46 +08:00
co-authored by Cursor
parent e621722218
commit 768902f2f7
28 changed files with 627 additions and 263 deletions
@@ -246,6 +246,9 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
// ----------------------------------------------------
// 6. 批次结算与分站金库到账 (Settlement Batch & Wallet Credit)
// ----------------------------------------------------
if err := db.Model(&model.RentalOrder{}).Where("id = ?", orderDTO.ID).Update("status", "completed").Error; err != nil {
t.Fatal(err)
}
distRepo := distribution.NewRepository(db)
// 创建结算批次
@@ -131,8 +131,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
if err := tx.Save(&order).Error; err != nil {
return err
}
if order.Status == "completed" {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil {
return err
}
}
@@ -373,6 +373,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
checkout.ShortfallCent = settlement.ShortfallCent
checkout.OvershootAmountCent = settlement.OvershootAmountCent
checkout.Status = "settled"
}
func isPlatformManagedOrder(order model.RentalOrder) bool {
@@ -19,16 +19,13 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
// 1. Onboard new site
req := OnboardSiteRequest{
SiteCode: "gz_dist",
SiteName: "广州分销站",
Domain: "gz.distribution.com",
AdminUsername: "gz_operator",
AdminPassword: "Password123!",
AdminNickname: "广州运营小李",
MemberType: "operator",
DistributorReceiveRatio: 3.8,
PlatformSellRatio: 4.2,
ServiceFeeValue: 2.0,
SiteCode: "gz_dist",
SiteName: "广州分销站",
Domain: "gz.distribution.com",
AdminUsername: "gz_operator",
AdminPassword: "Password123!",
AdminNickname: "广州运营小李",
MemberType: "operator",
}
resp, err := svc.OnboardSite(ctx, req)
@@ -48,11 +45,11 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID {
t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID)
}
if resp.Rule.DistributorReceiveRatio != 38 {
t.Fatalf("expected rule percent 38, got %f", resp.Rule.DistributorReceiveRatio)
if resp.Rule.DistributorReceiveRatio != 35 || resp.Rule.PlatformSellRatio != 40 || resp.Rule.ServiceFeeValue != 25 {
t.Fatalf("expected default 35/40/25, got %+v", resp.Rule)
}
if resp.Rule.Status != "draft" {
t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status)
if resp.Rule.Status != "active" {
t.Fatalf("onboard rules should be active by default, got %s", resp.Rule.Status)
}
// 2. Duplicate site code or domain check
+2 -1
View File
@@ -333,7 +333,7 @@ type OnboardSiteRequest struct {
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
AdminNickname string `json:"admin_nickname"`
MemberType string `json:"member_type" binding:"omitempty,oneof=operator finance customer_service"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"` // 兼容旧入参,开通时忽略,四种模式一律走默认分成
PlatformSellRatio float64 `json:"platform_sell_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
}
@@ -417,6 +417,7 @@ type SiteWalletDTO struct {
PendingReleaseCent int64 `json:"pending_release_cent"`
TotalSettledCent int64 `json:"total_settled_cent"`
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
SettlementHoldDays int `json:"settlement_hold_days"`
}
type ApplySiteWithdrawalRequest struct {
@@ -440,6 +440,20 @@ func (h *Handler) MarkSettlementBatch(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) CancelSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.CancelSettlementBatch(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
func parseQueryUint(c *gin.Context, key string) int64 {
raw := strings.TrimSpace(c.Query(key))
@@ -761,6 +775,10 @@ func writeError(c *gin.Context, err error) {
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrInvalidSettlementHold):
response.BadRequest(c, "结算冷却天数须在 0 到 365 之间")
case errors.Is(err, ErrBatchNotCancellable):
response.BadRequest(c, "该结算批次无法作废")
case errors.Is(err, ErrTooManySyncListings):
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncListingNotFound):
@@ -206,8 +206,8 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
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"}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
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, result.MarkupAmountCent, splits), SettlementStatus: "pending"}
// 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。
var existing model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil {
@@ -336,7 +336,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
// 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。
settlementStatus := "pending"
if financial.ReleasedAt != nil {
@@ -352,7 +352,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
"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),
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits),
"settlement_status": settlementStatus,
"release_at": releaseAt,
}).Error; err != nil {
@@ -433,7 +433,7 @@ func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.Dist
if financial.ReleasedAt != nil {
return financial.ReleaseAt, nil
}
if order.Status != "completed" {
if !orderStatusAllowsRelease(order.Status) {
return financial.ReleaseAt, nil
}
completedAt := order.SettledAt
@@ -464,7 +464,7 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
}
return err
}
if order.Status != "completed" {
if !orderStatusAllowsRelease(order.Status) {
continue
}
releaseAt, err := computeReleaseAt(tx, order, financial)
@@ -479,6 +479,10 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
return nil
}
func orderStatusAllowsRelease(status string) bool {
return status == "completed" || status == "closed"
}
// partySettled 判断参与方份额是否已经通过结算批次入账。
func partySettled(tx *gorm.DB, partyID uint64) (bool, error) {
var count int64
@@ -472,3 +472,59 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
}
}
func TestApplyRefundTxClampsSellerMarkup(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{SiteCode: "markup_refund", SiteName: "加价回退站", Domain: "markup-refund.dist.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-MARKUP-REFUND", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed",
RentAmountCent: 10000, OwnerRentAmountCent: 6000, ShelfMarkupCent: 3000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
var seller model.DistributionOrderParty
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 3400 {
t.Fatalf("paid seller = %d, want 400+3000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 2000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 2000 {
t.Fatalf("partial refund seller markup = %d, want 2000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 0 {
t.Fatalf("full refund must return seller markup, got %d", seller.IncomeAmountCent)
}
}
@@ -97,13 +97,46 @@ func DefaultRule(mode string) (Rule, error) {
base.DistributorARatio = DefaultReceivePercent
base.DistributorBRatio = DefaultSellPercent
case ModeSiteSelfSell:
return Rule{}, ErrUnsupportedMode
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) {
@@ -147,7 +180,8 @@ 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 - input.MarkupAmountCent
effectiveMarkup := EffectiveMarkupCent(netPaid, input.OwnerRentAmountCent, input.MarkupAmountCent)
profit := netPaid - input.OwnerRentAmountCent - effectiveMarkup
if profit < 0 {
profit = 0
}
@@ -164,7 +198,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
NetPaidAmountCent: netPaid,
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: input.MarkupAmountCent,
MarkupAmountCent: effectiveMarkup,
SplitBaseAmountCent: profit,
}
if rule.TaxEnabled {
@@ -206,6 +240,25 @@ func maxInt64(a, b int64) int64 {
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
@@ -236,6 +289,11 @@ func SplitSettlement(rule Rule, profitCent int64, markupCent int64) []PartySplit
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
}
@@ -10,6 +10,7 @@ func TestDefaultRules(t *testing.T) {
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 35 && r.PlatformSellRatio == 40 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 35 && r.DistributorSellRatio == 40 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 35 && r.DistributorBRatio == 40 }},
{ModeSiteSelfSell, func(r Rule) bool { return r.DistributorSellRatio == 75 && r.ServiceFeeValue == 25 }},
}
for _, item := range cases {
rule, err := DefaultRule(item.mode)
@@ -17,8 +18,8 @@ func TestDefaultRules(t *testing.T) {
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
}
}
if _, err := DefaultRule(ModeSiteSelfSell); err != ErrUnsupportedMode {
t.Fatalf("site self-sell must not have a default split, err=%v", err)
if _, err := DefaultRule("unknown_mode"); err != ErrUnsupportedMode {
t.Fatalf("unknown mode must be unsupported, err=%v", err)
}
}
@@ -116,3 +117,54 @@ func TestSplitSettlementThreeParty(t *testing.T) {
t.Fatalf("splits = %#v", splits)
}
}
func TestCalculateSettlementClampsMarkupToRemainingNetPaid(t *testing.T) {
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000, RefundAmountCent: 10000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
})
if err != nil {
t.Fatal(err)
}
if got.MarkupAmountCent != 0 || got.SplitBaseAmountCent != 0 || got.NetPaidAmountCent != 0 {
t.Fatalf("full refund must zero markup and profit: %#v", got)
}
partial, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000, RefundAmountCent: 2000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
})
if err != nil {
t.Fatal(err)
}
if partial.NetPaidAmountCent != 8000 || partial.MarkupAmountCent != 2000 || partial.SplitBaseAmountCent != 0 {
t.Fatalf("partial refund should clamp markup to remainder: %#v", partial)
}
}
func TestSplitSettlementSiteSelfSell(t *testing.T) {
rule, err := DefaultRule(ModeSiteSelfSell)
if err != nil {
t.Fatal(err)
}
splits := SplitSettlement(rule, 10000, 2000)
if len(splits) != 1 || splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 9500 {
t.Fatalf("self-sell split = %#v, want 7500+2000", splits)
}
}
func TestValidateRuleRequiresPercentSum(t *testing.T) {
rule, err := DefaultRule(ModeTwoPartyDistributorReceive)
if err != nil {
t.Fatal(err)
}
if err := ValidateRule(rule); err != nil {
t.Fatalf("default rule should be valid: %v", err)
}
rule.DistributorReceiveRatio = 10
if err := ValidateRule(rule); err != ErrInvalidRate {
t.Fatalf("invalid percent sum err = %v", err)
}
if err := ValidateRule(Rule{Mode: "not_a_mode", ServiceFeeUnit: ServiceFeeUnitPercent}); err != ErrUnsupportedMode {
t.Fatalf("unsupported mode err = %v", err)
}
}
@@ -39,7 +39,7 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error)
Select("DISTINCT f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = ? AND f.release_at IS NOT NULL AND f.release_at <= ?", "pending", now).
Where("ro.status = ?", "completed").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Order("f.order_id ASC").
@@ -189,7 +189,7 @@ func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
if err := tx.Select("id, status, refund_status").First(&order, orderID).Error; err != nil {
return false, err
}
if order.Status != "completed" || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
if !orderStatusAllowsRelease(order.Status) || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
return false, nil
}
var openDisputes int64
@@ -441,3 +441,37 @@ func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
}
}
func TestAutoReleaseDueSettlementsIncludesClosedOrders(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "closed_site", "关单释放站", "closed.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-CLOSED-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "closed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 0, SplitBaseAmountCent: 0,
SettlementStatus: "pending", ReleaseAt: &past,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 700,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil || released != 1 {
t.Fatalf("closed order release = %d err=%v, want 1", released, err)
}
}
@@ -47,8 +47,11 @@ var (
ErrEmptyListingIDs = errors.New("listing ids required")
ErrInvalidListingStatus = errors.New("invalid listing status")
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled")
)
var settlementReadyOrderStatuses = []string{"completed", "closed"}
const maxSyncListingIDs = 200
func liveMoneyMovementEnabled() bool {
@@ -390,6 +393,10 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
}
normalizeRulePercents(&parsed)
parsed.Mode = strings.TrimSpace(req.Mode)
if err := ValidateRule(parsed); err != nil {
return nil, err
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio,
@@ -979,14 +986,17 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
return err
}
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
var parties []model.DistributionOrderParty
if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
Where("order_id IN (?)",
tx.Model(&model.DistributionOrderFinancial{}).
Select("order_id").
Where("settlement_status = 'pending'").
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd),
tx.Table("distribution_order_financials f").
Select("f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = 'pending'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd),
).
Order("id ASC").
Find(&parties).Error; err != nil {
@@ -1028,6 +1038,9 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
var items []model.DistributionSettlementItem
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
return err
@@ -1073,6 +1086,37 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSettlementBatchNotFound
}
return err
}
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil {
return err
}
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
if err := tx.Where("batch_id = ?", row.ID).Delete(&model.DistributionSettlementItem{}).Error; err != nil {
return err
}
row.Status = "cancelled"
row.TotalAmountCent = 0
return tx.Save(&row).Error
})
if err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func uniqueSettlementOrderIDs(items []model.DistributionSettlementItem) []uint64 {
seen := make(map[uint64]struct{}, len(items))
ids := make([]uint64, 0, len(items))
@@ -1121,13 +1165,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
}
return nil, err
}
var orderExists int64
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOrderNotFound
}
return nil, err
}
if orderExists == 0 {
return nil, ErrOrderNotFound
}
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
var existing int64
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
@@ -1142,7 +1186,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
parsed := ruleFromVersion(rule)
result, err := CalculateSettlement(parsed, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
result, err := CalculateSettlement(parsed, SettlementInput{
GrossPaidAmountCent: req.GrossPaidAmountCent,
RefundAmountCent: req.RefundAmountCent,
OwnerRentAmountCent: order.OwnerRentAmountCent,
MarkupAmountCent: req.MarkupAmountCent,
GrossProfitByParty: partyInputs,
})
if err != nil {
return nil, err
}
@@ -1423,29 +1473,9 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return err
}
// 一键开通只落草稿。字段存百分数;口头 3.5/4/2.5 写入时换成 35/40/25。
parsed := Rule{
DistributorReceiveRatio: req.DistributorReceiveRatio,
PlatformSellRatio: req.PlatformSellRatio,
ServiceFeeValue: req.ServiceFeeValue,
ServiceFeeUnit: ServiceFeeUnitPercent,
}
normalizeRulePercents(&parsed)
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "draft",
DistributorReceiveRatio: parsed.DistributorReceiveRatio,
PlatformSellRatio: parsed.PlatformSellRatio,
ServiceFeeValue: parsed.ServiceFeeValue,
ServiceFeeUnit: parsed.ServiceFeeUnit,
PaymentFeeBPS: 50,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: time.Now(),
}
if err := tx.Create(&rule).Error; err != nil {
now := time.Now()
receiveRule, err := createOnboardDefaultRules(tx, site.ID, taxEnabled, taxRate, now)
if err != nil {
return err
}
if err := wallet.EnsureAccountTx(tx, 0, site.ID); err != nil {
@@ -1454,7 +1484,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
resp = OnboardSiteResponse{
Site: siteDTO(site),
Rule: ruleDTO(rule),
Rule: ruleDTO(receiveRule),
Member: DistributionCurrentMemberDTO{
AdminID: adminUser.ID,
Username: adminUser.Username,
@@ -1475,6 +1505,43 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return &resp, nil
}
// createOnboardDefaultRules 开通时四种模式都按默认分成直接生效,之后在规则页改即可。
func createOnboardDefaultRules(tx *gorm.DB, siteID uint64, taxEnabled bool, taxRate int64, effectiveFrom time.Time) (model.DistributionRuleVersion, error) {
var receive model.DistributionRuleVersion
modes := []string{ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive, ModeThreeParty, ModeSiteSelfSell}
for _, mode := range modes {
def, err := DefaultRule(mode)
if err != nil {
return model.DistributionRuleVersion{}, err
}
row := model.DistributionRuleVersion{
SiteID: &siteID,
Mode: mode,
Version: 1,
Status: "active",
PlatformReceiveRatio: def.PlatformReceiveRatio,
PlatformSellRatio: def.PlatformSellRatio,
DistributorReceiveRatio: def.DistributorReceiveRatio,
DistributorSellRatio: def.DistributorSellRatio,
DistributorARatio: def.DistributorARatio,
DistributorBRatio: def.DistributorBRatio,
ServiceFeeValue: def.ServiceFeeValue,
ServiceFeeUnit: def.ServiceFeeUnit,
PaymentFeeBPS: def.PaymentFeeBPS,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: effectiveFrom,
}
if err := tx.Create(&row).Error; err != nil {
return model.DistributionRuleVersion{}, err
}
if mode == ModeTwoPartyDistributorReceive {
receive = row
}
}
return receive, nil
}
func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
@@ -1940,6 +2007,13 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
var site model.DistributionSite
if err := r.db.WithContext(ctx).Select("id, settlement_hold_days").First(&site, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
var account model.WalletAccount
err := r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1959,7 +2033,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
Where("ro.status = 'completed'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
@@ -1977,6 +2051,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
PendingReleaseCent: pendingRelease,
TotalSettledCent: totalSettled,
TotalWithdrawnCent: totalWithdrawn,
SettlementHoldDays: site.SettlementHoldDays,
}, nil
}
@@ -166,6 +166,95 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
}
}
func TestCreateSettlementBatchSkipsRentingOrders(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "ds_renting", SiteName: "租赁中批次站", Domain: "ds-renting.example.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "DS-RENTING-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if batch.TotalAmountCent != 0 {
t.Fatalf("renting order must not enter DS batch, total=%d", batch.TotalAmountCent)
}
}
func TestCancelSettlementBatchUnblocksAutoRelease(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "ds_cancel", SiteName: "作废批次站", Domain: "ds-cancel.example.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "DS-CANCEL-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
cancelled, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID)
if err != nil || cancelled.Status != "cancelled" {
t.Fatalf("cancel = %#v err=%v", cancelled, err)
}
var leftover int64
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID).Count(&leftover).Error; err != nil || leftover != 0 {
t.Fatalf("cancelled batch must drop items, leftover=%d err=%v", leftover, err)
}
if _, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchNotCancellable {
t.Fatalf("second cancel err = %v", err)
}
again, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if again.TotalAmountCent != 1400 {
t.Fatalf("cancelled items should be eligible again, total=%d", again.TotalAmountCent)
}
}
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
@@ -448,8 +537,16 @@ func TestOnboardSiteDoesNotDumpCatalog(t *testing.T) {
if err := db.Where("user_id = 0 AND distribution_site_id = ?", onboardResp.Site.ID).First(&treasury).Error; err != nil {
t.Fatalf("onboard must create site treasury wallet, err=%v", err)
}
if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 {
t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule)
if onboardResp.Rule.Status != "active" || onboardResp.Rule.Mode != ModeTwoPartyDistributorReceive ||
onboardResp.Rule.DistributorReceiveRatio != 35 || onboardResp.Rule.PlatformSellRatio != 40 || onboardResp.Rule.ServiceFeeValue != 25 {
t.Fatalf("onboard receive rule should be default active 35/40/25, got %+v", onboardResp.Rule)
}
var extraRules []model.DistributionRuleVersion
if err := db.Where("site_id = ? AND status = ?", onboardResp.Site.ID, "active").Order("mode ASC").Find(&extraRules).Error; err != nil {
t.Fatal(err)
}
if len(extraRules) != 4 {
t.Fatalf("onboard should activate all four default modes, got %d", len(extraRules))
}
}
@@ -177,6 +177,12 @@ func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (
}
return s.repo.MarkSettlementBatch(ctx, adminID, id)
}
func (s *Service) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CancelSettlementBatch(ctx, adminID, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
@@ -77,15 +77,11 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
return nil
}
orderID := order.ID
var siteID uint64
if order.DistributionSiteID != nil {
siteID = *order.DistributionSiteID
}
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncomeCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: siteID,
DistributionSiteID: 0,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.OwnerRentIncomeCent,
@@ -98,7 +94,7 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
if settlement.DepositCompensationCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: siteID,
DistributionSiteID: 0,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.DepositCompensationCent,
+1
View File
@@ -667,6 +667,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/settlement-batches", requirePerm("distribution:settlement_manage"), distributionHandler.CreateSettlementBatch)
adminRoutes.GET("/distribution/settlement-batches/:id/items", requirePerm("distribution:settlement_view"), distributionHandler.ListSettlementItems)
adminRoutes.POST("/distribution/settlement-batches/:id/settle", requirePerm("distribution:settlement_manage"), distributionHandler.MarkSettlementBatch)
adminRoutes.POST("/distribution/settlement-batches/:id/cancel", requirePerm("distribution:settlement_manage"), distributionHandler.CancelSettlementBatch)
adminRoutes.GET("/files/object", fileHandler.Object)
adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List)
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
+8 -3
View File
@@ -115,6 +115,7 @@ export interface Site {
display_account_enabled: boolean
tax_enabled: boolean
tax_rate_bps: number
settlement_hold_days?: number
logo_url?: string
favicon_url?: string
banners_json?: string
@@ -302,9 +303,7 @@ export interface OnboardSitePayload {
display_account_enabled?: boolean
tax_enabled?: boolean
tax_rate_bps?: number
distributor_receive_ratio?: number
platform_sell_ratio?: number
service_fee_value?: number
settlement_hold_days?: number
}
export interface OnboardSiteResult {
@@ -440,6 +439,11 @@ export async function settleBatch(id: number): Promise<SettlementBatch> {
return extract(resp)
}
export async function cancelSettlementBatch(id: number): Promise<SettlementBatch> {
const resp = await api.post<ApiResponse<SettlementBatch>>(`/admin/distribution/settlement-batches/${id}/cancel`)
return extract(resp)
}
export async function listSettlementItems(batchID: number): Promise<SettlementItem[]> {
const resp = await api.get<ApiResponse<{ items: SettlementItem[] }>>(`/admin/distribution/settlement-batches/${batchID}/items`)
return extract(resp).items ?? []
@@ -509,6 +513,7 @@ export interface SiteWallet {
pending_release_cent: number
total_settled_cent: number
total_withdrawn_cent: number
settlement_hold_days?: number
}
export async function fetchSiteWallet(siteID: number): Promise<SiteWallet> {
+1 -7
View File
@@ -1030,13 +1030,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>3. 减:支付通道手续费(千分之五):</span>
<span style={{ color: '#cf1322' }}>
- ¥{((selectedOrder.payment_fee_cent || 0) / 100).toFixed(2)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>4. 可分润主站提价利润池:</span>
<span style={{ color: '#595959' }}>3. 可分润主站提价利润池:</span>
<strong>¥{((selectedOrder.split_base_amount_cent || 0) / 100).toFixed(2)}</strong>
</div>
+2 -8
View File
@@ -152,7 +152,7 @@ export default function Rules() {
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
租客通过本分销站点租赁主站共享号源池账号,获得扣除号主底价及通道费后的<strong>主站提价利润池 40% 分成</strong>。
租客通过本分销站点租赁主站共享号源池账号,获得扣除号主底价后的<strong>主站提价利润池 40% 分成</strong>。
</Typography.Text>
</Space>
</div>
@@ -200,7 +200,7 @@ export default function Rules() {
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
号主本金 100% 刚兑保障,支付通道手续费固定为 0.5%,订单履约验收后系统自动打款至商户钱包。
号主本金 100% 刚兑保障。利润按收号 / 卖出 / 服务费切分,订单完成并过冷却期后系统自动释放入账至商户钱包。
</Typography.Text>
</Space>
</div>
@@ -270,12 +270,6 @@ export default function Rules() {
width: 110,
render: (_: unknown, r: Rule) => <span>{r.service_fee_value}%</span>,
},
{
title: '通道手续费',
width: 110,
dataIndex: 'payment_fee_bps',
render: (v: number) => <span>{(v / 100).toFixed(2)}%</span>,
},
{
title: '生效时间',
dataIndex: 'effective_from',
+41 -10
View File
@@ -32,6 +32,7 @@ import {
listSettlementItems,
listSites,
settleBatch,
cancelSettlementBatch,
getMe,
type SettlementBatch,
type SettlementItem,
@@ -41,9 +42,11 @@ import { useMember } from '../MemberContext'
import type { Dayjs } from 'dayjs'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
settled: { label: '已打款结算', color: 'success' },
draft: { label: '待审核出账', color: 'warning' },
settled: { label: '已释放入账', color: 'success' },
draft: { label: '待释放入账', color: 'warning' },
pending: { label: '待释放入账', color: 'warning' },
processing: { label: '结算处理中', color: 'processing' },
cancelled: { label: '已作废', color: 'default' },
}
const PARTY_ROLE_LABELS: Record<string, string> = {
@@ -132,16 +135,34 @@ export default function Settlements() {
async function handleSettle(batch: SettlementBatch) {
Modal.confirm({
title: '确认打款结算?',
content: `即将对批次【${batch.batch_no}】执行结算操作,打款金额 ${formatMoney(batch.total_amount_cent)} 将计入商户可用余额。`,
title: '立即释放入账?',
content: `即将把批次【${batch.batch_no}】的 ${formatMoney(batch.total_amount_cent)} 转入商户可提现余额,人工批次会绕过冷却期。`,
onOk: async () => {
try {
await settleBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已成功结算入账!`)
message.success(`批次 ${batch.batch_no} 已释放入账`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '结算失败')
message.error(respMessage ?? '释放失败')
}
},
})
}
async function handleCancel(batch: SettlementBatch) {
Modal.confirm({
title: '作废未入账批次?',
content: `作废【${batch.batch_no}】后,其中订单可重新进入自动释放或新的人工批次。`,
okButtonProps: { danger: true },
onOk: async () => {
try {
await cancelSettlementBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已作废`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '作废失败')
}
},
})
@@ -170,7 +191,7 @@ export default function Settlements() {
</Typography.Title>
<Typography.Text type="secondary">
{isSuperadmin
? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算'
? '冷却期满后系统按站点按天自动释放入账;也可手工创建批次立即入账,或作废未入账草稿以免挡住自动释放'
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
</Typography.Text>
</div>
@@ -288,7 +309,7 @@ export default function Settlements() {
},
{
title: '操作',
width: 160,
width: 240,
render: (_: unknown, row: SettlementBatch) => (
<Space>
<Button
@@ -299,14 +320,24 @@ export default function Settlements() {
>
查看明细
</Button>
{isSuperadmin && row.status !== 'settled' && (
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
<Button
type="link"
size="small"
style={{ color: '#52c41a' }}
onClick={() => void handleSettle(row)}
>
确认打款结算
立即释放入账
</Button>
)}
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
<Button
type="link"
size="small"
danger
onClick={() => void handleCancel(row)}
>
作废
</Button>
)}
</Space>
+12 -31
View File
@@ -43,12 +43,10 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
form.resetFields()
form.setFieldsValue({
member_type: 'operator',
distributor_receive_ratio: 35,
platform_sell_ratio: 40,
service_fee_value: 25,
display_account_enabled: true,
tax_enabled: false,
tax_rate_bps: 100,
settlement_hold_days: 7,
})
setModalOpen(true)
}
@@ -67,9 +65,7 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
display_account_enabled: values.display_account_enabled,
tax_enabled: values.tax_enabled,
tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0,
distributor_receive_ratio: Number(values.distributor_receive_ratio),
platform_sell_ratio: Number(values.platform_sell_ratio),
service_fee_value: Number(values.service_fee_value),
settlement_hold_days: Number(values.settlement_hold_days ?? 7),
}
const res = await onboardSite(payload)
message.success('分销站点及分销商管理员开通成功!')
@@ -254,31 +250,18 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>3. 分销比例与佣金规则初始化</Typography.Title>
<Typography.Title level={5}>3. 结算冷却期与展示</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%)。之后可在规则页调整。
</Typography.Paragraph>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="分销商收号分成 (%)"
name="distributor_receive_ratio"
tooltip="卖出后利润的 35%"
label="结算冷却期 (天)"
name="settlement_hold_days"
tooltip="订单完成后进入冷却期,期满自动释放到可提现余额;0 表示完成即释放"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="平台卖号分成 (%)"
name="platform_sell_ratio"
tooltip="卖出后利润的 40%"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="平台服务费 (%)"
name="service_fee_value"
tooltip="卖出后利润的 25%"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} />
<InputNumber min={0} max={365} step={1} style={{ width: '100%' }} />
</Form.Item>
</Space>
@@ -330,10 +313,8 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
<Descriptions.Item label="所属角色">
{successResult.member.member_type}
</Descriptions.Item>
<Descriptions.Item label="初始规则版本">
收号分成: {successResult.rule.distributor_receive_ratio}% · 卖号分成:{' '}
{successResult.rule.platform_sell_ratio}% · 服务费:{' '}
{successResult.rule.service_fee_value}%
<Descriptions.Item label="初始分成规则">
四种模式均已按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%),可到规则页调整
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'right' }}>
+4 -1
View File
@@ -167,6 +167,9 @@ export default function Wallet() {
</Typography.Title>
<Typography.Text type="secondary">
查看分销结算收益到账情况,申请提取收益并实时追踪财务打款流水。
{wallet?.settlement_hold_days != null
? ` 当前站点冷却期 ${wallet.settlement_hold_days} 天。`
: ''}
</Typography.Text>
</div>
<Space>
@@ -208,7 +211,7 @@ export default function Wallet() {
<Col flex="1">
<Card bordered={false} style={{ background: '#fafafa' }}>
<Statistic
title="待释放收益 (订单完成冷却中)"
title="待释放收益 (冷却期内)"
value={((wallet?.pending_release_cent ?? 0) / 100).toFixed(2)}
prefix={<HourglassOutlined style={{ color: '#1677ff' }} />}
suffix="元"
@@ -94,9 +94,6 @@ export interface OnboardDistributionSitePayload {
tax_enabled?: boolean
tax_rate_bps?: number
settlement_hold_days?: number
distributor_receive_ratio?: number
platform_sell_ratio?: number
service_fee_value?: number
}
export interface OnboardDistributionSiteResult {
@@ -254,6 +251,13 @@ export async function settleDistributionBatch(id: number) {
return data.data
}
export async function cancelDistributionBatch(id: number) {
const { data } = await apiClient.post<ApiResponse<DistributionSettlementBatch>>(
`/admin/distribution/settlement-batches/${id}/cancel`
)
return data.data
}
export async function fetchDistributionSettlementItems(batchId: number) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSettlementItem[] }>>(
`/admin/distribution/settlement-batches/${batchId}/items`
@@ -16,6 +16,11 @@ export const DISTRIBUTION_MODES = [
label: '三方分账',
hint: '分站卖另一分站的货',
},
{
value: 'site_self_sell',
label: '本站自卖',
hint: '分站卖本站货,卖出站拿 75%',
},
] as const
const MODE_LABELS: Record<string, string> = Object.fromEntries(
@@ -46,10 +51,6 @@ export function formatRatioPercent(value?: number) {
return `${n.toFixed(n % 1 === 0 ? 0 : 1)}%`
}
export function formatPaymentFeeBps(bps?: number) {
return `${Number(bps || 0) / 100}%`
}
export function ruleSplitParts(rule: DistributionRule) {
switch (rule.mode) {
case 'two_party_distributor_receive':
@@ -67,6 +68,8 @@ export function ruleSplitParts(rule: DistributionRule) {
{ label: '收号站', value: formatRatioPercent(rule.distributor_a_ratio) },
{ label: '卖出站', value: formatRatioPercent(rule.distributor_b_ratio) },
]
case 'site_self_sell':
return [{ label: '本站卖出', value: formatRatioPercent(rule.distributor_sell_ratio) }]
default:
return []
}
@@ -90,7 +90,7 @@ onMounted(async () => {
<div>
<h2 class="page-title">分销订单</h2>
<p class="page-subtitle">
查看分销订单的利润基数、主站承担的通道费,以及冷却期释放状态。不要把订单履约结算和分销入账混为一谈。
查看分销订单的利润基数与冷却期释放状态。不要把订单履约结算和分销入账混为一谈。
</p>
</div>
</div>
@@ -171,12 +171,6 @@ onMounted(async () => {
{{ formatCent(row.split_base_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="通道费" min-width="110" align="right">
<template #default="{ row }">
<div>{{ formatCent(row.payment_fee_cent) }}</div>
<small class="muted">主站承担</small>
</template>
</el-table-column>
<el-table-column label="服务费" min-width="90" align="right">
<template #default="{ row }">
{{ formatCent(row.service_fee_cent) }}
@@ -255,9 +249,4 @@ onMounted(async () => {
justify-content: flex-end;
margin-top: 16px;
}
.muted {
color: #9ca3af;
font-size: 12px;
}
</style>
@@ -14,7 +14,6 @@ import {
import {
DISTRIBUTION_MODES,
distributionModeLabel,
formatPaymentFeeBps,
formatServiceFee,
ruleSplitParts,
} from '@/features/admin/utils/distributionLabels'
@@ -94,7 +93,7 @@ function resetCreateForm() {
createForm.site_id = selectedSiteId.value
createForm.mode = selectedMode.value || 'two_party_distributor_receive'
createForm.receive_ratio = 35
createForm.sell_ratio = 40
createForm.sell_ratio = createForm.mode === 'site_self_sell' ? 75 : 40
createForm.service_fee_value = 25
createForm.payment_fee_bps = 50
createForm.tax_enabled = false
@@ -102,6 +101,12 @@ function resetCreateForm() {
createForm.activate = true
}
function handleModeChange(mode: string) {
createForm.sell_ratio = mode === 'site_self_sell' ? 75 : 40
createForm.receive_ratio = 35
createForm.service_fee_value = 25
}
function handleOpenCreate() {
resetCreateForm()
createVisible.value = true
@@ -123,6 +128,8 @@ function buildCreatePayload(): CreateRulePayload {
} else if (createForm.mode === 'two_party_platform_receive') {
payload.platform_receive_ratio = Number(createForm.receive_ratio)
payload.distributor_sell_ratio = Number(createForm.sell_ratio)
} else if (createForm.mode === 'site_self_sell') {
payload.distributor_sell_ratio = Number(createForm.sell_ratio)
} else {
payload.distributor_a_ratio = Number(createForm.receive_ratio)
payload.distributor_b_ratio = Number(createForm.sell_ratio)
@@ -132,11 +139,17 @@ function buildCreatePayload(): CreateRulePayload {
async function handleCreate() {
const total =
Number(createForm.receive_ratio) +
Number(createForm.sell_ratio) +
Number(createForm.service_fee_value)
createForm.mode === 'site_self_sell'
? Number(createForm.sell_ratio) + Number(createForm.service_fee_value)
: Number(createForm.receive_ratio) +
Number(createForm.sell_ratio) +
Number(createForm.service_fee_value)
if (Math.abs(total - 100) > 0.05) {
ElMessage.warning(`收号 + 卖出 + 服务费应为 100%,当前为 ${total}%`)
ElMessage.warning(
createForm.mode === 'site_self_sell'
? `本站分成 + 服务费应为 100%,当前为 ${total}%`
: `收号 + 卖出 + 服务费应为 100%,当前为 ${total}%`
)
return
}
creating.value = true
@@ -167,7 +180,7 @@ onMounted(async () => {
<div>
<h2 class="page-title">分销规则配置</h2>
<p class="page-subtitle">
按模式配置利润分成。利润 = 净付款 − 号主 − 加价;通道费由主站承担,只记账不进分成。
按模式配置利润分成。利润 = 净付款 − 号主 − 加价,再按收号 / 卖出 / 服务费切分。
</p>
</div>
<div class="header-filters">
@@ -208,7 +221,7 @@ onMounted(async () => {
:closable="false"
show-icon
class="rules-alert"
title="默认 35% 收号 / 40% 卖出 / 25% 服务费。站点专属规则优先于全局默认;通道费默认 0.5%,从主站服务费与角色分成中承担。"
title="默认 35% 收号 / 40% 卖出 / 25% 服务费。站点专属规则优先于全局默认。"
/>
<el-card shadow="never">
@@ -240,14 +253,6 @@ onMounted(async () => {
</div>
</template>
</el-table-column>
<el-table-column label="通道费" min-width="140">
<template #default="{ row }">
<div class="fee-cell">
<span>{{ formatPaymentFeeBps(row.payment_fee_bps) }}</span>
<small>主站承担</small>
</div>
</template>
</el-table-column>
<el-table-column label="税费" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
@@ -287,7 +292,7 @@ onMounted(async () => {
:closable="false"
type="info"
show-icon
:title="`${createModeMeta?.label}:${createModeMeta?.hint}。分成三项合计应为 100%。`"
:title="`${createModeMeta?.label}:${createModeMeta?.hint}。${createForm.mode === 'site_self_sell' ? '本站分成与服务费合计应为 100%。' : '分成三项合计应为 100%。'}`"
style="margin-bottom: 16px"
/>
<el-form :model="createForm" label-width="120px">
@@ -307,7 +312,7 @@ onMounted(async () => {
</el-select>
</el-form-item>
<el-form-item label="分销模式">
<el-select v-model="createForm.mode" style="width: 100%">
<el-select v-model="createForm.mode" style="width: 100%" @change="handleModeChange">
<el-option
v-for="item in DISTRIBUTION_MODES"
:key="item.value"
@@ -316,7 +321,10 @@ onMounted(async () => {
/>
</el-select>
</el-form-item>
<el-form-item :label="createForm.mode === 'three_party' ? '收号站分成' : '收号分成'">
<el-form-item
v-if="createForm.mode !== 'site_self_sell'"
:label="createForm.mode === 'three_party' ? '收号站分成' : '收号分成'"
>
<el-input-number
v-model="createForm.receive_ratio"
:min="0"
@@ -325,7 +333,15 @@ onMounted(async () => {
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="createForm.mode === 'three_party' ? '卖出站分成' : '卖出分成'">
<el-form-item
:label="
createForm.mode === 'site_self_sell'
? '本站分成'
: createForm.mode === 'three_party'
? '卖出站分成'
: '卖出分成'
"
>
<el-input-number
v-model="createForm.sell_ratio"
:min="0"
@@ -343,18 +359,6 @@ onMounted(async () => {
style="width: 100%"
/>
</el-form-item>
<el-form-item label="通道费率 BPS">
<el-input-number
v-model="createForm.payment_fee_bps"
:min="0"
:max="10000"
:step="1"
style="width: 100%"
/>
<div class="form-hint">
当前 {{ formatPaymentFeeBps(createForm.payment_fee_bps) }},由主站承担,不从利润里扣。
</div>
</el-form-item>
<el-form-item label="征收税费">
<el-switch v-model="createForm.tax_enabled" />
</el-form-item>
@@ -447,22 +451,4 @@ onMounted(async () => {
background: #eff6ff;
color: #1d4ed8;
}
.fee-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.fee-cell small {
color: #9ca3af;
font-size: 12px;
}
.form-hint {
margin-top: 4px;
color: #6b7280;
font-size: 12px;
line-height: 1.4;
}
</style>
@@ -7,6 +7,7 @@ import {
fetchDistributionSettlementItems,
createDistributionSettlementBatch,
settleDistributionBatch,
cancelDistributionBatch,
fetchDistributionSites,
type DistributionSettlementBatch,
type DistributionSettlementItem,
@@ -104,6 +105,21 @@ async function handleSettle(row: DistributionSettlementBatch) {
}
}
async function handleCancel(row: DistributionSettlementBatch) {
try {
await ElMessageBox.confirm(
`作废批次 ${row.batch_no} 后,其中订单可重新进入自动释放或新的人工批次。`,
'作废未入账批次',
{ type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' }
)
await cancelDistributionBatch(row.id)
ElMessage.success('批次已作废')
await loadBatches()
} catch {
// cancelled or error handled
}
}
async function handleViewItems(row: DistributionSettlementBatch) {
currentBatch.value = row
itemsDialogVisible.value = true
@@ -119,6 +135,7 @@ async function handleViewItems(row: DistributionSettlementBatch) {
function batchStatus(row: DistributionSettlementBatch) {
if (row.status === 'settled') return { label: '已释放入账', type: 'success' as const }
if (row.status === 'cancelled') return { label: '已作废', type: 'info' as const }
if (row.status === 'pending' || row.status === 'draft')
return { label: '待入账', type: 'warning' as const }
return { label: row.status, type: 'info' as const }
@@ -193,17 +210,25 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewItems(row)">查看明细</el-button>
<el-button
v-if="row.status !== 'settled'"
v-if="row.status !== 'settled' && row.status !== 'cancelled'"
link
type="success"
@click="handleSettle(row)"
>
立即释放入账
</el-button>
<el-button
v-if="row.status !== 'settled' && row.status !== 'cancelled'"
link
type="danger"
@click="handleCancel(row)"
>
作废
</el-button>
</template>
</el-table-column>
</el-table>
@@ -63,9 +63,6 @@ const onboardForm = reactive({
admin_password: '',
admin_nickname: '',
member_type: 'operator',
distributor_receive_ratio: 35,
platform_sell_ratio: 40,
service_fee_value: 25,
settlement_hold_days: 7,
display_account_enabled: true,
tax_enabled: false,
@@ -105,9 +102,6 @@ function handleOpenOnboard() {
onboardForm.admin_password = ''
onboardForm.admin_nickname = ''
onboardForm.member_type = 'operator'
onboardForm.distributor_receive_ratio = 35
onboardForm.platform_sell_ratio = 40
onboardForm.service_fee_value = 25
onboardForm.settlement_hold_days = 7
onboardForm.display_account_enabled = true
onboardForm.tax_enabled = false
@@ -129,9 +123,6 @@ async function handleOnboardSubmit() {
admin_password: onboardForm.admin_password,
admin_nickname: onboardForm.admin_nickname.trim() || undefined,
member_type: onboardForm.member_type,
distributor_receive_ratio: Number(onboardForm.distributor_receive_ratio),
platform_sell_ratio: Number(onboardForm.platform_sell_ratio),
service_fee_value: Number(onboardForm.service_fee_value),
settlement_hold_days: Number(onboardForm.settlement_hold_days),
display_account_enabled: onboardForm.display_account_enabled,
tax_enabled: onboardForm.tax_enabled,
@@ -189,8 +180,7 @@ onMounted(() => {
<div>
<h2 class="page-title">分销站点管理</h2>
<p class="page-subtitle">
开通分销站、配置结算冷却期。利润按 35% 收号 / 40% 卖出 / 25%
服务费切分,通道费由主站承担。
开通分销站、配置结算冷却期。利润按 35% 收号 / 40% 卖出 / 25% 服务费切分。
</p>
</div>
<div class="header-actions">
@@ -325,50 +315,15 @@ onMounted(() => {
</el-col>
</el-row>
<div class="form-section-title">3. 分站收号规则与冷却期</div>
<div class="form-section-title">3. 结算冷却期与展示</div>
<el-alert
type="info"
:closable="false"
show-icon
title="此处只生成「两方 · 分站收号」草稿。利润 = 净付款 − 号主 − 加价;通道费 0.5% 由主站承担。另外两种模式请到规则页按全局默认启用或单独配置。"
title="开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖为 75% + 25%)。之后可在「分销规则」页单独调整。"
style="margin-bottom: 12px"
/>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分站收号分成 (%)">
<el-input-number
v-model="onboardForm.distributor_receive_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主站卖出分成 (%)">
<el-input-number
v-model="onboardForm.platform_sell_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="平台服务费 (%)">
<el-input-number
v-model="onboardForm.service_fee_value"
:min="0"
:max="100"
:step="1"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结算冷却期 (天)">
<el-input-number
@@ -380,19 +335,15 @@ onMounted(() => {
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="展示主站账号">
<el-switch v-model="onboardForm.display_account_enabled" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否征税">
<el-switch v-model="onboardForm.tax_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="是否征税">
<el-switch v-model="onboardForm.tax_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onboardDialogVisible = false">取消</el-button>
@@ -425,10 +376,9 @@ onMounted(() => {
<el-descriptions-item label="分销后台地址">
<code>http://{{ windowHost }}:5190/login</code>
</el-descriptions-item>
<el-descriptions-item label="初始规则">
分站收号 {{ onboardResult.rule.distributor_receive_ratio }}% · 主站卖出
{{ onboardResult.rule.platform_sell_ratio }}% · 服务费
{{ onboardResult.rule.service_fee_value }}%(草稿,通道费主站承担)
<el-descriptions-item label="初始分成规则">
四种模式均已按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% +
25%),可到规则页调整。
</el-descriptions-item>
<el-descriptions-item label="结算冷却期">
{{ onboardResult.site.settlement_hold_days }} 天