fix(worker): 删除依赖残留字段 task_id 的死 JOIN,店铺名兜底改读 material_json

核查结论:work_orders.task_id 是残留字段,不是"数据没同步上来"。
  1. 唯一写入点 createWorkOrder 写的是 input.taskId || null,而两个调用方
     一个硬编码 taskId: null、一个根本不传;updateWorkOrder 的白名单里也没有它;
     git 全历史检索 `taskId:` 只出现过 null 一种取值。
  2. 即便强写也无从关联:工单关联 100,231 个订单、履约任务关联 87,603 个订单,
     交集为 0;按 order_item_id 比对 100,229 条同样 0 命中。而两者时间范围
     (2026-07~10)与 orders.id 区间(1~280k)都重叠 —— 说明是按规则表分流的
     两条并行履约通道,一单只走一条。
  3. 依赖它的 idx_work_orders_task_unique 为空(8KB)。

改动:
  - 三处 `LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id`
    (取消申请 / 售后案 / 工单反馈)恒为空,删除;
  - 店铺名兜底 `ft.shop_name/shop_id` 改为读工单自己的
    material_json->'source'->>'shopName'/'shopId';
  - 取消申请里的 `ft.platform_order_id` 一并删除(实测 needs_ft_pid_fallback = 0,纯 no-op);
  - 迁移 091 给列与索引打上注释,避免后续再被当成数据问题重复排查。

生产逐行回归(旧逻辑 vs 新逻辑取差集):
  取消申请 715/715 行、103 条店铺名从空变有值、回归 0;
  售后案 21/21 行、1 条、回归 0;工单反馈 348/348 行、15 条、回归 0;
  合计 119 条修复、0 条回归、行数与 platform_order_id 零变化。

验证:新增源码级守卫(禁止再 JOIN fulfillment_tasks、兜底必须读 material_json)。
This commit is contained in:
yml2213
2026-10-03 14:27:46 +08:00
parent 0173efb277
commit c8a4b7e945
6 changed files with 96 additions and 10 deletions
@@ -0,0 +1,30 @@
-- 091_annotate_vestigial_work_order_task_id.sql —— 标注残留字段 work_orders.task_id。
--
-- 该列自迁移 004 引入,设计意图是把接单工单关联到履约任务(fulfillment_tasks)。
-- 但生产核查(2026-10-03,库 order_site @47.114.114.225)确认它从未被写入过,
-- 且结构上也无从关联:
--
-- 1) 唯一写入点是 createWorkOrder 的 `input.taskId || null`,而两个调用方
-- 一个是硬编码 `taskId: null`(sync-worker-orders-service),另一个根本不传;
-- updateWorkOrder 的 patch 白名单里也没有这一列;
-- git 全历史检索 `taskId:` 只出现过 null 一种取值。
--
-- 2) 即便强行写值也无从关联:工单关联 100,231 个订单、履约任务关联 87,603 个订单,
-- 交集为 0;按 order_item_id 比对 100,229 条同样 0 命中。而两者的时间范围
-- (2026-07 ~ 10)与 orders.id 区间(1 ~ 280k)却都是重叠的 —— 说明两套系统
-- 是按规则表分流的两条并行履约通道(命中 work_product_rules 建工单、
-- 命中 fulfillment_profiles 建履约任务),一单只走一条,本就不存在对应关系。
--
-- 3) 依赖该列的三处 `LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id`
-- (取消申请 / 售后案 / 工单反馈)永远匹配不上;其中用作店铺名兜底的那部分
-- 已改为读 wo.material_json->'source'->>'shopName'(生产实测覆盖 2478 条
-- 中 2476 条),死 JOIN 已删除。
--
-- 结论:保留列与索引(drop 不可逆,且类型定义与类型行多处引用),仅作标注,
-- 避免后续再被当成"数据没同步上来"重复排查。
COMMENT ON COLUMN work_orders.task_id IS
'【残留字段,勿用】设计上用于关联履约任务,但无任何代码写入,且工单与履约任务的订单集合零交集(order_id / order_item_id 比对均为 0)。需要店铺名等信息请读 material_json->''source'',不要 JOIN fulfillment_tasks。';
COMMENT ON INDEX idx_work_orders_task_unique IS
'【残留索引】服务于永不写入的 work_orders.task_id,当前 0 行;保留以免重建时结构漂移。';
@@ -23,8 +23,11 @@ const AFTER_SALES_CASE_SELECT = `
COALESCE(
NULLIF(BTRIM(o.shop_name), ''),
NULLIF(BTRIM(o.shop_id), ''),
NULLIF(BTRIM(ft.shop_name), ''),
NULLIF(BTRIM(ft.shop_id), ''),
-- 原先兜底用 fulfillment_tasks,但 wo.task_id 是残留字段(无代码写入、
-- 两套系统订单集合零交集),那个 LEFT JOIN 永远匹配不上,兜底形同虚设。
-- 改用工单自己的 material_json 快照(生产实测覆盖 2,478 条中的 2,476 条)。
NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''),
NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''),
''
) AS shop_name,
wu.username AS worker_username,
@@ -33,7 +36,6 @@ const AFTER_SALES_CASE_SELECT = `
FROM worker_after_sales_cases ac
INNER JOIN work_orders wo ON wo.id = ac.work_order_id
LEFT JOIN orders o ON o.id = wo.order_id
LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id
INNER JOIN worker_users wu ON wu.id = ac.worker_id
LEFT JOIN work_order_shares wos ON wos.id = ac.work_order_share_id
`
@@ -36,6 +36,8 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
input.productRuleId || null,
input.orderId || null,
input.orderItemId || null,
// task_id 是残留字段(见迁移 091):没有调用方会传非空值,工单与履约任务的
// 订单集合也零交集,所以这里恒为 NULL。保留列是为了不改结构,勿据此做关联查询。
input.taskId || null,
input.platformOrderId,
input.productName,
@@ -19,12 +19,17 @@ import type { WorkerCancelRequestRow, WorkOrderRow, WorkOrderShareRow } from './
const WORKER_CANCEL_REQUEST_SELECT = `
SELECT
cr.*,
COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), o.platform_order_id, ft.platform_order_id, '') AS platform_order_id,
COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), o.platform_order_id, '') AS platform_order_id,
-- 店铺名兜底:orders 为空时退回工单自己的 material_json 快照。
-- 这里原先兜底用的是 fulfillment_tasks,但 wo.task_id 是残留字段(无任何代码写入,
-- 且工单与履约任务的订单集合零交集),那个 LEFT JOIN 永远匹配不上,
-- 生产实测 2,478 条 orders 店铺名为空的工单因此显示成空。
-- material_json->'source'->>'shopName' 实测覆盖其中 2,476 条。
COALESCE(
NULLIF(BTRIM(o.shop_name), ''),
NULLIF(BTRIM(o.shop_id), ''),
NULLIF(BTRIM(ft.shop_name), ''),
NULLIF(BTRIM(ft.shop_id), ''),
NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''),
NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''),
''
) AS shop_name,
wo.product_name,
@@ -34,7 +39,6 @@ const WORKER_CANCEL_REQUEST_SELECT = `
FROM worker_order_cancel_requests cr
INNER JOIN work_orders wo ON wo.id = cr.work_order_id
LEFT JOIN orders o ON o.id = wo.order_id
LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id
INNER JOIN worker_users wu ON wu.id = cr.worker_id
LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id
`
@@ -13,8 +13,11 @@ const WORKER_ORDER_FEEDBACK_SELECT = `
COALESCE(
NULLIF(BTRIM(o.shop_name), ''),
NULLIF(BTRIM(o.shop_id), ''),
NULLIF(BTRIM(ft.shop_name), ''),
NULLIF(BTRIM(ft.shop_id), ''),
-- 原先兜底用 fulfillment_tasks,但 wo.task_id 是残留字段(无代码写入、
-- 两套系统订单集合零交集),那个 LEFT JOIN 永远匹配不上,兜底形同虚设。
-- 改用工单自己的 material_json 快照(生产实测覆盖 2,478 条中的 2,476 条)。
NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''),
NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''),
''
) AS shop_name,
wu.username AS worker_username,
@@ -23,7 +26,6 @@ const WORKER_ORDER_FEEDBACK_SELECT = `
FROM worker_order_feedbacks wf
INNER JOIN work_orders wo ON wo.id = wf.work_order_id
LEFT JOIN orders o ON o.id = wo.order_id
LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id
INNER JOIN worker_users wu ON wu.id = wf.worker_id
LEFT JOIN work_order_shares wos ON wos.id = wf.work_order_share_id
`
@@ -2620,6 +2620,52 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量
}
})
test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => {
const { readFile } = await import('node:fs/promises')
// 背景:task_id 无任何代码写入(git 全历史只出现过 null),且工单与履约任务的订单
// 集合零交集(order_id / order_item_id 比对均为 0,2026-10-03 生产核查)。
// 三处 LEFT JOIN fulfillment_tasks 永远匹配不上,店铺名兜底已改为读 material_json。
for (const name of [
'worker-cancel-request-repo.ts',
'after-sales-repo.ts',
'worker-order-feedback-repo.ts',
]) {
const source = await readFile(
new URL(`../../repositories/worker-platform/${name}`, import.meta.url),
'utf8',
)
// 只断言不存在 JOIN(注释里可以解释这段历史),并确认兜底已切到 material_json
assert.ok(
!/\bJOIN\s+fulfillment_tasks\b/i.test(source),
`${name} 不应再 JOIN fulfillment_tasks:wo.task_id 是永不写入的残留字段,该连接恒为空`,
)
assert.ok(
source.includes("material_json->'source'->>'shopName'"),
`${name} 的店铺名兜底必须读工单自己的 material_json 快照`,
)
}
// 写入点必须保留说明,避免下一个人又把 task_id 当成"数据没同步上来"
const managementRepo = await readFile(
new URL('../../repositories/worker-platform/work-order-management-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
managementRepo.includes('残留字段'),
'createWorkOrder 的 task_id 绑定处必须保留残留字段说明',
)
const migration = await readFile(
new URL('../../db/migrations/091_annotate_vestigial_work_order_task_id.sql', import.meta.url),
'utf8',
)
assert.ok(
migration.includes('COMMENT ON COLUMN work_orders.task_id'),
'必须给 work_orders.task_id 留下数据库注释',
)
})
test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数据库累计耗时第一的热路径)', async () => {
const { readFile } = await import('node:fs/promises')
// 覆盖索引:不改 SQL,靠 index-only scan 让规划器放弃全表顺序扫