feat(finance): 人工支出改为从客服备用金扣款
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user