Files
hfb_sys/backend/internal/modules/distribution/settlement_guard_test.go
T

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 断言 (order_id, party_id) 唯一键生效:
// 同一 (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)
}