Files
order_site/apps/backend/src/services/admin/admin-fund-service.ts
T
yml2213 9d5af359fa feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛
- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
2026-10-27 16:08:16 +08:00

2223 lines
71 KiB
TypeScript

import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import type { JsonObject } from '../../types/json.js'
import type { AdminSession } from './admin-auth-service.js'
import { normalizeDateQuery, normalizePage, normalizePageSize } from './admin-query-utils.js'
const MONTH_START_SQL =
"(date_trunc('month', now() AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai')"
const ACCOUNT_TYPES = new Set(['supervisor', 'agent'])
const ACCOUNT_STATUSES = new Set(['active', 'frozen', 'closed'])
const EXPENSE_CATEGORIES = new Set([
'platform_recharge',
'purchase',
'compensation',
'freight',
'other',
])
const PAYEE_TYPES = new Set(['platform', 'supplier', 'customer', 'other'])
const RECONCILIATION_STATUSES = new Set(['pending', 'confirmed', 'discrepancy'])
type AccountType = 'supervisor' | 'agent'
type EntryType = 'opening' | 'funding' | 'issue' | 'expense' | 'return' | 'adjustment'
type Visibility =
| { kind: 'all' }
| { kind: 'none' }
| { kind: 'support'; userId: number }
| { kind: 'supervisor'; userId: number; supervisorAccountId: number }
type FundAccountRow = {
id: string | number
account_no: string
account_type: AccountType
owner_admin_user_id: string | number
owner_name: string
responsible_supervisor_id: string | number | null
responsible_supervisor_name?: string | null
balance_fen: string | number
status: string
created_at: string
updated_at: string
month_in_fen?: string | number | null
month_out_fen?: string | number | null
last_entry_at?: string | null
last_reconciled_at?: string | null
}
type FundEntryRow = {
id: string | number
entry_no: string
account_id: string | number
entry_type: EntryType
amount_fen: string | number
balance_after_fen: string | number
transfer_no: string
counterparty_account_id: string | number | null
counterparty_name?: string | null
reconciliation_id?: string | number | null
occurred_at: string
created_by_admin_user_id: string | number | null
created_by_username?: string | null
evidence_json?: unknown
note: string
created_at: string
}
type ManualExpenseRow = {
id: string | number
expense_no: string
account_id: string | number
amount_fen: string | number
category: string
payee_type: string
payee_name: string
payee_account: string
platform_name: string
platform_account: string
order_id: string | number | null
order_item_id: string | number | null
paid_at: string
evidence_json?: unknown
note: string
fund_entry_id: string | number
created_by_admin_user_id: string | number | null
created_by_username?: string | null
created_at: string
updated_at: string
agent_name?: string | null
account_no?: string | null
}
type ReconciliationRow = {
id: string | number
reconciliation_no: string
account_id: string | number
period_start: string
period_end: string
system_balance_fen: string | number
actual_balance_fen: string | number
difference_fen: string | number
status: string
reason: string
evidence_json?: unknown
confirmed_by_admin_user_id: string | number | null
confirmed_by_username?: string | null
confirmed_at: string | null
created_by_admin_user_id: string | number | null
created_by_username?: string | null
created_at: string
updated_at: string
agent_name?: string | null
account_no?: string | null
}
function toNumber(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function toId(value: unknown): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0
}
function text(value: unknown, fallback = ''): string {
return String(value ?? fallback).trim()
}
function normalizeFen(value: unknown, minimum = 1): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < minimum) {
throw createHttpError('金额必须是大于 0 的整数分', {
statusCode: 400,
errorCode: 'fund_amount_invalid',
})
}
return parsed
}
function normalizeNonNegativeFen(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
throw createHttpError('金额必须是非负整数分', {
statusCode: 400,
errorCode: 'fund_amount_invalid',
})
}
return parsed
}
function normalizeOccurredAt(value: unknown): string {
const normalized = text(value)
if (!normalized) return nowIso()
const parsed = Date.parse(normalized)
if (!Number.isFinite(parsed)) {
throw createHttpError('时间格式无效', {
statusCode: 400,
errorCode: 'fund_time_invalid',
})
}
if (parsed > Date.now() + 5 * 60 * 1000) {
throw createHttpError('时间不能晚于当前时间', {
statusCode: 400,
errorCode: 'fund_time_future',
})
}
return new Date(parsed).toISOString()
}
function normalizeEvidence(value: unknown): JsonObject[] {
if (!Array.isArray(value)) return []
return value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item) => ({
objectKey: text(item.objectKey),
url: text(item.url),
thumbnailUrl: text(item.thumbnailUrl),
mediumUrl: text(item.mediumUrl),
filename: text(item.filename),
contentType: text(item.contentType),
size: toNumber(item.size),
}))
.filter((item) => item.objectKey || item.url)
.slice(0, 6)
}
function parseEvidence(value: unknown): JsonObject[] {
if (Array.isArray(value)) return value as JsonObject[]
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? (parsed as JsonObject[]) : []
} catch {
return []
}
}
return []
}
function assertEvidenceOrNote(evidence: JsonObject[], note: string) {
if (evidence.length === 0 && !note) {
throw createHttpError('请上传凭证或填写说明', {
statusCode: 400,
errorCode: 'fund_evidence_required',
})
}
}
function assertExpenseEvidence(evidence: JsonObject[]) {
if (evidence.length === 0) {
throw createHttpError('支出必须上传付款凭证', {
statusCode: 400,
errorCode: 'fund_expense_evidence_required',
})
}
}
function normalizeAccountType(value: unknown): AccountType {
const normalized = text(value) as AccountType
if (!ACCOUNT_TYPES.has(normalized)) {
throw createHttpError('账户类型无效', {
statusCode: 400,
errorCode: 'fund_account_type_invalid',
})
}
return normalized
}
function normalizeAccountStatus(value: unknown): string {
const normalized = text(value)
if (!ACCOUNT_STATUSES.has(normalized)) {
throw createHttpError('账户状态无效', {
statusCode: 400,
errorCode: 'fund_account_status_invalid',
})
}
return normalized
}
function normalizeExpenseCategory(value: unknown): string {
const normalized = text(value)
if (!EXPENSE_CATEGORIES.has(normalized)) {
throw createHttpError('支出分类无效', {
statusCode: 400,
errorCode: 'fund_expense_category_invalid',
})
}
return normalized
}
function normalizePayeeType(value: unknown): string {
const normalized = text(value)
if (!PAYEE_TYPES.has(normalized)) {
throw createHttpError('收款方类型无效', {
statusCode: 400,
errorCode: 'fund_payee_type_invalid',
})
}
return normalized
}
function normalizeRequestId(value: unknown): string {
return text(value).slice(0, 80)
}
function normalizeReconciliationStatus(value: unknown): string {
const normalized = text(value)
if (!RECONCILIATION_STATUSES.has(normalized)) {
throw createHttpError('对账状态无效', {
statusCode: 400,
errorCode: 'fund_reconciliation_status_invalid',
})
}
return normalized
}
function generateNo(prefix: string) {
return randomId(prefix, 5).toUpperCase()
}
function buildFundAccountNoBase(username: string): string {
const slug =
String(username || '')
.trim()
.replace(/[^A-Za-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase()
.slice(0, 32) || 'ACCOUNT'
return `FA-${slug}`
}
/** 账户编号直接带用户名,列表里一眼能对应到人;重名时追加 -2、-3。 */
async function allocateFundAccountNo(client: PoolClient, username: string): Promise<string> {
const base = buildFundAccountNoBase(username)
let candidate = base
for (let suffix = 2; suffix <= 99; suffix += 1) {
const existing = await client.query(
'SELECT 1 FROM fund_accounts WHERE account_no = $1 LIMIT 1',
[candidate],
)
if (!existing.rowCount) {
return candidate
}
candidate = `${base}-${suffix}`
}
throw createHttpError('生成备用金账户编号失败', {
statusCode: 500,
errorCode: 'fund_account_no_conflict',
})
}
function requiredRow<T>(row: T | undefined, message: string): T {
if (!row) {
throw createHttpError(message, {
statusCode: 500,
errorCode: 'fund_row_missing',
})
}
return row
}
export function hasFundPermission(session: AdminSession, permission: string) {
return session.role === 'admin' || session.permissions.includes(permission)
}
function assertAdmin(session: AdminSession) {
if (session.role !== 'admin') {
throw createHttpError('只有管理员可以执行此操作', {
statusCode: 403,
errorCode: 'fund_admin_required',
})
}
}
async function resolveVisibility(session: AdminSession): Promise<Visibility> {
if (session.role === 'admin' || session.role === 'finance') {
return { kind: 'all' }
}
if (session.role === 'support') {
return { kind: 'support', userId: session.userId }
}
const supervisor = await getSupervisorAccountForUser(session.userId)
if (!supervisor) {
return { kind: 'none' }
}
return {
kind: 'supervisor',
userId: session.userId,
supervisorAccountId: Number(supervisor.id),
}
}
function buildAccountScope(alias: string, visibility: Visibility, params: unknown[]): string {
if (visibility.kind === 'all') return 'TRUE'
if (visibility.kind === 'none') return 'FALSE'
if (visibility.kind === 'support') {
params.push(visibility.userId)
return `${alias}.account_type = 'agent' AND ${alias}.owner_admin_user_id = $${params.length}`
}
params.push(visibility.supervisorAccountId)
return `(${alias}.account_type = 'agent' OR ${alias}.id = $${params.length})`
}
async function getSupervisorAccountForUser(userId: number, client?: PoolClient) {
const sql = `
SELECT *
FROM fund_accounts
WHERE owner_admin_user_id = $1
AND account_type = 'supervisor'
AND status <> 'closed'
ORDER BY id
LIMIT 1
`
const result = client
? await client.query<FundAccountRow>(sql, [userId])
: await query<FundAccountRow>(sql, [userId])
return result.rows[0] || null
}
async function selectAccountRow(id: number, client?: PoolClient) {
const sql = `
SELECT
fa.*,
rs.owner_name AS responsible_supervisor_name,
COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen,
COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen,
last_entry.last_entry_at,
last_reconciliation.last_reconciled_at
FROM fund_accounts fa
LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen,
COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen
FROM fund_entries fe
WHERE fe.account_id = fa.id
AND fe.entry_type <> 'opening'
AND fe.occurred_at >= ${MONTH_START_SQL}
) month_stats ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fe.occurred_at) AS last_entry_at
FROM fund_entries fe
WHERE fe.account_id = fa.id
) last_entry ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fr.confirmed_at) AS last_reconciled_at
FROM fund_reconciliations fr
WHERE fr.account_id = fa.id AND fr.status = 'confirmed'
) last_reconciliation ON TRUE
WHERE fa.id = $1
LIMIT 1
`
const result = client
? await client.query<FundAccountRow>(sql, [id])
: await query<FundAccountRow>(sql, [id])
return result.rows[0] || null
}
async function assertAccountVisible(
accountId: number,
visibility: Visibility,
client?: PoolClient,
): Promise<FundAccountRow> {
const account = await selectAccountRow(accountId, client)
if (!account) {
throw createHttpError('备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_account_not_found',
})
}
if (visibility.kind === 'all') return account
if (visibility.kind === 'none') {
throw createHttpError('当前账号没有备用金账户', {
statusCode: 403,
errorCode: 'fund_account_forbidden',
})
}
if (visibility.kind === 'support') {
if (
account.account_type !== 'agent' ||
Number(account.owner_admin_user_id) !== visibility.userId
) {
throw createHttpError('无权查看该备用金账户', {
statusCode: 403,
errorCode: 'fund_account_forbidden',
})
}
return account
}
if (account.account_type === 'agent' || Number(account.id) === visibility.supervisorAccountId) {
return account
}
throw createHttpError('无权查看该备用金账户', {
statusCode: 403,
errorCode: 'fund_account_forbidden',
})
}
function mapFundAccount(row: FundAccountRow) {
return {
id: Number(row.id),
accountNo: row.account_no,
accountType: row.account_type,
ownerAdminUserId: Number(row.owner_admin_user_id),
ownerName: row.owner_name,
responsibleSupervisorId: row.responsible_supervisor_id
? Number(row.responsible_supervisor_id)
: null,
responsibleSupervisorName: row.responsible_supervisor_name || '',
balanceFen: toNumber(row.balance_fen),
status: row.status,
monthInFen: toNumber(row.month_in_fen),
monthOutFen: toNumber(row.month_out_fen),
lastEntryAt: row.last_entry_at || null,
lastReconciledAt: row.last_reconciled_at || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function mapFundEntry(row: FundEntryRow) {
return {
id: Number(row.id),
entryNo: row.entry_no,
accountId: Number(row.account_id),
entryType: row.entry_type,
amountFen: toNumber(row.amount_fen),
balanceAfterFen: toNumber(row.balance_after_fen),
transferNo: row.transfer_no || '',
counterpartyAccountId: row.counterparty_account_id ? Number(row.counterparty_account_id) : null,
counterpartyName: row.counterparty_name || '',
reconciliationId: row.reconciliation_id ? Number(row.reconciliation_id) : null,
occurredAt: row.occurred_at,
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
createdByUsername: row.created_by_username || '',
evidence: parseEvidence(row.evidence_json),
note: row.note || '',
createdAt: row.created_at,
}
}
function mapManualExpense(row: ManualExpenseRow) {
return {
id: Number(row.id),
expenseNo: row.expense_no,
accountId: Number(row.account_id),
accountNo: row.account_no || '',
agentName: row.agent_name || '',
amountFen: toNumber(row.amount_fen),
category: row.category,
payeeType: row.payee_type,
payeeName: row.payee_name,
payeeAccount: row.payee_account || '',
platformName: row.platform_name || '',
platformAccount: row.platform_account || '',
orderId: row.order_id ? Number(row.order_id) : null,
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
paidAt: row.paid_at,
evidence: parseEvidence(row.evidence_json),
note: row.note || '',
fundEntryId: Number(row.fund_entry_id),
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
createdByUsername: row.created_by_username || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function mapReconciliation(row: ReconciliationRow) {
return {
id: Number(row.id),
reconciliationNo: row.reconciliation_no,
accountId: Number(row.account_id),
accountNo: row.account_no || '',
agentName: row.agent_name || '',
periodStart: row.period_start,
periodEnd: row.period_end,
systemBalanceFen: toNumber(row.system_balance_fen),
actualBalanceFen: toNumber(row.actual_balance_fen),
differenceFen: toNumber(row.difference_fen),
status: row.status,
reason: row.reason || '',
evidence: parseEvidence(row.evidence_json),
confirmedByAdminUserId: row.confirmed_by_admin_user_id
? Number(row.confirmed_by_admin_user_id)
: null,
confirmedByUsername: row.confirmed_by_username || '',
confirmedAt: row.confirmed_at,
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
createdByUsername: row.created_by_username || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
async function listEntries(accountId: number, page: number, pageSize: number) {
const offset = (page - 1) * pageSize
const [countResult, rowsResult] = await Promise.all([
query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM fund_entries WHERE account_id = $1',
[accountId],
),
query<FundEntryRow>(
`
SELECT
fe.*,
COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username,
ca.owner_name AS counterparty_name
FROM fund_entries fe
LEFT JOIN admin_users cu ON cu.id = fe.created_by_admin_user_id
LEFT JOIN fund_accounts ca ON ca.id = fe.counterparty_account_id
WHERE fe.account_id = $1
ORDER BY fe.occurred_at DESC, fe.id DESC
LIMIT $2 OFFSET $3
`,
[accountId, pageSize, offset],
),
])
return {
items: rowsResult.rows.map(mapFundEntry),
pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) },
}
}
export async function getAdminFundOverview(session: AdminSession) {
const visibility = await resolveVisibility(session)
const params: unknown[] = []
const scope = buildAccountScope('fa', visibility, params)
const [accountResult, entryResult, reconciliationResult, unreconciledResult] = await Promise.all([
query<{
supervisor_balance_fen: string
agent_balance_fen: string
active_supervisor_count: number
active_agent_count: number
}>(
`
SELECT
COALESCE(SUM(fa.balance_fen) FILTER (WHERE fa.account_type = 'supervisor'), 0)::bigint AS supervisor_balance_fen,
COALESCE(SUM(fa.balance_fen) FILTER (WHERE fa.account_type = 'agent'), 0)::bigint AS agent_balance_fen,
COUNT(*) FILTER (WHERE fa.account_type = 'supervisor' AND fa.status = 'active')::int AS active_supervisor_count,
COUNT(*) FILTER (WHERE fa.account_type = 'agent' AND fa.status = 'active')::int AS active_agent_count
FROM fund_accounts fa
WHERE ${scope}
`,
params,
),
query<{
month_issued_fen: string
month_expense_fen: string
month_returned_fen: string
}>(
`
SELECT
COALESCE(SUM(fe.amount_fen) FILTER (
WHERE fa.account_type = 'agent' AND fe.entry_type = 'issue' AND fe.amount_fen > 0
), 0)::bigint AS month_issued_fen,
COALESCE(-SUM(fe.amount_fen) FILTER (
WHERE fa.account_type = 'agent' AND fe.entry_type = 'expense'
), 0)::bigint AS month_expense_fen,
COALESCE(-SUM(fe.amount_fen) FILTER (
WHERE fa.account_type = 'agent' AND fe.entry_type = 'return'
), 0)::bigint AS month_returned_fen
FROM fund_entries fe
JOIN fund_accounts fa ON fa.id = fe.account_id
WHERE ${scope}
AND fe.occurred_at >= ${MONTH_START_SQL}
`,
params,
),
query<{ pending_count: number; discrepancy_count: number }>(
`
SELECT
COUNT(*) FILTER (WHERE fr.status IN ('pending', 'discrepancy'))::int AS pending_count,
COUNT(*) FILTER (WHERE fr.status = 'discrepancy')::int AS discrepancy_count
FROM fund_reconciliations fr
JOIN fund_accounts fa ON fa.id = fr.account_id
WHERE ${scope}
`,
params,
),
query<{ unreconciled_count: number }>(
`
SELECT COUNT(*)::int AS unreconciled_count
FROM fund_accounts fa
WHERE ${scope}
AND fa.account_type = 'agent'
AND fa.status = 'active'
AND NOT EXISTS (
SELECT 1
FROM fund_reconciliations fr
WHERE fr.account_id = fa.id
AND fr.status = 'confirmed'
AND fr.confirmed_at >= now() - interval '7 days'
)
`,
params,
),
])
const accounts = accountResult.rows[0]
const entries = entryResult.rows[0]
const reconciliations = reconciliationResult.rows[0]
const unreconciled = unreconciledResult.rows[0]
return {
supervisorBalanceFen: toNumber(accounts?.supervisor_balance_fen),
agentBalanceFen: toNumber(accounts?.agent_balance_fen),
totalBalanceFen:
toNumber(accounts?.supervisor_balance_fen) + toNumber(accounts?.agent_balance_fen),
activeSupervisorCount: toNumber(accounts?.active_supervisor_count),
activeAgentCount: toNumber(accounts?.active_agent_count),
monthIssuedFen: toNumber(entries?.month_issued_fen),
monthExpenseFen: toNumber(entries?.month_expense_fen),
monthReturnedFen: toNumber(entries?.month_returned_fen),
pendingReconciliationCount: toNumber(reconciliations?.pending_count),
discrepancyCount: toNumber(reconciliations?.discrepancy_count),
unreconciledAgentCount: toNumber(unreconciled?.unreconciled_count),
}
}
export async function listAdminFundAccounts(payload: JsonObject, session: AdminSession) {
const visibility = await resolveVisibility(session)
const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize)
const offset = (page - 1) * pageSize
const params: unknown[] = []
const scope = buildAccountScope('fa', visibility, params)
const clauses = [scope]
const accountType = text(payload.accountType)
if (accountType) {
if (!ACCOUNT_TYPES.has(accountType)) {
throw createHttpError('账户类型无效', {
statusCode: 400,
errorCode: 'fund_account_type_invalid',
})
}
params.push(accountType)
clauses.push(`fa.account_type = $${params.length}`)
}
const status = text(payload.status)
if (status) {
params.push(normalizeAccountStatus(status))
clauses.push(`fa.status = $${params.length}`)
}
const keyword = text(payload.keyword)
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(fa.owner_name ILIKE $${params.length} OR au.username ILIKE $${params.length} OR fa.account_no ILIKE $${params.length})`,
)
}
const responsibleSupervisorId = toId(payload.responsibleSupervisorId)
if (responsibleSupervisorId) {
params.push(responsibleSupervisorId)
clauses.push(`fa.responsible_supervisor_id = $${params.length}`)
}
const where = clauses.join(' AND ')
const countResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM fund_accounts fa
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
WHERE ${where}
`,
params,
)
const listParams = [...params, pageSize, offset]
const result = await query<FundAccountRow>(
`
SELECT
fa.*,
rs.owner_name AS responsible_supervisor_name,
COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen,
COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen,
last_entry.last_entry_at,
last_reconciliation.last_reconciled_at
FROM fund_accounts fa
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen,
COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen
FROM fund_entries fe
WHERE fe.account_id = fa.id
AND fe.entry_type <> 'opening'
AND fe.occurred_at >= ${MONTH_START_SQL}
) month_stats ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fe.occurred_at) AS last_entry_at
FROM fund_entries fe
WHERE fe.account_id = fa.id
) last_entry ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fr.confirmed_at) AS last_reconciled_at
FROM fund_reconciliations fr
WHERE fr.account_id = fa.id AND fr.status = 'confirmed'
) last_reconciliation ON TRUE
WHERE ${where}
ORDER BY fa.account_type ASC, fa.owner_name ASC, fa.id ASC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
items: result.rows.map(mapFundAccount),
pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) },
}
}
export async function getAdminFundAccountDetail(
accountId: number,
payload: JsonObject,
session: AdminSession,
) {
const visibility = await resolveVisibility(session)
const account = await assertAccountVisible(accountId, visibility)
const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize)
const entries = await listEntries(accountId, page, pageSize)
return { account: mapFundAccount(account), ...entries }
}
export async function getAdminFundMyAccount(session: AdminSession) {
const result = await query<FundAccountRow>(
`
SELECT
fa.*,
rs.owner_name AS responsible_supervisor_name,
COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen,
COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen,
last_entry.last_entry_at,
last_reconciliation.last_reconciled_at
FROM fund_accounts fa
LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen,
COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen
FROM fund_entries fe
WHERE fe.account_id = fa.id
AND fe.entry_type <> 'opening'
AND fe.occurred_at >= ${MONTH_START_SQL}
) month_stats ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fe.occurred_at) AS last_entry_at
FROM fund_entries fe
WHERE fe.account_id = fa.id
) last_entry ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fr.confirmed_at) AS last_reconciled_at
FROM fund_reconciliations fr
WHERE fr.account_id = fa.id AND fr.status = 'confirmed'
) last_reconciliation ON TRUE
WHERE fa.account_type = 'agent'
AND fa.owner_admin_user_id = $1
LIMIT 1
`,
[session.userId],
)
const account = result.rows[0] || null
if (!account) {
return {
account: null,
entries: [],
expenses: [],
pagination: { page: 1, pageSize: 0, total: 0 },
}
}
const accountId = Number(account.id)
const [entries, expenses] = await Promise.all([
listEntries(accountId, 1, 100),
listAdminFundExpenses({ accountId, page: 1, pageSize: 100 }, session),
])
return {
account: mapFundAccount(account),
entries: entries.items,
expenses: expenses.items,
pagination: entries.pagination,
}
}
export async function listAdminFundSetupOptions(session: AdminSession) {
assertAdmin(session)
const [usersResult, accountsResult] = await Promise.all([
query<{
id: string | number
username: string
display_name: string
role: string
status: string
}>(
`
SELECT id, username, display_name, role, status
FROM admin_users
WHERE status = 'active'
ORDER BY role ASC, display_name ASC, username ASC
`,
),
query<FundAccountRow>(
`
SELECT
fa.*,
rs.owner_name AS responsible_supervisor_name,
COALESCE(month_stats.month_in_fen, 0)::bigint AS month_in_fen,
COALESCE(month_stats.month_out_fen, 0)::bigint AS month_out_fen,
last_entry.last_entry_at,
last_reconciliation.last_reconciled_at
FROM fund_accounts fa
LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(fe.amount_fen) FILTER (WHERE fe.amount_fen > 0), 0)::bigint AS month_in_fen,
COALESCE(SUM(-fe.amount_fen) FILTER (WHERE fe.amount_fen < 0), 0)::bigint AS month_out_fen
FROM fund_entries fe
WHERE fe.account_id = fa.id
AND fe.entry_type <> 'opening'
AND fe.occurred_at >= ${MONTH_START_SQL}
) month_stats ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fe.occurred_at) AS last_entry_at
FROM fund_entries fe
WHERE fe.account_id = fa.id
) last_entry ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(fr.confirmed_at) AS last_reconciled_at
FROM fund_reconciliations fr
WHERE fr.account_id = fa.id AND fr.status = 'confirmed'
) last_reconciliation ON TRUE
ORDER BY fa.account_type ASC, fa.owner_name ASC
`,
),
])
const accounts = accountsResult.rows.map(mapFundAccount)
return {
users: usersResult.rows.map((row) => ({
id: Number(row.id),
username: row.username,
displayName: row.display_name || row.username,
role: row.role,
status: row.status,
})),
supervisorAccounts: accounts.filter((account) => account.accountType === 'supervisor'),
agentAccounts: accounts.filter((account) => account.accountType === 'agent'),
}
}
export async function createAdminFundAccount(payload: JsonObject, session: AdminSession) {
assertAdmin(session)
const accountType = normalizeAccountType(payload.accountType)
const ownerAdminUserId = toId(payload.ownerAdminUserId)
if (!ownerAdminUserId) {
throw createHttpError('请选择账户所属人员', {
statusCode: 400,
errorCode: 'fund_owner_required',
})
}
const openingBalanceFen = normalizeNonNegativeFen(payload.openingBalanceFen ?? 0)
const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId)
const now = nowIso()
return withTransaction(async (client) => {
const userResult = await client.query<{
id: string | number
username: string
display_name: string
role: string
}>(
'SELECT id, username, display_name, role FROM admin_users WHERE id = $1 AND status = $2 LIMIT 1',
[ownerAdminUserId, 'active'],
)
const user = userResult.rows[0]
if (!user) {
throw createHttpError('账户所属人员不存在或已停用', {
statusCode: 404,
errorCode: 'fund_owner_not_found',
})
}
if (accountType === 'agent' && user.role !== 'support') {
throw createHttpError('客服备用金账户必须绑定 support 角色', {
statusCode: 400,
errorCode: 'fund_agent_role_invalid',
})
}
if (accountType === 'supervisor' && user.role !== 'operator' && user.role !== 'admin') {
throw createHttpError('主管资金池必须绑定 operator 或 admin 角色', {
statusCode: 400,
errorCode: 'fund_supervisor_role_invalid',
})
}
if (accountType === 'agent') {
if (!responsibleSupervisorId) {
throw createHttpError('客服账户必须指定责任主管', {
statusCode: 400,
errorCode: 'fund_responsible_supervisor_required',
})
}
const supervisorResult = await client.query<FundAccountRow>(
"SELECT * FROM fund_accounts WHERE id = $1 AND account_type = 'supervisor' LIMIT 1",
[responsibleSupervisorId],
)
if (!supervisorResult.rows[0]) {
throw createHttpError('责任主管账户不存在', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
}
const existing = await client.query<{ id: string | number }>(
'SELECT id FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = $2 LIMIT 1',
[ownerAdminUserId, accountType],
)
if (existing.rows[0]) {
throw createHttpError('该人员已有同类型备用金账户', {
statusCode: 409,
errorCode: 'fund_account_exists',
})
}
const accountNo = await allocateFundAccountNo(client, user.username)
const insertResult = await client.query<FundAccountRow>(
`
INSERT INTO fund_accounts (
account_no, account_type, owner_admin_user_id, owner_name,
responsible_supervisor_id, balance_fen, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7)
RETURNING *
`,
[
accountNo,
accountType,
ownerAdminUserId,
user.display_name || user.username,
accountType === 'agent' ? responsibleSupervisorId : null,
openingBalanceFen,
now,
],
)
const account = insertResult.rows[0]
if (openingBalanceFen > 0) {
await client.query(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
occurred_at, created_by_admin_user_id, evidence_json, note, created_at
) VALUES ($1, $2, 'opening', $3, $3, $4, $5, '[]'::jsonb, $6, $4)
`,
[
generateNo('FE'),
requiredRow(account, '创建备用金账户失败').id,
openingBalanceFen,
now,
session.userId,
'上线期初余额',
],
)
}
return { account: mapFundAccount(requiredRow(account, '创建备用金账户失败')) }
})
}
export async function ensureAdminFundAgentAccounts(payload: JsonObject, session: AdminSession) {
assertAdmin(session)
const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId)
if (!responsibleSupervisorId) {
throw createHttpError('请选择责任主管', {
statusCode: 400,
errorCode: 'fund_responsible_supervisor_required',
})
}
const openingBalanceFen = normalizeNonNegativeFen(payload.openingBalanceFen ?? 0)
const now = nowIso()
return withTransaction(async (client) => {
const supervisorResult = await client.query<FundAccountRow>(
"SELECT * FROM fund_accounts WHERE id = $1 AND account_type = 'supervisor' AND status <> 'closed' LIMIT 1",
[responsibleSupervisorId],
)
if (!supervisorResult.rows[0]) {
throw createHttpError('责任主管账户不存在或已关闭', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
const usersResult = await client.query<{
id: string | number
username: string
display_name: string
}>(
`
SELECT au.id, au.username, au.display_name
FROM admin_users au
WHERE au.status = 'active'
AND au.role = 'support'
AND NOT EXISTS (
SELECT 1 FROM fund_accounts fa
WHERE fa.owner_admin_user_id = au.id AND fa.account_type = 'agent'
)
ORDER BY au.id
`,
)
const created: Array<{ id: number; accountNo: string; ownerName: string }> = []
for (const user of usersResult.rows) {
const accountNo = await allocateFundAccountNo(client, user.username)
const insertResult = await client.query<FundAccountRow>(
`
INSERT INTO fund_accounts (
account_no, account_type, owner_admin_user_id, owner_name,
responsible_supervisor_id, balance_fen, status, created_at, updated_at
) VALUES ($1, 'agent', $2, $3, $4, $5, 'active', $6, $6)
RETURNING *
`,
[
accountNo,
user.id,
user.display_name || user.username,
responsibleSupervisorId,
openingBalanceFen,
now,
],
)
const account = insertResult.rows[0]
if (openingBalanceFen > 0) {
await client.query(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
occurred_at, created_by_admin_user_id, evidence_json, note, created_at
) VALUES ($1, $2, 'opening', $3, $3, $4, $5, '[]'::jsonb, $6, $4)
`,
[
generateNo('FE'),
requiredRow(account, '创建客服备用金账户失败').id,
openingBalanceFen,
now,
session.userId,
'上线期初余额',
],
)
}
created.push({
id: Number(requiredRow(account, '创建客服备用金账户失败').id),
accountNo,
ownerName: user.display_name || user.username,
})
}
return { createdCount: created.length, items: created }
})
}
export async function updateAdminFundAccount(
accountId: number,
payload: JsonObject,
session: AdminSession,
) {
assertAdmin(session)
const account = await selectAccountRow(accountId)
if (!account) {
throw createHttpError('备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_account_not_found',
})
}
const status = text(payload.status)
const responsibleSupervisorId = toId(payload.responsibleSupervisorAccountId)
if (status) normalizeAccountStatus(status)
if (status === 'closed' && toNumber(account.balance_fen) !== 0) {
throw createHttpError('余额不为 0 的账户不能关闭', {
statusCode: 409,
errorCode: 'fund_account_balance_not_zero',
})
}
if (responsibleSupervisorId) {
const supervisor = await selectAccountRow(responsibleSupervisorId)
if (!supervisor || supervisor.account_type !== 'supervisor') {
throw createHttpError('责任主管账户不存在', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
}
const result = await query<FundAccountRow>(
`
UPDATE fund_accounts
SET
status = COALESCE(NULLIF($1, ''), status),
responsible_supervisor_id = COALESCE($2, responsible_supervisor_id),
updated_at = $3
WHERE id = $4
RETURNING *
`,
[status, responsibleSupervisorId || null, nowIso(), accountId],
)
return { account: mapFundAccount(requiredRow(result.rows[0], '更新备用金账户失败')) }
}
async function lockAccounts(client: PoolClient, accountIds: number[]) {
const ids = Array.from(new Set(accountIds)).sort((a, b) => a - b)
const result = await client.query<FundAccountRow>(
'SELECT * FROM fund_accounts WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE',
[ids],
)
return new Map(result.rows.map((row) => [Number(row.id), row]))
}
async function insertEntry(
client: PoolClient,
input: {
accountId: number
entryType: EntryType
amountFen: number
balanceAfterFen: number
transferNo?: string
counterpartyAccountId?: number | null
reconciliationId?: number | null
occurredAt: string
actorUserId: number
evidence: JsonObject[]
note: string
createdAt: string
requestId?: string
},
) {
try {
const result = await client.query<FundEntryRow>(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
transfer_no, counterparty_account_id, reconciliation_id, occurred_at,
created_by_admin_user_id, evidence_json, note, created_at, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14)
RETURNING *
`,
[
generateNo('FE'),
input.accountId,
input.entryType,
input.amountFen,
input.balanceAfterFen,
input.transferNo || '',
input.counterpartyAccountId || null,
input.reconciliationId || null,
input.occurredAt,
input.actorUserId,
JSON.stringify(input.evidence),
input.note,
input.createdAt,
input.requestId || '',
],
)
return result.rows[0]
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该请求已处理,请勿重复提交', {
statusCode: 409,
errorCode: 'fund_request_duplicate',
})
}
throw error
}
}
export async function createAdminFundFunding(payload: JsonObject, session: AdminSession) {
assertAdmin(session)
const supervisorAccountId = toId(payload.supervisorAccountId)
if (!supervisorAccountId) {
throw createHttpError('请选择主管资金池', {
statusCode: 400,
errorCode: 'fund_supervisor_account_required',
})
}
const amountFen = normalizeFen(payload.amountFen)
const occurredAt = normalizeOccurredAt(payload.occurredAt)
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const now = nowIso()
return withTransaction(async (client) => {
const accounts = await lockAccounts(client, [supervisorAccountId])
const supervisor = accounts.get(supervisorAccountId)
if (!supervisor || supervisor.account_type !== 'supervisor') {
throw createHttpError('主管资金池不存在', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
if (supervisor.status !== 'active') {
throw createHttpError('主管资金池已冻结或关闭', {
statusCode: 409,
errorCode: 'fund_account_inactive',
})
}
const nextBalance = toNumber(supervisor.balance_fen) + amountFen
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextBalance,
now,
supervisorAccountId,
])
const entry = await insertEntry(client, {
accountId: supervisorAccountId,
entryType: 'funding',
amountFen,
balanceAfterFen: nextBalance,
occurredAt,
actorUserId: session.userId,
evidence,
note,
createdAt: now,
requestId,
})
return {
entry: mapFundEntry(requiredRow(entry, '写入拨付流水失败')),
account: mapFundAccount({ ...supervisor, balance_fen: nextBalance }),
}
})
}
export async function createAdminFundIssue(payload: JsonObject, session: AdminSession) {
const agentAccountId = toId(payload.agentAccountId)
if (!agentAccountId) {
throw createHttpError('请选择客服备用金账户', {
statusCode: 400,
errorCode: 'fund_agent_account_required',
})
}
const amountFen = normalizeFen(payload.amountFen)
const occurredAt = normalizeOccurredAt(payload.occurredAt)
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const supervisorAccountId = await resolveSupervisorAccountId(payload, session)
const now = nowIso()
const transferNo = generateNo('FT')
return withTransaction(async (client) => {
const accounts = await lockAccounts(client, [supervisorAccountId, agentAccountId])
const supervisor = accounts.get(supervisorAccountId)
const agent = accounts.get(agentAccountId)
if (!supervisor || supervisor.account_type !== 'supervisor') {
throw createHttpError('主管资金池不存在', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
if (!agent || agent.account_type !== 'agent') {
throw createHttpError('客服备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
if (supervisor.status !== 'active' || agent.status !== 'active') {
throw createHttpError('账户已冻结或关闭,不能发放', {
statusCode: 409,
errorCode: 'fund_account_inactive',
})
}
const supervisorBalance = toNumber(supervisor.balance_fen)
if (supervisorBalance < amountFen) {
throw createHttpError('主管资金池余额不足', {
statusCode: 409,
errorCode: 'fund_balance_insufficient',
})
}
const nextSupervisorBalance = supervisorBalance - amountFen
const nextAgentBalance = toNumber(agent.balance_fen) + amountFen
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextSupervisorBalance,
now,
supervisorAccountId,
])
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextAgentBalance,
now,
agentAccountId,
])
const supervisorEntry = await insertEntry(client, {
accountId: supervisorAccountId,
entryType: 'issue',
amountFen: -amountFen,
balanceAfterFen: nextSupervisorBalance,
transferNo,
counterpartyAccountId: agentAccountId,
occurredAt,
actorUserId: session.userId,
evidence,
note,
createdAt: now,
requestId,
})
const agentEntry = await insertEntry(client, {
accountId: agentAccountId,
entryType: 'issue',
amountFen,
balanceAfterFen: nextAgentBalance,
transferNo,
counterpartyAccountId: supervisorAccountId,
occurredAt,
actorUserId: session.userId,
evidence,
note,
createdAt: now,
requestId,
})
return {
transferNo,
entries: [
mapFundEntry(requiredRow(supervisorEntry, '写入主管发放流水失败')),
mapFundEntry(requiredRow(agentEntry, '写入客服发放流水失败')),
],
}
})
}
async function resolveSupervisorAccountId(
payload: JsonObject,
session: AdminSession,
): Promise<number> {
if (session.role === 'operator') {
const own = await getSupervisorAccountForUser(session.userId)
if (!own) {
throw createHttpError('当前账号没有主管资金池', {
statusCode: 403,
errorCode: 'fund_supervisor_account_required',
})
}
return Number(own.id)
}
const requested = toId(payload.supervisorAccountId)
if (requested) return requested
if (session.role === 'admin') {
const own = await getSupervisorAccountForUser(session.userId)
if (own) return Number(own.id)
}
throw createHttpError('请选择主管资金池', {
statusCode: 400,
errorCode: 'fund_supervisor_account_required',
})
}
export async function createAdminFundReturn(payload: JsonObject, session: AdminSession) {
const agentAccountId = await resolveAgentAccountId(payload, session)
const amountFen = normalizeFen(payload.amountFen)
const occurredAt = normalizeOccurredAt(payload.occurredAt)
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const agent = await selectAccountRow(agentAccountId)
if (!agent || agent.account_type !== 'agent') {
throw createHttpError('客服备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
const supervisorAccountId =
session.role === 'support'
? toId(agent.responsible_supervisor_id)
: await resolveSupervisorAccountId(payload, session)
if (!supervisorAccountId) {
throw createHttpError('该客服未指定责任主管', {
statusCode: 409,
errorCode: 'fund_responsible_supervisor_required',
})
}
const now = nowIso()
const transferNo = generateNo('FT')
return withTransaction(async (client) => {
const accounts = await lockAccounts(client, [agentAccountId, supervisorAccountId])
const lockedAgent = accounts.get(agentAccountId)
const supervisor = accounts.get(supervisorAccountId)
if (!lockedAgent || lockedAgent.account_type !== 'agent') {
throw createHttpError('客服备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
if (!supervisor || supervisor.account_type !== 'supervisor') {
throw createHttpError('主管资金池不存在', {
statusCode: 404,
errorCode: 'fund_supervisor_account_not_found',
})
}
if (lockedAgent.status !== 'active' || supervisor.status !== 'active') {
throw createHttpError('账户已冻结或关闭,不能缴回', {
statusCode: 409,
errorCode: 'fund_account_inactive',
})
}
const agentBalance = toNumber(lockedAgent.balance_fen)
if (agentBalance < amountFen) {
throw createHttpError('客服备用金余额不足', {
statusCode: 409,
errorCode: 'fund_balance_insufficient',
})
}
const nextAgentBalance = agentBalance - amountFen
const nextSupervisorBalance = toNumber(supervisor.balance_fen) + amountFen
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextAgentBalance,
now,
agentAccountId,
])
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextSupervisorBalance,
now,
supervisorAccountId,
])
const agentEntry = await insertEntry(client, {
accountId: agentAccountId,
entryType: 'return',
amountFen: -amountFen,
balanceAfterFen: nextAgentBalance,
transferNo,
counterpartyAccountId: supervisorAccountId,
occurredAt,
actorUserId: session.userId,
evidence,
note,
createdAt: now,
requestId,
})
const supervisorEntry = await insertEntry(client, {
accountId: supervisorAccountId,
entryType: 'return',
amountFen,
balanceAfterFen: nextSupervisorBalance,
transferNo,
counterpartyAccountId: agentAccountId,
occurredAt,
actorUserId: session.userId,
evidence,
note,
createdAt: now,
requestId,
})
return {
transferNo,
entries: [
mapFundEntry(requiredRow(agentEntry, '写入客服缴回流水失败')),
mapFundEntry(requiredRow(supervisorEntry, '写入主管收回流水失败')),
],
}
})
}
export async function getOwnAgentAccountId(
userId: number,
client?: Pick<PoolClient, 'query'>,
): Promise<number> {
const sql =
"SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1"
const own = client
? await client.query<FundAccountRow>(sql, [userId])
: await query<FundAccountRow>(sql, [userId])
return own.rows[0] ? Number(own.rows[0].id) : 0
}
async function resolveAgentAccountId(payload: JsonObject, session: AdminSession): Promise<number> {
if (session.role === 'support') {
const ownAccountId = await getOwnAgentAccountId(session.userId)
if (!ownAccountId) {
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
return ownAccountId
}
const accountId = toId(payload.agentAccountId)
if (!accountId) {
throw createHttpError('请选择客服备用金账户', {
statusCode: 400,
errorCode: 'fund_agent_account_required',
})
}
return accountId
}
export type FundExpenseWriteInput = {
agentAccountId: number
amountFen: number
category: string
payeeType: string
payeeName: string
payeeAccount?: string
platformName?: string
platformAccount?: string
orderId?: number | null
orderItemId?: number | null
paidAt: string
evidence: JsonObject[]
note: string
actorUserId: number
allowDuplicate?: boolean
requestId?: string
}
/**
* 备用金支出唯一写入口:扣账户余额 + 写 fund_entries + 写 manual_expenses。
* 人工支出登记与备用金页面都走这里,避免两套扣款逻辑。
*/
export async function writeFundExpense(
client: PoolClient,
input: FundExpenseWriteInput,
): Promise<{
expense: ManualExpenseRow
ownerAdminUserId: number
ownerName: string
balanceAfterFen: number
}> {
const accounts = await lockAccounts(client, [input.agentAccountId])
const account = accounts.get(input.agentAccountId)
if (!account || account.account_type !== 'agent') {
throw createHttpError('客服备用金账户不存在', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
if (account.status !== 'active') {
throw createHttpError('客服备用金账户已冻结或关闭', {
statusCode: 409,
errorCode: 'fund_account_inactive',
})
}
const balance = toNumber(account.balance_fen)
if (balance < input.amountFen) {
throw createHttpError('备用金余额不足,请先联系主管补拨', {
statusCode: 409,
errorCode: 'fund_balance_insufficient',
})
}
if (!input.allowDuplicate) {
const duplicateResult = await client.query<{ expense_no: string }>(
`
SELECT expense_no
FROM manual_expenses
WHERE account_id = $1
AND amount_fen = $2
AND payee_name = $3
AND ($4::bigint IS NULL OR order_id = $4)
AND created_at >= now() - interval '24 hours'
LIMIT 1
`,
[input.agentAccountId, input.amountFen, input.payeeName, input.orderId || null],
)
if (duplicateResult.rows[0]) {
throw createHttpError(
`疑似重复支出(${duplicateResult.rows[0].expense_no}),如确认无误请勾选“确认非重复”`,
{
statusCode: 409,
errorCode: 'fund_expense_duplicate',
},
)
}
}
const now = nowIso()
const nextBalance = balance - input.amountFen
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextBalance,
now,
input.agentAccountId,
])
const entry = await insertEntry(client, {
accountId: input.agentAccountId,
entryType: 'expense',
amountFen: -input.amountFen,
balanceAfterFen: nextBalance,
occurredAt: input.paidAt,
actorUserId: input.actorUserId,
evidence: input.evidence,
note: input.note,
createdAt: now,
requestId: input.requestId || '',
})
const entryRow = requiredRow(entry, '写入支出流水失败')
const expenseNo = generateNo('FX')
const expenseResult = await client.query<ManualExpenseRow>(
`
INSERT INTO manual_expenses (
expense_no, account_id, amount_fen, category, payee_type, payee_name,
payee_account, platform_name, platform_account, order_id, order_item_id,
paid_at, evidence_json, note, fund_entry_id, created_by_admin_user_id,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $17)
RETURNING *
`,
[
expenseNo,
input.agentAccountId,
input.amountFen,
input.category,
input.payeeType,
input.payeeName,
input.payeeAccount || '',
input.platformName || '',
input.platformAccount || '',
input.orderId || null,
input.orderItemId || null,
input.paidAt,
JSON.stringify(input.evidence),
input.note,
entryRow.id,
input.actorUserId,
now,
],
)
return {
expense: requiredRow(expenseResult.rows[0], '写入支出明细失败'),
ownerAdminUserId: Number(account.owner_admin_user_id),
ownerName: account.owner_name,
balanceAfterFen: nextBalance,
}
}
export async function createAdminFundExpense(payload: JsonObject, session: AdminSession) {
const agentAccountId = await resolveAgentAccountId(payload, session)
const amountFen = normalizeFen(payload.amountFen)
const category = normalizeExpenseCategory(payload.category)
const payeeType = normalizePayeeType(payload.payeeType)
const payeeName = text(payload.payeeName)
const payeeAccount = text(payload.payeeAccount)
const platformName = text(payload.platformName)
const platformAccount = text(payload.platformAccount)
const note = text(payload.note)
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const paidAt = normalizeOccurredAt(payload.paidAt || payload.occurredAt)
assertExpenseEvidence(evidence)
if (!payeeName) {
throw createHttpError('请填写收款方', {
statusCode: 400,
errorCode: 'fund_payee_required',
})
}
const orderId = toId(payload.orderId)
const orderItemId = toId(payload.orderItemId)
let resolvedOrderId = orderId
if (orderItemId) {
const itemResult = await query<{ id: string | number; order_id: string | number }>(
'SELECT id, order_id FROM order_items WHERE id = $1 LIMIT 1',
[orderItemId],
)
const item = itemResult.rows[0]
if (!item) {
throw createHttpError('关联订单项不存在', {
statusCode: 404,
errorCode: 'fund_order_item_not_found',
})
}
if (resolvedOrderId && Number(item.order_id) !== resolvedOrderId) {
throw createHttpError('订单项与订单不匹配', {
statusCode: 400,
errorCode: 'fund_order_item_mismatch',
})
}
resolvedOrderId = Number(item.order_id)
}
if (resolvedOrderId) {
const orderResult = await query<{ id: string | number }>(
'SELECT id FROM orders WHERE id = $1 LIMIT 1',
[resolvedOrderId],
)
if (!orderResult.rows[0]) {
throw createHttpError('关联订单不存在', {
statusCode: 404,
errorCode: 'fund_order_not_found',
})
}
}
const allowDuplicate = payload.allowDuplicate === true
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
return withTransaction(async (client) => {
const written = await writeFundExpense(client, {
agentAccountId,
amountFen,
category,
payeeType,
payeeName,
payeeAccount,
platformName,
platformAccount,
orderId: resolvedOrderId,
orderItemId,
paidAt,
evidence,
note,
actorUserId: session.userId,
allowDuplicate,
requestId,
})
return { expense: mapManualExpense(written.expense) }
})
}
export async function listAdminFundExpenses(payload: JsonObject, session: AdminSession) {
const visibility = await resolveVisibility(session)
const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize)
const offset = (page - 1) * pageSize
const params: unknown[] = []
const clauses = [buildAccountScope('fa', visibility, params)]
const accountId = toId(payload.accountId)
if (accountId) {
params.push(accountId)
clauses.push(`me.account_id = $${params.length}`)
}
const category = text(payload.category)
if (category) {
params.push(normalizeExpenseCategory(category))
clauses.push(`me.category = $${params.length}`)
}
const payeeType = text(payload.payeeType)
if (payeeType) {
params.push(normalizePayeeType(payeeType))
clauses.push(`me.payee_type = $${params.length}`)
}
const keyword = text(payload.keyword)
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(`(
me.expense_no ILIKE $${params.length}
OR me.payee_name ILIKE $${params.length}
OR me.platform_name ILIKE $${params.length}
OR fa.owner_name ILIKE $${params.length}
OR ou.username ILIKE $${params.length}
OR me.order_id::text ILIKE $${params.length}
)`)
}
const dateFrom = normalizeDateQuery(payload.dateFrom)
if (dateFrom) {
params.push(dateFrom)
clauses.push(`me.paid_at >= $${params.length}::timestamptz`)
}
const dateTo = normalizeDateQuery(payload.dateTo, true)
if (dateTo) {
params.push(dateTo)
clauses.push(`me.paid_at <= $${params.length}::timestamptz`)
}
const where = clauses.join(' AND ')
const countResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM manual_expenses me
JOIN fund_accounts fa ON fa.id = me.account_id
LEFT JOIN admin_users ou ON ou.id = fa.owner_admin_user_id
WHERE ${where}
`,
params,
)
const listParams = [...params, pageSize, offset]
const result = await query<ManualExpenseRow>(
`
SELECT
me.*,
fa.owner_name AS agent_name,
fa.account_no,
COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username
FROM manual_expenses me
JOIN fund_accounts fa ON fa.id = me.account_id
LEFT JOIN admin_users ou ON ou.id = fa.owner_admin_user_id
LEFT JOIN admin_users cu ON cu.id = me.created_by_admin_user_id
WHERE ${where}
ORDER BY me.paid_at DESC, me.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
items: result.rows.map(mapManualExpense),
pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) },
}
}
export async function getAdminFundExpenseDetail(expenseId: number, session: AdminSession) {
const visibility = await resolveVisibility(session)
const params: unknown[] = [expenseId]
const scope = buildAccountScope('fa', visibility, params)
const result = await query<ManualExpenseRow>(
`
SELECT
me.*,
fa.owner_name AS agent_name,
fa.account_no,
COALESCE(NULLIF(cu.display_name, ''), cu.username) AS created_by_username
FROM manual_expenses me
JOIN fund_accounts fa ON fa.id = me.account_id
LEFT JOIN admin_users cu ON cu.id = me.created_by_admin_user_id
WHERE me.id = $1 AND ${scope}
LIMIT 1
`,
params,
)
const row = result.rows[0]
if (!row) {
throw createHttpError('支出记录不存在', {
statusCode: 404,
errorCode: 'fund_expense_not_found',
})
}
return { expense: mapManualExpense(row) }
}
export async function listAdminFundReconciliations(payload: JsonObject, session: AdminSession) {
const visibility = await resolveVisibility(session)
const page = normalizePage(payload.page)
const pageSize = normalizePageSize(payload.pageSize)
const offset = (page - 1) * pageSize
const params: unknown[] = []
const clauses = [buildAccountScope('fa', visibility, params)]
const accountId = toId(payload.accountId)
if (accountId) {
params.push(accountId)
clauses.push(`fr.account_id = $${params.length}`)
}
const status = text(payload.status)
if (status) {
params.push(normalizeReconciliationStatus(status))
clauses.push(`fr.status = $${params.length}`)
}
const dateFrom = normalizeDateQuery(payload.dateFrom)
if (dateFrom) {
params.push(dateFrom)
clauses.push(`fr.period_end >= $${params.length}::timestamptz`)
}
const dateTo = normalizeDateQuery(payload.dateTo, true)
if (dateTo) {
params.push(dateTo)
clauses.push(`fr.period_start <= $${params.length}::timestamptz`)
}
const where = clauses.join(' AND ')
const countResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM fund_reconciliations fr
JOIN fund_accounts fa ON fa.id = fr.account_id
WHERE ${where}
`,
params,
)
const listParams = [...params, pageSize, offset]
const result = await query<ReconciliationRow>(
`
SELECT
fr.*,
fa.owner_name AS agent_name,
fa.account_no,
COALESCE(NULLIF(cu.display_name, ''), cu.username) AS confirmed_by_username,
COALESCE(NULLIF(cc.display_name, ''), cc.username) AS created_by_username
FROM fund_reconciliations fr
JOIN fund_accounts fa ON fa.id = fr.account_id
LEFT JOIN admin_users cu ON cu.id = fr.confirmed_by_admin_user_id
LEFT JOIN admin_users cc ON cc.id = fr.created_by_admin_user_id
WHERE ${where}
ORDER BY fr.period_end DESC, fr.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
items: result.rows.map(mapReconciliation),
pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) },
}
}
export async function createAdminFundReconciliation(payload: JsonObject, session: AdminSession) {
const visibility = await resolveVisibility(session)
const accountId = toId(payload.accountId)
if (!accountId) {
throw createHttpError('请选择对账账户', {
statusCode: 400,
errorCode: 'fund_account_required',
})
}
const account = await assertAccountVisible(accountId, visibility)
if (account.account_type !== 'agent') {
throw createHttpError('P0 只对客服备用金账户对账', {
statusCode: 400,
errorCode: 'fund_reconciliation_agent_only',
})
}
const periodStart = normalizeOccurredAt(payload.periodStart)
const periodEnd = normalizeOccurredAt(payload.periodEnd)
if (Date.parse(periodStart) > Date.parse(periodEnd)) {
throw createHttpError('对账开始时间不能晚于结束时间', {
statusCode: 400,
errorCode: 'fund_reconciliation_period_invalid',
})
}
const actualBalanceFen = normalizeNonNegativeFen(payload.actualBalanceFen ?? 0)
const systemBalanceFen = toNumber(account.balance_fen)
const differenceFen = actualBalanceFen - systemBalanceFen
const reason = text(payload.reason)
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
if (differenceFen !== 0 && !reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
const now = nowIso()
const result = await query<ReconciliationRow>(
`
INSERT INTO fund_reconciliations (
reconciliation_no, account_id, period_start, period_end,
system_balance_fen, actual_balance_fen, difference_fen, status,
reason, evidence_json, created_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, $12)
RETURNING *
`,
[
generateNo('FR'),
accountId,
periodStart,
periodEnd,
systemBalanceFen,
actualBalanceFen,
differenceFen,
differenceFen === 0 ? 'pending' : 'discrepancy',
reason,
JSON.stringify(evidence),
session.userId,
now,
],
)
return {
reconciliation: mapReconciliation({
...requiredRow(result.rows[0], '写入对账记录失败'),
agent_name: account.owner_name,
account_no: account.account_no,
}),
}
}
export async function confirmAdminFundReconciliation(
reconciliationId: number,
payload: JsonObject,
session: AdminSession,
) {
if (session.role === 'support') {
throw createHttpError('客服不能确认对账', {
statusCode: 403,
errorCode: 'fund_reconciliation_forbidden',
})
}
const visibility = await resolveVisibility(session)
const params: unknown[] = [reconciliationId]
const scope = buildAccountScope('fa', visibility, params)
const result = await query<ReconciliationRow>(
`
SELECT fr.*, fa.owner_name AS agent_name, fa.account_no
FROM fund_reconciliations fr
JOIN fund_accounts fa ON fa.id = fr.account_id
WHERE fr.id = $1 AND ${scope}
LIMIT 1
`,
params,
)
const reconciliation = result.rows[0]
if (!reconciliation) {
throw createHttpError('对账记录不存在', {
statusCode: 404,
errorCode: 'fund_reconciliation_not_found',
})
}
if (reconciliation.status === 'confirmed') {
throw createHttpError('该对账已确认', {
statusCode: 409,
errorCode: 'fund_reconciliation_already_confirmed',
})
}
const reason = text(payload.reason) || reconciliation.reason
const actualBalanceFen = toNumber(reconciliation.actual_balance_fen)
const now = nowIso()
return withTransaction(async (client) => {
const locked = await lockAccounts(client, [Number(reconciliation.account_id)])
const account = locked.get(Number(reconciliation.account_id))
if (!account) {
throw createHttpError('对账账户不存在', {
statusCode: 404,
errorCode: 'fund_account_not_found',
})
}
// 关键:用“确认时点的当前余额”重算差额,而不是创建对账时的旧差额。
// 否则创建后发生的发放/支出/缴回会让调整后的余额仍然不等于实际盘点。
const currentBalanceFen = toNumber(account.balance_fen)
const differenceFen = actualBalanceFen - currentBalanceFen
if (differenceFen !== 0) {
if (!hasFundPermission(session, 'fund.adjust')) {
throw createHttpError('有差异的对账需要财务或管理员调整', {
statusCode: 403,
errorCode: 'fund_adjust_forbidden',
})
}
if (!reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
}
if (differenceFen !== 0) {
const nextBalance = currentBalanceFen + differenceFen
if (nextBalance < 0) {
throw createHttpError('调整后余额不能为负', {
statusCode: 409,
errorCode: 'fund_balance_insufficient',
})
}
await client.query(
'UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3',
[nextBalance, now, account.id],
)
await insertEntry(client, {
accountId: Number(account.id),
entryType: 'adjustment',
amountFen: differenceFen,
balanceAfterFen: nextBalance,
reconciliationId,
occurredAt: now,
actorUserId: session.userId,
evidence: normalizeEvidence(payload.evidence || reconciliation.evidence_json),
note: reason || '对账差异调整',
createdAt: now,
})
}
await client.query(
`
UPDATE fund_reconciliations
SET status = 'confirmed',
reason = $1,
system_balance_fen = $5,
difference_fen = $6,
confirmed_by_admin_user_id = $2,
confirmed_at = $3,
updated_at = $3
WHERE id = $4
`,
[reason, session.userId, now, reconciliationId, currentBalanceFen, differenceFen],
)
const updated = await client.query<ReconciliationRow>(
`
SELECT fr.*, fa.owner_name AS agent_name, fa.account_no, COALESCE(NULLIF(cu.display_name, ''), cu.username) AS confirmed_by_username
FROM fund_reconciliations fr
JOIN fund_accounts fa ON fa.id = fr.account_id
LEFT JOIN admin_users cu ON cu.id = fr.confirmed_by_admin_user_id
WHERE fr.id = $1
LIMIT 1
`,
[reconciliationId],
)
return {
reconciliation: mapReconciliation(requiredRow(updated.rows[0], '更新对账记录失败')),
}
})
}
export async function getAdminFundSupervisorOptions(session: AdminSession) {
const visibility = await resolveVisibility(session)
const params: unknown[] = []
const scope = buildAccountScope('fa', visibility, params)
const result = await query<FundAccountRow>(
`
SELECT fa.*, rs.owner_name AS responsible_supervisor_name
FROM fund_accounts fa
LEFT JOIN fund_accounts rs ON rs.id = fa.responsible_supervisor_id
WHERE ${scope} AND fa.account_type = 'supervisor'
ORDER BY fa.owner_name
`,
params,
)
return { items: result.rows.map(mapFundAccount) }
}