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
@@ -1,9 +1,9 @@
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
import type { FormInstance } from 'antd'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { createAdminManualOrderCost } from '@/services/admin'
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
export type ManualOrderCostItem = {
@@ -24,6 +24,7 @@ type ManualOrderCostModalProps = {
type ManualOrderCostFormValues = {
orderItemId: number
fundAccountId: number
amountYuan: number
expenseCategory: string
payeeName?: string
@@ -68,6 +69,14 @@ export default function ManualOrderCostModal({
const queryClient = useQueryClient()
const [form] = Form.useForm<ManualOrderCostFormValues>()
const [canApprove, setCanApprove] = useState(false)
const fundAccountsQuery = useQuery({
queryKey: ['admin-fund-accounts', 'agent'],
queryFn: () =>
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
enabled: open,
})
const fundAccounts = fundAccountsQuery.data?.data.items || []
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
useEffect(() => {
if (!open) return
@@ -75,18 +84,20 @@ export default function ManualOrderCostModal({
form.resetFields()
form.setFieldsValue({
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
fundAccountId: soleFundAccountId,
amountYuan: undefined,
expenseCategory: 'service_fee',
paymentMethod: 'other',
effective: hasAdminPermission('finance.review'),
})
}, [form, items, open])
}, [form, items, open, soleFundAccountId])
const mutation = useMutation({
mutationFn: (values: ManualOrderCostFormValues) =>
createAdminManualOrderCost({
orderId,
orderItemId: values.orderItemId,
fundAccountId: values.fundAccountId,
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
expenseCategory: values.expenseCategory,
payeeName: values.payeeName || '内部人工',
@@ -107,6 +118,7 @@ export default function ManualOrderCostModal({
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
}
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
@@ -144,11 +156,39 @@ export default function ManualOrderCostModal({
showIcon
style={{ marginBottom: 14 }}
message="每一单单独录实际金额,不按小时、不按单量套模板"
description="成本会先关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
/>
<Form.Item label="订单号">
<Input value={orderNo} readOnly />
</Form.Item>
<Form.Item
name="fundAccountId"
label="备用金账户"
rules={[{ required: true, message: '请选择备用金账户' }]}
extra="人工支出会直接从该客服的备用金余额扣减"
>
<Select
showSearch
optionFilterProp="label"
loading={fundAccountsQuery.isLoading}
placeholder="请选择这笔支出从哪个客服备用金账户出"
options={fundAccounts.map((account) => ({
value: account.id,
label: `${account.ownerName} · ${account.accountNo} · 余额 ¥${(
account.balanceFen / 100
).toFixed(2)}`,
}))}
/>
</Form.Item>
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 14 }}
message="没有可用的客服备用金账户"
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
/>
) : null}
{items.length === 1 ? (
<>
<Form.Item label="计入订单项">
@@ -37,6 +37,7 @@ import dayjs from '@/lib/dayjs'
import {
approveAdminManualExpense,
createAdminManualExpense,
fetchAdminFundAccounts,
fetchAdminManualExpenseCostItems,
fetchAdminManualExpenseOverview,
fetchAdminManualExpenses,
@@ -54,6 +55,7 @@ type FilterForm = { dateRange?: unknown; status?: string; keyword?: string }
type ExpenseForm = {
orderId?: number
orderItemId?: number
fundAccountId?: number
costItemId?: number
amountYuan?: number
expenseCategory: string
@@ -137,10 +139,17 @@ export default function AdminManualExpensePage() {
queryKey: ['admin-manual-expense-cost-items'],
queryFn: fetchAdminManualExpenseCostItems,
})
const fundAccountsQuery = useQuery({
queryKey: ['admin-fund-accounts', 'agent'],
queryFn: () =>
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
enabled: createOpen,
})
const list = listQuery.data?.data
const overview = overviewQuery.data?.data?.overview
const costItems = costItemsQuery.data?.data.items || []
const fundAccounts = fundAccountsQuery.data?.data.items || []
useEffect(() => {
const orderId = Number(searchParams.get('orderId') || 0)
@@ -160,9 +169,17 @@ export default function AdminManualExpensePage() {
if (systemItem) expenseForm.setFieldValue('costItemId', systemItem.id)
}, [costItems, createOpen, expenseForm])
useEffect(() => {
if (!createOpen || fundAccounts.length !== 1) return
if (!expenseForm.getFieldValue('fundAccountId')) {
expenseForm.setFieldValue('fundAccountId', fundAccounts[0]?.id)
}
}, [createOpen, expenseForm, fundAccounts])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expense-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
}
function applyFilters(values: FilterForm) {
@@ -193,6 +210,7 @@ export default function AdminManualExpensePage() {
const response = await createAdminManualExpense({
orderId: values.orderId || undefined,
orderItemId: values.orderItemId || undefined,
fundAccountId: values.fundAccountId,
costItemId: values.costItemId || undefined,
amountFen,
expenseCategory: values.expenseCategory,
@@ -346,6 +364,22 @@ export default function AdminManualExpensePage() {
</Space>
),
},
{
title: '备用金账户',
key: 'fundAccount',
width: 180,
render: (_, row) =>
row.fundAccountId ? (
<Space direction="vertical" size={2}>
<span>{row.fundAccountOwnerName || '-'}</span>
<Typography.Text type="secondary">
{row.fundAccountNo || `#${row.fundAccountId}`} · {row.fundExpenseNo || '-'}
</Typography.Text>
</Space>
) : (
<Tag color="default">未走备用金</Tag>
),
},
{
title: '凭证',
dataIndex: 'evidenceJson',
@@ -538,7 +572,7 @@ export default function AdminManualExpensePage() {
columns={columns}
dataSource={list?.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1650 }}
scroll={{ x: 1830 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
@@ -590,6 +624,34 @@ export default function AdminManualExpensePage() {
</Form.Item>
</Col>
</Row>
<Form.Item
name="fundAccountId"
label="备用金账户"
rules={[{ required: true, message: '请选择备用金账户' }]}
extra="人工支出会直接从该客服的备用金余额扣减,并同步出现在「备用金」支出明细里"
>
<Select
showSearch
optionFilterProp="label"
loading={fundAccountsQuery.isLoading}
placeholder="请选择这笔支出从哪个客服备用金账户出"
options={fundAccounts.map((account) => ({
value: account.id,
label: `${account.ownerName} · ${account.accountNo} · 余额 ${formatMoney(
account.balanceFen,
)}`,
}))}
/>
</Form.Item>
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 14 }}
message="没有可用的客服备用金账户"
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
/>
) : null}
<Row gutter={12}>
<Col span={12}>
<Form.Item
@@ -13,6 +13,10 @@ export type AdminManualExpense = {
orderItemId: number | null
workOrderId: number | null
fundExpenseId: number | null
fundExpenseNo: string
fundAccountId: number | null
fundAccountNo: string
fundAccountOwnerName: string
costItemId: number | null
costItemName: string
costItemCode: string
@@ -1,12 +1,13 @@
# 人工支出登记落地方案(2026-10-18 修订)
> 状态:**已实施 P0(`dev` 分支,迁移 111;订单页人工录单为 2026-10-22 增量)**
> 最后校准:2026-10-22
> 代码基线:`dev @ 7c883207 + 财务 108–112`
> 状态:**已实施 P0(`dev` 分支,迁移 111;订单页人工录单、备用金扣款为 2026-10-22/23 增量)**
> 最后校准:2026-10-23
> 代码基线:`dev @ cd9bb141 + 财务 108–112`
> 依据:业务口径「人工块当前不以 SLA 监管为目标,先把流程搞清楚:哪个客服处理了哪些东西、付了多少钱、什么项目、关联哪个订单」。
> 数据来源:本地只读克隆(生产快照,数据截至 2026-10-14)+ 现有代码盘点。
> 重要:**客服备用金支出已经实施**(`manual_expenses`);**公司人工支出登记**(`manual_expense_records`)也已实施,两张表通过 `fund_expense_id` 关联,不是同一张表。
> 重要:人工成本**逐单、按实际金额**录入,入口在订单界面;**不按小时,也不按固定单量/固定单价**。
> 重要:**人工支出实际上都走客服备用金**——登记时必须选择备用金账户,系统在同一事务内扣减余额、写 `fund_entries` 与 `manual_expenses`,再回填 `manual_expense_records.fund_expense_id`。
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) ·
> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)
@@ -23,18 +24,19 @@
1. 客服侧已经收口的是“备用金余额与用途”,解决“客服手里有多少钱、这笔钱花到哪”。
2. **公司口径的实际支出/垫付已落地**:后台「人工支出」页(`/admin/manual-expenses`)支持登记、审批、驳回、报销;通过后按成本项写 `cost_snapshots`,没有有效成本版本时进入异常队列。
3. 订单界面已接入「人工录单」:订单列表对人工单显示按钮、订单详情始终可录,弹窗锁定订单号并逐单填写**实际金额**,不存在小时单价或固定单量计价。
4. 两张表通过 `fund_expense_id` 关联:如果这笔公司支出来源于客服备用金,就填;公司直付则留空。
3. 订单界面已接入「人工录单」:订单列表对人工单显示按钮、订单详情始终可录,弹窗锁定订单号、选择**备用金账户**并逐单填写**实际金额**,不存在小时单价或固定单量计价。
4. 两张表通过 `fund_expense_id` 关联,且该字段由系统写入:人工支出先从客服备用金账户扣款并生成 `manual_expenses`,再写 `manual_expense_records`。
5. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
实现落点(2026-10-22):
实现落点(2026-10-23):
- 迁移:`apps/backend/src/db/migrations/111_manual_expense_records.sql`
- 服务:`apps/backend/src/services/finance/manual-expense-service.ts`
- 服务:`apps/backend/src/services/finance/manual-expense-service.ts`;备用金扣款唯一写入口 `admin-fund-service.ts` 的 `writeFundExpense`
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `/finance/manual-expenses*`;订单页专用 `POST /finance/manual-order-costs`
- 页面:`apps/frontend/src/pages/admin/AdminManualExpensePage.tsx`,入口 `/admin/manual-expenses`
- 订单入口:订单列表/详情「人工录单」打开 `ManualOrderCostModal`,锁定订单号、选择订单项、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
- 订单入口:订单列表/详情「人工录单」打开 `ManualOrderCostModal`,锁定订单号、选择订单项与备用金账户、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
- 权限:查看 `finance.view` / `fund.view`,登记 `fund.expense` / `finance.config`,审核 `finance.review`
- 资金流:登记时锁定 `fund_accounts` 行 → 校验余额 → 扣减 → 写支出流水 → 写 `manual_expenses` → 写 `manual_expense_records.fund_expense_id`,全程同一事务;余额不足直接拒绝
- 成本快照:通过后以 `subject_type='manual_case'`、`subject_id=expense.id` 写 `actual` 快照;同一订单项多笔支出可累加,不会互相覆盖
---
@@ -108,7 +110,7 @@
| 字段 | 谁提供 | 必填 | 说明 |
| --- | --- | --- | --- |
| 订单号 / 订单项 | 自动(订单) | 是(无订单支出除外) | 从订单详情/列表发起时锁定 |
| 客服备用金来源 | 人工关联 | 否 | 若这笔钱从客服备用金出,关联 `manual_expenses.id`;公司直付留空 |
| **客服备用金账户** | **人工选** | **是** | 决定这笔钱从哪个客服的备用金扣;同一事务内扣余额、写流水与 `manual_expenses` |
| 平台 / 店铺 / 商品 / 规格 / 数量 | 自动(订单) | 是 | 只读展示,避免手抄错 |
| 用户实付 | 自动(订单) | 是 | 只读,用于毛利 |
| 支付时间 | 自动(订单) | 是 | 校验支出时间不早于它 |
@@ -134,6 +136,7 @@
> **人工成本计价口径(2026-10-22 确认)**:人工单**逐单、按实际金额**登记,**不按小时、不按固定单量/固定单价**——每一单的实际付款都可能不同。
> 订单号是必填前置条件;同一订单项的多笔实际支出逐笔累加,未录入即保持 `cost_missing`,不按均价自动补齐。
> **资金来源(2026-10-23 确认)**:人工支出实际上都是客服备用金出的,因此登记时**必须选择备用金账户**;公司直付不再作为默认路径。
### 3.3 一对多关系(必须允许)
+3 -2
View File
@@ -518,9 +518,10 @@ pending_assign -> assigned -> processing -> waiting_customer
> **业务口径(2026-10-22 确认)**:人工成本**不能按小时,也不能按固定单量/固定单价**——每一单的实际付款都不一样。
> 必须**先关联订单号,再逐单录入这一单实际付出去的钱**。
> **资金来源(2026-10-23 确认)**:人工支出实际上都走客服备用金,登记时必须选择备用金账户。
- 入口在订单界面:订单列表对人工单显示「人工录单」,订单详情始终有「人工录单」;弹窗锁定订单号,订单项多于 1 个时由人工选择。
- 接口 `POST /api/v1/admin/finance/manual-order-costs`:写入 `manual_expense_records`,成本项固定为 `sys_manual_service`,金额即本单实际支出。
- 入口在订单界面:订单列表对人工单显示「人工录单」,订单详情始终有「人工录单」;弹窗锁定订单号、选择**备用金账户**,订单项多于 1 个时由人工选择。
- 接口 `POST /api/v1/admin/finance/manual-order-costs`:同一事务内扣减备用金余额、写 `fund_entries` 与 `manual_expenses`,再写 `manual_expense_records`(成本项固定 `sys_manual_service`,`fund_expense_id` 必填),金额即本单实际支出;余额不足直接拒绝。
- 有审核权限(`admin` / `finance` / `finance.review`)且选择立即生效时,当场审批并写 `actual` 快照;否则留在 `submitted`,由财务在「人工支出」页审核。
- 快照以 `subject_type='manual_case'`、`subject_id=expense.id` 写入并携带 `order_item_id`;销售事实按 `cost_snapshots.order_item_id` 汇总,同一订单项的多笔支出(渠道费 + 加急费 + 补差价)累加。
- 工时只作为后续可选的分析字段,不参与成本计算;不设「按单固定单价」。