新增其他线下出款记账

This commit is contained in:
yml2213
2026-07-26 17:13:08 +08:00
parent dbe0cda2c6
commit 379536e5c5
16 changed files with 1125 additions and 35 deletions
@@ -290,8 +290,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
item.WithdrawalPaidCount = row.WithdrawalPaidCount item.WithdrawalPaidCount = row.WithdrawalPaidCount
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent item.ManualPaidAmountCent = row.ManualPaidAmountCent
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount item.ManualPaidCount = row.ManualPaidCount
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
itemsByDate[date] = item itemsByDate[date] = item
} }
@@ -71,9 +71,21 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
return nil, err return nil, err
} }
var manualPaid struct {
AmountCent int64
Count int64
}
if err := db.Table("manual_disbursements").
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("status = ?", "paid").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Scan(&manualPaid).Error; err != nil {
return nil, err
}
return &DisbursementSummaryDTO{ return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent, PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + withdrawalPaid.Count, PaidCount: offlinePaid.Count + withdrawalPaid.Count + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent, PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count, PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent, OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
@@ -88,6 +100,8 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
WithdrawalPendingCount: withdrawalPending.Count, WithdrawalPendingCount: withdrawalPending.Count,
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent, WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
WithdrawalReviewCount: withdrawalReview.Count, WithdrawalReviewCount: withdrawalReview.Count,
ManualPaidAmountCent: manualPaid.AmountCent,
ManualPaidCount: manualPaid.Count,
}, nil }, nil
} }
@@ -118,7 +132,19 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
return nil, err return nil, err
} }
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows)) manualRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("manual_disbursements").
Select(`DATE(paid_at) AS date,
COALESCE(SUM(amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("status = ?", "paid").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Group("DATE(paid_at)").
Scan(&manualRows).Error; err != nil {
return nil, err
}
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows)+len(manualRows))
for _, row := range offlineRows { for _, row := range offlineRows {
date := dailyDateKey(row.Date) date := dailyDateKey(row.Date)
item := rowsByDate[date] item := rowsByDate[date]
@@ -135,6 +161,14 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
item.WithdrawalPaidCount = row.Count item.WithdrawalPaidCount = row.Count
rowsByDate[date] = item rowsByDate[date] = item
} }
for _, row := range manualRows {
date := dailyDateKey(row.Date)
item := rowsByDate[date]
item.Date = date
item.ManualPaidAmountCent = row.AmountCent
item.ManualPaidCount = row.Count
rowsByDate[date] = item
}
rows := make([]dailyDisbursementRow, 0, len(rowsByDate)) rows := make([]dailyDisbursementRow, 0, len(rowsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -157,4 +191,6 @@ type dailyDisbursementRow struct {
OfflineSettlementPaidCount int64 OfflineSettlementPaidCount int64
WithdrawalPaidAmountCent int64 WithdrawalPaidAmountCent int64
WithdrawalPaidCount int64 WithdrawalPaidCount int64
ManualPaidAmountCent int64
ManualPaidCount int64
} }
@@ -79,6 +79,12 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
ro.offline_settled_by AS operator_id, ro.offline_settled_by AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark, ro.offline_settlement_remark AS remark,
'' AS category,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
lu.parsed_payload AS source_payload`). lu.parsed_payload AS source_payload`).
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = ( 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 SELECT MAX(lu2.id) FROM listing_uploads AS lu2 WHERE lu2.listing_id = ro.listing_id
@@ -118,11 +124,52 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id, COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, 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, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
NULL AS source_payload`). NULL AS source_payload`).
Joins("LEFT JOIN users AS u ON u.id = wr.user_id"). 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)") Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)")
union := db.Raw("? UNION ALL ?", platformManaged, withdrawal) manualOffline := db.Table("manual_disbursements AS md").
Select(`'manual_offline' AS source_type,
md.id AS source_id,
md.disbursement_no AS business_no,
0 AS order_id,
0 AS withdrawal_id,
0 AS user_id,
md.payee_name,
'' AS payee_phone,
'' AS uploader_name,
'' AS source_channel,
'' AS account_type,
'' AS account_name,
'' AS account_no,
'' AS bank_name,
md.amount_cent,
0 AS fee_cent,
md.amount_cent AS actual_amount_cent,
md.status,
md.status AS raw_status,
md.created_at AS business_created_at,
md.paid_at,
md.created_by AS operator_id,
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark,
md.category,
md.voucher_url,
md.voided_at,
md.voided_by,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name,
md.void_reason,
NULL AS source_payload`).
Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by").
Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by")
union := db.Raw("? UNION ALL ? UNION ALL ?", platformManaged, withdrawal, manualOffline)
return db.Table("(?) AS d", union) return db.Table("(?) AS d", union)
} }
@@ -179,6 +226,12 @@ type disbursementItemRow struct {
OperatorID *uint64 OperatorID *uint64
OperatorName string OperatorName string
Remark string Remark string
Category string
VoucherURL string
VoidedAt *time.Time
VoidedBy *uint64
VoidedByName string
VoidReason string
SourcePayload []byte SourcePayload []byte
} }
@@ -217,5 +270,11 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
OperatorID: r.OperatorID, OperatorID: r.OperatorID,
OperatorName: r.OperatorName, OperatorName: r.OperatorName,
Remark: r.Remark, Remark: r.Remark,
Category: r.Category,
VoucherURL: r.VoucherURL,
VoidedAt: r.VoidedAt,
VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName,
VoidReason: r.VoidReason,
} }
} }
@@ -36,6 +36,12 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
)`, )`,
`CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, `CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`,
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT,
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME
)`,
} }
for _, statement := range statements { for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil { if err := db.Exec(statement).Error; err != nil {
@@ -50,7 +56,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
(7, '财务甲', 'finance_a')`).Error; err != nil { (7, '财务甲', 'finance_a'), (8, '财务乙', 'finance_b')`).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO listing_uploads if err := db.Exec(`INSERT INTO listing_uploads
@@ -80,6 +86,15 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
at(5), at(6), at(7), at(8)).Error; err != nil { at(5), at(6), at(7), at(8)).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, payee_name, amount_cent, paid_at, remark,
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db) repo := NewRepository(db)
query := DisbursementQuery{ query := DisbursementQuery{
@@ -93,11 +108,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("Disbursements() error = %v", err) t.Fatalf("Disbursements() error = %v", err)
} }
if result.Total != 5 || len(result.Items) != 5 { if result.Total != 7 || len(result.Items) != 7 {
t.Fatalf("记录数 = %d/%d, want 5/5", result.Total, len(result.Items)) t.Fatalf("记录数 = %d/%d, want 7/7", result.Total, len(result.Items))
} }
if result.Summary.PaidAmountCent != 21900 || result.Summary.PaidCount != 2 { if result.Summary.PaidAmountCent != 24400 || result.Summary.PaidCount != 3 {
t.Fatalf("已出款 = %d/%d, want 21900/2", result.Summary.PaidAmountCent, result.Summary.PaidCount) t.Fatalf("已出款 = %d/%d, want 24400/3", result.Summary.PaidAmountCent, result.Summary.PaidCount)
} }
if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 { if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 {
t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount) t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount)
@@ -120,6 +135,17 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName) t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName)
} }
var manualItem *DisbursementItemDTO
for index := range result.Items {
if result.Items[index].BusinessNo == "OD202607010001" {
manualItem = &result.Items[index]
break
}
}
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
}
paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{ paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{
SourceType: "withdrawal", SourceType: "withdrawal",
Status: "paid", Status: "paid",
@@ -35,10 +35,19 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
)`).Error; err != nil { )`).Error; err != nil {
t.Fatalf("创建提现表失败: %v", err) t.Fatalf("创建提现表失败: %v", err)
} }
if err := db.Exec(`CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL,
amount_cent INTEGER NOT NULL,
paid_at DATETIME NOT NULL
)`).Error; err != nil {
t.Fatalf("创建其他线下出款表失败: %v", err)
}
loc := timeutil.ShanghaiLocation() loc := timeutil.ShanghaiLocation()
inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc) inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc)
inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc) inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc)
inRangeManual := time.Date(2026, 7, 3, 12, 0, 0, 0, loc)
outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc) outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc)
for _, args := range [][]any{ for _, args := range [][]any{
@@ -54,6 +63,16 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
t.Fatalf("写入订单失败: %v", err) t.Fatalf("写入订单失败: %v", err)
} }
} }
for _, args := range [][]any{
{1, "paid", 2500, inRangeManual},
{2, "paid", 1000, outOfRange},
{3, "voided", 6000, inRangeManual},
} {
if err := db.Exec(`INSERT INTO manual_disbursements
(id, status, amount_cent, paid_at) VALUES (?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入其他线下出款失败: %v", err)
}
}
for _, args := range [][]any{ for _, args := range [][]any{
{1, "completed", 10000, 100, 9900, inRangeWithdrawal}, {1, "completed", 10000, 100, 9900, inRangeWithdrawal},
{2, "completed", 5000, 0, 5000, outOfRange}, {2, "completed", 5000, 0, 5000, outOfRange},
@@ -77,8 +96,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("disbursementSummary() error = %v", err) t.Fatalf("disbursementSummary() error = %v", err)
} }
if summary.PaidAmountCent != 21900 || summary.PaidCount != 2 { if summary.PaidAmountCent != 24400 || summary.PaidCount != 3 {
t.Fatalf("实际出款 = %d/%d, want 21900/2", summary.PaidAmountCent, summary.PaidCount) t.Fatalf("实际出款 = %d/%d, want 24400/3", summary.PaidAmountCent, summary.PaidCount)
} }
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 { if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount) t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
@@ -86,6 +105,9 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 { 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) t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent)
} }
if summary.ManualPaidAmountCent != 2500 || summary.ManualPaidCount != 1 {
t.Fatalf("其他线下出款 = %d/%d, want 2500/1", summary.ManualPaidAmountCent, summary.ManualPaidCount)
}
if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 { if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 {
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount) t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
} }
@@ -97,8 +119,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("dailyDisbursements() error = %v", err) t.Fatalf("dailyDisbursements() error = %v", err)
} }
if len(daily) != 2 { if len(daily) != 3 {
t.Fatalf("每日出款行数 = %d, want 2", len(daily)) t.Fatalf("每日出款行数 = %d, want 3", len(daily))
} }
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 { if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
t.Fatalf("第一日代管出款 = %+v", daily[0]) t.Fatalf("第一日代管出款 = %+v", daily[0])
@@ -106,4 +128,7 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 { if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 {
t.Fatalf("第二日提现出款 = %+v", daily[1]) t.Fatalf("第二日提现出款 = %+v", daily[1])
} }
if daily[2].Date != "2026-07-03" || daily[2].ManualPaidAmountCent != 2500 {
t.Fatalf("第三日其他线下出款 = %+v", daily[2])
}
} }
@@ -69,6 +69,8 @@ type DisbursementSummaryDTO struct {
WithdrawalPendingCount int64 `json:"withdrawal_pending_count"` WithdrawalPendingCount int64 `json:"withdrawal_pending_count"`
WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"` WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"`
WithdrawalReviewCount int64 `json:"withdrawal_review_count"` WithdrawalReviewCount int64 `json:"withdrawal_review_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"`
} }
type DisbursementListDTO struct { type DisbursementListDTO struct {
@@ -115,6 +117,44 @@ type DisbursementItemDTO struct {
OperatorID *uint64 `json:"operator_id,omitempty"` OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Category string `json:"category"`
VoucherURL string `json:"voucher_url"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
}
type CreateManualDisbursementRequest struct {
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
}
type VoidManualDisbursementRequest struct {
Reason string `json:"reason"`
}
type ManualDisbursementDTO struct {
ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
Status string `json:"status"`
CreatedBy uint64 `json:"created_by"`
CreatedByName string `json:"created_by_name"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidReason string `json:"void_reason"`
CreatedAt time.Time `json:"created_at"`
} }
type FinanceSummaryDTO struct { type FinanceSummaryDTO struct {
@@ -166,6 +206,8 @@ type FinanceDailyDTO struct {
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"` OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"` WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"` WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"`
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"` DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
DisbursementPaidCount int64 `json:"disbursement_paid_count"` DisbursementPaidCount int64 `json:"disbursement_paid_count"`
} }
@@ -7,6 +7,8 @@ import (
"strings" "strings"
"time" "time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/timeutil" "hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
@@ -60,6 +62,49 @@ func (h *Handler) Disbursements(c *gin.Context) {
response.OK(c, result) response.OK(c, result)
} }
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateManualDisbursementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "线下出款信息不正确")
return
}
item, err := h.service.CreateManualDisbursement(c.Request.Context(), req, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) VoidManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "线下出款记录 ID 不正确")
return
}
var req VoidManualDisbursementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写作废原因")
return
}
item, err := h.service.VoidManualDisbursement(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
start, end, ok := parseDateRange(c, 6) start, end, ok := parseDateRange(c, 6)
if !ok { if !ok {
@@ -124,11 +169,11 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
StartDate: start, StartDate: start,
EndDate: end, EndDate: end,
} }
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" { if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" {
response.BadRequest(c, "出款类型不正确") response.BadRequest(c, "出款类型不正确")
return query, false return query, false
} }
if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" { if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" && query.Status != "voided" {
response.BadRequest(c, "出款状态不正确") response.BadRequest(c, "出款状态不正确")
return query, false return query, false
} }
@@ -186,7 +231,30 @@ func writeFinanceError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidManualDisbursement):
response.BadRequest(c, "线下出款信息不正确")
case errors.Is(err, ErrManualDisbursementNotFound):
response.NotFound(c, "线下出款记录不存在")
case errors.Is(err, ErrManualDisbursementNotPaid):
response.BadRequest(c, "该线下出款记录已作废")
default: default:
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
} }
} }
func financeAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func financeAuditMeta(c *gin.Context) auditlog.Meta {
return auditlog.Meta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RequestID: middleware.GetRequestID(c),
}
}
@@ -0,0 +1,200 @@
package adminfinance
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type manualDisbursementRecord struct {
ID uint64
DisbursementNo string
Category string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
}
func (manualDisbursementRecord) TableName() string {
return "manual_disbursements"
}
func (r *Repository) CreateManualDisbursement(
ctx context.Context,
req CreateManualDisbursementRequest,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
disbursementNo, err := newManualDisbursementNo()
if err != nil {
return nil, err
}
record := manualDisbursementRecord{
DisbursementNo: disbursementNo,
Category: req.Category,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
PaidAt: req.PaidAt,
Remark: req.Remark,
VoucherURL: req.VoucherURL,
Status: "paid",
CreatedBy: adminID,
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil {
return err
}
id := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "manual_disbursement_create",
BizType: "manual_disbursement",
BizID: &id,
Meta: meta,
Detail: map[string]any{
"disbursement_no": disbursementNo,
"category": req.Category,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"paid_at": req.PaidAt,
"has_voucher": req.VoucherURL != "",
},
})
})
if err != nil {
return nil, err
}
return r.findManualDisbursement(ctx, record.ID)
}
func (r *Repository) VoidManualDisbursement(
ctx context.Context,
id uint64,
reason string,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record manualDisbursementRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrManualDisbursementNotFound
}
return err
}
if record.Status != "paid" {
return ErrManualDisbursementNotPaid
}
now := timeutil.ShanghaiNow()
if err := tx.Model(&record).Updates(map[string]any{
"status": "voided",
"voided_by": adminID,
"voided_at": now,
"void_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
bizID := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "manual_disbursement_void",
BizType: "manual_disbursement",
BizID: &bizID,
Meta: meta,
Detail: map[string]any{
"disbursement_no": record.DisbursementNo,
"amount_cent": record.AmountCent,
"reason": reason,
},
})
})
if err != nil {
return nil, err
}
return r.findManualDisbursement(ctx, id)
}
func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*ManualDisbursementDTO, error) {
var row manualDisbursementDetailRow
err := r.db.WithContext(ctx).Table("manual_disbursements AS md").
Select(`md.*,
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by").
Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by").
Where("md.id = ?", id).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrManualDisbursementNotFound
}
if err != nil {
return nil, err
}
return &ManualDisbursementDTO{
ID: row.ID,
DisbursementNo: row.DisbursementNo,
Category: row.Category,
PayeeName: row.PayeeName,
AmountCent: row.AmountCent,
PaidAt: row.PaidAt,
Remark: row.Remark,
VoucherURL: row.VoucherURL,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedByName: row.CreatedByName,
VoidedBy: row.VoidedBy,
VoidedByName: row.VoidedByName,
VoidedAt: row.VoidedAt,
VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
}, nil
}
type manualDisbursementDetailRow struct {
ID uint64
DisbursementNo string
Category string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
CreatedByName string
VoidedBy *uint64
VoidedByName string
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
}
func newManualDisbursementNo() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("OD%s%s", timeutil.ShanghaiNow().Format("20060102150405"), hex.EncodeToString(buf)), nil
}
@@ -0,0 +1,96 @@
package adminfinance
import (
"errors"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestManualDisbursementCreateAndVoid(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 manual_disbursements (
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER,
action TEXT, biz_type TEXT, biz_id INTEGER, ip TEXT, user_agent TEXT,
detail BLOB, created_at DATETIME
)`,
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("创建测试表失败: %v", err)
}
}
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
(7, '财务甲', 'finance_a'), (8, '财务乙', 'finance_b')`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "operating_expense",
PayeeName: "测试供应商",
AmountCent: 8800,
PaidAt: paidAt,
Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err)
}
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" {
t.Fatalf("创建结果不正确: %+v", created)
}
voided, err := repo.VoidManualDisbursement(t.Context(), created.ID, "录入金额有误", 8, auditlog.Meta{RequestID: "req-void"})
if err != nil {
t.Fatalf("作废其他线下出款失败: %v", err)
}
if voided.Status != "voided" || voided.VoidedByName != "财务乙" || voided.VoidReason != "录入金额有误" || voided.VoidedAt == nil {
t.Fatalf("作废结果不正确: %+v", voided)
}
if _, err := repo.VoidManualDisbursement(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrManualDisbursementNotPaid) {
t.Fatalf("重复作废错误 = %v, want ErrManualDisbursementNotPaid", err)
}
var auditCount int64
if err := db.Table("audit_logs").Where("biz_type = ?", "manual_disbursement").Count(&auditCount).Error; err != nil {
t.Fatal(err)
}
if auditCount != 2 {
t.Fatalf("审计日志数量 = %d, want 2", auditCount)
}
}
func TestManualDisbursementValidation(t *testing.T) {
service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown",
PayeeName: "测试收款人",
AmountCent: 100,
PaidAt: timeutil.ShanghaiNow(),
Remark: "测试",
}, 1, auditlog.Meta{})
if !errors.Is(err, ErrInvalidManualDisbursement) {
t.Fatalf("无效分类错误 = %v, want ErrInvalidManualDisbursement", err)
}
}
@@ -3,9 +3,19 @@ package adminfinance
import ( import (
"context" "context"
"errors" "errors"
"net/url"
"strings"
"unicode/utf8"
"hfb_sys/backend/internal/auditlog"
) )
var ErrDependencyUnavailable = errors.New("dependency unavailable") var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
)
type Service struct { type Service struct {
repo *Repository repo *Repository
@@ -53,3 +63,66 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
} }
return s.repo.Disbursements(ctx, query) return s.repo.Disbursements(ctx, query)
} }
func (s *Service) CreateManualDisbursement(
ctx context.Context,
req CreateManualDisbursementRequest,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
!validManualVoucherURL(req.VoucherURL) {
return nil, ErrInvalidManualDisbursement
}
return s.repo.CreateManualDisbursement(ctx, req, adminID, meta)
}
func (s *Service) VoidManualDisbursement(
ctx context.Context,
id uint64,
reason string,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reason = strings.TrimSpace(reason)
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
return nil, ErrInvalidManualDisbursement
}
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
}
func validManualDisbursementCategory(category string) bool {
switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
return true
default:
return false
}
}
func validManualVoucherURL(raw string) bool {
if raw == "" {
return true
}
if len(raw) > 500 {
return false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.IsAbs() || parsed.Path != "/api/files/object" {
return false
}
return strings.HasPrefix(parsed.Query().Get("key"), "manual-disbursement/")
}
+1 -1
View File
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string { func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene)) scene = strings.TrimSpace(strings.ToLower(scene))
switch scene { switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle": case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "manual-disbursement":
return scene return scene
default: default:
return "misc" return "misc"
+2
View File
@@ -639,6 +639,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard) adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard)
adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details) adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details)
adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements) adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements)
adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement)
adminRoutes.POST("/finance/manual-disbursements/:id/void", requirePerm("finance:manual_disbursement"), adminFinanceHandler.VoidManualDisbursement)
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
@@ -0,0 +1,44 @@
-- +goose Up
CREATE TABLE manual_disbursements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
disbursement_no VARCHAR(64) NOT NULL COMMENT '其他线下出款单号',
category VARCHAR(32) NOT NULL COMMENT '出款分类',
payee_name VARCHAR(100) NOT NULL COMMENT '收款人或单位名称',
amount_cent BIGINT NOT NULL COMMENT '实际出款金额(分)',
paid_at DATETIME NOT NULL COMMENT '实际出款时间',
remark VARCHAR(500) NOT NULL COMMENT '出款用途备注',
voucher_url VARCHAR(500) NOT NULL DEFAULT '' COMMENT '付款凭证地址',
status VARCHAR(16) NOT NULL DEFAULT 'paid' COMMENT '状态: paid已出款/voided已作废',
created_by BIGINT UNSIGNED NOT NULL COMMENT '录入管理员ID',
voided_by BIGINT UNSIGNED NULL COMMENT '作废管理员ID',
voided_at DATETIME NULL COMMENT '作废时间',
void_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '作废原因',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_manual_disbursements_no (disbursement_no),
KEY idx_manual_disbursements_paid (status, paid_at),
KEY idx_manual_disbursements_created (created_at),
KEY idx_manual_disbursements_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='其他线下出款记账';
INSERT INTO permissions (code, name, resource, action) VALUES
('finance:manual_disbursement', '管理其他线下出款', 'finance', 'manual_disbursement')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code IN ('super_admin', 'finance') AND p.code = 'finance:manual_disbursement';
-- +goose Down
DELETE rp FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code = 'finance:manual_disbursement';
DELETE FROM permissions WHERE code = 'finance:manual_disbursement';
DROP TABLE IF EXISTS manual_disbursements;
@@ -54,6 +54,8 @@ export interface FinanceDailyItem {
offline_settlement_paid_count: number offline_settlement_paid_count: number
withdrawal_paid_amount_cent: number withdrawal_paid_amount_cent: number
withdrawal_paid_count: number withdrawal_paid_count: number
manual_paid_amount_cent: number
manual_paid_count: number
disbursement_paid_amount_cent: number disbursement_paid_amount_cent: number
disbursement_paid_count: number disbursement_paid_count: number
} }
@@ -82,6 +84,8 @@ export interface FinanceDisbursementSummary {
withdrawal_pending_count: number withdrawal_pending_count: number
withdrawal_review_amount_cent: number withdrawal_review_amount_cent: number
withdrawal_review_count: number withdrawal_review_count: number
manual_paid_amount_cent: number
manual_paid_count: number
} }
export interface FinanceDisbursementListSummary { export interface FinanceDisbursementListSummary {
@@ -96,7 +100,7 @@ export interface FinanceDisbursementListSummary {
} }
export interface FinanceDisbursementItem { export interface FinanceDisbursementItem {
source_type: 'platform_managed' | 'withdrawal' source_type: 'platform_managed' | 'withdrawal' | 'manual_offline'
source_id: number source_id: number
business_no: string business_no: string
order_id?: number order_id?: number
@@ -113,13 +117,47 @@ export interface FinanceDisbursementItem {
amount_cent: number amount_cent: number
fee_cent: number fee_cent: number
actual_amount_cent: number actual_amount_cent: number
status: 'review_pending' | 'payment_pending' | 'paid' | 'rejected' | 'cancelled' status: 'review_pending' | 'payment_pending' | 'paid' | 'rejected' | 'cancelled' | 'voided'
raw_status: string raw_status: string
business_created_at?: string business_created_at?: string
paid_at?: string paid_at?: string
operator_id?: number operator_id?: number
operator_name: string operator_name: string
remark: string remark: string
category: string
voucher_url: string
voided_at?: string
voided_by?: number
voided_by_name: string
void_reason: string
}
export interface CreateManualDisbursementPayload {
category: string
payee_name: string
amount_cent: number
paid_at: string
remark: string
voucher_url?: string
}
export interface ManualDisbursement {
id: number
disbursement_no: string
category: string
payee_name: string
amount_cent: number
paid_at: string
remark: string
voucher_url: string
status: 'paid' | 'voided'
created_by: number
created_by_name: string
voided_by?: number
voided_by_name: string
voided_at?: string
void_reason: string
created_at: string
} }
export interface FinanceDisbursementQuery extends FinanceDateQuery { export interface FinanceDisbursementQuery extends FinanceDateQuery {
@@ -239,6 +277,8 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
withdrawal_pending_count: 0, withdrawal_pending_count: 0,
withdrawal_review_amount_cent: 0, withdrawal_review_amount_cent: 0,
withdrawal_review_count: 0, withdrawal_review_count: 0,
manual_paid_amount_cent: 0,
manual_paid_count: 0,
}, },
} }
} }
@@ -280,3 +320,19 @@ export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery
}, },
} }
} }
export async function createManualDisbursement(payload: CreateManualDisbursementPayload) {
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
'/admin/finance/manual-disbursements',
payload
)
return data.data
}
export async function voidManualDisbursement(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
`/admin/finance/manual-disbursements/${id}/void`,
{ reason }
)
return data.data
}
@@ -66,7 +66,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Finance Dashboard</p> <p class="eyebrow">Finance Dashboard</p>
<h1>财务仪表盘</h1> <h1>财务仪表盘</h1>
<p>按天查看收付款平台收入号主结算平台代管打款和用户提现</p> <p>按天查看收付款平台收入号主结算及全部资金出款</p>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-date-picker <el-date-picker
@@ -157,7 +157,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
<div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid"> <div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid">
<div class="metric-card disbursement-card"> <div class="metric-card disbursement-card">
<span>代管及提现出款</span> <span>全部实际出款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong> <strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong>
<small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small> <small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small>
</div> </div>
@@ -177,6 +177,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
{{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }} {{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
</small> </small>
</div> </div>
<div class="metric-card disbursement-card">
<span>其他线下出款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }}</strong>
<small>{{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账</small>
</div>
<div class="metric-card pending-card"> <div class="metric-card pending-card">
<span>当前待出款</span> <span>当前待出款</span>
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong> <strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
@@ -262,6 +267,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
{{ moneyCent(row.withdrawal_paid_amount_cent) }} {{ moneyCent(row.withdrawal_paid_amount_cent) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="其他线下" width="130">
<template #default="{ row }">
{{ moneyCent(row.manual_paid_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="待线下" width="130"> <el-table-column label="待线下" width="130">
<template #default="{ row }"> <template #default="{ row }">
<span :class="rowOfflinePendingClass(row)"> <span :class="rowOfflinePendingClass(row)">
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search, View } from '@element-plus/icons-vue' import { CircleClose, Plus, Search, UploadFilled, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
createManualDisbursement,
fetchFinanceDisbursements, fetchFinanceDisbursements,
voidManualDisbursement,
type FinanceDisbursementItem, type FinanceDisbursementItem,
type FinanceDisbursementListSummary, type FinanceDisbursementListSummary,
} from '@/features/admin/api/adminFinance' } from '@/features/admin/api/adminFinance'
import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal' import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { uploadAdminFile } from '@/shared/api/files'
import { useAdminSessionStore } from '@/stores/adminSession' import { useAdminSessionStore } from '@/stores/adminSession'
import { adminPath } from '@/shared/utils/adminPath' import { adminPath } from '@/shared/utils/adminPath'
import { readError } from '@/shared/utils/error' import { readError } from '@/shared/utils/error'
import { formatCentWithSymbol } from '@/shared/utils/money' import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
import { formatDateTime, formatInputDate } from '@/shared/utils/time' import { formatDateTime, formatInputDate } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue' import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
@@ -26,7 +30,28 @@ const currentPage = ref(1)
const currentPageSize = ref(20) const currentPageSize = ref(20)
const showWithdrawalDetail = ref(false) const showWithdrawalDetail = ref(false)
const selectedWithdrawal = ref<WithdrawalDetail | null>(null) const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
const createVisible = ref(false)
const createSaving = ref(false)
const voucherUploading = ref(false)
const manualActionID = ref(0)
const selectedManual = ref<FinanceDisbursementItem | null>(null)
const summary = ref<FinanceDisbursementListSummary>(emptySummary()) const summary = ref<FinanceDisbursementListSummary>(emptySummary())
const canManageManual = computed(() => adminSession.hasPermission('finance:manual_disbursement'))
const manualCategories = [
{ value: 'user_compensation', label: '用户补偿' },
{ value: 'seller_supplement', label: '卖家补款' },
{ value: 'operating_expense', label: '运营支出' },
{ value: 'channel_fee', label: '渠道费用' },
{ value: 'other', label: '其他' },
]
const createForm = reactive({
category: '',
payee_name: '',
amount_yuan: 0,
paid_at: new Date(),
remark: '',
voucher_url: '',
})
const filters = reactive({ const filters = reactive({
date_type: 'created', date_type: 'created',
start_date: defaultStartDate(), start_date: defaultStartDate(),
@@ -98,6 +123,103 @@ function onWithdrawalSaved() {
void loadItems() void loadItems()
} }
function openCreate() {
resetCreateForm()
createVisible.value = true
}
function resetCreateForm() {
createForm.category = ''
createForm.payee_name = ''
createForm.amount_yuan = 0
createForm.paid_at = new Date()
createForm.remark = ''
createForm.voucher_url = ''
}
async function uploadVoucher(options: { file: File }) {
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('付款凭证仅支持图片')
return
}
voucherUploading.value = true
try {
const uploaded = await uploadAdminFile(options.file, 'manual-disbursement')
createForm.voucher_url = uploaded.url
ElMessage.success('凭证已上传')
} catch (error) {
ElMessage.error(readError(error, '凭证上传失败'))
} finally {
voucherUploading.value = false
}
}
async function submitManualDisbursement() {
const amountCent = yuanToCent(createForm.amount_yuan)
if (
!createForm.category ||
!createForm.payee_name.trim() ||
amountCent <= 0 ||
!createForm.paid_at
) {
ElMessage.warning('请填写完整的出款信息')
return
}
if (!createForm.remark.trim()) {
ElMessage.warning('请填写出款用途')
return
}
createSaving.value = true
try {
await createManualDisbursement({
category: createForm.category,
payee_name: createForm.payee_name.trim(),
amount_cent: amountCent,
paid_at: createForm.paid_at.toISOString(),
remark: createForm.remark.trim(),
voucher_url: createForm.voucher_url || undefined,
})
createVisible.value = false
ElMessage.success('线下出款已记账')
await loadItems()
} catch (error) {
ElMessage.error(readError(error, '线下出款记账失败'))
} finally {
createSaving.value = false
}
}
function openManualDetail(row: FinanceDisbursementItem) {
selectedManual.value = row
}
async function handleVoidManual(row: FinanceDisbursementItem) {
if (manualActionID.value) return
try {
const { value } = await ElMessageBox.prompt(
`确认作废出款单 ${row.business_no}?作废后将从财务统计中排除。`,
'作废线下出款',
{
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '请输入作废原因',
inputValidator: value => !!value.trim() || '请输入作废原因',
type: 'warning',
}
)
manualActionID.value = row.source_id
await voidManualDisbursement(row.source_id, value.trim())
selectedManual.value = null
ElMessage.success('线下出款已作废')
await loadItems()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(readError(error, '作废线下出款失败'))
} finally {
manualActionID.value = 0
}
}
function emptySummary(): FinanceDisbursementListSummary { function emptySummary(): FinanceDisbursementListSummary {
return { return {
record_count: 0, record_count: 0,
@@ -122,11 +244,18 @@ function money(value: number) {
} }
function sourceLabel(source: string) { function sourceLabel(source: string) {
return source === 'platform_managed' ? '平台代管' : '用户提现' const labels: Record<string, string> = {
platform_managed: '平台代管',
withdrawal: '用户提现',
manual_offline: '其他线下出款',
}
return labels[source] || source
} }
function sourceType(source: string) { function sourceType(source: string) {
return source === 'platform_managed' ? 'primary' : 'info' if (source === 'platform_managed') return 'primary'
if (source === 'manual_offline') return 'warning'
return 'info'
} }
function statusLabel(status: string) { function statusLabel(status: string) {
@@ -136,6 +265,7 @@ function statusLabel(status: string) {
paid: '已打款', paid: '已打款',
rejected: '已拒绝', rejected: '已拒绝',
cancelled: '已取消', cancelled: '已取消',
voided: '已作废',
} }
return labels[status] || status return labels[status] || status
} }
@@ -144,9 +274,19 @@ function statusType(status: string) {
if (status === 'paid') return 'success' if (status === 'paid') return 'success'
if (status === 'review_pending' || status === 'payment_pending') return 'warning' if (status === 'review_pending' || status === 'payment_pending') return 'warning'
if (status === 'rejected') return 'danger' if (status === 'rejected') return 'danger'
if (status === 'voided') return 'danger'
return 'info' return 'info'
} }
function itemStatusLabel(row: FinanceDisbursementItem) {
if (row.source_type === 'manual_offline' && row.status === 'paid') return '已出款'
return statusLabel(row.status)
}
function categoryLabel(category: string) {
return manualCategories.find(item => item.value === category)?.label || category || '-'
}
function accountTypeLabel(type: string) { function accountTypeLabel(type: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
alipay: '支付宝', alipay: '支付宝',
@@ -163,9 +303,12 @@ function accountTypeLabel(type: string) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Disbursements</p> <p class="eyebrow">Disbursements</p>
<h1>资金出款</h1> <h1>资金出款</h1>
<p>查询平台代管线下结算与用户提现的应付待办和实际打款记录</p> <p>统一查询平台代管用户提现和其他线下出款记录</p>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button v-if="canManageManual" type="success" :icon="Plus" @click="openCreate">
新增线下出款
</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search"> <el-button type="primary" :icon="Search" :loading="loading" @click="search">
查询 查询
@@ -233,6 +376,7 @@ function accountTypeLabel(type: string) {
> >
<el-option label="平台代管" value="platform_managed" /> <el-option label="平台代管" value="platform_managed" />
<el-option label="用户提现" value="withdrawal" /> <el-option label="用户提现" value="withdrawal" />
<el-option label="其他线下出款" value="manual_offline" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="出款状态"> <el-form-item label="出款状态">
@@ -242,10 +386,15 @@ function accountTypeLabel(type: string) {
<el-option label="已打款" value="paid" /> <el-option label="已打款" value="paid" />
<el-option label="已拒绝" value="rejected" /> <el-option label="已拒绝" value="rejected" />
<el-option label="已取消" value="cancelled" /> <el-option label="已取消" value="cancelled" />
<el-option label="已作废" value="voided" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="业务单号"> <el-form-item label="业务单号">
<el-input v-model="filters.business_no" clearable placeholder="订单号或提现单号" /> <el-input
v-model="filters.business_no"
clearable
placeholder="订单号、提现单号或出款单号"
/>
</el-form-item> </el-form-item>
<el-form-item label="收款人"> <el-form-item label="收款人">
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" /> <el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
@@ -279,19 +428,26 @@ function accountTypeLabel(type: string) {
<strong>{{ row.payee_name || '-' }}</strong> <strong>{{ row.payee_name || '-' }}</strong>
<small v-if="row.payee_phone">{{ row.payee_phone }}</small> <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-if="row.source_type === 'withdrawal'">用户 ID {{ row.user_id }}</small>
<small v-else-if="row.source_type === 'manual_offline'">
{{ categoryLabel(row.category) }}
</small>
<small v-else>联系方式未登记</small> <small v-else>联系方式未登记</small>
<small v-if="row.uploader_name">上传{{ row.uploader_name }}</small> <small v-if="row.uploader_name">上传{{ row.uploader_name }}</small>
<small v-if="row.source_channel">渠道{{ row.source_channel }}</small> <small v-if="row.source_channel">渠道{{ row.source_channel }}</small>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="收款账户" min-width="205"> <el-table-column label="出款信息" min-width="205">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row.source_type === 'withdrawal'" class="stack-cell"> <div v-if="row.source_type === 'withdrawal'" class="stack-cell">
<span>{{ accountTypeLabel(row.account_type) }} · {{ row.account_name || '-' }}</span> <span>{{ accountTypeLabel(row.account_type) }} · {{ row.account_name || '-' }}</span>
<small>{{ row.account_no || '-' }}</small> <small>{{ row.account_no || '-' }}</small>
<small v-if="row.bank_name">{{ row.bank_name }}</small> <small v-if="row.bank_name">{{ row.bank_name }}</small>
</div> </div>
<div v-else-if="row.source_type === 'manual_offline'" class="stack-cell">
<el-tag size="small" effect="plain">{{ categoryLabel(row.category) }}</el-tag>
<small>{{ row.voucher_url ? '已上传付款凭证' : '未上传付款凭证' }}</small>
</div>
<span v-else class="muted-text">线下约定</span> <span v-else class="muted-text">线下约定</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -307,7 +463,7 @@ function accountTypeLabel(type: string) {
</el-table-column> </el-table-column>
<el-table-column label="状态" width="105" align="center"> <el-table-column label="状态" width="105" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag> <el-tag :type="statusType(row.status)">{{ itemStatusLabel(row) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="形成时间" min-width="175"> <el-table-column label="形成时间" min-width="175">
@@ -322,7 +478,7 @@ function accountTypeLabel(type: string) {
<el-table-column label="备注" min-width="180" show-overflow-tooltip> <el-table-column label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template> <template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90" fixed="right"> <el-table-column label="操作" width="155" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')" v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')"
@@ -337,6 +493,21 @@ function accountTypeLabel(type: string) {
<RouterLink v-else-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)"> <RouterLink v-else-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">
<el-button link type="primary" :icon="View">详情</el-button> <el-button link type="primary" :icon="View">详情</el-button>
</RouterLink> </RouterLink>
<template v-else-if="row.source_type === 'manual_offline'">
<el-button link type="primary" :icon="View" @click="openManualDetail(row)">
详情
</el-button>
<el-button
v-if="row.status === 'paid' && canManageManual"
link
type="danger"
:icon="CircleClose"
:loading="manualActionID === row.source_id"
@click="handleVoidManual(row)"
>
作废
</el-button>
</template>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -356,6 +527,159 @@ function accountTypeLabel(type: string) {
:withdrawal="selectedWithdrawal" :withdrawal="selectedWithdrawal"
@saved="onWithdrawalSaved" @saved="onWithdrawalSaved"
/> />
<el-dialog
v-model="createVisible"
title="新增其他线下出款"
width="560px"
:close-on-click-modal="false"
>
<el-form class="manual-form" label-position="top" @submit.prevent="submitManualDisbursement">
<el-form-item label="出款分类" required>
<el-select v-model="createForm.category" class="full-control" placeholder="请选择分类">
<el-option
v-for="item in manualCategories"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="收款人" required>
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
</el-form-item>
<el-form-item label="出款金额" required>
<el-input-number
v-model="createForm.amount_yuan"
class="full-control"
:min="0.01"
:precision="2"
:step="100"
controls-position="right"
/>
</el-form-item>
<el-form-item label="出款时间" required>
<el-date-picker
v-model="createForm.paid_at"
class="full-control"
type="datetime"
placeholder="选择出款时间"
/>
</el-form-item>
<el-form-item class="manual-form-wide" label="备注" required>
<el-input
v-model="createForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="填写出款用途"
/>
</el-form-item>
<el-form-item class="manual-form-wide" label="付款凭证(选填)">
<div v-if="createForm.voucher_url" class="voucher-preview">
<AuthImage
:source="createForm.voucher_url"
:admin="true"
fit="cover"
:preview-src-list="[createForm.voucher_url]"
:image-style="{ width: '104px', height: '104px', borderRadius: '6px' }"
/>
<el-button
class="voucher-remove"
circle
plain
type="danger"
:icon="CircleClose"
title="移除凭证"
@click="createForm.voucher_url = ''"
/>
</div>
<el-upload
v-else
:show-file-list="false"
:http-request="uploadVoucher"
accept="image/jpeg,image/png,image/webp"
>
<el-button :icon="UploadFilled" :loading="voucherUploading">上传凭证</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="createSaving" @click="createVisible = false">取消</el-button>
<el-button
type="primary"
:loading="createSaving"
:disabled="voucherUploading"
@click="submitManualDisbursement"
>
确认记账
</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="!!selectedManual"
title="其他线下出款详情"
width="620px"
@update:model-value="selectedManual = null"
>
<el-descriptions v-if="selectedManual" :column="2" border>
<el-descriptions-item label="出款单号">{{
selectedManual.business_no
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(selectedManual.status)">
{{ itemStatusLabel(selectedManual) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="出款分类">
{{ categoryLabel(selectedManual.category) }}
</el-descriptions-item>
<el-descriptions-item label="收款人">{{ selectedManual.payee_name }}</el-descriptions-item>
<el-descriptions-item label="出款金额">
<strong>{{ money(selectedManual.actual_amount_cent) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="出款时间">
{{ formatDateTime(selectedManual.paid_at) }}
</el-descriptions-item>
<el-descriptions-item label="录入人">
{{ selectedManual.operator_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="录入时间">
{{ formatDateTime(selectedManual.business_created_at) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ selectedManual.remark || '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="selectedManual.voucher_url" label="付款凭证" :span="2">
<AuthImage
:source="selectedManual.voucher_url"
:admin="true"
fit="cover"
:preview-src-list="[selectedManual.voucher_url]"
:image-style="{ width: '140px', height: '140px', borderRadius: '6px' }"
/>
</el-descriptions-item>
<el-descriptions-item v-if="selectedManual.status === 'voided'" label="作废信息" :span="2">
{{ selectedManual.voided_by_name || '-' }} ·
{{ formatDateTime(selectedManual.voided_at) }} ·
{{ selectedManual.void_reason || '-' }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="selectedManual = null">关闭</el-button>
<el-button
v-if="selectedManual?.status === 'paid' && canManageManual"
type="danger"
:icon="CircleClose"
:loading="manualActionID === selectedManual.source_id"
@click="handleVoidManual(selectedManual)"
>
作废记录
</el-button>
</template>
</el-dialog>
</section> </section>
</template> </template>
@@ -432,6 +756,28 @@ function accountTypeLabel(type: string) {
font-size: 14px; font-size: 14px;
} }
.manual-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.manual-form-wide {
grid-column: 1 / -1;
}
.voucher-preview {
position: relative;
width: 104px;
height: 104px;
}
.voucher-remove {
position: absolute;
top: -10px;
right: -10px;
}
@media (max-width: 1440px) { @media (max-width: 1440px) {
.disbursement-metrics { .disbursement-metrics {
grid-template-columns: repeat(3, minmax(170px, 1fr)); grid-template-columns: repeat(3, minmax(170px, 1fr));
@@ -444,8 +790,13 @@ function accountTypeLabel(type: string) {
@media (max-width: 760px) { @media (max-width: 760px) {
.disbursement-metrics, .disbursement-metrics,
.disbursement-filters { .disbursement-filters,
.manual-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.manual-form-wide {
grid-column: 1;
}
} }
</style> </style>