-- ============================================================================ -- 通用成本中心 + 标准商品 + 销售事实(建表草案) -- ---------------------------------------------------------------------------- -- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。 -- 接入时应拆成 `101_product_catalog.sql`、`102_cost_center.sql`、`103_sales_daily_facts.sql`, -- 并先跑 `npm run check:sql`(SQL 防退化护栏)。 -- 基线:main @ ccd5ed2a;数据依据:本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14)。 -- 金额单位:分(BIGINT)。本文件所有 *_fen 列均为分。 -- 设计依据:docs/成本中心与三块履约模型.md(三块载体、成本版本与快照)、docs/财务系统集成方案.md。 -- -- 目标(业务原话):以后改成本只在一个地方。 -- -> 所有成本金额只写 cost_versions;业务表只保存 cost_snapshot_id 与派生金额,永不回写历史。 -- ============================================================================ -- ============================================================================ -- §1 标准商品主数据 -- ---------------------------------------------------------------------------- -- 为什么必须先做:同一个商品名对应多个 sku_code(实测「苏尔南钻石100颗」47 个码、 -- 「指挥官密钥1个」42 个码),直接按 sku_code 或 sku_name 统计都会出错。 -- ============================================================================ CREATE TABLE IF NOT EXISTS product_catalog ( id BIGSERIAL PRIMARY KEY, canonical_sku TEXT NOT NULL, canonical_name TEXT NOT NULL, category_id BIGINT, unit_name TEXT NOT NULL DEFAULT '件', status TEXT NOT NULL DEFAULT 'active', attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku ON product_catalog (canonical_sku); CREATE INDEX IF NOT EXISTS idx_product_catalog_category ON product_catalog (category_id); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN ALTER TABLE product_catalog ADD CONSTRAINT chk_product_catalog_status CHECK (status IN ('active', 'archived')); END IF; END $$; COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表统计口径的唯一入口'; COMMENT ON COLUMN product_catalog.canonical_sku IS '标准编码,报表维度用它,不用原始 sku_code'; COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗,配合 normalized_quantity 使用'; -- 渠道映射:来源 + 店铺 + 平台SKU -> 标准商品 CREATE TABLE IF NOT EXISTS channel_product_mappings ( id BIGSERIAL PRIMARY KEY, provider TEXT NOT NULL, shop_id TEXT NOT NULL DEFAULT '', platform_sku_code TEXT NOT NULL, platform_sku_name TEXT NOT NULL DEFAULT '', canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT, quantity_multiplier INTEGER NOT NULL DEFAULT 1, confidence TEXT NOT NULL DEFAULT 'manual', note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); -- 同一来源+店铺+SKU 只能映射到一个标准商品;shop_id 为空表示该来源的全局映射 CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_key ON channel_product_mappings (provider, shop_id, platform_sku_code); CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product ON channel_product_mappings (canonical_product_id); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN ALTER TABLE channel_product_mappings ADD CONSTRAINT chk_channel_product_mappings_confidence CHECK (confidence IN ('exact', 'rule', 'manual')); END IF; END $$; COMMENT ON TABLE channel_product_mappings IS '来源+店铺+平台SKU -> 标准商品映射(报表口径归一化)'; COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5'; -- ============================================================================ -- §2 成本中心 -- ============================================================================ -- 成本项:定义成本类型与计价方式,不存金额 CREATE TABLE IF NOT EXISTS cost_items ( id BIGSERIAL PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, cost_type TEXT NOT NULL, carrier_type TEXT NOT NULL, pricing_mode TEXT NOT NULL, currency TEXT NOT NULL DEFAULT 'CNY', status TEXT NOT NULL DEFAULT 'active', note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_items_code ON cost_items (code); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_type') THEN ALTER TABLE cost_items ADD CONSTRAINT chk_cost_items_cost_type CHECK (cost_type IN ('auto_purchase', 'work_order_reward', 'manual_service', 'platform_fee', 'payment_fee', 'other')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_carrier_type') THEN ALTER TABLE cost_items ADD CONSTRAINT chk_cost_items_carrier_type CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy', 'manual', 'shared')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_pricing_mode') THEN ALTER TABLE cost_items ADD CONSTRAINT chk_cost_items_pricing_mode CHECK (pricing_mode IN ('per_item', 'per_order', 'per_hour', 'percent_of_revenue', 'fixed_daily')); END IF; END $$; COMMENT ON TABLE cost_items IS '成本项定义:类型 + 载体 + 计价方式;金额一律放 cost_versions'; COMMENT ON COLUMN cost_items.carrier_type IS '适用执行载体;shared 表示与载体无关的通用成本(如支付通道费)'; -- 成本版本:**唯一改价入口**,按生效时间新增版本,永不覆盖历史 CREATE TABLE IF NOT EXISTS cost_versions ( id BIGSERIAL PRIMARY KEY, cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT, provider TEXT NOT NULL DEFAULT '', shop_id TEXT NOT NULL DEFAULT '', canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, category_id BIGINT, amount_fen BIGINT NOT NULL, effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ, approval_status TEXT NOT NULL DEFAULT 'approved', approved_by TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); -- 同一成本项 + 同一适用范围 + 同一生效时间只能有一个版本 CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_versions_scope ON cost_versions (cost_item_id, provider, shop_id, coalesce(canonical_product_id, 0), effective_from); CREATE INDEX IF NOT EXISTS idx_cost_versions_lookup ON cost_versions (cost_item_id, effective_from DESC); CREATE INDEX IF NOT EXISTS idx_cost_versions_product ON cost_versions (canonical_product_id, effective_from DESC); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_amount') THEN ALTER TABLE cost_versions ADD CONSTRAINT chk_cost_versions_amount CHECK (amount_fen >= 0); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_period') THEN ALTER TABLE cost_versions ADD CONSTRAINT chk_cost_versions_period CHECK (effective_to IS NULL OR effective_to > effective_from); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_approval') THEN ALTER TABLE cost_versions ADD CONSTRAINT chk_cost_versions_approval CHECK (approval_status IN ('draft', 'pending', 'approved', 'rejected')); END IF; END $$; COMMENT ON TABLE cost_versions IS '成本版本:改价只在这里新增版本;历史成本靠 cost_snapshots 固化,不回写'; COMMENT ON COLUMN cost_versions.amount_fen IS '金额(分)。pricing_mode=percent_of_revenue 时表示万分比'; COMMENT ON COLUMN cost_versions.shop_id IS '空串=不限店铺;解析优先级 店铺+SKU > SKU > 分类 > 来源 > 全局'; -- 成本快照:每笔订单项/工单/人工单实际用了哪个版本、多少钱 CREATE TABLE IF NOT EXISTS cost_snapshots ( id BIGSERIAL PRIMARY KEY, subject_type TEXT NOT NULL, subject_id BIGINT NOT NULL, order_id BIGINT, order_item_id BIGINT, work_order_id BIGINT, carrier_type TEXT NOT NULL, cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT, cost_version_id BIGINT REFERENCES cost_versions(id) ON DELETE RESTRICT, canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, quantity NUMERIC(14, 3) NOT NULL DEFAULT 1, unit_amount_fen BIGINT NOT NULL DEFAULT 0, total_amount_fen BIGINT NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'resolved', resolved_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_cost_snapshots_subject ON cost_snapshots (subject_type, subject_id); CREATE INDEX IF NOT EXISTS idx_cost_snapshots_item ON cost_snapshots (order_item_id); CREATE INDEX IF NOT EXISTS idx_cost_snapshots_work_order ON cost_snapshots (work_order_id); -- 幂等:同一主体 + 同一成本项只允许一条快照 CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_subject_item ON cost_snapshots (subject_type, subject_id, cost_item_id); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_subject_type') THEN ALTER TABLE cost_snapshots ADD CONSTRAINT chk_cost_snapshots_subject_type CHECK (subject_type IN ('order_item', 'work_order', 'manual_case')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_source') THEN ALTER TABLE cost_snapshots ADD CONSTRAINT chk_cost_snapshots_source CHECK (source IN ('resolved', 'legacy_rule', 'manual_input', 'missing')); END IF; END $$; COMMENT ON TABLE cost_snapshots IS '成本快照:下单/建工单/人工完成时固化,后续改价不影响历史'; COMMENT ON COLUMN cost_snapshots.source IS 'resolved=命中成本中心;legacy_rule=回退旧 work_product_rules 金额;missing=未匹配到(禁止默认 0)'; -- 成本异常队列:匹配不到成本不能默认 0,必须进队列 CREATE TABLE IF NOT EXISTS cost_exceptions ( id BIGSERIAL PRIMARY KEY, subject_type TEXT NOT NULL, subject_id BIGINT NOT NULL, order_item_id BIGINT, work_order_id BIGINT, carrier_type TEXT NOT NULL, reason TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL, resolved_at TIMESTAMPTZ, resolved_by TEXT NOT NULL DEFAULT '' ); CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_subject ON cost_exceptions (subject_type, subject_id, reason); CREATE INDEX IF NOT EXISTS idx_cost_exceptions_open ON cost_exceptions (status, created_at DESC); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_exceptions_status') THEN ALTER TABLE cost_exceptions ADD CONSTRAINT chk_cost_exceptions_status CHECK (status IN ('open', 'resolved', 'ignored')); END IF; END $$; COMMENT ON TABLE cost_exceptions IS '成本缺失/冲突异常队列:报表必须把 open 数量暴露出来,禁止静默按 0 计'; -- ============================================================================ -- §3 订单项口径补齐(金额与数量) -- ---------------------------------------------------------------------------- -- 现状实测:order_items 只有 sku_code/sku_name/quantity/spec_json/snapshot,**没有任何金额列**; -- 且 quantity 不可信(316,585 项里 316,578 项 = 1,名称带「N个」的有 104,710 项)。 -- 现状利好:目前每笔已支付订单恰好 1 个订单项(316,585 单 = 316,585 项), -- 所以 orders.total_amount 暂时可当订单项收入;但 schema 允许多项,属**潜在**缺口。 -- -- 【口径已确认 2026-10-17】收入 = 用户付款金额,取 orders.total_amount: -- · 91:total_amount 即用户付款(与快手发码配合)。数据核验:91 已支付 312,569 笔中 -- 235,271 笔含快手 kuaishouSendCode.paymentFen,其中 235,268 笔与 total_amount **完全一致** -- (100.00%),仅 3 笔差 ¥1.80~2.00(2026-09-05 / 09-18 / 10-04,可单独抽查)。 -- · PDD:order_amount;抖音:pay_amount。三者同为“用户实付”,故统一 revenue_basis = 'user_payment'。 -- · 打手报酬 = **支出/成本**(最终由打手提现、我方付款),因此按**总额法**: -- 收入记用户实付全额,打手报酬作为履约成本,不做代收代付净额。 -- · 退款/仲裁**暂未接入**:refunded_amount_fen / refunded_at 为**预留字段**,本轮不写逻辑。 -- ============================================================================ ALTER TABLE order_items ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER, ADD COLUMN IF NOT EXISTS revenue_amount_fen BIGINT, ADD COLUMN IF NOT EXISTS revenue_basis TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS refunded_amount_fen BIGINT NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_basis') THEN ALTER TABLE order_items ADD CONSTRAINT chk_order_items_revenue_basis CHECK (revenue_basis IN ('', 'user_payment', 'manual_adjustment')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_amount') THEN ALTER TABLE order_items ADD CONSTRAINT chk_order_items_revenue_amount CHECK (revenue_amount_fen IS NULL OR revenue_amount_fen >= 0); END IF; END $$; CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product ON order_items (canonical_product_id); COMMENT ON COLUMN order_items.normalized_quantity IS '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量'; COMMENT ON COLUMN order_items.revenue_amount_fen IS '收入(分)= 用户实付,取 orders.total_amount;revenue_basis 通常为 user_payment'; COMMENT ON COLUMN order_items.refunded_at IS '【预留】退款时间:退款/仲裁暂未接入,本轮不写逻辑;接入后按日净销售额 = 销售额 - 退款额'; -- 过渡:让工单规则逐步改用成本中心(有 cost_item_id 走成本中心,否则回退旧金额字段) ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT, ADD COLUMN IF NOT EXISTS legacy_cost_source TEXT NOT NULL DEFAULT 'rule_amount'; CREATE INDEX IF NOT EXISTS idx_work_product_rules_cost_item ON work_product_rules (cost_item_id); COMMENT ON COLUMN work_product_rules.cost_item_id IS '迁移用过渡列:非空则金额取成本中心,空则回退 reward_amount/unit_price_fen'; COMMENT ON COLUMN work_product_rules.legacy_cost_source IS '回退来源标记:rule_amount=旧规则金额,便于统计未迁移规则数'; -- 口径已确认(2026-10-17):打手报酬是**支出**(工单结算 -> 可提现 -> 提现打款), -- 按总额法计入履约成本,与用户实付收入分开记,不做代收代付净额。 -- ============================================================================ -- §4 销售事实表(店铺 × 日期 × 标准商品) -- ============================================================================ CREATE TABLE IF NOT EXISTS sales_daily_facts ( id BIGSERIAL PRIMARY KEY, stat_date DATE NOT NULL, provider TEXT NOT NULL, shop_id TEXT NOT NULL DEFAULT '', canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT, carrier_type TEXT NOT NULL, order_count INTEGER NOT NULL DEFAULT 0, item_count INTEGER NOT NULL DEFAULT 0, gross_revenue_fen BIGINT NOT NULL DEFAULT 0, refund_amount_fen BIGINT NOT NULL DEFAULT 0, net_revenue_fen BIGINT NOT NULL DEFAULT 0, cost_amount_fen BIGINT NOT NULL DEFAULT 0, cost_missing_count INTEGER NOT NULL DEFAULT 0, gross_profit_fen BIGINT NOT NULL DEFAULT 0, computed_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key ON sales_daily_facts (stat_date, provider, shop_id, canonical_product_id, carrier_type); CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date ON sales_daily_facts (stat_date DESC); CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop ON sales_daily_facts (provider, shop_id, stat_date DESC); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_carrier') THEN ALTER TABLE sales_daily_facts ADD CONSTRAINT chk_sales_daily_facts_carrier CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy', 'manual')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit') THEN ALTER TABLE sales_daily_facts ADD CONSTRAINT chk_sales_daily_facts_profit CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen); END IF; END $$; COMMENT ON TABLE sales_daily_facts IS '店铺×日期(Asia/Shanghai)×标准商品×载体 的销售与成本事实;报表直接用这张表'; COMMENT ON COLUMN sales_daily_facts.stat_date IS '销售日按 Asia/Shanghai 归属,取 paid_at;退款按 refunded_at 归期'; COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS '成本缺失件数:>0 时该行毛利不可信,报表必须标注'; -- ============================================================================ -- §5 校验与重算(跑完后必须对平,差异不为 0 说明实现有问题) -- ============================================================================ -- Q1 成本解析覆盖率:open 异常占比应低于约定阈值,且不得为 0 成本静默通过 -- SELECT carrier_type, count(*) FILTER (WHERE source = 'missing') AS missing, -- count(*) AS total, -- round(100.0 * count(*) FILTER (WHERE source = 'missing') / count(*), 2) AS missing_pct -- FROM cost_snapshots GROUP BY 1; -- Q2 毛利守恒:销售事实的净收入必须等于订单项收入汇总(按同一维度) -- SELECT f.stat_date, f.provider, f.shop_id, -- f.net_revenue_fen AS fact_revenue, -- coalesce(sum(oi.revenue_amount_fen - oi.refunded_amount_fen), 0) AS item_revenue -- FROM sales_daily_facts f -- LEFT JOIN order_items oi ON oi.canonical_product_id = f.canonical_product_id -- LEFT JOIN orders o ON o.id = oi.order_id AND o.provider = f.provider AND o.shop_id = f.shop_id -- GROUP BY 1, 2, 3, f.net_revenue_fen -- HAVING f.net_revenue_fen <> coalesce(sum(oi.revenue_amount_fen - oi.refunded_amount_fen), 0) -- LIMIT 100; -- Q3 历史成本不回写校验:改 cost_versions 后,已有 cost_snapshots 金额不得变化 -- SELECT count(*) FROM cost_snapshots WHERE resolved_at < now() - interval '1 day' AND created_at <> resolved_at; -- ============================================================================ -- §6 落地顺序(与 docs/成本中心与三块履约模型.md §九 一致) -- 1) §1 商品主数据 + 渠道映射 -> 解决「同一商品 47 个 SKU 码」与销量口径 -- 2) §3 订单项口径(数量/收入)-> 解决 7.3 倍数量低估与收入口径不统一 -- 3) §2 成本中心影子期 -> 自动发货接上游固定成本;工单接现有规则金额;人工接按单成本 -- 4) §4 销售事实表 -> 店铺/日期/皮肤 的销量、收入、成本、毛利 -- 5) 之后才是影子总账(docs/财务系统集成方案.md) -- -- 【口径已确认 2026-10-17】 -- a) 收入 = 用户付款金额(orders.total_amount),计收入;已用数据核验与快手 paymentFen 100% 对齐。 -- b) 打手报酬 = 支出/成本(最终提现打款),按总额法。 -- c) 人工块:第一优先级是**人工支出可追溯**(付款项目/金额/订单号/付款人/凭证), -- 见 `2026-10-17-人工支出登记-DDL草案.sql`;按单还是按小时的取值口径仍待定,先按单登记。 -- d) 退款/仲裁:**暂未接入,先预留**(字段 + reversal_of_id + 事件类型,不实现逻辑)。 -- -- 【仍待确认】 -- 1) 收入确认时点(付款 / 发货 / 验收 / 售后期结束)——影响利润表与预收账款; -- 2) 自动发货 cost_amount 是否稳定、是否含平台积分/汇率; -- 3) 平台服务费/支付通道费由谁承担、结算周期与账单口径; -- 4) 寄卖货款的采购成本确认时点。 -- ============================================================================