Files
hfb_sys/backend/internal/modules/distribution/release.go
T
yml2213andCursor 768902f2f7 feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则
退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:13:46 +08:00

229 lines
7.8 KiB
Go

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 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).
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 !orderStatusAllowsRelease(order.Status) || 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
}