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:
@@ -43,6 +43,7 @@
|
||||
| 文档 | 状态 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 现行 | 当前架构、业务链路、风险与改进路线 |
|
||||
| [载体判定与成本中心落地-复核与草案-2026-10-17.md](载体判定与成本中心落地-复核与草案-2026-10-17.md) | 现行(复核+草案) | 只读克隆量化载体分布与人工块、对上一轮结论的复核与修正、两份 SQL 草案说明 |
|
||||
| [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(方案) | 自动发货/接单平台/人工客服三块、统一成本中心、销售事实与人工监管 |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | 现行(方案) | 财务系统边界、复式记账模型、分期落地 |
|
||||
| [影子总账说明.md](影子总账说明.md) | 现行(方案) | 影子总账的定义、运行方式、对账与转正标准 |
|
||||
@@ -57,6 +58,15 @@
|
||||
| [数据库备份与恢复.md](数据库备份与恢复.md) | 现行 | 备份、恢复、OSS |
|
||||
| [数据库SQL约束与运维.md](数据库SQL约束与运维.md) | 现行 | SQL 护栏与生产观测 |
|
||||
|
||||
### SQL 草案(未接入迁移)
|
||||
|
||||
草案只作设计与复盘依据;接入 `apps/backend/src/db/migrations/` 时再拆成 `NNN_slug.sql`,并先跑 `npm run check:sql`。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填、守恒校验、人工成本区间 |
|
||||
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` 建表草案 |
|
||||
|
||||
## 三、厂商协议与外部资料
|
||||
|
||||
这些目录是平台方文档或抓取资料,**不作为本项目实现说明**:
|
||||
@@ -84,3 +94,4 @@
|
||||
5. 新增来源时只加一个 `ProductMatchSourceKey` + 一个适配器;**91 + 快手 send-code 永远算一个 `kuaishou-91`**。
|
||||
6. 成本金额只改 `cost_versions`,不要在多个业务表直接改价;历史订单只读 `cost_snapshots`。
|
||||
7. 报表维度统一用 `canonical_product_id + shop_id + stat_date`,不要直接用原始 `sku_code` 或 `orders.total_amount`。
|
||||
8. 新增 SQL 草案放 `docs/drafts/`,命名 `<日期>-<主题>.sql`;**不得**直接放进 `apps/backend/src/db/migrations/`(后端启动会自动迁移),接入时再拆编号并先跑 `npm run check:sql`。
|
||||
|
||||
@@ -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 以备后续按工时核算。
|
||||
-- ============================================================================
|
||||
@@ -0,0 +1,165 @@
|
||||
# 载体判定与成本中心落地:复核与草案(2026-10-17 第二轮)
|
||||
|
||||
> 状态:现行(复核 + 草案);基线:`main @ ccd5ed2a`;代码未改动。
|
||||
> 数据来源:**本地只读克隆**(生产快照,`orders` 316,597 行,数据截至 2026-10-14 14:52),全程只读 SELECT,未连生产、未写入任何业务库。
|
||||
> 金额单位:**分**(`orders.total_amount` / `work_orders.reward_amount` / `source_order_amount_fen` 均为分)。
|
||||
> 上游文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md)、[项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md)。
|
||||
|
||||
本轮任务是把上一轮「只分析」的结论**复核到底、量化到数、落成可执行草案**。三件事:① 提交并推送待提交文档;② 收敛本机生产备份暴露面;③ 用只读 SQL 量化载体分布与人工块,并产出载体判定 + 成本中心两份 SQL 草案。
|
||||
|
||||
---
|
||||
|
||||
## 一、本轮执行结果
|
||||
|
||||
| # | 事项 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 提交并推送文档 | `ccd5ed2a docs: 补充三块执行载体与通用成本中心方案`,已推送 `6850bc64..ccd5ed2a main -> main`;pre-push 两端全量 check 通过 |
|
||||
| 2 | 备份目录加固 | `backups/` 与子目录 `chmod 700`,两份 dump `chmod 600`;删除残留临时 dump(见 §六) |
|
||||
| 3 | 数据量化 | 只读克隆上完成 8 组聚合(见 §二、§三) |
|
||||
| 4 | SQL 草案 | 载体判定 + 成本中心/销售事实两份,**已在沙箱库零错误应用验证**(见 §五) |
|
||||
|
||||
---
|
||||
|
||||
## 二、独立复核:上一轮结论逐条对照
|
||||
|
||||
| 上一轮结论 | 本轮复核方式 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 后端 740 测试(737 pass / 3 skipped) | 本地实跑 `npm test` | ✅ `tests 740 / pass 737 / fail 0 / skipped 3` |
|
||||
| 前端 143 测试全绿 | 本地实跑 | ✅ `143 pass / 0 fail` |
|
||||
| `typecheck` 通过 | 实跑 `tsc --noEmit` | ✅ 通过 |
|
||||
| 99 个迁移 / 53 张表 | 数文件 + 数 `CREATE TABLE` + 查库 | ✅ 99 迁移、53 条 `CREATE TABLE`;本地库 55 张 = 53 业务表 + `schema_migrations` + `pg_stat_statements`×2 |
|
||||
| 后端非测试源码 371 文件 | `find` 实测 | ✅ 371(另有测试 109) |
|
||||
| 无 CI、实际单分支 | 查 `.github` / `.gitlab-ci.yml`;`git log main..dev` | ✅ 均不存在;`main..dev` 为 0 |
|
||||
| 巨文件行数(3686/2015/1499/1478) | `wc -l` | ✅ 完全一致 |
|
||||
|
||||
**风险项抽查(读源码确认,非复述)**:发单金额后端只校验 `> 0`(`admin-work-order-management-service.ts:228`)✅;寄卖审核 `payload.workerId` 覆盖归属(`consignment-admin-service.ts:606`)✅;抢单先锁钱包、指派先锁工单(`work-order-grab-repo.ts:25` vs `:156`)✅ 锁序反转成立;自动工单押金硬编码 0(`sync-worker-orders-service.ts:245`)✅;Webhook 先落库去重后处理(`douyin-mashida/notify-service.ts:95` → `:112`)✅;`/collect/upload` 不校验订单存在、`/redeem` 仅凭订单号、`/files/object` 无鉴权、`CLAIM_TOKEN_TTL_HOURS=0` ✅;Caddy 无 CSP/HSTS ✅。
|
||||
|
||||
---
|
||||
|
||||
## 三、载体分布(近 30 天,只读克隆实测)
|
||||
|
||||
判定口径:**已支付订单项**为单位,「人工块」= 既无 `fulfillment_tasks` 也无 `work_orders`。
|
||||
|
||||
| 来源 | 已支付项 | 自动发货 `affiliate_dash` | 接单工单 | 人工块 | 双载体冲突 | 人工块金额 | 人工占比 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `kuaishou-91` | 112,520 | 30,900 | 42,907 | **38,451** | 0 | ¥4,853,281 | 34.2% |
|
||||
| `pdd` | 3,714 | 0 | 1,648 | **2,066** | 0 | ¥361,364 | 55.6% |
|
||||
| `douyin-mashida` | 4 | 2 | 0 | 2 | 0 | ¥63 | 50.0% |
|
||||
| **合计** | **116,238** | **30,902** | **44,555** | **40,519** | **0** | **¥5,214,708** | 34.9% |
|
||||
|
||||
补充说明(都已核对):
|
||||
|
||||
- 91 的 `112,520` 减去上述三类后还差 **262 项**,经查是历史执行器 `kuaishou_ct_assisted`(= `KUAISHOU_CLOUD`,已下线,活到 2026-09-15)的存量任务。**这是载体建模必须单列 `legacy` 的原因**,否则这 262 项会被错算成人工。
|
||||
- **双载体冲突 = 0 条**,说明文档「一个订单项只有一个执行载体」的不变式在当前数据上成立。
|
||||
- 人工块 `order_status` 全部 `paid`、`pay_status` 全部 `paid`、`claim_status` 全部 `active` —— **零完成信号**,与上一轮结论一致。
|
||||
|
||||
### 人工块的形状(监管收益判断)
|
||||
|
||||
| 来源 | 涉及店铺 | 单均 | p50 | p90 | ≥¥100 | ≥¥500 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 91 | **8** | ¥126.22 | ¥53 | ¥288 | 13,229 | 2,708 |
|
||||
| PDD | 4 | ¥174.91 | ¥141 | ¥306 | 1,353 | 150 |
|
||||
|
||||
Top 店铺(人工块):向日葵游戏服务店 14,903 项 / ¥183.9 万;西瓜游戏交易店 7,768 项 / ¥118.0 万;小鱼儿游戏服务店 8,190 项 / ¥97.4 万;佳佳游戏交易店 7,460 项 / ¥84.5 万;PDD 向日葵网络游戏专营店 1,885 项 / ¥32.8 万。
|
||||
|
||||
结论:**人工块店铺高度集中(91 只有 8 个店铺)**,意味着「先上线人工 case 队列」的监管收益集中在少数店铺上,容易先抓重点、快速看到效果。
|
||||
|
||||
### 月度趋势(确认不是历史遗留)
|
||||
|
||||
| 月份 | 已支付项 | 人工块 |
|
||||
| --- | ---: | ---: |
|
||||
| 2026-08 | 142,969 | 46,337 |
|
||||
| 2026-09 | 103,010 | 39,148 |
|
||||
| 2026-10(截至 14 日) | 55,647 | 17,469 |
|
||||
|
||||
### 人工成本区间(单均人工成本 X 元的月度量级)
|
||||
|
||||
| X | ¥3 | ¥5 | ¥8 | ¥15 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 月人工成本 | ¥121,557 | ¥202,595 | ¥324,152 | ¥607,785 |
|
||||
|
||||
这是「人工履约投入」的量级估算,不含商品/货源成本(那部分属于 `cost_versions`)。
|
||||
|
||||
---
|
||||
|
||||
## 四、两个口径硬问题(比上一轮描述更严重)
|
||||
|
||||
### 1. 数量:`quantity` 不可用,低估约 7.3 倍
|
||||
|
||||
| 指标 | 实测值(全量已支付 316,585 项) |
|
||||
| --- | ---: |
|
||||
| `quantity = 1` | 316,578(99.998%) |
|
||||
| `quantity > 1` | **7** |
|
||||
| 名称里带「N个」但 `quantity = 1` | **104,710** |
|
||||
| `sum(quantity)` | 316,593 |
|
||||
| 按 SKU 名称解析的数量合计 | **2,316,222** |
|
||||
|
||||
用 `quantity` 统计「卖了多少件」会低估约 **7.3 倍**。必须在订单项固化 `normalized_quantity`(草案已加列)。
|
||||
|
||||
### 2. SKU 多码一名:比上一轮更严重
|
||||
|
||||
| 商品名 | 不同 `sku_code` 数 | 项数 |
|
||||
| --- | ---: | ---: |
|
||||
| 苏尔南钻石100颗 | **47** | 3,319 |
|
||||
| 99999 | 44 | 540 |
|
||||
| 指挥官密钥1个 | **42** | 26,748 |
|
||||
| 指挥官密钥5个 | 41 | 3,608 |
|
||||
| 指挥官密钥10个 | 37 | 3,059 |
|
||||
| 苏尔南钻石50颗 | 35 | 379 |
|
||||
|
||||
(上一轮报的是近 30 天窗口的 27/35/10;本轮是全量已支付口径,两者不矛盾,但都指向同一个结论:**不做 `channel_product_mappings` 就无法统计销量**。)
|
||||
|
||||
---
|
||||
|
||||
## 五、对上一轮结论的 4 处修正
|
||||
|
||||
| # | 上一轮说法 | 本轮修正 |
|
||||
| --- | --- | --- |
|
||||
| 1 | C5「订单项无金额字段」是当下风险 | **是潜在缺口**:实测每笔已支付订单恰好 1 个订单项(316,585 单 = 316,585 项),订单级金额暂时等价;但 schema 允许多项,应在做销售事实前补齐 |
|
||||
| 2 | 三载体互斥(设计不变式) | **数据上已验证**:近 30 天双载体 = 0 条 |
|
||||
| 3 | 自动发货执行器只剩 `affiliate_dash` | 还有 **262 项历史 `kuaishou_ct_assisted` 任务**(已下线,2026-09-15 停);载体建模需单列 `legacy`,且这类任务仍在领取页给出「已下线」提示 |
|
||||
| 4 | 手动履约 `manual_dispatch` 任务极少 | 补充:`affiliate_dash` 侧仍在途 552 项(`link_generated` 300 + `waiting_binding` 252),属运营待跟进而非人工块 |
|
||||
|
||||
---
|
||||
|
||||
## 六、两份 SQL 草案
|
||||
|
||||
放在 `docs/drafts/`,**未接入** `apps/backend/src/db/migrations`(接入时再拆分编号并先跑 `npm run check:sql`,避免本地栈重启时自动迁移)。
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| [载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | §1 八组只读量化查询(含实测值注释);§2 判定规则与优先级(含 `legacy` 与冲突处理);§3 加列 + 回填 + 索引 + 回滚;§4 守恒校验与异常队列查询;§5 人工成本区间 |
|
||||
| [成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items`、`cost_versions`、`cost_snapshots`、`cost_exceptions`、`sales_daily_facts`;订单项口径补齐(`normalized_quantity`/`revenue_amount_fen`/`revenue_basis`/`refunded_at`);`work_product_rules.cost_item_id` 过渡列;§5 对平校验;§6 落地顺序与待业务确认 |
|
||||
|
||||
**验证方式**:在本地副本上 `pg_dump --schema-only` 建了一个一次性沙箱库(55 张表),按顺序 `psql -v ON_ERROR_STOP=1` 应用两份草案 —— **零错误**,生成 7 张新表(62 张)、`order_items` 由 9 列增至 19 列、新增 18 个 `CHECK` 约束;校验查询与回填语句均可执行。验证后沙箱库已 `DROP`。
|
||||
|
||||
关键设计约束(两份草案共同遵循):
|
||||
|
||||
1. **改价只发生在 `cost_versions`**,历史成本靠 `cost_snapshots` 固化,永不回写;
|
||||
2. **匹配不到成本不默认 0**,写入 `cost_exceptions`,报表必须暴露 `open` 数量;
|
||||
3. `carrier_type`/`carrier_status` 是**派生列**,事实来源仍是任务/工单存在性,必须有重算作业与守恒校验;
|
||||
4. 报表维度统一 `canonical_product_id + shop_id + stat_date`,禁止直接用 `sku_code` 或 `orders.total_amount`。
|
||||
|
||||
---
|
||||
|
||||
## 七、本机卫生记录(第 2 项执行细节)
|
||||
|
||||
| 项 | 处理前 | 处理后 |
|
||||
| --- | --- | --- |
|
||||
| `backups/`、`backups/postgres/`、`backups/douyin-platform-clear-20261014/` | `755` | **`700`** |
|
||||
| `order_site_latest.dump`(559 MB)、`order_site_remote.dump`(105 MB) | `644` | **`600`** |
|
||||
| `.order_site_readonly.dump.nKbeRp8kUA`(137,330,688 B) | `600`,残留 | **已删除**,`backups/` 由 765 MB 降至 634 MB |
|
||||
|
||||
删除前已确认:① 解析后绝对路径为 `…/order_site/backups/postgres/.order_site_readonly.dump.nKbeRp8kUA`;② 是**完整可读**的 PostgreSQL 自定义 dump(`pg_restore --list` 795 条目 / 53 个 `TABLE DATA`),sha256 `1a666af11ae9df2058bc2a5afa2196baefb9c125daf0cdecf18dfd20c59cc075`;③ 全仓库无任何脚本引用该文件或其余两份 dump 名;④ 无进程占用;⑤ 与同分钟产出的 `order_site_latest.dump` 属同一批快照,且本地克隆数据(截至 10-14)比这两份 10-10 快照更新。
|
||||
|
||||
**遗留建议**:本机仍存两份全量生产 dump(含买家信息与平台凭据)。建议确认磁盘已加密;若后续以本地克隆为唯一分析基线,可只保留一份最新 dump。
|
||||
|
||||
**分析基线建议**:本地 docker 栈(`order_site-postgres-1`)已含 10-14 生产快照(`orders` 316,597),后续所有 SQL 迭代直接打本地克隆即可,无需再连生产。
|
||||
|
||||
---
|
||||
|
||||
## 八、下一步
|
||||
|
||||
1. **业务先拍板 4 个周长问题**(阻塞建表):91 的 `total_amount` 是收入还是结算上限;打手报酬是成本还是代收代付;人工块单均成本取值与是否按工时;退款/仲裁的冲减与归期口径。
|
||||
2. **工程先做两件不改业务代码的事**:跑 `channel_product_mappings` 的初始映射(把 47 个码归到 1 个标准商品);把 `normalized_quantity` 用现有 `resolveSkuNameQuantity` 解析后回填订单项。
|
||||
3. 之后再按「商品主数据 → 成本中心影子期 → 人工 case → 影子总账 → 对账转正」推进;`manual_fulfillment_cases` 的 DDL 尚未产出(本轮只做载体判定与成本/销售事实),可作为下一份草案。
|
||||
Reference in New Issue
Block a user