From 2aeb789e20709b616216b2945f089ab5c0364767 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 23 Oct 2026 11:31:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E4=BA=BA=E5=B7=A5=E6=94=AF?= =?UTF-8?q?=E5=87=BA=E6=94=B9=E4=B8=BA=E4=BB=8E=E5=AE=A2=E6=9C=8D=E5=A4=87?= =?UTF-8?q?=E7=94=A8=E9=87=91=E6=89=A3=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/admin/admin-fund-service.ts | 249 +++++++++++------- .../finance/manual-expense-service.ts | 129 ++++++++- .../components/admin/ManualOrderCostModal.tsx | 48 +++- .../pages/admin/AdminManualExpensePage.tsx | 64 ++++- .../src/types/admin/manual-expense.ts | 4 + docs/人工支出登记落地方案-2026-10-17.md | 21 +- docs/成本中心与三块履约模型.md | 5 +- 7 files changed, 396 insertions(+), 124 deletions(-) diff --git a/apps/backend/src/services/admin/admin-fund-service.ts b/apps/backend/src/services/admin/admin-fund-service.ts index 5ca31b4b..473ac27a 100644 --- a/apps/backend/src/services/admin/admin-fund-service.ts +++ b/apps/backend/src/services/admin/admin-fund-service.ts @@ -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( + ` + 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( - ` - 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) } }) } diff --git a/apps/backend/src/services/finance/manual-expense-service.ts b/apps/backend/src/services/finance/manual-expense-service.ts index 68124b7c..78d3ef01 100644 --- a/apps/backend/src/services/finance/manual-expense-service.ts +++ b/apps/backend/src/services/finance/manual-expense-service.ts @@ -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 = {}) { @@ -144,12 +149,18 @@ export async function listManualExpenses(input: Record = {}) { 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( ` 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 diff --git a/apps/frontend/src/components/admin/ManualOrderCostModal.tsx b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx index 5a09227b..cf8fbb1b 100644 --- a/apps/frontend/src/components/admin/ManualOrderCostModal.tsx +++ b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx @@ -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() 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="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。" /> + + ({ + value: account.id, + label: `${account.ownerName} · ${account.accountNo} · 余额 ${formatMoney( + account.balanceFen, + )}`, + }))} + /> + + {!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? ( + + ) : null} 状态:**已实施 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 一对多关系(必须允许) diff --git a/docs/成本中心与三块履约模型.md b/docs/成本中心与三块履约模型.md index 6520ebda..760a3d9f 100644 --- a/docs/成本中心与三块履约模型.md +++ b/docs/成本中心与三块履约模型.md @@ -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` 汇总,同一订单项的多笔支出(渠道费 + 加急费 + 补差价)累加。 - 工时只作为后续可选的分析字段,不参与成本计算;不设「按单固定单价」。