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
@@ -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