Files
hfb_sys/backend/internal/modules/distribution/settlement_guard_test.go
T
yml2213 36ebe7a5f1 fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试
- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账
- 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化
- 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小
- 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx
- 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣
- 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数
- 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间)
- 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
2026-09-10 19:13:39 +08:00

293 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}