fix(distribution): 人工批次窗口期防退款穿透与退款中途金额单调性加固

This commit is contained in:
yml2213
2026-09-10 19:39:27 +08:00
parent 36ebe7a5f1
commit 140c538d4f
5 changed files with 318 additions and 17 deletions
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
@@ -47,6 +48,7 @@ var (
ErrInvalidListingStatus = errors.New("invalid listing status")
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled")
ErrSettlementBatchStale = errors.New("distribution settlement batch stale")
)
var settlementReadyOrderStatuses = []string{"completed", "closed"}
@@ -1019,6 +1021,8 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = 'pending'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd).
Order("f.order_id ASC").
Scan(&pendingOrderIDs).Error; err != nil {
@@ -1033,7 +1037,7 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
}
}
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
// 仅纳入已完结且未处于退款中/维权中的订单,与自动释放口径对齐。
var parties []model.DistributionOrderParty
if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
@@ -1043,6 +1047,8 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = 'pending'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd),
).
Order("id ASC").
@@ -1099,12 +1105,74 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
if total != row.TotalAmountCent {
return ErrSettlementTotalMismatch
}
// 复核批次内每个参与方与订单的最新实时状态,确保未发生退款变更、无进行中退款/维权:
orderIDs := uniqueSettlementOrderIDs(items)
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 orders []model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", orderIDs).
Order("id ASC").Find(&orders).Error; err != nil {
return err
}
if len(orders) != len(orderIDs) {
return ErrSettlementBatchStale
}
orderMap := make(map[uint64]model.RentalOrder, len(orders))
for _, o := range orders {
orderMap[o.ID] = o
}
var disputedCount int64
if err := tx.Table("disputes").
Where("order_id IN ? AND status IN ?", orderIDs, autoReleaseBlockedDisputeStatuses).
Count(&disputedCount).Error; err != nil {
return err
}
if disputedCount > 0 {
return ErrSettlementBatchStale
}
partyIDs := make([]uint64, 0, len(items))
for _, it := range items {
partyIDs = append(partyIDs, it.PartyID)
}
var parties []model.DistributionOrderParty
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", partyIDs).
Order("id ASC").Find(&parties).Error; err != nil {
return err
}
if len(parties) != len(partyIDs) {
return ErrSettlementBatchStale
}
partyMap := make(map[uint64]model.DistributionOrderParty, len(parties))
for _, p := range parties {
partyMap[p.ID] = p
}
for _, item := range items {
order, ok := orderMap[item.OrderID]
if !ok || !orderStatusAllowsRelease(order.Status) || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
return ErrSettlementBatchStale
}
party, ok := partyMap[item.PartyID]
if !ok || party.IncomeAmountCent != item.AmountCent {
return ErrSettlementBatchStale
}
}
now := time.Now()
if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID).
Update("status", "settled").Error; err != nil {
return err
}
orderIDs := uniqueSettlementOrderIDs(items)
if err := markFinancialsSettledIfSitePartiesDone(tx, orderIDs, now); err != nil {
return err
}