新增资金出款查询与财务统计
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user