docs(finance): 新增财务与履约 v2 迁移草案与同步链路对照
This commit is contained in:
@@ -159,6 +159,8 @@
|
||||
| --- | --- | --- |
|
||||
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | **已过时**:`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` | 已落地为 `103`–`105`;草案里的 `carrier_type` 已被 `cost_model` / `fulfillment_mode` 取代,仅作设计追溯 |
|
||||
| [drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql](drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql) | 渠道/状态/来源拆列、`sku_channel_rules`、异常两级化、水位表、卡券成本项 | 已落地为 `apps/backend/src/db/migrations/112_fulfillment_channel_split.sql`;草案保留作设计追溯 |
|
||||
| [drafts/2026-10-31-同步链路重构-旧函数对照.md](drafts/2026-10-31-同步链路重构-旧函数对照.md) | 16 个旧同步/回填入口 → 6 个新管道作业的对照与验收口径 | 已落地:三条管道服务 + 六个定时任务 + 后台「履约渠道」页 |
|
||||
| [archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql](archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | **已作废**:`manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线,人工成本改走「人工录单 → 扣备用金 → 写快照」;文件已移入 archive |
|
||||
|
||||
## 六、厂商协议与外部资料
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
-- 112_fulfillment_channel_split.sql —— 草案(已落地为 apps/backend/src/db/migrations/112_fulfillment_channel_split.sql)
|
||||
--
|
||||
-- 目标:把「渠道 / 状态 / 决策来源」从一列拆成三列,并把渠道决策从
|
||||
-- 「有没有任务」反推改为「配置驱动」。
|
||||
--
|
||||
-- 生产前置:
|
||||
-- · 生产迁移停在 111(2026-10-30 20:32);本迁移为 112,编号未被占用。
|
||||
-- · 生产数据:orders 438,740 / order_items 438,750 / 10.1 GB。
|
||||
-- · order_items 上任何全表 UPDATE 约 34s,超过 15s 语句超时——
|
||||
-- 所以本迁移**只做 DDL 与小表种子**,438k 行回填交给
|
||||
-- `scripts/backfill-fulfillment-channel.ts`(分批、幂等、可续跑)。
|
||||
--
|
||||
-- 本迁移不做的事:
|
||||
-- · 不删除 fulfillment_mode(等代码切换完成后由 113 删除)
|
||||
-- · 不清派生数据(由断代脚本执行)
|
||||
-- · 不改历史订单(回填脚本按断代日归档)
|
||||
--
|
||||
-- 与旧模型的关系:
|
||||
-- 旧:order_items.fulfillment_mode ∈ work_order / affiliate_dash /
|
||||
-- legacy_platform / manual(四值混渠道与兜底)
|
||||
-- 新:fulfillment_channel ∈ work_order / affiliate_dash /
|
||||
-- third_party_voucher / manual(可为空)
|
||||
-- fulfillment_status ∈ pending / assigned / archived
|
||||
-- fulfillment_source ∈ auto_rule / auto_mapping / carrier_evidence /
|
||||
-- manual_confirm / operator_assigned / cutover_archive
|
||||
--
|
||||
-- 迁移由 migrate.ts 包在单事务中执行,因此这里不使用 BEGIN/COMMIT。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 订单项:三个新列 + 锁定 + 决策人
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_channel TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_status TEXT NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_source TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_decided_by BIGINT
|
||||
REFERENCES admin_users(id) ON DELETE SET NULL;
|
||||
|
||||
-- 约束:渠道与状态正交,且 pending 不得带渠道、assigned 必须有渠道。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_channel'
|
||||
) THEN
|
||||
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_channel;
|
||||
END IF;
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_channel
|
||||
CHECK (
|
||||
fulfillment_channel IS NULL
|
||||
OR fulfillment_channel IN (
|
||||
'work_order', 'affiliate_dash', 'third_party_voucher', 'manual'
|
||||
)
|
||||
);
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_status'
|
||||
) THEN
|
||||
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_status;
|
||||
END IF;
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_status
|
||||
CHECK (fulfillment_status IN ('pending', 'assigned', 'archived'));
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'chk_order_items_fulfillment_channel_status'
|
||||
) THEN
|
||||
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_channel_status;
|
||||
END IF;
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_channel_status
|
||||
CHECK (
|
||||
(fulfillment_status = 'pending' AND fulfillment_channel IS NULL)
|
||||
OR (fulfillment_status = 'assigned' AND fulfillment_channel IS NOT NULL)
|
||||
OR fulfillment_status = 'archived'
|
||||
);
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_source'
|
||||
) THEN
|
||||
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_source;
|
||||
END IF;
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_source
|
||||
CHECK (
|
||||
fulfillment_source IN (
|
||||
'', 'auto_rule', 'auto_mapping', 'carrier_evidence',
|
||||
'manual_confirm', 'operator_assigned', 'cutover_archive'
|
||||
)
|
||||
);
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN order_items.fulfillment_channel IS
|
||||
'履约渠道(新唯一真源):work_order 接单平台 / affiliate_dash 自动发货 / third_party_voucher 第三方卡券(仅统计)/ manual 人工;pending 时为空';
|
||||
COMMENT ON COLUMN order_items.fulfillment_status IS
|
||||
'渠道状态:pending 待分类 / assigned 已确定 / archived 断代归档';
|
||||
COMMENT ON COLUMN order_items.fulfillment_source IS
|
||||
'渠道决策来源:auto_rule 规则命中 / auto_mapping 映射命中 / carrier_evidence 历史载体证据 / manual_confirm 人工确认 / operator_assigned 运营指定 / cutover_archive 断代归档';
|
||||
COMMENT ON COLUMN order_items.fulfillment_locked IS
|
||||
'运营指定渠道后置 true:channel_assign 跳过该行,直到运营解锁';
|
||||
COMMENT ON COLUMN order_items.fulfillment_decided_by IS
|
||||
'最近一次渠道决策的后台用户;自动决策为空';
|
||||
|
||||
-- 旧列标记为废弃(113 删除)。保留是为了让过渡期代码仍可读历史值。
|
||||
COMMENT ON COLUMN order_items.fulfillment_mode IS
|
||||
'【已废弃,勿写入】旧履约方式四值枚举;读取请改用 fulfillment_channel + fulfillment_status。计划由 113 迁移删除';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 新索引:按状态与渠道取数,全部走部分索引
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 待分类队列:只扫 pending 行。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending_id_v2
|
||||
ON order_items (id)
|
||||
WHERE fulfillment_status = 'pending';
|
||||
|
||||
-- 渠道维度的常规读路径(列表筛选、日报汇总)。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_channel_status
|
||||
ON order_items (fulfillment_channel, fulfillment_status)
|
||||
INCLUDE (id)
|
||||
WHERE fulfillment_status <> 'archived';
|
||||
|
||||
-- 商品归一:已定渠道但还没挂商品的行。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_id
|
||||
ON order_items (id)
|
||||
WHERE fulfillment_status = 'assigned' AND canonical_product_id IS NULL;
|
||||
|
||||
-- 成本快照:已定渠道 + 已归一 + 参与核算的渠道。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_channel_product
|
||||
ON order_items (fulfillment_channel, canonical_product_id)
|
||||
INCLUDE (id)
|
||||
WHERE fulfillment_status = 'assigned'
|
||||
AND canonical_product_id IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_order_items_fulfillment_pending_id_v2 IS
|
||||
'待分类队列:pending 订单项部分索引(2026-10-31 渠道拆分)';
|
||||
COMMENT ON INDEX idx_order_items_unmapped_id IS
|
||||
'商品归一管道:已定渠道未归一部分索引';
|
||||
COMMENT ON INDEX idx_order_items_channel_product IS
|
||||
'成本快照管道:渠道 + 商品维度覆盖索引';
|
||||
|
||||
-- 注意:旧索引 idx_order_items_fulfillment_pending_id(108)与
|
||||
-- idx_order_items_fulfillment_product(110)在 113 里删除;
|
||||
-- 过渡期保留,避免旧代码路径失速。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 渠道规则表(L1):精确键规则,覆盖自动发货 / 人工 / 卡券 / 运营覆盖
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 设计说明:
|
||||
-- · 接单平台(work_order)不走本表:它由接单模板 + 商品匹配引擎决定,
|
||||
-- 规则引擎的结论由 channel_assign 直接写成 channel=work_order。
|
||||
-- · 本表只处理「精确键」类渠道:affiliate_dash / manual /
|
||||
-- third_party_voucher,以及运营对任意渠道的显式覆盖。
|
||||
-- · provider / shop_id 为空串表示「不限」。
|
||||
-- · 同一 SKU 命中多条同优先级但渠道不同的规则 → 不自动选,
|
||||
-- 订单落 pending 并进 routing_conflict 队列。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sku_channel_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
match_key_type TEXT NOT NULL DEFAULT 'sku_code',
|
||||
match_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
source TEXT NOT NULL DEFAULT 'operator',
|
||||
source_ref_id BIGINT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_key_type'
|
||||
) THEN
|
||||
ALTER TABLE sku_channel_rules
|
||||
ADD CONSTRAINT chk_sku_channel_rules_key_type
|
||||
CHECK (match_key_type IN ('sku_code', 'internal_sku', 'sku_name'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_channel'
|
||||
) THEN
|
||||
ALTER TABLE sku_channel_rules
|
||||
ADD CONSTRAINT chk_sku_channel_rules_channel
|
||||
CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_source'
|
||||
) THEN
|
||||
ALTER TABLE sku_channel_rules
|
||||
ADD CONSTRAINT chk_sku_channel_rules_source
|
||||
CHECK (source IN ('operator', 'affiliate', 'manual', 'voucher', 'template'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_key'
|
||||
) THEN
|
||||
ALTER TABLE sku_channel_rules
|
||||
ADD CONSTRAINT chk_sku_channel_rules_key
|
||||
CHECK (btrim(match_key) <> '');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 同一 (来源范围, 键类型, 键, 渠道) 只允许一条;运营覆盖用更高优先级而非重复行。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sku_channel_rules_key_channel
|
||||
ON sku_channel_rules (provider, shop_id, match_key_type, match_key, channel);
|
||||
|
||||
-- 解析路径:provider/shop 精确优先,空串表示不限;按优先级升序取第一条。
|
||||
CREATE INDEX IF NOT EXISTS idx_sku_channel_rules_lookup
|
||||
ON sku_channel_rules (match_key_type, match_key, enabled, priority)
|
||||
INCLUDE (provider, shop_id, channel);
|
||||
|
||||
COMMENT ON TABLE sku_channel_rules IS
|
||||
'L1 渠道路由规则:精确键(SKU / 内部 SKU / SKU 名称)→ 渠道;接单平台由模板引擎决定,不在此表';
|
||||
COMMENT ON COLUMN sku_channel_rules.priority IS
|
||||
'越小越优先:运营指定 10 / 自动发货 60 / 人工 70;同优先级多渠道=冲突';
|
||||
COMMENT ON COLUMN sku_channel_rules.source_ref_id IS
|
||||
'来源对象 id:affiliate=product_catalog.id / manual=manual_fulfillment_configs.id / 其余为空';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 从现有配置生成初始规则(小表,836 行级,可在迁移内完成)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 4.1 自动发货:商品目录里的 aff:<sku> 即渠道归属
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel,
|
||||
priority, source, source_ref_id, enabled, note, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'', '',
|
||||
'sku_code',
|
||||
btrim(substr(p.canonical_sku, 5)),
|
||||
'affiliate_dash',
|
||||
60,
|
||||
'affiliate',
|
||||
p.id,
|
||||
TRUE,
|
||||
'由商品目录 aff: 前缀生成',
|
||||
NOW(), NOW()
|
||||
FROM product_catalog p
|
||||
WHERE p.canonical_sku LIKE 'aff:%'
|
||||
AND p.status = 'active'
|
||||
AND btrim(substr(p.canonical_sku, 5)) <> ''
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING;
|
||||
|
||||
-- 4.2 人工:已配置的人工 SKU(生产 43 条,按内部 SKU 精确匹配)
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel,
|
||||
priority, source, source_ref_id, enabled, note, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'', '',
|
||||
'internal_sku',
|
||||
btrim(cfg.sku_key),
|
||||
'manual',
|
||||
70,
|
||||
'manual',
|
||||
cfg.id,
|
||||
cfg.enabled,
|
||||
'由人工履约配置生成',
|
||||
NOW(), NOW()
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE btrim(cfg.sku_key) <> ''
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING;
|
||||
|
||||
-- 4.3 第三方卡券:不预置,运营在「渠道规则」页按 SKU 添加(source='voucher')。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. 成本项:第三方卡券采购成本(仅用于统计,无载体)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
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_model
|
||||
CHECK (cost_model IN (
|
||||
'affiliate_purchase', 'work_order_reward', 'manual_actual',
|
||||
'third_party_voucher_cost', 'platform_fee', 'payment_fee',
|
||||
'after_sales_loss', 'other'
|
||||
));
|
||||
ELSE
|
||||
ALTER TABLE cost_items DROP CONSTRAINT chk_cost_items_cost_model;
|
||||
ALTER TABLE cost_items
|
||||
ADD CONSTRAINT chk_cost_items_cost_model
|
||||
CHECK (cost_model IN (
|
||||
'affiliate_purchase', 'work_order_reward', 'manual_actual',
|
||||
'third_party_voucher_cost', 'platform_fee', 'payment_fee',
|
||||
'after_sales_loss', 'other'
|
||||
));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO cost_items (
|
||||
code, name, cost_model, pricing_mode, unit_name, currency,
|
||||
status, note, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'sys_third_party_voucher_cost',
|
||||
'第三方卡券采购成本',
|
||||
'third_party_voucher_cost',
|
||||
'per_item',
|
||||
'件',
|
||||
'CNY',
|
||||
'active',
|
||||
'第三方卡券渠道:仅统计收入与采购成本,不创建履约载体、不展示给买家',
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 不预置 0 元占位版本:0 元会被误当成真实成本。
|
||||
-- 运营在成本中心按商品配置采购价后再生成版本。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. 异常表:从「一单一条」改为「SKU 级问题头 + 明细」
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 先摘掉旧的逐单唯一约束(subject_type, subject_id, reason)。
|
||||
DROP INDEX IF EXISTS uq_cost_exceptions_subject_reason;
|
||||
|
||||
ALTER TABLE cost_exceptions
|
||||
ADD COLUMN IF NOT EXISTS scope_key TEXT,
|
||||
ADD COLUMN IF NOT EXISTS item_count INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS first_seen_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||
|
||||
-- 存量行(断代脚本会整体清空)给一个可用的 scope_key,避免 NOT NULL 失败。
|
||||
UPDATE cost_exceptions
|
||||
SET scope_key = COALESCE(
|
||||
NULLIF(scope_key, ''),
|
||||
reason || '|' || COALESCE(order_item_id::text, subject_id::text)
|
||||
),
|
||||
first_seen_at = COALESCE(first_seen_at, created_at),
|
||||
last_seen_at = COALESCE(last_seen_at, created_at)
|
||||
WHERE scope_key IS NULL OR first_seen_at IS NULL OR last_seen_at IS NULL;
|
||||
|
||||
-- 只有 open / ignored 的问题头保持唯一;resolved 允许重复出现(历史留痕)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_scope_open
|
||||
ON cost_exceptions (scope_key)
|
||||
WHERE status <> 'resolved';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_reason_status
|
||||
ON cost_exceptions (reason, status, last_seen_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cost_exception_items (
|
||||
exception_id BIGINT NOT NULL REFERENCES cost_exceptions(id) ON DELETE CASCADE,
|
||||
order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (exception_id, order_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exception_items_item
|
||||
ON cost_exception_items (order_item_id);
|
||||
|
||||
COMMENT ON TABLE cost_exceptions IS
|
||||
'成本/分类问题队列(SKU 级):scope_key 相同的订单项聚合为一条问题,明细见 cost_exception_items';
|
||||
COMMENT ON COLUMN cost_exceptions.reason IS
|
||||
'classification_missing 待分类 / routing_conflict 规则冲突 / product_mapping_missing 未归一 / cost_missing 成本缺失 / manual_config_missing 人工 SKU 未配置';
|
||||
COMMENT ON COLUMN cost_exceptions.item_count IS
|
||||
'当前牵涉的订单项数(明细去重后的计数)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. 管道水位表
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_watermarks (
|
||||
pipeline TEXT PRIMARY KEY,
|
||||
last_item_id BIGINT NOT NULL DEFAULT 0,
|
||||
last_stat_date DATE,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO finance_watermarks (pipeline, last_item_id, last_stat_date, last_success_at)
|
||||
VALUES
|
||||
('channel_assign', 0, NULL, NULL),
|
||||
('carrier_dispatch', 0, NULL, NULL),
|
||||
('product_normalize', 0, NULL, NULL),
|
||||
('cost_snapshot', 0, NULL, NULL),
|
||||
('sales_facts', 0, NULL, NULL),
|
||||
('ledger_post', 0, NULL, NULL)
|
||||
ON CONFLICT (pipeline) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE finance_watermarks IS
|
||||
'各同步管道水位:last_item_id 用于订单项级增量,last_stat_date 用于按日聚合';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. 人工 SKU 登记表降级为派生数据
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
COMMENT ON TABLE manual_sku_registry IS
|
||||
'【派生数据】人工 SKU 扫描结果,可由 pending/manual 订单项重新生成;真源是 manual_fulfillment_configs';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. 完成
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 迁移后仍需执行(按顺序):
|
||||
-- 1. scripts/backfill-fulfillment-channel.ts —— 438k 行分批回填 channel/status
|
||||
-- 2. scripts/finance-cutover-v2.ts —— 断代归档 + 派生数据清零
|
||||
-- 3. scripts/rebuild-after-cutover.ts —— 10-31 起点数据重建 + 期初建账
|
||||
@@ -0,0 +1,255 @@
|
||||
# 同步链路重构:16 个旧入口 → 6 个新作业
|
||||
|
||||
> 状态:**已实施**(2026-10-31):三条管道服务、六个定时任务、后台「履约渠道」页、断代与重建脚本均已落地
|
||||
> 日期:2026-10-31
|
||||
> 关联迁移草案:[2026-10-31-112-履约渠道拆分-迁移草案.sql](2026-10-31-112-履约渠道拆分-迁移草案.sql)
|
||||
> 生产基线:迁移 111,orders 438,740 / order_items 438,750,库 10.1 GB
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么必须重构
|
||||
|
||||
现在有 **16 个会写库的同步/回填入口**,它们全部直接改 `order_items` 这张"共享黑板"。三个结构性后果:
|
||||
|
||||
1. **同一件事多条路径**:商品归一 4 条、成本快照 3 条、事实重建 2 个入口
|
||||
2. **定时任务跨层写**:一个 job 同时写履约、商品、成本、事实、总账,任一步失败就半途
|
||||
3. **下游覆写上游**:`cost_finance_sync` 把 `fulfillment_reason` 覆盖成自己的名字——生产上 **127,250 条**真实判定原因被冲掉
|
||||
|
||||
目标不是"优化这些函数",而是**拆成三条单行道**:下游只读上游,永不回写。
|
||||
|
||||
---
|
||||
|
||||
## 二、旧入口全量清单
|
||||
|
||||
| # | 函数 | 所在文件 | 触发 | 写入 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `runCostFinanceSyncJob` | `services/scheduler/cost-finance-sync-job.ts` | 定时 | 串 2/3/4/5/6/15/16 |
|
||||
| 2 | `reconcilePendingFulfillmentModes` | `services/fulfillment/fulfillment-mode-service.ts` | 定时/手工/脚本 | `fulfillment_mode` |
|
||||
| 3 | `normalizeManualProducts` | `services/cost-center/manual-product-normalization-service.ts` | 定时 | 建 `man:` 商品 + `canonical_product_id` |
|
||||
| 4 | `rematchManualFulfillments` | `services/cost-center/manual-fulfillment-config-service.ts` | 定时/按钮 | `fulfillment_config_id` |
|
||||
| 5 | `syncEstimatedOrderItemCostSnapshots` | `services/cost-center/cost-snapshot-sync-service.ts` | 定时 | 全渠道预估快照 |
|
||||
| 6 | `syncActualWorkOrderCostSnapshots` | 同上 | 定时 | 工单实际快照 |
|
||||
| 7 | `syncAdminAffiliateDashProducts` | `services/cost-center/affiliate-product-service.ts` | 手工 | 建 `aff:` 商品 + 映射 |
|
||||
| 8 | `backfillAdminAffiliateDashSkus` | 同上 | 手工 | 回填订单项商品 |
|
||||
| 9 | `backfillAdminWorkOrderProductSkus` | `services/cost-center/work-order-product-service.ts` | 手工 | 建 `tpl:/wo:` 商品 + 映射 + 回填 |
|
||||
| 10 | `backfillAdminProductMappings` | `services/cost-center/product-catalog-service.ts` | 手工 | 通用映射回填 |
|
||||
| 11 | `applyAdminTemplateProductBootstrap` | `services/cost-center/template-bootstrap-service.ts` | 手工(一次性) | 建模板商品 + 映射 + 成本版本 |
|
||||
| 12 | `syncAdminWorkOrderTemplateCostVersions` | `services/cost-center/work-order-product-service.ts` | 手工 | 规则奖励价 → 成本版本 |
|
||||
| 13 | `syncAdminAffiliateDashCostSnapshots` | `services/cost-center/affiliate-product-service.ts` | 手工 | 再调 5 + 重算事实 |
|
||||
| 14 | `rebuildFactsForRange` | 同上 | 被 8/9/13 调用 | 销售事实 |
|
||||
| 15 | `rebuildSalesDailyFacts` | `services/finance/sales-facts-service.ts` | 定时/手工 | 销售事实(与 14 重复入口) |
|
||||
| 16 | `syncFinanceLedgerFromFacts` | `services/finance/finance-ledger-service.ts` | 定时 | 总账分录 |
|
||||
| — | `backfillAdminNormalizedQuantities` | `product-catalog-service.ts` | 手工 | 归一数量 |
|
||||
| — | `scripts/sync-cost-finance.ts` | 脚本 | 手工 | 同 1 |
|
||||
| — | `scripts/backfill-fulfillment-mode.ts` | 脚本 | 手工 | 同 2 |
|
||||
|
||||
---
|
||||
|
||||
## 三、新架构:三条管道 + 一个巡检
|
||||
|
||||
```
|
||||
管道 A 渠道判定:pending → assigned(只写 channel/status/source)
|
||||
管道 B 商品归一:null → canonical_product_id(只写商品列)
|
||||
管道 C 成本 → 事实 → 总账(只读 A/B,只写自己的表)
|
||||
巡检 只读比对,只告警不修复
|
||||
```
|
||||
|
||||
**四条铁律**
|
||||
|
||||
1. 每条管道只写自己负责的列/表
|
||||
2. 下游只读上游,禁止回写
|
||||
3. 每个作业幂等,重跑结果一致
|
||||
4. 每个作业有水位,失败只影响自己
|
||||
|
||||
---
|
||||
|
||||
## 四、六个作业定义
|
||||
|
||||
| 作业 | 扫描条件 | 写入 | 水位 | 建议频率 |
|
||||
|---|---|---|---|---|
|
||||
| `channel_assign` | `status='pending' AND fulfillment_locked=false` | `channel/status/source/decided_at` | `last_item_id` | 1 分钟 |
|
||||
| `carrier_dispatch` | `status='assigned' AND channel<>'third_party_voucher'` 且无载体 | 工单 / 自动发货任务 / 人工待履约 | `last_item_id` | 1 分钟 |
|
||||
| `product_normalize` | `status='assigned' AND canonical_product_id IS NULL` | `canonical_product_id` | `last_item_id` | 5 分钟 |
|
||||
| `cost_snapshot` | `status='assigned' AND canonical_product_id IS NOT NULL` 且缺快照 | `cost_snapshots`(含 actual 升级) | `last_item_id` | 5 分钟 |
|
||||
| `sales_facts` | 有变动的业务日期 | `sales_daily_facts` | `last_stat_date` | 10 分钟 |
|
||||
| `ledger_post` | 事实变动且期间开放 | `finance_journal_*` | 期间 | 10 分钟 |
|
||||
| `consistency_check` | 全量只读 | 只写告警日志 | — | 1 小时 |
|
||||
|
||||
### 4.1 `channel_assign` 的判定顺序
|
||||
|
||||
```
|
||||
1. 已锁定 → 跳过
|
||||
2. 精确规则(sku_channel_rules,按优先级升序)
|
||||
· 先按 provider+shop 精确匹配,再按 provider='' 或 shop='' 的通配
|
||||
· 同优先级命中多个渠道 → routing_conflict,落 pending
|
||||
3. 接单模板匹配(复用现有商品匹配引擎)
|
||||
· 命中 → work_order
|
||||
4. 都未命中 → pending(不兜底人工)
|
||||
```
|
||||
|
||||
> 接单平台不进 `sku_channel_rules`:它的匹配依赖模板文本条件(763 条规则里 757 条 `sku_code` 为空、match_type 全是 `contains`),交给既有匹配引擎更准确。
|
||||
|
||||
### 4.2 `carrier_dispatch` 的渠道差异
|
||||
|
||||
| 渠道 | 动作 |
|
||||
|---|---|
|
||||
| `affiliate_dash` | 创建自动发货任务(需要 L2 配置,缺失进「履约配置缺失」) |
|
||||
| `work_order` | 创建工单(模板已在匹配时确认存在) |
|
||||
| `manual` | 挂 `fulfillment_config_id`(配置缺失进「人工 SKU 未配置」) |
|
||||
| `third_party_voucher` | **不做任何事**(仅统计) |
|
||||
|
||||
### 4.3 `product_normalize`
|
||||
|
||||
```
|
||||
resolveCanonicalProduct(provider, shop_id, sku_code, sku_name)
|
||||
① 精确映射:channel_product_mappings (sku_code / sku_code_name)
|
||||
② 系列映射:(sku_code_series / sku_name_series)
|
||||
③ 都未命中 → NULL(不建商品,进 product_mapping_missing)
|
||||
```
|
||||
|
||||
**禁止自动建商品**。商品目录只允许两个来源:运营手工新建、断代 bootstrap 一次性生成。
|
||||
|
||||
### 4.4 `cost_snapshot`
|
||||
|
||||
统一入口,按 `(channel, canonical_product_id | work_product_rule_id, 业务日期)` 查 active 版本:
|
||||
|
||||
| 渠道 | 成本来源 | 快照阶段 |
|
||||
|---|---|---|
|
||||
| `affiliate_dash` | 商品采购价版本 | estimated |
|
||||
| `work_order` | 规则奖励价版本;工单结算后升级 | estimated → actual |
|
||||
| `manual` | 确认履约时录入 | actual |
|
||||
| `third_party_voucher` | 商品采购价版本 | estimated |
|
||||
|
||||
版本缺失 → 写 0 + `cost_missing`,**绝不把 0 当真实成本**。
|
||||
|
||||
### 4.5 `sales_facts` / `ledger_post`
|
||||
|
||||
- 事实按业务日期重算(先删后插),已关账期间跳过
|
||||
- 事实必须包含 pending 收入的「未分类桶」,不能丢
|
||||
- 总账只从事实生成,事实变了补调整分录
|
||||
|
||||
---
|
||||
|
||||
## 五、旧 → 新 对照
|
||||
|
||||
| 旧 | 新归属 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 `runCostFinanceSyncJob` | 拆成 6 个作业 | **删除** |
|
||||
| 2 `reconcilePendingFulfillmentModes` | `channel_assign` | 重写,只写新三列 |
|
||||
| 3 `normalizeManualProducts` | `product_normalize` | 删除"建商品"分支,保留匹配 |
|
||||
| 4 `rematchManualFulfillments` | `carrier_dispatch` | 合并进人工载体挂载 |
|
||||
| 5 `syncEstimatedOrderItemCostSnapshots` | `cost_snapshot` | 重写为统一入口 |
|
||||
| 6 `syncActualWorkOrderCostSnapshots` | `cost_snapshot` | 合并为 actual 升级分支 |
|
||||
| 7 `syncAdminAffiliateDashProducts` | 一次性 bootstrap | **降级为迁移脚本** |
|
||||
| 8 `backfillAdminAffiliateDashSkus` | `product_normalize` | **删除** |
|
||||
| 9 `backfillAdminWorkOrderProductSkus` | `product_normalize` + bootstrap | **删除** |
|
||||
| 10 `backfillAdminProductMappings` | `product_normalize` | **删除** |
|
||||
| 11 `applyAdminTemplateProductBootstrap` | 一次性迁移 | **保留为脚本,不进日常菜单** |
|
||||
| 12 `syncAdminWorkOrderTemplateCostVersions` | 模板事件 + 夜间巡检 | 改事件驱动,按钮降级为修复 |
|
||||
| 13 `syncAdminAffiliateDashCostSnapshots` | `cost_snapshot` | **删除** |
|
||||
| 14 `rebuildFactsForRange` | `sales_facts` | **删除**(与 15 合并) |
|
||||
| 15 `rebuildSalesDailyFacts` | `sales_facts` | 保留为内部实现 |
|
||||
| 16 `syncFinanceLedgerFromFacts` | `ledger_post` | 保留为内部实现 |
|
||||
| `backfillAdminNormalizedQuantities` | 一次性回填 | 保留脚本 |
|
||||
| `scripts/sync-cost-finance.ts` | — | 删除 |
|
||||
|
||||
**净结果**:日常菜单从 11 个按钮降到 2 个(渠道规则、成本配置),其余全自动。
|
||||
|
||||
---
|
||||
|
||||
## 六、接单模板同步的重设计
|
||||
|
||||
### 现状
|
||||
|
||||
- 模板在「接单平台」配置,成本中心靠**手动按钮**把奖励价写成成本版本
|
||||
- 模板改价后成本版本不自动跟进
|
||||
- 生产 763 条规则全部 `enabled`、757 条 `sku_code` 为空、`match_type` 全为 `contains`
|
||||
|
||||
### 新设计
|
||||
|
||||
| 时机 | 动作 | 事务边界 |
|
||||
|---|---|---|
|
||||
| 模板新增/改价 | 写一条规则级成本版本,旧版本 `retired` | 与模板保存同事务 |
|
||||
| 模板停用 | 成本版本 `retired` | 同上 |
|
||||
| 每天巡检 | 比对 763 条规则价格 vs active 版本,缺失或金额不一致 → 补版本 + 告警 | 独立作业 |
|
||||
|
||||
规则:
|
||||
- `effective_from` = 模板变更时间(不是同步时间)
|
||||
- 同日多次改价只留最后一版
|
||||
- 模板停用不影响历史快照
|
||||
|
||||
---
|
||||
|
||||
## 七、卡券渠道的同步边界
|
||||
|
||||
| 环节 | 是否参与 |
|
||||
|---|---|
|
||||
| `channel_assign` | ✅ 通过 `sku_channel_rules`(source=`voucher`) |
|
||||
| `carrier_dispatch` | ❌ 跳过 |
|
||||
| `product_normalize` | ✅ 与其他渠道一致 |
|
||||
| `cost_snapshot` | ✅ 采购成本 |
|
||||
| 事实/总账 | ✅ 正常计入 |
|
||||
|
||||
副作用:**无任务、无领取页、无通知、无人工队列**。
|
||||
|
||||
---
|
||||
|
||||
## 八、人工 43 条配置的保留
|
||||
|
||||
| 项 | 处理 |
|
||||
|---|---|
|
||||
| `manual_fulfillment_configs` 43 行 | 原样保留(`sku_key` / `platform_key` / `credit_quantity` / `cost_mode` 不动) |
|
||||
| 其中 `canonical_product_id` | 保留 |
|
||||
| `sku_channel_rules` | 由这 43 行自动生成 `channel=manual` 规则 |
|
||||
| `manual_sku_registry` 573 行 | 标记为派生,断代后重新生成 |
|
||||
|
||||
生产现状:近 7 天有 277 个 manual SKU 在跑,只配置了 29 个——缺口进「人工 SKU 未配置」队列。
|
||||
|
||||
---
|
||||
|
||||
## 九、定时任务清单(`scheduled_jobs`)
|
||||
|
||||
生产当前只注册了 3 个任务,**没有财务同步**。新清单:
|
||||
|
||||
| id | type | 频率 |
|
||||
|---|---|---|
|
||||
| `work-order-timeout` | 既有 | 60s |
|
||||
| `deposit-unfreeze` | 既有 | 60s |
|
||||
| `cloudtentacles-health` | 既有 | 300s |
|
||||
| `channel-assign` | 新增 | 60s |
|
||||
| `carrier-dispatch` | 新增 | 60s |
|
||||
| `product-normalize` | 新增 | 300s |
|
||||
| `cost-snapshot` | 新增 | 300s |
|
||||
| `sales-facts` | 新增 | 600s |
|
||||
| `ledger-post` | 新增 | 600s |
|
||||
| `consistency-check` | 新增 | 3600s |
|
||||
|
||||
---
|
||||
|
||||
## 十、迁移脚本清单
|
||||
|
||||
| 脚本 | 作用 | 幂等 |
|
||||
|---|---|---|
|
||||
| `scripts/backfill-fulfillment-channel.ts` | 438k 行分批回填三列 | ✅ 可续跑 |
|
||||
| `scripts/bootstrap-channel-rules.ts` | 从 aff 商品 / 人工配置生成规则 | ✅ |
|
||||
| `scripts/finance-cutover-v2.ts` | 断代归档 + 派生数据清零 | 一次性(有 cutover_meta 护栏) |
|
||||
| `scripts/rebuild-after-cutover.ts` | 10-31 起点重建 + 期初建账 | ✅ 可重放 |
|
||||
| `scripts/bootstrap-template-products.ts` | 模板商品一次性生成(原 11 号) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 十一、验收
|
||||
|
||||
| # | 检查 | 期望 |
|
||||
|---|---|---|
|
||||
| 1 | 跑完 `channel_assign` | `fulfillment_reason` 零变更 |
|
||||
| 2 | 跑完 `cost_snapshot` | 渠道字段零变更 |
|
||||
| 3 | 跑完 `sales_facts` | 订单项字段零变更 |
|
||||
| 4 | 同一订单项连跑三次 | 结果完全一致 |
|
||||
| 5 | 未配置 SKU 新单 | pending,且不产生成本异常 |
|
||||
| 6 | 卡券渠道订单 | 无 task / 无工单 / 无 claim token |
|
||||
| 7 | 43 条人工配置 | 数量与字段完全一致 |
|
||||
| 8 | 763 条模板规则 | 全有 active 成本版本 |
|
||||
| 9 | 自动发货订单项 | 100% 挂上 `aff:` 商品 |
|
||||
| 10 | 事实合计 | = 订单收入(含未分类桶) |
|
||||
| 11 | 总账 | 借贷平衡 |
|
||||
Reference in New Issue
Block a user