From d3d571357a8a28edb2faeae7ad325a1556c41cc4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 12:49:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(order):=20=E7=BB=93=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=B8=B8=E9=87=8F=E5=8C=96=E5=B9=B6=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E4=BB=B2=E8=A3=81=E7=BB=93=E5=8D=95=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仲裁结案会把结账单置为终态 settled,但该状态一直是裸字符串;同时 adminfinance 按日结算聚合只按 accepted 过滤,容易被误读成"仲裁单掉出统计"。本次只做卫生与口径固化,不改任何行为: - order 新增 CheckoutStatus{Submitted,Countered,Accepted,Disputed,Settled},包内沿用非导出别名 - dispute 的仲裁结案与争议取消分支改用常量;distribution 不能反向依赖 order,本地声明同名常量 - adminfinance 聚合里的 oc.status = 'accepted' 改为常量绑定,并注释说明仲裁单不计入是有意口径 - 新增 distribution 结账单退款重算用例(该分支原先无覆盖):accepted/settled 按结账单实际租金, submitted 不被采用;dashboard 用例补一张 settled 结账单,钉住"不计入已结算订单数" --- .../modules/adminfinance/dashboard.go | 5 +- .../modules/adminfinance/dashboard_test.go | 9 +- .../internal/modules/dispute/arbitration.go | 2 +- backend/internal/modules/dispute/cancel.go | 5 +- .../modules/distribution/lifecycle.go | 9 +- .../refund_checkout_status_test.go | 103 ++++++++++++++++++ backend/internal/modules/order/constants.go | 10 ++ .../order/repository_integration_test.go | 17 +++ 8 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 backend/internal/modules/distribution/refund_checkout_status_test.go diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 7b8a882..bea5e64 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -5,6 +5,7 @@ import ( "errors" "sync" + ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/timeutil" ) @@ -442,7 +443,9 @@ func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ELSE 0 END), 0) AS financial_exception_count, COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}). - Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). + // 统计口径:只算"正常结账确认"的订单。仲裁结案的结账单终态是 settled + // (由 dispute 模块写入),不纳入本指标,此处保持既有口径不变。 + Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = ?", ordermodule.CheckoutStatusAccepted). Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). Group("DATE_FORMAT(ro.settled_at, '%Y-%m-%d')"). diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index d364ec7..e165c3a 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -192,9 +192,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { loc := timeutil.ShanghaiLocation() settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc) + // ORD-S3 的结账单终态是 settled(仲裁结案),不纳入"正常结账确认"口径。 for _, row := range [][]any{ {1, "ORD-S1", 1, 1, 1, 1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300}, {2, "ORD-S2", 2, 2, 2, 2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200}, + {3, "ORD-S3", 3, 3, 3, 3, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 0}, } { if err := db.Exec(`INSERT INTO rental_orders (id, order_no, listing_id, account_id, owner_id, renter_id, @@ -204,7 +206,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { t.Fatalf("写入订单失败: %v", err) } } - for _, row := range [][]any{{1, 1, 1, "accepted", 300, 700}, {2, 2, 1, "accepted", 200, 600}} { + for _, row := range [][]any{ + {1, 1, 1, "accepted", 300, 700}, + {2, 2, 1, "accepted", 200, 600}, + {3, 3, 1, "settled", 0, 0}, + } { if err := db.Exec(`INSERT INTO order_checkouts (id, order_id, initiated_by, status, platform_fee_cent, owner_income_amount_cent) VALUES (?, ?, ?, ?, ?, ?)`, row...).Error; err != nil { @@ -236,6 +242,7 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { t.Fatalf("读取每日结算汇总失败: %v", err) } summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup) + // ORD-S3 的结账单终态为 settled(仲裁结案),按既有口径不计入"已结算订单数"。 if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { t.Fatalf("普通订单数量或利润错误: %+v", summary) } diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 3cc9610..7545229 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -375,7 +375,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model. checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent checkout.ShortfallCent = settlement.ShortfallCent checkout.OvershootAmountCent = settlement.OvershootAmountCent - checkout.Status = "settled" + checkout.Status = ordermodule.CheckoutStatusSettled } func isPlatformManagedOrder(order model.RentalOrder) bool { diff --git a/backend/internal/modules/dispute/cancel.go b/backend/internal/modules/dispute/cancel.go index 44f78e1..5791242 100644 --- a/backend/internal/modules/dispute/cancel.go +++ b/backend/internal/modules/dispute/cancel.go @@ -6,6 +6,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + ordermodule "hfb_sys/backend/internal/modules/order" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -145,9 +146,9 @@ func restoreCheckoutDispute(tx *gorm.DB, row *model.Dispute, order *model.Rental } checkout.Status = row.PreviousCheckoutStatus if checkout.Status == "" { - checkout.Status = "submitted" + checkout.Status = ordermodule.CheckoutStatusSubmitted if order.Status == "pending_checkout_accept" { - checkout.Status = "countered" + checkout.Status = ordermodule.CheckoutStatusCountered } } checkout.RenterRejectedAt = nil diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index 1cf7993..5f23fa1 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -12,6 +12,13 @@ import ( "gorm.io/gorm/clause" ) +// 结账单状态常量,与 order 模块的 checkoutStatus* 取值一致。 +// order 模块依赖 distribution,本包不能反向 import,故在此本地声明,避免裸字符串漂移。 +const ( + checkoutStatusAccepted = "accepted" + checkoutStatusSettled = "settled" +) + // distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema, // 兼容尚未执行分销迁移的旧环境,避免每次支付/退款事务都做系统表查询。 var ( @@ -284,7 +291,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { // 1. 如果存在已达成一致的结账单(accepted 或 settled),以结账单中的实际消费租金与号主实际所得为准 var checkout model.OrderCheckout hasAcceptedCheckout := false - if err := tx.Where("order_id = ? AND status IN ('accepted', 'settled')", orderID). + if err := tx.Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusAccepted, checkoutStatusSettled}). Order("id DESC").First(&checkout).Error; err == nil { hasAcceptedCheckout = true } diff --git a/backend/internal/modules/distribution/refund_checkout_status_test.go b/backend/internal/modules/distribution/refund_checkout_status_test.go new file mode 100644 index 0000000..9a12fb6 --- /dev/null +++ b/backend/internal/modules/distribution/refund_checkout_status_test.go @@ -0,0 +1,103 @@ +package distribution + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" +) + +// TestApplyRefundTxUsesAgreedCheckoutActualRent 覆盖 ApplyRefundTx 的结账单分支: +// 只有已达成一致的结账单(accepted / settled)才按其实际消费租金与号主实得重算, +// 仲裁结案的 settled 属于同一口径;未达成一致的 submitted 结账单不得被采用。 +func TestApplyRefundTxUsesAgreedCheckoutActualRent(t *testing.T) { + for _, tc := range []struct { + name string + checkoutStatus string + wantRentRefund int64 + }{ + {name: "accepted 结账单按实际租金重算", checkoutStatus: checkoutStatusAccepted, wantRentRefund: 2000}, + {name: "settled 结账单(仲裁结案)同口径", checkoutStatus: checkoutStatusSettled, wantRentRefund: 2000}, + {name: "submitted 结账单不被采用", checkoutStatus: "submitted", wantRentRefund: 1000}, + } { + t.Run(tc.name, func(t *testing.T) { + financial := snapshotAndRefundWithCheckout(t, tc.checkoutStatus) + if financial.RefundAmountCent != tc.wantRentRefund { + t.Fatalf("refund_amount_cent = %d, want %d", financial.RefundAmountCent, tc.wantRentRefund) + } + }) + } +} + +// snapshotAndRefundWithCheckout 构造一笔分销订单(租金 10000、号主底价 6000、无押金)、 +// 挂一张租金 8000 / 号主实得 5000 的结账单,快照后按 1000 退款,返回重算后的财务快照。 +func snapshotAndRefundWithCheckout(t *testing.T, checkoutStatus string) model.DistributionOrderFinancial { + t.Helper() + db := distributionTestDB(t) + + now := time.Now() + site := model.DistributionSite{ + SiteCode: "site_refund_" + checkoutStatus, + SiteName: "退款口径测试站", + Domain: "refund-" + checkoutStatus + ".dist.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, + Mode: ModeTwoPartyDistributorReceive, + Version: 1, + Status: "active", + DistributorReceiveRatio: 35, + PlatformSellRatio: 40, + ServiceFeeValue: 25, + ServiceFeeUnit: ServiceFeeUnitPercent, + PaymentFeeBPS: 50, + EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + order := model.RentalOrder{ + OrderNo: "ORD-REFUND-" + checkoutStatus, + ListingID: 9101, + AccountID: 9102, + OwnerID: 11, + RenterID: 12, + Status: "renting", + RentAmountCent: 10000, + OwnerRentAmountCent: 6000, + PlatformFeeCent: 4000, + DepositAmountCent: 0, + DistributionSiteID: &site.ID, + DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: 12, + Status: checkoutStatus, + RentAmountCent: 8000, + OwnerRentAmountCent: 5000, + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatalf("SnapshotPaidOrderTx failed: %v", err) + } + if err := ApplyRefundTx(db, order.ID, 1000); err != nil { + t.Fatalf("ApplyRefundTx failed: %v", err) + } + + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + return financial +} diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index cab53be..aaa143a 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -54,6 +54,16 @@ const ( checkoutStatusAccepted = "accepted" checkoutStatusDisputed = "disputed" + // 结账单状态的跨模块导出别名:dispute 仲裁结案写 settled,adminfinance 统计按 + // accepted 过滤,这些跨包位置不应再出现裸字符串。包内沿用上面的非导出名。 + // CheckoutStatusSettled 是仲裁终态(写回实际结算金额后置位),不属于 + // hasOpenCheckout 的"待结"集合——仲裁只发生在 completed 订单上。 + CheckoutStatusSubmitted = checkoutStatusSubmitted + CheckoutStatusCountered = checkoutStatusCountered + CheckoutStatusAccepted = checkoutStatusAccepted + CheckoutStatusDisputed = checkoutStatusDisputed + CheckoutStatusSettled = "settled" + checkoutTurnOwner = "owner" checkoutTurnRenter = "renter" diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 84490ad..f42a148 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -580,6 +580,7 @@ func TestCheckoutStatusConstantsAreDefined(t *testing.T) { checkoutStatusCountered, checkoutStatusAccepted, checkoutStatusDisputed, + CheckoutStatusSettled, } for _, status := range statuses { @@ -587,6 +588,22 @@ func TestCheckoutStatusConstantsAreDefined(t *testing.T) { t.Fatal("checkout status should not be empty") } } + + // 跨模块导出别名必须与包内常量取值一致:dispute 仲裁结案写 settled,adminfinance 按 accepted 过滤。 + exports := map[string]string{ + CheckoutStatusSubmitted: checkoutStatusSubmitted, + CheckoutStatusCountered: checkoutStatusCountered, + CheckoutStatusAccepted: checkoutStatusAccepted, + CheckoutStatusDisputed: checkoutStatusDisputed, + } + for exported, internal := range exports { + if exported != internal { + t.Fatalf("导出别名 %q 与包内常量 %q 不一致", exported, internal) + } + } + if CheckoutStatusSettled != "settled" { + t.Fatalf("CheckoutStatusSettled = %q, want settled", CheckoutStatusSettled) + } } // TestOrderDurationHours 测试订单时长计算