From dbe0cda2c62c1299fa527155b357bad93d6227d0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 26 Jul 2026 16:18:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B5=84=E9=87=91=E5=87=BA?= =?UTF-8?q?=E6=AC=BE=E6=9F=A5=E8=AF=A2=E4=B8=8E=E8=B4=A2=E5=8A=A1=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 30 +- .../internal/modules/adminfinance/detail.go | 13 +- .../modules/adminfinance/disbursement.go | 160 +++++++ .../modules/adminfinance/disbursement_list.go | 221 +++++++++ .../adminfinance/disbursement_list_test.go | 138 ++++++ .../modules/adminfinance/disbursement_test.go | 109 +++++ backend/internal/modules/adminfinance/dto.go | 115 ++++- .../internal/modules/adminfinance/handler.go | 79 ++- .../internal/modules/adminfinance/service.go | 16 + backend/internal/router/router.go | 1 + .../000038_finance_disbursement_stats.sql | 15 + .../src/features/admin/api/adminFinance.ts | 127 +++++ .../admin/views/AdminFinanceDashboardView.vue | 118 ++++- .../admin/views/AdminFinanceDetailsView.vue | 30 +- .../views/AdminFinanceDisbursementsView.vue | 451 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + 17 files changed, 1604 insertions(+), 31 deletions(-) create mode 100644 backend/internal/modules/adminfinance/disbursement.go create mode 100644 backend/internal/modules/adminfinance/disbursement_list.go create mode 100644 backend/internal/modules/adminfinance/disbursement_list_test.go create mode 100644 backend/internal/modules/adminfinance/disbursement_test.go create mode 100644 backend/migrations/000038_finance_disbursement_stats.sql create mode 100644 frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 21c8bcd..165b7ad 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -19,11 +19,16 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if err != nil { return nil, err } + disbursement, err := r.disbursementSummary(ctx, query) + if err != nil { + return nil, err + } return &DashboardDTO{ - Summary: *summary, - DailyItems: dailyItems, - PickupSummary: *pickup, - GeneratedAt: timeutil.ShanghaiNow(), + Summary: *summary, + DailyItems: dailyItems, + PickupSummary: *pickup, + DisbursementSummary: *disbursement, + GeneratedAt: timeutil.ShanghaiNow(), }, nil } @@ -230,6 +235,11 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi return nil, err } + disbursements, err := r.dailyDisbursements(ctx, query) + if err != nil { + return nil, 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") @@ -272,6 +282,18 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi 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.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + itemsByDate[date] = item + } items := make([]FinanceDailyDTO, 0, len(itemsByDate)) for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go index 33cd9ed..f4d40ee 100644 --- a/backend/internal/modules/adminfinance/detail.go +++ b/backend/internal/modules/adminfinance/detail.go @@ -48,10 +48,19 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB if query.SettlementStatus != "" { db = db.Where("ro.settlement_status = ?", query.SettlementStatus) } + if query.SettlementMode != "" { + db = db.Where("ro.settlement_mode = ?", query.SettlementMode) + } + if query.OfflineSettlementStatus != "" { + db = db.Where("COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus) + } if !query.StartDate.IsZero() && !query.EndDate.IsZero() { - if query.DateType == "created" { + switch query.DateType { + case "created": db = db.Where("ro.created_at >= ? AND ro.created_at <= ?", query.StartDate, query.EndDate) - } else { + case "offline_settled": + db = db.Where("ro.offline_settled_at >= ? AND ro.offline_settled_at <= ?", query.StartDate, query.EndDate) + default: db = db.Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate) } } diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go new file mode 100644 index 0000000..114e57a --- /dev/null +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -0,0 +1,160 @@ +package adminfinance + +import "context" + +// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。 +func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) { + db := r.db.WithContext(ctx) + + var offlinePaid struct { + AmountCent int64 + Count int64 + } + if err := db.Table("rental_orders"). + Select(`COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ?", "platform_managed"). + Where("offline_settlement_status = ?", "settled"). + Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate). + Scan(&offlinePaid).Error; err != nil { + return nil, err + } + + var offlinePending struct { + AmountCent int64 + Count int64 + } + if err := db.Table("rental_orders"). + Select(`COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent, COUNT(id) AS count`). + Where("settlement_mode = ?", "platform_managed"). + Where("offline_settlement_status = ?", "pending"). + Where("offline_settlement_amount_cent > 0"). + Scan(&offlinePending).Error; err != nil { + return nil, err + } + + var withdrawalPaid struct { + AmountCent int64 + FeeAmountCent int64 + ActualAmountCent int64 + Count int64 + } + if err := db.Table("withdrawal_requests"). + Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, + COALESCE(SUM(fee_cent), 0) AS fee_amount_cent, + COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, + COUNT(id) AS count`). + Where("status = ?", "completed"). + Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate). + Scan(&withdrawalPaid).Error; err != nil { + return nil, err + } + + var withdrawalPending struct { + ActualAmountCent int64 + Count int64 + } + if err := db.Table("withdrawal_requests"). + Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). + Where("status = ?", "processing"). + Scan(&withdrawalPending).Error; err != nil { + return nil, err + } + + var withdrawalReview struct { + ActualAmountCent int64 + Count int64 + } + if err := db.Table("withdrawal_requests"). + Select(`COALESCE(SUM(actual_amount_cent), 0) AS actual_amount_cent, COUNT(id) AS count`). + Where("status = ?", "pending"). + Scan(&withdrawalReview).Error; err != nil { + return nil, err + } + + return &DisbursementSummaryDTO{ + PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent, + PaidCount: offlinePaid.Count + withdrawalPaid.Count, + PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent, + PendingPaymentCount: offlinePending.Count + withdrawalPending.Count, + OfflineSettlementPaidAmountCent: offlinePaid.AmountCent, + OfflineSettlementPaidCount: offlinePaid.Count, + OfflineSettlementPendingAmountCent: offlinePending.AmountCent, + OfflineSettlementPendingCount: offlinePending.Count, + WithdrawalAmountCent: withdrawalPaid.AmountCent, + WithdrawalFeeAmountCent: withdrawalPaid.FeeAmountCent, + WithdrawalPaidAmountCent: withdrawalPaid.ActualAmountCent, + WithdrawalPaidCount: withdrawalPaid.Count, + WithdrawalPendingAmountCent: withdrawalPending.ActualAmountCent, + WithdrawalPendingCount: withdrawalPending.Count, + WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent, + WithdrawalReviewCount: withdrawalReview.Count, + }, nil +} + +func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuery) ([]dailyDisbursementRow, error) { + db := r.db.WithContext(ctx) + offlineRows := make([]dailyDisbursementSourceRow, 0) + if err := db.Table("rental_orders"). + Select(`DATE(offline_settled_at) AS date, + COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent, + COUNT(id) AS count`). + Where("settlement_mode = ?", "platform_managed"). + Where("offline_settlement_status = ?", "settled"). + Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(offline_settled_at)"). + Scan(&offlineRows).Error; err != nil { + return nil, err + } + + withdrawalRows := make([]dailyDisbursementSourceRow, 0) + if err := db.Table("withdrawal_requests"). + Select(`DATE(paid_at) AS date, + COALESCE(SUM(actual_amount_cent), 0) AS amount_cent, + COUNT(id) AS count`). + Where("status = ?", "completed"). + Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate). + Group("DATE(paid_at)"). + Scan(&withdrawalRows).Error; err != nil { + return nil, err + } + + rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows)) + for _, row := range offlineRows { + date := dailyDateKey(row.Date) + item := rowsByDate[date] + item.Date = date + item.OfflineSettlementPaidAmountCent = row.AmountCent + item.OfflineSettlementPaidCount = row.Count + rowsByDate[date] = item + } + for _, row := range withdrawalRows { + date := dailyDateKey(row.Date) + item := rowsByDate[date] + item.Date = date + item.WithdrawalPaidAmountCent = row.AmountCent + item.WithdrawalPaidCount = row.Count + rowsByDate[date] = item + } + + rows := make([]dailyDisbursementRow, 0, len(rowsByDate)) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + if row, ok := rowsByDate[day.Format("2006-01-02")]; ok { + rows = append(rows, row) + } + } + return rows, nil +} + +type dailyDisbursementSourceRow struct { + Date string + AmountCent int64 + Count int64 +} + +type dailyDisbursementRow struct { + Date string + OfflineSettlementPaidAmountCent int64 + OfflineSettlementPaidCount int64 + WithdrawalPaidAmountCent int64 + WithdrawalPaidCount int64 +} diff --git a/backend/internal/modules/adminfinance/disbursement_list.go b/backend/internal/modules/adminfinance/disbursement_list.go new file mode 100644 index 0000000..903c89d --- /dev/null +++ b/backend/internal/modules/adminfinance/disbursement_list.go @@ -0,0 +1,221 @@ +package adminfinance + +import ( + "context" + "encoding/json" + "strings" + "time" + + "gorm.io/gorm" +) + +func (r *Repository) Disbursements(ctx context.Context, query DisbursementQuery) (*DisbursementListDTO, error) { + var summary DisbursementListSummaryDTO + summaryDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query) + if err := summaryDB.Select(`COUNT(*) AS record_count, + COALESCE(SUM(CASE WHEN status = 'paid' THEN actual_amount_cent ELSE 0 END), 0) AS paid_amount_cent, + COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count, + COALESCE(SUM(CASE WHEN status = 'payment_pending' THEN actual_amount_cent ELSE 0 END), 0) AS payment_pending_amount_cent, + COALESCE(SUM(CASE WHEN status = 'payment_pending' THEN 1 ELSE 0 END), 0) AS payment_pending_count, + COALESCE(SUM(CASE WHEN status = 'review_pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_pending_amount_cent, + COALESCE(SUM(CASE WHEN status = 'review_pending' THEN 1 ELSE 0 END), 0) AS review_pending_count, + COALESCE(SUM(CASE WHEN source_type = 'withdrawal' AND status = 'paid' THEN fee_cent ELSE 0 END), 0) AS withdrawal_fee_amount_cent`). + Scan(&summary).Error; err != nil { + return nil, err + } + + rows := make([]disbursementItemRow, 0, query.PageSize) + orderColumn := "business_created_at" + if query.DateType == "paid" { + orderColumn = "paid_at" + } + offset := (query.Page - 1) * query.PageSize + rowsDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query) + if err := rowsDB.Order(orderColumn + " DESC"). + Order("source_id DESC"). + Offset(offset). + Limit(query.PageSize). + Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]DisbursementItemDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &DisbursementListDTO{ + Items: items, + Total: summary.RecordCount, + Page: query.Page, + PageSize: query.PageSize, + Summary: summary, + }, nil +} + +func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { + db := r.db.WithContext(ctx) + platformManaged := db.Table("rental_orders AS ro"). + Select(`'platform_managed' AS source_type, + ro.id AS source_id, + ro.order_no AS business_no, + ro.id AS order_id, + 0 AS withdrawal_id, + ro.owner_id AS user_id, + '外部卖家' AS payee_name, + '' AS payee_phone, + COALESCE(lu.uploader_name, '') AS uploader_name, + COALESCE(lu.source_channel, '') AS source_channel, + '' AS account_type, + '' AS account_name, + '' AS account_no, + '' AS bank_name, + ro.offline_settlement_amount_cent AS amount_cent, + 0 AS fee_cent, + ro.offline_settlement_amount_cent AS actual_amount_cent, + CASE WHEN ro.offline_settlement_status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status, + ro.offline_settlement_status AS raw_status, + ro.settled_at AS business_created_at, + ro.offline_settled_at AS paid_at, + ro.offline_settled_by AS operator_id, + COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, + ro.offline_settlement_remark AS remark, + lu.parsed_payload AS source_payload`). + Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = ( + SELECT MAX(lu2.id) FROM listing_uploads AS lu2 WHERE lu2.listing_id = ro.listing_id + )`). + Joins("LEFT JOIN admin_users AS operator ON operator.id = ro.offline_settled_by"). + Where("ro.settlement_mode = ?", "platform_managed"). + Where("ro.offline_settlement_status IN ?", []string{"pending", "settled"}). + Where("ro.offline_settlement_amount_cent > 0") + + withdrawal := db.Table("withdrawal_requests AS wr"). + Select(`'withdrawal' AS source_type, + wr.id AS source_id, + wr.withdraw_no AS business_no, + 0 AS order_id, + wr.id AS withdrawal_id, + wr.user_id, + COALESCE(NULLIF(u.nickname, ''), NULLIF(wr.account_name, ''), '') AS payee_name, + COALESCE(u.phone, '') AS payee_phone, + '' AS uploader_name, + '' AS source_channel, + wr.account_type, + wr.account_name, + wr.account_no, + wr.bank_name, + wr.amount_cent, + wr.fee_cent, + wr.actual_amount_cent, + CASE + WHEN wr.status = 'pending' THEN 'review_pending' + WHEN wr.status = 'processing' THEN 'payment_pending' + WHEN wr.status = 'completed' THEN 'paid' + ELSE wr.status + END AS status, + wr.status AS raw_status, + wr.created_at AS business_created_at, + wr.paid_at, + COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id, + COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, + CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark, + NULL AS source_payload`). + Joins("LEFT JOIN users AS u ON u.id = wr.user_id"). + Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)") + + union := db.Raw("? UNION ALL ?", platformManaged, withdrawal) + return db.Table("(?) AS d", union) +} + +func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQuery) *gorm.DB { + if query.SourceType != "" { + db = db.Where("d.source_type = ?", query.SourceType) + } + if query.Status != "" { + db = db.Where("d.status = ?", query.Status) + } + if query.BusinessNo != "" { + db = db.Where("d.business_no LIKE ?", "%"+query.BusinessNo+"%") + } + if query.UserID > 0 { + db = db.Where("d.user_id = ?", query.UserID) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ? + OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like) + } + if !query.StartDate.IsZero() && !query.EndDate.IsZero() { + if query.DateType == "paid" { + db = db.Where("d.paid_at >= ? AND d.paid_at <= ?", query.StartDate, query.EndDate) + } else { + db = db.Where("d.business_created_at >= ? AND d.business_created_at <= ?", query.StartDate, query.EndDate) + } + } + return db +} + +type disbursementItemRow struct { + SourceType string + SourceID uint64 + BusinessNo string + OrderID uint64 + WithdrawalID uint64 + UserID uint64 + PayeeName string + PayeePhone string + UploaderName string + SourceChannel string + AccountType string + AccountName string + AccountNo string + BankName string + AmountCent int64 + FeeCent int64 + ActualAmountCent int64 + Status string + RawStatus string + BusinessCreatedAt *time.Time + PaidAt *time.Time + OperatorID *uint64 + OperatorName string + Remark string + SourcePayload []byte +} + +func (r disbursementItemRow) toDTO() DisbursementItemDTO { + phone := strings.TrimSpace(r.PayeePhone) + if phone == "" && r.SourceType == "platform_managed" && len(r.SourcePayload) > 0 { + var payload struct { + ContactPhone string `json:"contactPhone"` + } + if json.Unmarshal(r.SourcePayload, &payload) == nil { + phone = strings.TrimSpace(payload.ContactPhone) + } + } + return DisbursementItemDTO{ + SourceType: r.SourceType, + SourceID: r.SourceID, + BusinessNo: r.BusinessNo, + OrderID: r.OrderID, + WithdrawalID: r.WithdrawalID, + UserID: r.UserID, + PayeeName: r.PayeeName, + PayeePhone: phone, + UploaderName: r.UploaderName, + SourceChannel: r.SourceChannel, + AccountType: r.AccountType, + AccountName: r.AccountName, + AccountNo: r.AccountNo, + BankName: r.BankName, + AmountCent: r.AmountCent, + FeeCent: r.FeeCent, + ActualAmountCent: r.ActualAmountCent, + Status: r.Status, + RawStatus: r.RawStatus, + BusinessCreatedAt: r.BusinessCreatedAt, + PaidAt: r.PaidAt, + OperatorID: r.OperatorID, + OperatorName: r.OperatorName, + Remark: r.Remark, + } +} diff --git a/backend/internal/modules/adminfinance/disbursement_list_test.go b/backend/internal/modules/adminfinance/disbursement_list_test.go new file mode 100644 index 0000000..b376ee3 --- /dev/null +++ b/backend/internal/modules/adminfinance/disbursement_list_test.go @@ -0,0 +1,138 @@ +package adminfinance + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/timeutil" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(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) + } + statements := []string{ + `CREATE TABLE rental_orders ( + id INTEGER PRIMARY KEY, order_no TEXT, listing_id INTEGER, owner_id INTEGER, + settlement_mode TEXT, offline_settlement_status TEXT, + offline_settlement_amount_cent INTEGER, offline_settlement_remark TEXT, + settled_at DATETIME, offline_settled_at DATETIME, offline_settled_by INTEGER + )`, + `CREATE TABLE listing_uploads ( + id INTEGER PRIMARY KEY, listing_id INTEGER, uploader_name TEXT, + source_channel TEXT, parsed_payload BLOB + )`, + `CREATE TABLE withdrawal_requests ( + id INTEGER PRIMARY KEY, withdraw_no TEXT, user_id INTEGER, + account_type TEXT, account_name TEXT, account_no TEXT, bank_name TEXT, + amount_cent INTEGER, fee_cent INTEGER, actual_amount_cent INTEGER, + status TEXT, created_at DATETIME, paid_at DATETIME, paid_by INTEGER, + reviewed_by INTEGER, payment_remark TEXT, review_remark TEXT + )`, + `CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, + `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("创建测试表失败: %v", err) + } + } + + loc := timeutil.ShanghaiLocation() + at := func(day int) time.Time { return time.Date(2026, 7, day, 10, 0, 0, 0, loc) } + if err := db.Exec(`INSERT INTO users (id, nickname, phone) VALUES + (1, '平台客服', 'admin:1'), (2, '提现用户', '13900000000')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES + (7, '财务甲', 'finance_a')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO listing_uploads + (id, listing_id, uploader_name, source_channel, parsed_payload) + VALUES + (1, 101, '旧上传人', 'external_api', '{}'), + (2, 101, '上传客服甲', 'external_api', ?)`, `{"contactPhone":"13800000000"}`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO rental_orders + (id, order_no, listing_id, owner_id, settlement_mode, offline_settlement_status, + offline_settlement_amount_cent, offline_settlement_remark, settled_at, offline_settled_at, offline_settled_by) + VALUES + (1, 'R202607010001', 101, 1, 'platform_managed', 'settled', 12000, '支付宝转账', ?, ?, 7), + (2, 'R202607010002', 102, 1, 'platform_managed', 'pending', 8000, '', ?, NULL, NULL)`, + at(2), at(3), at(4)).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO withdrawal_requests + (id, withdraw_no, user_id, account_type, account_name, account_no, bank_name, + amount_cent, fee_cent, actual_amount_cent, status, created_at, paid_at, + paid_by, reviewed_by, payment_remark, review_remark) + VALUES + (10, 'W202607010001', 2, 'bank', '张三', '****1234', '测试银行', 10000, 100, 9900, 'completed', ?, ?, 7, 7, '银行转账', ''), + (11, 'W202607010002', 2, 'alipay', '张三', '138****0000', '', 5100, 100, 5000, 'pending', ?, NULL, NULL, NULL, '', ''), + (12, 'W202607010003', 2, 'wechat', '张三', 'wx***01', '', 3000, 0, 3000, 'processing', ?, NULL, NULL, 7, '', '已审核')`, + at(5), at(6), at(7), at(8)).Error; err != nil { + t.Fatal(err) + } + + repo := NewRepository(db) + query := DisbursementQuery{ + DateType: "created", + StartDate: at(1), + EndDate: time.Date(2026, 7, 10, 23, 59, 59, 0, loc), + Page: 1, + PageSize: 20, + } + result, err := repo.Disbursements(t.Context(), query) + if err != nil { + t.Fatalf("Disbursements() error = %v", err) + } + if result.Total != 5 || len(result.Items) != 5 { + t.Fatalf("记录数 = %d/%d, want 5/5", result.Total, len(result.Items)) + } + if result.Summary.PaidAmountCent != 21900 || result.Summary.PaidCount != 2 { + t.Fatalf("已出款 = %d/%d, want 21900/2", result.Summary.PaidAmountCent, result.Summary.PaidCount) + } + if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 { + t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount) + } + if result.Summary.ReviewPendingAmountCent != 5000 || result.Summary.ReviewPendingCount != 1 { + t.Fatalf("待审核 = %d/%d, want 5000/1", result.Summary.ReviewPendingAmountCent, result.Summary.ReviewPendingCount) + } + + var platformItem *DisbursementItemDTO + for index := range result.Items { + if result.Items[index].BusinessNo == "R202607010001" { + platformItem = &result.Items[index] + break + } + } + if platformItem == nil { + t.Fatal("未查询到平台代管出款") + } + if platformItem.PayeeName != "外部卖家" || platformItem.PayeePhone != "13800000000" || platformItem.UploaderName != "上传客服甲" { + t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName) + } + + paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{ + SourceType: "withdrawal", + Status: "paid", + DateType: "paid", + StartDate: at(6), + EndDate: time.Date(2026, 7, 6, 23, 59, 59, 0, loc), + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("按打款日期查询提现失败: %v", err) + } + if paidWithdrawals.Total != 1 || paidWithdrawals.Items[0].WithdrawalID != 10 { + t.Fatalf("按打款日期查询结果 = %+v", paidWithdrawals.Items) + } +} diff --git a/backend/internal/modules/adminfinance/disbursement_test.go b/backend/internal/modules/adminfinance/disbursement_test.go new file mode 100644 index 0000000..d3ac973 --- /dev/null +++ b/backend/internal/modules/adminfinance/disbursement_test.go @@ -0,0 +1,109 @@ +package adminfinance + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/timeutil" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(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 rental_orders ( + id INTEGER PRIMARY KEY, + settlement_mode TEXT NOT NULL, + offline_settlement_status TEXT NOT NULL, + offline_settlement_amount_cent INTEGER NOT NULL, + offline_settled_at DATETIME NULL + )`).Error; err != nil { + t.Fatalf("创建订单表失败: %v", err) + } + if err := db.Exec(`CREATE TABLE withdrawal_requests ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + amount_cent INTEGER NOT NULL, + fee_cent INTEGER NOT NULL, + actual_amount_cent INTEGER NOT NULL, + paid_at DATETIME NULL + )`).Error; err != nil { + t.Fatalf("创建提现表失败: %v", err) + } + + loc := timeutil.ShanghaiLocation() + inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc) + inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc) + outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc) + + for _, args := range [][]any{ + {1, "platform_managed", "settled", 12000, inRangeOffline}, + {2, "platform_managed", "settled", 3000, outOfRange}, + {3, "platform_managed", "pending", 8000, nil}, + {4, "platform_managed", "pending", 0, nil}, + {5, "owner_wallet", "pending", 9000, nil}, + } { + if err := db.Exec(`INSERT INTO rental_orders + (id, settlement_mode, offline_settlement_status, offline_settlement_amount_cent, offline_settled_at) + VALUES (?, ?, ?, ?, ?)`, args...).Error; err != nil { + t.Fatalf("写入订单失败: %v", err) + } + } + for _, args := range [][]any{ + {1, "completed", 10000, 100, 9900, inRangeWithdrawal}, + {2, "completed", 5000, 0, 5000, outOfRange}, + {3, "processing", 5000, 200, 4800, nil}, + {4, "pending", 2100, 100, 2000, nil}, + {5, "rejected", 6000, 100, 5900, nil}, + } { + if err := db.Exec(`INSERT INTO withdrawal_requests + (id, status, amount_cent, fee_cent, actual_amount_cent, paid_at) + VALUES (?, ?, ?, ?, ?, ?)`, args...).Error; err != nil { + t.Fatalf("写入提现单失败: %v", err) + } + } + + query := DashboardQuery{ + StartDate: time.Date(2026, 7, 1, 0, 0, 0, 0, loc), + EndDate: time.Date(2026, 7, 3, 23, 59, 59, 0, loc), + } + repo := NewRepository(db) + summary, err := repo.disbursementSummary(t.Context(), query) + if err != nil { + t.Fatalf("disbursementSummary() error = %v", err) + } + if summary.PaidAmountCent != 21900 || summary.PaidCount != 2 { + t.Fatalf("实际出款 = %d/%d, want 21900/2", summary.PaidAmountCent, summary.PaidCount) + } + if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 { + t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount) + } + if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 { + t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent) + } + if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 { + t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount) + } + if summary.WithdrawalReviewAmountCent != 2000 || summary.WithdrawalReviewCount != 1 { + t.Fatalf("待审核提现 = %d/%d, want 2000/1", summary.WithdrawalReviewAmountCent, summary.WithdrawalReviewCount) + } + + daily, err := repo.dailyDisbursements(t.Context(), query) + if err != nil { + t.Fatalf("dailyDisbursements() error = %v", err) + } + if len(daily) != 2 { + t.Fatalf("每日出款行数 = %d, want 2", len(daily)) + } + if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 { + t.Fatalf("第一日代管出款 = %+v", daily[0]) + } + if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 { + t.Fatalf("第二日提现出款 = %+v", daily[1]) + } +} diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index 694a8f8..db84f11 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -8,22 +8,38 @@ type DashboardQuery struct { } type DetailQuery struct { - OrderNo string - UserID uint64 - OrderStatus string - SettlementStatus string - DateType string - StartDate time.Time - EndDate time.Time - Page int - PageSize int + OrderNo string + UserID uint64 + OrderStatus string + SettlementStatus string + SettlementMode string + OfflineSettlementStatus string + DateType string + StartDate time.Time + EndDate time.Time + Page int + PageSize int +} + +type DisbursementQuery struct { + SourceType string + Status string + DateType string + BusinessNo string + Keyword string + UserID uint64 + StartDate time.Time + EndDate time.Time + Page int + PageSize int } type DashboardDTO struct { - Summary FinanceSummaryDTO `json:"summary"` - DailyItems []FinanceDailyDTO `json:"daily_items"` - PickupSummary PickupSummaryDTO `json:"pickup_summary"` - GeneratedAt time.Time `json:"generated_at"` + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + PickupSummary PickupSummaryDTO `json:"pickup_summary"` + DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"` + GeneratedAt time.Time `json:"generated_at"` } // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 @@ -34,6 +50,73 @@ type PickupSummaryDTO struct { InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间) } +// DisbursementSummaryDTO 统计平台实际线下出款及当前待处理金额。 +// 已打款按确认打款时间归入查询区间,待处理不受日期范围限制,反映当前资金待办。 +type DisbursementSummaryDTO struct { + PaidAmountCent int64 `json:"paid_amount_cent"` + PaidCount int64 `json:"paid_count"` + PendingPaymentAmountCent int64 `json:"pending_payment_amount_cent"` + PendingPaymentCount int64 `json:"pending_payment_count"` + OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"` + OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"` + OfflineSettlementPendingAmountCent int64 `json:"offline_settlement_pending_amount_cent"` + OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"` + WithdrawalAmountCent int64 `json:"withdrawal_amount_cent"` + WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` + WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"` + WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` + WithdrawalPendingAmountCent int64 `json:"withdrawal_pending_amount_cent"` + WithdrawalPendingCount int64 `json:"withdrawal_pending_count"` + WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"` + WithdrawalReviewCount int64 `json:"withdrawal_review_count"` +} + +type DisbursementListDTO struct { + Items []DisbursementItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Summary DisbursementListSummaryDTO `json:"summary"` +} + +type DisbursementListSummaryDTO struct { + RecordCount int64 `json:"record_count"` + PaidAmountCent int64 `json:"paid_amount_cent"` + PaidCount int64 `json:"paid_count"` + PaymentPendingAmountCent int64 `json:"payment_pending_amount_cent"` + PaymentPendingCount int64 `json:"payment_pending_count"` + ReviewPendingAmountCent int64 `json:"review_pending_amount_cent"` + ReviewPendingCount int64 `json:"review_pending_count"` + WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"` +} + +type DisbursementItemDTO struct { + SourceType string `json:"source_type"` + SourceID uint64 `json:"source_id"` + BusinessNo string `json:"business_no"` + OrderID uint64 `json:"order_id,omitempty"` + WithdrawalID uint64 `json:"withdrawal_id,omitempty"` + UserID uint64 `json:"user_id"` + PayeeName string `json:"payee_name"` + PayeePhone string `json:"payee_phone"` + UploaderName string `json:"uploader_name"` + SourceChannel string `json:"source_channel"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` + BankName string `json:"bank_name"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + Status string `json:"status"` + RawStatus string `json:"raw_status"` + BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` + PaidAt *time.Time `json:"paid_at,omitempty"` + OperatorID *uint64 `json:"operator_id,omitempty"` + OperatorName string `json:"operator_name"` + Remark string `json:"remark"` +} + type FinanceSummaryDTO struct { TotalFlowAmountCent int64 `json:"total_flow_amount_cent"` TotalRefundAmountCent int64 `json:"total_refund_amount_cent"` @@ -79,6 +162,12 @@ type FinanceDailyDTO struct { PendingRefundCount int64 `json:"pending_refund_count"` SettledOrderCount int64 `json:"settled_order_count"` OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"` + OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"` + OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"` + WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"` + WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` + DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` + DisbursementPaidCount int64 `json:"disbursement_paid_count"` } type PaginatedResult struct { diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index 63054f0..f8335b9 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "hfb_sys/backend/internal/timeutil" @@ -46,6 +47,19 @@ func (h *Handler) Details(c *gin.Context) { response.OK(c, result) } +func (h *Handler) Disbursements(c *gin.Context) { + query, ok := parseDisbursementQuery(c) + if !ok { + return + } + result, err := h.service.Disbursements(c.Request.Context(), query) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, result) +} + func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { start, end, ok := parseDateRange(c, 6) if !ok { @@ -60,14 +74,65 @@ func parseDetailQuery(c *gin.Context) (DetailQuery, bool) { return DetailQuery{}, false } query := DetailQuery{ - OrderNo: c.Query("order_no"), - OrderStatus: c.Query("order_status"), - SettlementStatus: c.Query("settlement_status"), - DateType: c.DefaultQuery("date_type", "settled"), - StartDate: start, - EndDate: end, + OrderNo: c.Query("order_no"), + OrderStatus: c.Query("order_status"), + SettlementStatus: c.Query("settlement_status"), + SettlementMode: c.Query("settlement_mode"), + OfflineSettlementStatus: c.Query("offline_settlement_status"), + DateType: c.DefaultQuery("date_type", "settled"), + StartDate: start, + EndDate: end, } - if query.DateType != "created" && query.DateType != "settled" { + if query.DateType != "created" && query.DateType != "settled" && query.DateType != "offline_settled" { + response.BadRequest(c, "日期类型不正确") + return query, false + } + if query.SettlementMode != "" && query.SettlementMode != "owner_wallet" && query.SettlementMode != "platform_managed" { + response.BadRequest(c, "结算模式不正确") + return query, false + } + if query.OfflineSettlementStatus != "" && query.OfflineSettlementStatus != "none" && query.OfflineSettlementStatus != "pending" && query.OfflineSettlementStatus != "settled" { + response.BadRequest(c, "线下结算状态不正确") + return query, false + } + if raw := c.Query("user_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "用户 ID 不正确") + return query, false + } + query.UserID = value + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + query.Page = page + query.PageSize = pageSize + return query, true +} + +func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) { + start, end, ok := parseDateRange(c, 29) + if !ok { + return DisbursementQuery{}, false + } + query := DisbursementQuery{ + SourceType: strings.TrimSpace(c.Query("source_type")), + Status: strings.TrimSpace(c.Query("status")), + DateType: c.DefaultQuery("date_type", "created"), + BusinessNo: strings.TrimSpace(c.Query("business_no")), + Keyword: strings.TrimSpace(c.Query("keyword")), + StartDate: start, + EndDate: end, + } + if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" { + response.BadRequest(c, "出款类型不正确") + return query, false + } + if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" { + response.BadRequest(c, "出款状态不正确") + return query, false + } + if query.DateType != "created" && query.DateType != "paid" { response.BadRequest(c, "日期类型不正确") return query, false } diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index f06ca49..f620d3c 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -37,3 +37,19 @@ func (s *Service) Details(ctx context.Context, query DetailQuery) (*PaginatedRes } return s.repo.Details(ctx, query) } + +func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*DisbursementListDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + return s.repo.Disbursements(ctx, query) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 61b430d..d59526d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -638,6 +638,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard) adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details) + adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) diff --git a/backend/migrations/000038_finance_disbursement_stats.sql b/backend/migrations/000038_finance_disbursement_stats.sql new file mode 100644 index 0000000..ec5231b --- /dev/null +++ b/backend/migrations/000038_finance_disbursement_stats.sql @@ -0,0 +1,15 @@ +-- +goose Up + +ALTER TABLE rental_orders + ADD KEY idx_rental_orders_offline_settled_at (offline_settled_at, settlement_mode, offline_settlement_status); + +ALTER TABLE withdrawal_requests + ADD KEY idx_withdrawal_requests_paid_at (status, paid_at); + +-- +goose Down + +ALTER TABLE withdrawal_requests + DROP KEY idx_withdrawal_requests_paid_at; + +ALTER TABLE rental_orders + DROP KEY idx_rental_orders_offline_settled_at; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index 8a8282e..fe96eb9 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -50,6 +50,12 @@ export interface FinanceDailyItem { pending_refund_count: number settled_order_count: number offline_settlement_pending_count: number + offline_settlement_paid_amount_cent: number + offline_settlement_paid_count: number + withdrawal_paid_amount_cent: number + withdrawal_paid_count: number + disbursement_paid_amount_cent: number + disbursement_paid_count: number } export interface FinancePickupSummary { @@ -59,10 +65,87 @@ export interface FinancePickupSummary { in_progress_count: number } +export interface FinanceDisbursementSummary { + paid_amount_cent: number + paid_count: number + pending_payment_amount_cent: number + pending_payment_count: number + offline_settlement_paid_amount_cent: number + offline_settlement_paid_count: number + offline_settlement_pending_amount_cent: number + offline_settlement_pending_count: number + withdrawal_amount_cent: number + withdrawal_fee_amount_cent: number + withdrawal_paid_amount_cent: number + withdrawal_paid_count: number + withdrawal_pending_amount_cent: number + withdrawal_pending_count: number + withdrawal_review_amount_cent: number + withdrawal_review_count: number +} + +export interface FinanceDisbursementListSummary { + record_count: number + paid_amount_cent: number + paid_count: number + payment_pending_amount_cent: number + payment_pending_count: number + review_pending_amount_cent: number + review_pending_count: number + withdrawal_fee_amount_cent: number +} + +export interface FinanceDisbursementItem { + source_type: 'platform_managed' | 'withdrawal' + source_id: number + business_no: string + order_id?: number + withdrawal_id?: number + user_id: number + payee_name: string + payee_phone: string + uploader_name: string + source_channel: string + account_type: string + account_name: string + account_no: string + bank_name: string + amount_cent: number + fee_cent: number + actual_amount_cent: number + status: 'review_pending' | 'payment_pending' | 'paid' | 'rejected' | 'cancelled' + raw_status: string + business_created_at?: string + paid_at?: string + operator_id?: number + operator_name: string + remark: string +} + +export interface FinanceDisbursementQuery extends FinanceDateQuery { + source_type?: string + status?: string + date_type?: string + business_no?: string + keyword?: string + user_id?: string + page?: number + page_size?: number +} + +export interface FinanceDisbursementList { + items: FinanceDisbursementItem[] + total: number + page: number + page_size: number + summary: FinanceDisbursementListSummary +} + export interface FinanceDashboard { summary: FinanceSummary daily_items: FinanceDailyItem[] pickup_summary: FinancePickupSummary + disbursement_summary: FinanceDisbursementSummary generated_at: string } @@ -113,6 +196,8 @@ export interface FinanceDetailQuery extends FinanceDateQuery { user_id?: string order_status?: string settlement_status?: string + settlement_mode?: string + offline_settlement_status?: string date_type?: string page?: number page_size?: number @@ -137,6 +222,24 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { completed_count: 0, in_progress_count: 0, }, + disbursement_summary: data.data?.disbursement_summary ?? { + paid_amount_cent: 0, + paid_count: 0, + pending_payment_amount_cent: 0, + pending_payment_count: 0, + offline_settlement_paid_amount_cent: 0, + offline_settlement_paid_count: 0, + offline_settlement_pending_amount_cent: 0, + offline_settlement_pending_count: 0, + withdrawal_amount_cent: 0, + withdrawal_fee_amount_cent: 0, + withdrawal_paid_amount_cent: 0, + withdrawal_paid_count: 0, + withdrawal_pending_amount_cent: 0, + withdrawal_pending_count: 0, + withdrawal_review_amount_cent: 0, + withdrawal_review_count: 0, + }, } } @@ -153,3 +256,27 @@ export async function fetchFinanceDetails(query: FinanceDetailQuery = {}) { page_size: Number(result?.page_size ?? query.page_size ?? 20), } } + +export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery = {}) { + const { data } = await apiClient.get>( + '/admin/finance/disbursements', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + summary: result?.summary ?? { + record_count: 0, + paid_amount_cent: 0, + paid_count: 0, + payment_pending_amount_cent: 0, + payment_pending_count: 0, + review_pending_amount_cent: 0, + review_pending_count: 0, + withdrawal_fee_amount_cent: 0, + }, + } +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 8240e66..97a9a7f 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -66,7 +66,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
结账单口径
- 号主结算金额 + 号主应结算金额 {{ moneyCent(dashboard.summary.owner_effective_settlement_amount_cent) }} - 钱包入账 + 平台代管线下结算 + 钱包已入账 + 平台代管应付
- 待线下结算 + 区间待线下结算 {{ moneyCent(dashboard.summary.offline_settlement_pending_amount_cent) }} {{ dashboard.summary.offline_settlement_pending_count }} 笔平台代管待处理
@@ -148,6 +148,65 @@ function rowOfflinePendingClass(row: FinanceDailyItem) { +
+
+

资金出款

+

已打款按所选日期统计,待处理金额为当前全部待办。

+
+
+ +
+
+ 代管及提现出款 + {{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }} + {{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计 +
+
+ 平台代管打款 + {{ + moneyCent(dashboard.disbursement_summary.offline_settlement_paid_amount_cent) + }} + {{ dashboard.disbursement_summary.offline_settlement_paid_count }} 笔线下结算 +
+
+ 用户提现打款 + {{ moneyCent(dashboard.disbursement_summary.withdrawal_paid_amount_cent) }} + + {{ dashboard.disbursement_summary.withdrawal_paid_count }} 笔,钱包核销 + {{ moneyCent(dashboard.disbursement_summary.withdrawal_amount_cent) }},手续费 + {{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }} + +
+
+ 当前待出款 + {{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }} + {{ dashboard.disbursement_summary.pending_payment_count }} 笔已进入打款阶段 +
+
+ 代管待打款 + {{ + moneyCent(dashboard.disbursement_summary.offline_settlement_pending_amount_cent) + }} + {{ dashboard.disbursement_summary.offline_settlement_pending_count }} 笔待处理 +
+
+ 提现待打款 + {{ + moneyCent(dashboard.disbursement_summary.withdrawal_pending_amount_cent) + }} + {{ dashboard.disbursement_summary.withdrawal_pending_count }} 笔已审核 +
+
+ 提现待审核 + {{ + moneyCent(dashboard.disbursement_summary.withdrawal_review_amount_cent) + }} + {{ dashboard.disbursement_summary.withdrawal_review_count }} 笔待审核 +
+
+
线下提号结算 @@ -188,6 +247,21 @@ function rowOfflinePendingClass(row: FinanceDailyItem) { moneyCent(row.owner_effective_settlement_amount_cent) }} + + + + + + + + +