fix(distribution): 人工批次窗口期防退款穿透与退款中途金额单调性加固
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user