迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
273 lines
16 KiB
SQL
273 lines
16 KiB
SQL
-- ============================================================================
|
||
-- 人工支出登记 + 轻量人工处理记录(建表草案)
|
||
-- ----------------------------------------------------------------------------
|
||
-- 状态:已实施 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',
|
||
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
|
||
-- ============================================================================
|