diff --git a/apps/backend/src/db/migrations/103_product_catalog.sql b/apps/backend/src/db/migrations/103_product_catalog.sql index f20ae134..803f9434 100644 --- a/apps/backend/src/db/migrations/103_product_catalog.sql +++ b/apps/backend/src/db/migrations/103_product_catalog.sql @@ -3,11 +3,18 @@ -- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、 -- 店铺日报和订单毛利都只认 canonical_product_id。 -- +-- 设计要点(2026-10-23 重写): +-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定, +-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 / +-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。 +-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。 +-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1, +-- 必须由回填脚本按 SKU 名称解析后写入。 +-- -- 约束: -- 1. canonical_sku 是内部稳定编码,创建后不随平台改名; --- 2. 同一 provider + shop_id + platform_sku_code 只能有一条映射; --- 3. shop_id 为空串表示该来源的全局映射,具体店铺映射优先; --- 4. order_items.normalized_quantity 从 SKU 名称解析,解析失败先回填 quantity。 +-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先; +-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。 CREATE TABLE IF NOT EXISTS product_catalog ( id BIGSERIAL PRIMARY KEY, @@ -25,8 +32,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku ON product_catalog (canonical_sku); CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category ON product_catalog (status, category, canonical_name); -CREATE INDEX IF NOT EXISTS idx_product_catalog_name - ON product_catalog (canonical_name); DO $$ BEGIN @@ -48,16 +53,19 @@ BEGIN END $$; COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度'; -COMMENT ON COLUMN product_catalog.canonical_sku IS '内部稳定编码;创建后不随平台改名'; +COMMENT ON COLUMN product_catalog.canonical_sku IS + '内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工'; COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用'; 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_code TEXT NOT NULL DEFAULT '', platform_sku_name TEXT NOT NULL DEFAULT '', + platform_sku_name_key TEXT NOT NULL DEFAULT '', canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT, + match_type TEXT NOT NULL DEFAULT 'sku_code', quantity_multiplier INTEGER NOT NULL DEFAULT 1, confidence TEXT NOT NULL DEFAULT 'manual', source TEXT NOT NULL DEFAULT 'manual', @@ -67,12 +75,25 @@ CREATE TABLE IF NOT EXISTS channel_product_mappings ( updated_at TIMESTAMPTZ NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_key - ON channel_product_mappings (provider, shop_id, platform_sku_code); +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key + ON channel_product_mappings ( + provider, + shop_id, + platform_sku_code, + match_type, + platform_sku_name_key + ); CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product ON channel_product_mappings (canonical_product_id, enabled); CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup - ON channel_product_mappings (provider, shop_id, platform_sku_code, enabled); + ON channel_product_mappings ( + provider, + shop_id, + platform_sku_code, + match_type, + platform_sku_name_key, + enabled + ); DO $$ BEGIN @@ -91,23 +112,41 @@ BEGIN ADD CONSTRAINT chk_channel_product_mappings_multiplier CHECK (quantity_multiplier > 0); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_match_type + CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_match_key + CHECK (platform_sku_code <> '' OR platform_sku_name_key <> ''); + END IF; END $$; COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射'; COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先'; +COMMENT ON COLUMN channel_product_mappings.match_type IS + '匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series'; +COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS + '归一后的 SKU 名称或数量系列键;sku_code 模式为空串'; COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5'; 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 normalized_quantity INTEGER, + ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT, + ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT; CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product ON order_items (canonical_product_id); +CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup + ON order_items (normalized_sku_name, normalized_sku_series); COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定'; -COMMENT ON COLUMN order_items.normalized_quantity IS '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量'; - --- 初始回填只保证不为空;后续由成本中心页面/脚本按 SKU 名称解析覆盖。 -UPDATE order_items -SET normalized_quantity = GREATEST(COALESCE(quantity, 1), 1) -WHERE normalized_quantity IS NULL; +COMMENT ON COLUMN order_items.normalized_quantity IS + '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL'; +COMMENT ON COLUMN order_items.normalized_sku_name IS + 'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧'; +COMMENT ON COLUMN order_items.normalized_sku_series IS + '剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配'; diff --git a/apps/backend/src/db/migrations/104_cost_center.sql b/apps/backend/src/db/migrations/104_cost_center.sql index b5814ce1..70d27f2f 100644 --- a/apps/backend/src/db/migrations/104_cost_center.sql +++ b/apps/backend/src/db/migrations/104_cost_center.sql @@ -1,50 +1,61 @@ --- 104_cost_center.sql —— 成本项目、成本版本、成本快照与成本异常。 +-- 104_cost_center.sql —— 成本项、成本版本、成本快照、成本异常与订单项收入固化。 -- --- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存 --- cost_snapshot_id 或快照行,历史成本永不回写。 +-- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存快照行, +-- 历史成本永不回写。 -- --- 口径(2026-10-17 已确认): +-- 设计要点(2026-10-23 重写): +-- 1. 成本项用 cost_model 表达「成本怎么算」,不再复用 carrier_type。 +-- cost_model ∈ { affiliate_purchase, work_order_reward, manual_actual, +-- platform_fee, payment_fee, after_sales_loss, other } +-- 履约方式在 order_items.fulfillment_mode,两者互不推导。 +-- 2. 快照表不存 carrier_type:快照属于哪个订单项,join order_items 就有履约方式。 +-- 3. 异常表同理,异常属于哪个订单项由 order_item_id 决定。 +-- 4. 成本版本按业务发生时间解析(支付时间/验收时间),不是回填时的 now()。 +-- 5. 匹配不到成本不得默认 0,必须进入 cost_exceptions。 +-- +-- 口径(2026-10-22 确认): -- * 自动发货 = 上游采购成本; -- * 接单平台 = 打手报酬,按总额法计履约成本; --- * 人工客服 = 人工支出登记,按 5102 履约成本-人工客服; --- * 匹配不到成本不得默认 0,必须进入 cost_exceptions。 +-- * 人工客服 = 人工支出登记的实际金额; +-- * 收入按 paid_at 归属销售日;一单一品归整单,多品按等额拆分并标 basis。 + +-- --------------------------------------------------------------------------- +-- 1. 成本项 +-- --------------------------------------------------------------------------- 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, - unit_name TEXT NOT NULL DEFAULT '件', - 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 + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + cost_model TEXT NOT NULL, + pricing_mode TEXT NOT NULL, + unit_name TEXT NOT NULL DEFAULT '件', + 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); -CREATE INDEX IF NOT EXISTS idx_cost_items_status_carrier - ON cost_items (status, carrier_type, cost_type); +CREATE INDEX IF NOT EXISTS idx_cost_items_status_model + ON cost_items (status, cost_model); DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_type') THEN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_model') 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', 'after_sales_loss', 'shipping_other', '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', 'manual', 'shared')); + ADD CONSTRAINT chk_cost_items_cost_model + CHECK (cost_model IN ( + 'affiliate_purchase', 'work_order_reward', 'manual_actual', + 'platform_fee', 'payment_fee', 'after_sales_loss', 'other' + )); 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')); + CHECK (pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_status') THEN ALTER TABLE cost_items @@ -53,9 +64,17 @@ BEGIN END IF; END $$; -COMMENT ON TABLE cost_items IS '成本项定义:类型 + 载体 + 计价方式;金额一律放 cost_versions'; -COMMENT ON COLUMN cost_items.carrier_type IS '适用执行载体;shared 表示与载体无关的通用成本'; -COMMENT ON COLUMN cost_items.pricing_mode IS 'per_item/per_order/per_hour/percent_of_revenue/fixed_daily'; +COMMENT ON TABLE cost_items IS '成本项定义:成本模型 + 计价方式;金额一律放 cost_versions'; +COMMENT ON COLUMN cost_items.cost_model IS + '成本模型:affiliate_purchase=上游采购 / work_order_reward=打手报酬 / manual_actual=人工实际支出 / platform_fee / payment_fee / after_sales_loss / other'; +COMMENT ON COLUMN cost_items.pricing_mode IS + '计价方式:per_item 按件 / per_order 按单 / percent_of_revenue 按收入比例'; +COMMENT ON COLUMN cost_items.code IS + '成本项编码;sys_ 前缀为系统内置项,业务侧不要删除或改编码'; + +-- --------------------------------------------------------------------------- +-- 2. 成本版本:改价只在这里新增版本 +-- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS cost_versions ( id BIGSERIAL PRIMARY KEY, @@ -99,13 +118,11 @@ 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); + 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_rate') THEN ALTER TABLE cost_versions - ADD CONSTRAINT chk_cost_versions_rate - CHECK (rate_bps >= 0 AND rate_bps <= 10000); + ADD CONSTRAINT chk_cost_versions_rate CHECK (rate_bps >= 0 AND rate_bps <= 10000); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_period') THEN ALTER TABLE cost_versions @@ -134,6 +151,10 @@ COMMENT ON COLUMN cost_versions.amount_fen IS '固定金额(分);percent_o COMMENT ON COLUMN cost_versions.rate_bps IS '比例成本,单位万分比;10000=100%'; COMMENT ON COLUMN cost_versions.scope_type IS '匹配优先级:rule > product > shop > provider > global'; +-- --------------------------------------------------------------------------- +-- 3. 成本快照 +-- --------------------------------------------------------------------------- + CREATE TABLE IF NOT EXISTS cost_snapshots ( id BIGSERIAL PRIMARY KEY, subject_type TEXT NOT NULL, @@ -141,8 +162,6 @@ CREATE TABLE IF NOT EXISTS cost_snapshots ( order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, - manual_case_id BIGINT, - carrier_type TEXT NOT NULL, cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT, cost_version_id BIGINT NOT NULL REFERENCES cost_versions(id) ON DELETE RESTRICT, canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, @@ -152,18 +171,24 @@ CREATE TABLE IF NOT EXISTS cost_snapshots ( snapshot_stage TEXT NOT NULL DEFAULT 'estimated', source TEXT NOT NULL DEFAULT 'resolved', metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + cost_basis_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ NOT NULL, + superseded_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_subject_item - ON cost_snapshots (subject_type, subject_id, cost_item_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_active_subject_item + ON cost_snapshots (subject_type, subject_id, cost_item_id) + WHERE superseded_at IS NULL; 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 INDEX IF NOT EXISTS idx_cost_snapshots_product ON cost_snapshots (canonical_product_id, resolved_at DESC); +CREATE INDEX IF NOT EXISTS idx_cost_snapshots_active_subject + ON cost_snapshots (subject_type, subject_id) + WHERE superseded_at IS NULL; DO $$ BEGIN @@ -172,11 +197,6 @@ BEGIN 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_carrier_type') THEN - ALTER TABLE cost_snapshots - ADD CONSTRAINT chk_cost_snapshots_carrier_type - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared')); - END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_stage') THEN ALTER TABLE cost_snapshots ADD CONSTRAINT chk_cost_snapshots_stage @@ -194,8 +214,17 @@ BEGIN END IF; END $$; -COMMENT ON TABLE cost_snapshots IS '成本快照:订单项/工单/人工单实际使用的成本版本与金额'; +COMMENT ON TABLE cost_snapshots IS + '成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode'; COMMENT ON COLUMN cost_snapshots.source IS 'resolved=成本中心命中;legacy_rule=旧规则回退;manual_input=人工;missing=未匹配'; +COMMENT ON COLUMN cost_snapshots.cost_basis_at IS + '解析成本版本时使用的业务时间:订单支付时间/工单验收时间/人工完成时间'; +COMMENT ON COLUMN cost_snapshots.superseded_at IS + '被更新的实际成本替代的时间;非空行只保留历史,不再参与成本汇总'; + +-- --------------------------------------------------------------------------- +-- 4. 成本异常队列 +-- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS cost_exceptions ( id BIGSERIAL PRIMARY KEY, @@ -203,7 +232,6 @@ CREATE TABLE IF NOT EXISTS cost_exceptions ( subject_id BIGINT NOT NULL, order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, - carrier_type TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, @@ -216,6 +244,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_subject_reason ON cost_exceptions (subject_type, subject_id, reason); CREATE INDEX IF NOT EXISTS idx_cost_exceptions_status_created ON cost_exceptions (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_item + ON cost_exceptions (order_item_id, status); +-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。 +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id + ON order_items (fulfillment_mode, id); DO $$ BEGIN @@ -231,14 +264,212 @@ BEGIN END IF; END $$; -COMMENT ON TABLE cost_exceptions IS '成本缺失/冲突异常队列;open 数量必须在后台暴露'; +COMMENT ON TABLE cost_exceptions IS + '成本缺失/冲突异常队列;open 数量必须在后台暴露;履约方式看 order_items.fulfillment_mode'; + +-- --------------------------------------------------------------------------- +-- 5. 接单模板过渡列 +-- --------------------------------------------------------------------------- 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'; + ADD COLUMN IF NOT EXISTS cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT; 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=旧规则金额'; +COMMENT ON COLUMN work_product_rules.cost_item_id IS + '过渡列:非空则金额优先走成本中心;空则回退 reward_amount/unit_price_fen'; + +-- --------------------------------------------------------------------------- +-- 6. 订单项收入固化 +-- --------------------------------------------------------------------------- + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS revenue_amount_fen BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS revenue_basis TEXT NOT NULL DEFAULT 'unknown'; + +DO $$ +BEGIN + 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 >= 0); + END IF; + 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 ('order_total', 'equal_split', 'platform_item', 'manual', 'unknown')); + END IF; +END $$; + +-- 历史订单回填:当前生产快照全部是一单一品,仍按通用规则处理多订单项。 +WITH item_counts AS ( + SELECT order_id, COUNT(*)::int AS item_count + FROM order_items + GROUP BY order_id +), +ranked AS ( + SELECT + oi.id, + GREATEST(COALESCE(o.total_amount, 0), 0)::bigint AS order_revenue_fen, + ic.item_count, + ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id ASC) AS item_rank + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN item_counts ic ON ic.order_id = oi.order_id +) +UPDATE order_items oi +SET + revenue_amount_fen = CASE + WHEN r.item_count = 1 THEN r.order_revenue_fen + ELSE (r.order_revenue_fen / r.item_count) + + CASE WHEN r.item_rank = r.item_count THEN (r.order_revenue_fen % r.item_count) ELSE 0 END + END, + revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END, + updated_at = NOW() +FROM ranked r +WHERE oi.id = r.id + AND oi.revenue_basis = 'unknown'; + +CREATE INDEX IF NOT EXISTS idx_order_items_revenue_basis + ON order_items (revenue_basis); + +COMMENT ON COLUMN order_items.revenue_amount_fen IS + '订单项收入(分):一单一品=订单实付;多品=当前按等额拆分,basis 标明口径'; +COMMENT ON COLUMN order_items.revenue_basis IS + 'order_total=整单归属于唯一订单项;equal_split=多订单项等额拆分;platform_item=平台明细;manual/unknown=未定'; + +-- --------------------------------------------------------------------------- +-- 7. 成本版本页与成本中心页的聚合覆盖索引 +-- --------------------------------------------------------------------------- + +CREATE INDEX IF NOT EXISTS idx_work_orders_rule_cover + ON work_orders (product_rule_id, order_id, reward_amount); +CREATE INDEX IF NOT EXISTS idx_order_items_revenue_cover + ON order_items (order_id, revenue_amount_fen); + +-- 商品目录按分类名 lateral join work_categories,没有索引时每行都扫全表。 +CREATE INDEX IF NOT EXISTS idx_work_categories_name + ON work_categories (name); + +-- --------------------------------------------------------------------------- +-- 8. 系统内置成本项 +-- --------------------------------------------------------------------------- + +-- 接单平台打手报酬:实际金额取工单 reward_amount,预估阶段不参与; +-- 版本金额 0 只是占位,让 cost_snapshots.cost_version_id 有稳定引用。 +INSERT INTO cost_items ( + code, name, cost_model, pricing_mode, unit_name, currency, + status, note, created_at, updated_at +) +VALUES ( + 'sys_work_order_reward', + '接单平台打手报酬(实际结算)', + 'work_order_reward', + 'per_order', + '单', + 'CNY', + 'active', + '系统内置:实际金额取工单 reward_amount;版本金额仅占位,不参与预估成本', + NOW(), + NOW() +) +ON CONFLICT (code) DO UPDATE +SET + name = EXCLUDED.name, + cost_model = EXCLUDED.cost_model, + pricing_mode = EXCLUDED.pricing_mode, + note = EXCLUDED.note, + updated_at = NOW(); + +-- 人工客服履约成本:只能由公司人工支出登记给出实际金额。 +INSERT INTO cost_items ( + code, name, cost_model, pricing_mode, unit_name, currency, + status, note, created_at, updated_at +) +VALUES ( + 'sys_manual_service', + '人工客服履约成本', + 'manual_actual', + 'per_order', + '单', + 'CNY', + 'active', + '系统内置:由公司人工支出登记写入 actual 快照;逐单实际金额,不按工时或固定单价', + NOW(), + NOW() +) +ON CONFLICT (code) DO UPDATE +SET + name = EXCLUDED.name, + cost_model = EXCLUDED.cost_model, + pricing_mode = EXCLUDED.pricing_mode, + note = EXCLUDED.note, + updated_at = NOW(); + +-- 自动发货上游采购成本 +INSERT INTO cost_items ( + code, name, cost_model, pricing_mode, unit_name, currency, + status, note, created_at, updated_at +) +VALUES ( + 'sys_affiliate_purchase', + '自动发货上游采购成本', + 'affiliate_purchase', + 'per_item', + '件', + 'CNY', + 'active', + '系统内置:按上游 SKU 固定成本版本结算', + NOW(), + NOW() +) +ON CONFLICT (code) DO UPDATE +SET + name = EXCLUDED.name, + cost_model = EXCLUDED.cost_model, + pricing_mode = EXCLUDED.pricing_mode, + note = EXCLUDED.note, + updated_at = NOW(); + +-- 接单平台:active 占位版本,供 cost_snapshots.cost_version_id 引用。 +INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, source, + status, change_reason, created_at, updated_at +) +SELECT + ci.id, 'global', '', '', NULL, NULL, 0, 0, + '1970-01-01T00:00:00+08:00', 'manual', 'active', + '系统占位:接单平台实际金额取工单 reward_amount', + NOW(), NOW() +FROM cost_items ci +WHERE ci.code = 'sys_work_order_reward' +ON CONFLICT DO NOTHING; + +-- 人工客服:draft 版本,避免在财务未填写金额时静默按 0 计成本。 +INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, source, + status, change_reason, created_at, updated_at +) +SELECT + ci.id, 'global', '', '', NULL, NULL, 0, 0, + '1970-01-01T00:00:00+08:00', 'manual', 'draft', + '草稿占位:待财务填写人工客服实际按单成本后启用', + NOW(), NOW() +FROM cost_items ci +WHERE ci.code = 'sys_manual_service' +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 9. 财务角色补齐成本中心维护权限 +-- --------------------------------------------------------------------------- + +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES ('finance', 'finance.config') +ON CONFLICT (role_key, permission_key) DO NOTHING; diff --git a/apps/backend/src/db/migrations/105_cost_center_bootstrap_fix.sql b/apps/backend/src/db/migrations/105_cost_center_bootstrap_fix.sql deleted file mode 100644 index 8f5aceac..00000000 --- a/apps/backend/src/db/migrations/105_cost_center_bootstrap_fix.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 105_cost_center_bootstrap_fix.sql --- --- 背景: --- 103_product_catalog 已执行,里面把 normalized_quantity 直接用 quantity 填充。 --- quantity 长期几乎恒为 1,会让「5个」类 SKU 的归一数量永远无法再通过解析器修正。 --- --- 处理: --- 1. 清空初始化时写入的归一数量,由成本中心「回填数量」用 resolveSkuNameQuantity 重新解析; --- 2. 给财务角色补 finance.config,允许维护商品映射与成本版本(管理员仍绕过权限检查)。 --- --- 该列是本期新列,清空不会影响既有订单字段;回填接口只处理 normalized_quantity IS NULL。 - -UPDATE order_items -SET normalized_quantity = NULL -WHERE normalized_quantity IS NOT NULL; - -INSERT INTO admin_role_permissions (role_key, permission_key) -VALUES ('finance', 'finance.config') -ON CONFLICT DO NOTHING; diff --git a/apps/backend/src/db/migrations/109_sales_daily_facts.sql b/apps/backend/src/db/migrations/105_sales_daily_facts.sql similarity index 76% rename from apps/backend/src/db/migrations/109_sales_daily_facts.sql rename to apps/backend/src/db/migrations/105_sales_daily_facts.sql index 502aa470..32d8e2e4 100644 --- a/apps/backend/src/db/migrations/109_sales_daily_facts.sql +++ b/apps/backend/src/db/migrations/105_sales_daily_facts.sql @@ -1,9 +1,11 @@ --- 109_sales_daily_facts.sql —— 店铺 × 日期 × 标准商品 × 履约载体的销售事实。 +-- 105_sales_daily_facts.sql —— 店铺 × 日期 × 标准商品 × 履约方式的销售事实。 -- --- 口径(2026-10-22 确认): +-- 口径(2026-10-23 重写): -- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at; --- * 收入 = order_items.revenue_amount_fen(迁移 108 固化),不再直接拿整单金额; --- * 未归一商品允许 canonical_product_id 为空,单独成“未归一”行,保证总收入能对平; +-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额; +-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash / +-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐; +-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平; -- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count; -- * 退款暂未接入,refund_amount_fen 先按 pay_status=refunded 预留。 @@ -13,7 +15,7 @@ CREATE TABLE IF NOT EXISTS sales_daily_facts ( provider TEXT NOT NULL, shop_id TEXT NOT NULL DEFAULT '', canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, - carrier_type TEXT NOT NULL, + fulfillment_mode TEXT NOT NULL, order_count INTEGER NOT NULL DEFAULT 0, item_count INTEGER NOT NULL DEFAULT 0, quantity NUMERIC(14, 3) NOT NULL DEFAULT 0, @@ -29,14 +31,14 @@ CREATE TABLE IF NOT EXISTS sales_daily_facts ( updated_at TIMESTAMPTZ NOT NULL ); --- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为“未归一”桶。 +-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。 CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key ON sales_daily_facts ( stat_date, provider, shop_id, COALESCE(canonical_product_id, 0), - carrier_type + fulfillment_mode ); CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date ON sales_daily_facts (stat_date DESC); @@ -44,15 +46,19 @@ CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop ON sales_daily_facts (provider, shop_id, stat_date DESC); CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product ON sales_daily_facts (canonical_product_id, stat_date DESC); +CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode + ON sales_daily_facts (fulfillment_mode, stat_date DESC); DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_carrier' + SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode' ) THEN ALTER TABLE sales_daily_facts - ADD CONSTRAINT chk_sales_daily_facts_carrier - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared', 'unknown')); + ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode + CHECK (fulfillment_mode IN ( + 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' + )); END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage' @@ -87,9 +93,11 @@ BEGIN END $$; COMMENT ON TABLE sales_daily_facts IS - '店铺×日期(Asia/Shanghai)×标准商品×履约载体的销售与成本事实;报表直接读这张表'; + '店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表'; COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS - '标准商品;为空表示渠道映射未命中,报表单独展示“未归一”占比'; + '标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比'; +COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS + '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工'; COMMENT ON COLUMN sales_daily_facts.cost_stage IS 'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际'; COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS diff --git a/apps/backend/src/db/migrations/111_manual_expense_records.sql b/apps/backend/src/db/migrations/106_manual_expense_records.sql similarity index 76% rename from apps/backend/src/db/migrations/111_manual_expense_records.sql rename to apps/backend/src/db/migrations/106_manual_expense_records.sql index 067cb985..76b14845 100644 --- a/apps/backend/src/db/migrations/111_manual_expense_records.sql +++ b/apps/backend/src/db/migrations/106_manual_expense_records.sql @@ -1,11 +1,15 @@ --- 111_manual_expense_records.sql —— 公司人工支出/垫付登记。 +-- 106_manual_expense_records.sql —— 公司人工支出登记 + 备用金账户编号可读化。 -- --- 与迁移 100 的 `manual_expenses` 严格区分: +-- 与迁移 100 的 manual_expenses 严格区分: -- manual_expenses = 客服备用金支出明细(减少客服余额) --- manual_expense_records = 公司口径人工支出/垫付登记(谁为哪个订单付了多少钱) +-- manual_expense_records = 公司口径人工支出登记(谁为哪个订单付了多少钱) -- 两者通过 fund_expense_id 关联,不合并、不改名、不共用索引名。 -- --- 本轮只做登记、审批、报销与冲正预留;影子总账分录在 finance_* 阶段接入。 +-- 口径(2026-10-23 确认): +-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价; +-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户; +-- * 历史人工支出不补录,只取少量样本做校准; +-- * 客服必须在上线后 24 小时内为人工单录入成本。 CREATE TABLE IF NOT EXISTS manual_expense_records ( id BIGSERIAL PRIMARY KEY, @@ -14,7 +18,7 @@ CREATE TABLE IF NOT EXISTS manual_expense_records ( order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL, - cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT, + cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT, expense_category TEXT NOT NULL DEFAULT 'other', amount_fen BIGINT NOT NULL, currency TEXT NOT NULL DEFAULT 'CNY', @@ -129,9 +133,9 @@ BEGIN END $$; COMMENT ON TABLE manual_expense_records IS - '公司人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证;与客服备用金 manual_expenses 通过 fund_expense_id 关联'; + '公司人工支出登记:逐单实际金额;付款项目/收款方/凭证/审批;与客服备用金 manual_expenses 通过 fund_expense_id 关联'; COMMENT ON COLUMN manual_expense_records.fund_expense_id IS - '来源客服备用金支出(manual_expenses.id);公司直付为空'; + '来源客服备用金支出(manual_expenses.id);人工支出必须从备用金扣款,因此正常必有值'; COMMENT ON COLUMN manual_expense_records.cost_item_id IS '付款项目 -> cost_items;为空表示未归类,报表必须暴露未归类占比'; COMMENT ON COLUMN manual_expense_records.evidence_json IS @@ -159,3 +163,39 @@ CREATE INDEX IF NOT EXISTS idx_manual_expense_events_type COMMENT ON TABLE manual_expense_events IS '人工支出状态变更审计:只追加,不更新、不删除'; + +-- --------------------------------------------------------------------------- +-- 备用金账户编号改为可读的 FA-<用户名> +-- +-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里 +-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。 +-- --------------------------------------------------------------------------- + +WITH base AS ( + SELECT + fa.id, + 'FA-' || COALESCE( + NULLIF( + trim( + both '_' FROM upper( + regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g') + ) + ), + '' + ), + 'ACCOUNT-' || fa.id + ) AS base_no + FROM fund_accounts fa + LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id +), +numbered AS ( + SELECT + id, + base_no, + row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn + FROM base +) +UPDATE fund_accounts fa +SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END +FROM numbered n +WHERE fa.id = n.id; diff --git a/apps/backend/src/db/migrations/106_product_mapping_match_keys.sql b/apps/backend/src/db/migrations/106_product_mapping_match_keys.sql deleted file mode 100644 index 24e13de6..00000000 --- a/apps/backend/src/db/migrations/106_product_mapping_match_keys.sql +++ /dev/null @@ -1,72 +0,0 @@ --- 106_product_mapping_match_keys.sql —— 渠道商品映射支持 SKU 名称/系列消歧。 --- --- 背景:91 的 sku_code 会在同一店铺内被多个商品复用,单靠 --- provider + shop_id + platform_sku_code 无法唯一确定标准商品。 --- --- 规则: --- 1. sku_code:编码唯一,直接命中; --- 2. sku_code_name:编码冲突,但编码 + 精确 SKU 名称可唯一命中; --- 3. sku_code_series:编码冲突,但编码 + SKU 名称系列可唯一命中; --- 4. sku_name_series:编码无法提供时,用来源 + 店铺 + SKU 名称系列兜底; --- 5. 任何一层出现多个候选都不自动选择,交由冲突清单人工处理。 - -ALTER TABLE channel_product_mappings - ADD COLUMN IF NOT EXISTS match_type TEXT NOT NULL DEFAULT 'sku_code', - ADD COLUMN IF NOT EXISTS platform_sku_name_key TEXT NOT NULL DEFAULT ''; - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT, - ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type' - ) THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_match_type - CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series')); - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key' - ) THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_match_key - CHECK (platform_sku_code <> '' OR platform_sku_name_key <> ''); - END IF; -END $$; - -DROP INDEX IF EXISTS uq_channel_product_mappings_key; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key - ON channel_product_mappings ( - provider, - shop_id, - platform_sku_code, - match_type, - platform_sku_name_key - ); - -DROP INDEX IF EXISTS idx_channel_product_mappings_lookup; -CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_match_lookup - ON channel_product_mappings ( - provider, - shop_id, - platform_sku_code, - match_type, - platform_sku_name_key, - enabled - ); - -CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup - ON order_items (normalized_sku_name, normalized_sku_series); - -COMMENT ON COLUMN channel_product_mappings.match_type IS - '映射匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series'; -COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS - '归一后的 SKU 名称或数量系列键;sku_code 模式为空串'; -COMMENT ON COLUMN order_items.normalized_sku_name IS - 'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧'; -COMMENT ON COLUMN order_items.normalized_sku_series IS - '剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配'; diff --git a/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql b/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql new file mode 100644 index 00000000..2b630a18 --- /dev/null +++ b/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql @@ -0,0 +1,48 @@ +-- 107_order_item_fulfillment_mode.sql —— 统一履约事实。 +-- +-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode, +-- 不再各自用 provider / voucher / task 临时推断。 +-- +-- 设计要点(2026-10-23 重写): +-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode; +-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉; +-- 3. 核销码只是状态,不参与履约判定; +-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务), +-- 由统一决策服务 + 定时兜底在 24 小时内补齐。 +-- +-- 本迁移只做 DDL;31 万行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。 + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT, + ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode' + ) THEN + ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_fulfillment_mode + CHECK ( + fulfillment_mode IS NULL + OR fulfillment_mode IN ( + 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' + ) + ); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode + ON order_items (fulfillment_mode, order_id); + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending + ON order_items (order_id) + WHERE fulfillment_mode IS NULL; + +COMMENT ON COLUMN order_items.fulfillment_mode IS + '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段'; +COMMENT ON COLUMN order_items.fulfillment_decided_at IS + '履约方式最后一次判定时间'; +COMMENT ON COLUMN order_items.fulfillment_reason IS + '履约方式判定来源:订单同步 / 定时归集 / 历史回填等'; diff --git a/apps/backend/src/db/migrations/107_product_catalog_carrier.sql b/apps/backend/src/db/migrations/107_product_catalog_carrier.sql deleted file mode 100644 index 34320fae..00000000 --- a/apps/backend/src/db/migrations/107_product_catalog_carrier.sql +++ /dev/null @@ -1,95 +0,0 @@ --- 107_product_catalog_carrier.sql —— 标准商品增加「默认履约载体」。 --- --- 背景:商品主数据此前只有游戏/品类分类,成本项目与成本版本却按 --- 自动发货 / 接单平台 / 人工 三类执行载体配置,后台配置时找不到对应商品。 --- --- 口径(2026-10-22 修正): --- 1. 只有真正走 affiliate-dash(自动发货执行器)的商品才算 affiliate_dash; --- 以 fulfillment_tasks.executor_key = 'affiliate_dash' 的履约证据为准, --- 按 context_json.skuCode / skuName 建标准商品,SKU 固定为 `aff:`。 --- 2. 其余标准商品(接单模板初始化出来的 tpl:*)全部是 work_order; --- 渠道映射只代表「平台 SKU 能归一到标准商品」,不代表自动发货。 --- 3. manual / shared 不回填,由后台按需手工调整。 --- --- 2026-10-22 生产快照口径:自动发货任务 41,676 条,涉及 28 个 skuCode。 - -ALTER TABLE product_catalog - ADD COLUMN IF NOT EXISTS carrier_type TEXT NOT NULL DEFAULT 'work_order'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_carrier_type' - ) THEN - ALTER TABLE product_catalog - ADD CONSTRAINT chk_product_catalog_carrier_type - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared')); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS idx_product_catalog_status_carrier - ON product_catalog (status, carrier_type, canonical_name); - --- 1) 先把非自动发货商品统一归到接单平台(模板商品、历史误标都回到 work_order)。 -UPDATE product_catalog -SET carrier_type = 'work_order', - updated_at = NOW() -WHERE carrier_type IS DISTINCT FROM 'work_order' - AND canonical_sku NOT LIKE 'aff:%'; - --- 2) 按自动发货履约证据建商品:每个 skuCode 取一个最像人名的名称。 -WITH auto_task AS ( - SELECT - btrim(context_json->>'skuCode') AS sku_code, - btrim(context_json->>'skuName') AS sku_name, - created_at - FROM fulfillment_tasks - WHERE executor_key = 'affiliate_dash' - AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' - AND btrim(COALESCE(context_json->>'skuName', '')) <> '' -), -agg AS ( - SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at - FROM auto_task - GROUP BY sku_code -), -ranked AS ( - SELECT - sku_code, - sku_name, - row_number() OVER ( - PARTITION BY sku_code - ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name - ) AS rn - FROM auto_task - GROUP BY sku_code, sku_name -) -INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, carrier_type, status, - attributes_json, created_at, updated_at -) -SELECT - 'aff:' || ranked.sku_code, - ranked.sku_name, - '', - CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END, - 'affiliate_dash', - 'active', - jsonb_build_object( - 'source', 'affiliate_dash_task', - 'skuCode', ranked.sku_code, - 'taskCount', agg.task_count, - 'lastFulfilledAt', agg.last_fulfilled_at - ), - NOW(), - NOW() -FROM ranked -JOIN agg ON agg.sku_code = ranked.sku_code -WHERE ranked.rn = 1 -ON CONFLICT (canonical_sku) DO UPDATE -SET carrier_type = 'affiliate_dash', - attributes_json = EXCLUDED.attributes_json, - updated_at = NOW(); - -COMMENT ON COLUMN product_catalog.carrier_type IS - '默认履约载体:affiliate_dash=自动发货,work_order=接单平台,manual=人工,shared=多载体共用'; diff --git a/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql b/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql deleted file mode 100644 index 4302579f..00000000 --- a/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql +++ /dev/null @@ -1,98 +0,0 @@ --- 108_cost_snapshot_runtime.sql —— 成本快照进入运行层。 --- --- 目标: --- 1. 成本版本按业务发生时间(订单支付/工单验收)解析,而不是迁移或回填时的 now(); --- 2. 预估值可以在结算时被实际值替代,替代后旧快照保留但不再参与汇总; --- 3. 订单项固化收入金额,销售事实可以按订单项归集,不再直接拿整单金额。 --- --- 口径(2026-10-22 确认): --- * 收入按 paid_at 归属销售日;成本版本按 paid_at / accepted_at 解析; --- * 一单一品时订单收入全部归该订单项;多订单项按等额拆分,余数给最后一项, --- 并把 revenue_basis 标成 equal_split,后续有平台明细时再替换; --- * 历史快照不删除,只把被替代行标记 superseded_at。 - -ALTER TABLE cost_snapshots - ADD COLUMN IF NOT EXISTS cost_basis_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ; - --- 已存在的快照发生在旧逻辑的 now(),没有更好的业务时间,先用 resolved_at 兜底。 -UPDATE cost_snapshots -SET cost_basis_at = resolved_at -WHERE cost_basis_at IS NULL; - -ALTER TABLE cost_snapshots - ALTER COLUMN cost_basis_at SET DEFAULT NOW(); - --- 同一订单项/工单/人工单的同一成本项,只允许一条“当前有效”快照。 -DROP INDEX IF EXISTS uq_cost_snapshots_subject_item; -CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_active_subject_item - ON cost_snapshots (subject_type, subject_id, cost_item_id) - WHERE superseded_at IS NULL; - -CREATE INDEX IF NOT EXISTS idx_cost_snapshots_active_subject - ON cost_snapshots (subject_type, subject_id) - WHERE superseded_at IS NULL; - -COMMENT ON COLUMN cost_snapshots.cost_basis_at IS - '解析成本版本时使用的业务时间:订单支付时间/工单验收时间/人工完成时间'; -COMMENT ON COLUMN cost_snapshots.superseded_at IS - '被更新的实际成本替代的时间;非空行只保留历史,不再参与成本汇总'; - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS revenue_amount_fen BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS revenue_basis TEXT NOT NULL DEFAULT 'unknown'; - -DO $$ -BEGIN - 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 >= 0); - END IF; - 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 ('order_total', 'equal_split', 'platform_item', 'manual', 'unknown')); - END IF; -END $$; - --- 历史订单回填:当前生产快照全部是一单一品,仍按通用规则处理多订单项。 -WITH item_counts AS ( - SELECT order_id, COUNT(*)::int AS item_count - FROM order_items - GROUP BY order_id -), -ranked AS ( - SELECT - oi.id, - GREATEST(COALESCE(o.total_amount, 0), 0)::bigint AS order_revenue_fen, - ic.item_count, - ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id ASC) AS item_rank - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN item_counts ic ON ic.order_id = oi.order_id -) -UPDATE order_items oi -SET - revenue_amount_fen = CASE - WHEN r.item_count = 1 THEN r.order_revenue_fen - ELSE (r.order_revenue_fen / r.item_count) - + CASE WHEN r.item_rank = r.item_count THEN (r.order_revenue_fen % r.item_count) ELSE 0 END - END, - revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END, - updated_at = NOW() -FROM ranked r -WHERE oi.id = r.id - AND oi.revenue_basis = 'unknown'; - -CREATE INDEX IF NOT EXISTS idx_order_items_revenue_basis - ON order_items (revenue_basis); - -COMMENT ON COLUMN order_items.revenue_amount_fen IS - '订单项收入(分):一单一品=订单实付;多品=当前按等额拆分,basis 标明口径'; -COMMENT ON COLUMN order_items.revenue_basis IS - 'order_total=整单归属于唯一订单项;equal_split=多订单项等额拆分;platform_item=平台明细;manual/unknown=未定'; diff --git a/apps/backend/src/db/migrations/110_system_cost_items.sql b/apps/backend/src/db/migrations/110_system_cost_items.sql deleted file mode 100644 index 260a79ca..00000000 --- a/apps/backend/src/db/migrations/110_system_cost_items.sql +++ /dev/null @@ -1,95 +0,0 @@ --- 110_system_cost_items.sql —— 内置接单平台实际报酬与人工客服成本项。 --- --- 背景:接单平台的成本金额在验收时已经由 work_orders.reward_amount 确定, --- 不需要财务再按模板猜一个预估价;人工客服成本则必须由人工支出登记给出。 --- --- 口径: --- 1. sys_work_order_reward 由系统维护,预估阶段不参与;工单验收后按实际 --- reward_amount 直接写 actual 快照。版本金额 0 只是占位,不参与汇总。 --- 2. sys_manual_service 由人工支出登记使用,先建草稿版本占位; --- 财务填写真实按单金额并启用后,才参与成本解析。 - -INSERT INTO cost_items ( - code, name, cost_type, carrier_type, pricing_mode, unit_name, currency, - status, note, created_at, updated_at -) -VALUES ( - 'sys_work_order_reward', - '接单平台打手报酬(实际结算)', - 'work_order_reward', - 'work_order', - 'per_order', - '单', - 'CNY', - 'active', - '系统内置:实际金额取工单 reward_amount;版本金额仅占位,不参与预估成本', - NOW(), - NOW() -) -ON CONFLICT (code) DO UPDATE -SET - name = EXCLUDED.name, - cost_type = EXCLUDED.cost_type, - carrier_type = EXCLUDED.carrier_type, - pricing_mode = EXCLUDED.pricing_mode, - note = EXCLUDED.note, - updated_at = NOW(); - -INSERT INTO cost_items ( - code, name, cost_type, carrier_type, pricing_mode, unit_name, currency, - status, note, created_at, updated_at -) -VALUES ( - 'sys_manual_service', - '人工客服履约成本', - 'manual_service', - 'manual', - 'per_order', - '单', - 'CNY', - 'active', - '系统内置:由公司人工支出登记写入 actual 快照;草稿版本待财务填写实际单价', - NOW(), - NOW() -) -ON CONFLICT (code) DO UPDATE -SET - name = EXCLUDED.name, - cost_type = EXCLUDED.cost_type, - carrier_type = EXCLUDED.carrier_type, - pricing_mode = EXCLUDED.pricing_mode, - note = EXCLUDED.note, - updated_at = NOW(); - --- 接单平台:active 占位版本,供 cost_snapshots.cost_version_id 引用。 -INSERT INTO cost_versions ( - cost_item_id, scope_type, provider, shop_id, canonical_product_id, - work_product_rule_id, amount_fen, rate_bps, effective_from, source, - status, change_reason, created_at, updated_at -) -SELECT - ci.id, 'global', '', '', NULL, NULL, 0, 0, - '1970-01-01T00:00:00+08:00', 'manual', 'active', - '系统占位:接单平台实际金额取工单 reward_amount', - NOW(), NOW() -FROM cost_items ci -WHERE ci.code = 'sys_work_order_reward' -ON CONFLICT DO NOTHING; - --- 人工客服:draft 版本,避免在财务未填写金额时静默按 0 计成本。 -INSERT INTO cost_versions ( - cost_item_id, scope_type, provider, shop_id, canonical_product_id, - work_product_rule_id, amount_fen, rate_bps, effective_from, source, - status, change_reason, created_at, updated_at -) -SELECT - ci.id, 'global', '', '', NULL, NULL, 0, 0, - '1970-01-01T00:00:00+08:00', 'manual', 'draft', - '草稿占位:待财务填写人工客服实际按单成本后启用', - NOW(), NOW() -FROM cost_items ci -WHERE ci.code = 'sys_manual_service' -ON CONFLICT DO NOTHING; - -COMMENT ON COLUMN cost_items.code IS - '成本项编码;sys_ 前缀为系统内置项,业务侧不要删除或改编码'; diff --git a/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql b/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql deleted file mode 100644 index f1f160c2..00000000 --- a/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 112_cost_reporting_indexes.sql —— 成本版本页按接单规则汇总用户实付。 --- --- 背景:成本版本列表需要同时展示「用户实付均价」和「模板发单金额」。 --- 原查询按 work_orders.product_rule_id 聚合约 11 万工单 + 31 万订单项, --- 没有覆盖索引时需要 1 秒以上;以下两个覆盖索引把聚合降到约 150 毫秒。 - -CREATE INDEX IF NOT EXISTS idx_work_orders_rule_cover - ON work_orders (product_rule_id) - INCLUDE (order_item_id) - WHERE order_item_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_order_items_revenue_cover - ON order_items (id) - INCLUDE (order_id, revenue_amount_fen); diff --git a/apps/backend/src/db/migrations/113_fund_account_no_readable.sql b/apps/backend/src/db/migrations/113_fund_account_no_readable.sql deleted file mode 100644 index a9d25c92..00000000 --- a/apps/backend/src/db/migrations/113_fund_account_no_readable.sql +++ /dev/null @@ -1,35 +0,0 @@ --- 113_fund_account_no_readable.sql —— 备用金账户编号改为可读的 FA-<用户名>。 --- --- 背景:历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B), --- 在「备用金」列表里无法一眼对应到具体客服。 --- 新规则:FA-<大写用户名>,例如 FA-KF_2、FA-JIAJIALI; --- 同一用户名重名时追加 -2、-3。 - -WITH base AS ( - SELECT - fa.id, - 'FA-' || COALESCE( - NULLIF( - trim( - both '_' FROM upper( - regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g') - ) - ), - '' - ), - 'ACCOUNT-' || fa.id - ) AS base_no - FROM fund_accounts fa - LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id -), -numbered AS ( - SELECT - id, - base_no, - row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn - FROM base -) -UPDATE fund_accounts fa -SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END -FROM numbered n -WHERE fa.id = n.id; diff --git a/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql b/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql deleted file mode 100644 index cfdfcb27..00000000 --- a/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 114_order_item_fulfillment_mode.sql --- --- 统一履约事实:订单页、成本快照、销售事实只读 order_items.fulfillment_mode, --- 不再各自用 provider / voucher / task 临时推断。 --- --- 说明: --- 1. 本迁移只做 DDL;31 万行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。 --- 2. product_catalog / cost_items / cost_snapshots / sales_daily_facts 的 --- carrier_type CHECK 目前都不接受 legacy_platform,必须同时放宽, --- 否则历史平台数据写入会在运行时报错。 - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS fulfillment_mode text, - ADD COLUMN IF NOT EXISTS fulfillment_decided_at timestamptz, - ADD COLUMN IF NOT EXISTS fulfillment_reason text; - -ALTER TABLE order_items - ADD CONSTRAINT chk_order_items_fulfillment_mode - CHECK ( - fulfillment_mode IS NULL - OR fulfillment_mode IN ('work_order', 'affiliate_dash', 'legacy_platform', 'manual') - ); - -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode - ON order_items (fulfillment_mode, order_id); - -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending - ON order_items (order_id) - WHERE fulfillment_mode IS NULL; - -ALTER TABLE product_catalog DROP CONSTRAINT IF EXISTS chk_product_catalog_carrier_type; -ALTER TABLE product_catalog ADD CONSTRAINT chk_product_catalog_carrier_type - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); - -ALTER TABLE cost_items DROP CONSTRAINT IF EXISTS chk_cost_items_carrier_type; -ALTER TABLE cost_items ADD CONSTRAINT chk_cost_items_carrier_type - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); - -ALTER TABLE cost_snapshots DROP CONSTRAINT IF EXISTS chk_cost_snapshots_carrier_type; -ALTER TABLE cost_snapshots ADD CONSTRAINT chk_cost_snapshots_carrier_type - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); - -ALTER TABLE sales_daily_facts DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_carrier; -ALTER TABLE sales_daily_facts ADD CONSTRAINT chk_sales_daily_facts_carrier - CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared', 'unknown'));