docs: 补充载体判定与成本中心落地草案(含只读克隆量化复核)
- 新增 docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:复核上一轮结论、载体分布与人工块量化、4 处修正 - 新增 docs/drafts/2026-10-17-载体判定与人工块量化.sql:量化查询 + carrier_type 判定与回填 + 守恒校验 - 新增 docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql:商品主数据/成本中心/销售事实建表草案 - docs/README.md 登记新文档与草案,补充草案维护规则
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
-- ============================================================================
|
||||
-- 通用成本中心 + 标准商品 + 销售事实(建表草案)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 状态:草案(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 允许多项,属**潜在**缺口。
|
||||
-- 收入口径实测差异:91 = maxAmount、PDD = order_amount、抖音 = pay_amount,不能直接相加。
|
||||
-- ============================================================================
|
||||
|
||||
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 ('', 'ninety_one_max_amount', 'pdd_order_amount', 'douyin_pay_amount', 'manual'));
|
||||
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 '统一收入口径(分),必须同时写 revenue_basis 说明来源';
|
||||
COMMENT ON COLUMN order_items.refunded_at IS '退款时间:按日净销售额必需,当前 orders 只有 pay_status=refunded 与 updated_at';
|
||||
|
||||
-- 过渡:让工单规则逐步改用成本中心(有 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=旧规则金额,便于统计未迁移规则数';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- §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)
|
||||
--
|
||||
-- 待业务确认(阻塞项):
|
||||
-- a) 91 的 total_amount(maxAmount)到底是收入还是结算上限?
|
||||
-- b) 打手报酬是成本还是代收代付?
|
||||
-- c) 人工块按单成本取值,以及是否需要按工时核算?
|
||||
-- d) 退款/仲裁的冲减规则与归期口径。
|
||||
-- ============================================================================
|
||||
@@ -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 以备后续按工时核算。
|
||||
-- ============================================================================
|
||||
Reference in New Issue
Block a user