docs(finance): 履约与财务分流统一方案与只读审计脚本

This commit is contained in:
yml2213
2026-11-02 13:48:18 +08:00
parent 8da669b1fd
commit 652d501c2c
2 changed files with 379 additions and 0 deletions
@@ -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())
@@ -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?