From 801bfa0ba07e20cc45f6c9499bf8130cf4b9384e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 21:41:38 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E4=B8=8E=E6=88=90=E6=9C=AC=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=E8=8D=89=E6=A1=88=EF=BC=88=E5=90=AB=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=85=8B=E9=9A=86=E9=87=8F=E5=8C=96=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:复核上一轮结论、载体分布与人工块量化、4 处修正 - 新增 docs/drafts/2026-10-17-载体判定与人工块量化.sql:量化查询 + carrier_type 判定与回填 + 守恒校验 - 新增 docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql:商品主数据/成本中心/销售事实建表草案 - docs/README.md 登记新文档与草案,补充草案维护规则 --- docs/README.md | 11 + .../2026-10-17-成本中心与销售事实-DDL草案.sql | 383 ++++++++++++++++++ .../2026-10-17-载体判定与人工块量化.sql | 287 +++++++++++++ ...定与成本中心落地-复核与草案-2026-10-17.md | 165 ++++++++ 4 files changed, 846 insertions(+) create mode 100644 docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql create mode 100644 docs/drafts/2026-10-17-载体判定与人工块量化.sql create mode 100644 docs/载体判定与成本中心落地-复核与草案-2026-10-17.md diff --git a/docs/README.md b/docs/README.md index 54e241c5..fb46a632 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`。 diff --git a/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql new file mode 100644 index 00000000..830b11c7 --- /dev/null +++ b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.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) 退款/仲裁的冲减规则与归期口径。 +-- ============================================================================ diff --git a/docs/drafts/2026-10-17-载体判定与人工块量化.sql b/docs/drafts/2026-10-17-载体判定与人工块量化.sql new file mode 100644 index 00000000..ca9d276f --- /dev/null +++ b/docs/drafts/2026-10-17-载体判定与人工块量化.sql @@ -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 以备后续按工时核算。 +-- ============================================================================ diff --git a/docs/载体判定与成本中心落地-复核与草案-2026-10-17.md b/docs/载体判定与成本中心落地-复核与草案-2026-10-17.md new file mode 100644 index 00000000..1ecd2462 --- /dev/null +++ b/docs/载体判定与成本中心落地-复核与草案-2026-10-17.md @@ -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 尚未产出(本轮只做载体判定与成本/销售事实),可作为下一份草案。