新增财务角色与财务报表并完善后台权限
This commit is contained in:
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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
|
||||
@@ -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), {
|
||||
|
||||
@@ -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 || '')
|
||||
}
|
||||
|
||||
@@ -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: '读取资金配置失败',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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<void> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
@@ -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<FinanceDailyRow>(
|
||||
`${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<FinanceSnapshotRow>(
|
||||
`
|
||||
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<FinanceChannelDailyRow>(
|
||||
`${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<FinanceTransactionRow>(
|
||||
`${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()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user