From 74b62f34946cfdcd10f684eeb50e428efb7ae0b3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 22 Oct 2026 19:48:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E8=AE=A2=E5=8D=95=E9=A1=B5?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=BD=95=E5=8D=95=E6=8C=89=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E9=87=91=E9=A2=9D=E8=AE=A1=E5=85=A5=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/admin/finance.ts | 28 +++ .../finance/manual-expense-service.ts | 100 ++++++++ .../services/finance/sales-facts-service.ts | 3 +- .../components/admin/ManualOrderCostModal.tsx | 233 ++++++++++++++++++ .../src/pages/admin/AdminOrderDetailPage.tsx | 42 +++- .../src/pages/admin/AdminOrdersPage.tsx | 16 +- .../src/services/admin/manual-expense.ts | 9 + docs/README.md | 2 +- docs/人工支出登记落地方案-2026-10-17.md | 34 ++- docs/成本中心与三块履约模型.md | 38 +-- 10 files changed, 465 insertions(+), 40 deletions(-) create mode 100644 apps/frontend/src/components/admin/ManualOrderCostModal.tsx diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index 6e2b7473..959a9d3a 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -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']), diff --git a/apps/backend/src/services/finance/manual-expense-service.ts b/apps/backend/src/services/finance/manual-expense-service.ts index 019a5c82..68124b7c 100644 --- a/apps/backend/src/services/finance/manual-expense-service.ts +++ b/apps/backend/src/services/finance/manual-expense-service.ts @@ -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, diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts index 965d92c1..a8d5574a 100644 --- a/apps/backend/src/services/finance/sales-facts-service.ts +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -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') diff --git a/apps/frontend/src/components/admin/ManualOrderCostModal.tsx b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx new file mode 100644 index 00000000..8737459c --- /dev/null +++ b/apps/frontend/src/components/admin/ManualOrderCostModal.tsx @@ -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() + 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 ( + void submit()} + onCancel={onClose} + destroyOnHidden + width={620} + > + + form={form as FormInstance} + layout="vertical" + initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }} + > + + + + + {items.length === 1 ? ( + <> + + + + + + ) : ( + + + + + + + + + + + + + {canApprove ? ( + + + + ) : ( + + )} + + + ) +} diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index c132b889..13171fb8 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -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={ - {isAutoClaimOrder ? ( <> @@ -368,6 +383,19 @@ export default function AdminOrderDetailPage() { /> + + ({ + orderItemId: item.orderItemId, + itemTitle: item.itemTitle, + skuNick: item.skuNick, + quantity: item.quantity, + }))} + onClose={closeManualCost} + /> ) } diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index ac07cbe1..78bfb5bc 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -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 ( @@ -350,6 +353,17 @@ export default function AdminOrdersPage() { > 详情 + {isManualOrder ? ( + + + + ) : null} {row.canClaimH5 ? (