新增其他线下出款记账
This commit is contained in:
@@ -290,8 +290,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
|
||||
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
|
||||
item.WithdrawalPaidCount = row.WithdrawalPaidCount
|
||||
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent
|
||||
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount
|
||||
item.ManualPaidAmountCent = row.ManualPaidAmountCent
|
||||
item.ManualPaidCount = row.ManualPaidCount
|
||||
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent
|
||||
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
|
||||
itemsByDate[date] = item
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,21 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
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{
|
||||
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent,
|
||||
PaidCount: offlinePaid.Count + withdrawalPaid.Count,
|
||||
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
|
||||
PaidCount: offlinePaid.Count + withdrawalPaid.Count + manualPaid.Count,
|
||||
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
|
||||
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
|
||||
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
|
||||
@@ -88,6 +100,8 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
WithdrawalPendingCount: withdrawalPending.Count,
|
||||
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
|
||||
WithdrawalReviewCount: withdrawalReview.Count,
|
||||
ManualPaidAmountCent: manualPaid.AmountCent,
|
||||
ManualPaidCount: manualPaid.Count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -118,7 +132,19 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
||||
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 {
|
||||
date := dailyDateKey(row.Date)
|
||||
item := rowsByDate[date]
|
||||
@@ -135,6 +161,14 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
||||
item.WithdrawalPaidCount = row.Count
|
||||
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))
|
||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
||||
@@ -157,4 +191,6 @@ type dailyDisbursementRow struct {
|
||||
OfflineSettlementPaidCount int64
|
||||
WithdrawalPaidAmountCent 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,
|
||||
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
|
||||
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`).
|
||||
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
|
||||
@@ -118,11 +124,52 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
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,
|
||||
'' AS category,
|
||||
'' AS voucher_url,
|
||||
NULL AS voided_at,
|
||||
NULL AS voided_by,
|
||||
'' AS voided_by_name,
|
||||
'' AS void_reason,
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -179,6 +226,12 @@ type disbursementItemRow struct {
|
||||
OperatorID *uint64
|
||||
OperatorName string
|
||||
Remark string
|
||||
Category string
|
||||
VoucherURL string
|
||||
VoidedAt *time.Time
|
||||
VoidedBy *uint64
|
||||
VoidedByName string
|
||||
VoidReason string
|
||||
SourcePayload []byte
|
||||
}
|
||||
|
||||
@@ -217,5 +270,11 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
|
||||
OperatorID: r.OperatorID,
|
||||
OperatorName: r.OperatorName,
|
||||
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 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 {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
@@ -50,7 +56,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
query := DisbursementQuery{
|
||||
@@ -93,11 +108,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
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.Total != 7 || len(result.Items) != 7 {
|
||||
t.Fatalf("记录数 = %d/%d, want 7/7", 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.PaidAmountCent != 24400 || result.Summary.PaidCount != 3 {
|
||||
t.Fatalf("已出款 = %d/%d, want 24400/3", 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)
|
||||
@@ -120,6 +135,17 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
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{
|
||||
SourceType: "withdrawal",
|
||||
Status: "paid",
|
||||
|
||||
@@ -35,10 +35,19 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
)`).Error; err != nil {
|
||||
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()
|
||||
inRangeOffline := time.Date(2026, 7, 1, 10, 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)
|
||||
|
||||
for _, args := range [][]any{
|
||||
@@ -54,6 +63,16 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
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{
|
||||
{1, "completed", 10000, 100, 9900, inRangeWithdrawal},
|
||||
{2, "completed", 5000, 0, 5000, outOfRange},
|
||||
@@ -77,8 +96,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
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.PaidAmountCent != 24400 || summary.PaidCount != 3 {
|
||||
t.Fatalf("实际出款 = %d/%d, want 24400/3", summary.PaidAmountCent, summary.PaidCount)
|
||||
}
|
||||
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
|
||||
}
|
||||
@@ -97,8 +119,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dailyDisbursements() error = %v", err)
|
||||
}
|
||||
if len(daily) != 2 {
|
||||
t.Fatalf("每日出款行数 = %d, want 2", len(daily))
|
||||
if len(daily) != 3 {
|
||||
t.Fatalf("每日出款行数 = %d, want 3", len(daily))
|
||||
}
|
||||
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
|
||||
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 {
|
||||
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"`
|
||||
WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"`
|
||||
WithdrawalReviewCount int64 `json:"withdrawal_review_count"`
|
||||
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
||||
ManualPaidCount int64 `json:"manual_paid_count"`
|
||||
}
|
||||
|
||||
type DisbursementListDTO struct {
|
||||
@@ -115,6 +117,44 @@ type DisbursementItemDTO struct {
|
||||
OperatorID *uint64 `json:"operator_id,omitempty"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
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 {
|
||||
@@ -166,6 +206,8 @@ type FinanceDailyDTO struct {
|
||||
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
|
||||
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
|
||||
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"`
|
||||
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
@@ -60,6 +62,49 @@ func (h *Handler) Disbursements(c *gin.Context) {
|
||||
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) {
|
||||
start, end, ok := parseDateRange(c, 6)
|
||||
if !ok {
|
||||
@@ -124,11 +169,11 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
||||
StartDate: start,
|
||||
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, "出款类型不正确")
|
||||
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, "出款状态不正确")
|
||||
return query, false
|
||||
}
|
||||
@@ -186,7 +231,30 @@ func writeFinanceError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
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:
|
||||
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 (
|
||||
"context"
|
||||
"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 {
|
||||
repo *Repository
|
||||
@@ -53,3 +63,66 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
|
||||
}
|
||||
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/")
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
|
||||
func normalizeScene(scene string) string {
|
||||
scene = strings.TrimSpace(strings.ToLower(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
|
||||
default:
|
||||
return "misc"
|
||||
|
||||
@@ -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/details", requirePerm("wallet:view"), adminFinanceHandler.Details)
|
||||
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("/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
|
||||
withdrawal_paid_amount_cent: number
|
||||
withdrawal_paid_count: number
|
||||
manual_paid_amount_cent: number
|
||||
manual_paid_count: number
|
||||
disbursement_paid_amount_cent: number
|
||||
disbursement_paid_count: number
|
||||
}
|
||||
@@ -82,6 +84,8 @@ export interface FinanceDisbursementSummary {
|
||||
withdrawal_pending_count: number
|
||||
withdrawal_review_amount_cent: number
|
||||
withdrawal_review_count: number
|
||||
manual_paid_amount_cent: number
|
||||
manual_paid_count: number
|
||||
}
|
||||
|
||||
export interface FinanceDisbursementListSummary {
|
||||
@@ -96,7 +100,7 @@ export interface FinanceDisbursementListSummary {
|
||||
}
|
||||
|
||||
export interface FinanceDisbursementItem {
|
||||
source_type: 'platform_managed' | 'withdrawal'
|
||||
source_type: 'platform_managed' | 'withdrawal' | 'manual_offline'
|
||||
source_id: number
|
||||
business_no: string
|
||||
order_id?: number
|
||||
@@ -113,13 +117,47 @@ export interface FinanceDisbursementItem {
|
||||
amount_cent: number
|
||||
fee_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
|
||||
business_created_at?: string
|
||||
paid_at?: string
|
||||
operator_id?: number
|
||||
operator_name: 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 {
|
||||
@@ -239,6 +277,8 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
|
||||
withdrawal_pending_count: 0,
|
||||
withdrawal_review_amount_cent: 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">
|
||||
<p class="eyebrow">Finance Dashboard</p>
|
||||
<h1>财务仪表盘</h1>
|
||||
<p>按天查看收付款、平台收入、号主结算、平台代管打款和用户提现。</p>
|
||||
<p>按天查看收付款、平台收入、号主结算及全部资金出款。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-date-picker
|
||||
@@ -157,7 +157,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
||||
|
||||
<div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid">
|
||||
<div class="metric-card disbursement-card">
|
||||
<span>代管及提现出款</span>
|
||||
<span>全部实际出款</span>
|
||||
<strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong>
|
||||
<small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small>
|
||||
</div>
|
||||
@@ -177,6 +177,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
||||
{{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
|
||||
</small>
|
||||
</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">
|
||||
<span>当前待出款</span>
|
||||
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
|
||||
@@ -262,6 +267,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
||||
{{ moneyCent(row.withdrawal_paid_amount_cent) }}
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
<span :class="rowOfflinePendingClass(row)">
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { CircleClose, Plus, Search, UploadFilled, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createManualDisbursement,
|
||||
fetchFinanceDisbursements,
|
||||
voidManualDisbursement,
|
||||
type FinanceDisbursementItem,
|
||||
type FinanceDisbursementListSummary,
|
||||
} from '@/features/admin/api/adminFinance'
|
||||
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 { adminPath } from '@/shared/utils/adminPath'
|
||||
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 AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
|
||||
@@ -26,7 +30,28 @@ const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const showWithdrawalDetail = ref(false)
|
||||
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 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({
|
||||
date_type: 'created',
|
||||
start_date: defaultStartDate(),
|
||||
@@ -98,6 +123,103 @@ function onWithdrawalSaved() {
|
||||
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 {
|
||||
return {
|
||||
record_count: 0,
|
||||
@@ -122,11 +244,18 @@ function money(value: number) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return source === 'platform_managed' ? 'primary' : 'info'
|
||||
if (source === 'platform_managed') return 'primary'
|
||||
if (source === 'manual_offline') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
@@ -136,6 +265,7 @@ function statusLabel(status: string) {
|
||||
paid: '已打款',
|
||||
rejected: '已拒绝',
|
||||
cancelled: '已取消',
|
||||
voided: '已作废',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
@@ -144,9 +274,19 @@ function statusType(status: string) {
|
||||
if (status === 'paid') return 'success'
|
||||
if (status === 'review_pending' || status === 'payment_pending') return 'warning'
|
||||
if (status === 'rejected') return 'danger'
|
||||
if (status === 'voided') return 'danger'
|
||||
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) {
|
||||
const labels: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
@@ -163,9 +303,12 @@ function accountTypeLabel(type: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Disbursements</p>
|
||||
<h1>资金出款</h1>
|
||||
<p>查询平台代管线下结算与用户提现的应付、待办和实际打款记录。</p>
|
||||
<p>统一查询平台代管、用户提现和其他线下出款记录。</p>
|
||||
</div>
|
||||
<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 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="withdrawal" />
|
||||
<el-option label="其他线下出款" value="manual_offline" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="出款状态">
|
||||
@@ -242,10 +386,15 @@ function accountTypeLabel(type: string) {
|
||||
<el-option label="已打款" value="paid" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
<el-option label="已作废" value="voided" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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 label="收款人">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
|
||||
@@ -279,19 +428,26 @@ function accountTypeLabel(type: string) {
|
||||
<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-if="row.source_type === 'manual_offline'">
|
||||
{{ categoryLabel(row.category) }}
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -307,7 +463,7 @@ function accountTypeLabel(type: string) {
|
||||
</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>
|
||||
<el-tag :type="statusType(row.status)">{{ itemStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
<template #default="{ row }">{{ row.remark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<el-table-column label="操作" width="155" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
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}`)">
|
||||
<el-button link type="primary" :icon="View">详情</el-button>
|
||||
</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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -356,6 +527,159 @@ function accountTypeLabel(type: string) {
|
||||
:withdrawal="selectedWithdrawal"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@@ -432,6 +756,28 @@ function accountTypeLabel(type: string) {
|
||||
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) {
|
||||
.disbursement-metrics {
|
||||
grid-template-columns: repeat(3, minmax(170px, 1fr));
|
||||
@@ -444,8 +790,13 @@ function accountTypeLabel(type: string) {
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.disbursement-metrics,
|
||||
.disbursement-filters {
|
||||
.disbursement-filters,
|
||||
.manual-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.manual-form-wide {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user