fix(finance): 客服只能使用自己的备用金账户

This commit is contained in:
yml2213
2026-10-23 11:44:04 +08:00
parent 2aeb789e20
commit a7f60414a4
6 changed files with 126 additions and 57 deletions
@@ -1507,19 +1507,24 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
})
}
export async function getOwnAgentAccountId(userId: number): Promise<number> {
const own = await query<FundAccountRow>(
"SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1",
[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 own = await query<FundAccountRow>(
"SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' LIMIT 1",
[session.userId],
)
if (!own.rows[0]) {
const ownAccountId = await getOwnAgentAccountId(session.userId)
if (!ownAccountId) {
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
return Number(own.rows[0].id)
return ownAccountId
}
const accountId = toId(payload.agentAccountId)
@@ -7,7 +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'
import { getOwnAgentAccountId, writeFundExpense } from '../admin/admin-fund-service.js'
const EXPENSE_STATUSES = new Set([
'draft',
@@ -372,9 +372,26 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
inferExpenseCategory(costItem?.costType),
'支出分类无效',
)
const fundAccountId = normalizeOptionalId(
const requestedFundAccountId = normalizeOptionalId(
payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId,
)
let fundAccountId = requestedFundAccountId
if (session.role === 'support') {
const ownAccountId = await getOwnAgentAccountId(session.userId)
if (!ownAccountId) {
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
if (requestedFundAccountId && requestedFundAccountId !== ownAccountId) {
throw createHttpError('只能使用自己的备用金账户', {
statusCode: 403,
errorCode: 'manual_expense_fund_account_forbidden',
})
}
fundAccountId = ownAccountId
}
if (!fundAccountId) {
throw createHttpError('请选择备用金账户,人工支出必须从备用金扣款', {
statusCode: 400,
@@ -462,10 +479,10 @@ 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(
const requestedFundAccountId = normalizeOptionalId(
payload.fundAccountId ?? payload.fund_account_id ?? payload.agentAccountId,
)
if (!fundAccountId) {
if (!requestedFundAccountId && session.role !== 'support') {
throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', {
statusCode: 400,
errorCode: 'manual_order_cost_fund_account_required',
@@ -535,7 +552,7 @@ export async function createManualOrderCost(payload: JsonObject, session: AdminS
orderId,
orderItemId,
costItemId,
fundAccountId,
fundAccountId: requestedFundAccountId,
amountFen,
expenseCategory,
payeeName,
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { getAdminRole, hasAdminPermission } from '@/utils/admin-auth'
export type ManualOrderCostItem = {
orderItemId: number
@@ -77,6 +77,8 @@ export default function ManualOrderCostModal({
})
const fundAccounts = fundAccountsQuery.data?.data.items || []
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
const isSupport = getAdminRole() === 'support'
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
useEffect(() => {
if (!open) return
@@ -161,25 +163,45 @@ export default function ManualOrderCostModal({
<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>
{isSupport ? (
<>
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
<Input
readOnly
value={
ownFundAccount
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ¥${(
ownFundAccount.balanceFen / 100
).toFixed(2)}`
: '当前账号还没有备用金账户'
}
/>
</Form.Item>
<Form.Item name="fundAccountId" hidden>
<Input />
</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"
@@ -46,7 +46,7 @@ import {
} from '@/services/admin'
import type { AdminManualExpense, AdminManualExpenseStatus } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminPermission } from '@/utils/admin-auth'
import { hasAdminPermission, getAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './panels/shared'
@@ -150,6 +150,8 @@ export default function AdminManualExpensePage() {
const overview = overviewQuery.data?.data?.overview
const costItems = costItemsQuery.data?.data.items || []
const fundAccounts = fundAccountsQuery.data?.data.items || []
const isSupport = getAdminRole() === 'support'
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
useEffect(() => {
const orderId = Number(searchParams.get('orderId') || 0)
@@ -170,11 +172,13 @@ export default function AdminManualExpensePage() {
}, [costItems, createOpen, expenseForm])
useEffect(() => {
if (!createOpen || fundAccounts.length !== 1) return
if (!createOpen) return
if (isSupport && !ownFundAccount) return
if (fundAccounts.length !== 1 && !ownFundAccount) return
if (!expenseForm.getFieldValue('fundAccountId')) {
expenseForm.setFieldValue('fundAccountId', fundAccounts[0]?.id)
expenseForm.setFieldValue('fundAccountId', ownFundAccount?.id || fundAccounts[0]?.id)
}
}, [createOpen, expenseForm, fundAccounts])
}, [createOpen, expenseForm, fundAccounts, isSupport, ownFundAccount])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
@@ -624,25 +628,45 @@ 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>
{isSupport ? (
<>
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
<Input
readOnly
value={
ownFundAccount
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ${formatMoney(
ownFundAccount.balanceFen,
)}`
: '当前账号还没有备用金账户'
}
/>
</Form.Item>
<Form.Item name="fundAccountId" hidden>
<Input />
</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} · 余额 ${formatMoney(
account.balanceFen,
)}`,
}))}
/>
</Form.Item>
)}
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
<Alert
type="warning"
@@ -8,6 +8,7 @@
> 重要:**客服备用金支出已经实施**(`manual_expenses`);**公司人工支出登记**(`manual_expense_records`)也已实施,两张表通过 `fund_expense_id` 关联,不是同一张表。
> 重要:人工成本**逐单、按实际金额**录入,入口在订单界面;**不按小时,也不按固定单量/固定单价**。
> 重要:**人工支出实际上都走客服备用金**——登记时必须选择备用金账户,系统在同一事务内扣减余额、写 `fund_entries` 与 `manual_expenses`,再回填 `manual_expense_records.fund_expense_id`。
> 重要:**客服角色只能使用自己的备用金账户**(默认自动带入,不能选择别人);后端会强制校验,越权传别人的账户直接 403。管理员/财务可按需代录到指定客服账户。
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) ·
> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)
@@ -110,7 +111,7 @@
| 字段 | 谁提供 | 必填 | 说明 |
| --- | --- | --- | --- |
| 订单号 / 订单项 | 自动(订单) | 是(无订单支出除外) | 从订单详情/列表发起时锁定 |
| **客服备用金账户** | **人工选** | **是** | 决定这笔钱从哪个客服的备用金扣;同一事务内扣余额、写流水与 `manual_expenses` |
| **客服备用金账户** | **人工选 / 客服自动** | **是** | 决定这笔钱从哪个客服的备用金扣;客服角色锁定自己的账户,管理员/财务可选择。同一事务内扣余额、写流水与 `manual_expenses` |
| 平台 / 店铺 / 商品 / 规格 / 数量 | 自动(订单) | 是 | 只读展示,避免手抄错 |
| 用户实付 | 自动(订单) | 是 | 只读,用于毛利 |
| 支付时间 | 自动(订单) | 是 | 校验支出时间不早于它 |
@@ -136,7 +137,7 @@
> **人工成本计价口径(2026-10-22 确认)**:人工单**逐单、按实际金额**登记,**不按小时、不按固定单量/固定单价**——每一单的实际付款都可能不同。
> 订单号是必填前置条件;同一订单项的多笔实际支出逐笔累加,未录入即保持 `cost_missing`,不按均价自动补齐。
> **资金来源(2026-10-23 确认)**:人工支出实际上都是客服备用金出的,因此登记时**必须选择备用金账户**;公司直付不再作为默认路径。
> **资金来源(2026-10-23 确认)**:人工支出实际上都是客服备用金出的,因此登记时**必须选择备用金账户**;公司直付不再作为默认路径。**客服只能用自己的账户**,默认自动带入;后端对越权账户返回 403。
### 3.3 一对多关系(必须允许)
+1 -1
View File
@@ -518,7 +518,7 @@ pending_assign -> assigned -> processing -> waiting_customer
> **业务口径(2026-10-22 确认)**:人工成本**不能按小时,也不能按固定单量/固定单价**——每一单的实际付款都不一样。
> 必须**先关联订单号,再逐单录入这一单实际付出去的钱**。
> **资金来源(2026-10-23 确认)**:人工支出实际上都走客服备用金,登记时必须选择备用金账户。
> **资金来源(2026-10-23 确认)**:人工支出实际上都走客服备用金,登记时必须选择备用金账户;**客服角色只能用自己的账户(默认自动带入),管理员/财务可代录到指定客服账户**。
- 入口在订单界面:订单列表对人工单显示「人工录单」,订单详情始终有「人工录单」;弹窗锁定订单号、选择**备用金账户**,订单项多于 1 个时由人工选择。
- 接口 `POST /api/v1/admin/finance/manual-order-costs`:同一事务内扣减备用金余额、写 `fund_entries` 与 `manual_expenses`,再写 `manual_expense_records`(成本项固定 `sys_manual_service`,`fund_expense_id` 必填),金额即本单实际支出;余额不足直接拒绝。