Files
hfb_sys/backend/internal/modules/distribution/release_test.go
T
yml2213andCursor 647fdc0b4a feat(distribution): 通道费改主站承担,并按冷却期自动释放结算
利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 17:11:58 +08:00

444 lines
16 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"
)
func TestApplyRefundTxSetsReleaseAtForCompletedOrder(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := mustCreateDistSite(t, db, "rel_site", "释放测试站", "rel.dist.com")
if err := db.Model(&site).Update("settlement_hold_days", 3).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-RELEASE-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", 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 := ApplyRefundTx(db, order.ID, 0); 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.Fatalf("release_at not set: %+v", financial)
}
want := settledAt.AddDate(0, 0, 3)
if !financial.ReleaseAt.Equal(want) {
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
}
}
func TestAutoReleaseDueSettlements(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "auto_site", "自动释放站", "auto.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-AUTO-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)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
parties := []model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
}
if err := db.Create(&parties).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatalf("AutoReleaseDueSettlements failed: %v", err)
}
if released != 1 {
t.Fatalf("released = %d, want 1", released)
}
var updated model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.SettlementStatus != "settled" || updated.ReleasedAt == nil {
t.Fatalf("financial not released: %+v", updated)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 1400 {
t.Fatalf("site wallet = %d, want 1400", account.AvailableBalanceCent)
}
var batch model.DistributionSettlementBatch
if err := db.Where("site_id = ? AND status = 'settled'", site.ID).First(&batch).Error; err != nil {
t.Fatal(err)
}
if batch.TotalAmountCent != 1400 || len(batch.BatchNo) < 2 || batch.BatchNo[:2] != autoBatchPrefix {
t.Fatalf("batch = %+v", batch)
}
var itemCount int64
if err := db.Model(&model.DistributionSettlementItem{}).
Where("batch_id = ? AND status = 'settled'", batch.ID).Count(&itemCount).Error; err != nil {
t.Fatal(err)
}
if itemCount != 1 {
t.Fatalf("settled items = %d, want 1", itemCount)
}
// 幂等:重复执行不再释放,也不再入账。
released, err = repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("second run released = %d, want 0", released)
}
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 1400 {
t.Fatalf("site wallet after rerun = %d, want 1400", account.AvailableBalanceCent)
}
}
func TestAutoReleaseSkipsOrderInRefund(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "hold_site", "退款拦截站", "hold.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-HOLD-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "pending_review",
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("released = %d, want 0 while refund is pending", released)
}
}
func TestRefundAfterReleaseReversesSiteWallet(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "rev_site", "回冲测试站", "rev.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-REV-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)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
}).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil {
t.Fatal(err)
}
// 释放后退款 3000:利润 1000,分站份额降为 350,应回冲 1050。
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
t.Fatal(err)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 350 {
t.Fatalf("wallet after reversal = %d, want 350", account.AvailableBalanceCent)
}
var updated model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.SettlementStatus != "settled" {
t.Fatalf("released financial must stay settled, got %s", updated.SettlementStatus)
}
// 余额被提空后再次回冲应允许扣成负数,避免回冲失败。
if err := db.Model(&model.WalletAccount{}).Where("id = ?", account.ID).
Update("available_balance_cent", 0).Error; err != nil {
t.Fatal(err)
}
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
t.Fatalf("reverse with empty wallet failed: %v", err)
}
if err := db.Where("id = ?", account.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != -350 {
t.Fatalf("wallet = %d, want -350 (allow negative on reversal)", account.AvailableBalanceCent)
}
}
func TestEnsureCompletedReleaseAtTxSetsReleaseAt(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := mustCreateDistSite(t, db, "ens_site", "完单释放站", "ens.dist.com")
if err := db.Model(&site).Update("settlement_hold_days", 2).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-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", 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("release_at not set")
}
want := settledAt.AddDate(0, 0, 2)
if !financial.ReleaseAt.Equal(want) {
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
}
}
func TestAutoReleaseAppendsSameDayBatch(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "day_site", "同日追加站", "day.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
seed := func(orderNo string) {
t.Helper()
order := model.RentalOrder{
OrderNo: orderNo, 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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
}
seed("ORD-DAY-1")
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
t.Fatalf("first release = %d err=%v, want 1", released, err)
}
var firstBatch model.DistributionSettlementBatch
if err := db.Where("site_id = ?", site.ID).First(&firstBatch).Error; err != nil {
t.Fatal(err)
}
seed("ORD-DAY-2")
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
t.Fatalf("second release = %d err=%v, want 1", released, err)
}
var batches []model.DistributionSettlementBatch
if err := db.Where("site_id = ? AND batch_no LIKE ?", site.ID, autoBatchPrefix+"%").Find(&batches).Error; err != nil {
t.Fatal(err)
}
if len(batches) != 1 {
t.Fatalf("same-day auto batches = %d, want 1", len(batches))
}
if batches[0].ID != firstBatch.ID {
t.Fatalf("second release created a new batch %d, want %d", batches[0].ID, firstBatch.ID)
}
if batches[0].TotalAmountCent != 2800 {
t.Fatalf("batch total = %d, want 2800", batches[0].TotalAmountCent)
}
var itemCount int64
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batches[0].ID).Count(&itemCount).Error; err != nil {
t.Fatal(err)
}
if itemCount != 2 {
t.Fatalf("batch items = %d, want 2", itemCount)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 2800 {
t.Fatalf("wallet = %d, want 2800", account.AvailableBalanceCent)
}
}
func TestAutoReleaseSkipsRefundingOrder(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "rf_site", "退款中拦截站", "rf.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-RF-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "refunding",
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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("released = %d, want 0 while refunding", released)
}
}
func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
siteA := mustCreateDistSite(t, db, "rev_a", "收号站", "reva.dist.com")
siteB := mustCreateDistSite(t, db, "rev_b", "卖出站", "revb.dist.com")
rule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-3P-REV", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
DistributionSiteID: &siteB.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: siteB.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: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 1400, Ratio: 35},
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 1600, Ratio: 40},
}).Error; err != nil {
t.Fatal(err)
}
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 2 {
t.Fatalf("released = %d err=%v, want 2", released, err)
}
// 退款 3000 后利润 1000:A 350、B 400;回冲必须打到 PartyID(收号站 A),不能打到快照 SiteID(卖出站 B)。
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
t.Fatal(err)
}
var walletA, walletB model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB).Error; err != nil {
t.Fatal(err)
}
if walletA.AvailableBalanceCent != 350 {
t.Fatalf("receiver wallet A = %d, want 350 (clawback on PartyID)", walletA.AvailableBalanceCent)
}
if walletB.AvailableBalanceCent != 400 {
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
}
}