fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试

- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账
- 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化
- 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小
- 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx
- 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣
- 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数
- 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间)
- 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
This commit is contained in:
yml2213
2026-09-10 19:13:39 +08:00
parent e3a75fb2d7
commit 36ebe7a5f1
13 changed files with 1017 additions and 10 deletions
@@ -132,7 +132,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return err
}
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil {
if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil {
return err
}
}
@@ -0,0 +1,144 @@
package dispute
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// createHeldDepositDistributionFixture 构造「押金暂扣 + 分销站点」的仲裁前置数据:
// 站点 + 生效规则版本 + 进行中订单(押金 60 元全额暂扣)+ 待结算分销财务快照(毛收入 = 订单租金)。
func createHeldDepositDistributionFixture(t *testing.T, db *gorm.DB) (model.DistributionSite, model.DistributionRuleVersion, model.RentalOrder, model.DistributionOrderFinancial) {
t.Helper()
if err := database.AutoMigrateIfMissing(db,
&model.DistributionSite{},
&model.DistributionRuleVersion{},
&model.DistributionOrderFinancial{},
); err != nil {
t.Fatalf("补齐分销表失败: %v", err)
}
site := model.DistributionSite{
SiteCode: "arb_hold_site",
SiteName: "仲裁暂扣站",
Domain: "arb-hold.dist.com",
Status: "active",
DisplayAccountEnabled: true,
SettlementHoldDays: 3,
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建分销站点失败: %v", err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: "two_party_distributor_receive",
Version: 1,
Status: "active",
DistributorReceiveRatio: 35,
PlatformSellRatio: 40,
ServiceFeeValue: 25,
ServiceFeeUnit: "percent",
PaymentFeeBPS: 50,
EffectiveFrom: time.Now().Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("创建规则版本失败: %v", err)
}
_, _, order := createDisputeOrderFixture(t, db, model.RentalOrder{
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
DepositAmountCent: 6000,
DepositHoldStatus: model.DepositHoldStatusHeld,
DepositHoldAmountCent: 6000,
DistributionSiteID: &site.ID,
DistributionRuleVersionID: &rule.ID,
})
financial := model.DistributionOrderFinancial{
OrderID: order.ID,
SiteID: site.ID,
RuleVersionID: rule.ID,
GrossPaidAmountCent: order.RentAmountCent,
NetPaidAmountCent: order.RentAmountCent,
SplitBaseAmountCent: 2000,
SettlementStatus: "pending",
}
if err := db.Create(&financial).Error; err != nil {
t.Fatalf("创建分销财务快照失败: %v", err)
}
return site, rule, order, financial
}
// TestArbitrationDistributionRefundUsesActualRefundWithDepositHold 断言押金暂扣订单仲裁时,
// 分销重算使用「实际退款额」(裁决退款扣除被暂扣押金部分),而不是裁决书上的退款额。
// 用未扣除的裁决额重算会让分销快照多记退款、net_paid 少记(站点被多扣)。
func TestArbitrationDistributionRefundUsesActualRefundWithDepositHold(t *testing.T) {
db := setupDisputeTestDB(t)
startedRefundCent := int64(-1)
repo := NewRepository(db, Dependencies{
RefundStarter: RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, _ string, _ string) (string, error) {
startedRefundCent = refundAmountCent
return "refunded", nil
}),
})
_, _, order, financial := createHeldDepositDistributionFixture(t, db)
created, err := repo.Create(t.Context(), order.RenterID, order.ID, CreateRequest{
Type: "haf_coin_dispute",
Description: "押金暂扣期间申请仲裁全额退款",
})
if err != nil {
t.Fatalf("创建申诉失败: %v", err)
}
if _, err := repo.Arbitrate(t.Context(), 9001, created.ID, ArbitrateRequest{
Result: "full_refund",
Remark: "仲裁全额退款,押金部分暂扣中",
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁失败: %v", err)
}
// 裁决额 = 租金 + 押金;押金全额暂扣 → 实际退款 = 裁决额 − 押金。
arbitrationRefundCent := order.RentAmountCent + order.DepositAmountCent
actualRefundCent := arbitrationRefundCent - order.DepositHoldAmountCent
if actualRefundCent >= arbitrationRefundCent {
t.Fatalf("fixture 未构造出「实际退款 < 裁决额」:actual=%d arbitration=%d", actualRefundCent, arbitrationRefundCent)
}
if startedRefundCent != actualRefundCent {
t.Fatalf("实际发起退款 = %d, want %d(裁决额 %d 扣除被暂扣押金 %d)",
startedRefundCent, actualRefundCent, arbitrationRefundCent, order.DepositHoldAmountCent)
}
var savedOrder model.RentalOrder
if err := db.First(&savedOrder, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if savedOrder.RefundStatus != "pending" || savedOrder.RefundAmountCent != actualRefundCent {
t.Fatalf("订单退款 = %s/%d, want pending/%d", savedOrder.RefundStatus, savedOrder.RefundAmountCent, actualRefundCent)
}
// 押金退款不计入分销毛收入:实际退款先抵扣押金,剩余才是租金退款。
wantRentRefundCent := actualRefundCent - order.DepositAmountCent
unheldRentRefundCent := arbitrationRefundCent - order.DepositAmountCent
var snapshot model.DistributionOrderFinancial
if err := db.First(&snapshot, financial.ID).Error; err != nil {
t.Fatalf("读取分销快照失败: %v", err)
}
if snapshot.RefundAmountCent != wantRentRefundCent {
t.Fatalf("分销快照 refund_amount_cent = %d, want %d(按未扣除的裁决额 %d 重算会得到 %d)",
snapshot.RefundAmountCent, wantRentRefundCent, arbitrationRefundCent, unheldRentRefundCent)
}
if wantNetPaid := order.RentAmountCent - wantRentRefundCent; snapshot.NetPaidAmountCent != wantNetPaid {
t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)",
snapshot.NetPaidAmountCent, wantNetPaid, order.RentAmountCent, wantRentRefundCent)
}
}