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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 18:13:46 +08:00
co-authored by Cursor
parent e621722218
commit 768902f2f7
28 changed files with 627 additions and 263 deletions
@@ -131,8 +131,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
if err := tx.Save(&order).Error; err != nil {
return err
}
if order.Status == "completed" {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil {
return err
}
}
@@ -373,6 +373,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
checkout.ShortfallCent = settlement.ShortfallCent
checkout.OvershootAmountCent = settlement.OvershootAmountCent
checkout.Status = "settled"
}
func isPlatformManagedOrder(order model.RentalOrder) bool {
@@ -19,16 +19,13 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
// 1. Onboard new site
req := OnboardSiteRequest{
SiteCode: "gz_dist",
SiteName: "广州分销站",
Domain: "gz.distribution.com",
AdminUsername: "gz_operator",
AdminPassword: "Password123!",
AdminNickname: "广州运营小李",
MemberType: "operator",
DistributorReceiveRatio: 3.8,
PlatformSellRatio: 4.2,
ServiceFeeValue: 2.0,
SiteCode: "gz_dist",
SiteName: "广州分销站",
Domain: "gz.distribution.com",
AdminUsername: "gz_operator",
AdminPassword: "Password123!",
AdminNickname: "广州运营小李",
MemberType: "operator",
}
resp, err := svc.OnboardSite(ctx, req)
@@ -48,11 +45,11 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID {
t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID)
}
if resp.Rule.DistributorReceiveRatio != 38 {
t.Fatalf("expected rule percent 38, got %f", resp.Rule.DistributorReceiveRatio)
if resp.Rule.DistributorReceiveRatio != 35 || resp.Rule.PlatformSellRatio != 40 || resp.Rule.ServiceFeeValue != 25 {
t.Fatalf("expected default 35/40/25, got %+v", resp.Rule)
}
if resp.Rule.Status != "draft" {
t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status)
if resp.Rule.Status != "active" {
t.Fatalf("onboard rules should be active by default, got %s", resp.Rule.Status)
}
// 2. Duplicate site code or domain check
+2 -1
View File
@@ -333,7 +333,7 @@ type OnboardSiteRequest struct {
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
AdminNickname string `json:"admin_nickname"`
MemberType string `json:"member_type" binding:"omitempty,oneof=operator finance customer_service"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"` // 兼容旧入参,开通时忽略,四种模式一律走默认分成
PlatformSellRatio float64 `json:"platform_sell_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
}
@@ -417,6 +417,7 @@ type SiteWalletDTO struct {
PendingReleaseCent int64 `json:"pending_release_cent"`
TotalSettledCent int64 `json:"total_settled_cent"`
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
SettlementHoldDays int `json:"settlement_hold_days"`
}
type ApplySiteWithdrawalRequest struct {
@@ -440,6 +440,20 @@ func (h *Handler) MarkSettlementBatch(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) CancelSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.CancelSettlementBatch(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
func parseQueryUint(c *gin.Context, key string) int64 {
raw := strings.TrimSpace(c.Query(key))
@@ -761,6 +775,10 @@ func writeError(c *gin.Context, err error) {
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrInvalidSettlementHold):
response.BadRequest(c, "结算冷却天数须在 0 到 365 之间")
case errors.Is(err, ErrBatchNotCancellable):
response.BadRequest(c, "该结算批次无法作废")
case errors.Is(err, ErrTooManySyncListings):
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncListingNotFound):
@@ -206,8 +206,8 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, markup, splits), SettlementStatus: "pending"}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits), SettlementStatus: "pending"}
// 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。
var existing model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil {
@@ -336,7 +336,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
// 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。
settlementStatus := "pending"
if financial.ReleasedAt != nil {
@@ -352,7 +352,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
"payment_fee_cent": result.PaymentFeeCent,
"service_fee_cent": result.ServiceFeeCent,
"split_base_amount_cent": result.SplitBaseAmountCent,
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits),
"settlement_status": settlementStatus,
"release_at": releaseAt,
}).Error; err != nil {
@@ -433,7 +433,7 @@ func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.Dist
if financial.ReleasedAt != nil {
return financial.ReleaseAt, nil
}
if order.Status != "completed" {
if !orderStatusAllowsRelease(order.Status) {
return financial.ReleaseAt, nil
}
completedAt := order.SettledAt
@@ -464,7 +464,7 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
}
return err
}
if order.Status != "completed" {
if !orderStatusAllowsRelease(order.Status) {
continue
}
releaseAt, err := computeReleaseAt(tx, order, financial)
@@ -479,6 +479,10 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
return nil
}
func orderStatusAllowsRelease(status string) bool {
return status == "completed" || status == "closed"
}
// partySettled 判断参与方份额是否已经通过结算批次入账。
func partySettled(tx *gorm.DB, partyID uint64) (bool, error) {
var count int64
@@ -472,3 +472,59 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
}
}
func TestApplyRefundTxClampsSellerMarkup(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{SiteCode: "markup_refund", SiteName: "加价回退站", Domain: "markup-refund.dist.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-MARKUP-REFUND", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed",
RentAmountCent: 10000, OwnerRentAmountCent: 6000, ShelfMarkupCent: 3000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
var seller model.DistributionOrderParty
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 3400 {
t.Fatalf("paid seller = %d, want 400+3000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 2000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 2000 {
t.Fatalf("partial refund seller markup = %d, want 2000", seller.IncomeAmountCent)
}
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
t.Fatal(err)
}
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
t.Fatal(err)
}
if seller.IncomeAmountCent != 0 {
t.Fatalf("full refund must return seller markup, got %d", seller.IncomeAmountCent)
}
}
@@ -97,13 +97,46 @@ func DefaultRule(mode string) (Rule, error) {
base.DistributorARatio = DefaultReceivePercent
base.DistributorBRatio = DefaultSellPercent
case ModeSiteSelfSell:
return Rule{}, ErrUnsupportedMode
base.DistributorSellRatio = DefaultReceivePercent + DefaultSellPercent
default:
return Rule{}, ErrUnsupportedMode
}
return base, nil
}
// ValidateRule 校验模式,并在百分比服务费下要求收号+卖出+服务费合计为 100。
func ValidateRule(rule Rule) error {
normalizeRulePercents(&rule)
if _, err := DefaultRule(rule.Mode); err != nil {
return ErrUnsupportedMode
}
unit := rule.ServiceFeeUnit
if unit == "" || unit == ServiceFeeUnitRatio {
unit = ServiceFeeUnitPercent
}
if unit != ServiceFeeUnitPercent && unit != ServiceFeeUnitFixedAmount {
return ErrInvalidServiceUnit
}
if unit != ServiceFeeUnitPercent {
return nil
}
var sum float64
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
sum = rule.DistributorReceiveRatio + rule.PlatformSellRatio + rule.ServiceFeeValue
case ModeTwoPartyPlatformReceive:
sum = rule.PlatformReceiveRatio + rule.DistributorSellRatio + rule.ServiceFeeValue
case ModeThreeParty:
sum = rule.DistributorARatio + rule.DistributorBRatio + rule.ServiceFeeValue
case ModeSiteSelfSell:
sum = rule.DistributorSellRatio + rule.ServiceFeeValue
}
if math.Abs(sum-100) > 0.15 {
return ErrInvalidRate
}
return nil
}
// NormalizeSplitPercent 把口头 3.5/4/2.5 换成 35/40/25;已经是百分数的原样保留。
func NormalizeSplitPercent(value float64) float64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
@@ -147,7 +180,8 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent
effectiveMarkup := EffectiveMarkupCent(netPaid, input.OwnerRentAmountCent, input.MarkupAmountCent)
profit := netPaid - input.OwnerRentAmountCent - effectiveMarkup
if profit < 0 {
profit = 0
}
@@ -164,7 +198,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
NetPaidAmountCent: netPaid,
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: input.MarkupAmountCent,
MarkupAmountCent: effectiveMarkup,
SplitBaseAmountCent: profit,
}
if rule.TaxEnabled {
@@ -206,6 +240,25 @@ func maxInt64(a, b int64) int64 {
return b
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
// EffectiveMarkupCent 按剩余净付款压缩加价:全额退租金后卖方不得再留加价。
func EffectiveMarkupCent(netPaid, ownerRent, markup int64) int64 {
if markup <= 0 {
return 0
}
remain := netPaid - ownerRent
if remain <= 0 {
return 0
}
return minInt64(markup, remain)
}
// PartySplit 是一次分成拆分中单个参与方的金额。
type PartySplit struct {
PartyRole string
@@ -236,6 +289,11 @@ func SplitSettlement(rule Rule, profitCent int64, markupCent int64) []PartySplit
role string
ratio float64
}{{"distributor_a", rule.DistributorARatio}, {"distributor_b", rule.DistributorBRatio}}
case ModeSiteSelfSell:
roles = []struct {
role string
ratio float64
}{{"distributor_sell", rule.DistributorSellRatio}}
default:
return nil
}
@@ -10,6 +10,7 @@ func TestDefaultRules(t *testing.T) {
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 35 && r.PlatformSellRatio == 40 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 35 && r.DistributorSellRatio == 40 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 35 && r.DistributorBRatio == 40 }},
{ModeSiteSelfSell, func(r Rule) bool { return r.DistributorSellRatio == 75 && r.ServiceFeeValue == 25 }},
}
for _, item := range cases {
rule, err := DefaultRule(item.mode)
@@ -17,8 +18,8 @@ func TestDefaultRules(t *testing.T) {
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
}
}
if _, err := DefaultRule(ModeSiteSelfSell); err != ErrUnsupportedMode {
t.Fatalf("site self-sell must not have a default split, err=%v", err)
if _, err := DefaultRule("unknown_mode"); err != ErrUnsupportedMode {
t.Fatalf("unknown mode must be unsupported, err=%v", err)
}
}
@@ -116,3 +117,54 @@ func TestSplitSettlementThreeParty(t *testing.T) {
t.Fatalf("splits = %#v", splits)
}
}
func TestCalculateSettlementClampsMarkupToRemainingNetPaid(t *testing.T) {
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000, RefundAmountCent: 10000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
})
if err != nil {
t.Fatal(err)
}
if got.MarkupAmountCent != 0 || got.SplitBaseAmountCent != 0 || got.NetPaidAmountCent != 0 {
t.Fatalf("full refund must zero markup and profit: %#v", got)
}
partial, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000, RefundAmountCent: 2000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
})
if err != nil {
t.Fatal(err)
}
if partial.NetPaidAmountCent != 8000 || partial.MarkupAmountCent != 2000 || partial.SplitBaseAmountCent != 0 {
t.Fatalf("partial refund should clamp markup to remainder: %#v", partial)
}
}
func TestSplitSettlementSiteSelfSell(t *testing.T) {
rule, err := DefaultRule(ModeSiteSelfSell)
if err != nil {
t.Fatal(err)
}
splits := SplitSettlement(rule, 10000, 2000)
if len(splits) != 1 || splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 9500 {
t.Fatalf("self-sell split = %#v, want 7500+2000", splits)
}
}
func TestValidateRuleRequiresPercentSum(t *testing.T) {
rule, err := DefaultRule(ModeTwoPartyDistributorReceive)
if err != nil {
t.Fatal(err)
}
if err := ValidateRule(rule); err != nil {
t.Fatalf("default rule should be valid: %v", err)
}
rule.DistributorReceiveRatio = 10
if err := ValidateRule(rule); err != ErrInvalidRate {
t.Fatalf("invalid percent sum err = %v", err)
}
if err := ValidateRule(Rule{Mode: "not_a_mode", ServiceFeeUnit: ServiceFeeUnitPercent}); err != ErrUnsupportedMode {
t.Fatalf("unsupported mode err = %v", err)
}
}
@@ -39,7 +39,7 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error)
Select("DISTINCT f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = ? AND f.release_at IS NOT NULL AND f.release_at <= ?", "pending", now).
Where("ro.status = ?", "completed").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Order("f.order_id ASC").
@@ -189,7 +189,7 @@ func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
if err := tx.Select("id, status, refund_status").First(&order, orderID).Error; err != nil {
return false, err
}
if order.Status != "completed" || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
if !orderStatusAllowsRelease(order.Status) || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
return false, nil
}
var openDisputes int64
@@ -441,3 +441,37 @@ func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
}
}
func TestAutoReleaseDueSettlementsIncludesClosedOrders(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "closed_site", "关单释放站", "closed.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-CLOSED-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "closed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 0, SplitBaseAmountCent: 0,
SettlementStatus: "pending", ReleaseAt: &past,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 700,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil || released != 1 {
t.Fatalf("closed order release = %d err=%v, want 1", released, err)
}
}
@@ -47,8 +47,11 @@ var (
ErrEmptyListingIDs = errors.New("listing ids required")
ErrInvalidListingStatus = errors.New("invalid listing status")
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled")
)
var settlementReadyOrderStatuses = []string{"completed", "closed"}
const maxSyncListingIDs = 200
func liveMoneyMovementEnabled() bool {
@@ -390,6 +393,10 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
}
normalizeRulePercents(&parsed)
parsed.Mode = strings.TrimSpace(req.Mode)
if err := ValidateRule(parsed); err != nil {
return nil, err
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio,
@@ -979,14 +986,17 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
return err
}
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
var parties []model.DistributionOrderParty
if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
Where("order_id IN (?)",
tx.Model(&model.DistributionOrderFinancial{}).
Select("order_id").
Where("settlement_status = 'pending'").
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd),
tx.Table("distribution_order_financials f").
Select("f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = 'pending'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd),
).
Order("id ASC").
Find(&parties).Error; err != nil {
@@ -1028,6 +1038,9 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
var items []model.DistributionSettlementItem
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
return err
@@ -1073,6 +1086,37 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSettlementBatchNotFound
}
return err
}
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil {
return err
}
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
if err := tx.Where("batch_id = ?", row.ID).Delete(&model.DistributionSettlementItem{}).Error; err != nil {
return err
}
row.Status = "cancelled"
row.TotalAmountCent = 0
return tx.Save(&row).Error
})
if err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func uniqueSettlementOrderIDs(items []model.DistributionSettlementItem) []uint64 {
seen := make(map[uint64]struct{}, len(items))
ids := make([]uint64, 0, len(items))
@@ -1121,13 +1165,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
}
return nil, err
}
var orderExists int64
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOrderNotFound
}
return nil, err
}
if orderExists == 0 {
return nil, ErrOrderNotFound
}
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
var existing int64
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
@@ -1142,7 +1186,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
parsed := ruleFromVersion(rule)
result, err := CalculateSettlement(parsed, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
result, err := CalculateSettlement(parsed, SettlementInput{
GrossPaidAmountCent: req.GrossPaidAmountCent,
RefundAmountCent: req.RefundAmountCent,
OwnerRentAmountCent: order.OwnerRentAmountCent,
MarkupAmountCent: req.MarkupAmountCent,
GrossProfitByParty: partyInputs,
})
if err != nil {
return nil, err
}
@@ -1423,29 +1473,9 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return err
}
// 一键开通只落草稿。字段存百分数;口头 3.5/4/2.5 写入时换成 35/40/25。
parsed := Rule{
DistributorReceiveRatio: req.DistributorReceiveRatio,
PlatformSellRatio: req.PlatformSellRatio,
ServiceFeeValue: req.ServiceFeeValue,
ServiceFeeUnit: ServiceFeeUnitPercent,
}
normalizeRulePercents(&parsed)
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "draft",
DistributorReceiveRatio: parsed.DistributorReceiveRatio,
PlatformSellRatio: parsed.PlatformSellRatio,
ServiceFeeValue: parsed.ServiceFeeValue,
ServiceFeeUnit: parsed.ServiceFeeUnit,
PaymentFeeBPS: 50,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: time.Now(),
}
if err := tx.Create(&rule).Error; err != nil {
now := time.Now()
receiveRule, err := createOnboardDefaultRules(tx, site.ID, taxEnabled, taxRate, now)
if err != nil {
return err
}
if err := wallet.EnsureAccountTx(tx, 0, site.ID); err != nil {
@@ -1454,7 +1484,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
resp = OnboardSiteResponse{
Site: siteDTO(site),
Rule: ruleDTO(rule),
Rule: ruleDTO(receiveRule),
Member: DistributionCurrentMemberDTO{
AdminID: adminUser.ID,
Username: adminUser.Username,
@@ -1475,6 +1505,43 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return &resp, nil
}
// createOnboardDefaultRules 开通时四种模式都按默认分成直接生效,之后在规则页改即可。
func createOnboardDefaultRules(tx *gorm.DB, siteID uint64, taxEnabled bool, taxRate int64, effectiveFrom time.Time) (model.DistributionRuleVersion, error) {
var receive model.DistributionRuleVersion
modes := []string{ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive, ModeThreeParty, ModeSiteSelfSell}
for _, mode := range modes {
def, err := DefaultRule(mode)
if err != nil {
return model.DistributionRuleVersion{}, err
}
row := model.DistributionRuleVersion{
SiteID: &siteID,
Mode: mode,
Version: 1,
Status: "active",
PlatformReceiveRatio: def.PlatformReceiveRatio,
PlatformSellRatio: def.PlatformSellRatio,
DistributorReceiveRatio: def.DistributorReceiveRatio,
DistributorSellRatio: def.DistributorSellRatio,
DistributorARatio: def.DistributorARatio,
DistributorBRatio: def.DistributorBRatio,
ServiceFeeValue: def.ServiceFeeValue,
ServiceFeeUnit: def.ServiceFeeUnit,
PaymentFeeBPS: def.PaymentFeeBPS,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: effectiveFrom,
}
if err := tx.Create(&row).Error; err != nil {
return model.DistributionRuleVersion{}, err
}
if mode == ModeTwoPartyDistributorReceive {
receive = row
}
}
return receive, nil
}
func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
@@ -1940,6 +2007,13 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
var site model.DistributionSite
if err := r.db.WithContext(ctx).Select("id, settlement_hold_days").First(&site, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
var account model.WalletAccount
err := r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1959,7 +2033,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
Where("ro.status = 'completed'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
@@ -1977,6 +2051,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
PendingReleaseCent: pendingRelease,
TotalSettledCent: totalSettled,
TotalWithdrawnCent: totalWithdrawn,
SettlementHoldDays: site.SettlementHoldDays,
}, nil
}
@@ -166,6 +166,95 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
}
}
func TestCreateSettlementBatchSkipsRentingOrders(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "ds_renting", SiteName: "租赁中批次站", Domain: "ds-renting.example.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "DS-RENTING-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if batch.TotalAmountCent != 0 {
t.Fatalf("renting order must not enter DS batch, total=%d", batch.TotalAmountCent)
}
}
func TestCancelSettlementBatchUnblocksAutoRelease(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "ds_cancel", SiteName: "作废批次站", Domain: "ds-cancel.example.com", Status: "active"}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "DS-CANCEL-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
cancelled, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID)
if err != nil || cancelled.Status != "cancelled" {
t.Fatalf("cancel = %#v err=%v", cancelled, err)
}
var leftover int64
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID).Count(&leftover).Error; err != nil || leftover != 0 {
t.Fatalf("cancelled batch must drop items, leftover=%d err=%v", leftover, err)
}
if _, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchNotCancellable {
t.Fatalf("second cancel err = %v", err)
}
again, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if again.TotalAmountCent != 1400 {
t.Fatalf("cancelled items should be eligible again, total=%d", again.TotalAmountCent)
}
}
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
@@ -448,8 +537,16 @@ func TestOnboardSiteDoesNotDumpCatalog(t *testing.T) {
if err := db.Where("user_id = 0 AND distribution_site_id = ?", onboardResp.Site.ID).First(&treasury).Error; err != nil {
t.Fatalf("onboard must create site treasury wallet, err=%v", err)
}
if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 {
t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule)
if onboardResp.Rule.Status != "active" || onboardResp.Rule.Mode != ModeTwoPartyDistributorReceive ||
onboardResp.Rule.DistributorReceiveRatio != 35 || onboardResp.Rule.PlatformSellRatio != 40 || onboardResp.Rule.ServiceFeeValue != 25 {
t.Fatalf("onboard receive rule should be default active 35/40/25, got %+v", onboardResp.Rule)
}
var extraRules []model.DistributionRuleVersion
if err := db.Where("site_id = ? AND status = ?", onboardResp.Site.ID, "active").Order("mode ASC").Find(&extraRules).Error; err != nil {
t.Fatal(err)
}
if len(extraRules) != 4 {
t.Fatalf("onboard should activate all four default modes, got %d", len(extraRules))
}
}
@@ -177,6 +177,12 @@ func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (
}
return s.repo.MarkSettlementBatch(ctx, adminID, id)
}
func (s *Service) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CancelSettlementBatch(ctx, adminID, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
@@ -77,15 +77,11 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
return nil
}
orderID := order.ID
var siteID uint64
if order.DistributionSiteID != nil {
siteID = *order.DistributionSiteID
}
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncomeCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: siteID,
DistributionSiteID: 0,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.OwnerRentIncomeCent,
@@ -98,7 +94,7 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
if settlement.DepositCompensationCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: siteID,
DistributionSiteID: 0,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.DepositCompensationCent,