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> = {},
+1 -1
View File
@@ -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
View File
@@ -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. 寄卖货款的采购成本确认时点?
---