From 647fdc0b4ad6f7c81a36da079bfa8bfa71f5de93 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 17:11:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E8=B4=B9=E6=94=B9=E4=B8=BB=E7=AB=99=E6=89=BF=E6=8B=85=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E5=86=B7=E5=8D=B4=E6=9C=9F=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor --- backend/cmd/api/main.go | 2 + backend/internal/database/testdb.go | 13 +- .../internal/e2e/distribution_flow_test.go | 54 +-- .../jobs/distributionsettlement/job.go | 117 +++++ backend/internal/model/distribution.go | 3 + .../internal/modules/dispute/arbitration.go | 6 + backend/internal/modules/distribution/dto.go | 32 +- .../modules/distribution/lifecycle.go | 146 +++++- .../modules/distribution/lifecycle_test.go | 20 +- .../internal/modules/distribution/pricing.go | 5 +- .../modules/distribution/pricing_test.go | 6 +- .../internal/modules/distribution/release.go | 228 +++++++++ .../modules/distribution/release_test.go | 443 ++++++++++++++++++ .../modules/distribution/repository.go | 154 ++++-- .../modules/distribution/repository_test.go | 91 ++++ .../modules/order/checkout_finalize.go | 4 +- backend/internal/modules/wallet/repository.go | 5 +- ...000069_distribution_settlement_release.sql | 30 ++ distribution-admin/src/api.ts | 3 + distribution-admin/src/pages/Orders.tsx | 30 +- distribution-admin/src/pages/Settlements.tsx | 18 +- distribution-admin/src/pages/Wallet.tsx | 20 +- .../features/admin/api/adminDistribution.ts | 10 +- .../views/AdminDistributionSitesView.vue | 88 +++- 24 files changed, 1399 insertions(+), 129 deletions(-) create mode 100644 backend/internal/jobs/distributionsettlement/job.go create mode 100644 backend/internal/modules/distribution/release.go create mode 100644 backend/internal/modules/distribution/release_test.go create mode 100644 backend/migrations/000069_distribution_settlement_release.sql diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c98b4e1..3f56399 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/jobs/distributionsettlement" "hfb_sys/backend/internal/jobs/fileuploadcleanup" "hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/refundretry" @@ -99,6 +100,7 @@ func main() { if deps.DB != nil { ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx) fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx) + distributionsettlement.New(deps.DB, deps.Redis, logger).Start(jobCtx) if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil { paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger)) refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx) diff --git a/backend/internal/database/testdb.go b/backend/internal/database/testdb.go index 1dbe56c..4ca375b 100644 --- a/backend/internal/database/testdb.go +++ b/backend/internal/database/testdb.go @@ -494,7 +494,18 @@ func truncateAllTables(db *gorm.DB) error { } func listAutoIncrementTables(db *sql.DB) ([]string, error) { - rows, err := db.Query( + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + // MySQL 8 默认缓存 information_schema 的统计信息(86400s), + // 直接读取 auto_increment 可能拿到陈旧值并跳过重置,必须在本连接关闭缓存后再查。 + if _, err := conn.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil { + return nil, err + } + rows, err := conn.QueryContext(ctx, "SELECT table_name FROM information_schema.tables "+ "WHERE table_schema = DATABASE() AND table_name <> ? AND auto_increment > 1", schemaMetaTable, diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go index f6df0c9..3218206 100644 --- a/backend/internal/e2e/distribution_flow_test.go +++ b/backend/internal/e2e/distribution_flow_test.go @@ -29,10 +29,10 @@ func openDistributionE2EDB(t *testing.T) *gorm.DB { // 4. 支付成功自动触发快照: // - 号主成本 80 元保底; // - 分站自主加价 25 元 100% 归卖出分站; -// - 主站提价净利润(20 元 - 通道费 0.63 元 = 19.37 元)按 35% / 40% / 25% 严丝合缝切分; -// - 卖出分站总获得 32.75 元; +// - 主站提价利润 20 元(通道费 0.63 元由主站承担)按 35% / 40% / 25% 严丝合缝切分; +// - 卖出分站总获得 33.00 元; // -// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 32.75 元,财务状态置为 settled。 +// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 33.00 元,财务状态置为 settled。 func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) { db := openDistributionE2EDB(t) ctx := context.Background() @@ -201,17 +201,17 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) if financial.GrossPaidAmountCent != 12500 || financial.MarkupAmountCent != 2500 { t.Fatalf("financial snapshot error: gross=%d, markup=%d", financial.GrossPaidAmountCent, financial.MarkupAmountCent) } - // 支付通道费 0.5%:12500 * 50 / 10000 = 63 分 + // 支付通道费 0.5%:12500 * 50 / 10000 = 63 分,由主站承担,不参与分成基数 if financial.PaymentFeeCent != 63 { t.Fatalf("payment fee = %d, want 63", financial.PaymentFeeCent) } - // 主站提价总额 10000 - 8000 = 2000 分,扣除通道费 63 分后利润基数 = 1937 分 - if financial.SplitBaseAmountCent != 1937 { - t.Fatalf("split base amount = %d, want 1937", financial.SplitBaseAmountCent) + // 主站提价总额 10000 - 8000 = 2000 分,通道费不扣减,利润基数 = 2000 分 + if financial.SplitBaseAmountCent != 2000 { + t.Fatalf("split base amount = %d, want 2000", financial.SplitBaseAmountCent) } - // 平台服务费 25%:1937 * 25% = 484 分 - if financial.ServiceFeeCent != 484 { - t.Fatalf("service fee = %d, want 484", financial.ServiceFeeCent) + // 平台服务费 25%:2000 * 25% = 500 分 + if financial.ServiceFeeCent != 500 { + t.Fatalf("service fee = %d, want 500", financial.ServiceFeeCent) } // 验证各参与方收益快照 @@ -221,7 +221,7 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) } // 参与方 0:distributor_sell(分站 A,卖方) - // 收益 = 分站自主加价 2500 分 + 提价分成 40% (1937 * 40% = 775) = 3275 分 + // 收益 = 分站自主加价 2500 分 + 提价分成 40% (2000 * 40% = 800) = 3300 分 var distributorParty, platformParty model.DistributionOrderParty for _, p := range parties { if p.PartyRole == "distributor_sell" { @@ -230,15 +230,15 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) platformParty = p } } - if distributorParty.IncomeAmountCent != 3275 || distributorParty.PartyID != siteA.ID { - t.Fatalf("distributor_sell party error: income=%d (want 3275), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID) + if distributorParty.IncomeAmountCent != 3300 || distributorParty.PartyID != siteA.ID { + t.Fatalf("distributor_sell party error: income=%d (want 3300), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID) } - // 参与方 1:platform_receive(平台收号方,35% 分成:1937 * 35% = 678 分) - if platformParty.IncomeAmountCent != 678 { - t.Fatalf("platform_receive party error: income=%d (want 678)", platformParty.IncomeAmountCent) + // 参与方 1:platform_receive(平台收号方,35% 分成:2000 * 35% = 700 分) + if platformParty.IncomeAmountCent != 700 { + t.Fatalf("platform_receive party error: income=%d (want 700)", platformParty.IncomeAmountCent) } - // 尾差校验:1937 - (775 + 678) - 484 = 0 + // 尾差校验:2000 - (800 + 700) - 500 = 0 if financial.RoundingAdjustmentCent != 0 { t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent) } @@ -257,8 +257,8 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) if err != nil { t.Fatalf("CreateSettlementBatch failed: %v", err) } - if batch.TotalAmountCent != 3275 { - t.Fatalf("batch total amount = %d, want 3275", batch.TotalAmountCent) + if batch.TotalAmountCent != 3300 { + t.Fatalf("batch total amount = %d, want 3300", batch.TotalAmountCent) } // 确认结算批次入账 @@ -284,8 +284,8 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&siteWallet).Error; err != nil { t.Fatalf("find site wallet failed: %v", err) } - if siteWallet.AvailableBalanceCent != 3275 { - t.Fatalf("site wallet balance = %d, want 3275", siteWallet.AvailableBalanceCent) + if siteWallet.AvailableBalanceCent != 3300 { + t.Fatalf("site wallet balance = %d, want 3300", siteWallet.AvailableBalanceCent) } // 验证钱包流水记录 @@ -293,7 +293,7 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) if err := db.Where("user_id = 0 AND distribution_site_id = ? AND biz_type = 'distribution_settlement'", siteA.ID).First(&ledger).Error; err != nil { t.Fatalf("find wallet ledger failed: %v", err) } - if ledger.AmountCent != 3275 || ledger.Direction != "in" { + if ledger.AmountCent != 3300 || ledger.Direction != "in" { t.Fatalf("wallet ledger error: amount=%d, direction=%s", ledger.AmountCent, ledger.Direction) } @@ -302,9 +302,9 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) t.Logf("号主保底成本: %d 分 (80.00元)", 8000) t.Logf("分站自主加价(独享): %d 分 (25.00元)", shelfMarkupCent) t.Logf("支付通道手续费(0.5%%): %d 分 (0.63元)", financial.PaymentFeeCent) - t.Logf("主站提价净利润: %d 分 (19.37元)", financial.SplitBaseAmountCent) - t.Logf(" ├─ 收号方(主站 35%%): %d 分 (6.78元)", platformParty.IncomeAmountCent) - t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (7.75元)", distributorParty.IncomeAmountCent-shelfMarkupCent) - t.Logf(" └─ 平台服务费(25%%): %d 分 (4.84元)", financial.ServiceFeeCent) - t.Logf("分站 A 最终结算到账: %d 分 (32.75元) [加价25元 + 分成7.75元]", siteWallet.AvailableBalanceCent) + t.Logf("主站提价净利润(主站承担通道费): %d 分 (20.00元)", financial.SplitBaseAmountCent) + t.Logf(" ├─ 收号方(主站 35%%): %d 分 (7.00元)", platformParty.IncomeAmountCent) + t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (8.00元)", distributorParty.IncomeAmountCent-shelfMarkupCent) + t.Logf(" └─ 平台服务费(25%%): %d 分 (5.00元)", financial.ServiceFeeCent) + t.Logf("分站 A 最终结算到账: %d 分 (33.00元) [加价25元 + 分成8元]", siteWallet.AvailableBalanceCent) } diff --git a/backend/internal/jobs/distributionsettlement/job.go b/backend/internal/jobs/distributionsettlement/job.go new file mode 100644 index 0000000..776f4de --- /dev/null +++ b/backend/internal/jobs/distributionsettlement/job.go @@ -0,0 +1,117 @@ +package distributionsettlement + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "hfb_sys/backend/internal/modules/distribution" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" +) + +const releaseLockKey = "hfb:job:distributionsettlement:lock" + +type Job struct { + db *gorm.DB + redis *redis.Client + logger *zap.Logger + interval time.Duration + instanceID string +} + +func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job { + return &Job{ + db: db, + redis: redisClient, + logger: logger, + interval: 10 * time.Minute, + instanceID: newInstanceID(), + } +} + +// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行结算释放。 +// 未配置 Redis 时直接执行;释放逻辑本身幂等(行锁 + 结算明细唯一性)。 +func (j *Job) acquireLock(ctx context.Context) (func(), bool) { + if j.redis == nil { + return func() {}, true + } + ok, err := j.redis.SetNX(ctx, releaseLockKey, j.instanceID, j.lockTTL()).Result() + if err != nil { + j.logger.Warn("分销自动结算任务获取锁失败,将以单实例模式执行", zap.Error(err)) + return func() {}, true + } + if !ok { + return nil, false + } + return j.releaseLock, true +} + +// releaseLock 仅在锁仍归本实例时释放,避免误删其他实例的锁。 +func (j *Job) releaseLock() { + if j.redis == nil { + return + } + relCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`) + if err := script.Run(relCtx, j.redis, []string{releaseLockKey}, j.instanceID).Err(); err != nil { + j.logger.Warn("分销自动结算任务释放锁失败", zap.Error(err)) + } +} + +func (j *Job) lockTTL() time.Duration { + return 2 * j.interval +} + +func newInstanceID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("inst-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +func (j *Job) Start(ctx context.Context) { + if j == nil || j.db == nil { + return + } + go j.loop(ctx) +} + +func (j *Job) loop(ctx context.Context) { + j.run(ctx) + ticker := time.NewTicker(j.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + j.logger.Debug("分销自动结算任务已停止") + return + case <-ticker.C: + j.run(ctx) + } + } +} + +func (j *Job) run(ctx context.Context) { + release, ok := j.acquireLock(ctx) + if !ok { + return + } + defer release() + + repo := distribution.NewRepository(j.db) + count, err := repo.AutoReleaseDueSettlements(ctx) + if err != nil { + j.logger.Warn("分销自动结算释放失败", zap.Error(err)) + return + } + if count > 0 { + j.logger.Info("分销自动结算释放完成", zap.Int("orders", count)) + } +} diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index bef0ebc..1e760a3 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -18,6 +18,7 @@ type DistributionSite struct { DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` + SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -102,6 +103,8 @@ type DistributionOrderFinancial struct { SplitBaseAmountCent int64 `gorm:"not null;default:0" json:"split_base_amount_cent"` RoundingAdjustmentCent int64 `gorm:"not null;default:0" json:"rounding_adjustment_cent"` SettlementStatus string `gorm:"size:24;not null;default:'pending';index" json:"settlement_status"` + ReleaseAt *time.Time `gorm:"index:idx_distribution_financial_release,priority:2" json:"release_at,omitempty"` + ReleasedAt *time.Time `json:"released_at,omitempty"` SettledAt *time.Time `json:"settled_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 0f5a6ac..fb84c90 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -11,6 +11,7 @@ import ( "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" + "hfb_sys/backend/internal/modules/distribution" "hfb_sys/backend/internal/modules/notification" ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/rentergrowth" @@ -130,6 +131,11 @@ 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 { + return err + } + } if err := tx.Save(&listing).Error; err != nil { return err } diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 7129ab1..765b177 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -17,6 +17,7 @@ type SiteDTO struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` TaxEnabled bool `json:"tax_enabled"` TaxRateBPS int64 `json:"tax_rate_bps"` + SettlementHoldDays int `json:"settlement_hold_days"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -29,6 +30,7 @@ type CreateSiteRequest struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` + SettlementHoldDays *int `json:"settlement_hold_days"` } type UpdateSiteRequest struct { @@ -39,6 +41,7 @@ type UpdateSiteRequest struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` + SettlementHoldDays *int `json:"settlement_hold_days"` LogoURL *string `json:"logo_url"` FaviconURL *string `json:"favicon_url"` BannersJSON *string `json:"banners_json"` @@ -91,17 +94,22 @@ type UpdateRuleStatusRequest struct { } type DistributionOrderDTO struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - SiteID uint64 `json:"site_id"` - ListingID uint64 `json:"listing_id"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - Status string `json:"status"` - HandoffStatus string `json:"handoff_status"` - RefundStatus string `json:"refund_status"` - SettlementStatus string `json:"settlement_status"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + SiteID uint64 `json:"site_id"` + ListingID uint64 `json:"listing_id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + RefundStatus string `json:"refund_status"` + SettlementStatus string `json:"settlement_status"` + + // 分销快照释放状态:pending=待释放,settled=已释放入账;release_at 为预计释放时间。 + DistributorSettlementStatus string `json:"distributor_settlement_status"` + ReleaseAt *time.Time `json:"release_at,omitempty"` + DistributionMode string `json:"distribution_mode"` GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"` RefundAmountCent int64 `json:"refund_amount_cent"` @@ -318,6 +326,7 @@ type OnboardSiteRequest struct { DisplayAccountEnabled *bool `json:"display_account_enabled"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` + SettlementHoldDays *int `json:"settlement_hold_days"` AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"` AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"` AdminNickname string `json:"admin_nickname"` @@ -403,6 +412,7 @@ type SiteWalletDTO struct { SiteID uint64 `json:"site_id"` AvailableBalanceCent int64 `json:"available_balance_cent"` FrozenBalanceCent int64 `json:"frozen_balance_cent"` + PendingReleaseCent int64 `json:"pending_release_cent"` TotalSettledCent int64 `json:"total_settled_cent"` TotalWithdrawnCent int64 `json:"total_withdrawn_cent"` } diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index ecc9186..da7ce15 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -6,8 +6,10 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema, @@ -172,7 +174,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) } // SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。 -// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。 +// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。 func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error { if !distributionTablesPresent(tx) { return nil @@ -206,6 +208,14 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error } 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"} + // 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。 + var existing model.DistributionOrderFinancial + if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil { + financial.ReleaseAt = existing.ReleaseAt + financial.ReleasedAt = existing.ReleasedAt + financial.SettledAt = existing.SettledAt + financial.SettlementStatus = existing.SettlementStatus + } if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil { return err } @@ -246,7 +256,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { return nil } var financial model.DistributionOrderFinancial - if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } @@ -327,6 +337,15 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { return err } splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent) + // 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。 + settlementStatus := "pending" + if financial.ReleasedAt != nil { + settlementStatus = "settled" + } + releaseAt, err := computeReleaseAt(tx, order, financial) + if err != nil { + return err + } if err := tx.Model(&financial).Updates(map[string]any{ "refund_amount_cent": rentRefundCent, "net_paid_amount_cent": result.NetPaidAmountCent, @@ -334,13 +353,47 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits), - "settlement_status": "pending", + "settlement_status": settlementStatus, + "release_at": releaseAt, }).Error; err != nil { return err } for _, split := range splits { + var party model.DistributionOrderParty + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).First(&party).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return err + } + if party.IncomeAmountCent == split.AmountCent && party.ProfitAmountCent == split.AmountCent { + continue + } + // 已结算入账的分站份额被退款调减时,从收款站点钱包扣回差额(允许扣成负数,由后续收益抵扣)。 + settled, err := partySettled(tx, party.ID) + if err != nil { + return err + } + if split.AmountCent < party.IncomeAmountCent && party.PartyType == PartyTypeSite && settled { + reversal := party.IncomeAmountCent - split.AmountCent + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: 0, + DistributionSiteID: party.PartyID, + OrderID: &orderID, + Direction: "out", + AmountCent: reversal, + BalanceType: "available", + BizType: "distribution_reversal", + BizNo: order.OrderNo, + Remark: "结算入账后退款回冲", + AllowNegative: true, + }); err != nil { + return err + } + } if err := tx.Model(&model.DistributionOrderParty{}). - Where("order_id = ? AND party_role = ?", orderID, split.PartyRole). + Where("id = ?", party.ID). Updates(map[string]any{ "income_amount_cent": split.AmountCent, "profit_amount_cent": split.AmountCent, @@ -350,3 +403,88 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { } return nil } + +// EnsureCompletedReleaseAtTx 在订单进入 completed 且尚未入账时写入 release_at。 +// 无分销快照时为 no-op,供结账、仲裁、退款重算共用,避免完单后漏写导致永远无法自动释放。 +func EnsureCompletedReleaseAtTx(tx *gorm.DB, orderID uint64) error { + if !distributionTablesPresent(tx) { + return nil + } + var financial model.DistributionOrderFinancial + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var order model.RentalOrder + if err := tx.Select("id, status, settled_at").First(&order, orderID).Error; err != nil { + return err + } + releaseAt, err := computeReleaseAt(tx, order, financial) + if err != nil { + return err + } + return tx.Model(&financial).Update("release_at", releaseAt).Error +} + +// computeReleaseAt 按站点当前冷却天数计算释放时间;已入账快照保持原 release_at。 +func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.DistributionOrderFinancial) (*time.Time, error) { + if financial.ReleasedAt != nil { + return financial.ReleaseAt, nil + } + if order.Status != "completed" { + return financial.ReleaseAt, nil + } + completedAt := order.SettledAt + if completedAt == nil { + now := time.Now() + completedAt = &now + } + var site model.DistributionSite + if err := tx.Select("id, settlement_hold_days").First(&site, financial.SiteID).Error; err != nil { + return nil, err + } + due := completedAt.AddDate(0, 0, site.SettlementHoldDays) + return &due, nil +} + +// rewritePendingReleaseAt 站点冷却天数变更后,按新规则回写尚未入账的已完成订单释放时间。 +func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error { + var financials []model.DistributionOrderFinancial + if err := tx.Where("site_id = ? AND settlement_status = ? AND released_at IS NULL", siteID, "pending"). + Find(&financials).Error; err != nil { + return err + } + for _, financial := range financials { + var order model.RentalOrder + if err := tx.Select("id, status, settled_at").First(&order, financial.OrderID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return err + } + if order.Status != "completed" { + continue + } + releaseAt, err := computeReleaseAt(tx, order, financial) + if err != nil { + return err + } + if err := tx.Model(&model.DistributionOrderFinancial{}).Where("id = ?", financial.ID). + Update("release_at", releaseAt).Error; err != nil { + return err + } + } + return nil +} + +// partySettled 判断参与方份额是否已经通过结算批次入账。 +func partySettled(tx *gorm.DB, partyID uint64) (bool, error) { + var count int64 + if err := tx.Model(&model.DistributionSettlementItem{}). + Where("party_id = ? AND status = 'settled'", partyID).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go index 8a6fa32..c1aafc0 100644 --- a/backend/internal/modules/distribution/lifecycle_test.go +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -303,7 +303,7 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID { t.Fatalf("unexpected financial snapshot: %+v", financial) } - if financial.SplitBaseAmountCent != 3950 || financial.ServiceFeeCent != 988 { + if financial.SplitBaseAmountCent != 4000 || financial.ServiceFeeCent != 1000 || financial.PaymentFeeCent != 50 { t.Fatalf("profit snapshot = %+v", financial) } @@ -311,10 +311,10 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 { t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err) } - if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1383 { + if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1400 { t.Fatalf("distributor party = %+v", parties[0]) } - if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1580 { + if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1600 { t.Fatalf("platform party = %+v", parties[1]) } @@ -393,10 +393,10 @@ func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) { if seller == nil || platform == nil { t.Fatalf("missing parties: %+v", parties) } - if seller.IncomeAmountCent != 1580 { - t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent) + if seller.IncomeAmountCent != 1600 { + t.Fatalf("seller 40%% of profit = %d, want 1600", seller.IncomeAmountCent) } - if platform.IncomeAmountCent != 1383 || platform.PartyID != 0 { + if platform.IncomeAmountCent != 1400 || platform.PartyID != 0 { t.Fatalf("platform receive = %+v", platform) } } @@ -460,15 +460,15 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) { if financial.NetPaidAmountCent != 53330 { t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent) } - if financial.SplitBaseAmountCent != 6913 { - t.Fatalf("financial.SplitBaseAmountCent = %d, want 6913", financial.SplitBaseAmountCent) + if financial.SplitBaseAmountCent != 7180 { + t.Fatalf("financial.SplitBaseAmountCent = %d, want 7180", financial.SplitBaseAmountCent) } var distributorParty model.DistributionOrderParty if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil { t.Fatal(err) } - if distributorParty.IncomeAmountCent != 2420 { - t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2420", distributorParty.IncomeAmountCent) + if distributorParty.IncomeAmountCent != 2513 { + t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent) } } diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index 37e27a5..71d990c 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -133,7 +133,8 @@ func normalizeRulePercents(row *Rule) { } } -// CalculateSettlement 先扣通道手续费和号主应得,得到利润后再按 35/40/25 切。 +// CalculateSettlement 先扣号主应得与分站加价,得到利润后再按 35/40/25 切。 +// 支付通道手续费由主站承担:只记录金额供对账,不再从分成基数中扣除。 func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) { if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent { return SettlementResult{}, ErrInvalidAmount @@ -146,7 +147,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er } netPaid := input.GrossPaidAmountCent - input.RefundAmountCent paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000) - profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent - paymentFee + profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent if profit < 0 { profit = 0 } diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index 9933d72..a641bc2 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -37,14 +37,14 @@ func TestNormalizeSplitPercent(t *testing.T) { } } -func TestCalculateSettlementSplitsProfitAfterOwnerAndFee(t *testing.T) { +func TestCalculateSettlementSplitsProfitWithoutPaymentFee(t *testing.T) { rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50} got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000}) if err != nil { t.Fatal(err) } - // 通道费 50,利润 10000-6000-50=3950,服务费 25%=988 - if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 { + // 通道费 50 由主站承担(仅记录),利润 10000-6000=4000,服务费 25%=1000 + if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 4000 || got.ServiceFeeCent != 1000 { t.Fatalf("unexpected settlement: %#v", got) } } diff --git a/backend/internal/modules/distribution/release.go b/backend/internal/modules/distribution/release.go new file mode 100644 index 0000000..3aeffe5 --- /dev/null +++ b/backend/internal/modules/distribution/release.go @@ -0,0 +1,228 @@ +package distribution + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// autoBatchPrefix 区分自动释放批次与主站财务手工创建的结算批次(DS 前缀)。 +const autoBatchPrefix = "DA" + +// autoReleaseBlockedRefundStatuses 进行中的退款不得自动释放,避免先入账再回冲。 +var autoReleaseBlockedRefundStatuses = []string{"pending", "pending_review", "refunding"} + +// autoReleaseBlockedDisputeStatuses 开放中的维权订单暂缓释放。 +var autoReleaseBlockedDisputeStatuses = []string{"open", "processing"} + +// AutoReleaseDueSettlements 扫描到达释放时间的分销订单,按站点自动创建批次并释放到可提现余额。 +// 返回实际触达释放的订单数。 +func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error) { + if r == nil || r.db == nil { + return 0, ErrDependencyUnavailable + } + if !liveMoneyMovementEnabled() { + return 0, ErrLiveMoneyMovementFrozen + } + now := time.Now() + var orderIDs []uint64 + if err := r.db.WithContext(ctx). + Table("distribution_order_financials f"). + 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.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"). + Scan(&orderIDs).Error; err != nil { + return 0, err + } + if len(orderIDs) == 0 { + return 0, nil + } + + var siteIDs []uint64 + if err := r.db.WithContext(ctx). + Table("distribution_order_parties p"). + Select("DISTINCT p.party_id"). + Where("p.order_id IN ? AND p.party_type = ?", orderIDs, PartyTypeSite). + Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)"). + Order("p.party_id ASC"). + Scan(&siteIDs).Error; err != nil { + return 0, err + } + + released := 0 + for _, siteID := range siteIDs { + if siteID == 0 { + continue + } + count, err := r.releaseSiteSettlements(ctx, siteID, orderIDs, now) + if err != nil { + return released, fmt.Errorf("释放站点 %d 分销结算失败: %w", siteID, err) + } + released += count + } + return released, nil +} + +// releaseSiteSettlements 在事务内释放指定站点上所有到期订单的份额: +// 复用当天的自动批次,生成结算明细并把总额入账到站点钱包。 +func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, orderIDs []uint64, now time.Time) (int, error) { + releasedOrders := 0 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。 + var lockedFinancials []model.DistributionOrderFinancial + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id IN ?", orderIDs). + Order("id ASC").Find(&lockedFinancials).Error; err != nil { + return err + } + var parties []model.DistributionOrderParty + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id IN ? AND party_type = ? AND party_id = ?", orderIDs, PartyTypeSite, siteID). + Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)"). + Order("order_id ASC, id ASC").Find(&parties).Error; err != nil { + return err + } + + releasable := make(map[uint64]bool, len(parties)) + checked := make(map[uint64]struct{}, len(parties)) + eligible := make([]model.DistributionOrderParty, 0, len(parties)) + for _, party := range parties { + if _, done := checked[party.OrderID]; !done { + ok, err := releasableOrder(tx, party.OrderID, now) + if err != nil { + return err + } + checked[party.OrderID] = struct{}{} + releasable[party.OrderID] = ok + } + if !releasable[party.OrderID] { + continue + } + eligible = append(eligible, party) + } + if len(eligible) == 0 { + return nil + } + + batch, err := ensureAutoBatch(tx, siteID, now) + if err != nil { + return err + } + var total int64 + orderSet := make(map[uint64]struct{}, len(eligible)) + for _, party := range eligible { + item := model.DistributionSettlementItem{ + BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID, + AmountCent: party.IncomeAmountCent, Status: "settled", + } + if err := tx.Create(&item).Error; err != nil { + return err + } + total += party.IncomeAmountCent + orderSet[party.OrderID] = struct{}{} + } + if total > 0 { + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: 0, + DistributionSiteID: siteID, + Direction: "in", + AmountCent: total, + BalanceType: "available", + BizType: "distribution_settlement", + BizNo: batch.BatchNo, + Remark: fmt.Sprintf("分销自动结算释放 %s", batch.BatchNo), + }); err != nil { + return err + } + } + if err := tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID). + Updates(map[string]any{ + "total_amount_cent": gorm.Expr("total_amount_cent + ?", total), + "status": "settled", + "settled_at": now, + }).Error; err != nil { + return err + } + orderList := make([]uint64, 0, len(orderSet)) + for orderID := range orderSet { + orderList = append(orderList, orderID) + } + if err := markFinancialsSettledIfSitePartiesDone(tx, orderList, now); err != nil { + return err + } + releasedOrders = len(orderList) + return nil + }) + if err != nil { + return 0, err + } + return releasedOrders, nil +} + +// releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。 +// 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。 +func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) { + var financial model.DistributionOrderFinancial + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ?", orderID).First(&financial).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + if financial.SettlementStatus != "pending" || financial.ReleaseAt == nil || financial.ReleaseAt.After(now) { + return false, nil + } + var order model.RentalOrder + 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) { + return false, nil + } + var openDisputes int64 + if err := tx.Table("disputes"). + Where("order_id = ? AND status IN ?", orderID, autoReleaseBlockedDisputeStatuses). + Count(&openDisputes).Error; err != nil { + return false, err + } + return openDisputes == 0, nil +} + +// ensureAutoBatch 复用当天的自动批次(不论 pending/settled),没有则创建,保证对账按站点按天聚合。 +func ensureAutoBatch(tx *gorm.DB, siteID uint64, now time.Time) (*model.DistributionSettlementBatch, error) { + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var batch model.DistributionSettlementBatch + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("site_id = ? AND batch_no LIKE ? AND period_start = ?", siteID, autoBatchPrefix+"%", startOfDay). + Order("id ASC").First(&batch).Error + if err == nil { + return &batch, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + batch = model.DistributionSettlementBatch{ + SiteID: siteID, + BatchNo: fmt.Sprintf("%s%s%d", autoBatchPrefix, now.Format("20060102150405.000000000"), siteID), + PeriodStart: startOfDay, + PeriodEnd: startOfDay.AddDate(0, 0, 1), + Status: "pending", + } + if err := tx.Create(&batch).Error; err != nil { + return nil, err + } + return &batch, nil +} diff --git a/backend/internal/modules/distribution/release_test.go b/backend/internal/modules/distribution/release_test.go new file mode 100644 index 0000000..88b3628 --- /dev/null +++ b/backend/internal/modules/distribution/release_test.go @@ -0,0 +1,443 @@ +package distribution + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" +) + +func TestApplyRefundTxSetsReleaseAtForCompletedOrder(t *testing.T) { + db := distributionTestDB(t) + now := time.Now() + site := mustCreateDistSite(t, db, "rel_site", "释放测试站", "rel.dist.com") + if err := db.Model(&site).Update("settlement_hold_days", 3).Error; err != nil { + t.Fatal(err) + } + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + settledAt := now.Add(-time.Hour).Truncate(time.Second) + order := model.RentalOrder{ + OrderNo: "ORD-RELEASE-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + SettledAt: &settledAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatal(err) + } + if err := ApplyRefundTx(db, order.ID, 0); err != nil { + t.Fatal(err) + } + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.ReleaseAt == nil { + t.Fatalf("release_at not set: %+v", financial) + } + want := settledAt.AddDate(0, 0, 3) + if !financial.ReleaseAt.Equal(want) { + t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want) + } +} + +func TestAutoReleaseDueSettlements(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "auto_site", "自动释放站", "auto.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-AUTO-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, + } + if err := db.Create(&financial).Error; err != nil { + t.Fatal(err) + } + parties := []model.DistributionOrderParty{ + {OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400}, + {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600}, + } + if err := db.Create(&parties).Error; err != nil { + t.Fatal(err) + } + + released, err := repo.AutoReleaseDueSettlements(t.Context()) + if err != nil { + t.Fatalf("AutoReleaseDueSettlements failed: %v", err) + } + if released != 1 { + t.Fatalf("released = %d, want 1", released) + } + + var updated model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil { + t.Fatal(err) + } + if updated.SettlementStatus != "settled" || updated.ReleasedAt == nil { + t.Fatalf("financial not released: %+v", updated) + } + + var account model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { + t.Fatal(err) + } + if account.AvailableBalanceCent != 1400 { + t.Fatalf("site wallet = %d, want 1400", account.AvailableBalanceCent) + } + + var batch model.DistributionSettlementBatch + if err := db.Where("site_id = ? AND status = 'settled'", site.ID).First(&batch).Error; err != nil { + t.Fatal(err) + } + if batch.TotalAmountCent != 1400 || len(batch.BatchNo) < 2 || batch.BatchNo[:2] != autoBatchPrefix { + t.Fatalf("batch = %+v", batch) + } + + var itemCount int64 + if err := db.Model(&model.DistributionSettlementItem{}). + Where("batch_id = ? AND status = 'settled'", batch.ID).Count(&itemCount).Error; err != nil { + t.Fatal(err) + } + if itemCount != 1 { + t.Fatalf("settled items = %d, want 1", itemCount) + } + + // 幂等:重复执行不再释放,也不再入账。 + released, err = repo.AutoReleaseDueSettlements(t.Context()) + if err != nil { + t.Fatal(err) + } + if released != 0 { + t.Fatalf("second run released = %d, want 0", released) + } + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { + t.Fatal(err) + } + if account.AvailableBalanceCent != 1400 { + t.Fatalf("site wallet after rerun = %d, want 1400", account.AvailableBalanceCent) + } +} + +func TestAutoReleaseSkipsOrderInRefund(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "hold_site", "退款拦截站", "hold.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-HOLD-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "pending_review", + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, + } + if err := db.Create(&financial).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) + } + + released, err := repo.AutoReleaseDueSettlements(t.Context()) + if err != nil { + t.Fatal(err) + } + if released != 0 { + t.Fatalf("released = %d, want 0 while refund is pending", released) + } +} + +func TestRefundAfterReleaseReversesSiteWallet(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "rev_site", "回冲测试站", "rev.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-REV-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, + } + if err := db.Create(&financial).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}, + {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600}, + }).Error; err != nil { + t.Fatal(err) + } + if _, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil { + t.Fatal(err) + } + + // 释放后退款 3000:利润 1000,分站份额降为 350,应回冲 1050。 + if err := ApplyRefundTx(db, order.ID, 3000); err != nil { + t.Fatal(err) + } + var account model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { + t.Fatal(err) + } + if account.AvailableBalanceCent != 350 { + t.Fatalf("wallet after reversal = %d, want 350", account.AvailableBalanceCent) + } + var updated model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil { + t.Fatal(err) + } + if updated.SettlementStatus != "settled" { + t.Fatalf("released financial must stay settled, got %s", updated.SettlementStatus) + } + + // 余额被提空后再次回冲应允许扣成负数,避免回冲失败。 + if err := db.Model(&model.WalletAccount{}).Where("id = ?", account.ID). + Update("available_balance_cent", 0).Error; err != nil { + t.Fatal(err) + } + if err := ApplyRefundTx(db, order.ID, 10000); err != nil { + t.Fatalf("reverse with empty wallet failed: %v", err) + } + if err := db.Where("id = ?", account.ID).First(&account).Error; err != nil { + t.Fatal(err) + } + if account.AvailableBalanceCent != -350 { + t.Fatalf("wallet = %d, want -350 (allow negative on reversal)", account.AvailableBalanceCent) + } +} + +func TestEnsureCompletedReleaseAtTxSetsReleaseAt(t *testing.T) { + db := distributionTestDB(t) + now := time.Now() + site := mustCreateDistSite(t, db, "ens_site", "完单释放站", "ens.dist.com") + if err := db.Model(&site).Update("settlement_hold_days", 2).Error; err != nil { + t.Fatal(err) + } + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + settledAt := now.Add(-time.Hour).Truncate(time.Second) + order := model.RentalOrder{ + OrderNo: "ORD-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + SettledAt: &settledAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatal(err) + } + if err := EnsureCompletedReleaseAtTx(db, order.ID); err != nil { + t.Fatal(err) + } + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.ReleaseAt == nil { + t.Fatal("release_at not set") + } + want := settledAt.AddDate(0, 0, 2) + if !financial.ReleaseAt.Equal(want) { + t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want) + } +} + +func TestAutoReleaseAppendsSameDayBatch(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "day_site", "同日追加站", "day.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + + seed := func(orderNo string) { + t.Helper() + order := model.RentalOrder{ + OrderNo: orderNo, ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", 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: 10000, + SplitBaseAmountCent: 4000, 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: 1400, + }).Error; err != nil { + t.Fatal(err) + } + } + + seed("ORD-DAY-1") + if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 { + t.Fatalf("first release = %d err=%v, want 1", released, err) + } + var firstBatch model.DistributionSettlementBatch + if err := db.Where("site_id = ?", site.ID).First(&firstBatch).Error; err != nil { + t.Fatal(err) + } + + seed("ORD-DAY-2") + if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 { + t.Fatalf("second release = %d err=%v, want 1", released, err) + } + + var batches []model.DistributionSettlementBatch + if err := db.Where("site_id = ? AND batch_no LIKE ?", site.ID, autoBatchPrefix+"%").Find(&batches).Error; err != nil { + t.Fatal(err) + } + if len(batches) != 1 { + t.Fatalf("same-day auto batches = %d, want 1", len(batches)) + } + if batches[0].ID != firstBatch.ID { + t.Fatalf("second release created a new batch %d, want %d", batches[0].ID, firstBatch.ID) + } + if batches[0].TotalAmountCent != 2800 { + t.Fatalf("batch total = %d, want 2800", batches[0].TotalAmountCent) + } + var itemCount int64 + if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batches[0].ID).Count(&itemCount).Error; err != nil { + t.Fatal(err) + } + if itemCount != 2 { + t.Fatalf("batch items = %d, want 2", itemCount) + } + var account model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { + t.Fatal(err) + } + if account.AvailableBalanceCent != 2800 { + t.Fatalf("wallet = %d, want 2800", account.AvailableBalanceCent) + } +} + +func TestAutoReleaseSkipsRefundingOrder(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "rf_site", "退款中拦截站", "rf.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-RF-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "refunding", + 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: 10000, + SplitBaseAmountCent: 4000, 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: 1400, + }).Error; err != nil { + t.Fatal(err) + } + + released, err := repo.AutoReleaseDueSettlements(t.Context()) + if err != nil { + t.Fatal(err) + } + if released != 0 { + t.Fatalf("released = %d, want 0 while refunding", released) + } +} + +func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + siteA := mustCreateDistSite(t, db, "rev_a", "收号站", "reva.dist.com") + siteB := mustCreateDistSite(t, db, "rev_b", "卖出站", "revb.dist.com") + rule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-3P-REV", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", + DistributionSiteID: &siteB.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: siteB.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&[]model.DistributionOrderParty{ + {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 1400, Ratio: 35}, + {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 1600, Ratio: 40}, + }).Error; err != nil { + t.Fatal(err) + } + if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 2 { + t.Fatalf("released = %d err=%v, want 2", released, err) + } + + // 退款 3000 后利润 1000:A 350、B 400;回冲必须打到 PartyID(收号站 A),不能打到快照 SiteID(卖出站 B)。 + if err := ApplyRefundTx(db, order.ID, 3000); err != nil { + t.Fatal(err) + } + var walletA, walletB model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB).Error; err != nil { + t.Fatal(err) + } + if walletA.AvailableBalanceCent != 350 { + t.Fatalf("receiver wallet A = %d, want 350 (clawback on PartyID)", walletA.AvailableBalanceCent) + } + if walletB.AvailableBalanceCent != 400 { + t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index fb588fe..9a3c402 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -46,6 +46,7 @@ var ( ErrSyncListingNotFound = errors.New("sync listing not found") ErrEmptyListingIDs = errors.New("listing ids required") ErrInvalidListingStatus = errors.New("invalid listing status") + ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365") ) const maxSyncListingIDs = 200 @@ -55,6 +56,14 @@ func liveMoneyMovementEnabled() bool { return env != "production" && env != "prod" } +// normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。 +func normalizeSettlementHoldDays(days int) (int, error) { + if days < 0 || days > 365 { + return 0, ErrInvalidSettlementHold + } + return days, nil +} + type Repository struct { db *gorm.DB redis *redis.Client @@ -232,6 +241,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si if req.TaxRateBPS != nil { row.TaxRateBPS = *req.TaxRateBPS } + if req.SettlementHoldDays != nil { + holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) + if err != nil { + return nil, err + } + row.SettlementHoldDays = holdDays + } if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return nil, err } @@ -244,52 +260,70 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) { var row model.DistributionSite - if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrSiteNotFound + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSiteNotFound + } + return err } - return nil, err - } - if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil { - return nil, err - } - if req.SiteName != nil { - row.SiteName = strings.TrimSpace(*req.SiteName) - } - if req.Domain != nil { - row.Domain = strings.TrimSpace(*req.Domain) - } - if req.Status != nil { - row.Status = strings.TrimSpace(*req.Status) - } - if req.DisplayAccountEnabled != nil { - row.DisplayAccountEnabled = *req.DisplayAccountEnabled - } - if req.DefaultSupportGroupID != nil { - row.DefaultSupportGroupID = req.DefaultSupportGroupID - } - if req.TaxEnabled != nil { - row.TaxEnabled = *req.TaxEnabled - } - if req.TaxRateBPS != nil { - row.TaxRateBPS = *req.TaxRateBPS - } - if req.LogoURL != nil { - row.LogoURL = strings.TrimSpace(*req.LogoURL) - } - if req.FaviconURL != nil { - row.FaviconURL = strings.TrimSpace(*req.FaviconURL) - } - if req.BannersJSON != nil { - row.BannersJSON = *req.BannersJSON - } - if req.AnnouncementsJSON != nil { - row.AnnouncementsJSON = *req.AnnouncementsJSON - } - if req.FooterInfoJSON != nil { - row.FooterInfoJSON = *req.FooterInfoJSON - } - if err := r.db.WithContext(ctx).Save(&row).Error; err != nil { + if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil { + return err + } + if req.SiteName != nil { + row.SiteName = strings.TrimSpace(*req.SiteName) + } + if req.Domain != nil { + row.Domain = strings.TrimSpace(*req.Domain) + } + if req.Status != nil { + row.Status = strings.TrimSpace(*req.Status) + } + if req.DisplayAccountEnabled != nil { + row.DisplayAccountEnabled = *req.DisplayAccountEnabled + } + if req.DefaultSupportGroupID != nil { + row.DefaultSupportGroupID = req.DefaultSupportGroupID + } + if req.TaxEnabled != nil { + row.TaxEnabled = *req.TaxEnabled + } + if req.TaxRateBPS != nil { + row.TaxRateBPS = *req.TaxRateBPS + } + holdDaysChanged := false + if req.SettlementHoldDays != nil { + holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) + if err != nil { + return err + } + holdDaysChanged = holdDays != row.SettlementHoldDays + row.SettlementHoldDays = holdDays + } + if req.LogoURL != nil { + row.LogoURL = strings.TrimSpace(*req.LogoURL) + } + if req.FaviconURL != nil { + row.FaviconURL = strings.TrimSpace(*req.FaviconURL) + } + if req.BannersJSON != nil { + row.BannersJSON = *req.BannersJSON + } + if req.AnnouncementsJSON != nil { + row.AnnouncementsJSON = *req.AnnouncementsJSON + } + if req.FooterInfoJSON != nil { + row.FooterInfoJSON = *req.FooterInfoJSON + } + if err := tx.Save(&row).Error; err != nil { + return err + } + if holdDaysChanged { + return rewritePendingReleaseAt(tx, row.ID) + } + return nil + }) + if err != nil { return nil, err } item := siteDTO(row) @@ -403,6 +437,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, + SettlementHoldDays: row.SettlementHoldDays, LogoURL: row.LogoURL, FaviconURL: row.FaviconURL, BannersJSON: row.BannersJSON, @@ -476,6 +511,8 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent, COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent, COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent, + COALESCE(f.settlement_status, '') AS distributor_settlement_status, + f.release_at AS release_at, ro.created_at, COALESCE(rl.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, @@ -555,6 +592,8 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent, COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent, COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent, + COALESCE(f.settlement_status, '') AS distributor_settlement_status, + f.release_at AS release_at, ro.created_at, COALESCE(rl.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, @@ -1048,7 +1087,7 @@ func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now } if err := tx.Model(&model.DistributionOrderFinancial{}). Where("order_id = ?", orderID). - Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil { + Updates(map[string]any{"settlement_status": "settled", "settled_at": now, "released_at": now}).Error; err != nil { return err } } @@ -1312,6 +1351,14 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* if req.TaxRateBPS != nil { taxRate = *req.TaxRateBPS } + holdDays := 7 + if req.SettlementHoldDays != nil { + normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) + if err != nil { + return err + } + holdDays = normalized + } site := model.DistributionSite{ SiteCode: req.SiteCode, @@ -1321,6 +1368,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* DisplayAccountEnabled: displayEnabled, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, + SettlementHoldDays: holdDays, } if err := tx.Create(&site).Error; err != nil { return err @@ -1891,6 +1939,19 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID). Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error + // 待释放收益:订单已完成、快照待释放、且尚未进入任何结算批次的站内份额。 + var pendingRelease int64 + _ = r.db.WithContext(ctx).Table("distribution_order_parties p"). + Joins("JOIN distribution_order_financials f ON f.order_id = p.order_id"). + 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.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)"). + Select("COALESCE(SUM(p.income_amount_cent), 0)").Scan(&pendingRelease).Error + var totalWithdrawn int64 _ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}). Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID). @@ -1900,6 +1961,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) SiteID: siteID, AvailableBalanceCent: account.AvailableBalanceCent, FrozenBalanceCent: account.FrozenBalanceCent, + PendingReleaseCent: pendingRelease, TotalSettledCent: totalSettled, TotalWithdrawnCent: totalWithdrawn, }, nil diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index d526e87..c27f340 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -23,6 +23,7 @@ func distributionTestDB(t *testing.T) *gorm.DB { &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, &model.RentalListing{}, &model.GameAccount{}, &model.User{}, &model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{}, + &model.Dispute{}, ); err != nil { t.Fatal(err) } @@ -984,3 +985,93 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) { t.Fatalf("withdraw err = %v", err) } } + +func TestUpdateSiteRewritesPendingReleaseAt(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "hold_upd", "冷却回写站", "holdupd.dist.com") + if err := db.Model(&site).Update("settlement_hold_days", 7).Error; err != nil { + t.Fatal(err) + } + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + settledAt := now.Add(-time.Hour).Truncate(time.Second) + order := model.RentalOrder{ + OrderNo: "ORD-HOLD-UPD", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + SettledAt: &settledAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + oldRelease := settledAt.AddDate(0, 0, 7) + if err := db.Create(&model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &oldRelease, + }).Error; err != nil { + t.Fatal(err) + } + + holdDays := 0 + if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{SettlementHoldDays: &holdDays}); err != nil { + t.Fatal(err) + } + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.ReleaseAt == nil || !financial.ReleaseAt.Equal(settledAt) { + t.Fatalf("release_at = %v, want %v after hold days set to 0", financial.ReleaseAt, settledAt) + } +} + +func TestGetSiteWalletPendingReleaseExcludesOpenDispute(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "pend_dsp", "待释放纠纷站", "penddsp.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + past := now.Add(-time.Minute) + order := model.RentalOrder{ + OrderNo: "ORD-PEND-DSP", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", 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, 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: 1400, + }).Error; err != nil { + t.Fatal(err) + } + + walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatal(err) + } + if walletDTO.PendingReleaseCent != 1400 { + t.Fatalf("pending release = %d, want 1400", walletDTO.PendingReleaseCent) + } + + if err := db.Create(&model.Dispute{ + OrderID: order.ID, InitiatorID: 4, TargetUserID: 3, Type: "cannot_login", Status: "open", + }).Error; err != nil { + t.Fatal(err) + } + walletDTO, err = repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatal(err) + } + if walletDTO.PendingReleaseCent != 0 { + t.Fatalf("pending release with open dispute = %d, want 0", walletDTO.PendingReleaseCent) + } +} diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 44fe375..624bd8f 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -57,7 +57,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che return nil, err } if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { - _ = distribution.ApplyRefundTx(tx, order.ID, 0) + if err := distribution.ApplyRefundTx(tx, order.ID, 0); err != nil { + return nil, err + } } return refund, nil diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index ba7a4f7..c4197e4 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -30,6 +30,9 @@ type Entry struct { BizType string BizNo string Remark string + // AllowNegative 仅用于结算回冲等追偿场景:余额不足时允许扣成负数, + // 由后续收益抵扣,避免回冲失败导致账实不一致。 + AllowNegative bool } func NewRepository(db *gorm.DB) *Repository { @@ -240,7 +243,7 @@ func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) { if entry.Direction == "in" { account.AvailableBalanceCent += entry.AmountCent } else { - if account.AvailableBalanceCent < entry.AmountCent { + if account.AvailableBalanceCent < entry.AmountCent && !entry.AllowNegative { return 0, ErrInsufficientBalance } account.AvailableBalanceCent -= entry.AmountCent diff --git a/backend/migrations/000069_distribution_settlement_release.sql b/backend/migrations/000069_distribution_settlement_release.sql new file mode 100644 index 0000000..be5120b --- /dev/null +++ b/backend/migrations/000069_distribution_settlement_release.sql @@ -0,0 +1,30 @@ +-- +goose Up + +-- 分销站点结算冷却期:订单完成后 N 天自动释放到可提现余额,0 表示立即释放。 +ALTER TABLE distribution_sites + ADD COLUMN settlement_hold_days INT NOT NULL DEFAULT 7 COMMENT '订单完成后结算释放冷却天数,0=立即释放'; + +-- 财务快照记录释放时间:release_at 为订单完成时间 + 站点冷却期,released_at 为实际入账时间。 +ALTER TABLE distribution_order_financials + ADD COLUMN release_at DATETIME(6) NULL COMMENT '预计释放时间(订单完成时间+站点冷却期)', + ADD COLUMN released_at DATETIME(6) NULL COMMENT '实际释放入账时间', + ADD KEY idx_distribution_financial_release (settlement_status, release_at); + +-- 回填历史已完成订单的释放时间,使其纳入自动释放。 +UPDATE distribution_order_financials f +JOIN rental_orders ro ON ro.id = f.order_id +JOIN distribution_sites s ON s.id = f.site_id +SET f.release_at = DATE_ADD(COALESCE(ro.settled_at, f.created_at), INTERVAL s.settlement_hold_days DAY) +WHERE f.settlement_status = 'pending' + AND f.release_at IS NULL + AND ro.status = 'completed'; + +-- +goose Down + +ALTER TABLE distribution_order_financials + DROP KEY idx_distribution_financial_release, + DROP COLUMN released_at, + DROP COLUMN release_at; + +ALTER TABLE distribution_sites + DROP COLUMN settlement_hold_days; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 23c80ba..767cf9e 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -152,6 +152,8 @@ export interface DistributionOrder { handoff_status?: string refund_status: string settlement_status: string + distributor_settlement_status?: string + release_at?: string distribution_mode: string gross_paid_amount_cent?: number refund_amount_cent?: number @@ -504,6 +506,7 @@ export interface SiteWallet { site_id: number available_balance_cent: number frozen_balance_cent: number + pending_release_cent: number total_settled_cent: number total_withdrawn_cent: number } diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx index 99f9ca1..5d72ae8 100644 --- a/distribution-admin/src/pages/Orders.tsx +++ b/distribution-admin/src/pages/Orders.tsx @@ -67,6 +67,25 @@ const SETTLEMENT_STATUS_MAP: Record = closed: { label: '已关闭', color: 'default' }, } +function settlementTag(row: DistributionOrder): { label: string; color: string } { + if (row.distributor_settlement_status === 'settled') { + return { label: '已释放入账', color: 'success' } + } + if (row.distributor_settlement_status === 'pending' && row.release_at) { + const releaseDate = new Date(row.release_at).toLocaleDateString('zh-CN', { + month: 'numeric', + day: 'numeric', + }) + return { label: `待释放 ${releaseDate}`, color: 'processing' } + } + return ( + SETTLEMENT_STATUS_MAP[row.settlement_status] || { + label: row.settlement_status, + color: 'default', + } + ) +} + const REFUND_STATUS_MAP: Record = { none: { label: '未退款', color: 'default' }, pending: { label: '退款审核中', color: 'orange' }, @@ -455,12 +474,9 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) { }, { title: '结算状态', - width: 110, + width: 130, render: (_: unknown, row: DistributionOrder) => { - const conf = SETTLEMENT_STATUS_MAP[row.settlement_status] || { - label: row.settlement_status, - color: 'default', - } + const conf = settlementTag(row) return {conf.label} }, }, @@ -873,10 +889,10 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
结算状态
- {SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.label || selectedOrder.settlement_status} + {settlementTag(selectedOrder).label} diff --git a/distribution-admin/src/pages/Settlements.tsx b/distribution-admin/src/pages/Settlements.tsx index 0f5a6b6..42d037e 100644 --- a/distribution-admin/src/pages/Settlements.tsx +++ b/distribution-admin/src/pages/Settlements.tsx @@ -166,12 +166,12 @@ export default function Settlements() {
- {isSuperadmin ? '结算管理与批次出账' : '周期结算单与财务对账'} + {isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'} {isSuperadmin - ? '主站财务管理员可按周期创建结算批次并执行打款审核' - : '查看主站定期出具的结算账单,核对订单分成明细与资金入账凭证'} + ? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算' + : '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
{isSuperadmin && sites.length > 1 && ( @@ -191,18 +191,18 @@ export default function Settlements() { {/* Superadmin batch creation controls */} {isSuperadmin && ( - + - 选择结算结算周期: + 选择结算周期: setRange(value)} /> @@ -219,7 +219,7 @@ export default function Settlements() { emptyText: ( ), }} diff --git a/distribution-admin/src/pages/Wallet.tsx b/distribution-admin/src/pages/Wallet.tsx index 968390f..0e7c63a 100644 --- a/distribution-admin/src/pages/Wallet.tsx +++ b/distribution-admin/src/pages/Wallet.tsx @@ -24,6 +24,7 @@ import { DollarCircleOutlined, LockOutlined, CheckCircleOutlined, + HourglassOutlined, } from '@ant-design/icons' import { fetchSiteWallet, @@ -193,7 +194,7 @@ export default function Wallet() {
- + 可提现余额} @@ -204,7 +205,18 @@ export default function Wallet() { /> - + + + } + suffix="元" + valueStyle={{ color: '#1677ff' }} + /> + + + - + - + > ) { diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 993fab5..c828e57 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -1,11 +1,12 @@