feat(finance): 订单页人工录单按单实际金额计入成本

This commit is contained in:
yml2213
2026-10-22 19:48:56 +08:00
parent 7c8832078f
commit 74b62f3494
10 changed files with 465 additions and 40 deletions
+28
View File
@@ -14,6 +14,7 @@ import {
import {
approveManualExpense,
createManualExpense,
createManualOrderCost,
getManualExpense,
getManualExpenseOverview,
listManualExpenseCostItems,
@@ -184,6 +185,33 @@ router.post(
}),
)
router.post(
'/finance/manual-order-costs',
requireAnyAdminPermission(['fund.expense', 'finance.config']),
createJsonHandler((req) => createManualOrderCost(bodyOf(req), sessionOf(req)), {
successMessage: '订单人工成本已录入',
errorMessage: '录入订单人工成本失败',
scope: '[admin/finance/manual-order-costs]',
audit: (req, data) => {
const result = data as {
record?: { id?: number; expenseNo?: string; amountFen?: number; orderId?: number }
effective?: boolean
}
return {
action: 'manual_order_cost_created',
targetType: 'manual_expense',
targetId: String(result.record?.id || ''),
data: {
expenseNo: result.record?.expenseNo || '',
amountFen: result.record?.amountFen || 0,
orderId: result.record?.orderId || req.body?.orderId || '',
effective: result.effective === true,
},
}
},
}),
)
router.post(
'/finance/manual-expenses/:expenseId/approve',
requireAnyAdminPermission(['finance.review']),
@@ -424,6 +424,106 @@ 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 itemsResult = await query<{ id: number }>(
'SELECT id FROM order_items WHERE order_id = $1 ORDER BY id ASC',
[orderId],
)
if (itemsResult.rows.length === 0) {
throw createHttpError('订单没有订单项,无法录入人工成本', {
statusCode: 400,
errorCode: 'manual_order_cost_items_missing',
})
}
let orderItemId = requestedItemId
if (orderItemId) {
if (!itemsResult.rows.some((row) => Number(row.id) === orderItemId)) {
throw createHttpError('订单项不属于该订单', {
statusCode: 400,
errorCode: 'manual_order_cost_item_mismatch',
})
}
} else if (itemsResult.rows.length === 1) {
orderItemId = Number(itemsResult.rows[0]?.id || 0)
} else {
throw createHttpError('订单有多个订单项,请选择要计入的订单项', {
statusCode: 400,
errorCode: 'manual_order_cost_item_required',
})
}
const costItemResult = await query<{ id: number }>(
`SELECT id FROM cost_items
WHERE code = 'sys_manual_service' AND status = 'active'
LIMIT 1`,
)
const costItemId = Number(costItemResult.rows[0]?.id || 0)
if (!costItemId) {
throw createHttpError('人工客服履约成本项未启用', {
statusCode: 409,
errorCode: 'manual_order_cost_item_unavailable',
})
}
const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen)
const note = optionalText(payload.note, 500)
const expenseCategory = normalizeStatus(
payload.expenseCategory ?? payload.expense_category,
EXPENSE_CATEGORIES,
'service_fee',
'支出分类无效',
)
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) || '内部人工'
const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json)
if (!hasEvidence(evidenceJson)) {
evidenceJson.note = note || '订单页人工录单'
}
const canApprove =
session.role === 'admin' ||
session.role === 'finance' ||
(session.permissions || []).includes('finance.review')
const effective = payload.effective !== false && canApprove
const created = await createManualExpense(
{
orderId,
orderItemId,
costItemId,
amountFen,
expenseCategory,
payeeName,
payeeType: payload.payeeType ?? payload.payee_type ?? 'other',
paymentMethod: payload.paymentMethod ?? payload.payment_method ?? 'other',
note: note || '订单页人工录单',
evidenceJson,
status: 'submitted',
},
session,
)
if (!effective) {
return { record: created, effective: false, reason: 'pending_review' as const }
}
try {
const approved = await approveManualExpense(
created.id,
{ note: '订单页人工录单直接生效' },
session,
)
return { record: approved, effective: true, reason: 'approved' as const }
} catch (error) {
return {
record: created,
effective: false,
reason: 'approval_failed' as const,
message: error instanceof Error ? error.message : '自动生效失败',
}
}
}
export async function approveManualExpense(
expenseId: number | string,
payload: JsonObject,
@@ -94,8 +94,7 @@ export async function rebuildSalesDailyFacts(
COUNT(*)::int AS snapshot_count,
COUNT(*) FILTER (WHERE cs.snapshot_stage = 'actual')::int AS actual_count
FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = oi.id
WHERE cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
) cost ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
@@ -0,0 +1,233 @@
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
import type { FormInstance } from 'antd'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { createAdminManualOrderCost } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
export type ManualOrderCostItem = {
orderItemId: number
itemTitle: string
skuNick?: string
quantity?: number
}
type ManualOrderCostModalProps = {
open: boolean
orderId: number
orderNo: string
items: ManualOrderCostItem[]
onClose: () => void
}
type ManualOrderCostFormValues = {
orderItemId: number
amountYuan: number
expenseCategory: string
payeeName?: string
paymentMethod?: string
evidence?: string
note?: string
effective?: boolean
}
const CATEGORY_OPTIONS = [
{ value: 'service_fee', label: '人工服务费' },
{ value: 'goods_purchase', label: '商品采购' },
{ value: 'compensation', label: '赔付 / 补偿' },
{ value: 'refund_advance', label: '垫付退款' },
{ value: 'freight', label: '运费' },
{ value: 'other', label: '其他' },
]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'wechat', label: '微信' },
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行卡' },
{ value: 'cash', label: '现金' },
{ value: 'platform_balance', label: '平台余额' },
{ value: 'other', label: '其他' },
]
function itemLabel(item: ManualOrderCostItem) {
const parts = [item.itemTitle || `订单项 #${item.orderItemId}`]
if (item.skuNick) parts.push(item.skuNick)
if (item.quantity && item.quantity > 1) parts.push(`×${item.quantity}`)
return parts.join(' · ')
}
export default function ManualOrderCostModal({
open,
orderId,
orderNo,
items,
onClose,
}: ManualOrderCostModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ManualOrderCostFormValues>()
const [canApprove, setCanApprove] = useState(false)
useEffect(() => {
if (!open) return
setCanApprove(hasAdminPermission('finance.review'))
form.resetFields()
form.setFieldsValue({
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
amountYuan: undefined,
expenseCategory: 'service_fee',
paymentMethod: 'other',
effective: hasAdminPermission('finance.review'),
})
}, [form, items, open])
const mutation = useMutation({
mutationFn: (values: ManualOrderCostFormValues) =>
createAdminManualOrderCost({
orderId,
orderItemId: values.orderItemId,
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
expenseCategory: values.expenseCategory,
payeeName: values.payeeName || '内部人工',
paymentMethod: values.paymentMethod || 'other',
note: values.note || '',
evidenceJson: values.evidence
? { reference: values.evidence }
: { note: values.note || '订单页人工录单' },
effective: canApprove ? values.effective !== false : false,
}),
onSuccess: (response) => {
const result = response.data
if (result.effective) {
message.success(`已计入订单成本,支出单号 ${result.record.expenseNo}`)
} else if (result.reason === 'approval_failed') {
message.warning(result.message || '已提交,但自动生效失败,请到人工支出页审核')
} else {
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
}
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
onClose()
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '录入人工成本失败')
},
})
async function submit() {
const values = await form.validateFields()
mutation.mutate(values)
}
return (
<Modal
open={open}
title="人工录单 / 录入订单成本"
okText={canApprove ? '确认录入并计入成本' : '提交审核'}
cancelText="取消"
confirmLoading={mutation.isPending}
onOk={() => void submit()}
onCancel={onClose}
destroyOnHidden
width={620}
>
<Form<ManualOrderCostFormValues>
form={form as FormInstance<ManualOrderCostFormValues>}
layout="vertical"
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="每一单单独录实际金额,不按小时、不按单量套模板"
description="成本会先关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
/>
<Form.Item label="订单号">
<Input value={orderNo} readOnly />
</Form.Item>
{items.length === 1 ? (
<>
<Form.Item label="计入订单项">
<Input value={itemLabel(items[0])} readOnly />
</Form.Item>
<Form.Item name="orderItemId" hidden>
<Input />
</Form.Item>
</>
) : (
<Form.Item
name="orderItemId"
label="计入订单项"
rules={[{ required: true, message: '请选择订单项' }]}
>
<Select
placeholder="订单有多个商品,请选择计入哪一项"
options={items.map((item) => ({
value: item.orderItemId,
label: itemLabel(item),
}))}
/>
</Form.Item>
)}
<Form.Item
name="amountYuan"
label="人工成本金额(元)"
rules={[{ required: true, message: '请输入实际金额' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
<Form.Item
name="expenseCategory"
label="费用类型"
rules={[{ required: true, message: '请选择费用类型' }]}
>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
<Form.Item name="payeeName" label="处理人 / 收款方">
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
</Form.Item>
<Form.Item name="paymentMethod" label="付款方式">
<Select options={PAYMENT_METHOD_OPTIONS} />
</Form.Item>
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="这一单实际做了什么、为什么是这个金额"
/>
</Form.Item>
{canApprove ? (
<Form.Item
name="effective"
label="立即计入成本"
valuePropName="checked"
extra="关闭后先提交,等财务在人工支出页审核通过再计入毛利。"
>
<Switch />
</Form.Item>
) : (
<Alert
type="warning"
showIcon
message="提交后需要财务审核"
description="当前账号没有 finance.review 权限,录入后状态为待审核,审核通过才会写入成本快照。"
/>
)}
</Form>
</Modal>
)
}
@@ -22,10 +22,11 @@ import {
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
@@ -38,10 +39,27 @@ export default function AdminOrderDetailPage() {
const queryClient = useQueryClient()
const { message, modal } = App.useApp()
const { orderId = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [manualCostOpen, setManualCostOpen] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
useEffect(() => {
if (searchParams.get('manualCost') === '1') {
setManualCostOpen(true)
}
}, [searchParams])
function closeManualCost() {
setManualCostOpen(false)
if (searchParams.get('manualCost')) {
const next = new URLSearchParams(searchParams)
next.delete('manualCost')
setSearchParams(next, { replace: true })
}
}
const query = useQuery({
queryKey: ['admin-order-detail', orderId],
queryFn: () => fetchAdminOrderDetail(orderId),
@@ -183,11 +201,8 @@ export default function AdminOrderDetailPage() {
title="基础信息"
extra={
<Space>
<Button
icon={<FileTextOutlined />}
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
>
登记人工支出
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
人工录单
</Button>
{isAutoClaimOrder ? (
<>
@@ -368,6 +383,19 @@ export default function AdminOrderDetailPage() {
/>
</div>
</Modal>
<ManualOrderCostModal
open={manualCostOpen}
orderId={detail.order.orderId}
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
items={detail.items.map((item) => ({
orderItemId: item.orderItemId,
itemTitle: item.itemTitle,
skuNick: item.skuNick,
quantity: item.quantity,
}))}
onClose={closeManualCost}
/>
</section>
)
}
@@ -1,5 +1,6 @@
import {
CopyOutlined,
DollarOutlined,
EyeOutlined,
LinkOutlined,
ReloadOutlined,
@@ -333,11 +334,13 @@ export default function AdminOrdersPage() {
},
{
title: '操作',
minWidth: 130,
minWidth: 220,
fixed: 'right',
render: (_, row) => {
const orderNo = row.platformOrderId || String(row.orderId)
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
const isManualOrder =
row.fulfillmentType === 'manual' || (!row.canClaimH5 && !row.workOrderCount)
return (
<Space size={6} wrap>
@@ -350,6 +353,17 @@ export default function AdminOrdersPage() {
>
详情
</Button>
{isManualOrder ? (
<Tooltip title="人工录单:录入这一单的实际人工成本">
<Button
size="small"
icon={<DollarOutlined />}
onClick={() => navigate(`/admin/orders/${row.orderId}?manualCost=1`)}
>
人工录单
</Button>
</Tooltip>
) : null}
{row.canClaimH5 ? (
<Tooltip title="复制自动直充 H5 领取链接">
<Button
@@ -49,6 +49,15 @@ export function createAdminManualExpense(payload: Record<string, unknown>) {
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
}
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
return apiPost<{
record: AdminManualExpense
effective: boolean
reason: 'approved' | 'pending_review' | 'approval_failed'
message?: string
}>('/api/v1/admin/finance/manual-order-costs', payload)
}
export function approveAdminManualExpense(
expenseId: number,
payload: Record<string, unknown> = {},