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) }
})
}
@@ -7,6 +7,7 @@ import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { generateManualExpenseNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { writeFundExpense } from '../admin/admin-fund-service.js'
const EXPENSE_STATUSES = new Set([
'draft',
@@ -70,6 +71,10 @@ type ManualExpenseRow = {
sku_code: string | null
sku_name: string | null
work_order_no: string | null
fund_expense_no?: string | null
fund_account_id?: number | null
fund_account_no?: string | null
fund_account_owner_name?: string | null
}
export async function listManualExpenses(input: Record<string, unknown> = {}) {
@@ -144,12 +149,18 @@ export async function listManualExpenses(input: Record<string, unknown> = {}) {
oi.sku_name,
ci.name AS cost_item_name,
ci.code AS cost_item_code,
wo.work_order_no
wo.work_order_no,
me.expense_no AS fund_expense_no,
me.account_id AS fund_account_id,
fa.account_no AS fund_account_no,
fa.owner_name AS fund_account_owner_name
FROM manual_expense_records r
LEFT JOIN orders o ON o.id = r.order_id
LEFT JOIN order_items oi ON oi.id = r.order_item_id
LEFT JOIN cost_items ci ON ci.id = r.cost_item_id
LEFT JOIN work_orders wo ON wo.id = r.work_order_id
LEFT JOIN manual_expenses me ON me.id = r.fund_expense_id
LEFT JOIN fund_accounts fa ON fa.id = me.account_id
WHERE ${where}
ORDER BY r.paid_at DESC, r.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
@@ -361,15 +372,39 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
inferExpenseCategory(costItem?.costType),
'支出分类无效',
)
const paidByAdminUserId =
normalizeOptionalId(payload.paidByAdminUserId ?? payload.paid_by_admin_user_id) ||
session.userId
const paidByName =
optionalText(payload.paidByName ?? payload.paid_by_name, 80) || session.displayName
const fundAccountId = normalizeOptionalId(
payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId,
)
if (!fundAccountId) {
throw createHttpError('请选择备用金账户,人工支出必须从备用金扣款', {
statusCode: 400,
errorCode: 'manual_expense_fund_account_required',
})
}
const expenseNo = generateManualExpenseNo()
const now = nowIso()
return withTransaction(async (client) => {
const fundWritten = await writeFundExpense(client, {
agentAccountId: fundAccountId,
amountFen,
category: mapFundExpenseCategory(expenseCategory),
payeeType: mapFundPayeeType(payeeType),
payeeName,
payeeAccount: optionalText(payload.payeeAccount ?? payload.payee_account, 120),
orderId,
orderItemId,
paidAt,
evidence: toFundEvidence(evidenceJson),
note: buildFundExpenseNote(
optionalText(payload.note, 500),
optionalText(evidenceJson.reference, 200),
expenseNo,
),
actorUserId: session.userId,
})
const fundExpenseId = Number(fundWritten.expense.id)
const result = await client.query<ManualExpenseRow>(
`
INSERT INTO manual_expense_records (
@@ -392,12 +427,12 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
orderItemId,
orderContext?.workOrderId ||
normalizeOptionalId(payload.workOrderId ?? payload.work_order_id),
normalizeOptionalId(payload.fundExpenseId ?? payload.fund_expense_id),
fundExpenseId,
costItem?.id || null,
expenseCategory,
amountFen,
paidByAdminUserId,
paidByName,
fundWritten.ownerAdminUserId,
fundWritten.ownerName || session.displayName,
payeeType,
payeeName,
optionalText(payload.payeeAccount ?? payload.payee_account, 120),
@@ -417,7 +452,7 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
fromStatus: '',
toStatus: status,
actor: session,
payload: { amountFen, costItemId: costItem?.id || null },
payload: { amountFen, costItemId: costItem?.id || null, fundExpenseId },
now,
})
return getManualExpenseByIdWithClient(client, Number(record.id))
@@ -427,6 +462,15 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
export async function createManualOrderCost(payload: JsonObject, session: AdminSession) {
const orderId = normalizeId(payload.orderId ?? payload.order_id)
const requestedItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id)
const fundAccountId = normalizeOptionalId(
payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId,
)
if (!fundAccountId) {
throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', {
statusCode: 400,
errorCode: 'manual_order_cost_fund_account_required',
})
}
const itemsResult = await query<{ id: number }>(
'SELECT id FROM order_items WHERE order_id = $1 ORDER BY id ASC',
[orderId],
@@ -491,6 +535,7 @@ export async function createManualOrderCost(payload: JsonObject, session: AdminS
orderId,
orderItemId,
costItemId,
fundAccountId,
amountFen,
expenseCategory,
payeeName,
@@ -652,12 +697,18 @@ async function getManualExpenseByIdWithClient(client: PoolClient | null, expense
oi.sku_name,
ci.name AS cost_item_name,
ci.code AS cost_item_code,
wo.work_order_no
wo.work_order_no,
me.expense_no AS fund_expense_no,
me.account_id AS fund_account_id,
fa.account_no AS fund_account_no,
fa.owner_name AS fund_account_owner_name
FROM manual_expense_records r
LEFT JOIN orders o ON o.id = r.order_id
LEFT JOIN order_items oi ON oi.id = r.order_item_id
LEFT JOIN cost_items ci ON ci.id = r.cost_item_id
LEFT JOIN work_orders wo ON wo.id = r.work_order_id
LEFT JOIN manual_expenses me ON me.id = r.fund_expense_id
LEFT JOIN fund_accounts fa ON fa.id = me.account_id
WHERE r.id = $1
LIMIT 1
`
@@ -976,6 +1027,10 @@ function mapManualExpense(row: ManualExpenseRow) {
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
fundExpenseId: row.fund_expense_id ? Number(row.fund_expense_id) : null,
fundExpenseNo: row.fund_expense_no || '',
fundAccountId: row.fund_account_id ? Number(row.fund_account_id) : null,
fundAccountNo: row.fund_account_no || '',
fundAccountOwnerName: row.fund_account_owner_name || '',
costItemId: row.cost_item_id ? Number(row.cost_item_id) : null,
costItemName: row.cost_item_name || '',
costItemCode: row.cost_item_code || '',
@@ -1021,6 +1076,58 @@ function inferExpenseCategory(costType: string | undefined) {
return 'other'
}
function mapFundExpenseCategory(category: string): string {
switch (category) {
case 'goods_purchase':
return 'purchase'
case 'compensation':
return 'compensation'
case 'freight':
return 'freight'
case 'recharge':
return 'platform_recharge'
default:
return 'other'
}
}
function mapFundPayeeType(payeeType: string): string {
switch (payeeType) {
case 'worker':
case 'upstream':
return 'supplier'
case 'customer':
return 'customer'
case 'platform':
return 'platform'
default:
return 'other'
}
}
function toFundEvidence(evidence: JsonObject): JsonObject[] {
const items: JsonObject[] = []
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
for (const value of urls) {
const url = String(value || '').trim()
if (url) {
items.push({ url, filename: '人工支出凭证', contentType: 'image/*' })
}
}
const reference = String(evidence.reference || '').trim()
if (/^https?:\/\//i.test(reference) && !items.some((item) => item.url === reference)) {
items.push({ url: reference, filename: '人工支出凭证', contentType: 'image/*' })
}
return items.slice(0, 6)
}
function buildFundExpenseNote(note: string, reference: string, expenseNo: string): string {
const parts = [`人工支出 ${expenseNo}`]
if (reference) parts.push(`凭证:${reference}`)
if (note) parts.push(note)
return parts.join(' · ').slice(0, 500)
}
function normalizeEvidence(value: unknown): JsonObject {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonObject