diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 4edaf5c..0bf89d4 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -2,6 +2,7 @@ package adminfinance import ( "context" + "sync" "hfb_sys/backend/internal/timeutil" ) @@ -10,30 +11,63 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if cached := r.loadDashboardCache(ctx, query); cached != nil { return cached, nil } - dailyItems, err := r.dailyItems(ctx, query) - if err != nil { - return nil, err + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( + dailyItems []FinanceDailyDTO + pickup *PickupSummaryDTO + mohong *MohongSummaryDTO + disbursement *DisbursementSummaryDTO + operatingExpense *OperatingExpenseSummaryDTO + ) + var wg sync.WaitGroup + errCh := make(chan error, 5) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() } - pickup, err := r.pickupSummary(ctx, query) - if err != nil { - return nil, err + run(func() error { + var err error + dailyItems, err = r.dailyItems(ctx, query) + return err + }) + run(func() error { + var err error + pickup, err = r.pickupSummary(ctx, query) + return err + }) + run(func() error { + var err error + mohong, err = r.mohongSummary(ctx, query) + return err + }) + run(func() error { + var err error + disbursement, err = r.disbursementSummary(ctx, query) + return err + }) + run(func() error { + var err error + operatingExpense, err = r.operatingExpenseSummary(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return nil, err + } } + summary, err := r.summary(ctx, query, pickup) if err != nil { return nil, err } - mohong, err := r.mohongSummary(ctx, query) - if err != nil { - return nil, err - } - disbursement, err := r.disbursementSummary(ctx, query) - if err != nil { - return nil, err - } - operatingExpense, err := r.operatingExpenseSummary(ctx, query) - if err != nil { - return nil, err - } result := &DashboardDTO{ Summary: *summary, DailyItems: dailyItems, @@ -126,6 +160,7 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) { db := r.db.WithContext(ctx) var payment paymentSummaryRow + originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query) if err := db.Table("payment_orders AS po"). Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, @@ -135,12 +170,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Scan(&payment).Error; err != nil { return nil, err @@ -164,32 +194,31 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * THEN COALESCE(ro.offline_settlement_amount_cent, 0) ELSE 0 END), 0) AS offline_settlement_pending_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0 THEN 1 ELSE 0 END), 0) AS offline_settlement_pending_count, - COUNT(ro.id) AS settled_order_count`). + COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 + OR COALESCE(p.refunding_amount_cent, 0) > 0 + OR ro.refund_status = 'refunding' + OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE COALESCE(w.owner_wallet_income_amount_cent, 0) + END) >= ? + THEN 1 + ELSE 0 + END), 0) AS financial_exception_count, + COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent). Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)). + Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)). Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). Scan(&settlement).Error; err != nil { return nil, err } - var exceptionCount int64 - if err := db.Table("(?) AS d", r.financeDetailBaseQuery(ctx, DetailQuery{ - DateType: "settled", - StartDate: query.StartDate, - EndDate: query.EndDate, - })). - Where("finance_status <> ?", financeStatusNormal). - Where("finance_status <> ?", financeStatusOfflineSettlementPending). - Count(&exceptionCount).Error; err != nil { - return nil, err - } - // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。 var estimated estimatedIncomeRow if err := db.Table("rental_orders"). @@ -232,7 +261,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * SettledOrderCount: settlement.SettledOrderCount, PendingSettleOrderCount: estimated.PendingSettleOrderCount, OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount, - FinancialExceptionCount: exceptionCount, + FinancialExceptionCount: settlement.FinancialExceptionCount, }, nil } @@ -274,7 +303,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi END), 0) AS offline_settlement_pending_count, COUNT(ro.id) AS settled_order_count`). Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(db)). + 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(ro.settled_at)"). Scan(&settlements).Error; err != nil { @@ -439,7 +468,8 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi // normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { rows := make([]dailyPaymentRow, 0) - err := r.db.WithContext(ctx).Table("payment_orders AS po"). + db := r.db.WithContext(ctx) + err := db.Table("payment_orders AS po"). Select(`DATE(po.created_at) AS date, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, @@ -450,12 +480,7 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(po.created_at)"). Scan(&rows).Error @@ -512,6 +537,7 @@ type settlementSummaryRow struct { OfflineSettlementAmountCent int64 OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 SettledOrderCount int64 } diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index 4394252..754b040 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -240,3 +240,87 @@ func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) { t.Fatalf("调整日汇总 = %+v", adjusted) } } + +func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + for _, statement := range []string{ + `CREATE TABLE payment_orders ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, biz_type TEXT NOT NULL, + status TEXT NOT NULL, amount_cent INTEGER NOT NULL, created_at DATETIME NOT NULL + )`, + `CREATE TABLE rental_orders ( + id INTEGER PRIMARY KEY, settled_at DATETIME NULL, settlement_mode TEXT NOT NULL, + offline_settlement_amount_cent INTEGER NOT NULL, offline_settlement_status TEXT NOT NULL, + refund_status TEXT NOT NULL, settlement_status TEXT NOT NULL, status TEXT NOT NULL, + created_at DATETIME NOT NULL, platform_fee_cent INTEGER NOT NULL + )`, + `CREATE TABLE order_checkouts ( + id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, status TEXT NOT NULL, + platform_fee_cent INTEGER NOT NULL, owner_income_amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE wallet_ledger ( + id INTEGER PRIMARY KEY, order_id INTEGER NULL, direction TEXT NOT NULL, + biz_type TEXT NOT NULL, amount_cent INTEGER NOT NULL + )`, + `CREATE TABLE admin_pickups ( + id INTEGER PRIMARY KEY, status TEXT NOT NULL, settle_amount_cent INTEGER NOT NULL, + profit_amount_cent INTEGER NOT NULL, completed_at DATETIME NULL + )`, + `CREATE TABLE admin_pickup_financial_adjustments ( + id INTEGER PRIMARY KEY, pickup_id INTEGER NOT NULL, settle_delta_cent INTEGER NOT NULL, + profit_delta_cent INTEGER NOT NULL, created_at DATETIME NOT NULL + )`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("创建统计测试表失败: %v", err) + } + } + + loc := timeutil.ShanghaiLocation() + settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc) + for _, row := range [][]any{ + {1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300}, + {2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200}, + } { + if err := db.Exec(`INSERT INTO rental_orders + (id, settled_at, settlement_mode, offline_settlement_amount_cent, offline_settlement_status, + refund_status, settlement_status, status, created_at, platform_fee_cent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入订单失败: %v", err) + } + } + for _, row := range [][]any{{1, 1, "accepted", 300, 700}, {2, 2, "accepted", 200, 600}} { + if err := db.Exec(`INSERT INTO order_checkouts + (id, order_id, status, platform_fee_cent, owner_income_amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入结账单失败: %v", err) + } + } + for _, row := range [][]any{{1, 1, "in", "owner_income", 700}, {2, 2, "in", "owner_income", 500}} { + if err := db.Exec(`INSERT INTO wallet_ledger (id, order_id, direction, biz_type, amount_cent) VALUES (?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入钱包流水失败: %v", err) + } + } + + query := DashboardQuery{ + StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc), + } + repo := NewRepository(db) + pickup, err := repo.pickupSummary(t.Context(), query) + if err != nil { + t.Fatalf("读取提号汇总失败: %v", err) + } + summary, err := repo.summary(t.Context(), query, pickup) + if err != nil { + t.Fatalf("读取财务汇总失败: %v", err) + } + if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { + t.Fatalf("普通订单数量或利润错误: %+v", summary) + } + if summary.FinancialExceptionCount != 1 { + t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) + } +} diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go index 7ef386b..435c925 100644 --- a/backend/internal/modules/adminfinance/subquery.go +++ b/backend/internal/modules/adminfinance/subquery.go @@ -28,3 +28,44 @@ func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}). Group("order_id") } + +// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。 +// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合, +// 再使用 wallet_ledger.order_id 索引关联对应流水。 +func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + return db.Table("rental_orders AS scoped_order"). + Select(`scoped_order.id AS order_id, + COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`). + Joins(`LEFT JOIN wallet_ledger AS wallet + ON wallet.order_id = scoped_order.id + AND wallet.direction = ? + AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation"}). + Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate). + Group("scoped_order.id") +} + +// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态, +// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。 +func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + return db.Table("rental_orders AS scoped_order"). + Select(`scoped_order.id AS order_id, + COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent, + COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()). + Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()). + Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate). + Group("scoped_order.id") +} + +// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。 +// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。 +func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB { + refundOrders := db.Table("payment_orders AS refund"). + Select("DISTINCT refund.order_id"). + Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded"). + Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate) + return db.Table("payment_orders AS paid"). + Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent"). + Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders). + Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid"). + Group("paid.order_id") +} diff --git a/backend/migrations/000060_finance_dashboard_indexes.sql b/backend/migrations/000060_finance_dashboard_indexes.sql new file mode 100644 index 0000000..2a1cd14 --- /dev/null +++ b/backend/migrations/000060_finance_dashboard_indexes.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- 财务仪表盘会按完成/取消日期读取提号,并按调整创建日汇总利润。 +-- 补齐复合索引,避免数据增长后统计退化为全表扫描。 +-- 该版本曾在部分开发库中部分执行,以下写法兼容已有同名索引后再次运行。 + +-- +goose StatementBegin +SET @pickup_completed_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_completed_at' +); +SET @pickup_completed_index_sql = IF( + @pickup_completed_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_completed_at (status, completed_at)', + 'SELECT 1' +); +PREPARE pickup_completed_index_statement FROM @pickup_completed_index_sql; +EXECUTE pickup_completed_index_statement; +DEALLOCATE PREPARE pickup_completed_index_statement; + +SET @pickup_cancelled_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickups' + AND index_name = 'idx_admin_pickups_status_cancelled_at' +); +SET @pickup_cancelled_index_sql = IF( + @pickup_cancelled_index_exists = 0, + 'ALTER TABLE admin_pickups ADD KEY idx_admin_pickups_status_cancelled_at (status, cancelled_at)', + 'SELECT 1' +); +PREPARE pickup_cancelled_index_statement FROM @pickup_cancelled_index_sql; +EXECUTE pickup_cancelled_index_statement; +DEALLOCATE PREPARE pickup_cancelled_index_statement; + +SET @pickup_adjustment_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'admin_pickup_financial_adjustments' + AND index_name = 'idx_admin_pickup_financial_adjustments_created_at' +); +SET @pickup_adjustment_index_sql = IF( + @pickup_adjustment_index_exists = 0, + 'ALTER TABLE admin_pickup_financial_adjustments ADD KEY idx_admin_pickup_financial_adjustments_created_at (created_at)', + 'SELECT 1' +); +PREPARE pickup_adjustment_index_statement FROM @pickup_adjustment_index_sql; +EXECUTE pickup_adjustment_index_statement; +DEALLOCATE PREPARE pickup_adjustment_index_statement; +-- +goose StatementEnd + +-- +goose Down + +-- 补偿迁移不删除索引,避免回滚时误删此前已存在的同名索引。 diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 704d51f..166178d 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -218,10 +218,12 @@ function diffType(value: number | undefined | null) {
{{ moneyCent(dashboard.summary.platform_income_amount_cent) }}