新增财务角色与财务报表并完善后台权限

This commit is contained in:
yml2213
2026-09-02 13:58:03 +08:00
parent de63daf1ac
commit bf167d98ed
29 changed files with 1242 additions and 69 deletions
@@ -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()
}