fix(order): 修正仲裁后号主实际收入

This commit is contained in:
yml2213
2026-11-02 18:34:40 +08:00
parent 599e22331d
commit 5334c2b218
4 changed files with 141 additions and 86 deletions
+2 -1
View File
@@ -129,7 +129,8 @@ type AdminOrderListItemDTO struct {
SettlementStatus string `json:"settlement_status"`
CreatedAt time.Time `json:"created_at"`
// OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回;
// 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。
// 用指针区分「未结账(null)」与「到手 0 元」。平台代管订单取线下结算金额,
// 其他订单取号主钱包最终入账合计。
OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"`
}
+23 -29
View File
@@ -199,7 +199,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm
}
}
if len(incomeOrderIDs) > 0 {
ownerIncome, err = r.latestOwnerIncomeByOrder(ctx, incomeOrderIDs)
ownerIncome, err = r.ownerActualIncomeByOrder(ctx, incomeOrderIDs)
if err != nil {
return nil, err
}
@@ -255,48 +255,42 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ
return r.adminOrderJoinQuery(ctx)
}
// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。
// ownerActualIncomeByOrder 批量读取每单号主最终实际结算金额(分)。
//
// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。
// 只取结账表两列,不加载 content / evidence_urls 等大字段。
// 结账行只能代表协商结果,不能代表仲裁后的最终落账:
// - 平台代管订单:最终金额写入 rental_orders.offline_settlement_amount_cent;
// - 普通钱包订单:最终金额以 wallet_ledger 中号主的实际入账合计为准。
//
// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程,
// 因此按 order_id 取 MAX(id) 作为最终结账行。
//
// 执行计划(MySQL 8.4 实测,EXPLAIN FORMAT=TREE):
//
// -> Nested loop inner join
// -> Materialize
// -> Group aggregate: max(order_checkouts.id)
// -> Covering index scan on order_checkouts using idx_order_checkouts_order_id
// -> Single-row index lookup on oc using PRIMARY (id=latest.latest_id)
//
// 用 STRAIGHT_JOIN 固定连接顺序:先物化「每单最新结账 id」小集合(走
// idx_order_checkouts_order_id 覆盖索引),再按主键逐行取金额。避免优化器
// 在数据量增长后改选全表扫描;开销只与当前页订单数相关,与结账表总量无关。
func (r *Repository) latestOwnerIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
// 仲裁可能不更新历史结账行,甚至 dispute.checkout_id 为空,因此不能继续取
// order_checkouts 的 owner_income_amount_cent,否则列表会显示仲裁前金额。
func (r *Repository) ownerActualIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
result := make(map[uint64]int64, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
latest := r.db.WithContext(ctx).
Table("order_checkouts").
Select("order_id, MAX(id) AS latest_id").
Where("order_id IN ?", orderIDs).
Group("order_id")
var rows []struct {
OrderID uint64
ID uint64
OwnerIncomeAmountCent int64
}
if err := r.db.WithContext(ctx).
Table("(?) AS latest", latest).
Select("oc.order_id, oc.owner_income_amount_cent").
Joins("STRAIGHT_JOIN order_checkouts AS oc ON oc.id = latest.latest_id").
Table("rental_orders AS o").
Select(`o.id,
CASE WHEN o.settlement_mode = 'platform_managed'
THEN COALESCE(o.offline_settlement_amount_cent, 0)
ELSE COALESCE(SUM(wl.amount_cent), 0)
END AS owner_income_amount_cent`).
Joins(`LEFT JOIN wallet_ledger AS wl
ON wl.order_id = o.id
AND wl.user_id = o.owner_id
AND wl.direction = 'in'
AND wl.biz_type IN ?`, []string{walletBizOwnerIncome, walletBizDepositCompensation, "arbitration_owner_income"}).
Where("o.id IN ?", orderIDs).
Group("o.id, o.settlement_mode, o.offline_settlement_amount_cent").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.OrderID] = row.OwnerIncomeAmountCent
result[row.ID] = row.OwnerIncomeAmountCent
}
return result, nil
}
@@ -2160,23 +2160,25 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd
return checkout
}
// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」,
// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
// TestListAdminPlatformManagedArbitrationUsesOfflineSettlement 校验平台代管订单仲裁后
// 列表读取线下实际结算金额,不能继续展示旧结账行中的金额。
func TestListAdminPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(7002)
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
order.Status = orderStatusCompleted
order.SettlementStatus = settlementStatusSettled
order.SettlementStatus = settlementStatusArbitrated
order.OfflineSettlementStatus = offlineSettlementStatusSettled
order.OfflineSettlementAmountCent = 382
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save completed order failed: %v", err)
t.Fatalf("save arbitrated order failed: %v", err)
}
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
checkout.Status = checkoutStatusAccepted
// 模拟仲裁前遗留的结账金额,列表不能把它当成最终收入。
checkout.OwnerIncomeAmountCent = 6000
if err := db.Save(&checkout).Error; err != nil {
t.Fatalf("save finalized checkout failed: %v", err)
t.Fatalf("save stale checkout failed: %v", err)
}
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
@@ -2186,16 +2188,15 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
if len(result.Items) != 1 {
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
}
if result.Items[0].SettlementStatus != settlementStatusSettled {
t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled)
if result.Items[0].SettlementStatus != settlementStatusArbitrated {
t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusArbitrated)
}
// 列表需直接展示号主实际收入,供客服在列表页核对结算。
income := result.Items[0].OwnerIncomeAmountCent
if income == nil {
t.Fatalf("owner income = nil, want 6000 for settled order")
t.Fatalf("owner income = nil, want 382 for platform-managed arbitrated order")
}
if *income != 6000 {
t.Fatalf("owner income = %d, want 6000", *income)
if *income != 382 {
t.Fatalf("owner income = %d, want 382", *income)
}
}
@@ -2227,28 +2228,54 @@ func TestListAdminOmitsOwnerIncomeWhileNegotiating(t *testing.T) {
}
}
// TestListAdminOwnerIncomeUsesLatestCheckout 校验一单存在多行结账记录时取最新一行(MAX(id))。
func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) {
// TestListAdminWalletArbitrationUsesWalletIncome 校验普通钱包订单仲裁后列表汇总
// 号主实际钱包入账,而不是读取仲裁前遗留的结账金额。
func TestListAdminWalletArbitrationUsesWalletIncome(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(7004)
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
owner, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
order.Status = orderStatusCompleted
order.SettlementStatus = settlementStatusSettled
order.SettlementStatus = settlementStatusArbitrated
order.SettlementMode = settlementModeOwnerWallet
order.OfflineSettlementStatus = offlineSettlementStatusNone
order.OfflineSettlementAmountCent = 0
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save completed order failed: %v", err)
t.Fatalf("save arbitrated order failed: %v", err)
}
first := createOpenPlatformCheckout(t, db, order, renter.ID)
first.Status = checkoutStatusAccepted
first.OwnerIncomeAmountCent = 1111
if err := db.Save(&first).Error; err != nil {
t.Fatalf("save first checkout failed: %v", err)
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
checkout.OwnerIncomeAmountCent = 56013
if err := db.Save(&checkout).Error; err != nil {
t.Fatalf("save stale checkout failed: %v", err)
}
second := createOpenPlatformCheckout(t, db, order, renter.ID)
second.Status = checkoutStatusAccepted
second.OwnerIncomeAmountCent = 2222
if err := db.Save(&second).Error; err != nil {
t.Fatalf("save second checkout failed: %v", err)
orderID := order.ID
ledgers := []model.WalletLedger{
{
LedgerNo: "WL-ARBITRATION-OWNER",
UserID: owner.ID,
OrderID: &orderID,
Direction: "in",
AmountCent: 38613,
BalanceType: "available",
BizType: "arbitration_owner_income",
BizNo: order.OrderNo,
Remark: "仲裁按实际消耗结算给号主",
},
{
LedgerNo: "WL-ARBITRATION-OTHER-USER",
UserID: renter.ID,
OrderID: &orderID,
Direction: "in",
AmountCent: 999999,
BalanceType: "available",
BizType: "arbitration_owner_income",
BizNo: order.OrderNo,
Remark: "不应计入号主收入",
},
}
if err := db.Create(&ledgers).Error; err != nil {
t.Fatalf("create wallet ledgers failed: %v", err)
}
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
@@ -2259,8 +2286,36 @@ func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) {
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
}
income := result.Items[0].OwnerIncomeAmountCent
if income == nil || *income != 2222 {
t.Fatalf("owner income = %v, want 2222 from latest checkout row", income)
if income == nil || *income != 38613 {
t.Fatalf("owner income = %v, want 38613 from arbitration wallet income", income)
}
}
// TestListAdminArbitratedWithoutPayoutReturnsZero 校验仲裁已经形成终态但无实际入账时,
// 列表返回明确的 0 元,而不是因为缺少结账行显示为未知(null)。
func TestListAdminArbitratedWithoutPayoutReturnsZero(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(7005)
_, _, order := createPlatformManagedCheckoutOrder(t, db, adminID)
order.Status = orderStatusClosed
order.SettlementStatus = settlementStatusArbitrated
order.OfflineSettlementStatus = offlineSettlementStatusNone
order.OfflineSettlementAmountCent = 0
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save arbitrated order failed: %v", err)
}
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("ListAdmin() error = %v", err)
}
if len(result.Items) != 1 {
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
}
income := result.Items[0].OwnerIncomeAmountCent
if income == nil || *income != 0 {
t.Fatalf("owner income = %v, want explicit zero for arbitrated order without payout", income)
}
}
@@ -2513,23 +2568,24 @@ func seedSettledAdminOrders(t *testing.T, db *gorm.DB, from, to int) {
t.Fatalf("create listing %d failed: %v", i, err)
}
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-N1-%04d", i),
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusCompleted,
SettlementStatus: settlementStatusSettled,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 1000,
EstimatedDurationHours: 24,
OfflineSettlementStatus: offlineSettlementStatusNone,
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
OrderNo: fmt.Sprintf("ORD-N1-%04d", i),
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusCompleted,
SettlementStatus: settlementStatusSettled,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 1000,
EstimatedDurationHours: 24,
OfflineSettlementStatus: offlineSettlementStatusNone,
OfflineSettlementAmountCent: int64(1000 + i),
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order %d failed: %v", i, err)
@@ -2600,26 +2656,28 @@ func (c *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (stri
c.statements = append(c.statements, sql)
}
// TestLatestOwnerIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态:
// 整页一条语句、用 STRAIGHT_JOIN 固定「先物化最新结账 id,再按主键取金额」的顺序。
// 该形态对应 EXPLAIN:覆盖索引扫描 + 主键单行查找,不随行数产生额外查询。
func TestLatestOwnerIncomeQueryIsSingleBatchStatement(t *testing.T) {
// TestOwnerActualIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态:
// 整页一条语句,平台代管读取线下结算金额,其他订单汇总钱包最终入账。
func TestOwnerActualIncomeQueryIsSingleBatchStatement(t *testing.T) {
capture := &sqlCaptureLogger{Interface: logger.Default.LogMode(logger.Silent)}
db := setupOrderTestDB(t).Session(&gorm.Session{Logger: capture})
repo := NewRepository(db)
if _, err := repo.latestOwnerIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil {
t.Fatalf("latestOwnerIncomeByOrder() error = %v", err)
if _, err := repo.ownerActualIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil {
t.Fatalf("ownerActualIncomeByOrder() error = %v", err)
}
if len(capture.statements) != 1 {
t.Fatalf("取数语句条数 = %d, want 1(多行必须合并为单条批量查询)", len(capture.statements))
}
sql := capture.statements[0]
if !strings.Contains(sql, "STRAIGHT_JOIN order_checkouts") {
t.Fatalf("缺少 STRAIGHT_JOIN 固定连接顺序,实际 SQL:\n%s", sql)
if !strings.Contains(sql, "LEFT JOIN wallet_ledger AS wl") {
t.Fatalf("缺少钱包入账汇总,实际 SQL:\n%s", sql)
}
if !strings.Contains(sql, "offline_settlement_amount_cent") {
t.Fatalf("缺少平台代管线下结算金额,实际 SQL:\n%s", sql)
}
if !strings.Contains(sql, "GROUP BY") {
t.Fatalf("缺少按 order_id 分组的 MAX(id) 子查询,实际 SQL:\n%s", sql)
t.Fatalf("缺少按订单分组汇总,实际 SQL:\n%s", sql)
}
if strings.Contains(sql, "SELECT *") {
t.Fatalf("应只取所需列,避免读取 content/evidence_urls 大字段,实际 SQL:\n%s", sql)
+2
View File
@@ -42,6 +42,8 @@ require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 1
require_pattern "backend/internal/modules/order/queries.go" "o.order_no = ?" "订单列表订单号唯一索引直达"
require_pattern "backend/internal/modules/order/queries.go" "l.listing_no = ?" "订单列表商品号唯一索引直达"
require_pattern "backend/internal/modules/order/queries.go" "o.owner_id = (SELECT id FROM users WHERE phone = ?)" "订单列表手机号解析用户索引直达"
require_pattern "backend/internal/modules/order/queries.go" "LEFT JOIN wallet_ledger AS wl" "订单列表号主实际收入取最终钱包入账"
require_pattern "backend/internal/modules/order/queries.go" "offline_settlement_amount_cent" "订单列表平台代管收入取线下结算金额"
require_pattern "backend/internal/modules/order/queries.go" "page == 1 && len(rows) < pageSize" "订单列表首页面单扫描短路"
require_pattern "backend/internal/modules/pickup/repository.go" "p.pickup_no = ?" "提号列表提号单号唯一索引直达"