新增资金出款查询与财务统计

This commit is contained in:
yml2213
2026-07-26 16:18:31 +08:00
parent 321f0781cf
commit dbe0cda2c6
17 changed files with 1604 additions and 31 deletions
@@ -19,10 +19,15 @@ 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,
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) {
@@ -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)
}
}
@@ -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
}
@@ -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,
}
}
@@ -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)
}
}
@@ -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])
}
}
@@ -12,6 +12,8 @@ type DetailQuery struct {
UserID uint64
OrderStatus string
SettlementStatus string
SettlementMode string
OfflineSettlementStatus string
DateType string
StartDate time.Time
EndDate time.Time
@@ -19,10 +21,24 @@ type DetailQuery struct {
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"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
GeneratedAt time.Time `json:"generated_at"`
}
@@ -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 {
@@ -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 {
@@ -63,11 +77,62 @@ func parseDetailQuery(c *gin.Context) (DetailQuery, bool) {
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
}
@@ -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)
}
+1
View File
@@ -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)
@@ -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;
@@ -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<ApiResponse<FinanceDisbursementList>>(
'/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,
},
}
}
@@ -66,7 +66,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
<div class="page-header">
<p class="eyebrow">Finance Dashboard</p>
<h1>财务仪表盘</h1>
<p>按天查看第三方收款退平台收入号主钱包入账/线下结算和结算差异</p>
<p>按天查看收付平台收入号主结算平台代管打款和用户提现</p>
</div>
<div class="toolbar-actions">
<el-date-picker
@@ -118,12 +118,12 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
<small>结账单口径</small>
</div>
<div class="metric-card">
<span>号主结算金额</span>
<span>号主结算金额</span>
<strong>{{ moneyCent(dashboard.summary.owner_effective_settlement_amount_cent) }}</strong>
<small>钱包入账 + 平台代管线下结算</small>
<small>钱包入账 + 平台代管应付</small>
</div>
<div class="metric-card">
<span>待线下结算</span>
<span>区间待线下结算</span>
<strong>{{ moneyCent(dashboard.summary.offline_settlement_pending_amount_cent) }}</strong>
<small>{{ dashboard.summary.offline_settlement_pending_count }} 笔平台代管待处理</small>
</div>
@@ -148,6 +148,65 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
</div>
</div>
<div v-if="dashboard?.disbursement_summary" class="section-heading">
<div>
<h2>资金出款</h2>
<p>已打款按所选日期统计待处理金额为当前全部待办</p>
</div>
</div>
<div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid">
<div class="metric-card disbursement-card">
<span>代管及提现出款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong>
<small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small>
</div>
<div class="metric-card disbursement-card">
<span>平台代管打款</span>
<strong>{{
moneyCent(dashboard.disbursement_summary.offline_settlement_paid_amount_cent)
}}</strong>
<small>{{ dashboard.disbursement_summary.offline_settlement_paid_count }} 笔线下结算</small>
</div>
<div class="metric-card disbursement-card">
<span>用户提现打款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.withdrawal_paid_amount_cent) }}</strong>
<small>
{{ dashboard.disbursement_summary.withdrawal_paid_count }} 钱包核销
{{ moneyCent(dashboard.disbursement_summary.withdrawal_amount_cent) }}手续费
{{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
</small>
</div>
<div class="metric-card pending-card">
<span>当前待出款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
<small>{{ dashboard.disbursement_summary.pending_payment_count }} 笔已进入打款阶段</small>
</div>
<div class="metric-card pending-card">
<span>代管待打款</span>
<strong>{{
moneyCent(dashboard.disbursement_summary.offline_settlement_pending_amount_cent)
}}</strong>
<small
>{{ dashboard.disbursement_summary.offline_settlement_pending_count }} 笔待处理</small
>
</div>
<div class="metric-card pending-card">
<span>提现待打款</span>
<strong>{{
moneyCent(dashboard.disbursement_summary.withdrawal_pending_amount_cent)
}}</strong>
<small>{{ dashboard.disbursement_summary.withdrawal_pending_count }} 笔已审核</small>
</div>
<div class="metric-card review-card">
<span>提现待审核</span>
<strong>{{
moneyCent(dashboard.disbursement_summary.withdrawal_review_amount_cent)
}}</strong>
<small>{{ dashboard.disbursement_summary.withdrawal_review_count }} 笔待审核</small>
</div>
</div>
<div v-if="dashboard?.pickup_summary" class="metric-grid pickup-grid">
<div class="metric-card pickup-card">
<span>线下提号结算</span>
@@ -188,6 +247,21 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
moneyCent(row.owner_effective_settlement_amount_cent)
}}</template>
</el-table-column>
<el-table-column label="实际出款" width="130">
<template #default="{ row }">
{{ moneyCent(row.disbursement_paid_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="代管打款" width="130">
<template #default="{ row }">
{{ moneyCent(row.offline_settlement_paid_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="提现打款" width="130">
<template #default="{ row }">
{{ moneyCent(row.withdrawal_paid_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="待线下" width="130">
<template #default="{ row }">
<span :class="rowOfflinePendingClass(row)">
@@ -238,6 +312,42 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
margin-top: 12px;
}
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin: 4px 0 12px;
}
.section-heading h2 {
margin: 0;
color: #1f2937;
font-size: 18px;
letter-spacing: 0;
}
.section-heading p {
margin: 5px 0 0;
color: #7f8aa3;
font-size: 13px;
}
.disbursement-grid {
margin-bottom: 20px;
}
.disbursement-card {
border-left: 3px solid #0f766e;
}
.pending-card {
border-left: 3px solid #d97706;
}
.review-card {
border-left: 3px solid #64748b;
}
.pickup-card {
border-left: 3px solid #6366f1;
}
@@ -26,6 +26,8 @@ const filters = reactive({
user_id: '',
order_status: '',
settlement_status: '',
settlement_mode: '',
offline_settlement_status: '',
})
onMounted(loadDetails)
@@ -53,6 +55,8 @@ function resetFilters() {
filters.user_id = ''
filters.order_status = ''
filters.settlement_status = ''
filters.settlement_mode = ''
filters.offline_settlement_status = ''
currentPage.value = 1
void loadDetails()
}
@@ -146,6 +150,7 @@ function defaultEndDate() {
<el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="结算日期" value="settled" />
<el-option label="线下打款日期" value="offline_settled" />
<el-option label="下单日期" value="created" />
</el-select>
</el-form-item>
@@ -196,6 +201,29 @@ function defaultEndDate() {
<el-option label="已结算" value="settled" />
</el-select>
</el-form-item>
<el-form-item label="结算模式">
<el-select
v-model="filters.settlement_mode"
clearable
placeholder="全部模式"
class="full-control"
>
<el-option label="号主钱包" value="owner_wallet" />
<el-option label="平台代管" value="platform_managed" />
</el-select>
</el-form-item>
<el-form-item label="线下状态">
<el-select
v-model="filters.offline_settlement_status"
clearable
placeholder="全部状态"
class="full-control"
>
<el-option label="待线下结算" value="pending" />
<el-option label="已线下结算" value="settled" />
<el-option label="无需线下结算" value="none" />
</el-select>
</el-form-item>
</el-form>
<div v-loading="loading" class="table-panel finance-details-table">
@@ -298,7 +326,7 @@ function defaultEndDate() {
}
.finance-filter-panel {
grid-template-columns: 7% 10% 10% 29% 23% 10% 11%;
grid-template-columns: repeat(9, minmax(100px, 1fr));
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
@@ -0,0 +1,451 @@
<script setup lang="ts">
import { Search, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import {
fetchFinanceDisbursements,
type FinanceDisbursementItem,
type FinanceDisbursementListSummary,
} from '@/features/admin/api/adminFinance'
import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
import { useAdminSessionStore } from '@/stores/adminSession'
import { adminPath } from '@/shared/utils/adminPath'
import { readError } from '@/shared/utils/error'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
const adminSession = useAdminSessionStore()
const loading = ref(false)
const detailLoading = ref(false)
const items = ref<FinanceDisbursementItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const showWithdrawalDetail = ref(false)
const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
const summary = ref<FinanceDisbursementListSummary>(emptySummary())
const filters = reactive({
date_type: 'created',
start_date: defaultStartDate(),
end_date: formatInputDate(new Date()),
source_type: '',
status: '',
business_no: '',
keyword: '',
user_id: '',
})
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const result = await fetchFinanceDisbursements({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
items.value = result.items
total.value = result.total
summary.value = result.summary
} catch (error) {
items.value = []
total.value = 0
summary.value = emptySummary()
ElMessage.error(readError(error, '资金出款明细加载失败'))
} finally {
loading.value = false
}
}
function search() {
currentPage.value = 1
void loadItems()
}
function resetFilters() {
filters.date_type = 'created'
filters.start_date = defaultStartDate()
filters.end_date = formatInputDate(new Date())
filters.source_type = ''
filters.status = ''
filters.business_no = ''
filters.keyword = ''
filters.user_id = ''
currentPage.value = 1
void loadItems()
}
async function openWithdrawalDetail(row: FinanceDisbursementItem) {
if (!row.withdrawal_id || detailLoading.value) return
detailLoading.value = true
try {
selectedWithdrawal.value = await fetchAdminWithdrawal(row.withdrawal_id)
showWithdrawalDetail.value = true
} catch (error) {
ElMessage.error(readError(error, '提现详情加载失败'))
} finally {
detailLoading.value = false
}
}
function onWithdrawalSaved() {
showWithdrawalDetail.value = false
selectedWithdrawal.value = null
void loadItems()
}
function emptySummary(): FinanceDisbursementListSummary {
return {
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,
}
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
}
function money(value: number) {
return formatCentWithSymbol(Number(value || 0))
}
function sourceLabel(source: string) {
return source === 'platform_managed' ? '平台代管' : '用户提现'
}
function sourceType(source: string) {
return source === 'platform_managed' ? 'primary' : 'info'
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
review_pending: '待审核',
payment_pending: '待打款',
paid: '已打款',
rejected: '已拒绝',
cancelled: '已取消',
}
return labels[status] || status
}
function statusType(status: string) {
if (status === 'paid') return 'success'
if (status === 'review_pending' || status === 'payment_pending') return 'warning'
if (status === 'rejected') return 'danger'
return 'info'
}
function accountTypeLabel(type: string) {
const labels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
bank: '银行卡',
}
return labels[type] || type || '线下约定'
}
</script>
<template>
<section class="page disbursement-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Disbursements</p>
<h1>资金出款</h1>
<p>查询平台代管线下结算与用户提现的应付待办和实际打款记录</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search">
查询
</el-button>
</div>
</div>
<div class="metric-grid disbursement-metrics">
<div class="metric-card">
<span>筛选记录</span>
<strong>{{ summary.record_count }} </strong>
<small>当前筛选条件</small>
</div>
<div class="metric-card paid-metric">
<span>已打款</span>
<strong>{{ money(summary.paid_amount_cent) }}</strong>
<small>{{ summary.paid_count }} 笔实际出款</small>
</div>
<div class="metric-card pending-metric">
<span>待打款</span>
<strong>{{ money(summary.payment_pending_amount_cent) }}</strong>
<small>{{ summary.payment_pending_count }} 笔已进入打款阶段</small>
</div>
<div class="metric-card review-metric">
<span>提现待审核</span>
<strong>{{ money(summary.review_pending_amount_cent) }}</strong>
<small>{{ summary.review_pending_count }} 笔待审核</small>
</div>
<div class="metric-card fee-metric">
<span>已完成提现手续费</span>
<strong>{{ money(summary.withdrawal_fee_amount_cent) }}</strong>
<small>随筛选结果统计</small>
</div>
</div>
<el-form class="filter-panel disbursement-filters" label-position="top" @keyup.enter="search">
<el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="业务形成日期" value="created" />
<el-option label="实际打款日期" value="paid" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="filters.start_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="filters.end_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="出款类型">
<el-select
v-model="filters.source_type"
clearable
placeholder="全部类型"
class="full-control"
>
<el-option label="平台代管" value="platform_managed" />
<el-option label="用户提现" value="withdrawal" />
</el-select>
</el-form-item>
<el-form-item label="出款状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="待审核" value="review_pending" />
<el-option label="待打款" value="payment_pending" />
<el-option label="已打款" value="paid" />
<el-option label="已拒绝" value="rejected" />
<el-option label="已取消" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="业务单号">
<el-input v-model="filters.business_no" clearable placeholder="订单号或提现单号" />
</el-form-item>
<el-form-item label="收款人">
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户 ID" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="items" empty-text="暂无出款记录">
<el-table-column label="业务" min-width="205" fixed="left">
<template #default="{ row }">
<div class="business-cell">
<el-tag size="small" :type="sourceType(row.source_type)">
{{ sourceLabel(row.source_type) }}
</el-tag>
<RouterLink
v-if="row.order_id"
class="business-no"
:to="adminPath(`orders/${row.order_id}`)"
>
{{ row.business_no }}
</RouterLink>
<strong v-else class="business-no">{{ row.business_no }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="收款人" min-width="170">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ row.payee_name || '-' }}</strong>
<small v-if="row.payee_phone">{{ row.payee_phone }}</small>
<small v-else-if="row.source_type === 'withdrawal'">用户 ID {{ row.user_id }}</small>
<small v-else>联系方式未登记</small>
<small v-if="row.uploader_name">上传{{ row.uploader_name }}</small>
<small v-if="row.source_channel">渠道{{ row.source_channel }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="收款账户" min-width="205">
<template #default="{ row }">
<div v-if="row.source_type === 'withdrawal'" class="stack-cell">
<span>{{ accountTypeLabel(row.account_type) }} · {{ row.account_name || '-' }}</span>
<small>{{ row.account_no || '-' }}</small>
<small v-if="row.bank_name">{{ row.bank_name }}</small>
</div>
<span v-else class="muted-text">线下约定</span>
</template>
</el-table-column>
<el-table-column label="金额" min-width="155" align="right">
<template #default="{ row }">
<div class="amount-stack">
<strong>{{ money(row.actual_amount_cent) }}</strong>
<small v-if="row.source_type === 'withdrawal'">
申请 {{ money(row.amount_cent) }} / 手续费 {{ money(row.fee_cent) }}
</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="形成时间" min-width="175">
<template #default="{ row }">{{ formatDateTime(row.business_created_at) }}</template>
</el-table-column>
<el-table-column label="打款时间" min-width="175">
<template #default="{ row }">{{ formatDateTime(row.paid_at) }}</template>
</el-table-column>
<el-table-column label="处理人" min-width="120">
<template #default="{ row }">{{ row.operator_name || '-' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')"
link
type="primary"
:icon="View"
:loading="detailLoading"
@click="openWithdrawalDetail(row)"
>
详情
</el-button>
<RouterLink v-else-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">
<el-button link type="primary" :icon="View">详情</el-button>
</RouterLink>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="loadItems"
/>
<WithdrawalDetailDialog
v-model="showWithdrawalDetail"
:withdrawal="selectedWithdrawal"
@saved="onWithdrawalSaved"
/>
</section>
</template>
<style scoped>
.disbursement-metrics {
grid-template-columns: repeat(5, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.paid-metric {
border-left: 3px solid #0f766e;
}
.pending-metric {
border-left: 3px solid #d97706;
}
.review-metric {
border-left: 3px solid #64748b;
}
.fee-metric {
border-left: 3px solid #2563eb;
}
.disbursement-filters {
grid-template-columns: repeat(8, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
}
.business-cell,
.stack-cell,
.amount-stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.business-cell {
align-items: flex-start;
}
.business-no {
overflow: hidden;
max-width: 100%;
color: #344054;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stack-cell strong,
.stack-cell span,
.amount-stack strong {
color: #344054;
}
.stack-cell small,
.amount-stack small,
.muted-text {
color: #7f8aa3;
font-size: 12px;
}
.amount-stack {
align-items: flex-end;
}
.amount-stack strong {
font-size: 14px;
}
@media (max-width: 1440px) {
.disbursement-metrics {
grid-template-columns: repeat(3, minmax(170px, 1fr));
}
.disbursement-filters {
grid-template-columns: repeat(4, minmax(130px, 1fr));
}
}
@media (max-width: 760px) {
.disbursement-metrics,
.disbursement-filters {
grid-template-columns: 1fr;
}
}
</style>
+6
View File
@@ -163,6 +163,12 @@ const allNavGroups: NavGroup[] = [
icon: Document,
permission: 'wallet:view',
},
{
label: '资金出款',
to: adminPath('finance/disbursements'),
icon: Money,
permission: 'wallet:view',
},
{
label: '资金流水',
to: adminPath('wallet-ledger'),
+6
View File
@@ -132,6 +132,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminFinanceDetailsView.vue'),
meta: adminMeta,
},
{
path: adminPath('finance/disbursements'),
name: 'admin-finance-disbursements',
component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'),
meta: adminMeta,
},
{
path: adminPath('wallet-ledger'),
name: 'admin-wallet-ledger',