重构:统一商品渠道投影并收敛权威文档

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent 0a595acfa9
commit 1634502363
67 changed files with 2147 additions and 444 deletions
+95
View File
@@ -0,0 +1,95 @@
# order_site 项目规则
## 项目目标
维护一个多渠道订单履约平台,覆盖平台入单、内部 SKU、履约路由、接单工单、自动发货、人工客服、
第三方卡券、寄卖、成本、销售事实与财务总账。
## 每次任务的读取顺序
1. 本文件。
2. [docs/README.md](docs/README.md)。
3. 涉及订单、内部 SKU、履约、商品、成本或财务时,必须完整阅读
[docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md)。
4. 涉及部署、数据库或备份时,再读取对应的运维文档。
5. `docs/archive/` 只用于追溯,不作为实现依据。
## 唯一权威
订单、内部 SKU、渠道路由、履约载体、标准商品、成本和财务口径只有一个权威来源:
[docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md)
禁止:
- 在代码注释、接口、页面或新文档中定义另一套业务口径。
- 将商品名称、`canonical_sku` 前缀、商品分类或支付状态用于反推履约渠道。
- 让多个服务同时写履约核心状态。
- 用自由 JSON 或字符串状态替代正式领域字段。
代码实现与权威文档冲突时,先停止修改并核对生产数据,不得自行选择其中一方继续开发。
## 数据安全边界
永久保留,不得删除或重写历史:
- `orders`
- `order_items` 中的平台原始字段
- 平台原始订单与原始报文
- 平台售后、退款和支付事实
- `work_orders`、`work_order_shares`、`work_order_events`
- 真实 `fulfillment_tasks` 执行记录
- `worker_wallet_ledgers`、`fund_accounts` 等真实资金流水
- `admin_audit_logs`
允许在备份和验收方案完备后删除并重建:
- `cost_snapshots`
- `cost_exceptions`
- `sales_daily_facts`
- `finance_journal_*`
- `fulfillments` / `fulfillment_attempts` / `fulfillment_issues`
- 页面投影、统计缓存、水印和任务重试状态
任何数据清理必须先区分“原始事实”“业务输入配置”“可重建派生数据”。
## 开发规则
1. 先读权威文档,再改代码。
2. 结构性变更必须新增迁移,不修改已发布迁移。
3. 核心状态只能通过对应领域服务修改。
4. 新功能必须有真实 PostgreSQL 集成测试或明确说明无法覆盖的原因。
5. 页面与统计如果显示同一个业务量,必须读取同一投影或同一查询口径。
6. 不把临时修数据脚本当成长期业务流程。
7. 历史清理必须可 dry-run、可审计、可回滚,并且全量扫描存量。
8. 删除兼容代码前必须完成“回填 → 双写 → 对比 → 切换读路径 → 停止旧写 → 观察”。
## 文档规则
1. 业务与架构口径只更新唯一权威文档。
2. `docs/README.md` 只做导航,不定义业务规则。
3. 被替代的内部设计文档移动到 `docs/archive/<日期>/`。
4. 外部平台协议可以保留,但不得覆盖项目自身领域模型。
5. 文档中的生产迁移号、代码基线、数据结论必须注明日期和来源。
6. 不保留第二份“现行方案”“最终口径”或“统一总纲”。
## 验证要求
涉及代码时至少执行:
```bash
apps/backend: npm run check
apps/frontend: npm run check
```
涉及生产数据判断时:
- 默认只读。
- 显式设置只读事务。
- 不执行 `UPDATE`、`DELETE`、`TRUNCATE`、DDL 或调度任务。
## Git
- 所有 Git commit 提交信息使用中文。
- 不回退或覆盖用户的未提交修改。
- 提交时只暂存本次任务相关文件。
+5 -30
View File
@@ -26,43 +26,18 @@ deploy/
caddy/ Caddy 反向代理
docker/ 前后端 Dockerfile
mac-dev.sh / ubuntu-deploy.sh
docs/ 文档索引、架构分析、财务方案、平台接口、部署说明
docs/ 唯一权威模型、平台接口、运维文档、历史归档
docker-compose.dev.yml 本地 Docker 开发环境
docker-compose.yml 生产 / 服务器 Compose
```
## 来源模型(现行)
## 业务模型
系统只有三个订单来源:
订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径:
| sourceKey | 数据通道 | 说明 |
| --- | --- | --- |
| `kuaishou-91` | 91 回调 + 快手 send-code | **同一个来源的两条通道**:91 是订单骨架权威源,快手 send-code 是数据补全源与兜底源;共享店铺键空间与同一套匹配上下文 |
| `pdd` | 拼多多订单同步 | 两段式 `pdd_raw_orders → orders` |
| `douyin-mashida` | 抖音码速达推送 | 抖店订单、DYC 券、H5/兑换页 |
[docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md)
不要把 91 与快手 send-code 拆成两个来源或两个适配器。代码中的唯一来源键是 `kuaishou-91`(`apps/backend/src/services/product-match/types.ts`)。
## 业务边界(简图)
```text
kuaishou-91(91 回调 + 快手 send-code)
→ 本系统建单 / 匹配商品 / 创建履约任务
→ 返回统一 claimUrl
用户打开领取页
→ 可选:快手行业电子凭证自动核销
→ affiliate-dash / 人工 完成发货
后台
→ 订单任务、平台配置、审计、人工复核
接单平台(打手众包)
→ 工单发布到抢单大厅,打手抢单代练
→ 提交验收 / 问题单处置 / 任务时限超时自动判定
抖音码速达
→ 抖店订单推送 → 建单 → DYC 券 → 领取/兑换页 → 核销
```
更完整的链路说明见:`docs/履约配置/多发货平台与电子凭证整体链路.md`。
文档总索引见:`docs/README.md`。
文档总索引见:[docs/README.md](docs/README.md)。
## 本地开发
@@ -0,0 +1,426 @@
-- 154_internal_sku_registry_and_routes.sql
--
-- 统一内部 SKU 身份与履约路由:
-- 1. internal_skus 成为内部 SKU 的稳定身份;
-- 2. sku_channel_routes 成为主渠道/兜底渠道的唯一结构化表达;
-- 3. 兼容期由旧 internal_sku_channels 通过触发器同步;
-- 4. product_catalog 增加 source_kind 与 internal_sku_id,不再依赖前缀判断业务来源。
CREATE TABLE internal_skus (
id BIGSERIAL PRIMARY KEY,
internal_sku_key TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
merged_into_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT internal_skus_key_not_blank CHECK (BTRIM(internal_sku_key) <> ''),
CONSTRAINT internal_skus_status_check CHECK (status IN ('active', 'disabled', 'merged'))
);
CREATE UNIQUE INDEX uq_internal_skus_key
ON internal_skus(internal_sku_key);
COMMENT ON TABLE internal_skus IS
'中文内部 SKU 稳定身份;所有路由、配置和商品关联最终通过 internal_sku_id 引用';
COMMENT ON COLUMN internal_skus.internal_sku_key IS
'NFKC、去空白、转小写后的稳定键';
WITH source_rows AS (
SELECT internal_sku AS raw_value, updated_at FROM internal_sku_channels
UNION ALL
SELECT internal_sku AS raw_value, updated_at FROM internal_sku_mappings
UNION ALL
SELECT sku_key AS raw_value, updated_at FROM manual_fulfillment_configs
UNION ALL
SELECT sku_key AS raw_value, updated_at FROM manual_sku_registry
UNION ALL
SELECT canonical_name AS raw_value, updated_at FROM product_catalog
),
normalized AS (
SELECT
canonicalize_internal_sku(raw_value) AS internal_sku_key,
BTRIM(raw_value) AS display_name,
updated_at
FROM source_rows
WHERE canonicalize_internal_sku(raw_value) <> ''
),
picked AS (
SELECT DISTINCT ON (internal_sku_key)
internal_sku_key,
display_name,
updated_at
FROM normalized
ORDER BY internal_sku_key, updated_at DESC NULLS LAST, display_name
)
INSERT INTO internal_skus (
internal_sku_key,
display_name,
status,
created_at,
updated_at
)
SELECT
internal_sku_key,
display_name,
'active',
COALESCE(updated_at, NOW()),
COALESCE(updated_at, NOW())
FROM picked
ON CONFLICT (internal_sku_key) DO UPDATE SET
display_name = CASE
WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name
ELSE internal_skus.display_name
END,
updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at);
ALTER TABLE internal_sku_mappings
ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL;
ALTER TABLE manual_fulfillment_configs
ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL;
ALTER TABLE product_catalog
ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL,
ADD COLUMN source_kind TEXT;
UPDATE internal_sku_mappings mapping
SET internal_sku_id = sku.id
FROM internal_skus sku
WHERE sku.internal_sku_key = canonicalize_internal_sku(mapping.internal_sku);
UPDATE manual_fulfillment_configs config
SET internal_sku_id = sku.id
FROM internal_skus sku
WHERE sku.internal_sku_key = canonicalize_internal_sku(config.sku_key);
UPDATE product_catalog product
SET source_kind = CASE
WHEN product.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
WHEN product.canonical_sku LIKE 'tpl:%' OR product.canonical_sku LIKE 'wo:%' THEN 'work_order'
WHEN product.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
WHEN product.canonical_sku LIKE 'man:%' THEN 'manual'
ELSE 'unclassified'
END;
UPDATE product_catalog product
SET internal_sku_id = sku.id
FROM internal_skus sku
WHERE sku.internal_sku_key = canonicalize_internal_sku(
CASE
WHEN product.canonical_sku LIKE 'man:%' THEN substring(product.canonical_sku from 5)
WHEN product.canonical_sku LIKE 'tpv:%' THEN substring(product.canonical_sku from 5)
ELSE NULL
END
);
ALTER TABLE product_catalog
ALTER COLUMN source_kind SET DEFAULT 'unclassified',
ALTER COLUMN source_kind SET NOT NULL;
CREATE INDEX idx_product_catalog_internal_sku
ON product_catalog(internal_sku_id)
WHERE internal_sku_id IS NOT NULL;
CREATE INDEX idx_product_catalog_source_kind
ON product_catalog(source_kind, status);
CREATE INDEX idx_internal_sku_mappings_internal_sku
ON internal_sku_mappings(internal_sku_id)
WHERE internal_sku_id IS NOT NULL;
CREATE INDEX idx_manual_configs_internal_sku
ON manual_fulfillment_configs(internal_sku_id)
WHERE internal_sku_id IS NOT NULL;
CREATE TABLE sku_channel_routes (
id BIGSERIAL PRIMARY KEY,
internal_sku_id BIGINT NOT NULL REFERENCES internal_skus(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
route_role TEXT NOT NULL DEFAULT 'fallback',
priority INTEGER NOT NULL DEFAULT 100,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
carrier_config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'legacy_sync',
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(),
CONSTRAINT sku_channel_routes_channel_check
CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')),
CONSTRAINT sku_channel_routes_role_check
CHECK (route_role IN ('primary', 'fallback')),
CONSTRAINT sku_channel_routes_priority_check
CHECK (priority >= 0)
);
CREATE UNIQUE INDEX uq_sku_channel_routes_channel
ON sku_channel_routes(internal_sku_id, channel);
CREATE UNIQUE INDEX uq_sku_channel_routes_primary
ON sku_channel_routes(internal_sku_id)
WHERE enabled = TRUE AND route_role = 'primary';
CREATE INDEX idx_sku_channel_routes_lookup
ON sku_channel_routes(channel, enabled, priority)
WHERE enabled = TRUE;
COMMENT ON TABLE sku_channel_routes IS
'内部 SKU 履约路由:一个主渠道加若干显式兜底渠道';
COMMENT ON COLUMN sku_channel_routes.carrier_config_json IS
'渠道载体参数,例如 workRuleKey / affiliateSku / manualConfigId / voucherSku';
WITH legacy_rows AS (
SELECT
sku.id AS internal_sku_id,
channel.channel,
channel.priority,
channel.is_fallback,
channel.enabled,
channel.canonical_product_id,
channel.config_json,
channel.source,
channel.note,
channel.created_by_admin_user_id,
channel.created_at,
channel.updated_at,
ROW_NUMBER() OVER (
PARTITION BY sku.id, channel.channel
ORDER BY channel.is_fallback, channel.priority, channel.id
) AS channel_rank
FROM internal_sku_channels channel
JOIN internal_skus sku
ON sku.internal_sku_key = canonicalize_internal_sku(channel.internal_sku)
WHERE channel.enabled = TRUE
),
deduplicated AS (
SELECT * FROM legacy_rows WHERE channel_rank = 1
),
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY internal_sku_id
ORDER BY is_fallback, priority, channel
) AS overall_rank
FROM deduplicated
)
INSERT INTO sku_channel_routes (
internal_sku_id,
channel,
route_role,
priority,
enabled,
canonical_product_id,
carrier_config_json,
source,
note,
created_by_admin_user_id,
created_at,
updated_at
)
SELECT
internal_sku_id,
channel,
CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END,
CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END,
TRUE,
canonical_product_id,
COALESCE(config_json, '{}'::jsonb),
COALESCE(NULLIF(source, ''), 'legacy_sync'),
COALESCE(note, ''),
created_by_admin_user_id,
COALESCE(created_at, NOW()),
COALESCE(updated_at, NOW())
FROM ranked
ON CONFLICT (internal_sku_id, channel) DO NOTHING;
CREATE OR REPLACE FUNCTION ensure_canonical_internal_sku(p_raw TEXT)
RETURNS BIGINT
LANGUAGE plpgsql
AS $$
DECLARE
v_key TEXT := canonicalize_internal_sku(p_raw);
v_display_name TEXT := BTRIM(COALESCE(p_raw, ''));
v_id BIGINT;
BEGIN
IF v_key = '' THEN
RETURN NULL;
END IF;
INSERT INTO internal_skus (internal_sku_key, display_name, status, created_at, updated_at)
VALUES (v_key, v_display_name, 'active', NOW(), NOW())
ON CONFLICT (internal_sku_key) DO UPDATE SET
display_name = CASE
WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name
ELSE internal_skus.display_name
END,
updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_sku_id BIGINT;
BEGIN
SELECT id INTO v_sku_id
FROM internal_skus
WHERE internal_sku_key = canonicalize_internal_sku(p_raw)
LIMIT 1;
IF v_sku_id IS NULL THEN
RETURN;
END IF;
DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id;
WITH legacy_rows AS (
SELECT
channel.channel,
channel.priority,
channel.is_fallback,
channel.canonical_product_id,
channel.config_json,
channel.source,
channel.note,
channel.created_by_admin_user_id,
channel.created_at,
channel.updated_at,
ROW_NUMBER() OVER (
PARTITION BY channel.channel
ORDER BY channel.is_fallback, channel.priority, channel.id
) AS channel_rank
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku)
= canonicalize_internal_sku(p_raw)
),
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
ORDER BY is_fallback, priority, channel
) AS overall_rank
FROM legacy_rows
WHERE channel_rank = 1
)
INSERT INTO sku_channel_routes (
internal_sku_id,
channel,
route_role,
priority,
enabled,
canonical_product_id,
carrier_config_json,
source,
note,
created_by_admin_user_id,
created_at,
updated_at
)
SELECT
v_sku_id,
channel,
CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END,
CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END,
TRUE,
canonical_product_id,
COALESCE(config_json, '{}'::jsonb),
COALESCE(NULLIF(source, ''), 'legacy_sync'),
COALESCE(note, ''),
created_by_admin_user_id,
COALESCE(created_at, NOW()),
COALESCE(updated_at, NOW())
FROM ranked;
END;
$$;
CREATE OR REPLACE FUNCTION sync_sku_channel_routes_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP <> 'INSERT' THEN
PERFORM sync_sku_channel_routes_for_key(OLD.internal_sku);
END IF;
IF TG_OP <> 'DELETE' THEN
PERFORM ensure_canonical_internal_sku(NEW.internal_sku);
PERFORM sync_sku_channel_routes_for_key(NEW.internal_sku);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_sync_sku_channel_routes
AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels
FOR EACH ROW
EXECUTE FUNCTION sync_sku_channel_routes_trigger();
CREATE OR REPLACE FUNCTION set_internal_sku_id_from_text()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.internal_sku);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_internal_sku_mapping_identity
BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_mappings
FOR EACH ROW
EXECUTE FUNCTION set_internal_sku_id_from_text();
CREATE OR REPLACE FUNCTION set_manual_config_internal_sku_id()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.sku_key);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_manual_config_internal_sku_identity
BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs
FOR EACH ROW
EXECUTE FUNCTION set_manual_config_internal_sku_id();
CREATE OR REPLACE FUNCTION normalize_product_identity()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.source_kind := COALESCE(
NULLIF(NEW.source_kind, ''),
CASE
WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order'
WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual'
ELSE 'unclassified'
END
);
IF NEW.internal_sku_id IS NULL THEN
SELECT sku.id INTO NEW.internal_sku_id
FROM internal_skus sku
WHERE sku.internal_sku_key = canonicalize_internal_sku(
CASE
WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5)
WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5)
ELSE NULL
END
)
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_normalize_product_identity
BEFORE INSERT OR UPDATE OF canonical_sku, canonical_name, source_kind, internal_sku_id
ON product_catalog
FOR EACH ROW
EXECUTE FUNCTION normalize_product_identity();
@@ -0,0 +1,96 @@
-- 155_product_current_projection.sql
--
-- 统一商品当前渠道投影,并清理已被其他渠道接管且没有人工兜底配置的旧 man 商品。
UPDATE sku_channel_routes route
SET canonical_product_id = product.id,
updated_at = NOW()
FROM product_catalog product
WHERE route.channel = 'work_order'
AND route.canonical_product_id IS NULL
AND product.status = 'active'
AND product.canonical_sku = 'tpl:' || COALESCE(route.carrier_config_json->>'workRuleKey', '');
CREATE OR REPLACE VIEW v_product_current AS
SELECT
product.*,
sku.internal_sku_key,
COALESCE(
primary_route.channel,
CASE product.source_kind
WHEN 'affiliate_dash' THEN 'affiliate_dash'
WHEN 'work_order' THEN 'work_order'
WHEN 'third_party_voucher' THEN 'third_party_voucher'
WHEN 'manual' THEN 'manual'
ELSE NULL
END
) AS active_primary_channel,
COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels,
manual_config.id AS manual_config_id,
(manual_config.id IS NOT NULL) AS has_active_manual_config
FROM product_catalog product
LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id
LEFT JOIN LATERAL (
SELECT route.channel
FROM sku_channel_routes route
WHERE route.internal_sku_id = product.internal_sku_id
AND route.enabled = TRUE
AND route.route_role = 'primary'
ORDER BY route.priority, route.channel
LIMIT 1
) primary_route ON TRUE
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels
FROM sku_channel_routes route
WHERE route.internal_sku_id = product.internal_sku_id
AND route.enabled = TRUE
AND route.route_role = 'fallback'
) fallback_routes ON TRUE
LEFT JOIN LATERAL (
SELECT config.id
FROM manual_fulfillment_configs config
WHERE config.enabled = TRUE
AND (
config.canonical_product_id = product.id
OR (
sku.internal_sku_key IS NOT NULL
AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key
)
)
ORDER BY config.id
LIMIT 1
) manual_config ON TRUE;
COMMENT ON VIEW v_product_current IS
'标准商品统一投影:来源、当前主渠道、兜底渠道和人工配置状态同源输出';
WITH candidates AS (
SELECT product.id
FROM v_product_current product
WHERE product.status = 'active'
AND product.canonical_sku LIKE 'man:%'
AND product.active_primary_channel IS NOT NULL
AND product.active_primary_channel <> 'manual'
AND product.has_active_manual_config = FALSE
AND EXISTS (
SELECT 1
FROM sku_channel_routes route
WHERE route.internal_sku_id = product.internal_sku_id
AND route.enabled = TRUE
AND route.route_role = 'primary'
AND route.channel <> 'manual'
)
)
UPDATE product_catalog product
SET status = 'archived',
attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object(
'legacyManualArchived', TRUE,
'legacyManualArchiveReason', 'superseded_by_current_primary_channel',
'legacyManualArchivedAt', NOW()
),
updated_at = NOW()
FROM candidates
WHERE product.id = candidates.id;
COMMENT ON COLUMN product_catalog.source_kind IS
'标准商品来源类型;来源不等于当前履约渠道,当前渠道统一读取 v_product_current';
@@ -0,0 +1,53 @@
-- 156_fix_product_source_kind_default.sql
--
-- source_kind 的列默认值会让 BEFORE INSERT 触发器看到“非空但未分类”,
-- 从而跳过前缀回填。这里保留默认值兼容直接写入,同时让已知前缀始终覆盖
-- unclassified,并修复兼容期内已经写入的误判行。
CREATE OR REPLACE FUNCTION normalize_product_identity()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.source_kind := CASE
WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order'
WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual'
ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified')
END;
IF NEW.internal_sku_id IS NULL THEN
SELECT sku.id INTO NEW.internal_sku_id
FROM internal_skus sku
WHERE sku.internal_sku_key = canonicalize_internal_sku(
CASE
WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5)
WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5)
ELSE NULL
END
)
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
UPDATE product_catalog
SET source_kind = CASE
WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order'
WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
WHEN canonical_sku LIKE 'man:%' THEN 'manual'
ELSE source_kind
END,
updated_at = NOW()
WHERE source_kind = 'unclassified'
AND (
canonical_sku LIKE 'aff:%'
OR canonical_sku LIKE 'tpl:%'
OR canonical_sku LIKE 'wo:%'
OR canonical_sku LIKE 'tpv:%'
OR canonical_sku LIKE 'man:%'
);
@@ -0,0 +1,105 @@
-- 157_preserve_fallback_only_routes.sql
--
-- 兼容同步不能把“只有兜底渠道”的旧配置自动升级成主渠道。
-- 只有旧表中明确 is_fallback = FALSE 的渠道才允许竞争 primary;
-- 如果某个内部 SKU 没有主渠道,则所有启用路由都保持 fallback。
CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_sku_id BIGINT;
BEGIN
SELECT id INTO v_sku_id
FROM internal_skus
WHERE internal_sku_key = canonicalize_internal_sku(p_raw)
LIMIT 1;
IF v_sku_id IS NULL THEN
RETURN;
END IF;
DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id;
WITH legacy_rows AS (
SELECT
channel.channel,
channel.priority,
channel.is_fallback,
channel.canonical_product_id,
channel.config_json,
channel.source,
channel.note,
channel.created_by_admin_user_id,
channel.created_at,
channel.updated_at,
ROW_NUMBER() OVER (
PARTITION BY channel.channel
ORDER BY channel.priority, channel.id
) AS channel_rank
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku)
= canonicalize_internal_sku(p_raw)
),
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY is_fallback
ORDER BY priority, channel
) AS role_rank
FROM legacy_rows
WHERE channel_rank = 1
)
INSERT INTO sku_channel_routes (
internal_sku_id,
channel,
route_role,
priority,
enabled,
canonical_product_id,
carrier_config_json,
source,
note,
created_by_admin_user_id,
created_at,
updated_at
)
SELECT
v_sku_id,
channel,
CASE
WHEN is_fallback = FALSE AND role_rank = 1 THEN 'primary'
ELSE 'fallback'
END,
CASE
WHEN is_fallback = FALSE AND role_rank = 1 THEN priority
ELSE GREATEST(priority, 90)
END,
TRUE,
canonical_product_id,
COALESCE(config_json, '{}'::jsonb),
COALESCE(NULLIF(source, ''), 'legacy_sync'),
COALESCE(note, ''),
created_by_admin_user_id,
COALESCE(created_at, NOW()),
COALESCE(updated_at, NOW())
FROM ranked;
END;
$$;
DO $$
DECLARE
v_internal_sku TEXT;
BEGIN
FOR v_internal_sku IN
SELECT DISTINCT internal_sku
FROM internal_sku_channels
WHERE enabled = TRUE
LOOP
PERFORM sync_sku_channel_routes_for_key(v_internal_sku);
END LOOP;
END;
$$;
@@ -0,0 +1,57 @@
-- 158_manual_source_is_not_manual_route.sql
--
-- man: 只表示商品来源,不能证明当前由人工履约。
-- 兼容期内,自动发货 / 接单平台 / 第三方卡券这类直连来源允许暂时由 source_kind
-- 推导当前渠道;人工客服必须有真实启用的 primary route,否则就是“未路由”。
CREATE OR REPLACE VIEW v_product_current AS
SELECT
product.*,
sku.internal_sku_key,
COALESCE(
primary_route.channel,
CASE product.source_kind
WHEN 'affiliate_dash' THEN 'affiliate_dash'
WHEN 'work_order' THEN 'work_order'
WHEN 'third_party_voucher' THEN 'third_party_voucher'
ELSE NULL
END
) AS active_primary_channel,
COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels,
manual_config.id AS manual_config_id,
(manual_config.id IS NOT NULL) AS has_active_manual_config
FROM product_catalog product
LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id
LEFT JOIN LATERAL (
SELECT route.channel
FROM sku_channel_routes route
WHERE route.internal_sku_id = product.internal_sku_id
AND route.enabled = TRUE
AND route.route_role = 'primary'
ORDER BY route.priority, route.channel
LIMIT 1
) primary_route ON TRUE
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels
FROM sku_channel_routes route
WHERE route.internal_sku_id = product.internal_sku_id
AND route.enabled = TRUE
AND route.route_role = 'fallback'
) fallback_routes ON TRUE
LEFT JOIN LATERAL (
SELECT config.id
FROM manual_fulfillment_configs config
WHERE config.enabled = TRUE
AND (
config.canonical_product_id = product.id
OR (
sku.internal_sku_key IS NOT NULL
AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key
)
)
ORDER BY config.id
LIMIT 1
) manual_config ON TRUE;
COMMENT ON VIEW v_product_current IS
'标准商品统一投影:来源不等于当前渠道;manual 必须有真实主路由,直连来源兼容期允许回退 source_kind';
@@ -21,6 +21,18 @@ import type {
// 履约方式 / 商品来源 / 成本模型
// ---------------------------------------------------------------------------
/** 标准商品目录允许使用的当前渠道筛选,包含“未路由”这个投影状态。 */
export const PRODUCT_CURRENT_CHANNEL_FILTERS = [
'affiliate_dash',
'work_order',
'third_party_voucher',
'manual',
'on_account',
'unrouted',
] as const
export type ProductCurrentChannelFilter = (typeof PRODUCT_CURRENT_CHANNEL_FILTERS)[number]
/**
* 由 canonical_sku 前缀推导商品来源。
* 返回空串表示未分类;未分类商品不允许反推履约方式。
@@ -43,6 +55,20 @@ export function productSourceFilterSql(paramIndex: number, column = 'p.canonical
)`
}
/** 当前渠道筛选:标准商品页签的唯一筛选口径。 */
export function productChannelFilterSql(
paramIndex: number,
column = 'p.active_primary_channel',
): string {
return `(
($${paramIndex} = 'unrouted' AND ${column} IS NULL)
OR (
$${paramIndex} <> 'unrouted'
AND ${column} = $${paramIndex}
)
)`
}
/**
* 成本模型是否适用于该履约方式。
*
@@ -95,7 +121,8 @@ export function mapProduct(row: ProductRow) {
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
category: row.category || '',
source: productSourceOf(row.canonical_sku),
source: String(row.source_kind || productSourceOf(row.canonical_sku)),
sourceKind: String(row.source_kind || productSourceOf(row.canonical_sku)),
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
@@ -122,6 +149,7 @@ export function mapProduct(row: ProductRow) {
? null
: row.template_sharing_enabled === true,
activePrimaryChannel: row.active_primary_channel || null,
fallbackChannels: Array.isArray(row.fallback_channels) ? row.fallback_channels : [],
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
@@ -2,20 +2,26 @@
* 成本中心总览与选项。
*
* 口径(2026-10-23 重写):
* 1. 商品来源统计按 canonical_sku 前缀分组,不再读 product_catalog.carrier_type;
* 1. 商品统计按 v_product_current.active_primary_channel 分组;
* 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。
*/
import { query } from '../../db/client.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
import { productChannelFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, toNumber } from './cost-center-shared.js'
export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual'
export type CostCenterScope =
| 'affiliate_dash'
| 'work_order'
| 'third_party_voucher'
| 'manual'
| 'on_account'
| 'unrouted'
/**
* 成本中心总览。
*
* scope 为空表示「全部来源」;传入具体来源时,商品/映射/成本项/版本/异常/未映射
* 六项统计都按该来源收敛,页面顶部数字才会和下面的列表对得上。
* scope 为空表示「全部渠道」;传入具体渠道时,商品/映射/成本项/版本/异常/未映射
* 六项统计都按该渠道收敛,页面顶部数字才会和下面的列表对得上。
*
* 来源与成本模型的对应关系:
* affiliate_dash → affiliate_purchase
@@ -36,9 +42,9 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
`
WITH scoped_products AS (
SELECT p.id
FROM product_catalog p
FROM v_product_current p
WHERE p.status = 'active'
AND ($1::text IS NULL OR ${productSourceFilterSql(1)})
AND ($1::text IS NULL OR ${productChannelFilterSql(1)})
),
scoped_cost_models AS (
SELECT CASE $1
@@ -46,6 +52,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
WHEN 'work_order' THEN 'work_order_reward'
WHEN 'third_party_voucher' THEN 'third_party_voucher_cost'
WHEN 'manual' THEN 'manual_actual'
WHEN 'on_account' THEN 'manual_actual'
END AS cost_model
)
SELECT
@@ -93,22 +100,12 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
)
const row = result.rows[0]
const [productSourceResult, costModelResult] = await Promise.all([
query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>(
`SELECT p.canonical_sku,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
query<{ channel: string | null; total: number }>(
`SELECT p.active_primary_channel AS channel,
COUNT(*)::int AS total
FROM product_catalog p
FROM v_product_current p
WHERE p.status = 'active'
GROUP BY p.canonical_sku, p.canonical_name`,
GROUP BY p.active_primary_channel`,
),
query<{ cost_model: string; total: number }>(
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
@@ -134,6 +131,8 @@ const COST_CENTER_SCOPES = new Set([
'work_order',
'third_party_voucher',
'manual',
'on_account',
'unrouted',
])
function normalizeScope(raw: unknown): CostCenterScope | null {
@@ -141,27 +140,18 @@ function normalizeScope(raw: unknown): CostCenterScope | null {
return COST_CENTER_SCOPES.has(value) ? (value as CostCenterScope) : null
}
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
function productSourceCountsOf(
rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>,
) {
/** 商品当前渠道计数:总览和列表共用 v_product_current。 */
function productSourceCountsOf(rows: Array<{ channel: string | null; total: number }>) {
const counts: Record<string, number> = {
affiliate_dash: 0,
work_order: 0,
third_party_voucher: 0,
manual: 0,
uncategorized: 0,
on_account: 0,
unrouted: 0,
}
for (const row of rows) {
const source = productSourceOf(row.canonical_sku)
// man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。
if (
source === 'manual' &&
row.active_primary_channel &&
row.active_primary_channel !== 'manual'
) {
continue
}
const key = source || 'uncategorized'
const key = row.channel || 'unrouted'
counts[key] = (counts[key] || 0) + toNumber(row.total)
}
return counts
@@ -183,10 +173,12 @@ export async function getAdminCostCenterOptions() {
canonical_sku: string
canonical_name: string
unit_name: string
source_kind: string
active_primary_channel: string | null
}>(
`
SELECT id, canonical_sku, canonical_name, unit_name
FROM product_catalog
SELECT id, canonical_sku, canonical_name, unit_name, source_kind, active_primary_channel
FROM v_product_current
WHERE status = 'active'
ORDER BY canonical_name, id
LIMIT 5000
@@ -225,7 +217,8 @@ export async function getAdminCostCenterOptions() {
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
unitName: row.unit_name,
source: productSourceOf(row.canonical_sku),
source: row.source_kind,
activePrimaryChannel: row.active_primary_channel,
})),
costItems: costItemsResult.rows.map((row) => ({
id: Number(row.id),
@@ -29,7 +29,10 @@ export type ProductRow = {
template_pricing_mode?: string | null
template_enabled?: boolean | null
template_sharing_enabled?: boolean | null
source_kind?: string | null
active_primary_channel?: string | null
fallback_channels?: string[] | null
manual_config_id?: number | null
affiliate_task_count?: number
affiliate_last_fulfilled_at?: string | null
}
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { costModelAppliesTo } from './cost-center-mappers.js'
@@ -9,7 +10,7 @@ import {
} from './cost-center-shared.js'
import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js'
import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { productChannelFilterSql, productSourceFilterSql } from './cost-center-mappers.js'
import {
buildTemplateMappingPlan,
buildTemplateProductSku,
@@ -289,6 +290,10 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
assert.ok(productSourceFilterSql(1).includes("'third_party_voucher'"))
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
const channelSql = productChannelFilterSql(1)
assert.ok(channelSql.includes('active_primary_channel'))
assert.ok(channelSql.includes("$1 = 'unrouted'"))
assert.ok(!channelSql.includes('canonical_sku'))
})
test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => {
@@ -310,11 +315,15 @@ test('第三方卡券删除:无历史引用硬删除,有历史引用自动
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
})
test('人工客服筛选保留所有可维护的 man 商品和显式兜底配置', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("LIKE 'man:%'"))
assert.ok(!sql.includes('live_channel'))
test('人工客服页签只按当前主渠道筛选,不能再按 man 前缀放回人工页', async () => {
const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8')
const sql = productChannelFilterSql(1)
assert.ok(sql.includes('active_primary_channel'))
assert.ok(sql.includes("$1 <> 'unrouted'"))
assert.ok(sql.includes('active_primary_channel = $1'))
assert.ok(!sql.includes('canonical_sku'))
assert.ok(source.includes('FROM v_product_current p'))
assert.ok(source.includes('productChannelFilterSql'))
})
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
@@ -325,7 +334,21 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫
)
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
assert.ok(!source.includes('ce.order_item_id IN ('))
assert.ok(source.includes('productSourceFilterSql(1)'))
assert.ok(source.includes('productChannelFilterSql(1)'))
})
test('运行期停止写入已废弃的 product_channel_capabilities', async () => {
const files = await Promise.all(
[
'./voucher-product-service.ts',
'./product-normalize-service.ts',
'./product-catalog-service.ts',
'../fulfillment/channel-assign-service.ts',
].map((path) => readFile(new URL(path, import.meta.url), 'utf8')),
)
for (const source of files) {
assert.ok(!source.includes('product_channel_capabilities'))
}
})
test('tpv: 商品来源归为第三方卡券', async () => {
@@ -27,7 +27,6 @@ import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-fa
import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js'
import {
AFFILIATE_TASK_AGG_JOIN,
FULFILLMENT_CHANNELS,
MAPPING_CONFIDENCES,
MAPPING_SOURCES,
PRODUCT_CATEGORY_JOINS,
@@ -52,7 +51,8 @@ import {
normalizeRequiredText,
normalizeStatus,
parseJsonObject,
productSourceFilterSql,
productChannelFilterSql,
PRODUCT_CURRENT_CHANNEL_FILTERS,
requiredRow,
resolveMappingNameKey,
text,
@@ -64,13 +64,8 @@ import {
type WorkCategoryRow,
} from './cost-center-shared.js'
/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */
const PRODUCT_SOURCE_FILTERS = new Set([
'affiliate_dash',
'work_order',
'third_party_voucher',
'manual',
])
/** 标准商品页签的当前渠道筛选取值。 */
const PRODUCT_CHANNEL_FILTERS = new Set<string>(PRODUCT_CURRENT_CHANNEL_FILTERS)
/**
* 校验新建商品的 SKU 前缀。
@@ -108,17 +103,19 @@ export async function listAdminProducts(payload: JsonObject = {}) {
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
baseParams.push(status)
baseClauses.push(`p.status = $${baseParams.length}`)
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) {
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
baseParams.push(source)
baseClauses.push(productSourceFilterSql(baseParams.length))
// carrierType 参数仅为兼容旧前端,语义统一为「按当前主渠道筛选」。
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) {
assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
baseParams.push(channel)
baseClauses.push(productChannelFilterSql(baseParams.length))
}
// 人工/接单平台商品不需要 affiliate 任务统计:跳过 30 天 fulfillment_tasks 聚合。
// 只有「全部」和「自动发货」Tab 才需要这条聚合,避免小库 IO 被无关数据拖慢。
const needsAffiliateTaskStats = !source || source === 'affiliate_dash'
const needsAffiliateTaskStats = !channel || channel === 'affiliate_dash'
const affiliateTaskJoinSql = needsAffiliateTaskStats ? AFFILIATE_TASK_AGG_JOIN : ''
const affiliateColumnsSql = needsAffiliateTaskStats
? 'COALESCE(aff.task_count, 0) AS affiliate_task_count, aff.last_fulfilled_at AS affiliate_last_fulfilled_at'
@@ -146,7 +143,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([
query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`,
`SELECT COUNT(*)::int AS total FROM v_product_current p ${PRODUCT_CATEGORY_JOINS} ${where}`,
listParams,
),
query<ProductRow>(
@@ -156,16 +153,6 @@ export async function listAdminProducts(payload: JsonObject = {}) {
tpl_rule.pricing_mode AS template_pricing_mode,
tpl_rule.enabled AS template_enabled,
tpl_rule.sharing_enabled AS template_sharing_enabled,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
root.id AS root_category_id,
@@ -228,7 +215,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
)
) AS active_cost_effective_from,
${affiliateColumnsSql}
FROM product_catalog p
FROM v_product_current p
${PRODUCT_CATEGORY_JOINS}
LEFT JOIN work_product_rules tpl_rule
ON p.canonical_sku LIKE 'tpl:%'
@@ -252,7 +239,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
query<ProductCategoryStatRow>(
`
SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total
FROM product_catalog p
FROM v_product_current p
${PRODUCT_CATEGORY_JOINS}
${baseWhere}
GROUP BY c.id, c.name, c.parent_id
@@ -512,16 +499,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
`,
[id, internalSku, nowIso()],
)
await client.query(
`
UPDATE product_channel_capabilities
SET enabled = FALSE,
updated_at = $2
WHERE product_id = $1 AND channel = 'third_party_voucher'
`,
[id, nowIso()],
)
if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) {
await client.query(
`
@@ -566,7 +543,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
`DELETE FROM cost_versions WHERE canonical_product_id = $1`,
[id],
)
await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id])
await client.query(
`
DELETE FROM internal_sku_channels
@@ -627,12 +603,14 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
params.push(canonicalProductId)
clauses.push(`m.canonical_product_id = $${params.length}`)
}
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) {
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
params.push(source)
clauses.push(productSourceFilterSql(params.length))
// carrierType 仅为旧客户端兼容,筛选口径统一是商品当前主渠道。
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) {
assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
params.push(channel)
clauses.push(productChannelFilterSql(params.length))
}
if (payload.enabled !== undefined && payload.enabled !== '') {
params.push(normalizeBoolean(payload.enabled))
@@ -652,7 +630,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.unit_name,
p.status
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
GROUP BY
p.id,
@@ -672,7 +650,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
COUNT(DISTINCT p.id)::int AS total_count,
COUNT(*)::int AS mapping_total
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
`,
params,
@@ -698,7 +676,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.canonical_sku,
p.canonical_name
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[])
ORDER BY
@@ -748,16 +726,16 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
* 「全部」时并行跑四种模式再求和,避免一次全表 hash join。
*/
export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}) {
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
const channel = text(payload.channel ?? payload.channel)
if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效')
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
const productParams: unknown[] = []
let sourceSql = 'TRUE'
if (source) {
productParams.push(source)
sourceSql = productSourceFilterSql(productParams.length)
let productChannelSql = 'TRUE'
if (channel) {
productParams.push(channel)
productChannelSql = productChannelFilterSql(productParams.length)
}
const counts = await query<{
product_total: number
@@ -767,20 +745,20 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
`
SELECT
(
SELECT COUNT(*)::int FROM product_catalog p
WHERE p.status = 'active' AND ${sourceSql}
SELECT COUNT(*)::int FROM v_product_current p
WHERE p.status = 'active' AND ${productChannelSql}
) AS product_total,
(
SELECT COUNT(*)::int
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${sourceSql}
JOIN v_product_current p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${productChannelSql}
) AS mapping_total,
(
SELECT COUNT(DISTINCT m.canonical_product_id)::int
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${sourceSql}
JOIN v_product_current p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${productChannelSql}
) AS mapping_product_total
`,
productParams,
@@ -816,9 +794,12 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
}
}
const modes = channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
const modes =
channel === 'unrouted'
? []
: channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
// 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。
const aggregates: Array<{ groupTotal: number; itemTotal: number }> = []
for (const mode of modes) {
@@ -849,8 +830,14 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
const params: unknown[] = []
const clauses = [`ce.reason = 'product_mapping_missing'`, `ce.status = 'open'`]
const channel = text(payload.channel ?? payload.channel)
if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效')
const channel = text(payload.channel ?? payload.currentChannel)
if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
if (channel === 'unrouted') {
return {
items: [],
pagination: { page, pageSize, total: 0, itemTotal: 0 },
}
}
const provider = text(payload.provider)
if (provider) {
params.push(normalizeProviderKey(provider))
@@ -210,15 +210,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
onlyWhenCanonicalProductDifferent: true,
})
await query(
`
INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId],
)
await upsertInternalSkuChannel({
internalSku,
channel: 'third_party_voucher',
@@ -89,21 +89,6 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
throw createHttpError('卡券标准商品保存失败', { statusCode: 500 })
}
await client.query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at
) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'voucher', $2, $2)
ON CONFLICT (product_id, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
source = 'voucher',
updated_at = EXCLUDED.updated_at
`,
[productId, now],
)
await upsertInternalSkuChannelWithClient(client, {
internalSku: skuKey,
channel: 'third_party_voucher',
@@ -2146,15 +2146,6 @@ async function ensureDraftProductForChannel(
const productId = Number(product?.id || 0)
if (!productId || product?.status !== 'active') return null
await query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source
) VALUES ($1, $2, 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId, channel],
)
return productId
}
@@ -0,0 +1,145 @@
/**
* 内部 SKU 与渠道路由兼容层集成测试(需要真实 PostgreSQL)。
*
* INTERNAL_SKU_ROUTING_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/fulfillment/internal-sku-routing.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.INTERNAL_SKU_ROUTING_TEST_DATABASE_URL || '').trim()
test(
'旧 internal_sku_channels 写入会同步路由,商品来源与主渠道投影保持分离',
{ skip: !TEST_DATABASE_URL ? '未设置 INTERNAL_SKU_ROUTING_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const internalSku = `路由集成测试-${suffix}`
const canonicalSku = `man:${internalSku}`
let internalSkuId = 0
let productId = 0
try {
const createdSku = await query<{ id: string }>(
'SELECT ensure_canonical_internal_sku($1) AS id',
[internalSku],
)
internalSkuId = Number(createdSku.rows[0]?.id || 0)
assert.ok(internalSkuId > 0)
const createdProduct = await query<{ id: string; source_kind: string }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status, attributes_json,
created_at, updated_at
) VALUES ($1, $2, '集成测试', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id, source_kind
`,
[canonicalSku, internalSku],
)
productId = Number(createdProduct.rows[0]?.id || 0)
assert.ok(productId > 0)
assert.equal(createdProduct.rows[0]?.source_kind, 'manual')
const beforeRoute = await query<{ active_primary_channel: string | null }>(
'SELECT active_primary_channel FROM v_product_current WHERE id = $1',
[productId],
)
assert.equal(
beforeRoute.rows[0]?.active_primary_channel,
null,
'man: 来源没有主路由时必须显示未路由',
)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
) VALUES ($1, 'manual', 10, FALSE, TRUE, $2, 'integration_test')
`,
[internalSku, productId],
)
const primary = await query<{ route_role: string; channel: string }>(
`
SELECT route_role, channel
FROM sku_channel_routes
WHERE internal_sku_id = $1 AND enabled = TRUE
`,
[internalSkuId],
)
assert.deepEqual(primary.rows, [{ route_role: 'primary', channel: 'manual' }])
const projection = await query<{
source_kind: string
active_primary_channel: string
internal_sku_id: string
}>(
`
SELECT source_kind, active_primary_channel, internal_sku_id
FROM v_product_current
WHERE id = $1
`,
[productId],
)
assert.equal(projection.rows[0]?.source_kind, 'manual')
assert.equal(projection.rows[0]?.active_primary_channel, 'manual')
assert.equal(Number(projection.rows[0]?.internal_sku_id), internalSkuId)
const { getAdminCostCenterOverview } =
await import('../cost-center/cost-center-overview-service.js')
const { listAdminProducts } = await import('../cost-center/product-catalog-service.js')
const overview = await getAdminCostCenterOverview({ scope: 'manual' })
const products = await listAdminProducts({ channel: 'manual', page: 1, pageSize: 100 })
assert.equal(products.pagination.total, overview.productCount)
assert.ok(products.items.every((product) => product.activePrimaryChannel === 'manual'))
await query(
`
UPDATE internal_sku_channels
SET is_fallback = TRUE, updated_at = NOW()
WHERE internal_sku = $1 AND channel = 'manual'
`,
[internalSku],
)
const fallback = await query<{ route_role: string }>(
'SELECT route_role FROM sku_channel_routes WHERE internal_sku_id = $1',
[internalSkuId],
)
assert.deepEqual(fallback.rows, [{ route_role: 'fallback' }])
await query(
`
UPDATE internal_sku_channels
SET is_fallback = FALSE, updated_at = NOW()
WHERE internal_sku = $1 AND channel = 'manual'
`,
[internalSku],
)
await assert.rejects(
() =>
query(
`
INSERT INTO sku_channel_routes (
internal_sku_id, channel, route_role, priority, enabled
) VALUES ($1, 'work_order', 'primary', 1, TRUE)
`,
[internalSkuId],
),
/uq_sku_channel_routes_primary|duplicate key/,
)
} finally {
if (productId > 0) {
await query('DELETE FROM product_catalog WHERE id = $1', [productId])
}
if (internalSkuId > 0) {
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
await query('DELETE FROM internal_skus WHERE id = $1', [internalSkuId])
}
await closeDb()
}
},
)
@@ -93,7 +93,7 @@ function buildRuleEntries(
})
}
// 只配了 sellerId 的"空条件结构化"(旧实现会整店命中)不编译:属于历史脏配置,
// 新模型一律要求显式判定维度(见 docs/接单模板匹配引擎重构设计.md §2.3)。
// 新模型一律要求显式判定维度(见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md §2.3)。
if (structuredAll.length > 0) {
entries.push({
key: `legacy:rule:${rule.id}:structured`,
@@ -5,7 +5,7 @@
* - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维);
* - 价格/数量/拼单:`pricing.ts` 唯一实现。
*
* 设计见 docs/接单模板匹配引擎重构设计.md。
* 设计见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md。
*/
export type {
ProductMatchCandidate,
@@ -66,7 +66,7 @@ export function AffiliateSkuPanel() {
queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize],
queryFn: () =>
fetchAdminProductCatalog({
carrierType: 'affiliate_dash',
channel: 'affiliate_dash',
page,
pageSize,
}),
@@ -34,7 +34,7 @@ type Props = {
providers: string[]
canConfig: boolean
diagnostics?: MappingDiagnostics
sourceLabel: string
channelLabel: string
expandedRowKeys: string[]
detailPageSize: number
onKeywordChange: (value: string) => void
@@ -60,7 +60,7 @@ export default function ChannelMappingCatalog({
providers,
canConfig,
diagnostics,
sourceLabel,
channelLabel,
expandedRowKeys,
detailPageSize,
onKeywordChange,
@@ -241,7 +241,7 @@ export default function ChannelMappingCatalog({
<Alert
type="success"
showIcon
message={`${sourceLabel}的订单项已全部归一`}
message={`${channelLabel}的订单项已全部归一`}
description={`${Number(diagnostics?.productTotal || 0)} 个标准商品、${Number(diagnostics?.mappingTotal || 0)} 条映射已覆盖全部订单;新订单若命中不到映射,会在每日成本同步后出现在「未归一」队列。`}
/>
)}
@@ -3,7 +3,7 @@
*
* 采用「诊断优先」结构:
* 1. 顶部统计卡:标准商品 / 映射条数 / 未归一订单项 / 未归一 SKU 组;
* 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射;
* 2. 未归一队列:按渠道 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射;
* 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。
*
* 2026-10-25 拆分:本文件只保留查询、状态与动作,视图拆到
@@ -44,7 +44,7 @@ import {
export function ChannelMappingPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter()
const { canConfig, channelFilter, options, refreshAll } = useCostCenter()
const [mappingForm] = Form.useForm<MappingFormValues>()
const [view, setView] = useState<'catalog' | 'queue'>('catalog')
@@ -67,11 +67,10 @@ export function ChannelMappingPanel() {
const providers = options?.providers || []
const diagnosticsQuery = useQuery({
queryKey: ['admin-cost-center-mapping-diagnostics', productSourceFilter],
queryKey: ['admin-cost-center-mapping-diagnostics', channelFilter],
queryFn: () =>
fetchAdminChannelMappingDiagnostics({
carrierType: productSourceFilter,
channel: productSourceFilter,
channel: channelFilter,
}),
})
const diagnostics = diagnosticsQuery.data?.data
@@ -79,7 +78,7 @@ export function ChannelMappingPanel() {
const mappingsQuery = useQuery({
queryKey: [
'admin-cost-center-mappings',
productSourceFilter,
channelFilter,
mappingKeyword,
mappingProvider,
mappingPage,
@@ -89,7 +88,7 @@ export function ChannelMappingPanel() {
fetchAdminChannelProductMappings({
keyword: mappingKeyword || undefined,
provider: mappingProvider || undefined,
carrierType: productSourceFilter,
channel: channelFilter,
page: mappingPage,
pageSize: mappingPageSize,
}),
@@ -99,7 +98,7 @@ export function ChannelMappingPanel() {
const queueQuery = useQuery({
queryKey: [
'admin-cost-center-unmapped-mappings',
productSourceFilter,
channelFilter,
queueKeyword,
queueProvider,
queuePage,
@@ -107,7 +106,7 @@ export function ChannelMappingPanel() {
],
queryFn: () =>
fetchAdminUnmappedMappingGroups({
channel: productSourceFilter,
channel: channelFilter,
provider: queueProvider || undefined,
keyword: queueKeyword || undefined,
page: queuePage,
@@ -126,7 +125,13 @@ export function ChannelMappingPanel() {
const productOptions =
options?.products
.filter((item) => !productSourceFilter || item.source === productSourceFilter)
.filter(
(item) =>
!channelFilter ||
(channelFilter === 'unrouted'
? item.activePrimaryChannel === null
: item.activePrimaryChannel === channelFilter),
)
.map((item) => ({
value: item.id,
label: `${item.canonicalName}(${item.canonicalSku})`,
@@ -274,9 +279,9 @@ export function ChannelMappingPanel() {
})
}
const sourceLabel = productSourceFilter
? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter)
: '全部来源'
const channelLabel = channelFilter
? (FULFILLMENT_MODE_LABELS[channelFilter] ?? channelFilter)
: '全部渠道'
return (
<>
@@ -284,7 +289,7 @@ export function ChannelMappingPanel() {
title={
<Space size={6} wrap>
<Typography.Text strong>渠道映射</Typography.Text>
<Tag color="blue">{sourceLabel}</Tag>
<Tag color="blue">{channelLabel}</Tag>
</Space>
}
extra={
@@ -344,7 +349,7 @@ export function ChannelMappingPanel() {
providers={providers}
canConfig={canConfig}
diagnostics={diagnostics}
sourceLabel={sourceLabel}
channelLabel={channelLabel}
expandedRowKeys={expandedMappingProductIds}
detailPageSize={mappingDetailPageSize}
onKeywordChange={(value) => {
@@ -387,7 +392,7 @@ export function ChannelMappingPanel() {
provider={queueProvider}
providers={providers}
canConfig={canConfig}
sourceLabel={sourceLabel}
channelLabel={channelLabel}
onKeywordChange={(value) => {
setQueueKeyword(value)
setQueuePage(1)
@@ -34,7 +34,7 @@ type Props = {
provider: string
providers: string[]
canConfig: boolean
sourceLabel: string
channelLabel: string
onKeywordChange: (value: string) => void
onProviderChange: (value: string) => void
onPageChange: (page: number, pageSize: number) => void
@@ -54,7 +54,7 @@ export default function ChannelMappingQueue({
provider,
providers,
canConfig,
sourceLabel,
channelLabel,
onKeywordChange,
onProviderChange,
onPageChange,
@@ -151,7 +151,7 @@ export default function ChannelMappingQueue({
<Alert
type="success"
showIcon
message={`${sourceLabel}没有未归一的平台 SKU`}
message={`${channelLabel}没有未归一的平台 SKU`}
description="订单项全部命中了标准商品映射;这里为空即代表销售与成本都能按商品归集。"
/>
) : (
@@ -1,7 +1,7 @@
/**
* 成本中心页面上下文。
*
* 只存放「所有面板都要知道」的三件事:权限、当前来源筛选、刷新动作。
* 只存放「所有面板都要知道」的三件事:权限、当前渠道筛选、刷新动作。
* 具体数据由各面板自己用 react-query 取——曾经这里塞过 products / versions /
* exceptions 等空数组当作「统一数据源」,实际没人读,已删除。
*/
@@ -15,16 +15,14 @@ export type CostCenterTab = 'products' | 'mappings' | 'cost' | 'exceptions'
export type CostCenterContextValue = {
canConfig: boolean
/** 当前来源筛选:all / affiliate_dash / work_order / manual。 */
/** 当前渠道筛选。 */
scope: CostScope
setScope: (scope: CostScope) => void
activeTab: CostCenterTab
setActiveTab: (tab: CostCenterTab) => void
scopeLabel: string
/** 商品/映射按 canonical_sku 前缀筛选。 */
productSourceFilter?: ProductSource
/** 成本项/版本/异常按履约方式筛选。 */
channelFilter?: 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual'
/** 商品、映射、成本项、版本和异常共用的当前渠道筛选。 */
channelFilter?: Exclude<CostScope, 'all'>
options?: {
providers: string[]
@@ -34,6 +32,7 @@ export type CostCenterContextValue = {
canonicalName: string
unitName: string
source: ProductSource
activePrimaryChannel: string | null
}>
costItems: Array<{
id: number
@@ -1,5 +1,5 @@
/**
* 成本中心外壳:来源筛选、总览统计与固定 Tab。
* 成本中心外壳:当前渠道筛选、总览统计与固定 Tab。
*
* 结构(2026-10-25 调整):
* 1. 来源不再是「假 Tab」,改为工具栏 Segmented —— 只筛数据,不改变导航;
@@ -39,7 +39,7 @@ import { CostCenterContext, type CostCenterTab } from './CostCenterContext'
import type { CostScope } from './shared'
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
{ key: 'all', label: '全部', hint: '不区分来源,查看全部商品、映射与成本。' },
{ key: 'all', label: '全部', hint: '不区分当前渠道,查看全部商品、映射与成本。' },
{
key: 'affiliate_dash',
label: '自动发货',
@@ -60,6 +60,16 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
label: '人工客服',
hint: '客服手工处理的商品;成本按内部 SKU 的消耗积分结算。',
},
{
key: 'on_account',
label: '统一结算',
hint: '统一结算商品;不进入人工客服或自动履约。',
},
{
key: 'unrouted',
label: '未路由',
hint: '尚未配置有效主渠道的商品。',
},
]
const TABS: readonly CostCenterTab[] = ['products', 'mappings', 'cost', 'exceptions']
@@ -137,11 +147,7 @@ export default function CostCenterShell() {
const scopeLabel = SCOPES.find((item) => item.key === scope)?.label ?? '全部'
const scopeHint = SCOPES.find((item) => item.key === scope)?.hint ?? ''
const productSourceFilter = scope === 'all' ? undefined : scope
const channelFilter =
scope === 'all'
? undefined
: (scope as 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual')
const channelFilter = scope === 'all' ? undefined : scope
return (
<CostCenterContext.Provider
value={{
@@ -151,7 +157,6 @@ export default function CostCenterShell() {
activeTab,
setActiveTab,
scopeLabel,
productSourceFilter,
channelFilter,
options,
refreshAll,
@@ -163,7 +168,7 @@ export default function CostCenterShell() {
<Typography.Text type="secondary">统计口径</Typography.Text>
<Tag color={scope === 'all' ? 'default' : 'blue'}>{scopeLabel}</Tag>
{scope !== 'all' ? (
<Typography.Text type="secondary">(跟随上方来源筛选变化)</Typography.Text>
<Typography.Text type="secondary">(跟随上方渠道筛选变化)</Typography.Text>
) : null}
<Button
size="small"
@@ -1,16 +1,16 @@
/**
* 成本配置:按来源展示「成本怎么算」。
* 成本配置:按履约渠道展示「成本怎么算」。
*
* 三个来源的成本形态完全不同,所以这里不做统一表格,而是按来源切换:
* 各渠道的成本形态不同,所以这里不做统一表格,而是按渠道切换:
* 自动发货 → 上游 SKU 采购价(按 aff:<skuCode> 归一)
* 接单平台 → 接单模板发单金额(只读核对,改价去接单模板页)
* 第三方卡券 → 卡券商品采购价(按 tpv:<sku> 归一,只统计)
* 人工客服 → 内部 SKU 的履约渠道与消耗积分
*
* 来源在页面顶部工具栏统一选择;选「全部」时这里给出四个入口,
* 渠道在页面顶部工具栏统一选择;选「全部」时这里给出四个入口,
* 避免把三套配置竖着堆在一页里。
*/
import { Button, Card, Col, Row, Space, Typography } from 'antd'
import { Alert, Button, Card, Col, Row, Space, Typography } from 'antd'
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
import { useCostCenter } from './CostCenterContext'
@@ -47,17 +47,42 @@ const CONFIG_ENTRIES: Array<{
]
export default function CostConfigPanel() {
const { scope, setScope } = useCostCenter()
const { scope, setScope, setActiveTab } = useCostCenter()
if (scope === 'affiliate_dash') return <AffiliateSkuPanel />
if (scope === 'work_order') return <WorkOrderPricePanel />
if (scope === 'third_party_voucher') return <VoucherSkuPanel />
if (scope === 'manual') return <ManualSkuConfigPanel />
if (scope === 'on_account') {
return (
<Alert
type="info"
showIcon
message="统一结算没有独立商品价格入口"
description="统一结算商品不进入人工或自动履约;实际成本以统一结算账单确认结果为准。"
/>
)
}
if (scope === 'unrouted') {
return (
<Alert
type="warning"
showIcon
message="未路由商品暂时没有履约成本口径"
description="先为标准商品配置明确主渠道,再维护对应渠道的成本。"
action={
<Button size="small" onClick={() => setActiveTab('products')}>
去标准商品
</Button>
}
/>
)
}
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Typography.Text type="secondary">
成本配置按来源分开维护,选择一个来源查看;也可以在页面顶部直接切换来源。
成本配置按履约渠道分开维护;也可以在页面顶部直接切换渠道。
</Typography.Text>
<Row gutter={[12, 12]}>
{CONFIG_ENTRIES.map((entry) => (
@@ -94,7 +94,7 @@ export function ProductCatalogPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const navigate = useNavigate()
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
const { canConfig, channelFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
@@ -120,7 +120,7 @@ export function ProductCatalogPanel() {
const [priceForm] = Form.useForm()
// 游戏分类与二级目录结构仅为接单平台(work_order)独有,自动发货与人工客服不使用
const isWorkOrder = productSourceFilter === 'work_order'
const isWorkOrder = channelFilter === 'work_order'
const showCategoryBar = isWorkOrder
useEffect(() => {
@@ -133,7 +133,7 @@ export function ProductCatalogPanel() {
const productsQuery = useQuery({
queryKey: [
'admin-cost-center-products',
productSourceFilter,
channelFilter,
productKeyword,
isWorkOrder ? productRootCategoryId : null,
isWorkOrder ? productSubCategoryId : null,
@@ -143,7 +143,7 @@ export function ProductCatalogPanel() {
queryFn: () =>
fetchAdminProductCatalog({
keyword: productKeyword || undefined,
carrierType: productSourceFilter,
channel: channelFilter,
rootCategoryId: isWorkOrder ? (productRootCategoryId ?? undefined) : undefined,
categoryId: isWorkOrder ? (productSubCategoryId ?? undefined) : undefined,
page: productPage,
@@ -451,7 +451,7 @@ export function ProductCatalogPanel() {
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
item.sourceKind === 'affiliate_dash'
? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。'
: ''
modal.confirm({
@@ -516,12 +516,6 @@ export function ProductCatalogPanel() {
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
</Tag>
) : null}
{row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? (
<Tag color="purple" style={{ marginInlineEnd: 0 }}>
当前主渠道:
{FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel}
</Tag>
) : null}
</Space>
</div>
),
@@ -540,14 +534,40 @@ export function ProductCatalogPanel() {
),
},
{
title: '已绑定渠道',
title: '当前履约',
width: 160,
render: (_, row) => (
<Space direction="vertical" size={2}>
{row.activePrimaryChannel ? (
<Tag
color={row.activePrimaryChannel === 'manual' ? 'orange' : 'purple'}
style={{ marginInlineEnd: 0 }}
>
{FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel}
</Tag>
) : (
<Tag style={{ marginInlineEnd: 0 }}>未路由</Tag>
)}
{row.fallbackChannels?.length ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
兜底:
{row.fallbackChannels
.map((channel) => FULFILLMENT_MODE_LABELS[channel] || channel)
.join(' / ')}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '平台映射',
dataIndex: 'mappingCount',
width: 130,
render: (value: number, row) => {
if (row.source === 'affiliate_dash') {
if (row.sourceKind === 'affiliate_dash') {
return <Tag color="default">直连无需映射</Tag>
}
if (row.source === 'manual') {
if (row.sourceKind === 'manual') {
return <Tag color="default">内部SKU无需映射</Tag>
}
return value > 0 ? (
@@ -562,7 +582,7 @@ export function ProductCatalogPanel() {
dataIndex: 'costVersionCount',
width: 140,
render: (value: number, row) => {
if (row.source === 'manual') {
if (row.sourceKind === 'manual') {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
以人工 SKU 配置为准
@@ -596,7 +616,7 @@ export function ProductCatalogPanel() {
>
{value > 0
? `${value} 个版本 · 改价`
: row.source === 'work_order'
: row.sourceKind === 'work_order'
? '配置结算成本'
: '未配置 · 立即配置'}
</Button>
@@ -634,7 +654,7 @@ export function ProductCatalogPanel() {
render: (_, row) =>
canConfig ? (
<Space size={2} wrap>
{row.source === 'manual' ? (
{row.sourceKind === 'manual' ? (
<Button
type="link"
size="small"
@@ -704,7 +724,7 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{productSourceFilter === 'work_order' ? (
{channelFilter === 'work_order' ? (
<>
<AdminRangePicker
value={backfillRange}
@@ -727,7 +747,7 @@ export function ProductCatalogPanel() {
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
/>
{productSourceFilter === 'manual' ? (
{channelFilter === 'manual' ? (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -737,7 +757,7 @@ export function ProductCatalogPanel() {
>
新增人工 SKU
</Button>
) : productSourceFilter === 'third_party_voucher' ? (
) : channelFilter === 'third_party_voucher' ? (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -758,7 +778,7 @@ export function ProductCatalogPanel() {
) : null
}
>
{productSourceFilter === 'manual' ? (
{channelFilter === 'manual' ? (
<Alert
type="info"
showIcon
@@ -863,7 +883,7 @@ export function ProductCatalogPanel() {
dataSource={products}
loading={productsQuery.isFetching}
rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')}
scroll={{ x: 1180 }}
scroll={{ x: 1320 }}
pagination={buildAdminTablePagination({
current: productPage,
pageSize: productPageSize,
@@ -55,7 +55,7 @@ export default function VoucherSkuPanel() {
queryKey: ['cost-center-voucher-products', page, pageSize],
queryFn: () =>
fetchAdminProductCatalog({
carrierType: 'third_party_voucher',
channel: 'third_party_voucher',
page,
pageSize,
}),
@@ -3,7 +3,7 @@
*
* 口径(2026-10-23 重写):
* 1. 履约渠道唯一真源是 order_items.fulfillment_channel + status,前端只读不改;
* 2. 商品来源由 canonical_sku 前缀推导,前端不再存「商品载体」字段;
* 2. 商品来源来自 sourceKind,当前渠道来自统一商品投影,两者不能混用;
* 3. 成本项用 cost_model 表达「成本怎么算」,没有「适用载体」这一列。
*/
import type { Dayjs } from 'dayjs'
@@ -16,8 +16,15 @@ import type {
ProductSource,
} from '@/types/admin'
/** 顶部来源切换:全部 / 自动发货 / 接单平台 / 第三方卡券 / 人工。 */
export type CostScope = 'all' | 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual'
/** 顶部当前渠道切换。 */
export type CostScope =
| 'all'
| 'affiliate_dash'
| 'work_order'
| 'third_party_voucher'
| 'manual'
| 'on_account'
| 'unrouted'
export type CostItemPreset = {
key: string
@@ -86,12 +93,14 @@ export const COST_MODEL_OPTIONS = [
{ value: 'other', label: '其他' },
]
/** 商品来源:由 canonical_sku 前缀推导。 */
/** 商品来源:来自 product_catalog.source_kind。 */
export const PRODUCT_SOURCE_OPTIONS = [
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'work_order', label: '接单平台' },
{ value: 'third_party_voucher', label: '第三方卡券' },
{ value: 'manual', label: '人工客服' },
{ value: 'on_account', label: '统一结算' },
{ value: 'unclassified', label: '未分类' },
]
export const PRODUCT_SOURCE_TAG_COLORS: Record<ProductSource, string> = {
@@ -99,6 +108,8 @@ export const PRODUCT_SOURCE_TAG_COLORS: Record<ProductSource, string> = {
work_order: 'blue',
third_party_voucher: 'cyan',
manual: 'orange',
on_account: 'gold',
unclassified: 'default',
'': 'default',
}
+12 -2
View File
@@ -8,8 +8,15 @@ export type FulfillmentChannel =
| 'manual'
| 'on_account'
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''
/** 商品来源:来自稳定 source_kind,空串表示未分类。 */
export type ProductSource =
| 'affiliate_dash'
| 'work_order'
| 'third_party_voucher'
| 'manual'
| 'on_account'
| 'unclassified'
| ''
/**
* 成本模型:只表达「这笔成本怎么算」,与履约方式正交。
@@ -45,6 +52,7 @@ export interface ProductCatalogItem {
canonicalName: string
category: string
source: ProductSource
sourceKind: ProductSource
rootCategoryId: number | null
rootCategoryName: string
subCategoryId: number | null
@@ -60,6 +68,7 @@ export interface ProductCatalogItem {
templateEnabled?: boolean | null
templateSharingEnabled?: boolean | null
activePrimaryChannel?: FulfillmentChannel | null
fallbackChannels?: FulfillmentChannel[]
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
affiliateTaskCount: number
affiliateLastFulfilledAt: string | null
@@ -164,6 +173,7 @@ export interface CostCenterOptions {
canonicalName: string
unitName: string
source: ProductSource
activePrimaryChannel: FulfillmentChannel | null
}>
costItems: Array<{
id: number
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('标准商品列表按当前渠道筛选,并单独展示来源、主渠道和兜底渠道', async () => {
const source = await readFile(
new URL('../src/pages/admin/cost-center/ProductCatalogPanel.tsx', import.meta.url),
'utf8',
)
assert.ok(source.includes('channel: channelFilter'))
assert.ok(!source.includes('carrierType: productSourceFilter'))
assert.ok(source.includes("title: '来源'"))
assert.ok(source.includes("title: '当前履约'"))
assert.ok(source.includes('row.activePrimaryChannel'))
assert.ok(source.includes('row.fallbackChannels'))
assert.ok(source.includes('未路由'))
})
test('渠道映射的统计、目录、队列和商品下拉都使用当前渠道', async () => {
const [panel, catalog, queue] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/ChannelMappingPanel.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/cost-center/ChannelMappingCatalog.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/cost-center/ChannelMappingQueue.tsx', import.meta.url),
'utf8',
),
])
assert.ok(panel.includes('channel: channelFilter'))
assert.ok(panel.includes('item.activePrimaryChannel === channelFilter'))
assert.ok(!panel.includes('carrierType: productSourceFilter'))
assert.ok(catalog.includes('channelLabel'))
assert.ok(queue.includes('channelLabel'))
})
@@ -52,7 +52,7 @@ test('人工商品不再允许手工创建或执行旧批量归档', async () =>
assert.ok(!productSource.includes('归档跨渠道重复商品'))
assert.ok(!productSource.includes('归档纯历史人工商品'))
assert.ok(productSource.includes("productSourceFilter === 'manual'"))
assert.ok(productSource.includes("channelFilter === 'manual'"))
assert.ok(productSource.includes('manualNew=1'))
assert.ok(productSource.includes('人工标准商品由人工 SKU 配置自动创建'))
assert.ok(serviceSource.includes('cost_manual_product_managed_by_config'))
@@ -43,6 +43,6 @@ test('第三方卡券新增 SKU 一次完成商品、规则和采购价', async
assert.ok(service.includes("'/api/v1/admin/cost-center/voucher-products'"))
assert.ok(!service.includes('/voucher-products/ensure-rules'))
assert.ok(panel.includes("searchParams.get('voucherNew')"))
assert.ok(productCatalog.includes("productSourceFilter === 'third_party_voucher'"))
assert.ok(productCatalog.includes("channelFilter === 'third_party_voucher'"))
assert.ok(productCatalog.includes('voucherNew=1'))
})
+37 -182
View File
@@ -1,200 +1,55 @@
# order_site 文档索引
# order_site 文档入口
> 最后补充:2026-11-05,新增《履约系统架构复盘与优化方案》;生产代码基线 `e8b76b3b` / 迁移 `136`,开发分支 `dev_11.5_2` 已推进到迁移 `141`(履约统一状态、追加事件与正式影子模型)。
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
> 本文是 `docs/` 的唯一入口。**整体财务流程看 [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md)**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
本文件只负责导航,不定义业务口径。
## 一、先记住六条口径
## 唯一权威
### 1. 来源只有三个,不是四个
[统一订单履约与财务模型-权威版](统一订单履约与财务模型-权威版.md)
| sourceKey | 包含的数据通道 | 说明 |
| --- | --- | --- |
| `kuaishou-91` | 91 回调 + 快手 send-code | **同一个来源的两条通道**:91 是订单骨架权威源,快手 send-code 是数据补全源与兜底源;共享店铺键空间与同一套匹配上下文 |
| `pdd` | 拼多多订单同步 | 两段式 `pdd_raw_orders → orders` |
| `douyin-mashida` | 抖音码速达推送 | 抖店订单、DYC 券、H5/兑换页 |
所有订单、内部 SKU、履约渠道、履约状态、商品归一、成本与财务相关开发,必须先阅读该文档。
**不要把 91 与快手 send-code 拆成两个来源、两个适配器或两条业务线。** 产品匹配层的唯一来源键是 `kuaishou-91`(`apps/backend/src/services/product-match/types.ts`),适配器是 `adapters/kuaishou-91.ts`。但订单表与成本映射里存储的 `provider` 仍是 `orders.provider = 91kaquan`;成本中心会把 `91 / open_91 / kuaishou-91` 这些别名归一为 `91kaquan`,映射键必须按落库值建。
## 项目规则
### 2. 一个订单 = 骨架 + 买家凭证 + 一个履约方式
[AGENTS.md](../AGENTS.md)
- 骨架:`orders` + `order_items`
- 买家凭证:快手电子凭证(`kuaishou_industry_vouchers`)或抖音券(`redeem_certificates`)
- 履约方式只有四种:`affiliate_dash` 自动发货、`work_orders` 接单平台、`legacy_platform` 已下线历史平台、`manual` 人工
- **电子凭证只是核销码,不参与履约方式判定**;有核销码的订单仍可能是自动发货/接单平台/人工
- 判定顺序固定:工单 → 自动发货任务 → 历史平台任务 → 其余为人工
- 人工块必须进入人工支出与成本快照,不能静默消失
包含任务读取顺序、数据安全边界、文档维护规则和提交要求。
### 3. 履约方式与成本模型必须分开
## 运维资料
- `fulfillment_channel`(+ `fulfillment_status`)回答“谁执行”;`cost_model` 回答“成本怎么算”
- 自动发货:`affiliate_purchase`
- 接单平台:`work_order_reward`
- 人工:`manual_actual`(逐单实际金额)
- 历史平台:`legacy_cost`(历史未采集则显式标记)
- 电子凭证不建成本项,也不作为成本类型
- 未确认成本时保持 `cost_missing`,不默认 0
### 4. 成本只在一个地方改
- 商品主数据:`product_catalog`
- 渠道 SKU 映射:`channel_product_mappings`
- 成本金额:只在 `cost_versions` 改,按生效日期新增版本
- 订单/工单/人工单只保存 `cost_snapshot_id`,历史成本永不回写
- 自动发货、接单平台、人工客服共用同一个成本解析器;匹配不到成本进入 `cost_missing`,不默认 0
### 5. 人工成本只有一条链路
```text
订单列表/详情「确认履约」→ 扣客服积分/备用金 → 写 cost_snapshots(manual_case)
```
| 名称 | 业务含义 | 当前状态 |
| --- | --- | --- |
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100);确认履约的现金支出也写这里 |
| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=order_item_id`(资金来源写 metadata) | 已实施(迁移 104) |
**没有独立的登记单,也没有审批与报销环节。** `manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线(这两张表在生产 102 上从未创建)。后续做财务系统时,人工成本的钱与账都从这条链路走,不要再另造一张人工支出表。
### 6. 历史人工直接放弃,上线后必须全覆盖
- 历史人工支出找不回来,**直接放弃**:不补录、不做样本校准、不阻塞上线;
- 上线后每一笔人工单必须有处理人、实际金额、备用金支出与成本快照;
- 人工单无成本记录、人工单未扣备用金、金额与备用金流水不一致,均不允许关账。
### 7. 文档状态
| 状态 | 含义 |
| 文档 | 用途 |
| --- | --- |
| 现行 | 与当前代码一致,改动代码时必须同步更新 |
| 方案 | 尚未实施,但作为下一阶段实现依据 |
| 参考 | 外部协议、历史数据或解释性材料,不作为实现依据 |
| 历史 | 只用于追溯,不得作为实现依据;位于 `docs/archive/` |
| [部署启动与数据库重建](部署启动与数据库重建.md) | 本地与生产部署、数据库重建 |
| [数据库备份与恢复](数据库备份与恢复.md) | 备份、恢复、OSS |
| [数据库SQL约束与运维](数据库SQL约束与运维.md) | SQL 护栏与生产观测 |
## 二、财务系统当前进度
## 专项参考
| 模块 | 状态 | 事实 |
| --- | --- | --- |
| 客服备用金台账 | **已实施 P0** | 迁移 `100/101/107`;账户、流水、支出、对账;编号 `FA-<用户名>`;客服只能使用本人账户 |
| 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 |
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0/P1** | 2,017 个标准商品、4,897 条映射:`tpl:` 710、`man:` 1,195、`aff:` 49、`wo:` 63;订单项已按履约证据/人工内部 SKU 归一 |
| 成本中心 | **已实施 P0** | 迁移 `103`;710 个规则级版本 + 2 个全局占位版本;工单成本快照 151,939 条,其中 actual 129,430、estimated 22,509 |
| 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `103`;375,447 项、收入 ¥3,143.61 万、成本 ¥572.95 万、毛利 ¥2,570.66 万;`sales_daily_facts.channel` |
| 人工成本录入(确认履约) | **已实施 P0(2026-10-26 单事务 + 防重复)** | 确认即扣积分/备用金并写 `actual` 快照;钱与成本同事务、订单项级唯一;无登记单/审批/报销 |
| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时 rematch + 确认时按 SKU 兜底匹配 |
| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`:首次人工配置之后的订单项未确认即入队,确认后自动收口 |
| 履约方式统一 | **已实施 P0** | 迁移 `103`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 |
| 人工商品归一 | **已实施 P1(本次快照恢复执行)** | 人工 115,194 项已归一为 1,195 个 `man:<内部SKU>` 商品;历史人工支出仍不补录 |
| 影子总账 | **已实施 P0(2026-10-26)** | `finance_accounts/journal_entries/journal_lines/periods`;销售事实按日凭证 + 手工凭证 + 试算平衡;不接管钱包余额 |
| 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 |
| 平台对账 / 期间关账 | **已实施 P0** | 平台账单 JSON 导入匹配、差异明细、标记解决;关账/反关账;见 `/admin/finance/ledger` |
| 平台退款 API | **部分实施** | 快手行业(91)同意退款已接入;抖音/拼多多暂未接 API,仍手工登记 |
专项参考只解释具体平台、工具或历史问题,不能覆盖唯一权威文档。
> 分支状态(2026-10-27):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移收敛为 `103`–`105`(103 财务基础、104 财务控制/L1、105 拼单/寄卖客服);已验证 001→105 与 102→105 两条升级路径。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。
## 三、统一实施顺序
```text
0. 统一履约事实(渠道 + 成本模型正交) 已完成(迁移 113)
1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台)
2. 成本中心 + 销售事实 已完成 P0
2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104)
3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录)
4. 退款冲减与异常队列 已完成手工退款 + 快手平台退款;人工缺成本已入队
5. 影子总账(销售事实 + 手工凭证) 已完成 P0(迁移 108)
6. 平台对账、期间关账 已完成 P0(手工账单导入);平台自动拉单未实施
```
**第 0 步已完成。** 迁移、回填、代码改造与验收结果见 [成本中心与履约模型](成本中心与履约模型.md) §六、§八。下一步只做第 3 步人工商品归一,不要同时改退款或总账。
## 四、当前必须拍板
| # | 问题 | 当前结论 | 影响 |
| --- | --- | --- | --- |
| 1 | 收入金额口径 | ✅ 用户实付(`orders.total_amount`) | 收入基数 |
| 2 | 收入确认时点 | ✅ 先按 `paid_at` 归属销售日;成本在快照生成后回填 | 利润表与预收账款 |
| 3 | 打手报酬 | ✅ 支出/成本,总额法 | 履约成本 |
| 4 | 退款/仲裁 | ✅ 暂不实现,只预留反向凭证 | 后续冲减 |
| 5 | 人工支出如何入账 | ✅ 逐单实际金额;从客服备用金扣款;客服只能用本人账户 | 人工成本与备用金 |
| 6 | 历史人工支出 | ✅ **不补录**,只取少量样本校准 | 上线范围 |
| 7 | 电子凭证是否作为分类 | ✅ **不作为履约/成本分类**,只是核销码状态 | 报表口径 |
| 8 | 平台服务费/支付通道费 | ⏳ 待定 | 费用科目与对账 |
| 9 | 寄卖货款成本确认时点 | ⏳ 待定 | 存货/采购成本 |
| 10 | 是否需要发票/税务、是否多币种 | ⏳ 待定 | 当前按 CNY、无税务实现 |
## 五、现行与参考文档
| 文档 | 状态 | 用途 |
| --- | --- | --- |
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
| [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md) | 现行 | 断代后财务全流程:六条管道、成本快照、事实、总账、退款、异常、运维与测试 |
| [订单全流程与内部SKU架构.md](订单全流程与内部SKU架构.md) | 现行 | 平台入单、中文内部 SKU、渠道判定、四类履约载体、商品归一与财务落账的完整链路 |
| [履约系统架构复盘与优化方案-2026-11-05.md](履约系统架构复盘与优化方案-2026-11-05.md) | 方案 | 履约状态、载体、尝试、异常队列与后台信息架构的分阶段重构方案 |
| [订单服务收集与状态页设计-2026-11-03.md](订单服务收集与状态页设计-2026-11-03.md) | 现行 | 分类级资料配置、发布版本与工单快照、`/o` 客户状态页、自动发布核销和通知预留 |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`105`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 |
| [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 |
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(影子总账 P0 已实施) | 复式记账、总账表、对账、关账;P1/P2 平台自动对账待接 |
| [财务系统深度分析-2026-10-27.md](财务系统深度分析-2026-10-27.md) | 参考(一次性审计,不改变现行口径) | 2026-10-27 代码/数据/权限审计、风险清单与上线前修复路线 |
| [dev合并main评估-2026-10-27.md](dev合并main评估-2026-10-27.md) | 参考(合并评估) | dev 领先 main 的改动、迁移收敛、生产规模升级实测与发布检查清单 |
| [财务代码审计复核-2026-10-27.md](财务代码审计复核-2026-10-27.md) | 参考(代码复核) | 对“旧报表重复扣减、总账 DELETE、钱包 BIGINT、死锁”等结论的逐条校正与补充 |
| [打手重复奖励核对-2026-10-27.md](打手重复奖励核对-2026-10-27.md) | 待人工核对 | 8 组疑似重复奖励、16 条流水、多计 ¥196.20;含 84886 寄卖误报说明 |
| [影子总账说明.md](影子总账说明.md) | 现行说明(P0 已实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 |
| [接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md) | 现行(P0/P1 已实施) | `kuaishou-91 / pdd / douyin-mashida` 三来源匹配引擎 |
| [履约配置/多发货平台与电子凭证整体链路.md](履约配置/多发货平台与电子凭证整体链路.md) | 现行 | 进单、履约、领取、电子凭证的整体链路 |
| [affiliate-dash-发货平台/affiliate-dash对接.md](affiliate-dash-发货平台/affiliate-dash对接.md) | 现行 | affiliate-dash 自动履约接口与字段 |
| [履约配置/cloudtentacles发货平台.md](履约配置/cloudtentacles发货平台.md) | 历史参考 | 已下线通道的第三方接口样例,不代表现行实现 |
| [91卡券/6.开发设计.md](91卡券/6.开发设计.md) | 现行 | `kuaishou-91` 来源的 91 侧接入设计 |
| [抖音-码速达/00-总览.md](抖音-码速达/00-总览.md) | 现行 | 抖音码速达接入总览与增量记录 |
| [抖音-码速达/12-按SKU分类改造总设计.md](抖音-码速达/12-按SKU分类改造总设计.md) | 现行 | 抖音三层分流与载体唯一 |
| [部署启动与数据库重建.md](部署启动与数据库重建.md) | 现行 | 本地/生产启动、迁移、重建 |
| [数据库备份与恢复.md](数据库备份与恢复.md) | 现行 | 备份、恢复、OSS |
| [数据库SQL约束与运维.md](数据库SQL约束与运维.md) | 现行 | SQL 护栏与生产观测 |
### SQL 草案(未接入迁移)
草案只作设计与复盘依据;接入 `apps/backend/src/db/migrations/` 时再拆成 `NNN_slug.sql`,并先跑 `npm run check:sql`。
| 文件 | 用途 | 接入前必须处理 |
| --- | --- | --- |
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | **已过时**:`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` / 渠道三列取代,仅作设计追溯 |
| [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 |
## 六、厂商协议与外部资料
这些目录是平台方文档或抓取资料,**不作为本项目实现说明**:
| 目录 | 内容 |
| 文档或目录 | 用途 |
| --- | --- |
| `91卡券/` | 91 卡券公开接口、签名、加密示例(项目接入结论见 `91卡券/6.开发设计.md`) |
| `行业电子凭证/` | 快手电子凭证接口、售后、测试指南 |
| `发货平台-2/` | 早期发货平台协议,现行为 `affiliate_dash`;保留作历史接口对照 |
| `抖音-码速达/接口API/` | 抖音码速达出网接口 |
| `抖音-码速达/消息推送/` | 抖音码速达推送消息字段与签名 |
| [设备认证-dcauth协议](设备认证-dcauth协议.md) | 设备认证协议 |
| [affiliate-dash-发货平台](affiliate-dash-发货平台/) | affiliate-dash 对接资料 |
| [91卡券](91卡券/) | 91 卡券接口与签名资料 |
| [抖音-码速达](抖音-码速达/) | 抖音码速达接口、消息与接入资料 |
| [行业电子凭证](行业电子凭证/) | 快手行业电子凭证协议 |
| [发货平台-2](发货平台-2/) | 历史发货平台协议 |
| [履约配置](履约配置/) | 专项接入与排障参考 |
| [微博微豆-CK接口与兑换链路确认-2026-11-07](微博微豆-CK接口与兑换链路确认-2026-11-07.md) | 微博微豆接口与兑换链路 |
| [打手重复奖励核对-2026-10-27](打手重复奖励核对-2026-10-27.md) | 历史财务核对记录 |
## 七、历史文档
## 历史归档
| 位置 | 说明 |
| --- | --- |
| [archive/README.md](archive/README.md) | 2026-10-08 及更早的阶段分析、寄卖方案、性能快照、一次性迁移检查脚本;2026-10-17 复核文档 |
| [archive/2026-10-17/载体判定与成本中心落地-复核与草案.md](archive/2026-10-17/载体判定与成本中心落地-复核与草案.md) | 2026-10-17 第二轮复核与数据量化;结论已吸收进现行成本中心文档,仅作历史追溯 |
| [archive/2026-10-23/](archive/2026-10-23/) | 2026-10-23 重写前的四篇:`成本中心与三块履约模型`、`人工支出登记落地方案`、`客服备用金台账实施方案`、`财务履约改造交接总结`;结论已分别并入现行 5 篇文档 |
[archive](archive/) 中的文档均已失效,只用于追溯。
## 八、维护规则
2026-11-08 统一重构前的订单、履约、成本、财务、架构与 SQL 草案已归档到:
1. 改代码影响业务口径时,先改现行文档,再提交代码。
2. 文档头部必须写「状态 + 最后校准日期 + 代码基线」。
3. 旧分析不要在原文件上继续堆结论;新建当期报告,旧文件移入 `docs/archive/<日期>/`。
4. 行号只用于当期基线;跨版本引用优先写文件与函数名。
5. 新增来源时只加一个 `ProductMatchSourceKey` + 一个适配器;**91 + 快手 send-code 永远算一个 `kuaishou-91`**。
6. 成本金额只改 `cost_versions`,不要在多个业务表直接改价;历史订单只读 `cost_snapshots`。
7. 报表维度统一用 `canonical_product_id + shop_id + stat_date`,不要直接用原始 `sku_code` 或 `orders.total_amount`。
8. 新增 SQL 草案放 `docs/drafts/`,命名 `<日期>-<主题>.sql`;**不得**直接放进 `apps/backend/src/db/migrations/`(后端启动会自动迁移),接入时再拆编号并先跑 `npm run check:sql`。
9. 财务系统实现顺序以本索引 §三 为准;单篇文档里的分期若与本索引冲突,以本索引为准。
[archive/2026-11-08](archive/2026-11-08/)
## 维护规则
1. 业务和架构口径只修改唯一权威文档。
2. 不再新增第二份“现行架构”“现行财务口径”或“总纲”。
3. 外部平台协议可以保留,但不得覆盖项目自身模型。
4. 已被替代的内部设计文档移入 `docs/archive/<日期>/`。
5. 文档中的代码与迁移基线必须来自实际仓库和生产只读核对。
+9
View File
@@ -0,0 +1,9 @@
# 2026-11-08 历史文档归档
本目录保存 2026-11-08 统一重构前使用的订单、履约、成本、财务和架构文档。
这些文档只用于追溯当时的设计与数据来源,不再作为现行口径、开发依据或验收标准。
现行唯一权威文档:
[统一订单履约与财务模型-权威版](../../统一订单履约与财务模型-权威版.md)
+14 -4
View File
@@ -1,7 +1,8 @@
# 历史文档归档
> 这里的文档只用于追溯当时的分析过程,**不是现行实现说明**。
> 代码、数据库结构与线上行为以仓库当前 `main` 和 `docs/README.md` 中标记为「现行」的文档为准。
> 代码、数据库结构与线上行为只以
> [统一订单履约与财务模型-权威版](../统一订单履约与财务模型-权威版.md) 为准。
## 2026-10-08
@@ -18,7 +19,7 @@
| 文档 | 归档原因 |
| --- | --- |
| `2026-10-17/载体判定与成本中心落地-复核与草案.md` | 2026-10-17 第二轮复核与数据量化;现行结论已吸收进 `成本中心与三块履约模型.md` 与 `财务系统集成方案.md`,仅保留作数据与 DDL 验证追溯 |
| `2026-10-17/载体判定与成本中心落地-复核与草案.md` | 2026-10-17 第二轮复核与数据量化;仅保留作数据和 DDL 验证追溯 |
## 2026-10-23
@@ -26,9 +27,18 @@
| --- | --- |
| `2026-10-23/2026-10-23-本地库原地改造.sql` | 逐列把 `carrier_type` / `cost_type` 改成 `cost_model` / `fulfillment_mode` 的一次性脚本;迁移收敛为 `103`–`107` 后不再成立,**禁止在新环境执行**,仅作改造矩阵追溯 |
## 2026-11-08
2026-11-08 完成订单、履约、商品、成本和财务口径统一。
此前所有相关业务架构、财务方案、字段契约和 SQL 草案整体归档到:
[2026-11-08](2026-11-08/)
## 使用建议
- 需要当前架构与风险:看 `docs/项目全量分析报告-2026-10-17.md`(参考)和 `docs/README.md`(状态索引)。
- 需要财务系统设计:看 `docs/财务系统集成方案.md`;实施顺序以 `docs/README.md` §三 为准。
- 需要当前架构、数据边界和实施计划:只看
[统一订单履约与财务模型-权威版](../统一订单履约与财务模型-权威版.md)。
- 需要某个平台协议:看对应的厂商文档目录(`91卡券/`、`行业电子凭证/`、`发货平台-2/`、`抖音-码速达/接口API/`、`抖音-码速达/消息推送/`)。
- 历史文档中的代码行号、测试数量、迁移数量不要直接引用。
- 历史文档不得重新标记为“现行”,需要复活时必须并入唯一权威文档。
+1 -1
View File
@@ -266,7 +266,7 @@
### 2)接单模板匹配引擎重构(`10464fea`,P0+P1)
按 [接单模板匹配引擎重构设计.md](../接单模板匹配引擎重构设计.md) 抽出 `services/product-match/`(适配器按**来源**划分:`kuaishou-91` / `pdd` / `douyin-mashida`,其中 `kuaishou-91` 同时覆盖 91 回调与快手 send-code 两条数据通道)。生产回放 6000 条订单项:`kuaishou-91`/PDD 决策 **0 差异**,抖音 5 条全为修正。旧 `work-product-match-mappers.ts` 变为门面,签名不变。
按 [接单模板匹配引擎重构设计.md](../archive/2026-11-08/接单模板匹配引擎重构设计.md) 抽出 `services/product-match/`(适配器按**来源**划分:`kuaishou-91` / `pdd` / `douyin-mashida`,其中 `kuaishou-91` 同时覆盖 91 回调与快手 send-code 两条数据通道)。生产回放 6000 条订单项:`kuaishou-91`/PDD 决策 **0 差异**,抖音 5 条全为修正。旧 `work-product-match-mappers.js` 改为兼容门面,签名不变。
### 3)工单「发布并核销」支持抖音券(本次)
@@ -348,7 +348,7 @@
| 可观测性 | `utils/logger.ts:96/122`、`repositories/task-event-repo.ts:16`、`repositories/admin-notification-repo.ts:25`、`services/realtime/realtime-event-service.ts:92` | 日志 / 任务事件 / 后台待办 + SSE |
| 环境变量白名单(硬约束) | `config/env-documentation.test.ts:57/63`、`.env.server.example`、`.env.mac-docker.example` | 新增 env 必须登记,否则测试红 |
| 隐私红线 | `services/worker-platform/sync-worker-orders-service.ts:206-220` | 打手工单素材不含买家信息 |
| 平台接入设计范本 | `docs/91卡券/6.开发设计.md`(来源建模/配置/签名/代码落点/测试范围)、`docs/履约配置/多发货平台与电子凭证整体链路.md` | 本项目的「新平台接入」方法论 |
| 平台接入设计范本 | `docs/91卡券/6.开发设计.md`(来源建模/配置/签名/代码落点/测试范围)、`docs/archive/2026-11-08/多发货平台与电子凭证整体链路.md` | 本项目的「新平台接入」方法论 |
---
@@ -0,0 +1,760 @@
---
type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
version: 1.0
updated_at: 2026-11-08
---
# 统一订单、履约与财务模型
> 状态:**唯一权威文档**。
> 最后校准:2026-11-08。
> 生产基线:迁移 `146`。
> 开发基线:迁移 `158`(仅本地,尚未部署生产)。
>
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
## 一、最终结论
系统按六个业务域拆分:
```text
订单接入
→ 商品身份
→ 履约路由
→ 履约执行
→ 财务核算
→ 运营治理
```
换一种更具体的表达:
```text
平台订单与原始行
→ 中文内部 SKU
→ 主渠道与兜底渠道
→ 当前履约单与执行尝试
→ 标准商品与成本事实
→ 销售事实、总账与报表
```
每一层只能回答一个问题:
| 层 | 唯一问题 | 唯一真源 |
| --- | --- | --- |
| 订单接入 | 平台实际发生了什么 | `orders` / `order_items` / 原始报文 |
| 商品身份 | 业务上这是什么商品 | `internal_skus` |
| 履约路由 | 这个商品默认由谁履约 | `sku_channel_routes` |
| 履约执行 | 当前实际执行到哪一步 | `fulfillments` / `fulfillment_attempts` |
| 财务商品 | 收入与成本归到哪个标准商品 | `product_catalog` / `channel_product_mappings` |
| 财务核算 | 应该记多少钱 | `cost_snapshots` / `sales_daily_facts` / `journal_*` |
任何页面、接口、任务和报表都必须从各自真源读取,不得根据另一层的字段反推。
## 二、不可违反的规则
1. **原始订单事实永不删除。**
`orders`、`order_items`、平台原始报文、平台售后与退款事实、真实工单执行记录、
真实钱包和资金流水、后台审计日志必须保留。
2. **标准商品不表达当前履约渠道。**
`canonical_sku` 前缀、`product_catalog.category` 和商品名称都不能反推当前渠道。
3. **人工配置不等于当前走人工。**
人工配置只表示具备人工执行或人工兜底能力。当前渠道必须来自路由结果。
4. **一个内部 SKU 最多一个主渠道。**
其他启用渠道只能是显式兜底,并记录优先级与用途。
5. **`assigned` 不等于履约完成。**
`assigned` 只允许表示路由已确定;载体是否存在由 `carrier_status` 单独表达。
6. **财务层只读上游。**
成本、事实和总账不得反向修改订单、路由、载体或商品来源。
7. **派生数据必须可重建。**
成本快照、销售事实、总账、异常队列、投影和影子履约模型都必须能够从原始订单、
输入配置和事件重算。
8. **页面与统计使用同一读模型。**
同一个数量,页签、卡片、列表和导出不得各自拼口径。
9. **历史文档不再定义现行规则。**
旧架构、旧财务方案和旧迁移说明只能放入归档目录。
10. **一次只允许一个权威写入口。**
核心状态只允许由对应领域服务修改;其他模块只能调用该服务。
## 三、领域边界
### 3.1 订单接入域
负责:
- 接收 91、快手送码、拼多多、抖音码速达的数据。
- 保存平台订单、订单项、原始报文、支付和售后事实。
- 保证来源重放幂等。
- 将平台商品字段解析为中文内部 SKU。
不负责:
- 选择履约渠道。
- 创建工单或履约任务。
- 计算成本。
- 决定标准商品。
订单项上的 `fulfillment_*`、`canonical_product_id` 等字段在过渡期保留,
但只能由对应领域服务通过兼容投影维护,不允许入单服务自行猜测。
### 3.2 商品身份域
目标真源:
```text
internal_skus
id
internal_sku_key -- NFKC、去空白、小写后的稳定键
display_name -- 运营可读名称
status -- active / disabled / merged
merged_into_id -- 合并目标
```
所有人工配置、渠道路由、订单项快照和渠道规则都必须通过 `internal_sku_id` 关联,
不再长期依赖多张表重复保存同一个中文文本。
平台字段到内部 SKU 的唯一入口:
```text
internal_sku_mappings
provider
shop_id
match_type
match_value
internal_sku_id
priority
enabled
```
匹配顺序固定:
```text
provider + shop 精确
→ provider 精确
→ shop 精确
→ 全局
```
### 3.3 履约路由域
目标真源:
```text
sku_channel_routes
id
internal_sku_id
channel
route_role -- primary / fallback
priority
enabled
carrier_config_json
source
created_by
updated_at
```
渠道枚举:
```text
affiliate_dash
work_order
third_party_voucher
manual
on_account
```
规则:
- `active + primary` 每个内部 SKU 最多一条。
- `fallback` 可以多条,按 `priority` 依次尝试。
- 主渠道失败时才能尝试兜底渠道。
- 运营指定和自动判定必须写差异化 `source`。
- 路由配置不能保存“当前订单结果”;当前结果属于履约域。
### 3.4 履约执行域
当前 `order_items.fulfillment_channel/status` 和 `v_fulfillment_item_state`
属于过渡读模型。目标模型使用:
```text
fulfillments
id
order_item_id
internal_sku_id
route_status -- unrouted / configured / locked
channel
carrier_status -- not_required / missing / creating / ready / failed / terminal
current_attempt_id
issue_code
issue_category
retryable
owner_role
created_at
updated_at
fulfillment_attempts
id
fulfillment_id
attempt_no
attempt_type -- task / work_order / manual
source_id
status
result_code
result_message
is_current
started_at
finished_at
context_json
fulfillment_issues
id
fulfillment_id
issue_code
issue_category
severity
retryable
auto_retryable
owner_role
status -- open / acknowledged / waiting / resolved
detail_json
first_seen_at
last_seen_at
resolved_at
```
状态必须正交:
```text
商业订单状态
≠ 路由状态
≠ 载体状态
≠ 当前尝试状态
≠ 异常状态
≠ 锁定原因
≠ 财务状态
```
同一订单项只能有一个当前尝试。历史尝试只追加,不覆盖。
### 3.5 商品与财务域
标准商品目标结构:
```text
product_catalog
id
canonical_sku
canonical_name
source_kind -- affiliate / work_order / voucher / manual / settlement
internal_sku_id -- 可空;关联业务内部 SKU
category -- 仅展示,不参与路由
unit_name
status
```
`canonical_sku` 前缀只用于兼容识别。运行时和统计必须读取 `source_kind`,
不能再散落 `LIKE 'man:%'` 之类的业务判断。
商品映射:
```text
channel_product_mappings
provider + shop_id + platform_sku
→ product_catalog
```
渠道规则关联的产品只用于说明该渠道执行时对应的财务商品,
不能反向决定路由。
成本与财务:
```text
cost_versions -- 成本规则与价格版本,是输入真源
cost_snapshots -- 单个订单项在某时点的成本事实,可重建
sales_daily_facts -- 收入、退款、成本汇总,可重建
finance_journal_* -- 总账凭证,可重建
```
## 四、单一真源矩阵
| 信息 | 唯一写入方 | 唯一真源 | 明确禁止 |
| --- | --- | --- | --- |
| 平台订单与订单项 | 订单接入服务 | `orders` / `order_items` | 财务或履约服务改写平台事实 |
| 平台原始报文 | 各平台适配器 | 原始表 / `raw_payload_json` | 覆盖历史报文 |
| 中文内部 SKU | 商品身份服务 | `internal_skus` | 各业务表各自发明同义 SKU |
| 平台字段到内部 SKU | 映射服务 | `internal_sku_mappings` | 按名称猜测渠道 |
| 主渠道与兜底 | 路由服务 | `sku_channel_routes` | UI 根据商品前缀筛选 |
| 当前履约状态 | 履约状态服务 | `fulfillments` | 多服务直接更新订单项 |
| 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 |
| 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 |
| 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 |
| 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 |
| 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 |
| 成本事实 | 成本快照服务 | `cost_snapshots` | 改价覆盖历史 |
| 销售事实 | 事实服务 | `sales_daily_facts` | 报表直接从订单表现算另一套 |
| 总账 | 总账服务 | `finance_journal_*` | 业务端直接写凭证 |
| 页面投影 | 只读投影 | `v_*` / materialized view | 页面自行拼口径 |
## 五、当前系统的问题证据
2026-11-08 生产只读核对结果:
- 生产迁移 `146`,本地开发基线迁移 `158`。
- 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。
- `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。
- 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。
- 41 个跨渠道 `man:` 商品中:卡券 33、自动发货 3、接单平台 2、统一结算 3。
- 另有 2 个 `man:` 商品无主渠道,但保留人工配置,属于人工兜底。
- 自动发货的 3 个商品中,`man:suit_shadow_gothic` 与 `man:套装-狂沙舞者`
有有效人工兜底配置,必须保持 active,但不能出现在“人工客服”页签。
- 最终可归档对象是 36 个:卡券 33、接单平台 2、无人工兜底的自动发货 1。
- 生产活动履约视图共 31744 行,其中 1226 行仍有结构化异常:
- 用户退款 532;
- 平台取消 126;
- 缺内部 SKU 29;
- 缺渠道 26;
- 人工配置缺失 19;
- 其余为载体重试、人工复核、状态不一致等。
- 当前任务重复为 0,多个活跃工单为 0,说明“当前尝试”约束已经开始生效。
这些问题证明:
1. 旧模型不是完全不可用,但多个真源同时存在。
2. 页面已经出现“总览按渠道、列表按来源”的口径分叉。
3. 历史清理功能曾经存在,但在后续重构中被移除。
4. 文档基线和生产基线不一致,旧文档仍在被当作现行依据。
## 六、数据保留与重建边界
本次重构允许清理派生数据,但必须严格分级。
### A 级:永久保留,不得删除或重写历史
- `orders`
- `order_items` 的原始平台字段
- 平台原始订单表与原始报文字段
- 平台售后、退款、支付事实
- `work_orders`、`work_order_shares`、`work_order_events`
- `fulfillment_tasks` 中真实执行与结果记录
- `worker_wallet_ledgers`、`fund_accounts` 的真实资金流水
- `admin_audit_logs`
- 财务期间、已确认退款和真实资金账户
A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型整齐”重写历史事实。
### B 级:业务输入,必须迁移而不是重建
- `internal_sku_mappings`
- `internal_sku_channels` 中仍有效的路由意图
- `work_product_rules` 及模板匹配配置
- `manual_fulfillment_configs`
- `product_catalog` 中仍有效的标准商品
- `channel_product_mappings`
- `cost_versions`
- 第三方平台配置、店铺配置和账号配置
迁移时必须人工核对冲突,不得自动选最新或按出现次数猜测。
### C 级:可删除并从 A/B 级重建
- `cost_snapshots`
- `cost_exceptions`
- `sales_daily_facts`
- `finance_journal_entries` / `finance_journal_lines`
- `fulfillments` / `fulfillment_attempts` / `fulfillment_issues`
- `v_fulfillment_item_state` 及其依赖投影
- 商品统计投影、渠道统计投影、页面汇总缓存
- 管道水位与失败重试状态
重建前必须备份;重建后必须通过 A/B 级数据守恒检查。
### D 级:兼容期结束后删除
- `product_channel_capabilities`
- `manual_sku_registry` 中可由配置重建的登记行
- 订单项上的重复履约结果字段,在正式模型切换后删除
- 商品表中的历史来源前缀判断代码
- 旧路由补丁脚本和一次性迁移工具
## 七、UI 与 API 统一口径
“标准商品目录”的页签必须表示**当前路由状态**,不是商品来源:
```text
全部
自动发货
接单平台
第三方卡券
人工客服
统一结算
未路由
```
商品来源作为独立列展示:
```text
aff: / tpl: / wo: / tpv: / man:
```
列表必须显示:
```text
商品
来源
当前主渠道
兜底渠道
当前状态
成本状态
```
以下行为禁止:
- “人工客服”页签显示当前主渠道不是 `manual` 的商品。
- 用 `product_catalog.category` 决定路由或页签。
- 用 `man:` 前缀判断当前人工订单量。
- 通过商品名称或标题自动猜测渠道。
- 同一页面使用两个不同统计 SQL。
统一读模型至少提供:
```text
product_id
canonical_sku
canonical_name
source_kind
internal_sku_id
current_primary_channel
fallback_channels
manual_config_id
current_status
has_active_cost
```
## 八、存量数据处理方案
### 8.1 已被其他渠道接管的 `man:` 商品
处理原则:
1. 先确认目标主渠道已有活动商品。
2. 将仍指向旧 `man:` 的人工兜底改指目标商品。
3. 保留订单项、成本版本、成本快照和历史引用。
4. 只把旧 `man:` 商品置为 `archived`,不硬删除。
5. 如果历史成本快照必须保留旧商品维度,不改变历史快照。
预期清理范围:
- 33 个卡券重复商品;
- 2 个接单平台重复商品;
- 1 个没有有效人工兜底配置的自动发货重复商品。
以上共 36 个 `manual_normalization` 旧商品,属于可确认归档对象。
另外 2 个自动发货 `man:` 商品有有效人工兜底配置,不归档、不进入人工主渠道页签。
### 8.2 统一结算商品
3 个当前主渠道为 `on_account` 的商品不是人工商品。
处理方式:
- 保留商品和历史引用;
- 增加统一结算分类;
- 从人工页签移出;
- 由 `on_account` 路由与结算成本口径管理。
### 8.3 无主渠道但有人工配置的商品
目前有 2 个,属于合法人工兜底。
处理方式:
- 保留;
- 在商品页显示“未路由 / 人工兜底”;
- 不计入“已分配”;
- 由运营决定是否补主渠道。
### 8.4 历史清理功能
`31b6d2d6` 曾实现跨渠道 `man:` 商品归档,但 `974044a4` 在人工 SKU 重构中删除了入口,
并把筛选测试改成保留所有 `man:` 商品。
后续清理必须:
- 作为一次性数据迁移,而不是临时页面按钮;
- 有 dry-run、数量、样本和备份;
- 有真实 PostgreSQL 集成测试;
- 不依赖“运营记得点清理”。
## 九、目标迁移路线
| 阶段 | 状态 | 是否改生产数据 |
| --- | --- | --- |
| 0 口径冻结与文档收口 | 已完成 | 否 |
| 1 只读审计与领域约束 | 本地进行中 | 否 |
| 2 建立目标身份与投影 | 本地完成 | 只新增兼容结构 |
| 3 迁移路由与商品配置 | 本地进行中 | 是,需备份 |
| 4 重建履约模型 | 未开始 | 是,需断代 |
| 5 重建财务派生数据 | 未开始 | 是,需备份与对账 |
| 6 停止旧写路径并删除兼容层 | 未开始 | 是,需观察期结束 |
### 阶段 0:口径冻结与文档收口
状态:本次完成。
产出:
- 本文件成为唯一权威。
- 旧业务架构文档全部归档。
- `docs/README.md` 只保留入口。
- `AGENTS.md` 固定执行规则。
验收:
- 活跃文档中不再存在竞争性的架构结论。
- 所有 Agent 和开发者从本文件开始。
### 阶段 1:只读审计与领域约束
目标:
- 不改变生产数据,先建立全量审计。
- 核对内部 SKU、路由、商品、履约和财务的冲突。
工作:
1. 建立内部 SKU 重复与合并候选报告。
2. 建立商品来源与主渠道冲突报告。
3. 建立无主渠道、多主渠道、无载体和退款混入履约报告。
4. 建立成本快照无法重建报告。
5. 为每类冲突定义责任角色和处理动作。
验收 SQL:
```text
同一内部 SKU 主渠道数 > 1 必须为 0
启用商品无 source_kind 必须为 0
人工配置指向非 man 商品 必须为 0
当前主渠道非 manual 的 man 商品出现在人工页 必须为 0(保留则必须有显式兜底)
assigned 且 carrier_status=missing 必须进入异常队列
退款/取消出现在普通履约队列 必须为 0
同一订单项当前尝试 > 1 必须为 0
```
### 阶段 2:建立目标身份与投影
目标:
- 建立 `internal_skus`。
- 为 `product_catalog` 增加 `source_kind` 和 `internal_sku_id`。
- 建立统一商品投影 `v_product_current`。
- 建立统一履约投影 `v_fulfillment_current`。
本地已完成身份表、商品来源字段和 `v_product_current`,并已切换商品列表、
总览与渠道映射的读取口径。生产未执行。
本地产出:
- 变更前备份:`backups/postgres/order_site-pre-unified-20261108.dump`,
SHA-256 `661d7751d76fcd7db80af92e97d1e84245a25c60b0ec76b8d83ca930fcbb5142`。
- 迁移 `154`:建立 `internal_skus`、`sku_channel_routes` 与兼容同步触发器。
- 迁移 `155`:建立 `v_product_current`,并归档可确认失效的旧 `man:` 商品。
- 迁移 `156`:修复 `source_kind` 默认值导致的前缀回填失效。
- 迁移 `157`:保证只有兜底配置的 SKU 不会被误升级成主渠道。
- 迁移 `158`:禁止 `man:` 来源直接冒充人工主渠道;没有 primary route 时显示“未路由”。
- 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。
- 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。
验收:
- 旧字段与新投影差异有清单。
- 商品列表和总览读取同一投影后数量一致。
- 目前 41 个跨渠道 `man:` 商品和 2 个无主渠道商品全部能分类。
### 阶段 3:迁移路由与商品配置
目标:
- 将 `internal_sku_channels` 迁移到 `sku_channel_routes`。
- 将人工配置、卡券配置、自动发货配置和接单模板配置关联到 `internal_sku_id`。
- 清理旧商品和错误兜底。
规则:
- 一个内部 SKU 一个主渠道。
- 其余渠道全部显式 `fallback`。
- 自动创建的标准商品必须有 `source_kind`。
- 历史 `man:` 清理只归档,不删除。
验收:
- 路由迁移前后主渠道结果一致。
- 人工页数量等于当前人工渠道商品数。
- 卡券和统一结算不再出现在人工页。
### 阶段 4:重建履约模型
目标:
- 以 A 级原始订单和真实载体为输入,重建 `fulfillments`、`fulfillment_attempts`、
`fulfillment_issues`。
- 停止写入过渡字段。
工作:
1. 清空旧影子履约和派生异常。
2. 回填当前履约单。
3. 回填当前尝试与历史尝试。
4. 生成结构化 issue。
5. 对比新旧读模型。
6. 切换后台与调度读路径。
验收:
- 同一订单项只有一个当前尝试。
- 所有 `assigned` 都有明确 `carrier_status` 或开放 issue。
- 退款、取消进入售后队列。
- 页面按钮作用域与后端动作一致。
### 阶段 5:重建财务派生数据
目标:
- 保留 A 级订单、退款事实和 B 级配置。
- 删除 C 级成本和财务派生数据并从新模型重建。
重建顺序:
```text
product normalization
→ cost snapshot
→ sales facts
→ journal
→ reconciliation
```
验收:
- 收入总额守恒。
- 退款总额守恒。
- 成本快照无重复。
- 事实与总账可对平。
- 所有成本缺失有结构化异常。
### 阶段 6:停止旧写路径并删除兼容层
只有前五个阶段持续稳定后才允许:
- 删除 `product_channel_capabilities`。
- 删除 `manual_sku_registry` 的派生行。
- 删除订单项重复渠道字段。
- 删除商品前缀业务判断。
- 删除旧管道、旧同步脚本和兼容 API。
禁止一次性同时删除旧字段和旧数据。
## 十、测试与生产门禁
以下检查必须进入代码测试或每日生产审计。
### 数据模型
- 一个内部 SKU 最多一个 primary route。
- 一个 fulfillment 最多一个 current attempt。
- 一个订单项最多一个 fulfillment。
- issue code 只能来自受控枚举。
- assigned 必须有 route 和 channel。
### 商品
- 每个 active product 必须有 `source_kind`。
- `source_kind` 与渠道专用商品类型一致。
- 人工页数量必须等于当前 primary manual 商品数。
- 总览、列表、导出必须使用同一投影。
- 旧 `man:` 商品不得在没有主渠道或收口状态时继续显示为人工。
### 履约
- 用户退款、平台取消不得出现在普通补载体队列。
- 同一订单项不得同时有活跃工单和活跃自动发货任务。
- 当前尝试必须是已存在且唯一的历史尝试。
- 所有重试动作必须按 issue_code 限定作用域。
### 财务
- 成本事实不可变,只能新增、替代或冲减。
- 事实、总账、退款和订单收入必须可对平。
- 财务重建必须从 A 级数据可重复执行。
### 文档
- 任何架构变更必须更新本文件。
- 旧文档不得再标记为“现行”。
- 文档中的迁移号和代码基线必须与生产一致。
## 十一、禁止事项
1. 不删除原始订单、平台报文、真实工作单、真实资金流水和审计日志。
2. 不根据商品名称、前缀或分类猜测履约渠道。
3. 不让多个服务同时写履约核心状态。
4. 不用自由 JSON 字符串代替结构化 issue。
5. 不以“最新”“出现最多”或“看起来像”解决映射冲突。
6. 不在没有投影和差异报告时切换读写路径。
7. 不把历史清理做成依赖运营点击的临时按钮。
8. 不为兼容旧口径保留第二份“现行文档”。
9. 不在一次发布中同时完成数据清理、模型切换和旧字段删除。
10. 不把“已经跑过测试”当成生产口径正确的证明。
## 十二、执行后的文档结构
```text
AGENTS.md
常驻规则、任务路由、数据安全边界
docs/README.md
文档总入口
docs/统一订单履约与财务模型-权威版.md
唯一业务与架构权威
docs/部署启动与数据库重建.md
docs/数据库备份与恢复.md
docs/数据库SQL约束与运维.md
运维参考
docs/91卡券/
docs/发货平台-2/
docs/抖音-码速达/
docs/行业电子凭证/
docs/affiliate-dash-发货平台/
外部协议与平台参考,不得覆盖本文件
docs/archive/
历史方案、旧口径和一次性分析
```
## 十三、当前下一步
本轮只完成文档和计划,不修改业务代码、不执行生产数据清理。
后续按以下顺序启动:
1. 完成阶段 1 的只读审计脚本与冲突清单。
2. 确定 `internal_skus`、`sku_channel_routes`、`product_catalog.source_kind`
的最终字段和迁移顺序。
3. 先建立投影,不直接切换写路径。
4. 清理派生数据前先做可恢复备份。
5. 每个阶段单独验收,不合并成一次性大迁移。