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) }