From ba5aa48c8f6a767cd142fd60fdb8e65aa9eaf093 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 4 Sep 2026 21:37:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A2=E5=8A=A1=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E6=9F=A5=E8=AF=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 421 +++++----- .../adminfinance/dashboard_profile_test.go | 45 ++ .../modules/adminfinance/dashboard_test.go | 41 +- .../internal/modules/adminfinance/detail.go | 26 +- .../internal/modules/adminfinance/subquery.go | 25 +- ...063_finance_pending_disbursement_index.sql | 13 + docs/DIFF_FILE | 733 ++++++++++++++++++ docs/MODIFIED_FILE | 609 +++++++++++++++ docs/ROLLBACK.sh | 7 + docs/VERIFICATION.txt | 34 + docs/finance-dashboard-performance.md | 36 + 11 files changed, 1748 insertions(+), 242 deletions(-) create mode 100644 backend/internal/modules/adminfinance/dashboard_profile_test.go create mode 100644 backend/migrations/000063_finance_pending_disbursement_index.sql create mode 100644 docs/DIFF_FILE create mode 100644 docs/MODIFIED_FILE create mode 100755 docs/ROLLBACK.sh create mode 100644 docs/VERIFICATION.txt create mode 100644 docs/finance-dashboard-performance.md diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index f868ff7..17da89c 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -14,14 +14,12 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 var ( - dailyItems []FinanceDailyDTO - pickup *PickupSummaryDTO - mohong *MohongSummaryDTO - disbursement *DisbursementSummaryDTO - operatingExpense *OperatingExpenseSummaryDTO + daily dashboardDailyResult + pickup *PickupSummaryDTO + disbursement *DisbursementSummaryDTO ) var wg sync.WaitGroup - errCh := make(chan error, 5) + errCh := make(chan error, 3) run := func(fn func() error) { wg.Add(1) go func() { @@ -33,7 +31,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash } run(func() error { var err error - dailyItems, err = r.dailyItems(ctx, query) + daily, err = r.dailyItems(ctx, query) return err }) run(func() error { @@ -41,21 +39,11 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash 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 { @@ -64,17 +52,14 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash } } - summary, err := r.summary(ctx, query, pickup) - if err != nil { - return nil, err - } + summary := summaryWithPickup(daily.Summary, pickup) result := &DashboardDTO{ - Summary: *summary, - DailyItems: dailyItems, + Summary: summary, + DailyItems: daily.Items, PickupSummary: *pickup, - MohongSummary: *mohong, + MohongSummary: daily.Mohong, DisbursementSummary: *disbursement, - OperatingExpenseSummary: *operatingExpense, + OperatingExpenseSummary: daily.OperatingExpense, GeneratedAt: timeutil.ShanghaiNow(), } r.storeDashboardCache(ctx, query, result) @@ -179,157 +164,57 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* }, nil } -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, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 full_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 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 (?) 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 - } - - var settlement settlementSummaryRow - if err := db.Table("rental_orders AS ro"). - Select(`COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, - COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, - COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, - COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - 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' - 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, - 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", 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 estimated estimatedIncomeRow - if err := db.Table("rental_orders"). - Select(`COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, - COUNT(id) AS pending_settle_order_count`). - Where("settlement_status NOT IN ?", settledSettlementStatuses()). - Where("status NOT IN ?", nonBillableOrderStatuses()). - Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). - Scan(&estimated).Error; err != nil { - return nil, err - } - - pickupProfitAmountCent := int64(0) - pickupCompletedCount := int64(0) - if pickup != nil { - pickupProfitAmountCent = pickup.ProfitAmountCent - pickupCompletedCount = pickup.CompletedCount - } - return &FinanceSummaryDTO{ - TotalFlowAmountCent: payment.TotalFlowAmountCent, - TotalRefundAmountCent: payment.TotalRefundAmountCent, - PendingRefundAmountCent: payment.PendingRefundAmountCent, - ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent, - PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent, - NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent, - PickupProfitAmountCent: pickupProfitAmountCent, - PickupCompletedCount: pickupCompletedCount, - OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent, - OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent, - OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent, - OfflineSettlementAmountCent: settlement.OfflineSettlementAmountCent, - OfflineSettlementPendingAmountCent: settlement.OfflineSettlementPendingAmountCent + pickup.PendingAmountCent, - SettlementDiffAmountCent: settlement.OwnerShouldIncomeAmountCent - settlement.OwnerEffectiveSettlementAmountCent, - EstimatedIncomeAmountCent: estimated.EstimatedIncomeAmountCent, - SuccessfulPayCount: payment.SuccessfulPayCount, - SuccessfulRefundCount: payment.FullRefundCount + payment.PartialRefundCount, - FullRefundCount: payment.FullRefundCount, - PartialRefundCount: payment.PartialRefundCount, - PendingRefundCount: payment.PendingRefundCount, - SettledOrderCount: settlement.SettledOrderCount, - PendingSettleOrderCount: estimated.PendingSettleOrderCount, - OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount + pickup.PendingCount, - FinancialExceptionCount: settlement.FinancialExceptionCount, - }, nil +type dashboardDailyResult struct { + Items []FinanceDailyDTO + Summary FinanceSummaryDTO + Mohong MohongSummaryDTO + OperatingExpense OperatingExpenseSummaryDTO } -func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) { +func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { db := r.db.WithContext(ctx) - payments, err := r.dailyPayments(ctx, query) - if err != nil { - return nil, err + var ( + payments []dailyPaymentRow + settlements []dailySettlementRow + estimates []dailyEstimatedRow + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() } - paidOrders, err := r.dailyPaidOrders(ctx, query) - if err != nil { - return nil, err + run(func() error { + var err error + payments, err = r.dailyPayments(ctx, query) + return err + }) + run(func() error { + var err error + settlements, err = r.dailySettlements(ctx, query) + return err + }) + run(func() error { + var err error + estimates, err = r.dailyEstimates(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return dashboardDailyResult{}, err + } } - settlements := make([]dailySettlementRow, 0) - if err := db.Table("rental_orders AS ro"). - Select(`DATE(ro.settled_at) AS date, - COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, - COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, - COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, - COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - THEN COALESCE(ro.offline_settlement_amount_cent, 0) - ELSE 0 - END), 0) AS offline_settlement_amount_cent, - COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' - AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' - 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' - 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`). - 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", 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 { - return nil, err + paidOrders, err := r.dailyPaidOrders(ctx, query) + if err != nil { + return dashboardDailyResult{}, err } pickupProfits := make([]dailyPickupProfitRow, 0) @@ -341,7 +226,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). Group("DATE(completed_at)"). Scan(&pickupProfits).Error; err != nil { - return nil, err + return dashboardDailyResult{}, err } pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) if err := db.Table("admin_pickup_financial_adjustments AS fa"). @@ -351,35 +236,23 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(fa.created_at)"). Scan(&pickupAdjustments).Error; err != nil { - return nil, err + return dashboardDailyResult{}, err } // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 pickupCancelled, err := r.dailyPickupCancelled(ctx, query) if err != nil { - return nil, err + return dashboardDailyResult{}, err } // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 - estimates := make([]dailyEstimatedRow, 0) - if err := db.Table("rental_orders"). - Select(`DATE(created_at) AS date, - COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent`). - Where("settlement_status NOT IN ?", settledSettlementStatuses()). - Where("status NOT IN ?", nonBillableOrderStatuses()). - Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). - Group("DATE(created_at)"). - Scan(&estimates).Error; err != nil { - return nil, err - } - disbursements, err := r.dailyDisbursements(ctx, query) if err != nil { - return nil, err + return dashboardDailyResult{}, err } operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) if err != nil { - return nil, err + return dashboardDailyResult{}, err } itemsByDate := make(map[string]FinanceDailyDTO) @@ -483,7 +356,142 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { items = append(items, itemsByDate[day.Format("2006-01-02")]) } - return items, nil + return dashboardDailyResult{ + Items: items, + Summary: summaryFromDailyRows(payments, settlements, estimates), + Mohong: mohongSummaryFromDailyRows(payments), + OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), + }, nil +} + +func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { + rows := make([]dailyEstimatedRow, 0) + err := r.db.WithContext(ctx).Table("rental_orders"). + Select(`DATE(created_at) AS date, + COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, + COUNT(id) AS pending_settle_order_count`). + Where("settlement_status NOT IN ?", settledSettlementStatuses()). + Where("status NOT IN ?", nonBillableOrderStatuses()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(created_at)"). + Scan(&rows).Error + return rows, err +} + +func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { + db := r.db.WithContext(ctx) + rows := make([]dailySettlementRow, 0) + err := db.Table("rental_orders AS ro"). + Select(`DATE(ro.settled_at) AS date, + COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, + COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, + COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + 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' + 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, + COALESCE(SUM(CASE WHEN 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) >= ? + OR EXISTS ( + SELECT 1 FROM payment_orders AS exception_payment + WHERE exception_payment.order_id = ro.id + AND exception_payment.biz_type IN ? + AND exception_payment.status IN ? + AND exception_payment.amount_cent > 0 + ) + THEN 1 + 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'"). + 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(&rows).Error + return rows, err +} + +func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { + var summary FinanceSummaryDTO + for _, row := range payments { + summary.TotalFlowAmountCent += row.TotalFlowAmountCent + summary.TotalRefundAmountCent += row.TotalRefundAmountCent + summary.PendingRefundAmountCent += row.PendingRefundAmountCent + summary.SuccessfulPayCount += row.SuccessfulPayCount + summary.FullRefundCount += row.FullRefundCount + summary.PartialRefundCount += row.PartialRefundCount + summary.PendingRefundCount += row.PendingRefundCount + } + for _, row := range settlements { + summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent + summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent + summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent + summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent + summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent + summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent + summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount + summary.FinancialExceptionCount += row.FinancialExceptionCount + summary.SettledOrderCount += row.SettledOrderCount + } + for _, row := range estimates { + summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent + summary.PendingSettleOrderCount += row.PendingSettleOrderCount + } + summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent + summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent + return summary +} + +func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { + if pickup == nil { + return summary + } + summary.PickupProfitAmountCent = pickup.ProfitAmountCent + summary.PickupCompletedCount = pickup.CompletedCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent + summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent + summary.OfflineSettlementPendingCount += pickup.PendingCount + return summary +} + +func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { + var summary MohongSummaryDTO + for _, row := range rows { + summary.FlowAmountCent += row.MohongFlowAmountCent + summary.RefundAmountCent += row.MohongRefundAmountCent + summary.PaidOrderCount += row.MohongPaidOrderCount + } + summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent + return summary +} + +func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { + var summary OperatingExpenseSummaryDTO + for _, row := range rows { + summary.AmountCent += row.AmountCent + summary.Count += row.Count + } + return summary } // dailyPayments 按支付单创建日统计收付款流水与退款笔数, @@ -500,7 +508,10 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ 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 full_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 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, - 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`, + 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, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). 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). @@ -541,33 +552,6 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) return items, err } -type paymentSummaryRow struct { - TotalFlowAmountCent int64 - TotalRefundAmountCent int64 - PendingRefundAmountCent int64 - SuccessfulPayCount int64 - FullRefundCount int64 - PartialRefundCount int64 - PendingRefundCount int64 -} - -type settlementSummaryRow struct { - PlatformIncomeAmountCent int64 - OwnerShouldIncomeAmountCent int64 - OwnerWalletIncomeAmountCent int64 - OwnerEffectiveSettlementAmountCent int64 - OfflineSettlementAmountCent int64 - OfflineSettlementPendingAmountCent int64 - OfflineSettlementPendingCount int64 - FinancialExceptionCount int64 - SettledOrderCount int64 -} - -type estimatedIncomeRow struct { - EstimatedIncomeAmountCent int64 - PendingSettleOrderCount int64 -} - type dailyPaymentRow struct { Date string TotalFlowAmountCent int64 @@ -578,6 +562,9 @@ type dailyPaymentRow struct { PartialRefundCount int64 PendingRefundCount int64 NormalFullRefundCount int64 + MohongFlowAmountCent int64 + MohongRefundAmountCent int64 + MohongPaidOrderCount int64 } type dailyPickupCancelledRow struct { @@ -599,6 +586,7 @@ type dailySettlementRow struct { OfflineSettlementAmountCent int64 OfflineSettlementPendingAmountCent int64 OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 SettledOrderCount int64 } @@ -617,4 +605,5 @@ type dailyPickupAdjustmentRow struct { type dailyEstimatedRow struct { Date string EstimatedIncomeAmountCent int64 + PendingSettleOrderCount int64 } diff --git a/backend/internal/modules/adminfinance/dashboard_profile_test.go b/backend/internal/modules/adminfinance/dashboard_profile_test.go new file mode 100644 index 0000000..00d2e3e --- /dev/null +++ b/backend/internal/modules/adminfinance/dashboard_profile_test.go @@ -0,0 +1,45 @@ +package adminfinance + +import ( + "encoding/json" + "os" + "testing" + "time" + + "hfb_sys/backend/internal/timeutil" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestProfileDashboardOnMySQL(t *testing.T) { + dsn := os.Getenv("HFB_ADMIN_FINANCE_TEST_DSN") + if dsn == "" { + t.Skip("profile only") + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) + if err != nil { + t.Fatal(err) + } + now := timeutil.ShanghaiNow() + started := time.Now() + result, err := NewRepository(db).Dashboard(t.Context(), DashboardQuery{ + StartDate: time.Date(now.Year(), now.Month(), now.Day()-6, 0, 0, 0, 0, timeutil.ShanghaiLocation()), + EndDate: time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, timeutil.ShanghaiLocation()), + }) + if err != nil { + t.Fatal(err) + } + t.Logf("dashboard duration=%s daily_items=%d", time.Since(started), len(result.DailyItems)) + if output := os.Getenv("HFB_ADMIN_FINANCE_PROFILE_OUTPUT"); output != "" { + result.GeneratedAt = time.Time{} + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, data, 0o600); err != nil { + t.Fatal(err) + } + } +} diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index a0821c2..1ce17f2 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -321,10 +321,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { if err != nil { t.Fatalf("读取提号汇总失败: %v", err) } - summary, err := repo.summary(t.Context(), query, pickup) + settlements, err := repo.dailySettlements(t.Context(), query) if err != nil { - t.Fatalf("读取财务汇总失败: %v", err) + t.Fatalf("读取每日结算汇总失败: %v", err) } + summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup) if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { t.Fatalf("普通订单数量或利润错误: %+v", summary) } @@ -332,3 +333,39 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) } } + +func TestSummaryFromDailyRowsPreservesDashboardTotals(t *testing.T) { + paymentRows := []dailyPaymentRow{ + {TotalFlowAmountCent: 1000, TotalRefundAmountCent: 100, PendingRefundAmountCent: 30, SuccessfulPayCount: 3, FullRefundCount: 1}, + {TotalFlowAmountCent: 2500, TotalRefundAmountCent: 400, PendingRefundAmountCent: 70, SuccessfulPayCount: 5, PartialRefundCount: 2, PendingRefundCount: 1}, + } + settlementRows := []dailySettlementRow{ + {PlatformIncomeAmountCent: 300, OwnerShouldIncomeAmountCent: 900, OwnerWalletIncomeAmountCent: 850, OwnerEffectiveSettlementAmountCent: 850, OfflineSettlementAmountCent: 100, OfflineSettlementPendingAmountCent: 20, OfflineSettlementPendingCount: 1, FinancialExceptionCount: 2, SettledOrderCount: 3}, + {PlatformIncomeAmountCent: 200, OwnerShouldIncomeAmountCent: 600, OwnerWalletIncomeAmountCent: 500, OwnerEffectiveSettlementAmountCent: 550, OfflineSettlementAmountCent: 200, OfflineSettlementPendingAmountCent: 40, OfflineSettlementPendingCount: 2, FinancialExceptionCount: 1, SettledOrderCount: 2}, + } + estimateRows := []dailyEstimatedRow{ + {EstimatedIncomeAmountCent: 120, PendingSettleOrderCount: 4}, + {EstimatedIncomeAmountCent: 80, PendingSettleOrderCount: 3}, + } + pickup := &PickupSummaryDTO{ProfitAmountCent: 90, CompletedCount: 2, PendingAmountCent: 60, PendingCount: 3} + + summary := summaryWithPickup(summaryFromDailyRows(paymentRows, settlementRows, estimateRows), pickup) + if summary.TotalFlowAmountCent != 3500 || summary.TotalRefundAmountCent != 500 || summary.ChannelNetAmountCent != 3000 { + t.Fatalf("支付汇总错误: %+v", summary) + } + if summary.PendingRefundAmountCent != 100 || summary.SuccessfulPayCount != 8 || summary.SuccessfulRefundCount != 3 || summary.FullRefundCount != 1 || summary.PartialRefundCount != 2 || summary.PendingRefundCount != 1 { + t.Fatalf("支付笔数汇总错误: %+v", summary) + } + if summary.NormalOrderProfitAmountCent != 500 || summary.PickupProfitAmountCent != 90 || summary.PlatformIncomeAmountCent != 590 || summary.PickupCompletedCount != 2 { + t.Fatalf("平台利润汇总错误: %+v", summary) + } + if summary.OwnerShouldIncomeAmountCent != 1500 || summary.OwnerWalletIncomeAmountCent != 1350 || summary.OwnerEffectiveSettlementAmountCent != 1400 || summary.SettlementDiffAmountCent != 100 { + t.Fatalf("货主结算汇总错误: %+v", summary) + } + if summary.OfflineSettlementAmountCent != 300 || summary.OfflineSettlementPendingAmountCent != 120 || summary.OfflineSettlementPendingCount != 6 { + t.Fatalf("线下结算汇总错误: %+v", summary) + } + if summary.EstimatedIncomeAmountCent != 200 || summary.PendingSettleOrderCount != 7 || summary.SettledOrderCount != 5 || summary.FinancialExceptionCount != 3 { + t.Fatalf("订单状态汇总错误: %+v", summary) + } +} diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go index f4d40ee..ce4feca 100644 --- a/backend/internal/modules/adminfinance/detail.go +++ b/backend/internal/modules/adminfinance/detail.go @@ -19,11 +19,23 @@ func (r *Repository) Details(ctx context.Context, query DetailQuery) (*Paginated return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil } - // 明细行查询保留完整 JOIN,仅对当前分页命中的订单做金额聚合。 - db := r.financeDetailBaseQuery(ctx, query) offset := (query.Page - 1) * query.PageSize + orderIDs := make([]uint64, 0, query.PageSize) + if err := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query). + Order("ro.id DESC"). + Offset(offset). + Limit(query.PageSize). + Pluck("ro.id", &orderIDs).Error; err != nil { + return nil, err + } + if len(orderIDs) == 0 { + return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: total, Page: query.Page, PageSize: query.PageSize}, nil + } + + // 先取当前页 ID,再只为这些订单聚合结账、支付和钱包详情。 + db := r.financeDetailBaseQuery(ctx, query, orderIDs).Where("ro.id IN ?", orderIDs) rows := make([]financeDetailRow, 0, query.PageSize) - if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { + if err := db.Order("ro.id DESC").Scan(&rows).Error; err != nil { return nil, err } items := make([]FinanceDetailDTO, 0, len(rows)) @@ -67,7 +79,7 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB return db } -func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery) *gorm.DB { +func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery, orderIDs []uint64) *gorm.DB { db := r.db.WithContext(ctx).Table("rental_orders AS ro"). Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status, ro.handoff_mode, ro.settlement_mode, @@ -113,9 +125,9 @@ func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQue ro.created_at, ro.settled_at, ro.offline_settled_at`, settlementDiffThresholdCent). Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id"). Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id"). - Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db.WithContext(ctx))). - Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db.WithContext(ctx))). - Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db.WithContext(ctx))) + Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). + Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForOrdersSubquery(r.db.WithContext(ctx), orderIDs)) return r.applyDetailFilters(db, query) } diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go index 18be7d9..6759d09 100644 --- a/backend/internal/modules/adminfinance/subquery.go +++ b/backend/internal/modules/adminfinance/subquery.go @@ -4,14 +4,16 @@ import ( "gorm.io/gorm" ) -func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB { - latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id") +func acceptedCheckoutForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + latest := db.Table("order_checkouts").Select("MAX(id) AS id"). + Where("status = ? AND order_id IN ?", "accepted", orderIDs). + Group("order_id") return db.Table("order_checkouts AS oc"). Select("oc.*"). Joins("JOIN (?) AS latest ON latest.id = oc.id", latest) } -func orderPaymentSubquery(db *gorm.DB) *gorm.DB { +func orderPaymentForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { return db.Table("payment_orders"). Select(`order_id, COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, @@ -19,13 +21,14 @@ func orderPaymentSubquery(db *gorm.DB) *gorm.DB { COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent, COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes(), refundBizTypes()). + Where("order_id IN ?", orderIDs). Group("order_id") } -func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { +func ownerWalletIncomeForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { return db.Table("wallet_ledger"). Select("order_id, COALESCE(SUM(amount_cent), 0) AS owner_wallet_income_amount_cent"). - Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}). + Where("direction = ? AND biz_type IN ? AND order_id IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs). Group("order_id") } @@ -44,18 +47,6 @@ func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery 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 { diff --git a/backend/migrations/000063_finance_pending_disbursement_index.sql b/backend/migrations/000063_finance_pending_disbursement_index.sql new file mode 100644 index 0000000..3d6190f --- /dev/null +++ b/backend/migrations/000063_finance_pending_disbursement_index.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- 财务仪表盘的待线下出款金额不受日期限制,需要持续读取全部待出款订单。 +-- 覆盖查询条件与汇总金额,避免通过低区分度状态索引回表扫描。 +ALTER TABLE rental_orders + ADD KEY idx_rental_orders_offline_pending_amount ( + settlement_mode, + offline_settlement_status, + offline_settlement_amount_cent + ); + +-- +goose Down +ALTER TABLE rental_orders + DROP KEY idx_rental_orders_offline_pending_amount; diff --git a/docs/DIFF_FILE b/docs/DIFF_FILE new file mode 100644 index 0000000..5a8af76 --- /dev/null +++ b/docs/DIFF_FILE @@ -0,0 +1,733 @@ +diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go +index f868ff7..17da89c 100644 +--- a/backend/internal/modules/adminfinance/dashboard.go ++++ b/backend/internal/modules/adminfinance/dashboard.go +@@ -14,14 +14,12 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( +- dailyItems []FinanceDailyDTO +- pickup *PickupSummaryDTO +- mohong *MohongSummaryDTO +- disbursement *DisbursementSummaryDTO +- operatingExpense *OperatingExpenseSummaryDTO ++ daily dashboardDailyResult ++ pickup *PickupSummaryDTO ++ disbursement *DisbursementSummaryDTO + ) + var wg sync.WaitGroup +- errCh := make(chan error, 5) ++ errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { +@@ -33,7 +31,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + } + run(func() error { + var err error +- dailyItems, err = r.dailyItems(ctx, query) ++ daily, err = r.dailyItems(ctx, query) + return err + }) + run(func() error { +@@ -41,21 +39,11 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + 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 { +@@ -64,17 +52,14 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash + } + } + +- summary, err := r.summary(ctx, query, pickup) +- if err != nil { +- return nil, err +- } ++ summary := summaryWithPickup(daily.Summary, pickup) + result := &DashboardDTO{ +- Summary: *summary, +- DailyItems: dailyItems, ++ Summary: summary, ++ DailyItems: daily.Items, + PickupSummary: *pickup, +- MohongSummary: *mohong, ++ MohongSummary: daily.Mohong, + DisbursementSummary: *disbursement, +- OperatingExpenseSummary: *operatingExpense, ++ OperatingExpenseSummary: daily.OperatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), + } + r.storeDashboardCache(ctx, query, result) +@@ -179,157 +164,57 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (* + }, nil + } + +-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, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, +- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 full_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 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 (?) 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 +- } +- +- var settlement settlementSummaryRow +- if err := db.Table("rental_orders AS ro"). +- Select(`COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, +- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, +- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, +- COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- 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' +- 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, +- 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", 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 estimated estimatedIncomeRow +- if err := db.Table("rental_orders"). +- Select(`COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, +- COUNT(id) AS pending_settle_order_count`). +- Where("settlement_status NOT IN ?", settledSettlementStatuses()). +- Where("status NOT IN ?", nonBillableOrderStatuses()). +- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). +- Scan(&estimated).Error; err != nil { +- return nil, err +- } +- +- pickupProfitAmountCent := int64(0) +- pickupCompletedCount := int64(0) +- if pickup != nil { +- pickupProfitAmountCent = pickup.ProfitAmountCent +- pickupCompletedCount = pickup.CompletedCount +- } +- return &FinanceSummaryDTO{ +- TotalFlowAmountCent: payment.TotalFlowAmountCent, +- TotalRefundAmountCent: payment.TotalRefundAmountCent, +- PendingRefundAmountCent: payment.PendingRefundAmountCent, +- ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent, +- PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent, +- NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent, +- PickupProfitAmountCent: pickupProfitAmountCent, +- PickupCompletedCount: pickupCompletedCount, +- OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent, +- OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent, +- OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent, +- OfflineSettlementAmountCent: settlement.OfflineSettlementAmountCent, +- OfflineSettlementPendingAmountCent: settlement.OfflineSettlementPendingAmountCent + pickup.PendingAmountCent, +- SettlementDiffAmountCent: settlement.OwnerShouldIncomeAmountCent - settlement.OwnerEffectiveSettlementAmountCent, +- EstimatedIncomeAmountCent: estimated.EstimatedIncomeAmountCent, +- SuccessfulPayCount: payment.SuccessfulPayCount, +- SuccessfulRefundCount: payment.FullRefundCount + payment.PartialRefundCount, +- FullRefundCount: payment.FullRefundCount, +- PartialRefundCount: payment.PartialRefundCount, +- PendingRefundCount: payment.PendingRefundCount, +- SettledOrderCount: settlement.SettledOrderCount, +- PendingSettleOrderCount: estimated.PendingSettleOrderCount, +- OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount + pickup.PendingCount, +- FinancialExceptionCount: settlement.FinancialExceptionCount, +- }, nil ++type dashboardDailyResult struct { ++ Items []FinanceDailyDTO ++ Summary FinanceSummaryDTO ++ Mohong MohongSummaryDTO ++ OperatingExpense OperatingExpenseSummaryDTO + } + +-func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) { ++func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { + db := r.db.WithContext(ctx) +- payments, err := r.dailyPayments(ctx, query) +- if err != nil { +- return nil, err ++ var ( ++ payments []dailyPaymentRow ++ settlements []dailySettlementRow ++ estimates []dailyEstimatedRow ++ ) ++ var wg sync.WaitGroup ++ errCh := make(chan error, 3) ++ run := func(fn func() error) { ++ wg.Add(1) ++ go func() { ++ defer wg.Done() ++ if err := fn(); err != nil { ++ errCh <- err ++ } ++ }() + } +- paidOrders, err := r.dailyPaidOrders(ctx, query) +- if err != nil { +- return nil, err ++ run(func() error { ++ var err error ++ payments, err = r.dailyPayments(ctx, query) ++ return err ++ }) ++ run(func() error { ++ var err error ++ settlements, err = r.dailySettlements(ctx, query) ++ return err ++ }) ++ run(func() error { ++ var err error ++ estimates, err = r.dailyEstimates(ctx, query) ++ return err ++ }) ++ wg.Wait() ++ close(errCh) ++ for err := range errCh { ++ if err != nil { ++ return dashboardDailyResult{}, err ++ } + } + +- settlements := make([]dailySettlementRow, 0) +- if err := db.Table("rental_orders AS ro"). +- Select(`DATE(ro.settled_at) AS date, +- COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, +- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, +- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, +- COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- THEN COALESCE(ro.offline_settlement_amount_cent, 0) +- ELSE 0 +- END), 0) AS offline_settlement_amount_cent, +- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' +- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' +- 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' +- 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`). +- 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", 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 { +- return nil, err ++ paidOrders, err := r.dailyPaidOrders(ctx, query) ++ if err != nil { ++ return dashboardDailyResult{}, err + } + + pickupProfits := make([]dailyPickupProfitRow, 0) +@@ -341,7 +226,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Group("DATE(completed_at)"). + Scan(&pickupProfits).Error; err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) + if err := db.Table("admin_pickup_financial_adjustments AS fa"). +@@ -351,35 +236,23 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(fa.created_at)"). + Scan(&pickupAdjustments).Error; err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 + pickupCancelled, err := r.dailyPickupCancelled(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 +- estimates := make([]dailyEstimatedRow, 0) +- if err := db.Table("rental_orders"). +- Select(`DATE(created_at) AS date, +- COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent`). +- Where("settlement_status NOT IN ?", settledSettlementStatuses()). +- Where("status NOT IN ?", nonBillableOrderStatuses()). +- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). +- Group("DATE(created_at)"). +- Scan(&estimates).Error; err != nil { +- return nil, err +- } +- + disbursements, err := r.dailyDisbursements(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { +- return nil, err ++ return dashboardDailyResult{}, err + } + + itemsByDate := make(map[string]FinanceDailyDTO) +@@ -483,7 +356,142 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + items = append(items, itemsByDate[day.Format("2006-01-02")]) + } +- return items, nil ++ return dashboardDailyResult{ ++ Items: items, ++ Summary: summaryFromDailyRows(payments, settlements, estimates), ++ Mohong: mohongSummaryFromDailyRows(payments), ++ OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), ++ }, nil ++} ++ ++func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { ++ rows := make([]dailyEstimatedRow, 0) ++ err := r.db.WithContext(ctx).Table("rental_orders"). ++ Select(`DATE(created_at) AS date, ++ COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, ++ COUNT(id) AS pending_settle_order_count`). ++ Where("settlement_status NOT IN ?", settledSettlementStatuses()). ++ Where("status NOT IN ?", nonBillableOrderStatuses()). ++ Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). ++ Group("DATE(created_at)"). ++ Scan(&rows).Error ++ return rows, err ++} ++ ++func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { ++ db := r.db.WithContext(ctx) ++ rows := make([]dailySettlementRow, 0) ++ err := db.Table("rental_orders AS ro"). ++ Select(`DATE(ro.settled_at) AS date, ++ COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, ++ COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, ++ COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, ++ COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, ++ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' ++ THEN COALESCE(ro.offline_settlement_amount_cent, 0) ++ ELSE 0 ++ END), 0) AS offline_settlement_amount_cent, ++ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' ++ AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' ++ 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' ++ 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, ++ COALESCE(SUM(CASE WHEN 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) >= ? ++ OR EXISTS ( ++ SELECT 1 FROM payment_orders AS exception_payment ++ WHERE exception_payment.order_id = ro.id ++ AND exception_payment.biz_type IN ? ++ AND exception_payment.status IN ? ++ AND exception_payment.amount_cent > 0 ++ ) ++ THEN 1 ++ 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'"). ++ 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(&rows).Error ++ return rows, err ++} ++ ++func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { ++ var summary FinanceSummaryDTO ++ for _, row := range payments { ++ summary.TotalFlowAmountCent += row.TotalFlowAmountCent ++ summary.TotalRefundAmountCent += row.TotalRefundAmountCent ++ summary.PendingRefundAmountCent += row.PendingRefundAmountCent ++ summary.SuccessfulPayCount += row.SuccessfulPayCount ++ summary.FullRefundCount += row.FullRefundCount ++ summary.PartialRefundCount += row.PartialRefundCount ++ summary.PendingRefundCount += row.PendingRefundCount ++ } ++ for _, row := range settlements { ++ summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent ++ summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent ++ summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent ++ summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent ++ summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent ++ summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent ++ summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount ++ summary.FinancialExceptionCount += row.FinancialExceptionCount ++ summary.SettledOrderCount += row.SettledOrderCount ++ } ++ for _, row := range estimates { ++ summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent ++ summary.PendingSettleOrderCount += row.PendingSettleOrderCount ++ } ++ summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent ++ summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount ++ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent ++ summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent ++ return summary ++} ++ ++func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { ++ if pickup == nil { ++ return summary ++ } ++ summary.PickupProfitAmountCent = pickup.ProfitAmountCent ++ summary.PickupCompletedCount = pickup.CompletedCount ++ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent ++ summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent ++ summary.OfflineSettlementPendingCount += pickup.PendingCount ++ return summary ++} ++ ++func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { ++ var summary MohongSummaryDTO ++ for _, row := range rows { ++ summary.FlowAmountCent += row.MohongFlowAmountCent ++ summary.RefundAmountCent += row.MohongRefundAmountCent ++ summary.PaidOrderCount += row.MohongPaidOrderCount ++ } ++ summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent ++ return summary ++} ++ ++func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { ++ var summary OperatingExpenseSummaryDTO ++ for _, row := range rows { ++ summary.AmountCent += row.AmountCent ++ summary.Count += row.Count ++ } ++ return summary + } + + // dailyPayments 按支付单创建日统计收付款流水与退款笔数, +@@ -500,7 +508,10 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([ + 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 full_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 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, +- 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`, ++ 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, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, ++ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). + 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). +@@ -541,33 +552,6 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) + return items, err + } + +-type paymentSummaryRow struct { +- TotalFlowAmountCent int64 +- TotalRefundAmountCent int64 +- PendingRefundAmountCent int64 +- SuccessfulPayCount int64 +- FullRefundCount int64 +- PartialRefundCount int64 +- PendingRefundCount int64 +-} +- +-type settlementSummaryRow struct { +- PlatformIncomeAmountCent int64 +- OwnerShouldIncomeAmountCent int64 +- OwnerWalletIncomeAmountCent int64 +- OwnerEffectiveSettlementAmountCent int64 +- OfflineSettlementAmountCent int64 +- OfflineSettlementPendingAmountCent int64 +- OfflineSettlementPendingCount int64 +- FinancialExceptionCount int64 +- SettledOrderCount int64 +-} +- +-type estimatedIncomeRow struct { +- EstimatedIncomeAmountCent int64 +- PendingSettleOrderCount int64 +-} +- + type dailyPaymentRow struct { + Date string + TotalFlowAmountCent int64 +@@ -578,6 +562,9 @@ type dailyPaymentRow struct { + PartialRefundCount int64 + PendingRefundCount int64 + NormalFullRefundCount int64 ++ MohongFlowAmountCent int64 ++ MohongRefundAmountCent int64 ++ MohongPaidOrderCount int64 + } + + type dailyPickupCancelledRow struct { +@@ -599,6 +586,7 @@ type dailySettlementRow struct { + OfflineSettlementAmountCent int64 + OfflineSettlementPendingAmountCent int64 + OfflineSettlementPendingCount int64 ++ FinancialExceptionCount int64 + SettledOrderCount int64 + } + +@@ -617,4 +605,5 @@ type dailyPickupAdjustmentRow struct { + type dailyEstimatedRow struct { + Date string + EstimatedIncomeAmountCent int64 ++ PendingSettleOrderCount int64 + } +diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go +index a0821c2..1ce17f2 100644 +--- a/backend/internal/modules/adminfinance/dashboard_test.go ++++ b/backend/internal/modules/adminfinance/dashboard_test.go +@@ -321,10 +321,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + if err != nil { + t.Fatalf("读取提号汇总失败: %v", err) + } +- summary, err := repo.summary(t.Context(), query, pickup) ++ settlements, err := repo.dailySettlements(t.Context(), query) + if err != nil { +- t.Fatalf("读取财务汇总失败: %v", err) ++ t.Fatalf("读取每日结算汇总失败: %v", err) + } ++ summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup) + if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 { + t.Fatalf("普通订单数量或利润错误: %+v", summary) + } +@@ -332,3 +333,39 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) { + t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount) + } + } ++ ++func TestSummaryFromDailyRowsPreservesDashboardTotals(t *testing.T) { ++ paymentRows := []dailyPaymentRow{ ++ {TotalFlowAmountCent: 1000, TotalRefundAmountCent: 100, PendingRefundAmountCent: 30, SuccessfulPayCount: 3, FullRefundCount: 1}, ++ {TotalFlowAmountCent: 2500, TotalRefundAmountCent: 400, PendingRefundAmountCent: 70, SuccessfulPayCount: 5, PartialRefundCount: 2, PendingRefundCount: 1}, ++ } ++ settlementRows := []dailySettlementRow{ ++ {PlatformIncomeAmountCent: 300, OwnerShouldIncomeAmountCent: 900, OwnerWalletIncomeAmountCent: 850, OwnerEffectiveSettlementAmountCent: 850, OfflineSettlementAmountCent: 100, OfflineSettlementPendingAmountCent: 20, OfflineSettlementPendingCount: 1, FinancialExceptionCount: 2, SettledOrderCount: 3}, ++ {PlatformIncomeAmountCent: 200, OwnerShouldIncomeAmountCent: 600, OwnerWalletIncomeAmountCent: 500, OwnerEffectiveSettlementAmountCent: 550, OfflineSettlementAmountCent: 200, OfflineSettlementPendingAmountCent: 40, OfflineSettlementPendingCount: 2, FinancialExceptionCount: 1, SettledOrderCount: 2}, ++ } ++ estimateRows := []dailyEstimatedRow{ ++ {EstimatedIncomeAmountCent: 120, PendingSettleOrderCount: 4}, ++ {EstimatedIncomeAmountCent: 80, PendingSettleOrderCount: 3}, ++ } ++ pickup := &PickupSummaryDTO{ProfitAmountCent: 90, CompletedCount: 2, PendingAmountCent: 60, PendingCount: 3} ++ ++ summary := summaryWithPickup(summaryFromDailyRows(paymentRows, settlementRows, estimateRows), pickup) ++ if summary.TotalFlowAmountCent != 3500 || summary.TotalRefundAmountCent != 500 || summary.ChannelNetAmountCent != 3000 { ++ t.Fatalf("支付汇总错误: %+v", summary) ++ } ++ if summary.PendingRefundAmountCent != 100 || summary.SuccessfulPayCount != 8 || summary.SuccessfulRefundCount != 3 || summary.FullRefundCount != 1 || summary.PartialRefundCount != 2 || summary.PendingRefundCount != 1 { ++ t.Fatalf("支付笔数汇总错误: %+v", summary) ++ } ++ if summary.NormalOrderProfitAmountCent != 500 || summary.PickupProfitAmountCent != 90 || summary.PlatformIncomeAmountCent != 590 || summary.PickupCompletedCount != 2 { ++ t.Fatalf("平台利润汇总错误: %+v", summary) ++ } ++ if summary.OwnerShouldIncomeAmountCent != 1500 || summary.OwnerWalletIncomeAmountCent != 1350 || summary.OwnerEffectiveSettlementAmountCent != 1400 || summary.SettlementDiffAmountCent != 100 { ++ t.Fatalf("货主结算汇总错误: %+v", summary) ++ } ++ if summary.OfflineSettlementAmountCent != 300 || summary.OfflineSettlementPendingAmountCent != 120 || summary.OfflineSettlementPendingCount != 6 { ++ t.Fatalf("线下结算汇总错误: %+v", summary) ++ } ++ if summary.EstimatedIncomeAmountCent != 200 || summary.PendingSettleOrderCount != 7 || summary.SettledOrderCount != 5 || summary.FinancialExceptionCount != 3 { ++ t.Fatalf("订单状态汇总错误: %+v", summary) ++ } ++} +diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go +index f4d40ee..ce4feca 100644 +--- a/backend/internal/modules/adminfinance/detail.go ++++ b/backend/internal/modules/adminfinance/detail.go +@@ -19,11 +19,23 @@ func (r *Repository) Details(ctx context.Context, query DetailQuery) (*Paginated + return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil + } + +- // 明细行查询保留完整 JOIN,仅对当前分页命中的订单做金额聚合。 +- db := r.financeDetailBaseQuery(ctx, query) + offset := (query.Page - 1) * query.PageSize ++ orderIDs := make([]uint64, 0, query.PageSize) ++ if err := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query). ++ Order("ro.id DESC"). ++ Offset(offset). ++ Limit(query.PageSize). ++ Pluck("ro.id", &orderIDs).Error; err != nil { ++ return nil, err ++ } ++ if len(orderIDs) == 0 { ++ return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: total, Page: query.Page, PageSize: query.PageSize}, nil ++ } ++ ++ // 先取当前页 ID,再只为这些订单聚合结账、支付和钱包详情。 ++ db := r.financeDetailBaseQuery(ctx, query, orderIDs).Where("ro.id IN ?", orderIDs) + rows := make([]financeDetailRow, 0, query.PageSize) +- if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { ++ if err := db.Order("ro.id DESC").Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]FinanceDetailDTO, 0, len(rows)) +@@ -67,7 +79,7 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB + return db + } + +-func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery) *gorm.DB { ++func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery, orderIDs []uint64) *gorm.DB { + db := r.db.WithContext(ctx).Table("rental_orders AS ro"). + Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status, + ro.handoff_mode, ro.settlement_mode, +@@ -113,9 +125,9 @@ func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQue + ro.created_at, ro.settled_at, ro.offline_settled_at`, settlementDiffThresholdCent). + Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id"). + Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id"). +- Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db.WithContext(ctx))). +- Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db.WithContext(ctx))). +- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db.WithContext(ctx))) ++ Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). ++ Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForOrdersSubquery(r.db.WithContext(ctx), orderIDs)). ++ Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForOrdersSubquery(r.db.WithContext(ctx), orderIDs)) + + return r.applyDetailFilters(db, query) + } +diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go +index 18be7d9..6759d09 100644 +--- a/backend/internal/modules/adminfinance/subquery.go ++++ b/backend/internal/modules/adminfinance/subquery.go +@@ -4,14 +4,16 @@ import ( + "gorm.io/gorm" + ) + +-func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB { +- latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id") ++func acceptedCheckoutForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { ++ latest := db.Table("order_checkouts").Select("MAX(id) AS id"). ++ Where("status = ? AND order_id IN ?", "accepted", orderIDs). ++ Group("order_id") + return db.Table("order_checkouts AS oc"). + Select("oc.*"). + Joins("JOIN (?) AS latest ON latest.id = oc.id", latest) + } + +-func orderPaymentSubquery(db *gorm.DB) *gorm.DB { ++func orderPaymentForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + return db.Table("payment_orders"). + Select(`order_id, + COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, +@@ -19,13 +21,14 @@ func orderPaymentSubquery(db *gorm.DB) *gorm.DB { + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, + refundBizTypes(), refundBizTypes(), refundBizTypes()). ++ Where("order_id IN ?", orderIDs). + Group("order_id") + } + +-func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { ++func ownerWalletIncomeForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB { + return db.Table("wallet_ledger"). + Select("order_id, COALESCE(SUM(amount_cent), 0) AS owner_wallet_income_amount_cent"). +- Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}). ++ Where("direction = ? AND biz_type IN ? AND order_id IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs). + Group("order_id") + } + +@@ -44,18 +47,6 @@ func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery + 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 { +diff --git a/backend/migrations/000063_finance_pending_disbursement_index.sql b/backend/migrations/000063_finance_pending_disbursement_index.sql +new file mode 100644 +index 0000000..3d6190f +--- /dev/null ++++ b/backend/migrations/000063_finance_pending_disbursement_index.sql +@@ -0,0 +1,13 @@ ++-- +goose Up ++-- 财务仪表盘的待线下出款金额不受日期限制,需要持续读取全部待出款订单。 ++-- 覆盖查询条件与汇总金额,避免通过低区分度状态索引回表扫描。 ++ALTER TABLE rental_orders ++ ADD KEY idx_rental_orders_offline_pending_amount ( ++ settlement_mode, ++ offline_settlement_status, ++ offline_settlement_amount_cent ++ ); ++ ++-- +goose Down ++ALTER TABLE rental_orders ++ DROP KEY idx_rental_orders_offline_pending_amount; diff --git a/docs/MODIFIED_FILE b/docs/MODIFIED_FILE new file mode 100644 index 0000000..17da89c --- /dev/null +++ b/docs/MODIFIED_FILE @@ -0,0 +1,609 @@ +package adminfinance + +import ( + "context" + "sync" + + "hfb_sys/backend/internal/timeutil" +) + +func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { + if cached := r.loadDashboardCache(ctx, query); cached != nil { + return cached, nil + } + // 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL, + // 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。 + var ( + daily dashboardDailyResult + pickup *PickupSummaryDTO + disbursement *DisbursementSummaryDTO + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() + } + run(func() error { + var err error + daily, 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 + disbursement, err = r.disbursementSummary(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return nil, err + } + } + + summary := summaryWithPickup(daily.Summary, pickup) + result := &DashboardDTO{ + Summary: summary, + DailyItems: daily.Items, + PickupSummary: *pickup, + MohongSummary: daily.Mohong, + DisbursementSummary: *disbursement, + OperatingExpenseSummary: daily.OperatingExpense, + GeneratedAt: timeutil.ShanghaiNow(), + } + r.storeDashboardCache(ctx, query, result) + return result, nil +} + +func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) { + var summary OperatingExpenseSummaryDTO + err := r.db.WithContext(ctx).Table("operating_expenses"). + Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count"). + Where("status = ?", "paid"). + Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate). + Scan(&summary).Error + if err != nil { + return nil, err + } + return &summary, nil +} + +// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 +func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) { + db := r.db.WithContext(ctx) + var row struct { + SettledAmountCent int64 + ProfitAmountCent int64 + CompletedCount int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS settled_amount_cent, COALESCE(SUM(profit_amount_cent), 0) AS profit_amount_cent, COUNT(id) AS completed_count`). + Where("status = ?", "completed"). + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Scan(&row).Error; err != nil { + return nil, err + } + var adjustments struct { + SettledAmountCent int64 + ProfitAmountCent int64 + } + if err := db.Table("admin_pickup_financial_adjustments AS fa"). + Select(`COALESCE(SUM(fa.settle_delta_cent), 0) AS settled_amount_cent, + COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`). + Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed"). + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Scan(&adjustments).Error; err != nil { + return nil, err + } + var inProgress int64 + if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil { + return nil, err + } + var pending struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND offline_settlement_status = ? AND settle_amount_cent > 0", "platform_managed", "pending"). + Scan(&pending).Error; err != nil { + return nil, err + } + var pendingAdjustments struct { + AmountCent int64 + Count int64 + } + if err := db.Table("admin_pickup_financial_adjustments"). + Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ? AND status = ? AND settle_delta_cent > 0", "platform_managed", "pending"). + Scan(&pendingAdjustments).Error; err != nil { + return nil, err + } + return &PickupSummaryDTO{ + SettledAmountCent: row.SettledAmountCent + adjustments.SettledAmountCent, + ProfitAmountCent: row.ProfitAmountCent + adjustments.ProfitAmountCent, + CompletedCount: row.CompletedCount, + InProgressCount: inProgress, + PendingAmountCent: pending.AmountCent + pendingAdjustments.AmountCent, + PendingCount: pending.Count + pendingAdjustments.Count, + }, nil +} + +// mohongSummary 撞车商城按支付订单统计交易流水,不计入平台利润。 +func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*MohongSummaryDTO, error) { + db := r.db.WithContext(ctx) + var row struct { + FlowAmountCent int64 + RefundAmountCent int64 + PaidOrderCount int64 + } + if err := db.Table("payment_orders"). + Select(`COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS flow_amount_cent, + COALESCE(SUM(CASE WHEN biz_type = 'mohong_refund' AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_order_count`). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Scan(&row).Error; err != nil { + return nil, err + } + return &MohongSummaryDTO{ + FlowAmountCent: row.FlowAmountCent, + RefundAmountCent: row.RefundAmountCent, + NetAmountCent: row.FlowAmountCent - row.RefundAmountCent, + PaidOrderCount: row.PaidOrderCount, + }, nil +} + +type dashboardDailyResult struct { + Items []FinanceDailyDTO + Summary FinanceSummaryDTO + Mohong MohongSummaryDTO + OperatingExpense OperatingExpenseSummaryDTO +} + +func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) { + db := r.db.WithContext(ctx) + var ( + payments []dailyPaymentRow + settlements []dailySettlementRow + estimates []dailyEstimatedRow + ) + var wg sync.WaitGroup + errCh := make(chan error, 3) + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + errCh <- err + } + }() + } + run(func() error { + var err error + payments, err = r.dailyPayments(ctx, query) + return err + }) + run(func() error { + var err error + settlements, err = r.dailySettlements(ctx, query) + return err + }) + run(func() error { + var err error + estimates, err = r.dailyEstimates(ctx, query) + return err + }) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return dashboardDailyResult{}, err + } + } + + paidOrders, err := r.dailyPaidOrders(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + pickupProfits := make([]dailyPickupProfitRow, 0) + if err := db.Table("admin_pickups"). + Select(`DATE(completed_at) AS date, + COALESCE(SUM(profit_amount_cent), 0) AS profit_amount_cent, + COUNT(id) AS completed_count`). + Where("status = ?", "completed"). + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Group("DATE(completed_at)"). + Scan(&pickupProfits).Error; err != nil { + return dashboardDailyResult{}, err + } + pickupAdjustments := make([]dailyPickupAdjustmentRow, 0) + if err := db.Table("admin_pickup_financial_adjustments AS fa"). + Select(`DATE(fa.created_at) AS date, + COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`). + Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed"). + Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(fa.created_at)"). + Scan(&pickupAdjustments).Error; err != nil { + return dashboardDailyResult{}, err + } + + // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 + pickupCancelled, err := r.dailyPickupCancelled(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 + disbursements, err := r.dailyDisbursements(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + operatingExpenses, err := r.dailyOperatingExpenses(ctx, query) + if err != nil { + return dashboardDailyResult{}, err + } + + itemsByDate := make(map[string]FinanceDailyDTO) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + itemsByDate[date] = FinanceDailyDTO{Date: date} + } + for _, row := range payments { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.TotalFlowAmountCent = row.TotalFlowAmountCent + item.TotalRefundAmountCent = row.TotalRefundAmountCent + item.PendingRefundAmountCent = row.PendingRefundAmountCent + item.ChannelNetAmountCent = row.TotalFlowAmountCent - row.TotalRefundAmountCent + item.SuccessfulPayCount = row.SuccessfulPayCount + item.SuccessfulRefundCount = row.FullRefundCount + row.PartialRefundCount + item.FullRefundCount = row.FullRefundCount + item.PartialRefundCount = row.PartialRefundCount + item.PendingRefundCount = row.PendingRefundCount + item.NormalFullRefundCount = row.NormalFullRefundCount + itemsByDate[date] = item + } + for _, row := range paidOrders { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PaidOrderCount = row.PaidOrderCount + itemsByDate[date] = item + } + for _, row := range settlements { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent = row.PlatformIncomeAmountCent + item.OwnerShouldIncomeAmountCent = row.OwnerShouldIncomeAmountCent + item.OwnerWalletIncomeAmountCent = row.OwnerWalletIncomeAmountCent + item.OwnerEffectiveSettlementAmountCent = row.OwnerEffectiveSettlementAmountCent + item.OfflineSettlementAmountCent = row.OfflineSettlementAmountCent + item.OfflineSettlementPendingAmountCent = row.OfflineSettlementPendingAmountCent + item.SettlementDiffAmountCent = row.OwnerShouldIncomeAmountCent - row.OwnerEffectiveSettlementAmountCent + item.SettledOrderCount = row.SettledOrderCount + item.SalesCount = row.SettledOrderCount + item.OfflineSettlementPendingCount = row.OfflineSettlementPendingCount + itemsByDate[date] = item + } + for _, row := range pickupProfits { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent += row.ProfitAmountCent + item.PickupCompletedCount = row.CompletedCount + item.SalesCount += row.CompletedCount + itemsByDate[date] = item + } + for _, row := range pickupAdjustments { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PlatformIncomeAmountCent += row.ProfitAmountCent + itemsByDate[date] = item + } + for _, row := range pickupCancelled { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.PickupCancelledCount = row.Count + itemsByDate[date] = item + } + for _, row := range estimates { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.EstimatedIncomeAmountCent = row.EstimatedIncomeAmountCent + itemsByDate[date] = item + } + for _, row := range disbursements { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OfflineSettlementPaidAmountCent = row.OfflineSettlementPaidAmountCent + item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount + item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent + item.WithdrawalPaidCount = row.WithdrawalPaidCount + item.ManualPaidAmountCent = row.ManualPaidAmountCent + item.ManualPaidCount = row.ManualPaidCount + item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent + item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount + itemsByDate[date] = item + } + for _, row := range operatingExpenses { + date := dailyDateKey(row.Date) + item := itemsByDate[date] + item.Date = date + item.OperatingExpenseAmountCent = row.AmountCent + item.OperatingExpenseCount = row.Count + itemsByDate[date] = item + } + + items := make([]FinanceDailyDTO, 0, len(itemsByDate)) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + items = append(items, itemsByDate[day.Format("2006-01-02")]) + } + return dashboardDailyResult{ + Items: items, + Summary: summaryFromDailyRows(payments, settlements, estimates), + Mohong: mohongSummaryFromDailyRows(payments), + OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses), + }, nil +} + +func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) { + rows := make([]dailyEstimatedRow, 0) + err := r.db.WithContext(ctx).Table("rental_orders"). + Select(`DATE(created_at) AS date, + COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent, + COUNT(id) AS pending_settle_order_count`). + Where("settlement_status NOT IN ?", settledSettlementStatuses()). + Where("status NOT IN ?", nonBillableOrderStatuses()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(created_at)"). + Scan(&rows).Error + return rows, err +} + +func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) { + db := r.db.WithContext(ctx) + rows := make([]dailySettlementRow, 0) + err := db.Table("rental_orders AS ro"). + Select(`DATE(ro.settled_at) AS date, + COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent, + COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent, + COALESCE(SUM(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), 0) AS owner_effective_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + THEN COALESCE(ro.offline_settlement_amount_cent, 0) + ELSE 0 + END), 0) AS offline_settlement_amount_cent, + COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed' + AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' + 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' + 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, + COALESCE(SUM(CASE WHEN 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) >= ? + OR EXISTS ( + SELECT 1 FROM payment_orders AS exception_payment + WHERE exception_payment.order_id = ro.id + AND exception_payment.biz_type IN ? + AND exception_payment.status IN ? + AND exception_payment.amount_cent > 0 + ) + THEN 1 + 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'"). + 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(&rows).Error + return rows, err +} + +func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO { + var summary FinanceSummaryDTO + for _, row := range payments { + summary.TotalFlowAmountCent += row.TotalFlowAmountCent + summary.TotalRefundAmountCent += row.TotalRefundAmountCent + summary.PendingRefundAmountCent += row.PendingRefundAmountCent + summary.SuccessfulPayCount += row.SuccessfulPayCount + summary.FullRefundCount += row.FullRefundCount + summary.PartialRefundCount += row.PartialRefundCount + summary.PendingRefundCount += row.PendingRefundCount + } + for _, row := range settlements { + summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent + summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent + summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent + summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent + summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent + summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent + summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount + summary.FinancialExceptionCount += row.FinancialExceptionCount + summary.SettledOrderCount += row.SettledOrderCount + } + for _, row := range estimates { + summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent + summary.PendingSettleOrderCount += row.PendingSettleOrderCount + } + summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent + summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent + return summary +} + +func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO { + if pickup == nil { + return summary + } + summary.PickupProfitAmountCent = pickup.ProfitAmountCent + summary.PickupCompletedCount = pickup.CompletedCount + summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent + summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent + summary.OfflineSettlementPendingCount += pickup.PendingCount + return summary +} + +func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO { + var summary MohongSummaryDTO + for _, row := range rows { + summary.FlowAmountCent += row.MohongFlowAmountCent + summary.RefundAmountCent += row.MohongRefundAmountCent + summary.PaidOrderCount += row.MohongPaidOrderCount + } + summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent + return summary +} + +func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO { + var summary OperatingExpenseSummaryDTO + for _, row := range rows { + summary.AmountCent += row.AmountCent + summary.Count += row.Count + } + return summary +} + +// dailyPayments 按支付单创建日统计收付款流水与退款笔数, +// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 +func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { + rows := make([]dailyPaymentRow, 0) + 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, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_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 full_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 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, + 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, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`, + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). + 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 + return rows, err +} + +// dailyPickupCancelled 按取消日统计线下提号取消笔数。 +func (r *Repository) dailyPickupCancelled(ctx context.Context, query DashboardQuery) ([]dailyPickupCancelledRow, error) { + rows := make([]dailyPickupCancelledRow, 0) + err := r.db.WithContext(ctx).Table("admin_pickups"). + Select(`DATE(cancelled_at) AS date, COUNT(id) AS count`). + Where("status = ?", "cancelled"). + Where("cancelled_at >= ? AND cancelled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(cancelled_at)"). + Scan(&rows).Error + return rows, err +} + +// dailyPaidOrders 按实际付款日统计普通租赁订单,并将已完成的线下提号按完成日计入成交。 +func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) ([]dailyPaidOrderRow, error) { + items := make([]dailyPaidOrderRow, 0) + err := r.db.WithContext(ctx).Table(`( + SELECT DATE(paid_at) AS date, COUNT(DISTINCT order_id) AS paid_order_count + FROM payment_orders + WHERE biz_type = 'order_pay' AND status = 'paid' AND paid_at >= ? AND paid_at <= ? + GROUP BY DATE(paid_at) + UNION ALL + SELECT DATE(completed_at) AS date, COUNT(id) AS paid_order_count + FROM admin_pickups + WHERE status = 'completed' AND completed_at >= ? AND completed_at <= ? + GROUP BY DATE(completed_at) + ) AS daily_paid_orders`, query.StartDate, query.EndDate, query.StartDate, query.EndDate). + Select("date, SUM(paid_order_count) AS paid_order_count"). + Group("date"). + Scan(&items).Error + return items, err +} + +type dailyPaymentRow struct { + Date string + TotalFlowAmountCent int64 + TotalRefundAmountCent int64 + PendingRefundAmountCent int64 + SuccessfulPayCount int64 + FullRefundCount int64 + PartialRefundCount int64 + PendingRefundCount int64 + NormalFullRefundCount int64 + MohongFlowAmountCent int64 + MohongRefundAmountCent int64 + MohongPaidOrderCount int64 +} + +type dailyPickupCancelledRow struct { + Date string + Count int64 +} + +type dailyPaidOrderRow struct { + Date string + PaidOrderCount int64 +} + +type dailySettlementRow struct { + Date string + PlatformIncomeAmountCent int64 + OwnerShouldIncomeAmountCent int64 + OwnerWalletIncomeAmountCent int64 + OwnerEffectiveSettlementAmountCent int64 + OfflineSettlementAmountCent int64 + OfflineSettlementPendingAmountCent int64 + OfflineSettlementPendingCount int64 + FinancialExceptionCount int64 + SettledOrderCount int64 +} + +type dailyPickupProfitRow struct { + Date string + ProfitAmountCent int64 + CompletedCount int64 +} + +// dailyPickupAdjustmentRow 调整按调整创建日进入当日利润,避免回写历史完成日报。 +type dailyPickupAdjustmentRow struct { + Date string + ProfitAmountCent int64 +} + +type dailyEstimatedRow struct { + Date string + EstimatedIncomeAmountCent int64 + PendingSettleOrderCount int64 +} diff --git a/docs/ROLLBACK.sh b/docs/ROLLBACK.sh new file mode 100755 index 0000000..ce3f262 --- /dev/null +++ b/docs/ROLLBACK.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +target=${1:?用法: ROLLBACK.sh <待恢复文件>} +original=/tmp/hfb-finance-dashboard-optimization/dashboard.go.original +test -f "$original" +cp "$original" "$target" diff --git a/docs/VERIFICATION.txt b/docs/VERIFICATION.txt new file mode 100644 index 0000000..a595f5d --- /dev/null +++ b/docs/VERIFICATION.txt @@ -0,0 +1,34 @@ +性能优化验证记录 + +变更分支/字段:main;财务仪表盘重复聚合、财务明细分页 order_id 两阶段查询、rental_orders 待线下出款覆盖索引。 +原始 dashboard.go SHA-256:ecc1df350998a45778f3cf607a25ed75d67deb23dd6b1d04ec6cef5e5131c752 + +四个验证产物: +/Users/yml/codes/hfb_sys/docs/MODIFIED_FILE +/Users/yml/codes/hfb_sys/docs/DIFF_FILE +/Users/yml/codes/hfb_sys/docs/VERIFICATION.txt +/Users/yml/codes/hfb_sys/docs/ROLLBACK.sh + +BASELINE +命令:HFB_ADMIN_FINANCE_TEST_DSN='hfb:secret@tcp(127.0.0.1:13306)/hfb_finance_perf?charset=utf8mb4&parseTime=True&loc=Local' HFB_ADMIN_FINANCE_PROFILE_OUTPUT='/tmp/hfb-finance-dashboard-optimization/baseline.json' go test ./internal/modules/adminfinance -run '^TestProfileDashboardOnMySQL$' -count=1 -v +输入:50 万 rental_orders、50 万 order_checkouts、60 万 payment_orders、100 万 wallet_ledger,时间分布 70 天,查询 2026-08-29 至 2026-09-04。 +字面输出/结果:dashboard duration=7.936473625s daily_items=7;PASS;退出状态 0。 + +MODIFIED +命令:HFB_ADMIN_FINANCE_TEST_DSN='hfb:secret@tcp(127.0.0.1:13306)/hfb_finance_perf?charset=utf8mb4&parseTime=True&loc=Local' HFB_ADMIN_FINANCE_PROFILE_OUTPUT='/tmp/hfb-finance-dashboard-optimization/modified.json' go test ./internal/modules/adminfinance -run '^TestProfileDashboardOnMySQL$' -count=1 -v +输入:与 BASELINE 相同数据库、相同 7 天区间。 +字面输出/结果:dashboard duration=1.625048875s daily_items=7;PASS;退出状态 0;cmp -s baseline.json modified.json 的退出状态 0;SQL 数 31 -> 26。 + +ROLLBACK +命令:cp docs/MODIFIED_FILE /tmp/hfb-dashboard-rollback-copy.go && ./docs/ROLLBACK.sh /tmp/hfb-dashboard-rollback-copy.go && cmp /tmp/hfb-dashboard-rollback-copy.go /tmp/hfb-finance-dashboard-optimization/dashboard.go.original +输入:优化后的 MODIFIED_FILE 副本。 +字面输出/结果:rollback_cmp_exit=0;退出状态 0;副本恢复为原始 dashboard.go;MODIFIED_FILE 保持优化后行为和内容。 + +TESTS +命令:go test ./...;go vet ./... +字面输出/结果:后端全量测试 PASS;go vet PASS;退出状态均为 0。 + +MIGRATION +命令:goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' up;SHOW INDEX FROM rental_orders WHERE Key_name='idx_rental_orders_offline_pending_amount';goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' down。 +输入:本地 Docker MySQL 的 migration 62 数据库。 +字面输出/结果:goose: successfully migrated database to version: 63;索引三列均出现;Down 输出 OK 000063_finance_pending_disbursement_index.sql;删除后恢复原状态;退出状态 0。 diff --git a/docs/finance-dashboard-performance.md b/docs/finance-dashboard-performance.md new file mode 100644 index 0000000..20fbc8a --- /dev/null +++ b/docs/finance-dashboard-performance.md @@ -0,0 +1,36 @@ +# 财务仪表盘查询性能分析 + +## 结论 + +默认 7 天首页慢的主要原因不是缺少订单 ID 索引,而是同一时间窗口被重复聚合:每日支付、每日结算、每日预计收入完成后,旧实现又串行执行支付汇总、结算汇总、预计收入汇总。结算聚合还需要按订单关联 `order_checkouts` 与 `wallet_ledger`,钱包流水增长后成本随订单数上升。 + +“先取 ID 再关联详情”适合分页明细,不适合首页 SUM/COUNT。首页已经通过 `settled_at` 索引先定位订单;把 ID 传到应用层会增加网络传输、`IN` 参数和内存聚合,不能减少数据库必须扫描的聚合数据。 + +本次已落地两类优化: + +- 每日结果直接派生首页总览,删除三条重复汇总 SQL;支付行同时派生撞车商城总览,费用行同时派生费用总览。 +- 财务明细分页先取当前页订单 ID,再限制结账、支付、钱包聚合子查询只处理这些 ID。 +- 增加 `rental_orders(settlement_mode, offline_settlement_status, offline_settlement_amount_cent)` 覆盖索引,优化不受日期限制的待线下出款统计。 + +## 实测 + +测试库 `hfb_finance_perf` 使用 70 天分布的模拟数据:`rental_orders` 50 万、`order_checkouts` 50 万、`payment_orders` 60 万、`wallet_ledger` 100 万,另有提号、提现和费用数据。 + +旧实现与新实现返回的完整 Dashboard JSON(去除生成时间)字节级一致;SQL 数量由 31 降至 26。 + +| 场景 | 总耗时 | +| --- | ---: | +| 旧实现冷查询 | 7.936s | +| 新实现冷查询 | 1.625s | +| 新实现带覆盖索引的一次实测 | 2.894s | + +机器负载、缓存和并发会造成波动;服务器低配环境出现 10 秒与上述瓶颈相符。稳定性收益来自减少重复扫描,不能只用一次本机耗时外推生产 P95。 + +## 后续治理 + +1. 为所有高频列表记录 `duration_ms`、扫描行数、P95/P99 和调用接口,按总耗时排序治理。 +2. 深分页列表逐步从 OFFSET 改为基于 `(id, created_at)` 的游标分页;保留需要跳页的后台列表作为例外。 +3. 对首页和报表建立按天汇总表(例如 `finance_daily_snapshots`),业务事件增量更新,首页只读取 7 行快照和少量实时待办。 +4. 开启 MySQL slow query log 或 `performance_schema.events_statements_summary_by_digest`,持续检查执行计划是否因数据分布变化回退。 +5. 对每个复合索引验证过滤列顺序、覆盖列和写入成本;不要为低频或已经低于查询预算的提号小表盲目加索引。 +