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;