From bf167d98ed9766bccd9291de0c566ba2f7f7d97c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 2 Sep 2026 13:58:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B4=A2=E5=8A=A1=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E4=B8=8E=E8=B4=A2=E5=8A=A1=E6=8A=A5=E8=A1=A8=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/072_admin_finance_role.sql | 34 ++ apps/backend/src/routes/admin.ts | 2 + apps/backend/src/routes/admin/dashboard.ts | 4 +- apps/backend/src/routes/admin/finance.ts | 31 ++ apps/backend/src/routes/admin/orders.ts | 2 + apps/backend/src/routes/admin/tasks.ts | 2 + .../routes/admin/worker-platform/config.ts | 3 +- .../routes/admin/worker-platform/finance.ts | 4 +- .../services/admin/admin-auth-role.test.ts | 13 + .../src/services/admin/admin-auth-service.ts | 8 +- .../admin/admin-finance-service.test.ts | 52 +++ .../services/admin/admin-finance-service.ts | 422 ++++++++++++++++++ .../src/services/admin/admin-rbac-service.ts | 6 +- apps/frontend/src/App.tsx | 30 +- apps/frontend/src/layouts/AdminLayout.tsx | 40 +- .../src/pages/admin/AdminFinancePage.tsx | 322 +++++++++++++ .../src/pages/admin/AdminUsersPage.tsx | 2 + .../pages/admin/AdminWorkerPlatformPage.tsx | 86 ++-- .../src/pages/admin/panels/FinancePanel.tsx | 9 +- apps/frontend/src/services/admin/finance.ts | 19 + apps/frontend/src/services/admin/index.ts | 1 + apps/frontend/src/types/admin/auth.ts | 2 +- apps/frontend/src/types/admin/finance.ts | 62 +++ apps/frontend/src/types/admin/index.ts | 10 + apps/frontend/src/utils/admin-auth.ts | 14 +- apps/frontend/src/utils/admin-options.ts | 1 + docs/finance-implementation-plan.md | 75 ++++ scripts/rollback-finance-demo.sql | 12 + scripts/seed-finance-demo.sql | 43 ++ 29 files changed, 1242 insertions(+), 69 deletions(-) create mode 100644 apps/backend/src/db/migrations/072_admin_finance_role.sql create mode 100644 apps/backend/src/routes/admin/finance.ts create mode 100644 apps/backend/src/services/admin/admin-auth-role.test.ts create mode 100644 apps/backend/src/services/admin/admin-finance-service.test.ts create mode 100644 apps/backend/src/services/admin/admin-finance-service.ts create mode 100644 apps/frontend/src/pages/admin/AdminFinancePage.tsx create mode 100644 apps/frontend/src/services/admin/finance.ts create mode 100644 apps/frontend/src/types/admin/finance.ts create mode 100644 docs/finance-implementation-plan.md create mode 100644 scripts/rollback-finance-demo.sql create mode 100644 scripts/seed-finance-demo.sql diff --git a/apps/backend/src/db/migrations/072_admin_finance_role.sql b/apps/backend/src/db/migrations/072_admin_finance_role.sql new file mode 100644 index 00000000..42087081 --- /dev/null +++ b/apps/backend/src/db/migrations/072_admin_finance_role.sql @@ -0,0 +1,34 @@ +-- 财务专员角色与财务模块权限。 +INSERT INTO admin_roles (role_key, role_name, description) +VALUES ('finance', '财务', '负责财务报表、资金申请审核和打款核对') +ON CONFLICT (role_key) DO UPDATE SET + role_name = EXCLUDED.role_name, + description = EXCLUDED.description, + updated_at = NOW(); + +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES + ('finance.view', '查看财务报表', '财务管理', '查看财务总览、日报和资金明细'), + ('finance.review', '审核资金申请', '财务管理', '审核充值和提现申请'), + ('finance.config', '配置财务规则', '财务管理', '维护充值、提现和押金规则'), + ('finance.export', '导出财务数据', '财务管理', '导出筛选后的财务明细') +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +-- 管理员保留全部权限;运营兼容现有资金审核能力;财务专员只获得财务相关权限。 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'admin', permission_key +FROM admin_permissions +WHERE permission_key LIKE 'finance.%' +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES ('operator', 'finance.view'), ('operator', 'finance.review') +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES ('finance', 'finance.view'), ('finance', 'finance.review') +ON CONFLICT DO NOTHING; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 0f4af9cd..535bd5ed 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -5,6 +5,7 @@ import auditLogsRouter from './admin/audit-logs.js' import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js' import dashboardRouter from './admin/dashboard.js' import devMockRouter from './admin/dev-mock.js' +import financeRouter from './admin/finance.js' import filesRouter from './admin/files.js' import kuaishouIndustryRouter from './admin/kuaishou-industry.js' import ordersRouter from './admin/orders.js' @@ -24,6 +25,7 @@ router.use(authRouter) router.use(requireAdminSession) router.use(filesRouter) router.use(dashboardRouter) +router.use(financeRouter) router.use(usersRouter) router.use(rbacRouter) router.use(auditLogsRouter) diff --git a/apps/backend/src/routes/admin/dashboard.ts b/apps/backend/src/routes/admin/dashboard.ts index 1e2261b8..b2985f48 100644 --- a/apps/backend/src/routes/admin/dashboard.ts +++ b/apps/backend/src/routes/admin/dashboard.ts @@ -3,10 +3,12 @@ import type { JsonObject } from '../../types/json.js' import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js' import { getAdminLoginLogs } from '../../services/admin/admin-login-log-service.js' -import { createJsonHandler } from './session.js' +import { createJsonHandler, requireAdminRoles } from './session.js' const router = Router() +router.use('/dashboard', requireAdminRoles(['admin', 'operator', 'support'])) + router.get( '/dashboard/summary', createJsonHandler(() => getAdminDashboardSummary(), { diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts new file mode 100644 index 00000000..7502a042 --- /dev/null +++ b/apps/backend/src/routes/admin/finance.ts @@ -0,0 +1,31 @@ +import { Router } from 'express' + +import { + getAdminFinanceSummary, + listAdminFinanceTransactions, +} from '../../services/admin/admin-finance-service.js' +import { createJsonHandler, requireAdminRoles } from './session.js' + +const router = Router() + +router.get( + '/finance/summary', + requireAdminRoles(['admin', 'operator', 'finance']), + createJsonHandler((req) => getAdminFinanceSummary(req.query), { + successMessage: 'ok', + errorMessage: '读取财务汇总失败', + scope: '[admin/finance/summary]', + }), +) + +router.get( + '/finance/transactions', + requireAdminRoles(['admin', 'operator', 'finance']), + createJsonHandler((req) => listAdminFinanceTransactions(req.query), { + successMessage: 'ok', + errorMessage: '读取财务明细失败', + scope: '[admin/finance/transactions]', + }), +) + +export default router diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 42217f12..4bf40079 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -7,6 +7,8 @@ import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/ad const router = Router() +router.use('/orders', requireAdminRoles(['admin', 'operator', 'support'])) + router.get( '/orders', createJsonHandler((req) => getAdminOrders(req.query as AdminOrderRouteQuery), { diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 93b3a1dd..d985e709 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -22,6 +22,8 @@ import type { AdminTaskActionResponse } from '../../types/admin/write-models.js' const router = Router() +router.use('/tasks', requireAdminRoles(['admin', 'operator', 'support'])) + function getTaskId(req: Request): string { return String((req.params as AdminTaskRouteParams).taskId || '') } diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index ef769be2..e9e379f0 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -35,6 +35,7 @@ const router = Router() router.get( '/worker-platform/summary', + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(() => getAdminWorkerPlatformSummary(), { successMessage: 'ok', errorMessage: '读取接单平台概览失败', @@ -342,7 +343,7 @@ router.get( router.get( '/worker-platform/finance-config', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminRoles(['admin', 'operator', 'support', 'finance']), createJsonHandler(() => getAdminWorkerFinanceConfig(), { successMessage: 'ok', errorMessage: '读取资金配置失败', diff --git a/apps/backend/src/routes/admin/worker-platform/finance.ts b/apps/backend/src/routes/admin/worker-platform/finance.ts index 507f3c7c..4e61df37 100644 --- a/apps/backend/src/routes/admin/worker-platform/finance.ts +++ b/apps/backend/src/routes/admin/worker-platform/finance.ts @@ -10,7 +10,7 @@ const router = Router() router.get( '/worker-platform/finance-requests', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminRoles(['admin', 'operator', 'support', 'finance']), createJsonHandler((req) => listAdminWorkerFinanceRequests(req.query), { successMessage: 'ok', errorMessage: '读取资金申请失败', @@ -20,7 +20,7 @@ router.get( router.post( '/worker-platform/finance-requests/:requestId/review', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'finance']), createJsonHandler( (req) => reviewAdminWorkerFinanceRequest( diff --git a/apps/backend/src/services/admin/admin-auth-role.test.ts b/apps/backend/src/services/admin/admin-auth-role.test.ts new file mode 100644 index 00000000..eb96022b --- /dev/null +++ b/apps/backend/src/services/admin/admin-auth-role.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { normalizeAdminRole } from './admin-auth-service.js' + +test('normalizeAdminRole preserves the dedicated finance role', () => { + assert.equal(normalizeAdminRole('finance'), 'finance') + assert.equal(normalizeAdminRole('FINANCE'), 'finance') +}) + +test('normalizeAdminRole keeps unknown roles compatible as operator', () => { + assert.equal(normalizeAdminRole('unknown-role'), 'operator') +}) diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index d7d6e992..ab8eaad9 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -34,7 +34,7 @@ export type AdminSession = { permissions: string[] } -type AdminRole = 'admin' | 'operator' | 'support' +type AdminRole = 'admin' | 'operator' | 'support' | 'finance' type AdminUserStatus = 'active' | 'disabled' export async function ensureAdminUsersBootstrapped(): Promise { @@ -513,6 +513,10 @@ export function normalizeAdminRole(role: unknown): AdminRole { return 'support' } + if (normalized === 'finance') { + return 'finance' + } + return 'operator' } @@ -539,7 +543,7 @@ function normalizeRoleQuery(role: unknown): string { const normalized = String(role || '') .trim() .toLowerCase() - return ['admin', 'operator', 'support'].includes(normalized) ? normalized : '' + return ['admin', 'operator', 'support', 'finance'].includes(normalized) ? normalized : '' } function normalizeStatusQuery(status: unknown): string { diff --git a/apps/backend/src/services/admin/admin-finance-service.test.ts b/apps/backend/src/services/admin/admin-finance-service.test.ts new file mode 100644 index 00000000..27b712e8 --- /dev/null +++ b/apps/backend/src/services/admin/admin-finance-service.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { calculateFinanceNetCashFlow, resolveFinanceRange } from './admin-finance-service.js' + +test('resolveFinanceRange uses China date boundaries and defaults to today', () => { + const result = resolveFinanceRange({}, '2026-09-02T01:00:00.000Z') + assert.deepEqual(result, { + from: '2026-09-01T16:00:00.000Z', + to: '2026-09-02T16:00:00.000Z', + fromDate: '2026-09-02', + toDate: '2026-09-02', + bucketFrom: '2026-09-01T16:00:00.000Z', + bucketTo: '2026-09-02T16:00:00.000Z', + }) +}) + +test('resolveFinanceRange swaps reversed dates and keeps inclusive end date', () => { + const result = resolveFinanceRange( + { dateFrom: '2026-09-05', dateTo: '2026-09-03' }, + '2026-09-02T00:00:00.000Z', + ) + assert.equal(result.fromDate, '2026-09-03') + assert.equal(result.toDate, '2026-09-05') + assert.equal(result.from, '2026-09-02T16:00:00.000Z') + assert.equal(result.to, '2026-09-05T16:00:00.000Z') +}) + +test('resolveFinanceRange preserves minute precision while bucketing by China calendar day', () => { + const result = resolveFinanceRange( + { dateFrom: '2026-09-02 10:15', dateTo: '2026-09-02 18:30' }, + '2026-09-02T00:00:00.000Z', + ) + assert.equal(result.from, '2026-09-02T02:15:00.000Z') + assert.equal(result.to, '2026-09-02T10:30:59.000Z') + assert.equal(result.bucketFrom, '2026-09-01T16:00:00.000Z') + assert.equal(result.bucketTo, '2026-09-02T16:00:00.000Z') +}) + +test('calculateFinanceNetCashFlow uses cash basis and excludes reward accrual', () => { + assert.equal( + calculateFinanceNetCashFlow({ + paidAmount: 10000, + refundAmount: 1000, + rechargeAmount: 500, + workerRecoveryAmount: 1000, + withdrawAmount: 3000, + afterSalesRefundAmount: 200, + }), + 7300, + ) +}) diff --git a/apps/backend/src/services/admin/admin-finance-service.ts b/apps/backend/src/services/admin/admin-finance-service.ts new file mode 100644 index 00000000..905410c2 --- /dev/null +++ b/apps/backend/src/services/admin/admin-finance-service.ts @@ -0,0 +1,422 @@ +import { query } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { nowIso } from '../../utils/time.js' + +const CHINA_TIME_ZONE = 'Asia/Shanghai' +const DAY_MS = 24 * 60 * 60 * 1000 + +type FinanceRange = { + from: string + to: string + fromDate: string + toDate: string + bucketFrom: string + bucketTo: string +} + +const FINANCE_EVENTS_CTE = ` + WITH finance_events AS ( + SELECT + 'order_paid'::text AS category, + o.id::bigint AS source_id, + COALESCE(o.paid_at, o.created_at) AS occurred_at, + o.total_amount::bigint AS amount, + '订单收款'::text AS label, + o.platform_order_id::text AS reference_no, + COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text AS channel + FROM orders o + WHERE o.pay_status IN ('paid', 'refunded') + AND o.total_amount > 0 + + UNION ALL + + SELECT + 'order_refund'::text, + o.id::bigint, + COALESCE(o.updated_at, o.created_at), + o.total_amount::bigint, + '订单退款'::text, + o.platform_order_id::text, + COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text + FROM orders o + WHERE o.pay_status = 'refunded' + AND o.total_amount > 0 + + UNION ALL + + SELECT + CASE WHEN wl.ledger_type IN ('reward_settlement', 'sharing_reward') + THEN 'worker_reward' ELSE 'worker_recovery' END::text, + wl.id::bigint, + wl.created_at, + ABS(wl.amount)::bigint, + CASE WHEN wl.ledger_type IN ('reward_settlement', 'sharing_reward') + THEN '打手报酬' ELSE '打手资金回收' END::text, + COALESCE(wl.related_work_order_id::text, ''), + 'worker-platform'::text + FROM worker_wallet_ledgers wl + WHERE wl.ledger_type IN ( + 'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction', + 'after_sales_balance_deduction', 'after_sales_debt_offset' + ) + AND wl.amount <> 0 + + UNION ALL + + SELECT + 'withdraw_paid'::text, + wfr.id::bigint, + wfr.reviewed_at, + wfr.amount::bigint, + '打手提现'::text, + wfr.account_channel::text, + COALESCE(NULLIF(wfr.account_channel, ''), '其他')::text + FROM worker_finance_requests wfr + WHERE wfr.request_type = 'withdraw' + AND wfr.status = 'approved' + AND wfr.reviewed_at IS NOT NULL + AND wfr.amount > 0 + + UNION ALL + + SELECT + 'recharge'::text, + wfr.id::bigint, + wfr.reviewed_at, + wfr.amount::bigint, + '打手充值'::text, + wfr.account_channel::text, + COALESCE(NULLIF(wfr.account_channel, ''), '其他')::text + FROM worker_finance_requests wfr + WHERE wfr.request_type = 'recharge' + AND wfr.status = 'approved' + AND wfr.reviewed_at IS NOT NULL + AND wfr.amount > 0 + + UNION ALL + + SELECT + 'after_sales_refund'::text, + wasc.id::bigint, + wasc.refunded_at, + wasc.refunded_amount::bigint, + '售后追缴退还'::text, + wasc.case_no::text, + 'worker-platform'::text + FROM worker_after_sales_cases wasc + WHERE wasc.refunded_at IS NOT NULL + AND wasc.refunded_amount > 0 + ) +` + +export async function getAdminFinanceSummary(payload: JsonObject = {}) { + const range = resolveFinanceRange(payload, nowIso()) + const [dailyResult, snapshotResult] = await Promise.all([ + query( + `${FINANCE_EVENTS_CTE} + SELECT + to_char(day AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS day, + COALESCE(SUM(amount) FILTER (WHERE category = 'order_paid'), 0)::bigint AS paid_amount, + COUNT(*) FILTER (WHERE category = 'order_paid')::int AS paid_order_count, + COALESCE(SUM(amount) FILTER (WHERE category = 'order_refund'), 0)::bigint AS refund_amount, + COALESCE(SUM(amount) FILTER (WHERE category = 'worker_reward'), 0)::bigint AS worker_reward_amount, + COALESCE(SUM(amount) FILTER (WHERE category = 'withdraw_paid'), 0)::bigint AS withdraw_amount, + COALESCE(SUM(amount) FILTER (WHERE category = 'recharge'), 0)::bigint AS recharge_amount, + COALESCE(SUM(amount) FILTER (WHERE category = 'after_sales_refund'), 0)::bigint AS after_sales_refund_amount, + COALESCE(SUM(amount) FILTER (WHERE category = 'worker_recovery'), 0)::bigint AS worker_recovery_amount + FROM generate_series($3::timestamptz, $4::timestamptz - interval '1 day', interval '1 day') day + LEFT JOIN finance_events event + ON event.occurred_at >= $1::timestamptz + AND event.occurred_at < $2::timestamptz + AND event.occurred_at >= day + AND event.occurred_at < day + interval '1 day' + GROUP BY day + ORDER BY day DESC`, + [range.from, range.to, range.bucketFrom, range.bucketTo], + ), + query( + ` + SELECT + (SELECT COALESCE(SUM(available_amount), 0)::bigint FROM worker_wallets) AS available_amount, + (SELECT COALESCE(SUM(frozen_deposit_amount), 0)::bigint FROM worker_wallets) AS frozen_deposit_amount, + (SELECT COALESCE(SUM(pending_unfreeze_amount), 0)::bigint FROM worker_wallets) AS pending_unfreeze_amount, + (SELECT COALESCE(SUM(frozen_withdraw_amount), 0)::bigint FROM worker_wallets) AS frozen_withdraw_amount, + (SELECT COALESCE(SUM(amount), 0)::bigint FROM worker_finance_requests WHERE status = 'pending' AND request_type = 'withdraw') AS pending_withdraw_amount, + (SELECT COUNT(*)::int FROM worker_finance_requests WHERE status = 'pending' AND request_type = 'withdraw') AS pending_withdraw_count + `, + ), + ]) + + const daily = dailyResult.rows.map(mapFinanceDailyRow) + const channelDailyResult = await query( + `${FINANCE_EVENTS_CTE} + SELECT + to_char(event.occurred_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS day, + event.channel, + event.category, + COUNT(*)::int AS total, + COALESCE(SUM(event.amount), 0)::bigint AS amount + FROM finance_events event + WHERE event.occurred_at >= $1::timestamptz + AND event.occurred_at < $2::timestamptz + GROUP BY 1, 2, 3 + ORDER BY day DESC, event.channel ASC, event.category ASC`, + [range.from, range.to], + ) + const totals = daily.reduce( + (result, row) => ({ + paidOrderCount: result.paidOrderCount + row.paidOrderCount, + paidAmount: result.paidAmount + row.paidAmount, + refundAmount: result.refundAmount + row.refundAmount, + workerRewardAmount: result.workerRewardAmount + row.workerRewardAmount, + withdrawAmount: result.withdrawAmount + row.withdrawAmount, + rechargeAmount: result.rechargeAmount + row.rechargeAmount, + afterSalesRefundAmount: result.afterSalesRefundAmount + row.afterSalesRefundAmount, + workerRecoveryAmount: result.workerRecoveryAmount + row.workerRecoveryAmount, + netCashFlow: result.netCashFlow + row.netCashFlow, + }), + emptyFinanceTotals(), + ) + const snapshot = snapshotResult.rows[0] || emptyFinanceSnapshot() + + return { + range: { from: range.fromDate, to: range.toDate }, + overview: { + ...totals, + availableAmount: Number(snapshot.available_amount || 0), + frozenDepositAmount: Number(snapshot.frozen_deposit_amount || 0), + pendingUnfreezeAmount: Number(snapshot.pending_unfreeze_amount || 0), + frozenWithdrawAmount: Number(snapshot.frozen_withdraw_amount || 0), + pendingWithdrawAmount: Number(snapshot.pending_withdraw_amount || 0), + pendingWithdrawCount: Number(snapshot.pending_withdraw_count || 0), + }, + daily, + channelDaily: channelDailyResult.rows.map((row) => ({ + date: String(row.day).slice(0, 10), + channel: row.channel || '其他', + category: row.category, + count: Number(row.total || 0), + amount: Number(row.amount || 0), + })), + } +} + +export async function listAdminFinanceTransactions(payload: JsonObject = {}) { + const range = resolveFinanceRange(payload, nowIso()) + const page = normalizePositiveInt(payload.page, 1) + const pageSize = Math.min(normalizePositiveInt(payload.pageSize, 20), 100) + const category = String(payload.category || '').trim() + const channel = String(payload.channel || '').trim() + const allowedCategories = new Set([ + 'order_paid', + 'order_refund', + 'worker_reward', + 'worker_recovery', + 'withdraw_paid', + 'recharge', + 'after_sales_refund', + ]) + const params: unknown[] = [range.from, range.to] + const categoryClause = allowedCategories.has(category) + ? ` AND event.category = $${params.push(category)}` + : '' + const channelClause = channel ? ` AND event.channel = $${params.push(channel)}` : '' + const offset = (page - 1) * pageSize + const countResult = await query<{ total: number }>( + `${FINANCE_EVENTS_CTE} + SELECT COUNT(*)::int AS total + FROM finance_events event + WHERE event.occurred_at >= $1 + AND event.occurred_at < $2${categoryClause}${channelClause}`, + params, + ) + const itemsParams = [...params, pageSize, offset] + const itemsResult = await query( + `${FINANCE_EVENTS_CTE} + SELECT category, source_id, occurred_at, amount, label, reference_no, channel + FROM finance_events event + WHERE event.occurred_at >= $1 + AND event.occurred_at < $2${categoryClause}${channelClause} + ORDER BY event.occurred_at DESC, event.source_id DESC + LIMIT $${itemsParams.length - 1} OFFSET $${itemsParams.length}`, + itemsParams, + ) + return { + items: itemsResult.rows.map((row) => ({ + category: row.category, + sourceId: Number(row.source_id), + occurredAt: row.occurred_at, + amount: Number(row.amount || 0), + direction: ['order_paid', 'recharge', 'worker_recovery'].includes(row.category) + ? 'in' + : 'out', + label: row.label, + referenceNo: row.reference_no || '', + channel: row.channel || '其他', + })), + pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) }, + range: { from: range.fromDate, to: range.toDate }, + } +} + +export function resolveFinanceRange(payload: JsonObject = {}, now = nowIso()): FinanceRange { + const today = chinaDateParts(now) + const fromInput = String(payload.dateFrom || payload.from || '') + const toInput = String(payload.dateTo || payload.to || '') + const fromDate = normalizeDateTimeDate(fromInput) || today + const toDate = normalizeDateTimeDate(toInput) || fromDate + const [safeFrom, safeTo] = fromDate <= toDate ? [fromDate, toDate] : [toDate, fromDate] + const from = parseFinanceBoundary(fromInput, safeFrom, false) + const to = parseFinanceBoundary(toInput, safeTo, true) + const normalizedFrom = Date.parse(from) <= Date.parse(to) ? from : chinaDateToUtcIso(safeFrom) + const normalizedTo = + Date.parse(from) <= Date.parse(to) + ? to + : new Date(Date.parse(chinaDateToUtcIso(safeTo)) + DAY_MS).toISOString() + return { + from: normalizedFrom, + to: normalizedTo, + fromDate: safeFrom, + toDate: safeTo, + bucketFrom: chinaDateToUtcIso(safeFrom), + bucketTo: new Date(Date.parse(chinaDateToUtcIso(safeTo)) + DAY_MS).toISOString(), + } +} + +export function calculateFinanceNetCashFlow(row: { + paidAmount: number + refundAmount: number + rechargeAmount: number + workerRecoveryAmount: number + withdrawAmount: number + afterSalesRefundAmount: number +}) { + return ( + row.paidAmount + + row.rechargeAmount - + row.refundAmount - + row.withdrawAmount - + row.afterSalesRefundAmount + + row.workerRecoveryAmount + ) +} + +type FinanceDailyRow = { + day: string + paid_amount: number + paid_order_count: number + refund_amount: number + worker_reward_amount: number + withdraw_amount: number + recharge_amount: number + after_sales_refund_amount: number + worker_recovery_amount: number +} + +type FinanceSnapshotRow = { + available_amount: number + frozen_deposit_amount: number + pending_unfreeze_amount: number + frozen_withdraw_amount: number + pending_withdraw_amount: number + pending_withdraw_count: number +} + +type FinanceTransactionRow = { + category: string + source_id: number + occurred_at: string + amount: number + label: string + reference_no: string + channel: string +} + +type FinanceChannelDailyRow = { + day: string + channel: string + category: string + total: number + amount: number +} + +function mapFinanceDailyRow(row: FinanceDailyRow) { + const mapped = { + date: String(row.day).slice(0, 10), + paidOrderCount: Number(row.paid_order_count || 0), + paidAmount: Number(row.paid_amount || 0), + refundAmount: Number(row.refund_amount || 0), + workerRewardAmount: Number(row.worker_reward_amount || 0), + withdrawAmount: Number(row.withdraw_amount || 0), + rechargeAmount: Number(row.recharge_amount || 0), + afterSalesRefundAmount: Number(row.after_sales_refund_amount || 0), + workerRecoveryAmount: Number(row.worker_recovery_amount || 0), + } + return { ...mapped, netCashFlow: calculateFinanceNetCashFlow(mapped) } +} + +function emptyFinanceTotals() { + return { + paidOrderCount: 0, + paidAmount: 0, + refundAmount: 0, + workerRewardAmount: 0, + withdrawAmount: 0, + rechargeAmount: 0, + afterSalesRefundAmount: 0, + workerRecoveryAmount: 0, + netCashFlow: 0, + } +} + +function emptyFinanceSnapshot(): FinanceSnapshotRow { + return { + available_amount: 0, + frozen_deposit_amount: 0, + pending_unfreeze_amount: 0, + frozen_withdraw_amount: 0, + pending_withdraw_amount: 0, + pending_withdraw_count: 0, + } +} + +function normalizePositiveInt(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeDateTimeDate(value: string) { + const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})/) + if (!match || Number.isNaN(Date.parse(`${match[1]}T00:00:00Z`))) return '' + return match[1] +} + +function parseFinanceBoundary(value: string, fallbackDate: string, end: boolean) { + const normalized = value.trim().replace('T', ' ') + if (!normalized || /^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return new Date(Date.parse(chinaDateToUtcIso(fallbackDate)) + (end ? DAY_MS : 0)).toISOString() + } + const minuteMatch = normalized.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})$/) + const valueWithSeconds = minuteMatch + ? `${minuteMatch[1]} ${minuteMatch[2]}:${end ? '59' : '00'}` + : normalized + const parsed = new Date(`${valueWithSeconds}+08:00`) + return Number.isNaN(parsed.getTime()) + ? new Date(Date.parse(chinaDateToUtcIso(fallbackDate)) + (end ? DAY_MS : 0)).toISOString() + : parsed.toISOString() +} + +function chinaDateParts(value: string) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: CHINA_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(value)) + const result = Object.fromEntries(parts.map((part) => [part.type, part.value])) + return `${result.year}-${result.month}-${result.day}` +} + +function chinaDateToUtcIso(value: string) { + return new Date(`${value}T00:00:00+08:00`).toISOString() +} diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index 96cb5dc8..a2efb8e1 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -2,6 +2,10 @@ import { query, withTransaction } from '../../db/client.js' import { createHttpError } from '../../utils/http.js' export const ADMIN_PERMISSION_KEYS = [ + 'finance.view', + 'finance.review', + 'finance.config', + 'finance.export', 'worker_order.view', 'worker_order.edit', 'worker_order.configure_share', @@ -112,7 +116,7 @@ export async function updateAdminRolePermissions(roleKey: unknown, permissionKey const normalizedRole = String(roleKey || '') .trim() .toLowerCase() - if (!['admin', 'operator', 'support'].includes(normalizedRole)) { + if (!['admin', 'operator', 'support', 'finance'].includes(normalizedRole)) { throw createHttpError('角色不存在或不可修改', { statusCode: 400, errorCode: 'admin_role_invalid', diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index d3577cfb..884ad9df 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -18,6 +18,7 @@ import AdminLayout from '@/layouts/AdminLayout' import WorkerLayout from '@/layouts/WorkerLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) +const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminCloudtentaclesRecordsPage = lazy( () => import('@/pages/admin/AdminCloudtentaclesRecordsPage'), @@ -54,9 +55,11 @@ function RequireAdmin() { return } -function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support'> }) { +function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support' | 'finance'> }) { if (!roles.includes(getAdminRole())) { - return + return ( + + ) } return @@ -104,15 +107,22 @@ function AdminApplication() { }> }> } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + }> } /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index c8ea6aa8..a03e4878 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -1,5 +1,6 @@ import { AuditOutlined, + AccountBookOutlined, BellOutlined, BugOutlined, DashboardOutlined, @@ -87,12 +88,14 @@ export default function AdminLayout() { const devMockStatusQuery = useQuery({ queryKey: ['admin-dev-mock-status'], queryFn: () => fetchDevMockStatus(), + enabled: role !== 'finance', retry: false, }) const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled) const notificationsQuery = useQuery({ queryKey: ['admin-notifications'], queryFn: fetchAdminNotifications, + enabled: role !== 'finance', retry: false, }) const notifications = notificationsQuery.data?.data.items || [] @@ -100,7 +103,7 @@ export default function AdminLayout() { useRealtimeEvents({ url: '/api/v1/admin/realtime', - token: getAdminToken(), + token: role === 'finance' ? '' : getAdminToken(), onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { @@ -110,14 +113,31 @@ export default function AdminLayout() { }) const menuItems = useMemo(() => { - const operationItems: MenuProps['items'] = [ - { key: '/admin/dashboard', icon: , label: '概览' }, - { key: '/admin/orders', icon: , label: '订单' }, - { key: '/admin/tasks', icon: , label: '任务' }, - { key: '/admin/worker-platform', icon: , label: '接单平台' }, - { key: '/admin/kuaishou-industry', icon: , label: '电子凭证' }, - { key: '/admin/cloudtentacles-records', icon: , label: '发货记录' }, - ] + const operationItems: MenuProps['items'] = + role === 'finance' + ? [ + { key: '/admin/finance', icon: , label: '财务' }, + { key: '/admin/worker-platform', icon: , label: '资金审核' }, + ] + : [ + { key: '/admin/dashboard', icon: , label: '概览' }, + { key: '/admin/orders', icon: , label: '订单' }, + ...(role === 'support' + ? [] + : [{ key: '/admin/finance', icon: , label: '财务' }]), + { key: '/admin/tasks', icon: , label: '任务' }, + { key: '/admin/worker-platform', icon: , label: '接单平台' }, + { + key: '/admin/kuaishou-industry', + icon: , + label: '电子凭证', + }, + { + key: '/admin/cloudtentacles-records', + icon: , + label: '发货记录', + }, + ] const items: MenuProps['items'] = [ { @@ -601,6 +621,7 @@ function playNotificationSound(kind: NotificationAlertKind) { function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/admin/orders')) return '/admin/orders' + if (pathname.startsWith('/admin/finance')) return '/admin/finance' if (pathname.startsWith('/admin/tasks')) return '/admin/tasks' if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform' if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry' @@ -629,5 +650,6 @@ function buildNotificationActionPath(notification: AdminNotification) { function roleLabel(role: string) { if (role === 'admin') return '管理员' if (role === 'operator') return '普通运营' + if (role === 'finance') return '财务' return '客服' } diff --git a/apps/frontend/src/pages/admin/AdminFinancePage.tsx b/apps/frontend/src/pages/admin/AdminFinancePage.tsx new file mode 100644 index 00000000..0e57b76b --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminFinancePage.tsx @@ -0,0 +1,322 @@ +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { + Alert, + Button, + Card, + Col, + Form, + Row, + Select, + Space, + Statistic, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import { AdminRangePicker } from '@/components/admin/AdminDatePicker' +import PageHeader from '@/components/admin/PageHeader' +import dayjs from '@/lib/dayjs' +import { fetchAdminFinanceSummary, fetchAdminFinanceTransactions } from '@/services/admin' +import type { + AdminFinanceChannelDailyItem, + AdminFinanceDailyItem, + AdminFinanceTransaction, +} from '@/types/admin/finance' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from './panels/shared' + +type FinanceFilterForm = { dateRange?: unknown } + +const CATEGORY_OPTIONS = [ + { value: '', label: '全部账目' }, + { value: 'order_paid', label: '订单收款' }, + { value: 'order_refund', label: '订单退款' }, + { value: 'worker_reward', label: '打手报酬' }, + { value: 'withdraw_paid', label: '打手提现' }, + { value: 'recharge', label: '打手充值' }, + { value: 'worker_recovery', label: '打手资金回收' }, + { value: 'after_sales_refund', label: '售后追缴退还' }, +] + +export default function AdminFinancePage() { + const [form] = Form.useForm() + const [filters, setFilters] = useState({ dateFrom: '', dateTo: '' }) + const [category, setCategory] = useState('') + const [channel, setChannel] = useState('') + const [channelSummaryFilter, setChannelSummaryFilter] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + + const summaryQuery = useQuery({ + queryKey: ['admin-finance-summary', filters], + queryFn: () => fetchAdminFinanceSummary(filters), + }) + const transactionsQuery = useQuery({ + queryKey: ['admin-finance-transactions', filters, category, page, pageSize], + queryFn: () => + fetchAdminFinanceTransactions({ + ...filters, + category: category || undefined, + channel: channel || undefined, + page, + pageSize, + }), + }) + const overview = summaryQuery.data?.data.overview + const daily = summaryQuery.data?.data.daily || [] + const channelDaily = (summaryQuery.data?.data.channelDaily || []).filter( + (item) => !channelSummaryFilter || item.channel === channelSummaryFilter, + ) + const transactionData = transactionsQuery.data?.data + + function applyFilters(values: FinanceFilterForm) { + const range = Array.isArray(values.dateRange) ? values.dateRange : [] + const dateFrom = + range[0] && dayjs(range[0]).isValid() ? dayjs(range[0]).format('YYYY-MM-DD HH:mm:ss') : '' + const dateTo = + range[1] && dayjs(range[1]).isValid() + ? dayjs(range[1]).second(59).format('YYYY-MM-DD HH:mm:ss') + : '' + setPage(1) + setFilters({ dateFrom, dateTo }) + } + + function resetFilters() { + form.resetFields() + setCategory('') + setChannel('') + setChannelSummaryFilter('') + setPage(1) + setFilters({ dateFrom: '', dateTo: '' }) + } + + const dailyColumns: TableColumnsType = [ + { title: '日期', dataIndex: 'date', width: 120 }, + { title: '支付订单', dataIndex: 'paidOrderCount', width: 100 }, + { title: '收款', dataIndex: 'paidAmount', render: formatMoney }, + { title: '退款', dataIndex: 'refundAmount', render: formatMoney }, + { title: '打手报酬', dataIndex: 'workerRewardAmount', render: formatMoney }, + { title: '已提现', dataIndex: 'withdrawAmount', render: formatMoney }, + { title: '充值入账', dataIndex: 'rechargeAmount', render: formatMoney }, + { title: '售后退还', dataIndex: 'afterSalesRefundAmount', render: formatMoney }, + { + title: '净现金流', + dataIndex: 'netCashFlow', + render: (value: number) => ( + = 0 ? 'success' : 'danger'}> + {formatMoney(value)} + + ), + }, + ] + + const transactionColumns: TableColumnsType = [ + { + title: '发生时间', + dataIndex: 'occurredAt', + width: 180, + render: (value) => formatAdminDateTime(value), + }, + { + title: '类型', + dataIndex: 'label', + width: 150, + render: (value: string, row) => ( + {value} + ), + }, + { title: '渠道', dataIndex: 'channel', width: 150 }, + { + title: '金额', + dataIndex: 'amount', + width: 140, + render: (value: number, row) => ( + + {row.direction === 'in' ? '+' : '-'} + {formatMoney(value)} + + ), + }, + { title: '来源编号', dataIndex: 'sourceId', width: 110 }, + { + title: '关联单号/渠道', + dataIndex: 'referenceNo', + render: (value: string) => value || '-', + }, + ] + + return ( +
+ + + + + form={form} + layout="inline" + className="filter-form" + onFinish={applyFilters} + > + + + + + + + + + + + + + {summaryQuery.error ? ( + + ) : null} + + + + + + + + + + + } + loading={summaryQuery.isFetching} + onClick={() => void summaryQuery.refetch()} + > + 刷新 + + } + > + + rowKey="date" + loading={summaryQuery.isLoading} + dataSource={daily} + columns={dailyColumns} + pagination={false} + scroll={{ x: 1080 }} + /> + + + + { + setCategory(value) + setPage(1) + }} + /> +