重构:统一商品渠道投影并收敛权威文档
This commit is contained in:
@@ -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 提交信息使用中文。
|
||||
- 不回退或覆盖用户的未提交修改。
|
||||
- 提交时只暂存本次任务相关文件。
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. 文档中的代码与迁移基线必须来自实际仓库和生产只读核对。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 2026-11-08 历史文档归档
|
||||
|
||||
本目录保存 2026-11-08 统一重构前使用的订单、履约、成本、财务和架构文档。
|
||||
|
||||
这些文档只用于追溯当时的设计与数据来源,不再作为现行口径、开发依据或验收标准。
|
||||
|
||||
现行唯一权威文档:
|
||||
|
||||
[统一订单履约与财务模型-权威版](../../统一订单履约与财务模型-权威版.md)
|
||||
+14
-4
@@ -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/`、`抖音-码速达/消息推送/`)。
|
||||
- 历史文档中的代码行号、测试数量、迁移数量不要直接引用。
|
||||
- 历史文档不得重新标记为“现行”,需要复活时必须并入唯一权威文档。
|
||||
|
||||
@@ -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. 每个阶段单独验收,不合并成一次性大迁移。
|
||||
|
||||
Reference in New Issue
Block a user