fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试
- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账 - 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化 - 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小 - 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx - 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣 - 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数 - 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间) - 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
package distributionsettlement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/distribution"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupSettlementJobTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := database.AutoMigrateIfMissing(db,
|
||||||
|
&model.AdminUser{}, &model.RentalListing{}, &model.GameAccount{}, &model.User{},
|
||||||
|
&model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{},
|
||||||
|
&model.DistributionOrderFinancial{}, &model.DistributionOrderParty{},
|
||||||
|
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
|
||||||
|
&model.WalletAccount{}, &model.WalletLedger{}, &model.Dispute{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunReleasesDueSettlementOnce 断言结算 job 会把到期订单入账到站点钱包并置财务快照为 settled,
|
||||||
|
// 且重复执行不会重复入账(幂等)。
|
||||||
|
func TestRunReleasesDueSettlementOnce(t *testing.T) {
|
||||||
|
db := setupSettlementJobTestDB(t)
|
||||||
|
job := New(db, nil, zap.NewNop())
|
||||||
|
now := time.Now()
|
||||||
|
past := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: "job_site", SiteName: "定时结算站", Domain: "job.dist.com",
|
||||||
|
Status: "active", DisplayAccountEnabled: true, SettlementHoldDays: 0,
|
||||||
|
}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rule := model.DistributionRuleVersion{
|
||||||
|
SiteID: &site.ID, Mode: distribution.ModeTwoPartyDistributorReceive, Version: 1, Status: "active",
|
||||||
|
DistributorReceiveRatio: 35, PlatformSellRatio: 40,
|
||||||
|
ServiceFeeUnit: distribution.ServiceFeeUnitFixedAmount, EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.Create(&rule).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-JOB-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)
|
||||||
|
}
|
||||||
|
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: distribution.PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
job.run(t.Context())
|
||||||
|
|
||||||
|
var financial model.DistributionOrderFinancial
|
||||||
|
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if financial.SettlementStatus != "settled" || financial.ReleasedAt == nil {
|
||||||
|
t.Fatalf("财务快照未置为已结算: %+v", financial)
|
||||||
|
}
|
||||||
|
var wallet model.WalletAccount
|
||||||
|
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if wallet.AvailableBalanceCent != 1400 {
|
||||||
|
t.Fatalf("站点钱包 = %d, want 1400", wallet.AvailableBalanceCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次执行不得重复入账。
|
||||||
|
job.run(t.Context())
|
||||||
|
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if wallet.AvailableBalanceCent != 1400 {
|
||||||
|
t.Fatalf("重复执行后站点钱包 = %d, want 1400(幂等)", wallet.AvailableBalanceCent)
|
||||||
|
}
|
||||||
|
var itemCount int64
|
||||||
|
if err := db.Model(&model.DistributionSettlementItem{}).Where("order_id = ?", order.ID).
|
||||||
|
Count(&itemCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if itemCount != 1 {
|
||||||
|
t.Fatalf("结算明细 = %d, want 1", itemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||||
if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil {
|
if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package dispute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createHeldDepositDistributionFixture 构造「押金暂扣 + 分销站点」的仲裁前置数据:
|
||||||
|
// 站点 + 生效规则版本 + 进行中订单(押金 60 元全额暂扣)+ 待结算分销财务快照(毛收入 = 订单租金)。
|
||||||
|
func createHeldDepositDistributionFixture(t *testing.T, db *gorm.DB) (model.DistributionSite, model.DistributionRuleVersion, model.RentalOrder, model.DistributionOrderFinancial) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.AutoMigrateIfMissing(db,
|
||||||
|
&model.DistributionSite{},
|
||||||
|
&model.DistributionRuleVersion{},
|
||||||
|
&model.DistributionOrderFinancial{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("补齐分销表失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: "arb_hold_site",
|
||||||
|
SiteName: "仲裁暂扣站",
|
||||||
|
Domain: "arb-hold.dist.com",
|
||||||
|
Status: "active",
|
||||||
|
DisplayAccountEnabled: true,
|
||||||
|
SettlementHoldDays: 3,
|
||||||
|
}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatalf("创建分销站点失败: %v", err)
|
||||||
|
}
|
||||||
|
rule := model.DistributionRuleVersion{
|
||||||
|
SiteID: &site.ID,
|
||||||
|
Mode: "two_party_distributor_receive",
|
||||||
|
Version: 1,
|
||||||
|
Status: "active",
|
||||||
|
DistributorReceiveRatio: 35,
|
||||||
|
PlatformSellRatio: 40,
|
||||||
|
ServiceFeeValue: 25,
|
||||||
|
ServiceFeeUnit: "percent",
|
||||||
|
PaymentFeeBPS: 50,
|
||||||
|
EffectiveFrom: time.Now().Add(-time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.Create(&rule).Error; err != nil {
|
||||||
|
t.Fatalf("创建规则版本失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||||
|
Status: "renting",
|
||||||
|
HandoffStatus: "received",
|
||||||
|
SettlementStatus: "unsettled",
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
DepositAmountCent: 6000,
|
||||||
|
DepositHoldStatus: model.DepositHoldStatusHeld,
|
||||||
|
DepositHoldAmountCent: 6000,
|
||||||
|
DistributionSiteID: &site.ID,
|
||||||
|
DistributionRuleVersionID: &rule.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
financial := model.DistributionOrderFinancial{
|
||||||
|
OrderID: order.ID,
|
||||||
|
SiteID: site.ID,
|
||||||
|
RuleVersionID: rule.ID,
|
||||||
|
GrossPaidAmountCent: order.RentAmountCent,
|
||||||
|
NetPaidAmountCent: order.RentAmountCent,
|
||||||
|
SplitBaseAmountCent: 2000,
|
||||||
|
SettlementStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := db.Create(&financial).Error; err != nil {
|
||||||
|
t.Fatalf("创建分销财务快照失败: %v", err)
|
||||||
|
}
|
||||||
|
return site, rule, order, financial
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArbitrationDistributionRefundUsesActualRefundWithDepositHold 断言押金暂扣订单仲裁时,
|
||||||
|
// 分销重算使用「实际退款额」(裁决退款扣除被暂扣押金部分),而不是裁决书上的退款额。
|
||||||
|
// 用未扣除的裁决额重算会让分销快照多记退款、net_paid 少记(站点被多扣)。
|
||||||
|
func TestArbitrationDistributionRefundUsesActualRefundWithDepositHold(t *testing.T) {
|
||||||
|
db := setupDisputeTestDB(t)
|
||||||
|
startedRefundCent := int64(-1)
|
||||||
|
repo := NewRepository(db, Dependencies{
|
||||||
|
RefundStarter: RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, _ string, _ string) (string, error) {
|
||||||
|
startedRefundCent = refundAmountCent
|
||||||
|
return "refunded", nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
_, _, order, financial := createHeldDepositDistributionFixture(t, db)
|
||||||
|
|
||||||
|
created, err := repo.Create(t.Context(), order.RenterID, order.ID, CreateRequest{
|
||||||
|
Type: "haf_coin_dispute",
|
||||||
|
Description: "押金暂扣期间申请仲裁全额退款",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建申诉失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.Arbitrate(t.Context(), 9001, created.ID, ArbitrateRequest{
|
||||||
|
Result: "full_refund",
|
||||||
|
Remark: "仲裁全额退款,押金部分暂扣中",
|
||||||
|
}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("仲裁失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裁决额 = 租金 + 押金;押金全额暂扣 → 实际退款 = 裁决额 − 押金。
|
||||||
|
arbitrationRefundCent := order.RentAmountCent + order.DepositAmountCent
|
||||||
|
actualRefundCent := arbitrationRefundCent - order.DepositHoldAmountCent
|
||||||
|
if actualRefundCent >= arbitrationRefundCent {
|
||||||
|
t.Fatalf("fixture 未构造出「实际退款 < 裁决额」:actual=%d arbitration=%d", actualRefundCent, arbitrationRefundCent)
|
||||||
|
}
|
||||||
|
if startedRefundCent != actualRefundCent {
|
||||||
|
t.Fatalf("实际发起退款 = %d, want %d(裁决额 %d 扣除被暂扣押金 %d)",
|
||||||
|
startedRefundCent, actualRefundCent, arbitrationRefundCent, order.DepositHoldAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedOrder model.RentalOrder
|
||||||
|
if err := db.First(&savedOrder, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
if savedOrder.RefundStatus != "pending" || savedOrder.RefundAmountCent != actualRefundCent {
|
||||||
|
t.Fatalf("订单退款 = %s/%d, want pending/%d", savedOrder.RefundStatus, savedOrder.RefundAmountCent, actualRefundCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 押金退款不计入分销毛收入:实际退款先抵扣押金,剩余才是租金退款。
|
||||||
|
wantRentRefundCent := actualRefundCent - order.DepositAmountCent
|
||||||
|
unheldRentRefundCent := arbitrationRefundCent - order.DepositAmountCent
|
||||||
|
var snapshot model.DistributionOrderFinancial
|
||||||
|
if err := db.First(&snapshot, financial.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取分销快照失败: %v", err)
|
||||||
|
}
|
||||||
|
if snapshot.RefundAmountCent != wantRentRefundCent {
|
||||||
|
t.Fatalf("分销快照 refund_amount_cent = %d, want %d(按未扣除的裁决额 %d 重算会得到 %d)",
|
||||||
|
snapshot.RefundAmountCent, wantRentRefundCent, arbitrationRefundCent, unheldRentRefundCent)
|
||||||
|
}
|
||||||
|
if wantNetPaid := order.RentAmountCent - wantRentRefundCent; snapshot.NetPaidAmountCent != wantNetPaid {
|
||||||
|
t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)",
|
||||||
|
snapshot.NetPaidAmountCent, wantNetPaid, order.RentAmountCent, wantRentRefundCent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -77,16 +78,30 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error)
|
|||||||
func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, orderIDs []uint64, now time.Time) (int, error) {
|
func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, orderIDs []uint64, now time.Time) (int, error) {
|
||||||
releasedOrders := 0
|
releasedOrders := 0
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先不加锁筛出本站尚未入账的候选参与方对应的订单,收敛后续加锁范围:
|
||||||
|
// 调用方传入的是全站到期订单,其中可能大多数已被手工批次纳入,无需加锁。
|
||||||
|
var candidateOrderIDs []uint64
|
||||||
|
if err := tx.Table("distribution_order_parties p").
|
||||||
|
Select("DISTINCT p.order_id").
|
||||||
|
Where("p.order_id IN ? AND p.party_type = ? AND p.party_id = ?", orderIDs, PartyTypeSite, siteID).
|
||||||
|
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
|
||||||
|
Order("p.order_id ASC").
|
||||||
|
Scan(&candidateOrderIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(candidateOrderIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。
|
// 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。
|
||||||
var lockedFinancials []model.DistributionOrderFinancial
|
var lockedFinancials []model.DistributionOrderFinancial
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("order_id IN ?", orderIDs).
|
Where("order_id IN ?", candidateOrderIDs).
|
||||||
Order("id ASC").Find(&lockedFinancials).Error; err != nil {
|
Order("id ASC").Find(&lockedFinancials).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var parties []model.DistributionOrderParty
|
var parties []model.DistributionOrderParty
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("order_id IN ? AND party_type = ? AND party_id = ?", orderIDs, PartyTypeSite, siteID).
|
Where("order_id IN ? AND party_type = ? AND party_id = ?", candidateOrderIDs, PartyTypeSite, siteID).
|
||||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
|
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 {
|
Order("order_id ASC, id ASC").Find(&parties).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -125,6 +140,11 @@ func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64,
|
|||||||
AmountCent: party.IncomeAmountCent, Status: "settled",
|
AmountCent: party.IncomeAmountCent, Status: "settled",
|
||||||
}
|
}
|
||||||
if err := tx.Create(&item).Error; err != nil {
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
|
if isDuplicateSettlementItem(err) {
|
||||||
|
// 唯一键命中说明另一条路径(手工批次或并发自动释放)已为该参与方建过明细,
|
||||||
|
// 跳过它,避免重复入账。
|
||||||
|
continue
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
total += party.IncomeAmountCent
|
total += party.IncomeAmountCent
|
||||||
@@ -168,6 +188,18 @@ func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64,
|
|||||||
return releasedOrders, nil
|
return releasedOrders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDuplicateSettlementItem 判断错误是否为结算明细唯一键冲突。
|
||||||
|
// GORM 未开启 TranslateError 时 MySQL 1062 不会映射为 ErrDuplicatedKey,因此同时检查错误链与错误文本。
|
||||||
|
func isDuplicateSettlementItem(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "1062")
|
||||||
|
}
|
||||||
|
|
||||||
// releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。
|
// releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。
|
||||||
// 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。
|
// 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。
|
||||||
func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
|
func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
|
||||||
|
|||||||
@@ -593,8 +593,14 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
|||||||
totalEarning += p.IncomeAmountCent
|
totalEarning += p.IncomeAmountCent
|
||||||
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
||||||
roles = append(roles, "sell")
|
roles = append(roles, "sell")
|
||||||
markupCent += items[i].ShelfMarkupCent
|
// 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值,
|
||||||
splitCent += (p.IncomeAmountCent - items[i].ShelfMarkupCent)
|
// 避免展示负的提价分成。
|
||||||
|
markup := items[i].ShelfMarkupCent
|
||||||
|
if p.IncomeAmountCent < markup {
|
||||||
|
markup = p.IncomeAmountCent
|
||||||
|
}
|
||||||
|
markupCent += markup
|
||||||
|
splitCent += p.IncomeAmountCent - markup
|
||||||
} else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
|
} else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
|
||||||
roles = append(roles, "receive")
|
roles = append(roles, "receive")
|
||||||
splitCent += p.IncomeAmountCent
|
splitCent += p.IncomeAmountCent
|
||||||
@@ -670,8 +676,14 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
|
|||||||
totalEarning += p.IncomeAmountCent
|
totalEarning += p.IncomeAmountCent
|
||||||
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
||||||
roles = append(roles, "sell")
|
roles = append(roles, "sell")
|
||||||
markupCent += item.ShelfMarkupCent
|
// 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值,
|
||||||
splitCent += (p.IncomeAmountCent - item.ShelfMarkupCent)
|
// 避免展示负的提价分成。
|
||||||
|
markup := item.ShelfMarkupCent
|
||||||
|
if p.IncomeAmountCent < markup {
|
||||||
|
markup = p.IncomeAmountCent
|
||||||
|
}
|
||||||
|
markupCent += markup
|
||||||
|
splitCent += p.IncomeAmountCent - markup
|
||||||
} else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
|
} else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
|
||||||
roles = append(roles, "receive")
|
roles = append(roles, "receive")
|
||||||
splitCent += p.IncomeAmountCent
|
splitCent += p.IncomeAmountCent
|
||||||
@@ -998,6 +1010,28 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
|
|||||||
if err := tx.Create(&batch).Error; err != nil {
|
if err := tx.Create(&batch).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 与自动释放路径串行化:先按周期锁定待结算财务快照,再选参与方,
|
||||||
|
// 避免同一订单同时被手工批次与自动释放纳入明细、重复入账。
|
||||||
|
// 分两步(先查 ID 再 FOR UPDATE)而不是 JOIN + FOR UPDATE,避免顺带锁住 rental_orders 行。
|
||||||
|
var pendingOrderIDs []uint64
|
||||||
|
if err := tx.Table("distribution_order_financials f").
|
||||||
|
Select("f.order_id").
|
||||||
|
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
|
||||||
|
Where("f.settlement_status = 'pending'").
|
||||||
|
Where("ro.status IN ?", settlementReadyOrderStatuses).
|
||||||
|
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd).
|
||||||
|
Order("f.order_id ASC").
|
||||||
|
Scan(&pendingOrderIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(pendingOrderIDs) > 0 {
|
||||||
|
var lockedFinancials []model.DistributionOrderFinancial
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id IN ?", pendingOrderIDs).
|
||||||
|
Order("id ASC").Find(&lockedFinancials).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
|
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
|
||||||
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
|
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
|
||||||
var parties []model.DistributionOrderParty
|
var parties []model.DistributionOrderParty
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package distribution
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addSettlementOrder 在某站点下追加一笔可结算订单:已完成订单 + 待结算财务快照 + 站点参与方。
|
||||||
|
func addSettlementOrder(t *testing.T, db *gorm.DB, siteID, ruleID uint64, orderNo string, incomeCent int64) (model.RentalOrder, model.DistributionOrderFinancial, model.DistributionOrderParty) {
|
||||||
|
t.Helper()
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: orderNo, ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||||
|
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||||
|
DistributionSiteID: &siteID, DistributionRuleVersionID: &ruleID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
financial := model.DistributionOrderFinancial{
|
||||||
|
OrderID: order.ID, SiteID: siteID, RuleVersionID: ruleID,
|
||||||
|
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||||
|
SplitBaseAmountCent: 4000, SettlementStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := db.Create(&financial).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
party := model.DistributionOrderParty{
|
||||||
|
OrderID: order.ID, SiteID: siteID, PartyRole: "distributor_receive",
|
||||||
|
PartyType: PartyTypeSite, PartyID: siteID, IncomeAmountCent: incomeCent,
|
||||||
|
}
|
||||||
|
if err := db.Create(&party).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return order, financial, party
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlementFixture 构造站点 + 规则 + 一笔可结算订单,供结算幂等用例复用。
|
||||||
|
func settlementFixture(t *testing.T, db *gorm.DB, siteCode, orderNo string, incomeCent int64) (model.DistributionSite, model.DistributionRuleVersion, model.RentalOrder, model.DistributionOrderFinancial, model.DistributionOrderParty) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
site := mustCreateDistSite(t, db, siteCode, siteCode+"站点", siteCode+".dist.com")
|
||||||
|
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||||
|
order, financial, party := addSettlementOrder(t, db, site.ID, rule.ID, orderNo, incomeCent)
|
||||||
|
return site, rule, order, financial, party
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateDraftBatch(t *testing.T, db *gorm.DB, siteID uint64, batchNo string) model.DistributionSettlementBatch {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
batch := model.DistributionSettlementBatch{
|
||||||
|
SiteID: siteID, BatchNo: batchNo,
|
||||||
|
PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), Status: "draft",
|
||||||
|
}
|
||||||
|
if err := db.Create(&batch).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettlementItemOrderPartyUniqueKey 断言 000070 唯一键生效:
|
||||||
|
// 同一 (order_id, party_id) 插入第二条结算明细必须失败。
|
||||||
|
func TestSettlementItemOrderPartyUniqueKey(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
site, _, order, _, party := settlementFixture(t, db, "uk_site", "ORD-UK-1", 1400)
|
||||||
|
batchA := mustCreateDraftBatch(t, db, site.ID, "DS-UK-A")
|
||||||
|
batchB := mustCreateDraftBatch(t, db, site.ID, "DS-UK-B")
|
||||||
|
|
||||||
|
if err := db.Create(&model.DistributionSettlementItem{
|
||||||
|
BatchID: batchA.ID, OrderID: order.ID, PartyID: party.ID, AmountCent: 1400, Status: "pending",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := db.Create(&model.DistributionSettlementItem{
|
||||||
|
BatchID: batchB.ID, OrderID: order.ID, PartyID: party.ID, AmountCent: 700, Status: "pending",
|
||||||
|
}).Error
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("同一 (order_id, party_id) 插入第二条结算明细必须失败")
|
||||||
|
}
|
||||||
|
if !isDuplicateSettlementItem(err) {
|
||||||
|
t.Fatalf("重复错误应被识别为唯一键冲突,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoReleaseSkipsPartyInManualBatch 断言已进手工批次(pending 明细)的参与方
|
||||||
|
// 不会被自动释放重复入账,且该财务快照不会被置为 settled。
|
||||||
|
func TestAutoReleaseSkipsPartyInManualBatch(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
now := time.Now()
|
||||||
|
past := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
site, rule, _, financialInBatch, _ := settlementFixture(t, db, "dup_site", "ORD-DUP-1", 1400)
|
||||||
|
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||||
|
SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.TotalAmountCent != 1400 {
|
||||||
|
t.Fatalf("手工批次总额 = %d, want 1400", batch.TotalAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同站点再补一笔真正可自动释放的订单,保证自动释放仍在运行而不是整体跳过。
|
||||||
|
_, financialDue, _ := addSettlementOrder(t, db, site.ID, rule.ID, "ORD-DUP-2", 700)
|
||||||
|
for _, id := range []uint64{financialInBatch.ID, financialDue.ID} {
|
||||||
|
if err := db.Model(&model.DistributionOrderFinancial{}).Where("id = ?", id).
|
||||||
|
Update("release_at", past).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if released != 1 {
|
||||||
|
t.Fatalf("released = %d, want 1(仅未入账的订单2)", released)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wallet model.WalletAccount
|
||||||
|
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if wallet.AvailableBalanceCent != 700 {
|
||||||
|
t.Fatalf("站点钱包 = %d, want 700(手工批次内订单不得重复入账)", wallet.AvailableBalanceCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
var inBatch model.DistributionOrderFinancial
|
||||||
|
if err := db.First(&inBatch, financialInBatch.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inBatch.SettlementStatus != "pending" || inBatch.ReleasedAt != nil {
|
||||||
|
t.Fatalf("手工批次内财务快照不应被自动释放:%+v", inBatch)
|
||||||
|
}
|
||||||
|
var due model.DistributionOrderFinancial
|
||||||
|
if err := db.First(&due, financialDue.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if due.SettlementStatus != "settled" || due.ReleasedAt == nil {
|
||||||
|
t.Fatalf("到期订单应被释放:%+v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateSettlementBatchExcludesAutoReleasedParty 断言自动释放已入账的参与方
|
||||||
|
// 不会再进入手工批次(NOT EXISTS 生效,批次总额为 0)。
|
||||||
|
func TestCreateSettlementBatchExcludesAutoReleasedParty(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
now := time.Now()
|
||||||
|
past := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
site, _, order, financial, _ := settlementFixture(t, db, "rel_batch_site", "ORD-REL-BATCH", 1400)
|
||||||
|
if err := db.Model(&model.DistributionOrderFinancial{}).Where("id = ?", financial.ID).
|
||||||
|
Update("release_at", past).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||||
|
if err != nil || released != 1 {
|
||||||
|
t.Fatalf("自动释放 released = %d err=%v, want 1", released, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||||
|
SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.TotalAmountCent != 0 {
|
||||||
|
t.Fatalf("已自动释放的参与方不应再进手工批次,total=%d", batch.TotalAmountCent)
|
||||||
|
}
|
||||||
|
var itemCount int64
|
||||||
|
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID).
|
||||||
|
Count(&itemCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if itemCount != 0 {
|
||||||
|
t.Fatalf("手工批次明细 = %d, want 0", itemCount)
|
||||||
|
}
|
||||||
|
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrSettlementBatchEmpty {
|
||||||
|
t.Fatalf("空批次结算错误 = %v, want ErrSettlementBatchEmpty", err)
|
||||||
|
}
|
||||||
|
var settled model.DistributionOrderFinancial
|
||||||
|
if err := db.Where("order_id = ?", order.ID).First(&settled).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settled.SettlementStatus != "settled" {
|
||||||
|
t.Fatalf("自动释放后的快照应保持 settled,got %s", settled.SettlementStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureCompletedReleaseAtTxSetsReleaseAtForClosedOrder 断言关单(closed)
|
||||||
|
// 同样属于可释放终态,EnsureCompletedReleaseAtTx 会补写非空 release_at。
|
||||||
|
func TestEnsureCompletedReleaseAtTxSetsReleaseAtForClosedOrder(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
now := time.Now()
|
||||||
|
site := mustCreateDistSite(t, db, "closed_ens_site", "关单释放站", "closed-ens.dist.com")
|
||||||
|
if err := db.Model(&site).Update("settlement_hold_days", 4).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-CLOSED-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||||
|
Status: "closed", 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("closed 订单应写入 release_at")
|
||||||
|
}
|
||||||
|
want := settledAt.AddDate(0, 0, 4)
|
||||||
|
if !financial.ReleaseAt.Equal(want) {
|
||||||
|
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOrderDTOClampsCompressedMarkup 断言退款压缩加价(income < shelf_markup)后,
|
||||||
|
// 列表与详情的提价分成不为负,且 markup + split == earning。
|
||||||
|
func TestOrderDTOClampsCompressedMarkup(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
now := time.Now()
|
||||||
|
site := mustCreateDistSite(t, db, "clamp_site", "压缩展示站", "clamp.dist.com")
|
||||||
|
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyPlatformReceive, now)
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-CLAMP-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||||
|
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 600, ShelfMarkupCent: 3000,
|
||||||
|
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 退款 9000 后净付款 1000、利润 0、有效加价 400 < 货架加价 3000。
|
||||||
|
if err := ApplyRefundTx(db, order.ID, 9000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var party model.DistributionOrderParty
|
||||||
|
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&party).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if party.IncomeAmountCent != 400 || party.IncomeAmountCent >= order.ShelfMarkupCent {
|
||||||
|
t.Fatalf("fixture 未构造出 income(%d) < shelf_markup(%d)", party.IncomeAmountCent, order.ShelfMarkupCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertClamped := func(label string, earning, markup, split int64) {
|
||||||
|
t.Helper()
|
||||||
|
if markup < 0 || split < 0 {
|
||||||
|
t.Fatalf("%s 提价分成/分成基数出现负数: markup=%d split=%d", label, markup, split)
|
||||||
|
}
|
||||||
|
if markup != 400 || split != 0 {
|
||||||
|
t.Fatalf("%s markup=%d split=%d, want 400/0", label, markup, split)
|
||||||
|
}
|
||||||
|
if earning != 400 || markup+split != earning {
|
||||||
|
t.Fatalf("%s earning=%d, markup+split=%d, want 两者相等且为 400", label, earning, markup+split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "sold", Page: 1, Size: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Items) != 1 {
|
||||||
|
t.Fatalf("列表条数 = %d, want 1", len(page.Items))
|
||||||
|
}
|
||||||
|
assertClamped("列表", page.Items[0].DistributorEarningCent, page.Items[0].DistributorMarkupCent, page.Items[0].DistributorSplitCent)
|
||||||
|
|
||||||
|
detail, err := repo.FindOrder(t.Context(), 0, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClamped("详情", detail.DistributorEarningCent, detail.DistributorMarkupCent, detail.DistributorSplitCent)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/distribution"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -90,6 +91,12 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin
|
|||||||
if err := tx.Save(order).Error; err != nil {
|
if err := tx.Save(order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。
|
||||||
|
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -498,6 +505,13 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti
|
|||||||
if err := tx.Save(order).Error; err != nil {
|
if err := tx.Save(order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放;
|
||||||
|
// restore 分支订单未终结,不在此列。
|
||||||
|
if order.Status == orderStatusClosed && order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -591,6 +605,12 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint
|
|||||||
if err := tx.Save(order).Error; err != nil {
|
if err := tx.Save(order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。
|
||||||
|
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureDistributionTables 补齐分销表(测试库由迁移克隆,通常已存在,这里是幂等兜底)。
|
||||||
|
func ensureDistributionTables(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.AutoMigrateIfMissing(db,
|
||||||
|
&model.DistributionSite{},
|
||||||
|
&model.DistributionRuleVersion{},
|
||||||
|
&model.DistributionOrderFinancial{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("补齐分销表失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDistributionSiteOrderFixture 构造分销站点订单:分销站点(冷却 3 天)+ 生效规则版本 +
|
||||||
|
// 进行中订单(非终态,未结算)+ 待结算财务快照(release_at 为空)。
|
||||||
|
func createDistributionSiteOrderFixture(t *testing.T, db *gorm.DB, orderNo, siteCode, domain, phonePrefix string) (model.RentalOrder, model.DistributionOrderFinancial, model.DistributionSite) {
|
||||||
|
t.Helper()
|
||||||
|
ensureDistributionTables(t, db)
|
||||||
|
|
||||||
|
owner := model.User{Phone: phonePrefix + "01"}
|
||||||
|
renter := model.User{Phone: phonePrefix + "02"}
|
||||||
|
if err := db.Create(&owner).Error; err != nil {
|
||||||
|
t.Fatalf("创建号主失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&renter).Error; err != nil {
|
||||||
|
t.Fatalf("创建租客失败: %v", err)
|
||||||
|
}
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
ServerRegion: "国服",
|
||||||
|
LoginPlatform: "steam",
|
||||||
|
Title: "关单释放测试账号",
|
||||||
|
Status: accountStatusRented,
|
||||||
|
}
|
||||||
|
if err := db.Create(&account).Error; err != nil {
|
||||||
|
t.Fatalf("创建账号失败: %v", err)
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
PriceCent: 10000,
|
||||||
|
Status: listingStatusRented,
|
||||||
|
ReviewStatus: listingReviewStatusApproved,
|
||||||
|
InTransaction: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
|
t.Fatalf("创建商品失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: siteCode,
|
||||||
|
SiteName: "关单释放站",
|
||||||
|
Domain: domain,
|
||||||
|
Status: "active",
|
||||||
|
DisplayAccountEnabled: true,
|
||||||
|
SettlementHoldDays: 3,
|
||||||
|
}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatalf("创建分销站点失败: %v", err)
|
||||||
|
}
|
||||||
|
rule := model.DistributionRuleVersion{
|
||||||
|
SiteID: &site.ID,
|
||||||
|
Mode: "two_party_distributor_receive",
|
||||||
|
Version: 1,
|
||||||
|
Status: "active",
|
||||||
|
DistributorReceiveRatio: 35,
|
||||||
|
PlatformSellRatio: 40,
|
||||||
|
ServiceFeeValue: 25,
|
||||||
|
ServiceFeeUnit: "percent",
|
||||||
|
PaymentFeeBPS: 50,
|
||||||
|
EffectiveFrom: time.Now().Add(-time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.Create(&rule).Error; err != nil {
|
||||||
|
t.Fatalf("创建规则版本失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: orderNo,
|
||||||
|
ListingID: listing.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
DepositAmountCent: 2000,
|
||||||
|
Status: orderStatusRenting,
|
||||||
|
HandoffStatus: handoffStatusReceived,
|
||||||
|
SettlementStatus: settlementStatusUnsettled,
|
||||||
|
DistributionSiteID: &site.ID,
|
||||||
|
DistributionRuleVersionID: &rule.ID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("创建订单失败: %v", err)
|
||||||
|
}
|
||||||
|
financial := model.DistributionOrderFinancial{
|
||||||
|
OrderID: order.ID,
|
||||||
|
SiteID: site.ID,
|
||||||
|
RuleVersionID: rule.ID,
|
||||||
|
GrossPaidAmountCent: 10000,
|
||||||
|
NetPaidAmountCent: 10000,
|
||||||
|
SplitBaseAmountCent: 2000,
|
||||||
|
SettlementStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := db.Create(&financial).Error; err != nil {
|
||||||
|
t.Fatalf("创建分销财务快照失败: %v", err)
|
||||||
|
}
|
||||||
|
return order, financial, site
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminCloseWritesDistributionReleaseAt 断言分销站点订单被客服关单后,会在同一事务内
|
||||||
|
// 补写 distribution_order_financials.release_at(= settled_at + 站点 settlement_hold_days)。
|
||||||
|
// 自动释放任务要求 release_at IS NOT NULL,缺少补写时这类关单订单永远不会被结算。
|
||||||
|
func TestAdminCloseWritesDistributionReleaseAt(t *testing.T) {
|
||||||
|
db := setupOrderTestDB(t)
|
||||||
|
repo := NewRepository(db, Dependencies{
|
||||||
|
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||||
|
return "refunding", nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
order, financial, site := createDistributionSiteOrderFixture(t, db, "ORD-CLOSE-REL-1", "close_rel_site", "close-rel.dist.com", "138000090")
|
||||||
|
|
||||||
|
if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "客服核实后关单"}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("AdminClose() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedOrder model.RentalOrder
|
||||||
|
if err := db.First(&savedOrder, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
if savedOrder.Status != orderStatusClosed || savedOrder.SettlementStatus != settlementStatusClosed {
|
||||||
|
t.Fatalf("关单后订单状态 = %s/%s, want %s/%s", savedOrder.Status, savedOrder.SettlementStatus, orderStatusClosed, settlementStatusClosed)
|
||||||
|
}
|
||||||
|
if savedOrder.SettledAt == nil {
|
||||||
|
t.Fatal("关单应写入 settled_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedFinancial model.DistributionOrderFinancial
|
||||||
|
if err := db.First(&savedFinancial, financial.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取分销财务快照失败: %v", err)
|
||||||
|
}
|
||||||
|
if savedFinancial.ReleaseAt == nil {
|
||||||
|
t.Fatal("关单后应补写分销快照 release_at,否则该订单永远不会被自动释放结算")
|
||||||
|
}
|
||||||
|
want := savedOrder.SettledAt.AddDate(0, 0, site.SettlementHoldDays)
|
||||||
|
if !savedFinancial.ReleaseAt.Equal(want) {
|
||||||
|
t.Fatalf("release_at = %v, want %v(settled_at + %d 天)", savedFinancial.ReleaseAt, want, site.SettlementHoldDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminCloseWithoutDistributionSiteIsNoop 断言非分销订单(DistributionSiteID 为空)
|
||||||
|
// 关单不受补写逻辑影响:没有分销快照也不报错,订单照常关闭,用来钉住无分销时为 no-op。
|
||||||
|
func TestAdminCloseWithoutDistributionSiteIsNoop(t *testing.T) {
|
||||||
|
db := setupOrderTestDB(t)
|
||||||
|
repo := NewRepository(db, Dependencies{
|
||||||
|
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||||
|
return "refunding", nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
ensureDistributionTables(t, db)
|
||||||
|
|
||||||
|
owner := model.User{Phone: "13800009501"}
|
||||||
|
renter := model.User{Phone: "13800009502"}
|
||||||
|
if err := db.Create(&owner).Error; err != nil {
|
||||||
|
t.Fatalf("创建号主失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&renter).Error; err != nil {
|
||||||
|
t.Fatalf("创建租客失败: %v", err)
|
||||||
|
}
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
ServerRegion: "国服",
|
||||||
|
LoginPlatform: "steam",
|
||||||
|
Title: "非分销关单账号",
|
||||||
|
Status: accountStatusRented,
|
||||||
|
}
|
||||||
|
if err := db.Create(&account).Error; err != nil {
|
||||||
|
t.Fatalf("创建账号失败: %v", err)
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
PriceCent: 10000,
|
||||||
|
Status: listingStatusRented,
|
||||||
|
ReviewStatus: listingReviewStatusApproved,
|
||||||
|
InTransaction: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
|
t.Fatalf("创建商品失败: %v", err)
|
||||||
|
}
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-CLOSE-NODIST-1",
|
||||||
|
ListingID: listing.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
DepositAmountCent: 2000,
|
||||||
|
Status: orderStatusRenting,
|
||||||
|
HandoffStatus: handoffStatusReceived,
|
||||||
|
SettlementStatus: settlementStatusUnsettled,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("创建订单失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.AdminClose(t.Context(), 9002, order.ID, AdminActionRequest{Reason: "普通订单关单"}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("无分销站点订单关单不应报错,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != orderStatusClosed {
|
||||||
|
t.Fatalf("订单状态 = %s, want %s", saved.Status, orderStatusClosed)
|
||||||
|
}
|
||||||
|
var snapshotCount int64
|
||||||
|
if err := db.Model(&model.DistributionOrderFinancial{}).Where("order_id = ?", order.ID).
|
||||||
|
Count(&snapshotCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计分销财务快照失败: %v", err)
|
||||||
|
}
|
||||||
|
if snapshotCount != 0 {
|
||||||
|
t.Fatalf("非分销订单不应产生分销财务快照,got %d", snapshotCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -421,11 +421,22 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64
|
|||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 同一订单可能多笔退款,且退款结果查询会重复回调:
|
||||||
|
// 以已退款成功流水合计为口径,并取本次金额与累计额的较大值,
|
||||||
|
// 保证 order.RefundAmountCent 单调不回退、重复回调幂等。
|
||||||
|
var refundedTotal int64
|
||||||
|
if err := tx.Model(&model.PaymentOrder{}).
|
||||||
|
Where("order_id = ? AND status = ?", orderID, "refunded").
|
||||||
|
Where("biz_type IN ?", refundBizTypes).
|
||||||
|
Select("COALESCE(SUM(amount_cent), 0)").Scan(&refundedTotal).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cumulative := max(refundAmountCent, refundedTotal)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
order.RefundStatus = "refunded"
|
order.RefundStatus = "refunded"
|
||||||
order.RefundAmountCent = refundAmountCent
|
order.RefundAmountCent = cumulative
|
||||||
order.RefundedAt = &now
|
order.RefundedAt = &now
|
||||||
if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent {
|
if bizType == "admin_refund" && order.Status == "completed" && cumulative >= order.RentAmountCent+order.DepositAmountCent {
|
||||||
if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil {
|
if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -433,7 +444,7 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64
|
|||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return distribution.ApplyRefundTx(tx, orderID, refundAmountCent)
|
return distribution.ApplyRefundTx(tx, orderID, cumulative)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
|
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/distribution"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUpdateOrderRefundStatusUsesCumulativeRefundedAmount 断言同一订单多笔退款时,
|
||||||
|
// 单笔回调传入的金额不会把订单累计退款额写小,分销财务快照按合计口径重算。
|
||||||
|
func TestUpdateOrderRefundStatusUsesCumulativeRefundedAmount(t *testing.T) {
|
||||||
|
db := setupPaymentTestDB(t)
|
||||||
|
repo := NewRepository(db, nil, nil)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: "cum_site", SiteName: "累计退款站", Domain: "cum.dist.com",
|
||||||
|
Status: "active", DisplayAccountEnabled: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rule := model.DistributionRuleVersion{
|
||||||
|
SiteID: &site.ID, Mode: distribution.ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
|
||||||
|
PlatformReceiveRatio: 35, DistributorSellRatio: 40, ServiceFeeValue: 0,
|
||||||
|
ServiceFeeUnit: distribution.ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.Create(&rule).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-CUM-REFUND", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 2,
|
||||||
|
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000,
|
||||||
|
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := distribution.SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两笔已退款成功流水:300 + 700 = 1000。
|
||||||
|
refunds := []model.PaymentOrder{
|
||||||
|
{PaymentNo: "REF-CUM-1", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "REF-CUM-1", AmountCent: 300, BizType: "admin_refund", Status: "refunded"},
|
||||||
|
{PaymentNo: "REF-CUM-2", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "REF-CUM-2", AmountCent: 700, BizType: "rent_refund", Status: "refunded"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&refunds).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只传其中一笔金额调用:累计口径应取两笔合计 1000。
|
||||||
|
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 300, "admin_refund"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.RefundAmountCent != 1000 {
|
||||||
|
t.Fatalf("order.RefundAmountCent = %d, want 1000(两笔退款合计)", saved.RefundAmountCent)
|
||||||
|
}
|
||||||
|
if saved.RefundStatus != "refunded" {
|
||||||
|
t.Fatalf("order.RefundStatus = %s, want refunded", saved.RefundStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var financial model.DistributionOrderFinancial
|
||||||
|
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if financial.RefundAmountCent != 1000 || financial.NetPaidAmountCent != 9000 || financial.SplitBaseAmountCent != 3000 {
|
||||||
|
t.Fatalf("分销快照未按累计退款重算:refund=%d netPaid=%d splitBase=%d",
|
||||||
|
financial.RefundAmountCent, financial.NetPaidAmountCent, financial.SplitBaseAmountCent)
|
||||||
|
}
|
||||||
|
var party model.DistributionOrderParty
|
||||||
|
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&party).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if party.IncomeAmountCent != 1200 {
|
||||||
|
t.Fatalf("分站份额 = %d, want 1200(利润 3000 的 40%%)", party.IncomeAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退款结果查询重复回调不应把累计额写小。
|
||||||
|
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 300, "admin_refund"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.RefundAmountCent != 1000 {
|
||||||
|
t.Fatalf("重复回调后 order.RefundAmountCent = %d, want 1000(单调不回退)", saved.RefundAmountCent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 同一订单同一参与方只允许一条结算明细,防止自动释放与手工批次并发重复入账。
|
||||||
|
-- 自动释放按 NOT EXISTS 选参与方,并发下两条路径可能同时落明细并各自入账,
|
||||||
|
-- 由该唯一键兜底,写明细失败重复错误时跳过该参与方。
|
||||||
|
ALTER TABLE distribution_settlement_items
|
||||||
|
ADD UNIQUE KEY uk_distribution_settlement_item_party (order_id, party_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE distribution_settlement_items
|
||||||
|
DROP KEY uk_distribution_settlement_item_party;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 000069 回填历史释放时间时只覆盖了 completed,遗漏了同样是可释放终态的 closed 订单,
|
||||||
|
-- 导致不退款关单(AdminRejectRefund close、AdminSeal 等)的订单 release_at 永久为 NULL。
|
||||||
|
-- 这里补齐 closed,使这些订单重新纳入自动释放扫描。
|
||||||
|
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 IN ('completed', 'closed');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
-- 回填不可逆:无法区分补写的 release_at 与原有值,Down 用占位语句保持 goose 格式。
|
||||||
|
SELECT 1;
|
||||||
@@ -776,6 +776,9 @@ distribution.hao858.com {
|
|||||||
- 各主体独立计算税费。
|
- 各主体独立计算税费。
|
||||||
- 重复支付、退款和结算请求不会重复入账。
|
- 重复支付、退款和结算请求不会重复入账。
|
||||||
- 分销结算明细与钱包流水一致。
|
- 分销结算明细与钱包流水一致。
|
||||||
|
- 同一订单同一参与方只允许一条结算明细(唯一键 `uk_distribution_settlement_item_party`):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不重复入账。
|
||||||
|
- 订单累计退款额取「本次回调金额与已退款成功流水合计」的较大值,多笔退款与退款结果重复回调都不会把口径写小;分销重算与仲裁回冲均按此累计额计算。
|
||||||
|
- 关单(`AdminClose` / `AdminSeal` / `AdminRejectRefund` 的 close 分支)属于可释放终态,会补写 `release_at`,避免不退款关单的订单永远不被自动结算。
|
||||||
|
|
||||||
### 业务正确性
|
### 业务正确性
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user