feat(distribution): 通道费改主站承担,并按冷却期自动释放结算
利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,7 @@ var (
|
||||
ErrSyncListingNotFound = errors.New("sync listing not found")
|
||||
ErrEmptyListingIDs = errors.New("listing ids required")
|
||||
ErrInvalidListingStatus = errors.New("invalid listing status")
|
||||
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
|
||||
)
|
||||
|
||||
const maxSyncListingIDs = 200
|
||||
@@ -55,6 +56,14 @@ func liveMoneyMovementEnabled() bool {
|
||||
return env != "production" && env != "prod"
|
||||
}
|
||||
|
||||
// normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。
|
||||
func normalizeSettlementHoldDays(days int) (int, error) {
|
||||
if days < 0 || days > 365 {
|
||||
return 0, ErrInvalidSettlementHold
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
@@ -232,6 +241,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if req.SettlementHoldDays != nil {
|
||||
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.SettlementHoldDays = holdDays
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,52 +260,70 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
||||
|
||||
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSiteNotFound
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
||||
}
|
||||
if req.FaviconURL != nil {
|
||||
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
|
||||
}
|
||||
if req.BannersJSON != nil {
|
||||
row.BannersJSON = *req.BannersJSON
|
||||
}
|
||||
if req.AnnouncementsJSON != nil {
|
||||
row.AnnouncementsJSON = *req.AnnouncementsJSON
|
||||
}
|
||||
if req.FooterInfoJSON != nil {
|
||||
row.FooterInfoJSON = *req.FooterInfoJSON
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
holdDaysChanged := false
|
||||
if req.SettlementHoldDays != nil {
|
||||
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
holdDaysChanged = holdDays != row.SettlementHoldDays
|
||||
row.SettlementHoldDays = holdDays
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
||||
}
|
||||
if req.FaviconURL != nil {
|
||||
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
|
||||
}
|
||||
if req.BannersJSON != nil {
|
||||
row.BannersJSON = *req.BannersJSON
|
||||
}
|
||||
if req.AnnouncementsJSON != nil {
|
||||
row.AnnouncementsJSON = *req.AnnouncementsJSON
|
||||
}
|
||||
if req.FooterInfoJSON != nil {
|
||||
row.FooterInfoJSON = *req.FooterInfoJSON
|
||||
}
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if holdDaysChanged {
|
||||
return rewritePendingReleaseAt(tx, row.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
@@ -403,6 +437,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
|
||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||
TaxEnabled: row.TaxEnabled,
|
||||
TaxRateBPS: row.TaxRateBPS,
|
||||
SettlementHoldDays: row.SettlementHoldDays,
|
||||
LogoURL: row.LogoURL,
|
||||
FaviconURL: row.FaviconURL,
|
||||
BannersJSON: row.BannersJSON,
|
||||
@@ -476,6 +511,8 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
|
||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
|
||||
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
|
||||
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
|
||||
f.release_at AS release_at,
|
||||
ro.created_at,
|
||||
COALESCE(rl.listing_no, '') AS listing_no,
|
||||
COALESCE(ga.title, '') AS title,
|
||||
@@ -555,6 +592,8 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
|
||||
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
|
||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
|
||||
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
|
||||
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
|
||||
f.release_at AS release_at,
|
||||
ro.created_at,
|
||||
COALESCE(rl.listing_no, '') AS listing_no,
|
||||
COALESCE(ga.title, '') AS title,
|
||||
@@ -1048,7 +1087,7 @@ func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now
|
||||
}
|
||||
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
||||
Where("order_id = ?", orderID).
|
||||
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
|
||||
Updates(map[string]any{"settlement_status": "settled", "settled_at": now, "released_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1312,6 +1351,14 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
||||
if req.TaxRateBPS != nil {
|
||||
taxRate = *req.TaxRateBPS
|
||||
}
|
||||
holdDays := 7
|
||||
if req.SettlementHoldDays != nil {
|
||||
normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
holdDays = normalized
|
||||
}
|
||||
|
||||
site := model.DistributionSite{
|
||||
SiteCode: req.SiteCode,
|
||||
@@ -1321,6 +1368,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
||||
DisplayAccountEnabled: displayEnabled,
|
||||
TaxEnabled: taxEnabled,
|
||||
TaxRateBPS: taxRate,
|
||||
SettlementHoldDays: holdDays,
|
||||
}
|
||||
if err := tx.Create(&site).Error; err != nil {
|
||||
return err
|
||||
@@ -1891,6 +1939,19 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
|
||||
Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID).
|
||||
Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error
|
||||
|
||||
// 待释放收益:订单已完成、快照待释放、且尚未进入任何结算批次的站内份额。
|
||||
var pendingRelease int64
|
||||
_ = r.db.WithContext(ctx).Table("distribution_order_parties p").
|
||||
Joins("JOIN distribution_order_financials f ON f.order_id = p.order_id").
|
||||
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
|
||||
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
|
||||
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
|
||||
Where("ro.status = 'completed'").
|
||||
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
|
||||
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
|
||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
|
||||
Select("COALESCE(SUM(p.income_amount_cent), 0)").Scan(&pendingRelease).Error
|
||||
|
||||
var totalWithdrawn int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}).
|
||||
Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID).
|
||||
@@ -1900,6 +1961,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
|
||||
SiteID: siteID,
|
||||
AvailableBalanceCent: account.AvailableBalanceCent,
|
||||
FrozenBalanceCent: account.FrozenBalanceCent,
|
||||
PendingReleaseCent: pendingRelease,
|
||||
TotalSettledCent: totalSettled,
|
||||
TotalWithdrawnCent: totalWithdrawn,
|
||||
}, nil
|
||||
|
||||
Reference in New Issue
Block a user