Files
order_site/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql
T
yml2213 7542d547bf feat: 新增客服备用金台账与后台用户中文显示名
- 新增备用金账户、流水、支出、对账与权限
- 后台用户支持中文显示名并同步备用金姓名
- 审计、登录记录、任务及工单操作人优先展示中文名
- 补充备用金与人工支出方案文档
2026-10-18 13:55:24 +08:00

261 lines
15 KiB
SQL
Raw 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.
-- ============================================================================
-- 人工支出登记 + 轻量人工处理记录(建表草案)
-- ----------------------------------------------------------------------------
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
-- 依赖:本文引用 product_catalog / cost_items / cost_snapshots,
-- 必须**在** `2026-10-17-成本中心与销售事实-DDL草案.sql` **之后**应用。
-- 接入时应拆成 `104_manual_cases.sql`、`105_manual_expenses.sql`,并先跑 `npm run check:sql`。
-- 基线:main @ 801bfa0b;依据:2026-10-17 业务口径确认(见 docs/载体判定与成本中心落地-复核与草案-2026-10-17.md §九)。
--
-- 业务原话(定优先级):
-- 「人工块现在意义不大,主要是把流程搞清楚:哪个人处理了哪些东西,付了多少钱。
-- 现在人工完全监管不到,很多地方需要人工付款,也不知道付款项目、金额、订单号啥的。」
--
-- 因此本草案的顺序是:
-- ① 人工支出可追溯(付款项目 / 金额 / 订单号 / 谁付的 / 凭证)——**核心诉求**
-- ② 处理人可见(谁在处理哪个订单项)——用轻量 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,
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_expenses_expense_no ON manual_expense_records (expense_no);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order ON manual_expense_records (order_id);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_item ON manual_expense_records (order_item_id);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_case ON manual_expense_records (case_id);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_paid_at ON manual_expense_records (paid_at DESC);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_status ON manual_expense_records (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_cost_item ON manual_expense_records (cost_item_id);
-- 付款人日结对账用
CREATE INDEX IF NOT EXISTS idx_manual_expenses_payer ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
-- 批量归组查询用
CREATE INDEX IF NOT EXISTS idx_manual_expenses_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_expenses_amount') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_amount CHECK (amount_fen <> 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_status') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_status
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_payee_type') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_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_expenses_method') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_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_expenses_category') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_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_expenses_approved') THEN
ALTER TABLE manual_expense_records
ADD CONSTRAINT chk_manual_expenses_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 '人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证,解决人工付款不可追溯';
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):
-- 借:5101 履约成本-人工(或按 cost_items 映射的科目) A
-- 贷:2005 其他应付-垫付人 A
-- reimbursed 时:
-- 借:2005 其他应付-垫付人 A
-- 贷:1001 银行存款 / 1002 支付通道待结算 A
--
-- 退款/仲裁仍未接入:本轮只保留 reversal_of_id 与 status='reversed',
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
-- ============================================================================