From ac47006711fb8dc594fdf52c22aca54ee8856944 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 15 Aug 2026 13:04:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=88=A9=E6=B6=A6=E6=98=BE=E7=A4=BA=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=95=B0=E9=87=8F=EF=BC=8C=E4=B8=83=E5=A4=A9=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=96=B0=E5=A2=9E=E9=80=80=E5=85=A8=E6=AC=BE/?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=8F=90=E5=8F=B7=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 84 ++++++++++---- .../modules/adminfinance/dashboard_test.go | 104 ++++++++++++++++++ backend/internal/modules/adminfinance/dto.go | 6 + .../internal/modules/adminfinance/helper.go | 13 +++ .../src/features/admin/api/adminFinance.ts | 9 +- .../admin/views/AdminFinanceDashboardView.vue | 25 ++++- 6 files changed, 218 insertions(+), 23 deletions(-) diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 3dcad53..86f87ec 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -168,8 +168,10 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * } pickupProfitAmountCent := int64(0) + pickupCompletedCount := int64(0) if pickup != nil { pickupProfitAmountCent = pickup.ProfitAmountCent + pickupCompletedCount = pickup.CompletedCount } return &FinanceSummaryDTO{ TotalFlowAmountCent: payment.TotalFlowAmountCent, @@ -179,6 +181,7 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent, NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent, PickupProfitAmountCent: pickupProfitAmountCent, + PickupCompletedCount: pickupCompletedCount, OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent, OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent, OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent, @@ -200,26 +203,8 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup * func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) { db := r.db.WithContext(ctx) - payments := make([]dailyPaymentRow, 0) - if 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`, - payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). - Joins(`LEFT JOIN ( - SELECT order_id, MAX(amount_cent) AS amount_cent - FROM payment_orders - WHERE biz_type IN ? AND status = 'paid' - GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). - Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). - Group("DATE(po.created_at)"). - Scan(&payments).Error; err != nil { + payments, err := r.dailyPayments(ctx, query) + if err != nil { return nil, err } paidOrders, err := r.dailyPaidOrders(ctx, query) @@ -273,6 +258,12 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi return nil, err } + // 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。 + pickupCancelled, err := r.dailyPickupCancelled(ctx, query) + if err != nil { + return nil, err + } + // 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。 estimates := make([]dailyEstimatedRow, 0) if err := db.Table("rental_orders"). @@ -309,6 +300,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi item.FullRefundCount = row.FullRefundCount item.PartialRefundCount = row.PartialRefundCount item.PendingRefundCount = row.PendingRefundCount + item.NormalFullRefundCount = row.NormalFullRefundCount itemsByDate[date] = item } for _, row := range paidOrders { @@ -343,6 +335,13 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi item.SalesCount += row.CompletedCount 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] @@ -372,6 +371,45 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi return items, nil } +// dailyPayments 按支付单创建日统计收付款流水与退款笔数, +// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。 +func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) { + rows := make([]dailyPaymentRow, 0) + err := r.db.WithContext(ctx).Table("payment_orders AS po"). + 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`, + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()). + Joins(`LEFT JOIN ( + SELECT order_id, MAX(amount_cent) AS amount_cent + FROM payment_orders + WHERE biz_type IN ? AND status = 'paid' + GROUP BY order_id + ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). + 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) @@ -427,6 +465,12 @@ type dailyPaymentRow struct { FullRefundCount int64 PartialRefundCount int64 PendingRefundCount int64 + NormalFullRefundCount int64 +} + +type dailyPickupCancelledRow struct { + Date string + Count int64 } type dailyPaidOrderRow struct { diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index 86f1d74..61e3d1d 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -79,3 +79,107 @@ func TestDailyPaidOrdersUsePaidAtAndIncludeCreatedPickups(t *testing.T) { t.Fatalf("每日已付款订单 = %#v, want 8月2日为3、8月3日为2", counts) } } + +func TestDailyPaymentsNormalFullRefundExcludesMohong(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.Exec(`CREATE TABLE payment_orders ( + id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + biz_type TEXT NOT NULL, + status TEXT NOT NULL, + amount_cent INTEGER NOT NULL, + created_at DATETIME NOT NULL + )`).Error; err != nil { + t.Fatalf("创建支付单表失败: %v", err) + } + + loc := timeutil.ShanghaiLocation() + day := func(value int) time.Time { return time.Date(2026, 8, value, 10, 0, 0, 0, loc) } + rows := [][]any{ + // 正常订单全额退款:计入 full_refund_count 与 normal_full_refund_count + {1, 101, "rent_refund", "refunded", 5000, day(2)}, + {2, 101, "order_pay", "paid", 5000, day(1)}, + // 正常订单部分退款:不计入全额 + {3, 102, "rent_refund", "refunded", 2000, day(2)}, + {4, 102, "order_pay", "paid", 5000, day(1)}, + // 撞车商城全额退款:只计入 full_refund_count,不计入 normal_full_refund_count + {5, 201, "mohong_refund", "refunded", 3000, day(2)}, + {6, 201, "mohong_pay", "paid", 3000, day(1)}, + } + for _, row := range rows { + if err := db.Exec(`INSERT INTO payment_orders (id, order_id, biz_type, status, amount_cent, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入支付单失败: %v", err) + } + } + + repo := NewRepository(db) + query := DashboardQuery{ + StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc), + } + result, err := repo.dailyPayments(t.Context(), query) + if err != nil { + t.Fatalf("读取每日收付款失败: %v", err) + } + if len(result) != 1 { + t.Fatalf("每日收付款记录数 = %d, want 1", len(result)) + } + row := result[0] + if row.FullRefundCount != 2 { + t.Fatalf("full_refund_count = %d, want 2(正常1笔 + 撞车1笔)", row.FullRefundCount) + } + if row.NormalFullRefundCount != 1 { + t.Fatalf("normal_full_refund_count = %d, want 1(仅正常订单全额退款)", row.NormalFullRefundCount) + } + if row.PartialRefundCount != 1 { + t.Fatalf("partial_refund_count = %d, want 1", row.PartialRefundCount) + } +} + +func TestDailyPickupCancelledByCancelledAt(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.Exec(`CREATE TABLE admin_pickups ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + cancelled_at DATETIME NULL + )`).Error; err != nil { + t.Fatalf("创建提号表失败: %v", err) + } + + loc := timeutil.ShanghaiLocation() + day := func(value int) time.Time { return time.Date(2026, 8, value, 15, 0, 0, 0, loc) } + for _, row := range [][]any{ + {1, "cancelled", day(2)}, + {2, "cancelled", day(2)}, + {3, "cancelled", day(3)}, + {4, "cancelled", nil}, // 取消时间缺失的不计 + {5, "completed", day(2)}, + } { + if err := db.Exec(`INSERT INTO admin_pickups (id, status, cancelled_at) VALUES (?, ?, ?)`, row...).Error; err != nil { + t.Fatalf("写入提号记录失败: %v", err) + } + } + + repo := NewRepository(db) + query := DashboardQuery{ + StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc), + } + result, err := repo.dailyPickupCancelled(t.Context(), query) + if err != nil { + t.Fatalf("读取每日取消提号失败: %v", err) + } + if len(result) != 1 { + t.Fatalf("每日取消提号记录数 = %d, want 1", len(result)) + } + if result[0].Count != 2 { + t.Fatalf("8月2日取消提号数 = %d, want 2", result[0].Count) + } +} diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index ade46f5..aa3981a 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -176,6 +176,8 @@ type FinanceSummaryDTO struct { PlatformIncomeAmountCent int64 `json:"platform_income_amount_cent"` NormalOrderProfitAmountCent int64 `json:"normal_order_profit_amount_cent"` PickupProfitAmountCent int64 `json:"pickup_profit_amount_cent"` + // PickupCompletedCount 线下提号利润对应的已完成提号笔数。 + PickupCompletedCount int64 `json:"pickup_completed_count"` OwnerShouldIncomeAmountCent int64 `json:"owner_should_income_amount_cent"` OwnerWalletIncomeAmountCent int64 `json:"owner_wallet_income_amount_cent"` OwnerEffectiveSettlementAmountCent int64 `json:"owner_effective_settlement_amount_cent"` @@ -215,6 +217,10 @@ type FinanceDailyDTO struct { PendingRefundCount int64 `json:"pending_refund_count"` SettledOrderCount int64 `json:"settled_order_count"` PickupCompletedCount int64 `json:"pickup_completed_count"` + // NormalFullRefundCount 正常(租赁)订单已退全款笔数(不含撞车商城退款)。 + NormalFullRefundCount int64 `json:"normal_full_refund_count"` + // PickupCancelledCount 线下提号取消笔数(按取消日归集)。 + PickupCancelledCount int64 `json:"pickup_cancelled_count"` SalesCount int64 `json:"sales_count"` PaidOrderCount int64 `json:"paid_order_count"` OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"` diff --git a/backend/internal/modules/adminfinance/helper.go b/backend/internal/modules/adminfinance/helper.go index 881dbec..c43e012 100644 --- a/backend/internal/modules/adminfinance/helper.go +++ b/backend/internal/modules/adminfinance/helper.go @@ -37,6 +37,19 @@ func refundBizTypes() []string { } } +// normalRefundBizTypes 正常(租赁)订单退款类型,不含撞车商城退款。 +func normalRefundBizTypes() []string { + return []string{ + "cancel_refund", + "admin_close_refund", + "admin_refund", + "checkout_refund", + "deposit_refund", + "rent_refund", + "arbitration_refund", + } +} + // settledSettlementStatuses 是视为"已完成结算"的结算状态集合。 // 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。 func settledSettlementStatuses() []string { diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index 4a8581f..fb27d32 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -13,6 +13,10 @@ export interface FinanceSummary { platform_income_amount_cent: number normal_order_profit_amount_cent: number pickup_profit_amount_cent: number + /** 普通(租赁)订单利润对应的已结算订单数量 */ + settled_order_count: number + /** 线下提号利润对应的已完成提号笔数 */ + pickup_completed_count: number owner_should_income_amount_cent: number owner_wallet_income_amount_cent: number owner_effective_settlement_amount_cent: number @@ -25,7 +29,6 @@ export interface FinanceSummary { full_refund_count: number partial_refund_count: number pending_refund_count: number - settled_order_count: number pending_settle_order_count: number offline_settlement_pending_count: number financial_exception_count: number @@ -52,6 +55,10 @@ export interface FinanceDailyItem { pending_refund_count: number settled_order_count: number pickup_completed_count: number + /** 正常(租赁)订单已退全款笔数(不含撞车商城退款) */ + normal_full_refund_count: number + /** 线下提号取消笔数 */ + pickup_cancelled_count: number sales_count: number paid_order_count: number offline_settlement_pending_count: number diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index ddc5385..5838d95 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -100,8 +100,13 @@ function diffType(value: number) { 平台利润 {{ moneyCent(dashboard.summary.platform_income_amount_cent) }} - 普通订单 {{ moneyCent(dashboard.summary.normal_order_profit_amount_cent) }} + 线下提号 - {{ moneyCent(dashboard.summary.pickup_profit_amount_cent) }} + 普通订单 {{ moneyCent(dashboard.summary.normal_order_profit_amount_cent) }}({{ + dashboard.summary.settled_order_count + }} + 单)+ 线下提号 {{ moneyCent(dashboard.summary.pickup_profit_amount_cent) }}({{ + dashboard.summary.pickup_completed_count + }} + 单)
@@ -261,6 +266,14 @@ function diffType(value: number) { + + + @@ -334,6 +347,14 @@ function diffType(value: number) { border-left: 3px solid #2563eb; } +.refund-cancel-cell { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + line-height: 1.4; +} + .generated-at { margin: 12px 0 0; color: #64748b;