refactor(order): 结账单状态常量化并锁定仲裁结单统计口径
仲裁结案会把结账单置为终态 settled,但该状态一直是裸字符串;同时 adminfinance 按日结算聚合只按
accepted 过滤,容易被误读成"仲裁单掉出统计"。本次只做卫生与口径固化,不改任何行为:
- order 新增 CheckoutStatus{Submitted,Countered,Accepted,Disputed,Settled},包内沿用非导出别名
- dispute 的仲裁结案与争议取消分支改用常量;distribution 不能反向依赖 order,本地声明同名常量
- adminfinance 聚合里的 oc.status = 'accepted' 改为常量绑定,并注释说明仲裁单不计入是有意口径
- 新增 distribution 结账单退款重算用例(该分支原先无覆盖):accepted/settled 按结账单实际租金,
submitted 不被采用;dashboard 用例补一张 settled 结账单,钉住"不计入已结算订单数"
This commit is contained in:
@@ -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')").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 测试订单时长计算
|
||||
|
||||
Reference in New Issue
Block a user