Files
order_site/docs/archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql
yml2213andClaude Opus 4.5 fb679d76f6 refactor(finance): 人工成本收敛为「人工录单 → 扣备用金 → 写快照」
公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。

口径调整(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>
2026-10-24 11:31:29 +08:00

289 lines
17 KiB
SQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- 【已作废】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',
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
-- ============================================================================