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 => 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 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 { 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(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 { 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(sql, [userId]) : await query(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(sql, [id]) : await query(sql, [id]) return result.rows[0] || null } async function assertAccountVisible( accountId: number, visibility: Visibility, client?: PoolClient, ): Promise { 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( ` 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( ` 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( ` 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( ` 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( "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( ` 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( "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( ` 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( ` 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( '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 }, ) { const result = await client.query( ` 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 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13) 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, ], ) return result.rows[0] } 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 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, }) 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 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, }) const agentEntry = await insertEntry(client, { accountId: agentAccountId, entryType: 'issue', amountFen, balanceAfterFen: nextAgentBalance, transferNo, counterpartyAccountId: supervisorAccountId, occurredAt, actorUserId: session.userId, evidence, note, createdAt: now, }) return { transferNo, entries: [ mapFundEntry(requiredRow(supervisorEntry, '写入主管发放流水失败')), mapFundEntry(requiredRow(agentEntry, '写入客服发放流水失败')), ], } }) } async function resolveSupervisorAccountId( payload: JsonObject, session: AdminSession, ): Promise { 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 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, }) const supervisorEntry = await insertEntry(client, { accountId: supervisorAccountId, entryType: 'return', amountFen, balanceAfterFen: nextSupervisorBalance, transferNo, counterpartyAccountId: agentAccountId, occurredAt, actorUserId: session.userId, evidence, note, createdAt: now, }) return { transferNo, entries: [ mapFundEntry(requiredRow(agentEntry, '写入客服缴回流水失败')), mapFundEntry(requiredRow(supervisorEntry, '写入主管收回流水失败')), ], } }) } export async function getOwnAgentAccountId( userId: number, client?: Pick, ): Promise { 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(sql, [userId]) : await query(sql, [userId]) return own.rows[0] ? Number(own.rows[0].id) : 0 } async function resolveAgentAccountId(payload: JsonObject, session: AdminSession): Promise { 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 } /** * 备用金支出唯一写入口:扣账户余额 + 写 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, }) const entryRow = requiredRow(entry, '写入支出流水失败') const expenseNo = generateNo('FX') const expenseResult = await client.query( ` 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 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, }) 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( ` 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( ` 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( ` 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( ` 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( ` 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 differenceFen = toNumber(reconciliation.difference_fen) const reason = text(payload.reason) || reconciliation.reason 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', }) } } 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', }) } if (differenceFen !== 0) { const nextBalance = toNumber(account.balance_fen) + 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: 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, confirmed_by_admin_user_id = $2, confirmed_at = $3, updated_at = $3 WHERE id = $4 `, [reason, session.userId, now, reconciliationId], ) const updated = await client.query( ` 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( ` 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) } }