feat(finance): 订单页人工录单按单实际金额计入成本
This commit is contained in:
@@ -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> = {},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# order_site 文档索引
|
||||
|
||||
> 最后校准:2026-10-22,代码基线 `dev @ 15989af8 + 财务 103–111`。
|
||||
> 最后校准:2026-10-22,代码基线 `dev @ 7c883207 + 财务 103–112`。
|
||||
> 本文是 `docs/` 的唯一入口。新增文档必须在上表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
|
||||
|
||||
## 一、先记住五条口径
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# 人工支出登记落地方案(2026-10-18 修订)
|
||||
|
||||
> 状态:**已实施 P0(`dev` 分支,迁移 111)**
|
||||
> 状态:**已实施 P0(`dev` 分支,迁移 111;订单页人工录单为 2026-10-22 增量)**
|
||||
> 最后校准:2026-10-22
|
||||
> 代码基线:`dev @ 15989af8 + 财务 108/109/110/111`
|
||||
> 代码基线:`dev @ 7c883207 + 财务 108–112`
|
||||
> 依据:业务口径「人工块当前不以 SLA 监管为目标,先把流程搞清楚:哪个客服处理了哪些东西、付了多少钱、什么项目、关联哪个订单」。
|
||||
> 数据来源:本地只读克隆(生产快照,数据截至 2026-10-14)+ 现有代码盘点。
|
||||
> 重要:**客服备用金支出已经实施**(`manual_expenses`);**公司人工支出登记**(`manual_expense_records`)也已实施,两张表通过 `fund_expense_id` 关联,不是同一张表。
|
||||
> 重要:人工成本**逐单、按实际金额**录入,入口在订单界面;**不按小时,也不按固定单量/固定单价**。
|
||||
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) ·
|
||||
> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)
|
||||
|
||||
@@ -22,16 +23,17 @@
|
||||
|
||||
1. 客服侧已经收口的是“备用金余额与用途”,解决“客服手里有多少钱、这笔钱花到哪”。
|
||||
2. **公司口径的实际支出/垫付已落地**:后台「人工支出」页(`/admin/manual-expenses`)支持登记、审批、驳回、报销;通过后按成本项写 `cost_snapshots`,没有有效成本版本时进入异常队列。
|
||||
3. 两张表通过 `fund_expense_id` 关联:如果这笔公司支出来源于客服备用金,就填;公司直付则留空。
|
||||
4. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
|
||||
3. 订单界面已接入「人工录单」:订单列表对人工单显示按钮、订单详情始终可录,弹窗锁定订单号并逐单填写**实际金额**,不存在小时单价或固定单量计价。
|
||||
4. 两张表通过 `fund_expense_id` 关联:如果这笔公司支出来源于客服备用金,就填;公司直付则留空。
|
||||
5. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
|
||||
|
||||
实现落点(2026-10-22):
|
||||
|
||||
- 迁移:`apps/backend/src/db/migrations/111_manual_expense_records.sql`
|
||||
- 服务:`apps/backend/src/services/finance/manual-expense-service.ts`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `/finance/manual-expenses*`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `/finance/manual-expenses*`;订单页专用 `POST /finance/manual-order-costs`
|
||||
- 页面:`apps/frontend/src/pages/admin/AdminManualExpensePage.tsx`,入口 `/admin/manual-expenses`
|
||||
- 订单入口:订单详情「登记人工支出」按钮,自动带入订单 ID
|
||||
- 订单入口:订单列表/详情「人工录单」打开 `ManualOrderCostModal`,锁定订单号、选择订单项、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
|
||||
- 权限:查看 `finance.view` / `fund.view`,登记 `fund.expense` / `finance.config`,审核 `finance.review`
|
||||
- 成本快照:通过后以 `subject_type='manual_case'`、`subject_id=expense.id` 写 `actual` 快照;同一订单项多笔支出可累加,不会互相覆盖
|
||||
|
||||
@@ -130,6 +132,9 @@
|
||||
|
||||
无订单支出**必须单独可见**(财务页筛选项 + 异常队列统计),不能混进订单支出里被平均掉。
|
||||
|
||||
> **人工成本计价口径(2026-10-22 确认)**:人工单**逐单、按实际金额**登记,**不按小时、不按固定单量/固定单价**——每一单的实际付款都可能不同。
|
||||
> 订单号是必填前置条件;同一订单项的多笔实际支出逐笔累加,未录入即保持 `cost_missing`,不按均价自动补齐。
|
||||
|
||||
### 3.3 一对多关系(必须允许)
|
||||
|
||||
- **一个订单项可以有多笔支出**:渠道费 + 加急费 + 补差价 → 逐笔登记,汇总到订单损益。
|
||||
@@ -140,15 +145,17 @@
|
||||
|
||||
## 四、交互落地(三个入口,一套数据)
|
||||
|
||||
### 4.1 订单列表(就是截图这一页)
|
||||
### 4.1 订单列表(就是截图这一页)— 已落地
|
||||
|
||||
- **行操作**加一个「登记支出」按钮(放在「详情」旁边;现在该位置只在自动直发时显示 H5 链接,不冲突)。
|
||||
- **新增一列/一个标签**:本单已登记支出合计(无则留空)。让主管扫一眼就能看出哪些单垫了钱、垫了多少。
|
||||
- **筛选条件**增加:`有/无支出登记`、`支出待审批` —— 这是主管抽查与财务对账的入口。
|
||||
- 行操作已加「人工录单」按钮(与「详情」「H5」并列),只对人工单显示;点击跳转 `/admin/orders/:id?manualCost=1` 并自动打开录单弹窗。
|
||||
- 本单已登记支出合计列尚未做;后续可加,让主管扫一眼看出哪些单垫了钱。
|
||||
- 筛选条件「有/无支出登记」「支出待审批」尚未做,是后续主管抽查与财务对账的入口。
|
||||
|
||||
### 4.2 订单详情页
|
||||
### 4.2 订单详情页 — 已落地「人工录单」弹窗
|
||||
|
||||
`AdminOrderDetailPage.tsx` 现有四张卡片:基础信息 / 商品明细 / 任务 / 原始载荷。建议在**商品明细与任务之间**插入:
|
||||
`AdminOrderDetailPage.tsx` 在操作区提供「人工录单」按钮,弹窗内锁定订单号、选择订单项(单订单项自动带出)、填写本单实际金额,可当场生效或提交财务审核。
|
||||
|
||||
订单详情后续可加一张汇总卡片(当前已能在弹窗内录入,卡片汇总尚未做):
|
||||
|
||||
```text
|
||||
┌ 人工支出 ────────────────────────────────┐
|
||||
@@ -158,7 +165,7 @@
|
||||
│ ├────────┼──────┼────────┼──────┼──────┤ │
|
||||
│ │上游拿货 │¥8.50 │微信-xx │客服A │待审 │ │
|
||||
│ └────────┴──────┴────────┴──────┴──────┘ │
|
||||
│ [+ 登记支出] │
|
||||
│ [+ 人工录单] │
|
||||
│ 毛利预览:收入 ¥10.30 − 标准成本 ¥0 − 支出 ¥8.50 = ¥1.80 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
@@ -288,6 +295,7 @@ reversed(预留) 反向登记 + 反向凭证,原凭证不动
|
||||
| 9 | 客服备用金支出是否自动生成公司人工支出? | **不自动**;提供“从备用金支出带入”,人工确认后生成,避免重复入账 |
|
||||
| 10 | 实际付款人必须等于登录人吗? | 允许代付;实际付款人必填,默认登录人,便于按人日结对账 |
|
||||
| 11 | `cost_item_id` 为空能否提交? | 可暂存/提交,但不得 `approved`;进“未归类”异常队列 |
|
||||
| 12 | 人工成本按单还是按小时? | **已确认(2026-10-22):逐单按实际金额**,不按小时、不按固定单量/固定单价;工时只作可选分析字段 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+22
-16
@@ -1,8 +1,8 @@
|
||||
# 成本中心与三块履约模型
|
||||
|
||||
> 状态:**现行(P0 已实施)**。商品主数据、渠道映射、成本中心、成本快照运行层、销售事实与公司人工支出登记已落地;轻量人工 case 与影子总账仍为后续方案。
|
||||
> 状态:**现行(P0 已实施)**。商品主数据、渠道映射、成本中心、成本快照运行层、销售事实与公司人工支出登记(含订单页逐单实际金额「人工录单」)已落地;轻量人工 case 与影子总账仍为后续方案。
|
||||
> 最后校准:2026-10-22
|
||||
> 代码基线:`dev @ 15989af8 + 财务 108/109/110/111`
|
||||
> 代码基线:`dev @ 7c883207 + 财务 108–112`
|
||||
> 数据基线:2026-10-17 生产库只读聚合(`default_transaction_read_only=on`,仅 SELECT,无写入)
|
||||
> 实施顺序以 [README.md](README.md) §三 为准;本文负责“商品、成本、销售事实”的数据模型,不重复总账与页面设计。
|
||||
> 关联:
|
||||
@@ -56,7 +56,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| ① 自动发货 | `affiliate_dash` 履约任务 | 固定成本,按皮肤/SKU,很少变化 | 上游成本、店铺/日期/皮肤销量 |
|
||||
| ② 接单平台 | `work_orders` + 拼单份额 | 变动成本,按模板/单价/固定价/拼单 | 发单价格、打手提现、售后追缴、毛利 |
|
||||
| ③ 人工客服 | 目前**无载体** | 人工工时/按单服务成本 | 谁处理、何时完成、证据、成本、异常 |
|
||||
| ③ 人工客服 | 目前**无载体** | 逐单**实际金额**(不按工时、不按固定单量) | 谁处理、何时完成、证据、成本、异常 |
|
||||
|
||||
硬不变式:
|
||||
|
||||
@@ -213,10 +213,10 @@ channel_product_mappings
|
||||
|
||||
| 成本 | 当前来源 | 问题 |
|
||||
| --- | --- | --- |
|
||||
| 自动发货采购成本 | `affiliate-dash` 商品接口 `cost_amount` | 代码已映射,但没有落库/展示/快照 |
|
||||
| 自动发货采购成本 | `affiliate-dash` 商品接口 `cost_amount` | 已落库:订单项按 `aff:<skuCode>` 归一,写 `estimated` 快照并在销售事实/成本版本页展示(2026-10-22) |
|
||||
| 工单报酬 | `work_product_rules.reward_amount` / `unit_price_fen` / `sharing_unit_reward` | 价格与匹配规则混在一张表,改价无版本 |
|
||||
| 工单实际成本 | `work_orders.reward_amount`、`share_reward` | 实际值有了,但没有统一成本项/版本 |
|
||||
| 人工成本 | 无 | 完全缺失 |
|
||||
| 人工成本 | 订单界面「人工录单」→ `manual_expense_records` + `sys_manual_service` 快照 | 已落地逐单实际金额;**不按小时、不按固定单量/固定单价**;未录入的订单项留在成本缺失队列 |
|
||||
| 平台费/支付费/售后损失 | 分散或未建模 | 无法算真实毛利 |
|
||||
|
||||
### 4.2 成本类型
|
||||
@@ -225,7 +225,7 @@ channel_product_mappings
|
||||
| --- | --- | --- |
|
||||
| `auto_purchase` | 自动发货上游采购成本 | 按件 |
|
||||
| `work_order_reward` | 接单平台打手报酬 | 按件/按单/按份 |
|
||||
| `manual_service` | 人工客服成本 | 按单/按小时 |
|
||||
| `manual_service` | 人工客服成本 | 逐单实际金额(不按工时) |
|
||||
| `platform_fee` | 平台服务费/佣金 | 按单/按比例 |
|
||||
| `payment_fee` | 支付通道费 | 按单/按比例 |
|
||||
| `after_sales_loss` | 售后损失/赔付 | 按单 |
|
||||
@@ -426,8 +426,9 @@ orders + order_items + channel_product_mappings + cost_snapshots
|
||||
> 因此本章顺序调整为:**① 人工支出登记 → ② 处理人可见(轻量 case) → ③ SLA 与监管指标(延后)**。
|
||||
> DDL 见 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)。
|
||||
>
|
||||
> **2026-10-18 进度**:客服备用金支出已实施(`fund.manual_expenses`),解决“客服手里有多少钱、这笔钱花到哪”;
|
||||
> 但公司口径的人工支出登记(`manual_expense_records`)仍未实施。后者通过 `fund_expense_id` 关联前者,不重复做客服余额系统。
|
||||
> **2026-10-22 进度**:客服备用金支出已实施(`fund.manual_expenses`),解决“客服手里有多少钱、这笔钱花到哪”;
|
||||
> 公司口径的人工支出登记(`manual_expense_records`)也已实施(迁移 `111`),并在订单界面提供「人工录单」逐单录入实际成本;
|
||||
> 后者通过 `fund_expense_id` 关联前者,不重复做客服余额系统。轻量人工 case 仍未实施。
|
||||
|
||||
### 8.1 问题本质
|
||||
|
||||
@@ -513,12 +514,17 @@ pending_assign -> assigned -> processing -> waiting_customer
|
||||
- 所有状态变更写 `manual_case_events`,不可改;
|
||||
- 客服不能私下改价/退款,必须走审批。
|
||||
|
||||
### 8.5 人工成本
|
||||
### 8.5 人工成本(已落地:逐单、按实际金额)
|
||||
|
||||
- 按单固定成本:简单,先落地;
|
||||
- 按小时:`agent_hourly_rate × (completed_at - started_at)`;
|
||||
- 建议两者都记录:固定服务成本 + 工时,便于后续校准;
|
||||
- 完成时写 `cost_snapshot`;缺失进入 `cost_missing`。
|
||||
> **业务口径(2026-10-22 确认)**:人工成本**不能按小时,也不能按固定单量/固定单价**——每一单的实际付款都不一样。
|
||||
> 必须**先关联订单号,再逐单录入这一单实际付出去的钱**。
|
||||
|
||||
- 入口在订单界面:订单列表对人工单显示「人工录单」,订单详情始终有「人工录单」;弹窗锁定订单号,订单项多于 1 个时由人工选择。
|
||||
- 接口 `POST /api/v1/admin/finance/manual-order-costs`:写入 `manual_expense_records`,成本项固定为 `sys_manual_service`,金额即本单实际支出。
|
||||
- 有审核权限(`admin` / `finance` / `finance.review`)且选择立即生效时,当场审批并写 `actual` 快照;否则留在 `submitted`,由财务在「人工支出」页审核。
|
||||
- 快照以 `subject_type='manual_case'`、`subject_id=expense.id` 写入并携带 `order_item_id`;销售事实按 `cost_snapshots.order_item_id` 汇总,同一订单项的多笔支出(渠道费 + 加急费 + 补差价)累加。
|
||||
- 工时只作为后续可选的分析字段,不参与成本计算;不设「按单固定单价」。
|
||||
- 未录入成本的订单项保持 `cost_missing`,进入成本异常队列,不默认记 0。
|
||||
|
||||
### 8.6 监管指标(阶段③,延后)
|
||||
|
||||
@@ -610,13 +616,13 @@ flowchart LR
|
||||
| 4 | 退款/仲裁怎么冲减? | **暂未接入,先预留**:字段、反向冲正关系与事件类型先留好,本轮不实现逻辑 | 业务确认 |
|
||||
| 5 | 收入确认时点? | **2026-10-22 确认:先按 `paid_at` 归属销售日**;成本在快照生成后回填到同一销售日,后续再评估验收/售后期口径 | 业务确认 |
|
||||
| 6 | 未归一商品怎么进报表? | **2026-10-22 确认:允许 `canonical_product_id` 为空,单独成“未归一”行**,保证总收入与订单总额对平 | 业务确认 |
|
||||
| 7 | 人工成本怎么归集? | **逐单、按实际金额**:关联订单号录入本单实际付款,**不按小时、不按固定单量/固定单价**;同一订单项多笔可累加 | 业务确认(2026-10-22) |
|
||||
|
||||
### 11.2 仍待确认
|
||||
|
||||
1. 自动发货的 `cost_amount` 是否稳定、是否含平台积分/汇率?
|
||||
2. 人工支出按单还是按小时归集?(先按单登记,工时字段后续再加)
|
||||
3. 平台服务费/支付通道费由谁承担、结算周期与账单口径?
|
||||
4. 寄卖货款的采购成本确认时点?
|
||||
2. 平台服务费/支付通道费由谁承担、结算周期与账单口径?
|
||||
3. 寄卖货款的采购成本确认时点?
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user