feat(distribution): 通道费改主站承担,并按冷却期自动释放结算
利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -67,6 +67,25 @@ const SETTLEMENT_STATUS_MAP: Record<string, { label: string; color: string }> =
|
||||
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<string, { label: string; color: string }> = {
|
||||
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 <Tag color={conf.color}>{conf.label}</Tag>
|
||||
},
|
||||
},
|
||||
@@ -873,10 +889,10 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
|
||||
<Tag
|
||||
color={SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.color || 'default'}
|
||||
color={settlementTag(selectedOrder).color}
|
||||
style={{ fontSize: 13, padding: '2px 8px' }}
|
||||
>
|
||||
{SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.label || selectedOrder.settlement_status}
|
||||
{settlementTag(selectedOrder).label}
|
||||
</Tag>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -166,12 +166,12 @@ export default function Settlements() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
{isSuperadmin ? '结算管理与批次出账' : '周期结算单与财务对账'}
|
||||
{isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{isSuperadmin
|
||||
? '主站财务管理员可按周期创建结算批次并执行打款审核'
|
||||
: '查看主站定期出具的结算账单,核对订单分成明细与资金入账凭证'}
|
||||
? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算'
|
||||
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isSuperadmin && sites.length > 1 && (
|
||||
@@ -191,18 +191,18 @@ export default function Settlements() {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="财务对账与结算说明"
|
||||
description="分销订单在完成履约且双方验收确认后自动归集出账。结算单经主站财务审核打款后,收益将100%直接计入商户钱包可用余额,可随时在「资金与提现」中申请提现。"
|
||||
message="自动结算与对账说明"
|
||||
description="订单完成后进入站点结算冷却期(由主站后台按商户配置,默认 7 天,0 天为立即释放),期满系统自动生成结算批次并将收益释放到「资金与提现」的可提现余额;退款或维权中的订单会暂停释放。"
|
||||
/>
|
||||
|
||||
{/* Superadmin batch creation controls */}
|
||||
{isSuperadmin && (
|
||||
<Card title="管理员批次出账操作" size="small" style={{ background: '#f8fafc' }}>
|
||||
<Card title="管理员手工出账(立即结算)" size="small" style={{ background: '#f8fafc' }}>
|
||||
<Space wrap>
|
||||
<Typography.Text strong>选择结算结算周期:</Typography.Text>
|
||||
<Typography.Text strong>选择结算周期:</Typography.Text>
|
||||
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
|
||||
生成结算账单批次
|
||||
生成结算批次并结算
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -219,7 +219,7 @@ export default function Settlements() {
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无结算账单。当期订单履约完成后将在下一个结算周期出具对账单。"
|
||||
description="暂无结算批次。订单完成冷却期结束后系统会自动出具对账单并入账。"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DollarCircleOutlined,
|
||||
LockOutlined,
|
||||
CheckCircleOutlined,
|
||||
HourglassOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
fetchSiteWallet,
|
||||
@@ -193,7 +194,7 @@ export default function Wallet() {
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Col flex="1">
|
||||
<Card bordered={false} style={{ background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)', color: '#fff' }}>
|
||||
<Statistic
|
||||
title={<span style={{ color: 'rgba(255,255,255,0.85)' }}>可提现余额</span>}
|
||||
@@ -204,7 +205,18 @@ export default function Wallet() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col flex="1">
|
||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||
<Statistic
|
||||
title="待释放收益 (订单完成冷却中)"
|
||||
value={((wallet?.pending_release_cent ?? 0) / 100).toFixed(2)}
|
||||
prefix={<HourglassOutlined style={{ color: '#1677ff' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#1677ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||
<Statistic
|
||||
title="冻结中余额 (提现审核中)"
|
||||
@@ -215,7 +227,7 @@ export default function Wallet() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col flex="1">
|
||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||
<Statistic
|
||||
title="累计已结算收益"
|
||||
@@ -226,7 +238,7 @@ export default function Wallet() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col flex="1">
|
||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||
<Statistic
|
||||
title="累计已成功提现"
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DistributionSite {
|
||||
display_account_enabled: boolean
|
||||
tax_enabled: boolean
|
||||
tax_rate_bps: number
|
||||
settlement_hold_days: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ export interface OnboardDistributionSitePayload {
|
||||
display_account_enabled?: boolean
|
||||
tax_enabled?: boolean
|
||||
tax_rate_bps?: number
|
||||
settlement_hold_days?: number
|
||||
distributor_receive_ratio?: number
|
||||
platform_sell_ratio?: number
|
||||
service_fee_value?: number
|
||||
@@ -129,7 +131,13 @@ export async function updateDistributionSite(
|
||||
payload: Partial<
|
||||
Pick<
|
||||
DistributionSite,
|
||||
'site_name' | 'domain' | 'status' | 'display_account_enabled' | 'tax_enabled' | 'tax_rate_bps'
|
||||
| 'site_name'
|
||||
| 'domain'
|
||||
| 'status'
|
||||
| 'display_account_enabled'
|
||||
| 'tax_enabled'
|
||||
| 'tax_rate_bps'
|
||||
| 'settlement_hold_days'
|
||||
>
|
||||
>
|
||||
) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus, Shop, Link, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
||||
import { Plus, Shop, Link, DocumentCopy, Refresh, Clock } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
onboardDistributionSite,
|
||||
syncDistributionSiteAccounts,
|
||||
updateDistributionSite,
|
||||
type DistributionSite,
|
||||
type OnboardDistributionSitePayload,
|
||||
type OnboardDistributionSiteResult,
|
||||
@@ -23,6 +24,37 @@ const onboardFormRef = ref<FormInstance>()
|
||||
const successDialogVisible = ref(false)
|
||||
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
|
||||
|
||||
const settlementDialogVisible = ref(false)
|
||||
const settlementSubmitting = ref(false)
|
||||
const settlementForm = reactive({
|
||||
site_id: 0,
|
||||
site_name: '',
|
||||
settlement_hold_days: 7,
|
||||
})
|
||||
|
||||
function handleOpenSettlement(row: DistributionSite) {
|
||||
settlementForm.site_id = row.id
|
||||
settlementForm.site_name = row.site_name
|
||||
settlementForm.settlement_hold_days = row.settlement_hold_days ?? 7
|
||||
settlementDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSettlementSubmit() {
|
||||
settlementSubmitting.value = true
|
||||
try {
|
||||
await updateDistributionSite(settlementForm.site_id, {
|
||||
settlement_hold_days: Number(settlementForm.settlement_hold_days),
|
||||
})
|
||||
ElMessage.success(`【${settlementForm.site_name}】结算冷却期已更新`)
|
||||
settlementDialogVisible.value = false
|
||||
await loadSites()
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '更新结算冷却期失败'))
|
||||
} finally {
|
||||
settlementSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onboardForm = reactive({
|
||||
site_name: '',
|
||||
site_code: '',
|
||||
@@ -203,12 +235,19 @@ onMounted(() => {
|
||||
<el-tag v-else type="info" size="small">免税</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结算冷却期" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.settlement_hold_days > 0 ? 'primary' : 'success'" size="small">
|
||||
{{ row.settlement_hold_days > 0 ? `${row.settlement_hold_days} 天` : '立即释放' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_at" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -219,6 +258,9 @@ onMounted(() => {
|
||||
>
|
||||
同步商品
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
|
||||
结算设置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -371,6 +413,42 @@ onMounted(() => {
|
||||
<el-button @click="successDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 结算冷却期设置对话框 -->
|
||||
<el-dialog
|
||||
v-model="settlementDialogVisible"
|
||||
title="结算冷却期设置"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="订单完成后进入冷却期,期满自动释放到商户可提现余额;0 天表示完成即释放。修改后对尚未释放的已完成订单立即生效。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="分销站点">
|
||||
<el-input :model-value="settlementForm.site_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="冷却天数">
|
||||
<el-input-number
|
||||
v-model="settlementForm.settlement_hold_days"
|
||||
:min="0"
|
||||
:max="365"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="settlement-hint">天(0-365)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="settlementDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="settlementSubmitting" @click="handleSettlementSubmit">
|
||||
保存设置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -433,4 +511,10 @@ export default {
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.settlement-hint {
|
||||
margin-left: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user