- 权限落库:新增 000066 迁移,注册 11 个 distribution:* 权限并授权 super_admin/ops/finance - 越权修复:siteAccessAllowed 校验贯穿站点/规则/订单/展示/支付通道/结算全部按 ID 操作 - 分账闭环:SplitSettlement 按比例拆分分成基数,销售加价归卖出方,尾差记入 rounding_adjustment_cent 归平台;支付快照与退款重算均按拆分写入参与方金额 - 结算批次:创建时聚合周期内待结算订单生成明细并汇总金额,结算时校验明细与总额一致后联动置位财务快照 - 手工补数接口禁止覆盖已有快照并写入审计日志 - 杂项:二维码池校验站点启用状态且不吞真实 DB 错误;绑定支付通道校验商户配置存在;表探测改为 sync.Once 缓存
180 lines
8.2 KiB
Go
180 lines
8.2 KiB
Go
package distribution
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func distributionTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db)
|
|
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now}
|
|
if err := db.Create(&rule).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 重复补数必须被拒绝,避免覆盖真实财务快照。
|
|
if _, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001}); err != ErrOrderSnapshotExists {
|
|
t.Fatalf("duplicate snapshot err = %v", err)
|
|
}
|
|
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
|
|
t.Fatalf("unexpected page: %#v", page)
|
|
}
|
|
if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db)
|
|
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.PaymentMerchantConfig{Name: "拉卡拉", Provider: "lkl", MerchantID: "M-1", PayWay: "ZFBZF"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 404, Priority: 1}); err != ErrPaymentConfigNotFound {
|
|
t.Fatalf("unknown config err = %v", err)
|
|
}
|
|
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 1, Priority: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
|
|
if err != nil || len(items) != 1 || items[0].ConfigID != 1 {
|
|
t.Fatalf("channels = %#v, err=%v", items, err)
|
|
}
|
|
if err := repo.UnbindPaymentChannel(t.Context(), 0, channel.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 准备一笔待结算订单:财务快照 + 参与方。
|
|
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", PaymentFeeBPS: 50, EffectiveFrom: time.Now().Add(-2 * time.Hour)}
|
|
if err := db.Create(&rule).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
order := model.RentalOrder{OrderNo: "D-2", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", 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: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"}
|
|
if err := db.Create(&financial).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parties := []model.DistributionOrderParty{
|
|
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 350},
|
|
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: "platform", IncomeAmountCent: 400},
|
|
}
|
|
if err := db.Create(&parties).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
start := time.Now().Add(-time.Hour)
|
|
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now().Add(time.Hour)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if batch.TotalAmountCent != 750 {
|
|
t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent)
|
|
}
|
|
var batchItems []model.DistributionSettlementItem
|
|
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 {
|
|
t.Fatalf("batch items = %d, err=%v", len(batchItems), err)
|
|
}
|
|
// 空批次不允许结算。
|
|
emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.MarkSettlementBatch(t.Context(), 0, emptyBatch.ID); err != ErrSettlementBatchEmpty {
|
|
t.Fatalf("empty batch settle err = %v", err)
|
|
}
|
|
|
|
settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID)
|
|
if err != nil || settled.Status != "settled" {
|
|
t.Fatalf("settled = %#v, err=%v", settled, err)
|
|
}
|
|
var settledFinancial model.DistributionOrderFinancial
|
|
if err := db.Where("order_id = ?", order.ID).First(&settledFinancial).Error; err != nil || settledFinancial.SettlementStatus != "settled" || settledFinancial.SettledAt == nil {
|
|
t.Fatalf("financial after settle = %#v, err=%v", settledFinancial, err)
|
|
}
|
|
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled {
|
|
t.Fatalf("second settle err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db)
|
|
site := model.DistributionSite{SiteCode: "scope", SiteName: "范围站", Domain: "scope.example.com"}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := model.DistributionSite{SiteCode: "other", SiteName: "其他站", Domain: "other.example.com"}
|
|
if err := db.Create(&other).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: 7, MemberType: "customer_service", Status: "active"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := repo.ScopeSiteID(t.Context(), 7, 0); err != nil || got != site.ID {
|
|
t.Fatalf("scope = %d, err=%v", got, err)
|
|
}
|
|
if _, err := repo.ScopeSiteID(t.Context(), 7, site.ID+1); err != ErrSiteAccessDenied {
|
|
t.Fatalf("access error = %v", err)
|
|
}
|
|
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
|
|
t.Fatalf("global scope = %d, err=%v", got, err)
|
|
}
|
|
|
|
// 被限制到站点 A 的成员不能通过资源 ID 跨站点访问。
|
|
if _, err := repo.FindSite(t.Context(), 7, other.ID); err != ErrSiteAccessDenied {
|
|
t.Fatalf("FindSite cross-site err = %v", err)
|
|
}
|
|
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, 999, "enabled"); err != ErrAccountDisplayNotFound {
|
|
t.Fatalf("UpdateAccountDisplay missing err = %v", err)
|
|
}
|
|
display := model.DistributionSiteAccount{SiteID: other.ID, AccountID: 5, ListingID: 6, DisplayStatus: "enabled"}
|
|
if err := db.Create(&display).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, display.ID, "disabled"); err != ErrSiteAccessDenied {
|
|
t.Fatalf("UpdateAccountDisplay cross-site err = %v", err)
|
|
}
|
|
}
|