diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go index 3218206..461a2ab 100644 --- a/backend/internal/e2e/distribution_flow_test.go +++ b/backend/internal/e2e/distribution_flow_test.go @@ -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) // 创建结算批次 diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index fb84c90..23d7d21 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -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 { diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go index 6996657..10944e9 100644 --- a/backend/internal/modules/distribution/auth_onboard_test.go +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -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 diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index b6c25b2..24ec08e 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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 { diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 1d841ae..1dab02a 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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): diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index da7ce15..140191b 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -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 diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go index c1aafc0..5006544 100644 --- a/backend/internal/modules/distribution/lifecycle_test.go +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index 71d990c..f17787d 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -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 } diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index a641bc2..4ba8588 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/release.go b/backend/internal/modules/distribution/release.go index 3aeffe5..655df1a 100644 --- a/backend/internal/modules/distribution/release.go +++ b/backend/internal/modules/distribution/release.go @@ -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 diff --git a/backend/internal/modules/distribution/release_test.go b/backend/internal/modules/distribution/release_test.go index 88b3628..8f4ad11 100644 --- a/backend/internal/modules/distribution/release_test.go +++ b/backend/internal/modules/distribution/release_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 5dd72ff..f318b77 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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 } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index c27f340..c25febb 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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)) } } diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index a64fe13..3b6ec80 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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 { diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 624bd8f..94a47b0 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -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, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 58641ec..f1d98a7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 767cf9e..87b1ed2 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -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 { return extract(resp) } +export async function cancelSettlementBatch(id: number): Promise { + const resp = await api.post>(`/admin/distribution/settlement-batches/${id}/cancel`) + return extract(resp) +} + export async function listSettlementItems(batchID: number): Promise { const resp = await api.get>(`/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 { diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx index 5d72ae8..e03fd4d 100644 --- a/distribution-admin/src/pages/Orders.tsx +++ b/distribution-admin/src/pages/Orders.tsx @@ -1030,13 +1030,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
- 3. 减:支付通道手续费(千分之五): - - - ¥{((selectedOrder.payment_fee_cent || 0) / 100).toFixed(2)} - -
-
- 4. 可分润主站提价利润池: + 3. 可分润主站提价利润池: ¥{((selectedOrder.split_base_amount_cent || 0) / 100).toFixed(2)}
diff --git a/distribution-admin/src/pages/Rules.tsx b/distribution-admin/src/pages/Rules.tsx index d753de1..3e618c1 100644 --- a/distribution-admin/src/pages/Rules.tsx +++ b/distribution-admin/src/pages/Rules.tsx @@ -152,7 +152,7 @@ export default function Rules() { - 租客通过本分销站点租赁主站共享号源池账号,获得扣除号主底价及通道费后的主站提价利润池 40% 分成。 + 租客通过本分销站点租赁主站共享号源池账号,获得扣除号主底价后的主站提价利润池 40% 分成。 @@ -200,7 +200,7 @@ export default function Rules() { - 号主本金 100% 刚兑保障,支付通道手续费固定为 0.5%,订单履约验收后系统自动打款至商户钱包。 + 号主本金 100% 刚兑保障。利润按收号 / 卖出 / 服务费切分,订单完成并过冷却期后系统自动释放入账至商户钱包。 @@ -270,12 +270,6 @@ export default function Rules() { width: 110, render: (_: unknown, r: Rule) => {r.service_fee_value}%, }, - { - title: '通道手续费', - width: 110, - dataIndex: 'payment_fee_bps', - render: (v: number) => {(v / 100).toFixed(2)}%, - }, { title: '生效时间', dataIndex: 'effective_from', diff --git a/distribution-admin/src/pages/Settlements.tsx b/distribution-admin/src/pages/Settlements.tsx index 42d037e..e59b742 100644 --- a/distribution-admin/src/pages/Settlements.tsx +++ b/distribution-admin/src/pages/Settlements.tsx @@ -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 = { - 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 = { @@ -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() { {isSuperadmin - ? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算' + ? '冷却期满后系统按站点按天自动释放入账;也可手工创建批次立即入账,或作废未入账草稿以免挡住自动释放' : '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'} @@ -288,7 +309,7 @@ export default function Settlements() { }, { title: '操作', - width: 160, + width: 240, render: (_: unknown, row: SettlementBatch) => ( - {isSuperadmin && row.status !== 'settled' && ( + {isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && ( + )} + {isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && ( + )} diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index 2615ad3..029649b 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -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 - 3. 分销比例与佣金规则初始化 + 3. 结算冷却期与展示 + + 开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%)。之后可在规则页调整。 + - - - - - - - + @@ -330,10 +313,8 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean {successResult.member.member_type} - - 收号分成: {successResult.rule.distributor_receive_ratio}% · 卖号分成:{' '} - {successResult.rule.platform_sell_ratio}% · 服务费:{' '} - {successResult.rule.service_fee_value}% + + 四种模式均已按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%),可到规则页调整
diff --git a/distribution-admin/src/pages/Wallet.tsx b/distribution-admin/src/pages/Wallet.tsx index 0e7c63a..29c58ad 100644 --- a/distribution-admin/src/pages/Wallet.tsx +++ b/distribution-admin/src/pages/Wallet.tsx @@ -167,6 +167,9 @@ export default function Wallet() { 查看分销结算收益到账情况,申请提取收益并实时追踪财务打款流水。 + {wallet?.settlement_hold_days != null + ? ` 当前站点冷却期 ${wallet.settlement_hold_days} 天。` + : ''}
@@ -208,7 +211,7 @@ export default function Wallet() { } suffix="元" diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index f1bee2f..4f91293 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -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>( + `/admin/distribution/settlement-batches/${id}/cancel` + ) + return data.data +} + export async function fetchDistributionSettlementItems(batchId: number) { const { data } = await apiClient.get>( `/admin/distribution/settlement-batches/${batchId}/items` diff --git a/frontend/src/features/admin/utils/distributionLabels.ts b/frontend/src/features/admin/utils/distributionLabels.ts index 598c45f..6da9386 100644 --- a/frontend/src/features/admin/utils/distributionLabels.ts +++ b/frontend/src/features/admin/utils/distributionLabels.ts @@ -16,6 +16,11 @@ export const DISTRIBUTION_MODES = [ label: '三方分账', hint: '分站卖另一分站的货', }, + { + value: 'site_self_sell', + label: '本站自卖', + hint: '分站卖本站货,卖出站拿 75%', + }, ] as const const MODE_LABELS: Record = 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 [] } diff --git a/frontend/src/features/admin/views/AdminDistributionOrdersView.vue b/frontend/src/features/admin/views/AdminDistributionOrdersView.vue index daaff0d..7461113 100644 --- a/frontend/src/features/admin/views/AdminDistributionOrdersView.vue +++ b/frontend/src/features/admin/views/AdminDistributionOrdersView.vue @@ -90,7 +90,7 @@ onMounted(async () => {

分销订单

- 查看分销订单的利润基数、主站承担的通道费,以及冷却期释放状态。不要把订单履约结算和分销入账混为一谈。 + 查看分销订单的利润基数与冷却期释放状态。不要把订单履约结算和分销入账混为一谈。

@@ -171,12 +171,6 @@ onMounted(async () => { {{ formatCent(row.split_base_amount_cent) }} - - - - - - - + diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 67b0c35..40ca9fc 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -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(() => {

分销站点管理

- 开通分销站、配置结算冷却期。利润按 35% 收号 / 40% 卖出 / 25% - 服务费切分,通道费由主站承担。 + 开通分销站、配置结算冷却期。利润按 35% 收号 / 40% 卖出 / 25% 服务费切分。

@@ -325,50 +315,15 @@ onMounted(() => { -
3. 分站收号规则与冷却期
+
3. 结算冷却期与展示
- - - - - - - - - - - - - - - - - { /> - - - - - - - + + +