From 379536e5c5a4a556809f4f3445c97f89c31228be Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 26 Jul 2026 17:13:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=B6=E4=BB=96=E7=BA=BF?= =?UTF-8?q?=E4=B8=8B=E5=87=BA=E6=AC=BE=E8=AE=B0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/adminfinance/dashboard.go | 6 +- .../modules/adminfinance/disbursement.go | 42 +- .../modules/adminfinance/disbursement_list.go | 61 ++- .../adminfinance/disbursement_list_test.go | 36 +- .../modules/adminfinance/disbursement_test.go | 33 +- backend/internal/modules/adminfinance/dto.go | 42 ++ .../internal/modules/adminfinance/handler.go | 72 +++- .../adminfinance/manual_disbursement.go | 200 ++++++++++ .../adminfinance/manual_disbursement_test.go | 96 +++++ .../internal/modules/adminfinance/service.go | 75 +++- backend/internal/modules/file/service.go | 2 +- backend/internal/router/router.go | 2 + .../000039_manual_offline_disbursements.sql | 44 ++ .../src/features/admin/api/adminFinance.ts | 60 ++- .../admin/views/AdminFinanceDashboardView.vue | 14 +- .../views/AdminFinanceDisbursementsView.vue | 375 +++++++++++++++++- 16 files changed, 1125 insertions(+), 35 deletions(-) create mode 100644 backend/internal/modules/adminfinance/manual_disbursement.go create mode 100644 backend/internal/modules/adminfinance/manual_disbursement_test.go create mode 100644 backend/migrations/000039_manual_offline_disbursements.sql diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 165b7ad..35dd349 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -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 } diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 114e57a..51f5db4 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -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 } diff --git a/backend/internal/modules/adminfinance/disbursement_list.go b/backend/internal/modules/adminfinance/disbursement_list.go index 903c89d..82ab7c9 100644 --- a/backend/internal/modules/adminfinance/disbursement_list.go +++ b/backend/internal/modules/adminfinance/disbursement_list.go @@ -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, } } diff --git a/backend/internal/modules/adminfinance/disbursement_list_test.go b/backend/internal/modules/adminfinance/disbursement_list_test.go index b376ee3..842de0f 100644 --- a/backend/internal/modules/adminfinance/disbursement_list_test.go +++ b/backend/internal/modules/adminfinance/disbursement_list_test.go @@ -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", diff --git a/backend/internal/modules/adminfinance/disbursement_test.go b/backend/internal/modules/adminfinance/disbursement_test.go index d3ac973..42d70e7 100644 --- a/backend/internal/modules/adminfinance/disbursement_test.go +++ b/backend/internal/modules/adminfinance/disbursement_test.go @@ -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]) + } } diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index db84f11..1fec921 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -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"` } diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index f8335b9..817a7a7 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -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), + } +} diff --git a/backend/internal/modules/adminfinance/manual_disbursement.go b/backend/internal/modules/adminfinance/manual_disbursement.go new file mode 100644 index 0000000..d8e954b --- /dev/null +++ b/backend/internal/modules/adminfinance/manual_disbursement.go @@ -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 +} diff --git a/backend/internal/modules/adminfinance/manual_disbursement_test.go b/backend/internal/modules/adminfinance/manual_disbursement_test.go new file mode 100644 index 0000000..7f712e1 --- /dev/null +++ b/backend/internal/modules/adminfinance/manual_disbursement_test.go @@ -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) + } +} diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index f620d3c..888972f 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -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/") +} diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index a3a00a1..00842ec 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -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" diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d59526d..b4d2b4f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/migrations/000039_manual_offline_disbursements.sql b/backend/migrations/000039_manual_offline_disbursements.sql new file mode 100644 index 0000000..b230550 --- /dev/null +++ b/backend/migrations/000039_manual_offline_disbursements.sql @@ -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; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index fe96eb9..d801902 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -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>( + '/admin/finance/manual-disbursements', + payload + ) + return data.data +} + +export async function voidManualDisbursement(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/finance/manual-disbursements/${id}/void`, + { reason } + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 97a9a7f..5f00aac 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -66,7 +66,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
- 代管及提现出款 + 全部实际出款 {{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }} {{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计
@@ -177,6 +177,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) { {{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
+
+ 其他线下出款 + {{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }} + {{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账 +
当前待出款 {{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }} @@ -262,6 +267,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) { {{ moneyCent(row.withdrawal_paid_amount_cent) }} + + +