docs(finance): 履约与财务分流统一方案与只读审计脚本
This commit is contained in:
@@ -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?
|
||||
Reference in New Issue
Block a user