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/config"
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/jobs/distributionsettlement"
|
||||||
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
||||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||||
"hfb_sys/backend/internal/jobs/refundretry"
|
"hfb_sys/backend/internal/jobs/refundretry"
|
||||||
@@ -99,6 +100,7 @@ func main() {
|
|||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
||||||
fileuploadcleanup.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 {
|
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
||||||
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
||||||
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
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) {
|
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 "+
|
"SELECT table_name FROM information_schema.tables "+
|
||||||
"WHERE table_schema = DATABASE() AND table_name <> ? AND auto_increment > 1",
|
"WHERE table_schema = DATABASE() AND table_name <> ? AND auto_increment > 1",
|
||||||
schemaMetaTable,
|
schemaMetaTable,
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ func openDistributionE2EDB(t *testing.T) *gorm.DB {
|
|||||||
// 4. 支付成功自动触发快照:
|
// 4. 支付成功自动触发快照:
|
||||||
// - 号主成本 80 元保底;
|
// - 号主成本 80 元保底;
|
||||||
// - 分站自主加价 25 元 100% 归卖出分站;
|
// - 分站自主加价 25 元 100% 归卖出分站;
|
||||||
// - 主站提价净利润(20 元 - 通道费 0.63 元 = 19.37 元)按 35% / 40% / 25% 严丝合缝切分;
|
// - 主站提价利润 20 元(通道费 0.63 元由主站承担)按 35% / 40% / 25% 严丝合缝切分;
|
||||||
// - 卖出分站总获得 32.75 元;
|
// - 卖出分站总获得 33.00 元;
|
||||||
//
|
//
|
||||||
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 32.75 元,财务状态置为 settled。
|
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 33.00 元,财务状态置为 settled。
|
||||||
func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) {
|
func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) {
|
||||||
db := openDistributionE2EDB(t)
|
db := openDistributionE2EDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -201,17 +201,17 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
|
|||||||
if financial.GrossPaidAmountCent != 12500 || financial.MarkupAmountCent != 2500 {
|
if financial.GrossPaidAmountCent != 12500 || financial.MarkupAmountCent != 2500 {
|
||||||
t.Fatalf("financial snapshot error: gross=%d, markup=%d", financial.GrossPaidAmountCent, financial.MarkupAmountCent)
|
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 {
|
if financial.PaymentFeeCent != 63 {
|
||||||
t.Fatalf("payment fee = %d, want 63", financial.PaymentFeeCent)
|
t.Fatalf("payment fee = %d, want 63", financial.PaymentFeeCent)
|
||||||
}
|
}
|
||||||
// 主站提价总额 10000 - 8000 = 2000 分,扣除通道费 63 分后利润基数 = 1937 分
|
// 主站提价总额 10000 - 8000 = 2000 分,通道费不扣减,利润基数 = 2000 分
|
||||||
if financial.SplitBaseAmountCent != 1937 {
|
if financial.SplitBaseAmountCent != 2000 {
|
||||||
t.Fatalf("split base amount = %d, want 1937", financial.SplitBaseAmountCent)
|
t.Fatalf("split base amount = %d, want 2000", financial.SplitBaseAmountCent)
|
||||||
}
|
}
|
||||||
// 平台服务费 25%:1937 * 25% = 484 分
|
// 平台服务费 25%:2000 * 25% = 500 分
|
||||||
if financial.ServiceFeeCent != 484 {
|
if financial.ServiceFeeCent != 500 {
|
||||||
t.Fatalf("service fee = %d, want 484", financial.ServiceFeeCent)
|
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,卖方)
|
// 参与方 0:distributor_sell(分站 A,卖方)
|
||||||
// 收益 = 分站自主加价 2500 分 + 提价分成 40% (1937 * 40% = 775) = 3275 分
|
// 收益 = 分站自主加价 2500 分 + 提价分成 40% (2000 * 40% = 800) = 3300 分
|
||||||
var distributorParty, platformParty model.DistributionOrderParty
|
var distributorParty, platformParty model.DistributionOrderParty
|
||||||
for _, p := range parties {
|
for _, p := range parties {
|
||||||
if p.PartyRole == "distributor_sell" {
|
if p.PartyRole == "distributor_sell" {
|
||||||
@@ -230,15 +230,15 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
|
|||||||
platformParty = p
|
platformParty = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if distributorParty.IncomeAmountCent != 3275 || distributorParty.PartyID != siteA.ID {
|
if distributorParty.IncomeAmountCent != 3300 || distributorParty.PartyID != siteA.ID {
|
||||||
t.Fatalf("distributor_sell party error: income=%d (want 3275), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
|
t.Fatalf("distributor_sell party error: income=%d (want 3300), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
|
||||||
}
|
}
|
||||||
// 参与方 1:platform_receive(平台收号方,35% 分成:1937 * 35% = 678 分)
|
// 参与方 1:platform_receive(平台收号方,35% 分成:2000 * 35% = 700 分)
|
||||||
if platformParty.IncomeAmountCent != 678 {
|
if platformParty.IncomeAmountCent != 700 {
|
||||||
t.Fatalf("platform_receive party error: income=%d (want 678)", platformParty.IncomeAmountCent)
|
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 {
|
if financial.RoundingAdjustmentCent != 0 {
|
||||||
t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent)
|
t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent)
|
||||||
}
|
}
|
||||||
@@ -257,8 +257,8 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateSettlementBatch failed: %v", err)
|
t.Fatalf("CreateSettlementBatch failed: %v", err)
|
||||||
}
|
}
|
||||||
if batch.TotalAmountCent != 3275 {
|
if batch.TotalAmountCent != 3300 {
|
||||||
t.Fatalf("batch total amount = %d, want 3275", batch.TotalAmountCent)
|
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 {
|
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)
|
t.Fatalf("find site wallet failed: %v", err)
|
||||||
}
|
}
|
||||||
if siteWallet.AvailableBalanceCent != 3275 {
|
if siteWallet.AvailableBalanceCent != 3300 {
|
||||||
t.Fatalf("site wallet balance = %d, want 3275", siteWallet.AvailableBalanceCent)
|
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 {
|
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)
|
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)
|
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 分 (80.00元)", 8000)
|
||||||
t.Logf("分站自主加价(独享): %d 分 (25.00元)", shelfMarkupCent)
|
t.Logf("分站自主加价(独享): %d 分 (25.00元)", shelfMarkupCent)
|
||||||
t.Logf("支付通道手续费(0.5%%): %d 分 (0.63元)", financial.PaymentFeeCent)
|
t.Logf("支付通道手续费(0.5%%): %d 分 (0.63元)", financial.PaymentFeeCent)
|
||||||
t.Logf("主站提价净利润: %d 分 (19.37元)", financial.SplitBaseAmountCent)
|
t.Logf("主站提价净利润(主站承担通道费): %d 分 (20.00元)", financial.SplitBaseAmountCent)
|
||||||
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (6.78元)", platformParty.IncomeAmountCent)
|
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (7.00元)", platformParty.IncomeAmountCent)
|
||||||
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (7.75元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
|
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (8.00元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
|
||||||
t.Logf(" └─ 平台服务费(25%%): %d 分 (4.84元)", financial.ServiceFeeCent)
|
t.Logf(" └─ 平台服务费(25%%): %d 分 (5.00元)", financial.ServiceFeeCent)
|
||||||
t.Logf("分站 A 最终结算到账: %d 分 (32.75元) [加价25元 + 分成7.75元]", siteWallet.AvailableBalanceCent)
|
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"`
|
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
||||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
SplitBaseAmountCent int64 `gorm:"not null;default:0" json:"split_base_amount_cent"`
|
||||||
RoundingAdjustmentCent int64 `gorm:"not null;default:0" json:"rounding_adjustment_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"`
|
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"`
|
SettledAt *time.Time `json:"settled_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/logging"
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/adminnotification"
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
|
"hfb_sys/backend/internal/modules/distribution"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
"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 {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Save(&listing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type SiteDTO struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||||
TaxEnabled bool `json:"tax_enabled"`
|
TaxEnabled bool `json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||||
|
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ type CreateSiteRequest struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateSiteRequest struct {
|
type UpdateSiteRequest struct {
|
||||||
@@ -39,6 +41,7 @@ type UpdateSiteRequest struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
LogoURL *string `json:"logo_url"`
|
LogoURL *string `json:"logo_url"`
|
||||||
FaviconURL *string `json:"favicon_url"`
|
FaviconURL *string `json:"favicon_url"`
|
||||||
BannersJSON *string `json:"banners_json"`
|
BannersJSON *string `json:"banners_json"`
|
||||||
@@ -102,6 +105,11 @@ type DistributionOrderDTO struct {
|
|||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
RefundStatus string `json:"refund_status"`
|
RefundStatus string `json:"refund_status"`
|
||||||
SettlementStatus string `json:"settlement_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"`
|
DistributionMode string `json:"distribution_mode"`
|
||||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||||
@@ -318,6 +326,7 @@ type OnboardSiteRequest struct {
|
|||||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"`
|
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"`
|
||||||
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
|
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
|
||||||
AdminNickname string `json:"admin_nickname"`
|
AdminNickname string `json:"admin_nickname"`
|
||||||
@@ -403,6 +412,7 @@ type SiteWalletDTO struct {
|
|||||||
SiteID uint64 `json:"site_id"`
|
SiteID uint64 `json:"site_id"`
|
||||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||||
|
PendingReleaseCent int64 `json:"pending_release_cent"`
|
||||||
TotalSettledCent int64 `json:"total_settled_cent"`
|
TotalSettledCent int64 `json:"total_settled_cent"`
|
||||||
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
|
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
|
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
|
||||||
@@ -172,7 +174,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
|
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
|
||||||
// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
|
// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
|
||||||
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
|
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
|
||||||
if !distributionTablesPresent(tx) {
|
if !distributionTablesPresent(tx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -206,6 +208,14 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
|||||||
}
|
}
|
||||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
|
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"}
|
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 {
|
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -246,7 +256,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var financial model.DistributionOrderFinancial
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -327,6 +337,15 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
|
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{
|
if err := tx.Model(&financial).Updates(map[string]any{
|
||||||
"refund_amount_cent": rentRefundCent,
|
"refund_amount_cent": rentRefundCent,
|
||||||
"net_paid_amount_cent": result.NetPaidAmountCent,
|
"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,
|
"service_fee_cent": result.ServiceFeeCent,
|
||||||
"split_base_amount_cent": result.SplitBaseAmountCent,
|
"split_base_amount_cent": result.SplitBaseAmountCent,
|
||||||
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
|
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
|
||||||
"settlement_status": "pending",
|
"settlement_status": settlementStatus,
|
||||||
|
"release_at": releaseAt,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, split := range splits {
|
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{}).
|
if err := tx.Model(&model.DistributionOrderParty{}).
|
||||||
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
|
Where("id = ?", party.ID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"income_amount_cent": split.AmountCent,
|
"income_amount_cent": split.AmountCent,
|
||||||
"profit_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
|
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 {
|
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
|
||||||
t.Fatalf("unexpected financial snapshot: %+v", financial)
|
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)
|
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 {
|
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)
|
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])
|
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])
|
t.Fatalf("platform party = %+v", parties[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,10 +393,10 @@ func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
|
|||||||
if seller == nil || platform == nil {
|
if seller == nil || platform == nil {
|
||||||
t.Fatalf("missing parties: %+v", parties)
|
t.Fatalf("missing parties: %+v", parties)
|
||||||
}
|
}
|
||||||
if seller.IncomeAmountCent != 1580 {
|
if seller.IncomeAmountCent != 1600 {
|
||||||
t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent)
|
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)
|
t.Fatalf("platform receive = %+v", platform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,15 +460,15 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
|
|||||||
if financial.NetPaidAmountCent != 53330 {
|
if financial.NetPaidAmountCent != 53330 {
|
||||||
t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent)
|
t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent)
|
||||||
}
|
}
|
||||||
if financial.SplitBaseAmountCent != 6913 {
|
if financial.SplitBaseAmountCent != 7180 {
|
||||||
t.Fatalf("financial.SplitBaseAmountCent = %d, want 6913", financial.SplitBaseAmountCent)
|
t.Fatalf("financial.SplitBaseAmountCent = %d, want 7180", financial.SplitBaseAmountCent)
|
||||||
}
|
}
|
||||||
|
|
||||||
var distributorParty model.DistributionOrderParty
|
var distributorParty model.DistributionOrderParty
|
||||||
if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil {
|
if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if distributorParty.IncomeAmountCent != 2420 {
|
if distributorParty.IncomeAmountCent != 2513 {
|
||||||
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2420", distributorParty.IncomeAmountCent)
|
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) {
|
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 {
|
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
|
||||||
return SettlementResult{}, ErrInvalidAmount
|
return SettlementResult{}, ErrInvalidAmount
|
||||||
@@ -146,7 +147,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
|
|||||||
}
|
}
|
||||||
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
||||||
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
||||||
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent - paymentFee
|
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent
|
||||||
if profit < 0 {
|
if profit < 0 {
|
||||||
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}
|
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
|
||||||
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000})
|
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// 通道费 50,利润 10000-6000-50=3950,服务费 25%=988
|
// 通道费 50 由主站承担(仅记录),利润 10000-6000=4000,服务费 25%=1000
|
||||||
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 {
|
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 4000 || got.ServiceFeeCent != 1000 {
|
||||||
t.Fatalf("unexpected settlement: %#v", got)
|
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")
|
ErrSyncListingNotFound = errors.New("sync listing not found")
|
||||||
ErrEmptyListingIDs = errors.New("listing ids required")
|
ErrEmptyListingIDs = errors.New("listing ids required")
|
||||||
ErrInvalidListingStatus = errors.New("invalid listing status")
|
ErrInvalidListingStatus = errors.New("invalid listing status")
|
||||||
|
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSyncListingIDs = 200
|
const maxSyncListingIDs = 200
|
||||||
@@ -55,6 +56,14 @@ func liveMoneyMovementEnabled() bool {
|
|||||||
return env != "production" && env != "prod"
|
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 {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
@@ -232,6 +241,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
|||||||
if req.TaxRateBPS != nil {
|
if req.TaxRateBPS != nil {
|
||||||
row.TaxRateBPS = *req.TaxRateBPS
|
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 {
|
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -244,14 +260,15 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
|||||||
|
|
||||||
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||||
var row model.DistributionSite
|
var row model.DistributionSite
|
||||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrSiteNotFound
|
return ErrSiteNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if req.SiteName != nil {
|
if req.SiteName != nil {
|
||||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||||
@@ -274,6 +291,15 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
|
|||||||
if req.TaxRateBPS != nil {
|
if req.TaxRateBPS != nil {
|
||||||
row.TaxRateBPS = *req.TaxRateBPS
|
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 {
|
if req.LogoURL != nil {
|
||||||
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
||||||
}
|
}
|
||||||
@@ -289,7 +315,15 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
|
|||||||
if req.FooterInfoJSON != nil {
|
if req.FooterInfoJSON != nil {
|
||||||
row.FooterInfoJSON = *req.FooterInfoJSON
|
row.FooterInfoJSON = *req.FooterInfoJSON
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
item := siteDTO(row)
|
item := siteDTO(row)
|
||||||
@@ -403,6 +437,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
|
|||||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||||
TaxEnabled: row.TaxEnabled,
|
TaxEnabled: row.TaxEnabled,
|
||||||
TaxRateBPS: row.TaxRateBPS,
|
TaxRateBPS: row.TaxRateBPS,
|
||||||
|
SettlementHoldDays: row.SettlementHoldDays,
|
||||||
LogoURL: row.LogoURL,
|
LogoURL: row.LogoURL,
|
||||||
FaviconURL: row.FaviconURL,
|
FaviconURL: row.FaviconURL,
|
||||||
BannersJSON: row.BannersJSON,
|
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.tax_amount_cent, 0) AS tax_amount_cent,
|
||||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_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.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,
|
ro.created_at,
|
||||||
COALESCE(rl.listing_no, '') AS listing_no,
|
COALESCE(rl.listing_no, '') AS listing_no,
|
||||||
COALESCE(ga.title, '') AS title,
|
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.tax_amount_cent, 0) AS tax_amount_cent,
|
||||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_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.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,
|
ro.created_at,
|
||||||
COALESCE(rl.listing_no, '') AS listing_no,
|
COALESCE(rl.listing_no, '') AS listing_no,
|
||||||
COALESCE(ga.title, '') AS title,
|
COALESCE(ga.title, '') AS title,
|
||||||
@@ -1048,7 +1087,7 @@ func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now
|
|||||||
}
|
}
|
||||||
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
||||||
Where("order_id = ?", orderID).
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1312,6 +1351,14 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
|||||||
if req.TaxRateBPS != nil {
|
if req.TaxRateBPS != nil {
|
||||||
taxRate = *req.TaxRateBPS
|
taxRate = *req.TaxRateBPS
|
||||||
}
|
}
|
||||||
|
holdDays := 7
|
||||||
|
if req.SettlementHoldDays != nil {
|
||||||
|
normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
holdDays = normalized
|
||||||
|
}
|
||||||
|
|
||||||
site := model.DistributionSite{
|
site := model.DistributionSite{
|
||||||
SiteCode: req.SiteCode,
|
SiteCode: req.SiteCode,
|
||||||
@@ -1321,6 +1368,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
|||||||
DisplayAccountEnabled: displayEnabled,
|
DisplayAccountEnabled: displayEnabled,
|
||||||
TaxEnabled: taxEnabled,
|
TaxEnabled: taxEnabled,
|
||||||
TaxRateBPS: taxRate,
|
TaxRateBPS: taxRate,
|
||||||
|
SettlementHoldDays: holdDays,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&site).Error; err != nil {
|
if err := tx.Create(&site).Error; err != nil {
|
||||||
return err
|
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).
|
Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID).
|
||||||
Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error
|
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
|
var totalWithdrawn int64
|
||||||
_ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}).
|
_ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}).
|
||||||
Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID).
|
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,
|
SiteID: siteID,
|
||||||
AvailableBalanceCent: account.AvailableBalanceCent,
|
AvailableBalanceCent: account.AvailableBalanceCent,
|
||||||
FrozenBalanceCent: account.FrozenBalanceCent,
|
FrozenBalanceCent: account.FrozenBalanceCent,
|
||||||
|
PendingReleaseCent: pendingRelease,
|
||||||
TotalSettledCent: totalSettled,
|
TotalSettledCent: totalSettled,
|
||||||
TotalWithdrawnCent: totalWithdrawn,
|
TotalWithdrawnCent: totalWithdrawn,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
|
|||||||
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
|
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
|
||||||
&model.RentalListing{}, &model.GameAccount{}, &model.User{},
|
&model.RentalListing{}, &model.GameAccount{}, &model.User{},
|
||||||
&model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{},
|
&model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{},
|
||||||
|
&model.Dispute{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -984,3 +985,93 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
|
|||||||
t.Fatalf("withdraw err = %v", err)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
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
|
return refund, nil
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type Entry struct {
|
|||||||
BizType string
|
BizType string
|
||||||
BizNo string
|
BizNo string
|
||||||
Remark string
|
Remark string
|
||||||
|
// AllowNegative 仅用于结算回冲等追偿场景:余额不足时允许扣成负数,
|
||||||
|
// 由后续收益抵扣,避免回冲失败导致账实不一致。
|
||||||
|
AllowNegative bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB) *Repository {
|
||||||
@@ -240,7 +243,7 @@ func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) {
|
|||||||
if entry.Direction == "in" {
|
if entry.Direction == "in" {
|
||||||
account.AvailableBalanceCent += entry.AmountCent
|
account.AvailableBalanceCent += entry.AmountCent
|
||||||
} else {
|
} else {
|
||||||
if account.AvailableBalanceCent < entry.AmountCent {
|
if account.AvailableBalanceCent < entry.AmountCent && !entry.AllowNegative {
|
||||||
return 0, ErrInsufficientBalance
|
return 0, ErrInsufficientBalance
|
||||||
}
|
}
|
||||||
account.AvailableBalanceCent -= entry.AmountCent
|
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
|
handoff_status?: string
|
||||||
refund_status: string
|
refund_status: string
|
||||||
settlement_status: string
|
settlement_status: string
|
||||||
|
distributor_settlement_status?: string
|
||||||
|
release_at?: string
|
||||||
distribution_mode: string
|
distribution_mode: string
|
||||||
gross_paid_amount_cent?: number
|
gross_paid_amount_cent?: number
|
||||||
refund_amount_cent?: number
|
refund_amount_cent?: number
|
||||||
@@ -504,6 +506,7 @@ export interface SiteWallet {
|
|||||||
site_id: number
|
site_id: number
|
||||||
available_balance_cent: number
|
available_balance_cent: number
|
||||||
frozen_balance_cent: number
|
frozen_balance_cent: number
|
||||||
|
pending_release_cent: number
|
||||||
total_settled_cent: number
|
total_settled_cent: number
|
||||||
total_withdrawn_cent: number
|
total_withdrawn_cent: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ const SETTLEMENT_STATUS_MAP: Record<string, { label: string; color: string }> =
|
|||||||
closed: { label: '已关闭', color: 'default' },
|
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 }> = {
|
const REFUND_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
none: { label: '未退款', color: 'default' },
|
none: { label: '未退款', color: 'default' },
|
||||||
pending: { label: '退款审核中', color: 'orange' },
|
pending: { label: '退款审核中', color: 'orange' },
|
||||||
@@ -455,12 +474,9 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结算状态',
|
title: '结算状态',
|
||||||
width: 110,
|
width: 130,
|
||||||
render: (_: unknown, row: DistributionOrder) => {
|
render: (_: unknown, row: DistributionOrder) => {
|
||||||
const conf = SETTLEMENT_STATUS_MAP[row.settlement_status] || {
|
const conf = settlementTag(row)
|
||||||
label: row.settlement_status,
|
|
||||||
color: 'default',
|
|
||||||
}
|
|
||||||
return <Tag color={conf.color}>{conf.label}</Tag>
|
return <Tag color={conf.color}>{conf.label}</Tag>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -873,10 +889,10 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
|||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
|
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
|
||||||
<Tag
|
<Tag
|
||||||
color={SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.color || 'default'}
|
color={settlementTag(selectedOrder).color}
|
||||||
style={{ fontSize: 13, padding: '2px 8px' }}
|
style={{ fontSize: 13, padding: '2px 8px' }}
|
||||||
>
|
>
|
||||||
{SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.label || selectedOrder.settlement_status}
|
{settlementTag(selectedOrder).label}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -166,12 +166,12 @@ export default function Settlements() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||||
{isSuperadmin ? '结算管理与批次出账' : '周期结算单与财务对账'}
|
{isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{isSuperadmin
|
{isSuperadmin
|
||||||
? '主站财务管理员可按周期创建结算批次并执行打款审核'
|
? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算'
|
||||||
: '查看主站定期出具的结算账单,核对订单分成明细与资金入账凭证'}
|
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
{isSuperadmin && sites.length > 1 && (
|
{isSuperadmin && sites.length > 1 && (
|
||||||
@@ -191,18 +191,18 @@ export default function Settlements() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="财务对账与结算说明"
|
message="自动结算与对账说明"
|
||||||
description="分销订单在完成履约且双方验收确认后自动归集出账。结算单经主站财务审核打款后,收益将100%直接计入商户钱包可用余额,可随时在「资金与提现」中申请提现。"
|
description="订单完成后进入站点结算冷却期(由主站后台按商户配置,默认 7 天,0 天为立即释放),期满系统自动生成结算批次并将收益释放到「资金与提现」的可提现余额;退款或维权中的订单会暂停释放。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Superadmin batch creation controls */}
|
{/* Superadmin batch creation controls */}
|
||||||
{isSuperadmin && (
|
{isSuperadmin && (
|
||||||
<Card title="管理员批次出账操作" size="small" style={{ background: '#f8fafc' }}>
|
<Card title="管理员手工出账(立即结算)" size="small" style={{ background: '#f8fafc' }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Typography.Text strong>选择结算结算周期:</Typography.Text>
|
<Typography.Text strong>选择结算周期:</Typography.Text>
|
||||||
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
|
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
|
||||||
生成结算账单批次
|
生成结算批次并结算
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -219,7 +219,7 @@ export default function Settlements() {
|
|||||||
emptyText: (
|
emptyText: (
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description="暂无结算账单。当期订单履约完成后将在下一个结算周期出具对账单。"
|
description="暂无结算批次。订单完成冷却期结束后系统会自动出具对账单并入账。"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DollarCircleOutlined,
|
DollarCircleOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
HourglassOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
fetchSiteWallet,
|
fetchSiteWallet,
|
||||||
@@ -193,7 +194,7 @@ export default function Wallet() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={6}>
|
<Col flex="1">
|
||||||
<Card bordered={false} style={{ background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)', color: '#fff' }}>
|
<Card bordered={false} style={{ background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)', color: '#fff' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<span style={{ color: 'rgba(255,255,255,0.85)' }}>可提现余额</span>}
|
title={<span style={{ color: 'rgba(255,255,255,0.85)' }}>可提现余额</span>}
|
||||||
@@ -204,7 +205,18 @@ export default function Wallet() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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' }}>
|
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="冻结中余额 (提现审核中)"
|
title="冻结中余额 (提现审核中)"
|
||||||
@@ -215,7 +227,7 @@ export default function Wallet() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col flex="1">
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="累计已结算收益"
|
title="累计已结算收益"
|
||||||
@@ -226,7 +238,7 @@ export default function Wallet() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col flex="1">
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }}>
|
<Card bordered={false} style={{ background: '#fafafa' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="累计已成功提现"
|
title="累计已成功提现"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface DistributionSite {
|
|||||||
display_account_enabled: boolean
|
display_account_enabled: boolean
|
||||||
tax_enabled: boolean
|
tax_enabled: boolean
|
||||||
tax_rate_bps: number
|
tax_rate_bps: number
|
||||||
|
settlement_hold_days: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export interface OnboardDistributionSitePayload {
|
|||||||
display_account_enabled?: boolean
|
display_account_enabled?: boolean
|
||||||
tax_enabled?: boolean
|
tax_enabled?: boolean
|
||||||
tax_rate_bps?: number
|
tax_rate_bps?: number
|
||||||
|
settlement_hold_days?: number
|
||||||
distributor_receive_ratio?: number
|
distributor_receive_ratio?: number
|
||||||
platform_sell_ratio?: number
|
platform_sell_ratio?: number
|
||||||
service_fee_value?: number
|
service_fee_value?: number
|
||||||
@@ -129,7 +131,13 @@ export async function updateDistributionSite(
|
|||||||
payload: Partial<
|
payload: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
DistributionSite,
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
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 {
|
import {
|
||||||
fetchDistributionSites,
|
fetchDistributionSites,
|
||||||
onboardDistributionSite,
|
onboardDistributionSite,
|
||||||
syncDistributionSiteAccounts,
|
syncDistributionSiteAccounts,
|
||||||
|
updateDistributionSite,
|
||||||
type DistributionSite,
|
type DistributionSite,
|
||||||
type OnboardDistributionSitePayload,
|
type OnboardDistributionSitePayload,
|
||||||
type OnboardDistributionSiteResult,
|
type OnboardDistributionSiteResult,
|
||||||
@@ -23,6 +24,37 @@ const onboardFormRef = ref<FormInstance>()
|
|||||||
const successDialogVisible = ref(false)
|
const successDialogVisible = ref(false)
|
||||||
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
|
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({
|
const onboardForm = reactive({
|
||||||
site_name: '',
|
site_name: '',
|
||||||
site_code: '',
|
site_code: '',
|
||||||
@@ -203,12 +235,19 @@ onMounted(() => {
|
|||||||
<el-tag v-else type="info" size="small">免税</el-tag>
|
<el-tag v-else type="info" size="small">免税</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="创建时间" prop="created_at" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatDateTime(row.created_at) }}
|
{{ formatDateTime(row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" fixed="right">
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -219,6 +258,9 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
同步商品
|
同步商品
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
|
||||||
|
结算设置
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -371,6 +413,42 @@ onMounted(() => {
|
|||||||
<el-button @click="successDialogVisible = false">关闭</el-button>
|
<el-button @click="successDialogVisible = false">关闭</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -433,4 +511,10 @@ export default {
|
|||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px dashed #e5e7eb;
|
border-bottom: 1px dashed #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settlement-hint {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user