feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则
退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 }} 天
|
||||
|
||||
Reference in New Issue
Block a user