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

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,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) {
@@ -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])
}
}
+102 -13
View File
@@ -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 {
@@ -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
}
@@ -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)