feat(finance): 人工支出改为从客服备用金扣款

This commit is contained in:
yml2213
2026-10-23 11:31:47 +08:00
parent cd9bb1419c
commit 2aeb789e20
7 changed files with 396 additions and 124 deletions
@@ -1532,6 +1532,144 @@ async function resolveAgentAccountId(payload: JsonObject, session: AdminSession)
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<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)
@@ -1588,110 +1726,27 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin
}
}
const now = nowIso()
const allowDuplicate = payload.allowDuplicate === true
return withTransaction(async (client) => {
const accounts = await lockAccounts(client, [agentAccountId])
const account = accounts.get(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 < amountFen) {
throw createHttpError('备用金余额不足,请先联系主管补拨', {
statusCode: 409,
errorCode: 'fund_balance_insufficient',
})
}
if (!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 created_at >= now() - interval '24 hours'
LIMIT 1
`,
[agentAccountId, amountFen, payeeName],
)
if (duplicateResult.rows[0]) {
throw createHttpError(
`疑似重复支出(${duplicateResult.rows[0].expense_no}),如确认无误请勾选“确认非重复”`,
{
statusCode: 409,
errorCode: 'fund_expense_duplicate',
},
)
}
}
const nextBalance = balance - amountFen
await client.query('UPDATE fund_accounts SET balance_fen = $1, updated_at = $2 WHERE id = $3', [
nextBalance,
now,
const written = await writeFundExpense(client, {
agentAccountId,
])
const entry = await insertEntry(client, {
accountId: agentAccountId,
entryType: 'expense',
amountFen: -amountFen,
balanceAfterFen: nextBalance,
occurredAt: paidAt,
actorUserId: session.userId,
amountFen,
category,
payeeType,
payeeName,
payeeAccount,
platformName,
platformAccount,
orderId: resolvedOrderId,
orderItemId,
paidAt,
evidence,
note,
createdAt: now,
actorUserId: session.userId,
allowDuplicate,
})
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,
agentAccountId,
amountFen,
category,
payeeType,
payeeName,
payeeAccount,
platformName,
platformAccount,
resolvedOrderId || null,
orderItemId || null,
paidAt,
JSON.stringify(evidence),
note,
entryRow.id,
session.userId,
now,
],
)
return {
expense: mapManualExpense(requiredRow(expenseResult.rows[0], '写入支出明细失败')),
}
return { expense: mapManualExpense(written.expense) }
})
}