fix(finance): 客服只能使用自己的备用金账户
This commit is contained in:
@@ -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
@@ -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` 必填),金额即本单实际支出;余额不足直接拒绝。
|
||||
|
||||
Reference in New Issue
Block a user