- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
2223 lines
71 KiB
TypeScript
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) }
|
|
}
|