docs: 落定业务口径并补充人工支出登记草案

口径确认(2026-10-17):
- 收入 = 用户实付(orders.total_amount),已核验与快手 paymentFen 100% 一致
- 打手报酬 = 支出/成本(最终提现打款),按总额法
- 人工块第一优先级改为「人工支出可追溯」,SLA 监管延后
- 退款/仲裁暂未接入,先预留字段与冲正关系

变更:
- 新增 docs/drafts/2026-10-17-人工支出登记-DDL草案.sql(manual_fulfillment_cases / manual_expense_records / manual_expense_events)
- 成本中心与销售事实-DDL草案.sql:revenue_basis 改为 user_payment,补预留说明与已确认口径
- 成本中心与三块履约模型.md:§8 顺序调整、§11 改为口径确认结果、§12 改为本地克隆基线
- 财务系统集成方案.md:拍板清单加状态,分录表补人工垫付与预留冲正
- 项目全量分析报告与本轮报告同步口径,docs/README.md 登记新草案
This commit is contained in:
yml2213
2026-10-17 21:57:34 +08:00
parent 801bfa0ba0
commit e3afed28d2
7 changed files with 365 additions and 52 deletions
@@ -0,0 +1,255 @@
-- ============================================================================
-- 人工支出登记 + 轻量人工处理记录(建表草案)
-- ----------------------------------------------------------------------------
-- 状态:草案(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,
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);
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',
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
-- ============================================================================
@@ -254,7 +254,15 @@ COMMENT ON TABLE cost_exceptions IS '成本缺失/冲突异常队列:报表必
-- 且 quantity 不可信(316,585 项里 316,578 项 = 1,名称带「N个」的有 104,710 项)。
-- 现状利好:目前每笔已支付订单恰好 1 个订单项(316,585 单 = 316,585 项),
-- 所以 orders.total_amount 暂时可当订单项收入;但 schema 允许多项,属**潜在**缺口。
-- 收入口径实测差异:91 = maxAmount、PDD = order_amount、抖音 = pay_amount,不能直接相加。
--
-- 【口径已确认 2026-10-17】收入 = 用户付款金额,取 orders.total_amount:
-- · 91:total_amount 即用户付款(与快手发码配合)。数据核验:91 已支付 312,569 笔中
-- 235,271 笔含快手 kuaishouSendCode.paymentFen,其中 235,268 笔与 total_amount **完全一致**
-- (100.00%),仅 3 笔差 ¥1.80~2.00(2026-09-05 / 09-18 / 10-04,可单独抽查)。
-- · PDD:order_amount;抖音:pay_amount。三者同为“用户实付”,故统一 revenue_basis = 'user_payment'。
-- · 打手报酬 = **支出/成本**(最终由打手提现、我方付款),因此按**总额法**:
-- 收入记用户实付全额,打手报酬作为履约成本,不做代收代付净额。
-- · 退款/仲裁**暂未接入**:refunded_amount_fen / refunded_at 为**预留字段**,本轮不写逻辑。
-- ============================================================================
ALTER TABLE order_items
@@ -270,7 +278,7 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_basis') THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_revenue_basis
CHECK (revenue_basis IN ('', 'ninety_one_max_amount', 'pdd_order_amount', 'douyin_pay_amount', 'manual'));
CHECK (revenue_basis IN ('', 'user_payment', 'manual_adjustment'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_amount') THEN
ALTER TABLE order_items ADD CONSTRAINT chk_order_items_revenue_amount CHECK (revenue_amount_fen IS NULL OR revenue_amount_fen >= 0);
@@ -280,8 +288,8 @@ END $$;
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product ON order_items (canonical_product_id);
COMMENT ON COLUMN order_items.normalized_quantity IS '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量';
COMMENT ON COLUMN order_items.revenue_amount_fen IS '统一收入口径(分),必须同时写 revenue_basis 说明来源';
COMMENT ON COLUMN order_items.refunded_at IS '退款时间:按日净销售额必需,当前 orders 只有 pay_status=refunded 与 updated_at';
COMMENT ON COLUMN order_items.revenue_amount_fen IS '收入(分)= 用户实付,取 orders.total_amount;revenue_basis 通常为 user_payment';
COMMENT ON COLUMN order_items.refunded_at IS '【预留】退款时间:退款/仲裁暂未接入,本轮不写逻辑;接入后按日净销售额 = 销售额 - 退款额';
-- 过渡:让工单规则逐步改用成本中心(有 cost_item_id 走成本中心,否则回退旧金额字段)
ALTER TABLE work_product_rules
@@ -292,6 +300,8 @@ CREATE INDEX IF NOT EXISTS idx_work_product_rules_cost_item ON work_product_rule
COMMENT ON COLUMN work_product_rules.cost_item_id IS '迁移用过渡列:非空则金额取成本中心,空则回退 reward_amount/unit_price_fen';
COMMENT ON COLUMN work_product_rules.legacy_cost_source IS '回退来源标记:rule_amount=旧规则金额,便于统计未迁移规则数';
-- 口径已确认(2026-10-17):打手报酬是**支出**(工单结算 -> 可提现 -> 提现打款),
-- 按总额法计入履约成本,与用户实付收入分开记,不做代收代付净额。
-- ============================================================================
@@ -375,9 +385,16 @@ COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS '成本缺失件数:
-- 4) §4 销售事实表 -> 店铺/日期/皮肤 的销量、收入、成本、毛利
-- 5) 之后才是影子总账(docs/财务系统集成方案.md)
--
-- 待业务确认(阻塞项):
-- a) 91 的 total_amount(maxAmount)到底是收入还是结算上限?
-- b) 打手报酬是成本还是代收代付?
-- c) 人工块按单成本取值,以及是否需要按工时核算?
-- d) 退款/仲裁的冲减规则与归期口径。
-- 【口径已确认 2026-10-17】
-- a) 收入 = 用户付款金额(orders.total_amount),计收入;已用数据核验与快手 paymentFen 100% 对齐。
-- b) 打手报酬 = 支出/成本(最终提现打款),按总额法。
-- c) 人工块:第一优先级是**人工支出可追溯**(付款项目/金额/订单号/付款人/凭证),
-- 见 `2026-10-17-人工支出登记-DDL草案.sql`;按单还是按小时的取值口径仍待定,先按单登记。
-- d) 退款/仲裁:**暂未接入,先预留**(字段 + reversal_of_id + 事件类型,不实现逻辑)。
--
-- 【仍待确认】
-- 1) 收入确认时点(付款 / 发货 / 验收 / 售后期结束)——影响利润表与预收账款;
-- 2) 自动发货 cost_amount 是否稳定、是否含平台积分/汇率;
-- 3) 平台服务费/支付通道费由谁承担、结算周期与账单口径;
-- 4) 寄卖货款的采购成本确认时点。
-- ============================================================================