diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts new file mode 100644 index 00000000..4d536538 --- /dev/null +++ b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts @@ -0,0 +1,151 @@ +/** + * 履约载体 ↔ 财务渠道 对齐审计(只读)。 + * + * 用法:npx tsx scripts/audit-fulfillment-finance-alignment.ts + */ +import { closeDb, query } from '../src/db/client.js' + +async function main() { + const pending = await query<{ + total: number + with_work_order: number + with_affiliate_task: number + with_manual_task: number + no_carrier: number + }>( + ` + SELECT + count(*)::int AS total, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS with_work_order, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS with_affiliate_task, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS with_manual_task, + count(*) FILTER ( + WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) + )::int AS no_carrier + FROM order_items oi + WHERE oi.fulfillment_status = 'pending' + `, + ) + + const channelCarrier = await query<{ + channel: string + total: number + work_orders: number + affiliate_tasks: number + manual_tasks: number + no_carrier: number + }>( + ` + SELECT + COALESCE(NULLIF(oi.fulfillment_channel, ''), '(pending)') AS channel, + count(*)::int AS total, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks, + count(*) FILTER ( + WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) + )::int AS no_carrier + FROM order_items oi + WHERE oi.fulfillment_status = 'assigned' + GROUP BY 1 + ORDER BY 2 DESC + `, + ) + + const internalSku = await query<{ + total: number + null_or_blank: number + numeric_only: number + with_cjk: number + }>( + ` + SELECT + count(*)::int AS total, + count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) = '')::int AS null_or_blank, + count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) ~ '^[0-9]+$')::int AS numeric_only, + count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) ~ '[一-龥]')::int AS with_cjk + FROM order_items + WHERE fulfillment_status = 'assigned' + `, + ) + + const rules = await query<{ + match_key_type: string + channel: string + scope: string + total: number + }>( + ` + SELECT match_key_type, channel, + CASE WHEN provider = '' AND shop_id = '' THEN 'global' ELSE 'scoped' END AS scope, + count(*)::int AS total + FROM sku_channel_rules + GROUP BY 1, 2, 3 + ORDER BY 1, 2, 3 + `, + ) + + const duplicateProducts = await query<{ duplicate_names: number; duplicate_rows: number }>( + ` + SELECT count(*)::int AS duplicate_names, COALESCE(sum(total), 0)::int AS duplicate_rows + FROM ( + SELECT canonical_name, count(*) AS total + FROM product_catalog + WHERE status = 'active' + GROUP BY canonical_name + HAVING count(*) > 1 + ) grouped + `, + ) + + const capabilities = await query<{ channel: string; total: number }>( + `SELECT channel, count(*)::int AS total FROM product_channel_capabilities WHERE enabled GROUP BY 1 ORDER BY 2 DESC`, + ) + + const byProvider = await query<{ + provider: string + total: number + pending: number + with_carrier: number + }>( + ` + SELECT o.provider, + count(*)::int AS total, + count(*) FILTER (WHERE oi.fulfillment_status = 'pending')::int AS pending, + count(*) FILTER ( + WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + OR EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) + )::int AS with_carrier + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08' + GROUP BY 1 + ORDER BY 2 DESC + `, + ) + + console.log( + JSON.stringify( + { + pending: pending.rows[0], + channelCarrier: channelCarrier.rows, + internalSku: internalSku.rows[0], + rules: rules.rows, + duplicateProducts: duplicateProducts.rows[0], + capabilities: capabilities.rows, + byProvider: byProvider.rows, + }, + null, + 2, + ), + ) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(() => closeDb()) diff --git a/docs/履约与财务分流统一方案-2026-11-02.md b/docs/履约与财务分流统一方案-2026-11-02.md new file mode 100644 index 00000000..ba8fd651 --- /dev/null +++ b/docs/履约与财务分流统一方案-2026-11-02.md @@ -0,0 +1,228 @@ +# 履约与财务分流统一方案 + +日期:2026-11-02 +状态:设计稿,待实施 +目标:以内部中文 SKU 为唯一主键,统一「下单路由 → 履约载体 → 财务渠道 → 成本」链路。 + +## 1. 现状结论 + +系统目前不是一条链路,而是三条互相独立的链路: + +``` +① 下单入口路由 + 平台字段 → affiliate 映射 / fulfillmentRouting 配置 → item_snapshot_json.fulfillmentRoute + +② 履约载体创建 + syncDeliveryTasksForOrder → fulfillment_tasks(affiliate_dash / manual_dispatch) + syncWorkerOrdersForSourceOrder → work_orders(接单模板匹配) + +③ 财务渠道 + sku_channel_rules + 工单/任务证据 → order_items.fulfillment_channel → 成本快照 +``` + +财务渠道不参与下单路由,也不创建任务/工单,只根据已有载体反推;三套规则各自维护,是当前流程混乱的根因。 + +## 2. 生产数据事实(2026-11-02) + +### 内部 SKU 质量 + +`order_items.manual_sku_key` 是迁移 106 的生成列: + +``` +skuNick → outSkuSn → sku_code → sku_name +``` + +它已经是事实上的内部 SKU;生产已分配订单项: + +| 平台 | 已分配订单项 | 空值 | 纯数字 | 含中文 | +|---|---:|---:|---:|---:| +| 91kaquan | 11,817 | 0 | 0 | 11,817 | +| douyin_mashida | 694 | 0 | 0 | 694 | +| pdd | 548 | 0 | 27 | 521 | + +结论:91/抖音 100% 中文内部 SKU;PDD 仅 27 条为纯数字(`outSkuSn=5003` 等),需要平台字段 → 中文映射补齐。 + +### 渠道规则 + +| 匹配键 | 渠道 | 范围 | 数量 | +|---|---|---|---:| +| internal_sku | manual | 全局 | 43 | +| internal_sku | affiliate_dash | 全局 | 16(本地合并脚本产生;生产尚未执行) | +| internal_sku | third_party_voucher | 全局 | 2(本地迁移 116 产生;生产尚未执行) | +| sku_code | affiliate_dash | 全局 | 30(生产) | +| sku_code | third_party_voucher | 平台/店铺 | 2(生产) | + +### 履约载体 + +- `fulfillment_tasks`:affiliate_dash 65,755;历史快手辅助 46,894 + 7,007(已下线);manual_dispatch 6。 +- `work_orders`:202,008,其中 accepted 175,626、pending_material 22,806。 +- 当前 pending 18 条:全部无工单、无任务;11月改名卡 5 条即属于此类。 + +### 商品重复 + +- 生产 active 商品 1,370:`tpl` 764、`man` 575、`aff` 30、`tpv` 1。 +- 同名商品 110 组、226 行。 +- 同名不代表必须合并:接单模板/人工/自动发货可能各自一行,属于渠道载体配置,不应按名称硬删。 + +## 3. 三条链路的细节 + +### 3.1 自动发货 + +1. `resolveOrderItemForFulfillment` + - 91:affiliate-dash SKU 映射 + `fulfillmentRouting`(商品名/内部 SKU)。 + - 抖音:`sku_order_list[].code` 即内部 SKU,查常量/后台映射表。 + - PDD:没有 affiliate 入口。 +2. 命中后写 `item_snapshot_json.fulfillmentRoute.selectedExecutorKey=affiliate_dash` 和 `snapshot.affiliateDash.sku`。 +3. `syncDeliveryTasksForOrder` 只为 `isConfigured` 的订单项建 `fulfillment_tasks`。 +4. 工单层跳过 `classification.channel=affiliate_dash` 的订单项,保证载体唯一。 + +缺口:财务 `channel_assign` 不读 `fulfillmentRoute`,只认 `executor_key=affiliate_dash` 的任务;入口路由和财务规则可能不一致。 + +### 3.2 接单平台 + +1. `syncWorkerOrdersForSourceOrder` 对非 affiliate 订单项匹配 `work_product_rules`。 +2. 匹配字段按平台适配器: + - 快手:sellerId、relItemId、relSkuId、skuNick、商品名; + - 抖音:内部 SKU(禁止引流标题兜底); + - PDD:goodsId、outSkuSn/spec、goodsName。 +3. 命中且 `auto_create` → 建工单,状态 `unassigned` / `pending_material`。 +4. 财务看到任意工单即归 `work_order`;成本只有 accepted/reward>0 才 actual。 + +缺口: +- 接单平台没有 `internal_sku` 规则,完全靠工单证据; +- 财务改渠道不会建工单; +- 工单匹配仍依赖平台字段,不是纯内部 SKU。 + +### 3.3 人工 + +- `manual_dispatch` 任务只有 6 条; +- 财务 `channel_assign` 不识别 `manual_dispatch`,只认规则/工单/affiliate 任务; +- 人工成本配置按 `manual_sku_key` 匹配,已经是内部 SKU 口径。 + +### 3.4 三方卡券 + +- 只统计,不建任务/工单; +- 商品自动建 `tpv:`,迁移 116 补 `internal_sku` 全局规则; +- 财务可正常识别;无载体是预期,不属于“缺载体”。 + +## 4. 根因 + +1. **没有单一真源**:下单入口、载体创建、财务各一套规则。 +2. **分流与派单分离**:财务改渠道不创建载体;下单建了载体财务又不一定识别。 +3. **规则键不统一**:affiliate 用英文 `sku_code`,卡券用平台码,人工用 `internal_sku`。 +4. **pending 语义混杂**:既表示“没规则”,也表示“没载体”,还表示“还没跑管道”。 +5. **商品/渠道/载体配置分散**:商品、渠道能力、工单模板、affiliate 映射、人工配置各在一处。 + +## 5. 目标模型 + +### 5.1 唯一键:内部中文 SKU + +沿用 `order_items.manual_sku_key` 作为内部 SKU(生成列),并新增平台字段 → 内部 SKU 的补全: + +```sql +internal_sku_mappings ( + provider, shop_id, match_type, match_value, + internal_sku, priority, enabled, source +) +``` + +`match_type`:`sku_code | sku_nick | out_sku_sn | spec | sku_name`。 +入口先按生成列得到内部 SKU;若是纯数字/空值,再查映射表,把 `spec_json.skuNick` 补成中文内部 SKU,使生成列变为中文。 + +### 5.2 单一渠道配置 + +新增: + +```sql +internal_sku_channels ( + internal_sku, + channel, -- affiliate_dash / work_order / manual / third_party_voucher + priority, + is_fallback, + enabled, + canonical_product_id, + config_json, -- affiliateSku / workRuleKey / manualConfigId / voucher... + source +) +``` + +一条内部 SKU 可以有多个渠道能力: +- 自动发货 priority 10; +- 人工 priority 20(兜底); +- 接单平台 priority 10; +- 卡券 priority 10。 + +下单入口、财务、成本中心都读这一份配置。 + +### 5.3 载体保证 + +选定渠道后必须保证载体: + +| 渠道 | 载体 | +|---|---| +| affiliate_dash | `fulfillment_tasks`(缺则创建或进问题队列) | +| work_order | `work_orders`(缺则按模板创建或进问题队列) | +| manual | 人工配置(缺则进配置异常) | +| third_party_voucher | 无载体,只统计 | + +### 5.4 财务只做镜像 + +``` +internal_sku_channels 决策 + + 实际载体 + → order_items.fulfillment_channel + → 成本快照 → 销售事实 +``` + +pending 只保留: +1. 无内部 SKU; +2. 无渠道能力; +3. 有渠道能力但载体创建失败/缺失。 + +## 6. 实施顺序 + +### 阶段 1:内部 SKU 映射(低风险) + +1. 建 `internal_sku_mappings`; +2. 回填 PDD 27 条纯数字内部 SKU; +3. 入口解析时写 `snapshot.internalSku`,纯数字则查映射补 `spec_json.skuNick`; +4. 审计脚本核对空值/数字/中文占比。 + +### 阶段 2:单一渠道配置 + +1. 建 `internal_sku_channels`; +2. 从现有 product capabilities / sku_channel_rules / affiliate 映射 / 工单模板 / 人工配置生成初始配置; +3. 新增统一 resolver `resolveChannelsForInternalSku`; +4. 财务 `channel_assign` 改为读 resolver,不再单独查 `sku_channel_rules`。 + +### 阶段 3:载体保证 + +1. 自动发货:渠道决策后确保 affiliate task; +2. 接单:渠道决策后确保 work order; +3. 人工:确保人工配置; +4. 卡券:跳过; +5. 失败进问题队列,不静默 pending。 + +### 阶段 4:清理与重判 + +1. 清理旧 `sku_code` affiliate/voucher 规则; +2. 按内部 SKU 生成全局规则; +3. 重判断代后 assigned/pending; +4. 本地三平台模拟 → 生产迁移/部署。 + +## 7. 验收 + +- 三平台订单:下单 → 内部 SKU → 渠道 → 载体 → 成本 → 销售事实全链路可追踪; +- 同名不同平台码不再拆 pending; +- 财务渠道与真实载体一致; +- pending 仅三类语义; +- 审计脚本 0 空内部 SKU、0 纯数字(映射后)、0 载体/渠道矛盾; +- 后端/前端测试、构建、SQL 护栏通过; +- 生产迁移/部署验证。 + +## 8. 待确认 + +1. PDD 纯数字内部 SKU 的中文映射由谁维护(商品名/规格/运营表)? +2. 双渠道商品(自动发货 + 人工)的兜底触发条件:任务失败、超时、还是人工手动切换? +3. 接单平台是否所有内部 SKU 都必须自动建工单,还是保留“运营指定”模式? +4. 同名 man/tpl 商品是否合并,还是保留多载体配置、只统一 internal_sku_channels?