docs: 重构项目文档并补充三块载体、成本中心与人工支出方案

合并本轮全部文档改动(原 5 个 docs 提交压成 1 个)。

口径与索引:
- 新增 docs/README.md 文档总索引:三来源口径(91 + 快手 send-code = kuaishou-91)、
  「一个订单 = 骨架 + 买家凭证 + 一个执行载体」、成本只改一处、文档状态机
- 过期阶段文档移入 docs/archive/2026-10-08/(保留内容,不删除)

分析报告:
- docs/项目全量分析报告-2026-10-17.md:架构、五条业务链路、P0/P1/P2 风险与改进路线
- docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:生产只读克隆量化、上一轮结论复核与 4 处修正

方案文档:
- docs/成本中心与三块履约模型.md:三块执行载体、标准商品、成本版本与快照、人工支出登记
- docs/财务系统集成方案.md:复式记账模型、分录表、分期落地
- docs/影子总账说明.md:影子期与转正期的边界、对账与转正标准

SQL 草案(docs/drafts/,未接入迁移):
- 载体判定与人工块量化.sql:量化查询、carrier_type 判定与回填、守恒校验
- 成本中心与销售事实-DDL草案.sql:product_catalog / channel_product_mappings /
  cost_items / cost_versions / cost_snapshots / cost_exceptions / sales_daily_facts
- 人工支出登记-DDL草案.sql:manual_fulfillment_cases / manual_expense_records / manual_expense_events

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

三份草案已在一次性沙箱库按依赖顺序零错误应用(10 张新表、27 个 CHECK 约束)。
This commit is contained in:
yml2213
2026-10-17 22:03:03 +08:00
parent 6850bc64ff
commit 7bacb7d5eb
31 changed files with 3015 additions and 83 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',
-- 不实现自动冲正逻辑;接入后按“反向登记 + 新分录”处理,禁止改动原记录。
-- ============================================================================
@@ -0,0 +1,400 @@
-- ============================================================================
-- 通用成本中心 + 标准商品 + 销售事实(建表草案)
-- ----------------------------------------------------------------------------
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
-- 接入时应拆成 `101_product_catalog.sql`、`102_cost_center.sql`、`103_sales_daily_facts.sql`,
-- 并先跑 `npm run check:sql`(SQL 防退化护栏)。
-- 基线:main @ ccd5ed2a;数据依据:本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14)。
-- 金额单位:分(BIGINT)。本文件所有 *_fen 列均为分。
-- 设计依据:docs/成本中心与三块履约模型.md(三块载体、成本版本与快照)、docs/财务系统集成方案.md。
--
-- 目标(业务原话):以后改成本只在一个地方。
-- -> 所有成本金额只写 cost_versions;业务表只保存 cost_snapshot_id 与派生金额,永不回写历史。
-- ============================================================================
-- ============================================================================
-- §1 标准商品主数据
-- ----------------------------------------------------------------------------
-- 为什么必须先做:同一个商品名对应多个 sku_code(实测「苏尔南钻石100颗」47 个码、
-- 「指挥官密钥1个」42 个码),直接按 sku_code 或 sku_name 统计都会出错。
-- ============================================================================
CREATE TABLE IF NOT EXISTS product_catalog (
id BIGSERIAL PRIMARY KEY,
canonical_sku TEXT NOT NULL,
canonical_name TEXT NOT NULL,
category_id BIGINT,
unit_name TEXT NOT NULL DEFAULT '件',
status TEXT NOT NULL DEFAULT 'active',
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku ON product_catalog (canonical_sku);
CREATE INDEX IF NOT EXISTS idx_product_catalog_category ON product_catalog (category_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_status CHECK (status IN ('active', 'archived'));
END IF;
END $$;
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表统计口径的唯一入口';
COMMENT ON COLUMN product_catalog.canonical_sku IS '标准编码,报表维度用它,不用原始 sku_code';
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗,配合 normalized_quantity 使用';
-- 渠道映射:来源 + 店铺 + 平台SKU -> 标准商品
CREATE TABLE IF NOT EXISTS channel_product_mappings (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
platform_sku_code TEXT NOT NULL,
platform_sku_name TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
confidence TEXT NOT NULL DEFAULT 'manual',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- 同一来源+店铺+SKU 只能映射到一个标准商品;shop_id 为空表示该来源的全局映射
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_key
ON channel_product_mappings (provider, shop_id, platform_sku_code);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
ON channel_product_mappings (canonical_product_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_confidence
CHECK (confidence IN ('exact', 'rule', 'manual'));
END IF;
END $$;
COMMENT ON TABLE channel_product_mappings IS '来源+店铺+平台SKU -> 标准商品映射(报表口径归一化)';
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
-- ============================================================================
-- §2 成本中心
-- ============================================================================
-- 成本项:定义成本类型与计价方式,不存金额
CREATE TABLE IF NOT EXISTS cost_items (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
cost_type TEXT NOT NULL,
carrier_type TEXT NOT NULL,
pricing_mode TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
status TEXT NOT NULL DEFAULT 'active',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_items_code ON cost_items (code);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_type') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_cost_type
CHECK (cost_type IN ('auto_purchase', 'work_order_reward', 'manual_service', 'platform_fee', 'payment_fee', 'other'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_carrier_type') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_carrier_type
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy', 'manual', 'shared'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_pricing_mode') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_pricing_mode
CHECK (pricing_mode IN ('per_item', 'per_order', 'per_hour', 'percent_of_revenue', 'fixed_daily'));
END IF;
END $$;
COMMENT ON TABLE cost_items IS '成本项定义:类型 + 载体 + 计价方式;金额一律放 cost_versions';
COMMENT ON COLUMN cost_items.carrier_type IS '适用执行载体;shared 表示与载体无关的通用成本(如支付通道费)';
-- 成本版本:**唯一改价入口**,按生效时间新增版本,永不覆盖历史
CREATE TABLE IF NOT EXISTS cost_versions (
id BIGSERIAL PRIMARY KEY,
cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT,
provider TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
category_id BIGINT,
amount_fen BIGINT NOT NULL,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
approval_status TEXT NOT NULL DEFAULT 'approved',
approved_by TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- 同一成本项 + 同一适用范围 + 同一生效时间只能有一个版本
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_versions_scope
ON cost_versions (cost_item_id, provider, shop_id, coalesce(canonical_product_id, 0), effective_from);
CREATE INDEX IF NOT EXISTS idx_cost_versions_lookup
ON cost_versions (cost_item_id, effective_from DESC);
CREATE INDEX IF NOT EXISTS idx_cost_versions_product
ON cost_versions (canonical_product_id, effective_from DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_amount') THEN
ALTER TABLE cost_versions ADD CONSTRAINT chk_cost_versions_amount CHECK (amount_fen >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_period') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_period
CHECK (effective_to IS NULL OR effective_to > effective_from);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_approval') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_approval
CHECK (approval_status IN ('draft', 'pending', 'approved', 'rejected'));
END IF;
END $$;
COMMENT ON TABLE cost_versions IS '成本版本:改价只在这里新增版本;历史成本靠 cost_snapshots 固化,不回写';
COMMENT ON COLUMN cost_versions.amount_fen IS '金额(分)。pricing_mode=percent_of_revenue 时表示万分比';
COMMENT ON COLUMN cost_versions.shop_id IS '空串=不限店铺;解析优先级 店铺+SKU > SKU > 分类 > 来源 > 全局';
-- 成本快照:每笔订单项/工单/人工单实际用了哪个版本、多少钱
CREATE TABLE IF NOT EXISTS cost_snapshots (
id BIGSERIAL PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id BIGINT NOT NULL,
order_id BIGINT,
order_item_id BIGINT,
work_order_id BIGINT,
carrier_type TEXT NOT NULL,
cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
cost_version_id BIGINT REFERENCES cost_versions(id) ON DELETE RESTRICT,
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
quantity NUMERIC(14, 3) NOT NULL DEFAULT 1,
unit_amount_fen BIGINT NOT NULL DEFAULT 0,
total_amount_fen BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'resolved',
resolved_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_subject ON cost_snapshots (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_item ON cost_snapshots (order_item_id);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_work_order ON cost_snapshots (work_order_id);
-- 幂等:同一主体 + 同一成本项只允许一条快照
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_subject_item
ON cost_snapshots (subject_type, subject_id, cost_item_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_subject_type') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_subject_type
CHECK (subject_type IN ('order_item', 'work_order', 'manual_case'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_source') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_source
CHECK (source IN ('resolved', 'legacy_rule', 'manual_input', 'missing'));
END IF;
END $$;
COMMENT ON TABLE cost_snapshots IS '成本快照:下单/建工单/人工完成时固化,后续改价不影响历史';
COMMENT ON COLUMN cost_snapshots.source IS 'resolved=命中成本中心;legacy_rule=回退旧 work_product_rules 金额;missing=未匹配到(禁止默认 0)';
-- 成本异常队列:匹配不到成本不能默认 0,必须进队列
CREATE TABLE IF NOT EXISTS cost_exceptions (
id BIGSERIAL PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id BIGINT NOT NULL,
order_item_id BIGINT,
work_order_id BIGINT,
carrier_type TEXT NOT NULL,
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolved_by TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_subject
ON cost_exceptions (subject_type, subject_id, reason);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_open
ON cost_exceptions (status, created_at DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_exceptions_status') THEN
ALTER TABLE cost_exceptions
ADD CONSTRAINT chk_cost_exceptions_status CHECK (status IN ('open', 'resolved', 'ignored'));
END IF;
END $$;
COMMENT ON TABLE cost_exceptions IS '成本缺失/冲突异常队列:报表必须把 open 数量暴露出来,禁止静默按 0 计';
-- ============================================================================
-- §3 订单项口径补齐(金额与数量)
-- ----------------------------------------------------------------------------
-- 现状实测:order_items 只有 sku_code/sku_name/quantity/spec_json/snapshot,**没有任何金额列**;
-- 且 quantity 不可信(316,585 项里 316,578 项 = 1,名称带「N个」的有 104,710 项)。
-- 现状利好:目前每笔已支付订单恰好 1 个订单项(316,585 单 = 316,585 项),
-- 所以 orders.total_amount 暂时可当订单项收入;但 schema 允许多项,属**潜在**缺口。
--
-- 【口径已确认 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
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER,
ADD COLUMN IF NOT EXISTS revenue_amount_fen BIGINT,
ADD COLUMN IF NOT EXISTS revenue_basis TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS refunded_amount_fen BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
DO $$
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 ('', '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);
END IF;
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 '收入(分)= 用户实付,取 orders.total_amount;revenue_basis 通常为 user_payment';
COMMENT ON COLUMN order_items.refunded_at IS '【预留】退款时间:退款/仲裁暂未接入,本轮不写逻辑;接入后按日净销售额 = 销售额 - 退款额';
-- 过渡:让工单规则逐步改用成本中心(有 cost_item_id 走成本中心,否则回退旧金额字段)
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS legacy_cost_source TEXT NOT NULL DEFAULT 'rule_amount';
CREATE INDEX IF NOT EXISTS idx_work_product_rules_cost_item ON work_product_rules (cost_item_id);
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):打手报酬是**支出**(工单结算 -> 可提现 -> 提现打款),
-- 按总额法计入履约成本,与用户实付收入分开记,不做代收代付净额。
-- ============================================================================
-- §4 销售事实表(店铺 × 日期 × 标准商品)
-- ============================================================================
CREATE TABLE IF NOT EXISTS sales_daily_facts (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
carrier_type TEXT NOT NULL,
order_count INTEGER NOT NULL DEFAULT 0,
item_count INTEGER NOT NULL DEFAULT 0,
gross_revenue_fen BIGINT NOT NULL DEFAULT 0,
refund_amount_fen BIGINT NOT NULL DEFAULT 0,
net_revenue_fen BIGINT NOT NULL DEFAULT 0,
cost_amount_fen BIGINT NOT NULL DEFAULT 0,
cost_missing_count INTEGER NOT NULL DEFAULT 0,
gross_profit_fen BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key
ON sales_daily_facts (stat_date, provider, shop_id, canonical_product_id, carrier_type);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date ON sales_daily_facts (stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop ON sales_daily_facts (provider, shop_id, stat_date DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_carrier') THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_carrier
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit') THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_profit
CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen);
END IF;
END $$;
COMMENT ON TABLE sales_daily_facts IS '店铺×日期(Asia/Shanghai)×标准商品×载体 的销售与成本事实;报表直接用这张表';
COMMENT ON COLUMN sales_daily_facts.stat_date IS '销售日按 Asia/Shanghai 归属,取 paid_at;退款按 refunded_at 归期';
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS '成本缺失件数:>0 时该行毛利不可信,报表必须标注';
-- ============================================================================
-- §5 校验与重算(跑完后必须对平,差异不为 0 说明实现有问题)
-- ============================================================================
-- Q1 成本解析覆盖率:open 异常占比应低于约定阈值,且不得为 0 成本静默通过
-- SELECT carrier_type, count(*) FILTER (WHERE source = 'missing') AS missing,
-- count(*) AS total,
-- round(100.0 * count(*) FILTER (WHERE source = 'missing') / count(*), 2) AS missing_pct
-- FROM cost_snapshots GROUP BY 1;
-- Q2 毛利守恒:销售事实的净收入必须等于订单项收入汇总(按同一维度)
-- SELECT f.stat_date, f.provider, f.shop_id,
-- f.net_revenue_fen AS fact_revenue,
-- coalesce(sum(oi.revenue_amount_fen - oi.refunded_amount_fen), 0) AS item_revenue
-- FROM sales_daily_facts f
-- LEFT JOIN order_items oi ON oi.canonical_product_id = f.canonical_product_id
-- LEFT JOIN orders o ON o.id = oi.order_id AND o.provider = f.provider AND o.shop_id = f.shop_id
-- GROUP BY 1, 2, 3, f.net_revenue_fen
-- HAVING f.net_revenue_fen <> coalesce(sum(oi.revenue_amount_fen - oi.refunded_amount_fen), 0)
-- LIMIT 100;
-- Q3 历史成本不回写校验:改 cost_versions 后,已有 cost_snapshots 金额不得变化
-- SELECT count(*) FROM cost_snapshots WHERE resolved_at < now() - interval '1 day' AND created_at <> resolved_at;
-- ============================================================================
-- §6 落地顺序(与 docs/成本中心与三块履约模型.md §九 一致)
-- 1) §1 商品主数据 + 渠道映射 -> 解决「同一商品 47 个 SKU 码」与销量口径
-- 2) §3 订单项口径(数量/收入)-> 解决 7.3 倍数量低估与收入口径不统一
-- 3) §2 成本中心影子期 -> 自动发货接上游固定成本;工单接现有规则金额;人工接按单成本
-- 4) §4 销售事实表 -> 店铺/日期/皮肤 的销量、收入、成本、毛利
-- 5) 之后才是影子总账(docs/财务系统集成方案.md)
--
-- 【口径已确认 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) 寄卖货款的采购成本确认时点。
-- ============================================================================
@@ -0,0 +1,287 @@
-- ============================================================================
-- 载体判定与人工块量化(只读量化 + 回填草案)
-- ----------------------------------------------------------------------------
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
-- 接入时应拆成 `100_order_item_carrier.sql`(见 §3),并先跑 `npm run check:sql`。
-- 基线:main @ ccd5ed2a;数据来源 = 本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14 14:52)
-- 金额单位:分(BIGINT)。orders.total_amount / work_orders.reward_amount / source_order_amount_fen 均为分。
-- 口径基准:§1 所有查询以「已支付订单项」为单位;人工块 = 既无 fulfillment_tasks 也无 work_orders。
-- 设计依据:docs/成本中心与三块履约模型.md、docs/README.md 第 2 条口径。
-- ============================================================================
-- ============================================================================
-- §0 只读安全(对生产库执行 §1 时先设置;只跑 SELECT)
-- ============================================================================
-- SET default_transaction_read_only = on;
-- SET statement_timeout = '60s';
-- 所有查询里的“截止时间”统一由 CTE w 取 max(paid_at),避免写死日期导致窗口漂移。
-- ============================================================================
-- §1 量化查询(只读,可直接对生产/克隆执行)
-- ============================================================================
-- Q1 载体分布:按来源统计「自动发货 / 工单 / 人工 / 双载体冲突」
-- 实测(快照,近 30 天):91 人工 38,451(34.2%,¥485.3 万);PDD 人工 2,066(55.6%,¥36.1 万);
-- 抖音 人工 2/4;双载体冲突 = 0(三载体互斥在数据上成立)。
WITH w AS (
SELECT max(paid_at) AS hi FROM orders WHERE pay_status = 'paid' AND paid_at IS NOT NULL
),
base AS (
SELECT oi.id AS item_id,
o.provider,
o.shop_id,
o.total_amount,
EXISTS (SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash') AS has_auto,
EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS has_task,
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) AS has_wo
FROM order_items oi
JOIN orders o ON o.id = oi.order_id, w
WHERE o.pay_status = 'paid'
AND o.paid_at >= w.hi - interval '30 days'
)
SELECT provider,
count(*) AS paid_items,
count(*) FILTER (WHERE has_auto) AS auto_items,
count(*) FILTER (WHERE has_wo) AS work_order_items,
count(*) FILTER (WHERE NOT has_task AND NOT has_wo) AS manual_items,
count(*) FILTER (WHERE has_task AND has_wo) AS conflict_items,
round(sum(total_amount) FILTER (WHERE NOT has_task AND NOT has_wo) / 100.0) AS manual_yuan,
round(100.0 * count(*) FILTER (WHERE NOT has_task AND NOT has_wo) / count(*), 1) AS manual_pct
FROM base
GROUP BY provider
ORDER BY manual_items DESC;
-- Q1b 非 affiliate_dash 的任务执行器(识别历史执行器,避免把“历史自动”误判成人工)
-- 实测:affiliate_dash 30,902 项;kuaishou_ct_assisted 262 项(= KUAISHOU_CLOUD,已下线,见 executors/registry.ts)
WITH w AS (
SELECT max(paid_at) AS hi FROM orders WHERE pay_status = 'paid' AND paid_at IS NOT NULL
),
base AS (
SELECT oi.id AS item_id
FROM order_items oi JOIN orders o ON o.id = oi.order_id, w
WHERE o.pay_status = 'paid' AND o.paid_at >= w.hi - interval '30 days'
)
SELECT ft.executor_key, count(DISTINCT b.item_id) AS items, count(*) AS tasks
FROM base b JOIN fulfillment_tasks ft ON ft.order_item_id = b.item_id
GROUP BY 1 ORDER BY 2 DESC;
-- Q2 人工块:店铺集中度 + 金额分位(用于判断监管收益与人工成本量级)
-- 实测:91 只有 8 个店铺,avg ¥126.22 / p50 ¥53 / p90 ¥288;≥¥100 有 13,229 项,≥¥500 有 2,708 项。
WITH w AS (
SELECT max(paid_at) AS hi FROM orders WHERE pay_status = 'paid' AND paid_at IS NOT NULL
),
base AS (
SELECT o.provider, o.shop_id, o.shop_name, o.total_amount
FROM order_items oi JOIN orders o ON o.id = oi.order_id, w
WHERE o.pay_status = 'paid' AND o.paid_at >= w.hi - interval '30 days'
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
)
SELECT provider,
count(DISTINCT shop_id) AS shops,
count(*) AS items,
round(avg(total_amount) / 100.0, 2) AS avg_yuan,
round(percentile_disc(0.5) WITHIN GROUP (ORDER BY total_amount) / 100.0, 2) AS p50_yuan,
round(percentile_disc(0.9) WITHIN GROUP (ORDER BY total_amount) / 100.0, 2) AS p90_yuan,
count(*) FILTER (WHERE total_amount >= 10000) AS ge_100y,
count(*) FILTER (WHERE total_amount >= 50000) AS ge_500y
FROM base GROUP BY provider ORDER BY items DESC;
-- Q3 人工块是否存在任何“完成信号”
-- 实测:40,519 项全部 order_status='paid' AND pay_status='paid' AND claim_status='active' —— 零完成信号。
WITH w AS (
SELECT max(paid_at) AS hi FROM orders WHERE pay_status = 'paid' AND paid_at IS NOT NULL
)
SELECT o.order_status, o.pay_status, o.claim_status, count(*) AS items,
round(sum(o.total_amount) / 100.0) AS yuan
FROM order_items oi JOIN orders o ON o.id = oi.order_id, w
WHERE o.pay_status = 'paid' AND o.paid_at >= w.hi - interval '30 days'
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
GROUP BY 1, 2, 3 ORDER BY items DESC;
-- Q4 月度趋势:确认人工块不是历史遗留
-- 实测:2026-08 人工 46,337 / 已支付 142,969;2026-09 39,148 / 103,010;2026-10(部分月)17,469 / 55,647。
SELECT to_char(o.paid_at, 'YYYY-MM') AS mon,
count(*) AS paid_items,
count(*) FILTER (
WHERE NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
) AS manual_items
FROM order_items oi JOIN orders o ON o.id = oi.order_id
WHERE o.pay_status = 'paid' AND o.paid_at >= '2026-08-01'
GROUP BY 1 ORDER BY 1;
-- Q5 数量口径:quantity 不可用于“卖了多少件”
-- 实测(全量已支付 316,585 项):quantity=1 的有 316,578 项;quantity>1 仅 7 项;
-- 其中 104,710 项的名称里带「N个」;sum(quantity)=316,593,而按名称解析 sum=2,316,222 —— 相差 7.3 倍。
SELECT count(*) AS items,
count(*) FILTER (WHERE quantity = 1) AS qty_eq_1,
count(*) FILTER (WHERE quantity = 1 AND sku_name ~ '[0-9]+[ ]*个') AS qty1_name_has_num,
count(*) FILTER (WHERE quantity > 1) AS qty_gt_1,
sum(quantity) AS sum_quantity,
sum(coalesce((regexp_match(sku_name, '([0-9]+)[ ]*个'))[1]::int, quantity)) AS sum_name_quantity
FROM order_items oi JOIN orders o ON o.id = oi.order_id
WHERE o.pay_status = 'paid';
-- Q6 SKU 多码一名:同一商品名对应多个 sku_code(必须先做映射再统计)
-- 实测(全量已支付):苏尔南钻石100颗 47 个码 / 3,319 项;指挥官密钥1个 42 个码 / 26,748 项;
-- 指挥官密钥5个 41 个码;指挥官密钥10个 37 个码;苏尔南钻石50颗 35 个码。
SELECT sku_name, count(DISTINCT sku_code) AS distinct_sku, count(*) AS items
FROM order_items oi JOIN orders o ON o.id = oi.order_id
WHERE o.pay_status = 'paid'
GROUP BY 1
HAVING count(DISTINCT sku_code) > 5
ORDER BY 2 DESC
LIMIT 20;
-- ============================================================================
-- §2 判定规则(唯一口径,代码与回填必须一致)
-- ----------------------------------------------------------------------------
-- 执行载体 carrier_type ∈ { affiliate_dash, work_order, legacy, manual }
-- ※ 与文档三分类的差异:数据中存在已下线执行器(kuaishou_ct_assisted / kuaishou_feifei /
-- kuaishou-industry)的存量任务,单列 legacy,不并入 affiliate_dash,也不并入 manual。
-- 近 30 天 legacy = 262 项。需业务确认其归属(建议只读展示、不计入自动发货口径)。
--
-- 判定优先级(严格顺序,命中即停):
-- 1. 存在 fulfillment_tasks 且 executor_key IN ('affiliate_dash') -> affiliate_dash
-- 2. 存在 fulfillment_tasks 且 executor_key IN ('kuaishou_ct_assisted',
-- 'kuaishou_feifei', 'kuaishou-industry') -> legacy
-- 3. 存在 work_orders(任意状态,含已取消) -> work_order
-- 4. 以上都无 -> manual
-- 命中 1/2 且 3 同时成立 = 冲突,写入异常队列,不静默取其一(当前实测 0 条)。
--
-- carrier_status ∈ { pending, active, completed, failed, cancelled }
-- 终态判定:affiliate_dash 取 task_status IN ('redeemed','closed');work_order 取 status='accepted';
-- manual 只能由人工 case 完成时写入(当前无表,见 docs/成本中心与三块履约模型.md §6)。
-- 宽限期:paid_at 之后 N 小时(建议 24h)内允许 carrier_type 为空(判定未跑完);
-- 超过宽限期仍无载体 -> 强制置 manual 并建 case,禁止长期为空。
--
-- 重要:本判定是**派生列**,不是事实来源。事实来源仍是 fulfillment_tasks / work_orders 存在性;
-- 派生列只用于报表、监管与成本挂载,必须提供重算作业(见 §4)。
-- ============================================================================
-- ============================================================================
-- §3 回填草案(接入时拆成 100_order_item_carrier.sql;分三步,可回滚)
-- ============================================================================
-- 3.1 加列(ADD COLUMN + 常量 DEFAULT 在 PG11+ 为 metadata-only,不重写 31 万行)
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS carrier_type TEXT,
ADD COLUMN IF NOT EXISTS carrier_status TEXT,
ADD COLUMN IF NOT EXISTS carrier_decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS carrier_reason TEXT NOT NULL DEFAULT '';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_carrier_type') THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_carrier_type
CHECK (carrier_type IS NULL OR carrier_type IN ('affiliate_dash', 'work_order', 'legacy', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_carrier_status') THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_carrier_status
CHECK (carrier_status IS NULL OR carrier_status IN ('pending', 'active', 'completed', 'failed', 'cancelled'));
END IF;
END $$;
COMMENT ON COLUMN order_items.carrier_type IS '执行载体(派生): affiliate_dash(自动发货)/work_order(接单工单)/legacy(已下线执行器存量)/manual(人工客服)';
COMMENT ON COLUMN order_items.carrier_status IS '载体状态: pending/active/completed/failed/cancelled';
COMMENT ON COLUMN order_items.carrier_decided_at IS '载体判定时间(重算作业会更新)';
COMMENT ON COLUMN order_items.carrier_reason IS '载体判定原因或人工改派说明';
-- 3.2 回填(只填 NULL,可反复执行;建议按 10 万行分批,避免长事务)
-- 注意:不要在 UPDATE 里直接 JOIN 多张一对多表,统一用 EXISTS,避免行放大。
UPDATE order_items oi
SET carrier_type = CASE
WHEN EXISTS (SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash')
THEN 'affiliate_dash'
WHEN EXISTS (SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei', 'kuaishou-industry'))
THEN 'legacy'
WHEN EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
THEN 'work_order'
ELSE 'manual'
END,
carrier_decided_at = now(),
carrier_reason = 'backfill:2026-10-17'
WHERE oi.carrier_type IS NULL
AND oi.id IN (
SELECT oi2.id
FROM order_items oi2 JOIN orders o ON o.id = oi2.order_id
WHERE o.pay_status = 'paid' AND o.paid_at < now() - interval '24 hours'
LIMIT 100000
);
-- 3.3 索引(CONCURRENTLY 不能放在事务里,需单独执行;生产执行前确认无长事务)
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_items_carrier_type
-- ON order_items (carrier_type);
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fulfillment_tasks_order_item_executor
-- ON fulfillment_tasks (order_item_id, executor_key);
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_work_orders_order_item
-- ON work_orders (order_item_id);
-- 3.4 回滚
-- ALTER TABLE order_items DROP CONSTRAINT IF EXISTS chk_order_items_carrier_type;
-- ALTER TABLE order_items DROP CONSTRAINT IF EXISTS chk_order_items_carrier_status;
-- ALTER TABLE order_items
-- DROP COLUMN IF EXISTS carrier_type,
-- DROP COLUMN IF EXISTS carrier_status,
-- DROP COLUMN IF EXISTS carrier_decided_at,
-- DROP COLUMN IF EXISTS carrier_reason;
-- ============================================================================
-- §4 校验(每次重算后必须跑;差异不为 0 说明派生列与事实不一致)
-- ============================================================================
-- Q7 守恒校验:三类 + legacy 必须等于已支付订单项总数
WITH w AS (
SELECT max(paid_at) AS hi FROM orders WHERE pay_status = 'paid' AND paid_at IS NOT NULL
),
paid AS (
SELECT oi.id AS item_id
FROM order_items oi JOIN orders o ON o.id = oi.order_id, w
WHERE o.pay_status = 'paid' AND o.paid_at >= w.hi - interval '30 days'
)
SELECT count(*) AS paid_items,
count(*) FILTER (WHERE oi.carrier_type = 'affiliate_dash') AS auto_items,
count(*) FILTER (WHERE oi.carrier_type = 'work_order') AS work_order_items,
count(*) FILTER (WHERE oi.carrier_type = 'legacy') AS legacy_items,
count(*) FILTER (WHERE oi.carrier_type = 'manual') AS manual_items,
count(*) FILTER (WHERE oi.carrier_type IS NULL) AS still_null,
count(*) FILTER (WHERE oi.carrier_type = 'manual'
AND EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
OR oi.carrier_type <> 'manual'
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
) AS mismatched
FROM paid p JOIN order_items oi ON oi.id = p.item_id;
-- Q8 冲突与长期无载体(应进异常队列,不进报表)
SELECT oi.id, oi.order_id, oi.carrier_type,
EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS has_task,
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) AS has_wo
FROM order_items oi
WHERE oi.carrier_type IN ('affiliate_dash', 'legacy')
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
LIMIT 100;
-- ============================================================================
-- §5 人工成本区间(按单成本 × 人工块件数)
-- ----------------------------------------------------------------------------
-- 人工块实测:40,519 项 / 30 天(91 38,451 + PDD 2,066 + 抖音 2),涉及金额 ¥5,214,708。
-- 单均人工成本 X 元时的月度人工成本:
-- X=3 -> ¥121,557 X=5 -> ¥202,595 X=8 -> ¥324,152 X=15 -> ¥607,785
-- 说明:这是“人工履约投入”的量级估算,不含商品/货源成本;商品成本走 cost_versions。
-- 建议先按单固定成本落地,同时记录 started_at/completed_at 以备后续按工时核算。
-- ============================================================================