公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。
口径调整(2026-10-23 确认):
- 历史人工支出找不回来,直接放弃:不补录、不做样本校准、不做均价估算,
只保证上线后的新单不丢。
- 人工成本逐单按实际金额,从客服备用金扣款,客服只能用本人账户;
没有登记单、没有审批、没有报销,录入即计入毛利。
后端:
- 新增 services/finance/manual-order-cost-service.ts:扣备用金 + 写 actual
成本快照,同一事务,余额不足直接拒绝不允许负余额。
- 成本快照主体从「人工支出登记单 id」改为「客服备用金支出单 id」
(subject_type='manual_case',subject_id=manual_expenses.id)。
- 删除 services/finance/manual-expense-service.ts(1263 行)与
routes/admin/finance.ts 下 /finance/manual-expenses* 全套接口。
前端:
- 删除 AdminManualExpensePage.tsx(778 行)与 /admin/manual-expenses 菜单页。
- ManualOrderCostModal 去掉「提交审核 / 立即计入成本」开关,录入即生效。
- 新增 services/admin/manual-order-cost.ts。
迁移:
- 106_manual_expense_records.sql → 106_fund_account_no_readable.sql,
只保留备用金账户编号可读化。
- 108_drop_manual_expense_records.sql:删除 manual_expense_records 与
manual_expense_events 两张表。
- 109_isolate_legacy_manual_snapshots.sql:把来源为旧登记链的历史孤儿快照
subject_id 改为负数,与 manual_expenses.id 彻底隔离。
踩坑修复(E2E 实测发现):主体 id 换空间后与历史孤儿快照撞唯一索引
uq_cost_snapshots_active_subject_item,INSERT ... ON CONFLICT DO NOTHING
静默跳过,导致「钱从备用金扣了、成本快照却没写成」。修法是迁移 109 隔离
legacy 主体 + 后端快照写失败必须抛错回滚(409),杜绝账实不符。
文档:现行 5 篇 + README 基线校准为 dev @ 43f402cf + 迁移 103–109,
修正已删除的旧迁移号,删除「人工支出登记 + 审批 + 报销」口径,
已作废 DDL 草案移入 docs/archive/2026-10-23/。
校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测两笔同订单项各自独立主体、余额扣减与快照金额一一对应;
本地库迁移 108/109 执行后数据量与改造前完全一致。
Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
289 lines
17 KiB
SQL
289 lines
17 KiB
SQL
-- ============================================================================
|
||
-- 【已作废】2026-10-23
|
||
--
|
||
-- 本草案设计的 `manual_expense_records`(公司人工支出登记单)与 `manual_expense_events`
|
||
-- (审批报销状态机)已随迁移 108_drop_manual_expense_records.sql 删除。
|
||
--
|
||
-- 现行口径:人工成本只有一条链路 ——
|
||
-- 订单页「人工录单」→ 扣客服备用金(manual_expenses)→ 写 cost_snapshots
|
||
-- (subject_type='manual_case',subject_id=manual_expenses.id)
|
||
-- 没有独立的登记单,也没有审批与报销环节。
|
||
--
|
||
-- 本文件仅作设计追溯,禁止按此实现。当前口径见:
|
||
-- docs/人工支出与备用金.md
|
||
-- docs/财务与履约口径总纲-2026-10-23.md
|
||
-- ============================================================================
|
||
|
||
-- ============================================================================
|
||
-- 人工支出登记 + 轻量人工处理记录(建表草案)
|
||
-- ----------------------------------------------------------------------------
|
||
-- 状态:已实施 P0。`manual_expense_records` / `manual_expense_events` 已接入
|
||
-- `111_manual_expense_records.sql`;`manual_fulfillment_cases` 仍未实施。
|
||
-- 依赖:product_catalog / cost_items / cost_snapshots / manual_expenses。
|
||
-- 当前已使用迁移至 111(成本中心 103–108,系统成本项 110,人工支出 111)。
|
||
-- 基线:main @ 7542d547;依据:2026-10-17 业务口径确认 + 2026-10-18 备用金落地复核。
|
||
--
|
||
-- 重要命名边界:
|
||
-- * `manual_expenses`(迁移 100)= 客服备用金支出明细,已有索引/约束名 `*_manual_expenses_*`;
|
||
-- * `manual_expense_records`(本草案)= 公司人工支出/垫付登记;
|
||
-- * 本文件所有索引和约束必须使用 `*_manual_expense_records_*`,禁止复用 `manual_expenses` 的名字。
|
||
-- * 两者通过 `manual_expense_records.fund_expense_id` 关联,不能合并成一张表。
|
||
--
|
||
-- 业务原话(定优先级):
|
||
-- 「人工块现在意义不大,主要是把流程搞清楚:哪个人处理了哪些东西,付了多少钱。
|
||
-- 现在人工完全监管不到,很多地方需要人工付款,也不知道付款项目、金额、订单号啥的。」
|
||
--
|
||
-- 因此本草案的顺序是:
|
||
-- ① 人工支出可追溯(付款项目 / 金额 / 订单号 / 谁付的 / 凭证)——**核心诉求**
|
||
-- ② 处理人可见(谁在处理哪个订单项)——用轻量 case 承载
|
||
-- ③ SLA / 超时升级 / 监管指标——**延后**,不进本轮最小实现
|
||
-- ============================================================================
|
||
|
||
|
||
-- ============================================================================
|
||
-- §1 轻量人工处理记录(谁在处理哪些东西)
|
||
-- ----------------------------------------------------------------------------
|
||
-- 与 docs/成本中心与三块履约模型.md §8.3 的差异:去掉 due_at/SLA 强约束与优先级体系,
|
||
-- 只保留“谁、什么时候、处理了哪个订单项、结果是什么”。SLA 字段等 ③ 阶段再加。
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE IF NOT EXISTS manual_fulfillment_cases (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
case_no TEXT NOT NULL,
|
||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE CASCADE,
|
||
provider TEXT NOT NULL DEFAULT '',
|
||
shop_id TEXT NOT NULL DEFAULT '',
|
||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||
quantity INTEGER NOT NULL DEFAULT 1,
|
||
status TEXT NOT NULL DEFAULT 'pending_assign',
|
||
assignee_admin_user_id BIGINT,
|
||
assignee_name TEXT NOT NULL DEFAULT '',
|
||
assigned_at TIMESTAMPTZ,
|
||
started_at TIMESTAMPTZ,
|
||
completed_at TIMESTAMPTZ,
|
||
outcome TEXT NOT NULL DEFAULT '',
|
||
delivery_reference TEXT NOT NULL DEFAULT '',
|
||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
note TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_cases_case_no ON manual_fulfillment_cases (case_no);
|
||
-- 一个订单项最多一个 case:与「一个订单项只有一个执行载体」保持一致
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_cases_order_item
|
||
ON manual_fulfillment_cases (order_item_id) WHERE order_item_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_manual_cases_status ON manual_fulfillment_cases (status, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_cases_assignee ON manual_fulfillment_cases (assignee_admin_user_id, status);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_cases_order ON manual_fulfillment_cases (order_id);
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_cases_status') THEN
|
||
ALTER TABLE manual_fulfillment_cases
|
||
ADD CONSTRAINT chk_manual_cases_status
|
||
CHECK (status IN ('pending_assign', 'assigned', 'processing', 'waiting_customer',
|
||
'pending_review', 'completed', 'failed', 'cancelled'));
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_cases_outcome') THEN
|
||
ALTER TABLE manual_fulfillment_cases
|
||
ADD CONSTRAINT chk_manual_cases_outcome
|
||
CHECK (outcome IN ('', 'delivered', 'refunded', 'failed', 'cancelled'));
|
||
END IF;
|
||
-- 完成必须有结果;避免“点完成但什么都没填”
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_cases_completed') THEN
|
||
ALTER TABLE manual_fulfillment_cases
|
||
ADD CONSTRAINT chk_manual_cases_completed
|
||
CHECK (status <> 'completed' OR (completed_at IS NOT NULL AND outcome <> ''));
|
||
END IF;
|
||
END $$;
|
||
|
||
COMMENT ON TABLE manual_fulfillment_cases IS '轻量人工处理记录:谁在处理哪个订单项、结果如何(SLA 阶段延后)';
|
||
COMMENT ON COLUMN manual_fulfillment_cases.evidence_json IS '交付凭证:截图对象 key、参考号等;完成后必填(由服务层校验)';
|
||
|
||
|
||
-- ============================================================================
|
||
-- §2 人工支出登记(核心诉求)
|
||
-- ----------------------------------------------------------------------------
|
||
-- 必须能回答:这笔钱是什么项目、多少钱、关联哪个订单/订单项、谁付的、付给谁、什么时候、凭证在哪。
|
||
-- 与成本中心的关系:
|
||
-- cost_item_id = “付款项目”,指向 cost_items(cost_type = 'manual_service' 或其它)
|
||
-- approved 后生成 cost_snapshots(subject_type = 'manual_case' 或 'order_item',source = 'manual_input')
|
||
-- 影子总账期:登记 approved 即产生一条业务事件,进复式分录(借:履约成本/费用,贷:其他应付/银行存款)
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
expense_no TEXT NOT NULL,
|
||
case_id BIGINT REFERENCES manual_fulfillment_cases(id) ON DELETE SET NULL,
|
||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||
-- 可选:这笔公司支出来源于客服备用金支出时填写;公司直付留空
|
||
fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL,
|
||
cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
|
||
expense_category TEXT NOT NULL DEFAULT 'other',
|
||
amount_fen BIGINT NOT NULL,
|
||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||
paid_by_admin_user_id BIGINT,
|
||
paid_by_name TEXT NOT NULL DEFAULT '',
|
||
payee_type TEXT NOT NULL DEFAULT 'other',
|
||
payee_name TEXT NOT NULL DEFAULT '',
|
||
payee_account TEXT NOT NULL DEFAULT '',
|
||
payment_method TEXT NOT NULL DEFAULT 'other',
|
||
paid_at TIMESTAMPTZ NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'submitted',
|
||
approved_by TEXT NOT NULL DEFAULT '',
|
||
approved_at TIMESTAMPTZ,
|
||
reject_reason TEXT NOT NULL DEFAULT '',
|
||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
cost_snapshot_id BIGINT REFERENCES cost_snapshots(id) ON DELETE RESTRICT,
|
||
-- 预留:退款/仲裁接入后用反向登记冲正,不修改已通过的原始记录
|
||
reversal_of_id BIGINT REFERENCES manual_expense_records(id) ON DELETE RESTRICT,
|
||
-- 可选:一笔转账覆盖多个订单时用于归组(拆成多条明细,同一 batch)
|
||
expense_batch_no TEXT NOT NULL DEFAULT '',
|
||
note TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_expense_no ON manual_expense_records (expense_no);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_order ON manual_expense_records (order_id);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_item ON manual_expense_records (order_item_id);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_case ON manual_expense_records (case_id);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_paid_at ON manual_expense_records (paid_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_status ON manual_expense_records (status, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_cost_item ON manual_expense_records (cost_item_id);
|
||
-- 同一笔客服备用金支出最多生成一条公司人工支出,避免重复入账
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_fund_expense
|
||
ON manual_expense_records (fund_expense_id) WHERE fund_expense_id IS NOT NULL;
|
||
-- 付款人日结对账用
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_payer ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||
-- 批量归组查询用
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_batch
|
||
ON manual_expense_records (expense_batch_no) WHERE expense_batch_no <> '';
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_amount') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_amount CHECK (amount_fen <> 0);
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_status') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_status
|
||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_payee_type') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_payee_type
|
||
CHECK (payee_type IN ('worker', 'upstream', 'customer', 'platform', 'other'));
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_method') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_method
|
||
CHECK (payment_method IN ('wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'));
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_category') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_category
|
||
CHECK (expense_category IN ('goods_purchase', 'compensation', 'refund_advance', 'service_fee',
|
||
'freight', 'recharge', 'other'));
|
||
END IF;
|
||
-- 已通过/已报销必须有审批信息
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_approved') THEN
|
||
ALTER TABLE manual_expense_records
|
||
ADD CONSTRAINT chk_manual_expense_records_approved
|
||
CHECK (status NOT IN ('approved', 'reimbursed') OR (approved_at IS NOT NULL AND approved_by <> ''));
|
||
END IF;
|
||
END $$;
|
||
|
||
COMMENT ON TABLE manual_expense_records IS '公司人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证;与客服备用金 manual_expenses 通过 fund_expense_id 关联';
|
||
COMMENT ON COLUMN manual_expense_records.fund_expense_id IS '来源客服备用金支出(manual_expenses.id);公司直付为空';
|
||
COMMENT ON COLUMN manual_expense_records.cost_item_id IS '付款项目 -> cost_items;为空表示未归类,必须进异常队列而不是默认其他';
|
||
COMMENT ON COLUMN manual_expense_records.expense_category IS '兜底分类:cost_item_id 未填时的粗分类,报表需同时暴露未归类占比';
|
||
COMMENT ON COLUMN manual_expense_records.paid_by_admin_user_id IS '实际垫付/付款的后台用户;用于“谁付了多少钱”对账';
|
||
COMMENT ON COLUMN manual_expense_records.evidence_json IS '凭证:支付截图对象 key、流水号、聊天记录 key 等;提交时必填(服务层校验)';
|
||
COMMENT ON COLUMN manual_expense_records.reversal_of_id IS '预留:退款/仲裁接入后按反向登记冲正,禁止 UPDATE 已通过记录';
|
||
COMMENT ON COLUMN manual_expense_records.status IS 'draft草稿/submitted待审/approved已通过/rejected已驳回/reimbursed已报销/reversed已冲正';
|
||
|
||
-- 状态变更审计(不可改)
|
||
CREATE TABLE IF NOT EXISTS manual_expense_events (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
expense_id BIGINT NOT NULL REFERENCES manual_expense_records(id) ON DELETE CASCADE,
|
||
event_type TEXT NOT NULL,
|
||
from_status TEXT NOT NULL DEFAULT '',
|
||
to_status TEXT NOT NULL DEFAULT '',
|
||
actor_type TEXT NOT NULL DEFAULT 'admin',
|
||
actor_id TEXT NOT NULL DEFAULT '',
|
||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
created_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_expense
|
||
ON manual_expense_events (expense_id, created_at);
|
||
|
||
COMMENT ON TABLE manual_expense_events IS '人工支出状态变更审计:只追加,不更新、不删除';
|
||
|
||
|
||
-- ============================================================================
|
||
-- §3 校验与报表查询
|
||
-- ============================================================================
|
||
|
||
-- Q1 「谁付了多少钱」:按付款人 × 项目 汇总(日结/周结用)
|
||
-- SELECT r.paid_by_name, coalesce(ci.name, r.expense_category) AS item, count(*) AS records,
|
||
-- round(sum(r.amount_fen) / 100.0, 2) AS yuan
|
||
-- FROM manual_expense_records r
|
||
-- LEFT JOIN cost_items ci ON ci.id = r.cost_item_id
|
||
-- WHERE r.status IN ('approved', 'reimbursed') AND r.paid_at >= now() - interval '7 days'
|
||
-- GROUP BY 1, 2 ORDER BY sum(r.amount_fen) DESC;
|
||
|
||
-- Q2 未归类占比(必须持续为 0,否则报表口径不可信)
|
||
-- SELECT count(*) FILTER (WHERE cost_item_id IS NULL) AS unclassified,
|
||
-- count(*) AS total,
|
||
-- round(100.0 * count(*) FILTER (WHERE cost_item_id IS NULL) / nullif(count(*), 0), 2) AS unclassified_pct
|
||
-- FROM manual_expense_records WHERE status <> 'draft';
|
||
|
||
-- Q3 没有关联订单号的支出(你说的“也不知道订单号”,这里必须能点名)
|
||
-- SELECT count(*) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL) AS no_order_link,
|
||
-- round(sum(amount_fen) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL) / 100.0, 2) AS yuan
|
||
-- FROM manual_expense_records WHERE status IN ('approved', 'reimbursed');
|
||
|
||
-- Q4 订单损益:一个订单项的收入 - 各类成本(含人工支出)
|
||
-- SELECT oi.id AS order_item_id, o.provider, o.shop_id,
|
||
-- oi.revenue_amount_fen AS revenue_fen,
|
||
-- coalesce(sum(cs.total_amount_fen), 0) AS cost_fen,
|
||
-- coalesce(sum(r.amount_fen), 0) AS manual_expense_fen
|
||
-- FROM order_items oi
|
||
-- JOIN orders o ON o.id = oi.order_id
|
||
-- LEFT JOIN cost_snapshots cs ON cs.order_item_id = oi.id
|
||
-- LEFT JOIN manual_expense_records r ON r.order_item_id = oi.id AND r.status IN ('approved', 'reimbursed')
|
||
-- WHERE oi.id = $1
|
||
-- GROUP BY 1, 2, 3, 4;
|
||
|
||
-- Q5 与成本中心对账:approved 的支出必须都有 cost_snapshot(影子期允许延迟,转正期不允许缺)
|
||
-- SELECT count(*) AS approved_without_snapshot
|
||
-- FROM manual_expense_records
|
||
-- WHERE status IN ('approved', 'reimbursed') AND cost_snapshot_id IS NULL;
|
||
|
||
|
||
-- ============================================================================
|
||
-- §4 与财务系统(影子总账)的对接约定
|
||
-- ----------------------------------------------------------------------------
|
||
-- 事件类型(幂等键):
|
||
-- manual_expense:{expense_no}:approved 人工支出登记通过
|
||
-- manual_expense:{expense_no}:reimbursed 已报销(垫付 -> 公司支出)
|
||
-- manual_expense:{expense_no}:reversed 冲正(退款/仲裁接入后启用,本轮仅预留)
|
||
--
|
||
-- 建议分录(approved 时,金额 A):
|
||
-- 借:5102 履约成本-人工客服(或按 cost_items 映射的科目) A
|
||
-- 贷:2005 其他应付-垫付人 A
|
||
-- reimbursed 时:
|
||
-- 借:2005 其他应付-垫付人 A
|
||
-- 贷:1001 银行存款 / 1002 支付通道待结算 A
|
||
--
|
||
-- 退款/仲裁仍未接入:本轮只保留 reversal_of_id 与 status='reversed',
|
||
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
|
||
-- ============================================================================
|