From 16345023631a7a4774400a497c5250bd78cd87be Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 8 Nov 2026 18:19:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=95=86=E5=93=81=E6=B8=A0=E9=81=93=E6=8A=95=E5=BD=B1=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=9D=83=E5=A8=81=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 95 +++ README.md | 35 +- .../154_internal_sku_registry_and_routes.sql | 426 ++++++++++ .../155_product_current_projection.sql | 96 +++ .../156_fix_product_source_kind_default.sql | 53 ++ .../157_preserve_fallback_only_routes.sql | 105 +++ .../158_manual_source_is_not_manual_route.sql | 57 ++ .../cost-center/cost-center-mappers.ts | 30 +- .../cost-center-overview-service.ts | 73 +- .../services/cost-center/cost-center-types.ts | 3 + .../services/cost-center/cost-center.test.ts | 37 +- .../cost-center/product-catalog-service.ts | 123 ++- .../cost-center/product-normalize-service.ts | 9 - .../cost-center/voucher-product-service.ts | 15 - .../fulfillment/channel-assign-service.ts | 9 - .../internal-sku-routing.integration.test.ts | 145 ++++ .../src/services/product-match/compile.ts | 2 +- .../src/services/product-match/index.ts | 2 +- .../admin/cost-center/AffiliateSkuPanel.tsx | 2 +- .../cost-center/ChannelMappingCatalog.tsx | 6 +- .../admin/cost-center/ChannelMappingPanel.tsx | 37 +- .../admin/cost-center/ChannelMappingQueue.tsx | 6 +- .../admin/cost-center/CostCenterContext.ts | 11 +- .../admin/cost-center/CostCenterShell.tsx | 23 +- .../admin/cost-center/CostConfigPanel.tsx | 37 +- .../admin/cost-center/ProductCatalogPanel.tsx | 64 +- .../admin/cost-center/VoucherSkuPanel.tsx | 2 +- .../src/pages/admin/cost-center/shared.ts | 19 +- apps/frontend/src/types/admin/cost-center.ts | 14 +- .../cost-center-channel-projection-ui.test.ts | 41 + .../tests/manual-sku-config-ui.test.ts | 2 +- .../tests/voucher-sku-delete-ui.test.ts | 2 +- docs/README.md | 219 +---- docs/archive/2026-11-08/README.md | 9 + .../2026-11-08}/dev合并main评估-2026-10-27.md | 0 .../2026-10-17-成本中心与销售事实-DDL草案.sql | 0 .../2026-10-17-载体判定与人工块量化.sql | 0 .../2026-10-31-112-履约渠道拆分-迁移草案.sql | 0 .../2026-10-31-同步链路重构-旧函数对照.md | 0 .../2026-11-08}/人工支出与备用金.md | 0 ...动关闭任务链接未失效问题排查与修复方案.md | 0 .../多发货平台与电子凭证整体链路.md | 0 .../履约与财务分流统一方案-2026-11-02.md | 0 ...履约渠道重试与大小写归一修复-2026-11-05.md | 0 .../履约系统架构复盘与优化方案-2026-11-05.md | 0 .../工单拼单多次参与改造方案-2026-10-23.md | 0 docs/{ => archive/2026-11-08}/影子总账说明.md | 0 .../2026-11-08}/成本中心API契约.md | 0 .../2026-11-08}/成本中心与履约模型.md | 0 ...单深挖-抢单拼单好友赠送结算-2026-10-23.md | 0 .../2026-11-08}/接单模板匹配引擎重构设计.md | 0 ...PU剧烈波动原因分析与优化方案-2026-10-30.md | 0 .../生产部署核对清单-2026-11-02.md | 0 .../2026-11-08}/订单全流程与内部SKU架构.md | 0 .../订单服务收集与状态页设计-2026-11-03.md | 0 .../财务上线初始化与验收-2026-10-26.md | 0 .../财务与履约口径总纲-2026-10-23.md | 0 .../财务代码审计复核-2026-10-27.md | 0 .../2026-11-08}/财务流程总览-2026-11-01.md | 0 .../财务系统深度分析-2026-10-27.md | 0 .../2026-11-08}/财务系统集成方案.md | 0 .../项目全流程与数据库设计梳理-2026-10-23.md | 0 .../项目全量分析报告-2026-10-17.md | 0 docs/archive/README.md | 18 +- docs/抖音-码速达/00-总览.md | 2 +- docs/抖音-码速达/04-结合本项目的深度分析.md | 2 +- docs/统一订单履约与财务模型-权威版.md | 760 ++++++++++++++++++ 67 files changed, 2147 insertions(+), 444 deletions(-) create mode 100644 AGENTS.md create mode 100644 apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql create mode 100644 apps/backend/src/db/migrations/155_product_current_projection.sql create mode 100644 apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql create mode 100644 apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql create mode 100644 apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql create mode 100644 apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts create mode 100644 apps/frontend/tests/cost-center-channel-projection-ui.test.ts create mode 100644 docs/archive/2026-11-08/README.md rename docs/{ => archive/2026-11-08}/dev合并main评估-2026-10-27.md (100%) rename docs/{ => archive/2026-11-08}/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql (100%) rename docs/{ => archive/2026-11-08}/drafts/2026-10-17-载体判定与人工块量化.sql (100%) rename docs/{ => archive/2026-11-08}/drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql (100%) rename docs/{ => archive/2026-11-08}/drafts/2026-10-31-同步链路重构-旧函数对照.md (100%) rename docs/{ => archive/2026-11-08}/人工支出与备用金.md (100%) rename docs/{履约配置 => archive/2026-11-08}/后台手动关闭任务链接未失效问题排查与修复方案.md (100%) rename docs/{履约配置 => archive/2026-11-08}/多发货平台与电子凭证整体链路.md (100%) rename docs/{ => archive/2026-11-08}/履约与财务分流统一方案-2026-11-02.md (100%) rename docs/{ => archive/2026-11-08}/履约渠道重试与大小写归一修复-2026-11-05.md (100%) rename docs/{ => archive/2026-11-08}/履约系统架构复盘与优化方案-2026-11-05.md (100%) rename docs/{ => archive/2026-11-08}/工单拼单多次参与改造方案-2026-10-23.md (100%) rename docs/{ => archive/2026-11-08}/影子总账说明.md (100%) rename docs/{ => archive/2026-11-08}/成本中心API契约.md (100%) rename docs/{ => archive/2026-11-08}/成本中心与履约模型.md (100%) rename docs/{ => archive/2026-11-08}/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md (100%) rename docs/{ => archive/2026-11-08}/接单模板匹配引擎重构设计.md (100%) rename docs/{ => archive/2026-11-08}/生产CPU剧烈波动原因分析与优化方案-2026-10-30.md (100%) rename docs/{ => archive/2026-11-08}/生产部署核对清单-2026-11-02.md (100%) rename docs/{ => archive/2026-11-08}/订单全流程与内部SKU架构.md (100%) rename docs/{ => archive/2026-11-08}/订单服务收集与状态页设计-2026-11-03.md (100%) rename docs/{ => archive/2026-11-08}/财务上线初始化与验收-2026-10-26.md (100%) rename docs/{ => archive/2026-11-08}/财务与履约口径总纲-2026-10-23.md (100%) rename docs/{ => archive/2026-11-08}/财务代码审计复核-2026-10-27.md (100%) rename docs/{ => archive/2026-11-08}/财务流程总览-2026-11-01.md (100%) rename docs/{ => archive/2026-11-08}/财务系统深度分析-2026-10-27.md (100%) rename docs/{ => archive/2026-11-08}/财务系统集成方案.md (100%) rename docs/{ => archive/2026-11-08}/项目全流程与数据库设计梳理-2026-10-23.md (100%) rename docs/{ => archive/2026-11-08}/项目全量分析报告-2026-10-17.md (100%) create mode 100644 docs/统一订单履约与财务模型-权威版.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3adf284d --- /dev/null +++ b/AGENTS.md @@ -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 提交信息使用中文。 +- 不回退或覆盖用户的未提交修改。 +- 提交时只暂存本次任务相关文件。 diff --git a/README.md b/README.md index 29184edc..fa38aca7 100644 --- a/README.md +++ b/README.md @@ -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)。 ## 本地开发 diff --git a/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql b/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql new file mode 100644 index 00000000..77b2aa10 --- /dev/null +++ b/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql @@ -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(); diff --git a/apps/backend/src/db/migrations/155_product_current_projection.sql b/apps/backend/src/db/migrations/155_product_current_projection.sql new file mode 100644 index 00000000..f60d22ed --- /dev/null +++ b/apps/backend/src/db/migrations/155_product_current_projection.sql @@ -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'; diff --git a/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql b/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql new file mode 100644 index 00000000..57ab0e9d --- /dev/null +++ b/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql @@ -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:%' + ); diff --git a/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql b/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql new file mode 100644 index 00000000..69721021 --- /dev/null +++ b/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql @@ -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; +$$; diff --git a/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql b/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql new file mode 100644 index 00000000..49880ed8 --- /dev/null +++ b/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql @@ -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'; diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 8b6b8b43..d376a5ff 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -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, diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index 099a9e5d..f6266aa0 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -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 = { 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), diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index db0ab4b6..1ecc21b4 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -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 } diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index c7039c29..c5c49771 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -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 () => { diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index e89a2453..4514d546 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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(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( @@ -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( ` 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)) diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 050ab17b..ecea347a 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -210,15 +210,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise { + 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() + } + }, +) diff --git a/apps/backend/src/services/product-match/compile.ts b/apps/backend/src/services/product-match/compile.ts index 7f797b5c..f0a6d271 100644 --- a/apps/backend/src/services/product-match/compile.ts +++ b/apps/backend/src/services/product-match/compile.ts @@ -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`, diff --git a/apps/backend/src/services/product-match/index.ts b/apps/backend/src/services/product-match/index.ts index e96e841d..a3f3f46c 100644 --- a/apps/backend/src/services/product-match/index.ts +++ b/apps/backend/src/services/product-match/index.ts @@ -5,7 +5,7 @@ * - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维); * - 价格/数量/拼单:`pricing.ts` 唯一实现。 * - * 设计见 docs/接单模板匹配引擎重构设计.md。 + * 设计见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md。 */ export type { ProductMatchCandidate, diff --git a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx index 127e7b8f..1be48917 100644 --- a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx @@ -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, }), diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx index a139892c..f743d58a 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx @@ -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({ )} diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx index 82b49f2e..74e9a93d 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx @@ -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() 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={ 渠道映射 - {sourceLabel} + {channelLabel} } 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) diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx index f49b7314..58b321a1 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx @@ -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({ ) : ( diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts index f33ab919..e9586acb 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts @@ -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 options?: { providers: string[] @@ -34,6 +32,7 @@ export type CostCenterContextValue = { canonicalName: string unitName: string source: ProductSource + activePrimaryChannel: string | null }> costItems: Array<{ id: number diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 05e23324..e73bb269 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -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 ( 统计口径 {scopeLabel} {scope !== 'all' ? ( - (跟随上方来源筛选变化) + (跟随上方渠道筛选变化) ) : null} + } + /> + ) + } return ( - 成本配置按来源分开维护,选择一个来源查看;也可以在页面顶部直接切换来源。 + 成本配置按履约渠道分开维护;也可以在页面顶部直接切换渠道。 {CONFIG_ENTRIES.map((entry) => ( diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index ee9af673..e2260a81 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -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() // 历史修复只处理最近 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' ? '按件计价' : '固定金额'} ) : null} - {row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? ( - - 当前主渠道: - {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} - - ) : null} ), @@ -540,14 +534,40 @@ export function ProductCatalogPanel() { ), }, { - title: '已绑定渠道', + title: '当前履约', + width: 160, + render: (_, row) => ( + + {row.activePrimaryChannel ? ( + + {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} + + ) : ( + 未路由 + )} + {row.fallbackChannels?.length ? ( + + 兜底: + {row.fallbackChannels + .map((channel) => FULFILLMENT_MODE_LABELS[channel] || channel) + .join(' / ')} + + ) : null} + + ), + }, + { + title: '平台映射', dataIndex: 'mappingCount', width: 130, render: (value: number, row) => { - if (row.source === 'affiliate_dash') { + if (row.sourceKind === 'affiliate_dash') { return 直连无需映射 } - if (row.source === 'manual') { + if (row.sourceKind === 'manual') { return 内部SKU无需映射 } 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 ( 以人工 SKU 配置为准 @@ -596,7 +616,7 @@ export function ProductCatalogPanel() { > {value > 0 ? `${value} 个版本 · 改价` - : row.source === 'work_order' + : row.sourceKind === 'work_order' ? '配置结算成本' : '未配置 · 立即配置'} @@ -634,7 +654,7 @@ export function ProductCatalogPanel() { render: (_, row) => canConfig ? ( - {row.source === 'manual' ? ( + {row.sourceKind === 'manual' ? ( - ) : productSourceFilter === 'third_party_voucher' ? ( + ) : channelFilter === 'third_party_voucher' ? (