diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index 684f865..00e8ae3 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -307,8 +307,30 @@ const CREATED_BACKFILL_BATCH = 200; // 单子早随主单发走了,状态表还停在「发货中」。 // ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位, // 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。 +// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**: +// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question) +// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。 +// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。 const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']); +// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认: +// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废, +// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」 +// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。 +const VOID_QUESTION_STATUS = 'Question'; + +// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。 +// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」; +// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。 +// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明), +// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。 +const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']); + +// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。 +// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底: +// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。 +const AFTERSALE_VOID_STATUS = '已作废'; + // 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。 // 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」, // 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。 @@ -554,6 +576,9 @@ module.exports = { CREATED_SOURCE_TABLES, CREATED_BACKFILL_BATCH, VOID_ORDER_STATUS, + VOID_QUESTION_STATUS, + VOID_ORDER_LABEL_KEYWORDS, + AFTERSALE_VOID_STATUS, SHIP_TIMELINESS_HOURS, toNumber, }; diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 4ef9d6d..789d6a5 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -129,9 +129,17 @@ const { CREATED_SOURCE_TABLES, CREATED_BACKFILL_BATCH, VOID_ORDER_STATUS, + VOID_QUESTION_STATUS, + VOID_ORDER_LABEL_KEYWORDS, + AFTERSALE_VOID_STATUS, resolveCenter, } = registry; +// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node) +const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS + .map((k) => `labels LIKE N'%${k}%'`) + .join(' OR '); + // 明细上限:只用于页面展示,超出部分用 truncated 标记提示 const DEFAULT_DETAIL_LIMIT = 500; const MAX_DETAIL_LIMIT = 2000; @@ -224,7 +232,7 @@ async function loadPendingStates(round) { // mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原 // 就是库里存的北京时间,不会偏移 8 小时。 `SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name, - e.order_status_name, e.logistic_status_name, + e.order_status_name, e.aftersale_status_name, e.logistic_status_name, e.logistic_id, e.logistic_company_name, e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time, e.send_time, e.query_time @@ -293,7 +301,8 @@ async function backfillOrderFacts(oids) { const results = await mapLimit(batches, 4, async (batch) => { try { return await cloudDatabase.querySlow( - `SELECT o_id, created, status, send_date, is_merge, is_split + `SELECT o_id, created, status, send_date, is_merge, is_split, + CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`, {}, { connectRequestTimeoutMs: 300000 } @@ -314,10 +323,15 @@ async function backfillOrderFacts(oids) { createdAt: formatDateTime(r.created), status, sendDate, - // 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货 + // 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除 void: VOID_ORDER_STATUS.includes(status) || Number(r.is_merge) === 1 || Number(r.is_split) === 1, + // 作废(软):问题单 Question / 标签含「发货前售后」。 + // ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单 + // 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。 + question: status === VOID_QUESTION_STATUS, + presaleVoid: Number(r.presaleVoid) === 1, // 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。 // 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。 shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status), @@ -569,6 +583,19 @@ async function getShipTimeliness(options = {}) { if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示 + // 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。 + // ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」, + // 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。 + // 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」): + // · 状态表 aftersale_status_name =「已作废」(售后维度最权威) + // · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810 + // · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带 + if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS + || (fact && (fact.question || fact.presaleVoid))) { + voidCount += 1; + continue; + } + // ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛) if (!benchmarkAt) { bucket.heldCount += 1; diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 9f56b96..2782e78 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -207,6 +207,13 @@ function stubDb(dbPath, scenario = 'default') { 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), // 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), + // ── 2026-10-22 作废单三类形态 ── + // 38:订单表 Question(聚水潭「已作废/问题单」) + 38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }), + // 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转) + 39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }), + // 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常 + 40: f('jst_Orders', { created: '2026-10-20 11:10:00' }), }; // 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」 @@ -227,6 +234,8 @@ function stubDb(dbPath, scenario = 'default') { shop_name: '测试店', logistic_status_name: '', query_time: '2026-10-20 11:18:27', + // 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到 + aftersale_status_name: '暂无售后', // 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time first_oa_confirm_time: o.last_oa_confirm_time ?? null, ...o, @@ -326,6 +335,19 @@ function stubDb(dbPath, scenario = 'default') { mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-22 08:50:00', last_oa_confirm_time: '2026-09-22 09:05:00' }), + // ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)── + // 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810 + mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 09:10:00', + last_oa_confirm_time: '2026-10-20 09:15:00' }), + // 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980 + mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 10:10:00', + last_oa_confirm_time: '2026-10-20 10:15:00' }), + // 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除 + mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', aftersale_status_name: '已作废', + pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }), ]; // 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00 @@ -392,6 +414,7 @@ function stubDb(dbPath, scenario = 'default') { send_date: x.send_date, is_merge: x.is_merge, is_split: x.is_split, + presaleVoid: x.presaleVoid || 0, }; }); } @@ -506,12 +529,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub // 被排除的仓不能算 assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核'); // 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单 - for (const id of ['9', '10', '11', '12']) { + // 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废) + for (const id of ['9', '10', '11', '12', '38', '39', '40']) { assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`); assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`); } - // 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent) - assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数'); + // 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类) + assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数'); // 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20) assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped'); @@ -561,13 +585,16 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用'); assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表'); - // 补创建时间:必须走两张订单表,且**只投影 6 个窄列** + // 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来) assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实'); for (const bs of calls.backfillSql) { - assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, - `补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`); - assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u, + assert.match(bs, + /SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, + `补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`); + assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u, '多取一列就会把 0.2s 拖到 38s'); + // labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文 + assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔'); } // ⚠️ 窗口参数走 params,不在 SQL 里手写