'use strict'; /** * 仓储中心发货时效 —— 服务层 * * ═══════════════════════════════════════════════════════════════════════════ * 🔥 2026-10-28 数据源切换 + 起算点二次纠偏(业务 yanhui 定版) * ═══════════════════════════════════════════════════════════════════════════ * 数据源(已定版,不要动): * rpt.jst_order_express_details(状态镜像)→ * rpt.jst_Orders + rpt.jst_qmcust_orderlist(**店铺互斥,必须都读**) * * 为什么状态镜像表不能当**候选池**(实测证据,2026-10-27/28): * ① send_time **漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里, * 订单表已有 send_date 的 **563 单**、status 已是 Sent 的 492 单; * ② 被合并 / 拆单父单**不回写**:另有 Merged 89 + Split 755 单被冤枉; * ③ 会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮 * 推出去的 34 单里混着已发货的(业务当场发现); * ④ 两表发货时间**同源**:send_time vs send_date 27387 单逐条比对**零偏差** * ⟹ 订单表才是权威,状态表只是它的滞后副本。 * * 🔥 起算点:**审核时间 `last_oa_confirm_time`**(2026-10-28 下午二次纠偏) * ───────────────────────────────────────────────────────────────────────── * 下午那一版曾把起算点改成 `created`(订单创建时间),理由是「审核时间就是 * 订单创建时间」。**这个判断是错的**,业务当场拿 9837824 打回来: * * 9837824(崔桥工厂仓,京喜-橡野里家具工厂店)逐字段: * order_date 10-19 23:43:35 / created 10-19 23:44:22 / pay_date 10-19 23:44:05 * last_oa_confirm_time **10-28 09:58:42**(= first_oa_confirm_time) * 运单 JDVB71664383839,10:09:55 快递员上门取件 * ⟹ 10-19 下的单压了 9 天才审核,仓库审核后 11 分钟就打单, * 按 created 算它「超时 205 小时」,纯属把审核积压算到仓库头上。 * * 量化(10-28 13:00 轮 599 单):376 单是**今天 07:00 之后**才审核的 * (08:10~10:30 集中批量审核,多为 10-17~10-27 积压单),491 单其实已有运单号。 * 改口径后实测:599 → 144,再剔除 6 单「状态表 send_time 有值、订单表 send_date 空」 * 的漏回写单 → **138**。 * * ⚠️ 别再被「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟」误导: * 那只统计了当期正常单,长尾积压单能差 8~11 天。判断起算点必须看全量分布。 * * 为什么用 `last_oa_confirm_time` 而不是 `first_oa_confirm_time`(业务 2026-10-28 选定): * 客服改单重审会刷新 last,业务认为重审后订单重新进仓库作业流,应从最后一次 * 审核重新计时。实测 599 单里 first==last 366 单,first cap, detailLimit: Math.min(cap, allOrders.length), detailTotal: allOrders.length, heldTotal: allHeld.length, }; } /** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */ function formatDateTime(value) { if (!value) return ''; if (value instanceof Date) { if (Number.isNaN(value.getTime())) return ''; const p = (n) => String(n).padStart(2, '0'); return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}` + ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`; } const s = String(value); if (!s) return ''; return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19); } /** * 取两个 `YYYY-MM-DD HH:mm:ss` 里较晚的那个(空值视为不存在)。 * * 🔥 2026-10-28 下午:起算点必须在「状态表审核时间」和「订单表 modified」之间取较晚者。 * 同格式字符串可以直接字典序比较,不需要转 Date(避免时区/跨天坑)。 */ function laterOf(a, b) { const x = String(a || ''); const y = String(b || ''); if (!x) return y; if (!y) return x; return y > x ? y : x; } /** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */ function diffMinutes(fromText, toText) { const a = formatDateTime(fromText); const b = formatDateTime(toText); if (!a || !b) return 0; const pa = new Date(a.replace(' ', 'T')).getTime(); const pb = new Date(b.replace(' ', 'T')).getTime(); if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0; return Math.floor((pb - pa) / 60000); } /** * 取「未发货订单」候选 —— 直接打**订单两表**(`jst_Orders` + `jst_qmcust_orderlist`)。 * * 🔥 2026-10-28 数据源切换(业务 yanhui:「销售订单表是错误的,应该抓 * jst_Orders + jst_OrderItem,审核时间就是 jst_Orders 的创建时间」)。 * 原来打的是状态镜像表 `jst_order_express_details`,它有三个致命问题: * · `send_time` 漏回写 —— 近 7 天 563 单明明已发却仍算「未发」; * · 被合并 / 拆单父单不回写 —— 89 + 755 单被冤枉; * · 会整批停更 —— 2026-10-27 11:54 之后 6.8 小时零写入。 * 而且两表发货时间实测 27387 单**逐条零偏差** ⟹ 订单表才是权威。 * * 过滤条件(SQL 侧一次到位,不把无关行传回 Node): * · `send_date IS NULL` —— 未发货(未发单必为空,已发单 100% 有值) * · `status IN (3 种)` —— Delivering 发货中 / WaitConfirm 待审核 / Question 异常; * Sent/Cancelled/Split/Merged/WaitOuterSent 直接不进候选 * · `created >= windowStart` —— 30 天护栏(`HISTORY_LOOKBACK_DAYS`) * * ⚠️ 这里的 SQL 只是**粗筛**:起算点是审核时间(在状态表里),SQL 拿不到, * 所以「哪一轮」由 Node 侧按起算点再判一次。护栏按 `created` 卡, * 理论上「创建超过 30 天、今天才审核」的单会被漏掉 —— 实测极罕见 * (最老积压单创建 10-17,审核 10-28,差 11 天),暂不放宽,别顺手改成去掉护栏。 * * 仓库:订单表给的是 `wms_co_id`(不是仓名文本),主查询**只回这个 id**, * 由 Node 侧用 `registry.loadWarehouseNameMap()` 的缓存换仓名,再走 * `resolveCenter()` 关键词归属 —— 比状态表的仓名更准(同一仓在状态表里有 * 「简尚-崔桥工厂仓 / 工厂-…」多种写法)。 * 🔥 **不要在 SQL 里 LEFT JOIN `jst_warehouseInfo`**:实测 JOIN 让同一条查询 * 从 28s 涨到 61~105s(qmcust 视图上 JOIN 直接把执行计划带偏), * 换到 Node 侧查一次仓表缓存即可(仓表只有几百行,缓存 10 分钟)。 * * 🔥 别再按 `is_split / is_merge` 过滤:实测 `is_split=1` 恰恰是**拆单子单** * (同一 so_id 下多行、各自要发货,近 30 天 1096 行),按老逻辑 `is_split=0` * 会把它们全排掉、少算超时;父单的 status 是 `Split`,本来就不在候选状态里。 * `is_merge=1` 且仍 Delivering 的只有 2 行,属于合单后真正要发的单,也不该排。 * * 🔥 性能(2026-10-28 实测,30 天护栏): * · `jst_Orders` **1.8s** * · `jst_qmcust_orderlist` **50.2s** ← 不加 RECOMPILE 时被参数嗅探拖死 * · 同一条 SQL 加 `OPTION (RECOMPILE)` → **0.4s** * ⟹ **必须带 RECOMPILE**,否则 qmcust 会走错执行计划(实测 33s vs 0.7s)。 * 时间条件也直接拼字面量(值由服务端自己算的 `YYYY-MM-DD HH:mm:ss`,经校验), * 字面量 + RECOMPILE 两个措施一起上才稳。 * * ⚠️ 只投影窄列:`labels` 长文本只在 SQL 里算成布尔 `presaleVoid`,不把原文传回。 */ async function loadPendingOrders(round) { // 时间字面量:只允许数字 / 短横 / 冒号 / 空格,杜绝拼接注入 const startLit = String(round.windowStart || '').replace(/[^0-9\-: ]/g, '').slice(0, 19); if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(startLit)) { const err = new Error(`核验窗口起点非法: ${round.windowStart}`); err.statusCode = 500; throw err; } const statusList = ORDER_CANDIDATE_STATUSES.map((s) => `N'${s}'`).join(', '); const presaleSql = `CASE WHEN labels LIKE N'%${ VOID_ORDER_LABEL_KEYWORDS[0]}%' THEN 1 ELSE 0 END`; const rows = await cloudDatabase.querySlow( `SELECT x.o_id, x.so_id, x.shop_name, x.status, x.created, x.pay_date, x.logistics_company, x.l_id, x.presaleVoid, x.src, x.updated_at, x.wms_co_id, x.modified FROM ( SELECT o.o_id, o.so_id, o.shop_name, o.status, o.created, o.pay_date, o.logistics_company, o.l_id, o.wms_co_id, ${presaleSql} AS presaleVoid, o.updateTime AS updated_at, o.modified, N'jst_Orders' AS src FROM rpt.jst_Orders o WITH (NOLOCK) WHERE o.send_date IS NULL AND o.created >= '${startLit}' AND o.status IN (${statusList}) UNION ALL SELECT c.o_id, c.so_id, c.shop_name, c.status, c.created, c.pay_date, c.logistics_company, c.l_id, c.wms_co_id, ${presaleSql} AS presaleVoid, c.local_updated AS updated_at, c.modified, N'jst_qmcust_orderlist' AS src FROM rpt.jst_qmcust_orderlist c WITH (NOLOCK) WHERE c.send_date IS NULL AND c.created >= '${startLit}' AND c.status IN (${statusList}) ) x OPTION (RECOMPILE)`, {}, // 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s) { connectRequestTimeoutMs: 300000 } ); return rows || []; } /** * 批量取**审核时间**(起算点)—— 唯一来源是状态镜像表 `jst_order_express_details`。 * * 为什么必须回状态表拿: * · `jst_Orders` / `jst_qmcust_orderlist` **都没有审核时间字段**(85 列逐一看过, * 只有 order_date / pay_date / created / modified / updateTime,前者是下单、 * 后者是灌库时间,`modified` 还会被每天凌晨批量刷新); * · 状态表 `first_oa_confirm_time` / `last_oa_confirm_time` 才是聚水潭的审核时间。 * ⚠️ 只从它拿**这一个字段**,候选取舍(哪些单未发货)仍然只看订单两表 —— * 状态表的 send_time 漏回写、会整批停更,那些坑一个都不能重新踩回来。 * * 业务口径(2026-10-28):起算点用 `last_oa_confirm_time`(业务选「最后审核时间」, * 理由见文件头)。空值不在这里兜,交给调用方回退 `created`。 * * 性能:`inner_order_id IN (...)` 一次 600 个约 **15s**(全表扫一次,402 万行视图)。 * 🔥 千万别改成逐单查 —— 实测单条 **14.5s**,599 条要 2.4 小时。 * 分批 600 是因为 T-SQL 单条语句 IN 列表太长会解析超时;两批各 600 / 剩余。 * * 失败降级:抛错由调用方 catch → 全部回退 created,并在 payload 里标 degraded, * **不要因为状态表挂了就整页 500**(候选池在订单表,本来就有数据)。 */ async function loadOaConfirmInfo(stateRows) { const ids = [...new Set( (stateRows || []).map((r) => Number(r.o_id)).filter((n) => Number.isFinite(n)) )]; const map = new Map(); if (!ids.length) return map; // 🔥 2026-10-28 下午:串行 9 批实测 **135s**(全并行 9 批只要 6s,并行 3 批 28.8s)。 // 生产上「主查询 63~105s + 状态表 135s」直接把 /list 顶过 300s 超时, // 页面打不开 —— 所以**必须并发**。并发 6 是实测过的安全值: // 单批约 15s,6 并发后整体 10~15s,且不至于把 402 万行视图的连接池打爆。 const BATCH = 600; const CONCURRENCY = 6; const chunks = []; for (let i = 0; i < ids.length; i += BATCH) { chunks.push(ids.slice(i, i + BATCH)); } for (let i = 0; i < chunks.length; i += CONCURRENCY) { const group = chunks.slice(i, i + CONCURRENCY); // eslint-disable-next-line no-await-in-loop const results = await Promise.all(group.map((chunk) => { // ids 已用 Number.isFinite 过滤过,拼进 IN 是纯数字,无注入面 return cloudDatabase.querySlow( `SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time FROM rpt.jst_order_express_details WITH (NOLOCK) WHERE inner_order_id IN (${chunk.join(',')})`, {}, { connectRequestTimeoutMs: 300000 } ); })); for (const rows of results) { for (const r of rows || []) { map.set(Number(r.inner_order_id), { first: formatDateTime(r.first_oa_confirm_time), last: formatDateTime(r.last_oa_confirm_time), // 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况 // (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空) sendTime: formatDateTime(r.send_time), }); } } } return map; } function emptySummary() { return { // 超时未发货(考核仓库的部分) overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, // 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任 heldCount: 0, // 已剔除的作废 / 已发单(页面只作说明,不进考核) voidCount: 0, // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能) carrierSkipped: 0, // 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写) stateShippedSkipped: 0, // 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到) unknownWarehouse: 0, }; } function buildEmptyCenters(centerList) { return centerList.map((c) => ({ key: c.key, name: c.name, placeholder: c.placeholder, warehouseCount: c.warehouses.length, warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), overdueUnshipped: 0, oldestDays: 0, heldCount: 0, })); } async function getShipTimeliness(options = {}) { // 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间, // 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测) const now = options.now instanceof Date ? options.now : registry.beijingNow(); // 导出(fullDetail)要**全部**行,不受页面展示上限约束; // 页面 / 钉钉推送走普通 limit(钉钉只列前 N 条,页面截断提示) const fullDetail = options.fullDetail === true || ['1', 'true', 'yes'].includes(String(options.fullDetail || '').toLowerCase()); const limit = fullDetail ? Infinity : Math.min( Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1), MAX_DETAIL_LIMIT ); // ① 主仓清单(用于展示「含子仓 N 个」;归属判定走仓名关键词) const centerList = await registry.loadCenterWarehouseMap(); // 全量「wms_co_id → 仓名」—— 主查询只回 wms_co_id,名字在这里换 // (不在 SQL 里 LEFT JOIN:实测 JOIN 会把 28s 拖成 61~105s,视图下推被破坏) const warehouseNames = await registry.loadWarehouseNameMap(); const pad2 = (n) => String(n).padStart(2, '0'); // ② 决定本轮截止时刻 const rounds = registry.describeRounds(now); let round = null; if (options.round) { const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase()); if (!target) { const err = new Error(`未知的核验轮次: ${options.round}`); err.statusCode = 400; throw err; } round = rounds.find((r) => r.key === target.key); if (!round) { const err = new Error(`无法构造核验窗口: ${options.round}`); err.statusCode = 500; throw err; } } else { round = registry.resolveActiveRound(now); } // 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库 if (!round) { const nextRound = rounds.find((r) => !r.active) || rounds[0]; return { generatedAt: formatDateTime(now), date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`, round: null, notStarted: true, isPreviousDay: false, rounds, nextRoundAt: nextRound ? nextRound.deadline : '', nextWindowEnd: nextRound ? nextRound.windowEnd : '', deadline: '', window: null, summary: emptySummary(), centers: buildEmptyCenters(centerList), orders: [], heldOrders: [], truncated: false, detailLimit: Number.isFinite(limit) ? limit : 0, detailTotal: 0, heldTotal: 0, detailDegraded: false, freshness: null, note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`, }; } // ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时 // 先看缓存:同一轮次 + 同一天,5 分钟内直接复用(存全量,出口再按 limit 切) const force = options.force === true || ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase()); // 🔥 缓存键只含「轮次 + 日期」,**不含 limit**(原因见 withDetailLimit 的注释): // 页面、钉钉推送、导出三方共用同一份缓存,导出的不会再重查一遍视图。 const cacheKey = `${round.key}|${round.date}`; if (!force && resultCache.data && resultCache.key === cacheKey && Date.now() - resultCache.at < CACHE_TTL_MS) { return { ...withDetailLimit(resultCache.data, limit), cached: true, cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000), }; } let stateRows = []; try { stateRows = await loadPendingOrders(round); } catch (e) { const err = new Error(`订单表(jst_Orders / jst_qmcust_orderlist)查询失败:${e.message}`); err.statusCode = 502; throw err; } // ③.5 起算点:批量取审核时间(最后审核时间优先,缺失回退订单创建时间) // ⚠️ 状态表挂了不阻断页面:全量回退 created,并在 payload 里标降级 let oaConfirmMap = new Map(); let oaConfirmDegraded = false; try { oaConfirmMap = await loadOaConfirmInfo(stateRows); } catch (e) { oaConfirmDegraded = true; console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message); } // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) let voidCount = 0; // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账 let carrierSkipped = 0; // 订单表 send_date 为空、但状态表 send_time 有值(漏回写)→ 已发,剔除 let stateShippedSkipped = 0; // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) let unknownWarehouse = 0; // ④ 分桶 const acc = new Map(centerList.map((c) => [c.key, { pendingCount: 0, oldestDays: 0, heldCount: 0, }])); const pendingRows = []; const heldRows = []; const seenSo = new Set(); let snapshotAt = ''; for (const row of stateRows) { // 新鲜度:订单表最后一次更新时间(顺带取 MAX,不额外扫表) const ut = formatDateTime(row.updated_at); if (ut > snapshotAt) snapshotAt = ut; // ── 仓库归属:主查询只回 wms_co_id,用仓表缓存换名字再走关键词归属 ────── // ⚠️ 必须用仓名走关键词归属(子仓并入主仓、京东云仓排除都在 resolveCenter 里), // 不能拿 wms_co_id 硬编码 —— 上游新增子仓后不用改代码。 // 实测订单表 wms_co_id 映射出的仓名与状态表 warehouse_name **1821 单完全一致**, // 所以换字段不影响归属结果。 const wmsCoId = Number(row.wms_co_id); const warehouseName = warehouseNames.get(wmsCoId) || ''; const center = resolveCenter(warehouseName); if (!center) { unknownWarehouse += 1; // 代发仓 / 三方仓 / 未指定仓 / 配件仓(常州简尚家居)… continue; } const bucket = acc.get(center.key); if (!bucket) continue; // ── 起算点 = **审核时间**(业务 2026-10-28 定版)──────────────────────── // 业务原话:「9837824 这个单的创建时间是 2026-10-28 09:58:42」—— 那是 // 状态表的 last_oa_confirm_time,不是订单表的 created(10-19 23:44:22)。 // 积压单 created → 审核能差 8~11 天,按 created 算等于把审核积压 // 算到仓库头上(详见文件头 9837824 的证据)。 // // 🔥 2026-10-28 下午再纠偏:**状态表的审核时间会严重滞后,必须和订单表 // `modified` 取较晚者**。铁证(9978643,业务给的): // · qmcust.modified = 2026-10-28 14:52:29 // · 聚水潭页面真实审核时间 = 2026-10-28 14:52:28 ← 秒级吻合 // · 状态表 last_oa_confirm = 2026-10-27 17:08:43 ← 旧值,早了一天 // · 状态表 query_time = 2026-10-28 10:29:11 ← 10:29 后就没再抓过 // 只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时(冤枉仓库)。 // 更惨的是**审核时间为空的单**(13:00 轮 65 单里 57 单):回退 created 后, // 明明是今天 13:40~15:03 才审核的单,被按 10-25~10-27 的下单时间算超时。 // // ⟹ 口径:起算点 = max(状态表 last_oa_confirm_time, 订单表 modified), // 两者都没有才回退 created。`modified` 是聚水潭源端的「最后修改时间」, // 审核动作一定会刷新它;因客服改单/改备注而推后的情况属「放行」方向 // (少报超时,不冤枉仓库),业务已知悉。 const createdAt = formatDateTime(row.created); const modifiedAt = formatDateTime(row.modified); const oaInfo = oaConfirmMap.get(Number(row.o_id)); // ── ② 已发兜底:状态表说发了,就不再算未发 ───────────────────────────── // PR #88 换源时记录的是「状态表 send_time 漏回写」这个方向,但**反方向也会漏**: // 2026-10-28 实测 13:00 轮 144 单里 **6 单**状态表 send_time 有值、且有 // 物流揽收轨迹,而订单表 send_date 仍是 NULL —— 只信订单表就会冤枉仓库。 // ⟹ 口径:**任一方有发货时间即视为已发**,剔除量记 summary.stateShippedSkipped。 // ⚠️ 只认 send_time,**不要**拿 `logistic_time` 当发货时间:它多数是 // 「快递员将上门取件」的预告(实测 9983162 有轨迹但并未收件),会提前放行。 if (oaInfo && oaInfo.sendTime) { stateShippedSkipped += 1; continue; } const oaConfirmAt = oaInfo ? (oaInfo.last || '') : ''; // 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。 // · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据); // · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据), // 起算点绝不该早于下单时刻。 const benchmarkAt = laterOf(laterOf(oaConfirmAt, modifiedAt), createdAt) || createdAt; let benchmarkSource = 'created'; if (benchmarkAt && benchmarkAt === modifiedAt) benchmarkSource = 'modified'; else if (benchmarkAt && benchmarkAt === oaConfirmAt) benchmarkSource = 'oa_confirm'; const payAt = formatDateTime(row.pay_date); const statusRaw = String(row.status || ''); // 同一 so_id 可能因拆包出现多行,按 so_id + 创建时间 去重 const dedupeKey = `${row.so_id}|${createdAt}`; if (seenSo.has(dedupeKey)) continue; seenSo.add(dedupeKey); // ── 本轮核验范围(窗口上界按**起算点**卡)────────────────────────────── // 13:00 轮:今天 07:00 前审核的单 + 历史未发 // 18:00 轮:今天 16:00 前审核的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00) // ⚠️ 历史未发单一律算 —— 昨天、前天乃至更早挂着没发的都要进超时列表。 const windowEndAt = registry.centerWindowEnd(center.key, round); const todayStart = `${round.date} 00:00:00`; if (benchmarkAt && benchmarkAt < round.windowStart) continue; // 超出 30 天护栏 if (benchmarkAt && benchmarkAt >= todayStart && benchmarkAt > windowEndAt) continue; const base = { row, center, state: ORDER_STATE_LABELS[statusRaw] || statusRaw, statusRaw, src: String(row.src || ''), createdAt, oaConfirmAt, payAt, // 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算 benchmarkAt, benchmarkSource, // 页面「审核时间」列显示的就是起算点(回退时会打 `*`) auditAt: benchmarkAt, windowAt: benchmarkAt, warehouseName, logisticCompany: String(row.logistics_company || '').trim(), logisticId: String(row.l_id || '').trim(), }; // ── ① 不计考核:待审核(仓库还没接到单)/ 异常(疑问单)────────────────── if (statusRaw === ORDER_UNAUDITED_STATUS || statusRaw === ORDER_QUESTION_STATUS) { bucket.heldCount += 1; heldRows.push({ ...base, holdReason: statusRaw === ORDER_UNAUDITED_STATUS ? '待审核' : '订单异常', }); continue; } // SQL 白名单只放了 3 种状态,走到这里必然是「发货中」; // 再兜一次是防止以后有人放宽候选白名单却没补判定分支 if (statusRaw !== ORDER_PENDING_STATUS) { voidCount += 1; continue; } // ── ② 作废:labels 含「发货前售后」(客户发货前退款/取消)────────────── // ⚠️ 状态表那条「售后已作废」判据已随数据源切换放弃 —— 实测它在 // 「发货中+未发」里只命中 4 单(基本都是 Sent),不值得为它多扫一张 402 万行表。 if (Number(row.presaleVoid) === 1) { voidCount += 1; continue; } // ── ③ 不考核的快递(自提 / 中铁 / 壹米滴答 / 武进安能)────────────────── // 走这几种运输方式的单不按仓储发货时效考核:自提客户自己来拉、 // 中铁 / 壹米滴答是专线大件、武进安能是加盟制快运网点,揽收与在途都不受仓库控制。 // ⚠️ 「武进安能」别泛化成「安能」:常州安能新等网点仍要考核。 const hitCarrier = EXCLUDED_CARRIERS.find((k) => base.logisticCompany.includes(k)); if (hitCarrier) { carrierSkipped += 1; continue; } // ── ④ 超时判定:到核验时刻仍未发货即超时(没有「满 24 小时」门槛)──────── if (!benchmarkAt) { bucket.heldCount += 1; heldRows.push({ ...base, holdReason: '缺审核时间与创建时间' }); continue; } // waited = 起算点(审核时间)→ 本轮核验时刻(13:00 / 18:00) const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline)); bucket.pendingCount += 1; const days = Math.floor(waited / 1440); if (days > bucket.oldestDays) bucket.oldestDays = days; pendingRows.push({ ...base, waited }); } // ⑤ 排序:最久的排前面(业务一眼看最急的) pendingRows.sort((a, b) => { if (a.windowAt < b.windowAt) return -1; if (a.windowAt > b.windowAt) return 1; return 0; }); heldRows.sort((a, b) => { const x = a.windowAt || ''; const y = b.windowAt || ''; if (x < y) return -1; if (x > y) return 1; return 0; }); // ⚠️ 这里**不再按 limit 切**:payload 存全量进缓存,出口由 withDetailLimit 截断。 // 以前在这里 slice,缓存里就只有前 limit 行 —— 导出拿不到全部,且 limit 一变就重查。 const detailRows = pendingRows; const heldDetail = heldRows; const orders = detailRows.map((x) => { const r = x.row; return { oId: String(r.o_id || ''), soId: String(r.so_id || ''), shopName: String(r.shop_name || ''), // 订单创建时间(聚水潭建单)—— 只做展示与兜底,不是考核起算点 createdAt: x.createdAt, // 发货时间:候选本身就是 send_date IS NULL,所以恒为空 → 前端显示「未发货」 sendTime: formatDateTime(r.send_date), // 付款时间(并列展示,方便对账) payTime: x.payAt, // 「审核时间」= 起算点:状态表 last_oa_confirm_time;缺失回退 createdAt // benchmarkSource='created' 时前端该列会打 `*` 提示是回退值 auditTime: x.auditAt, benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, // 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单 logisticStatus: x.logisticId ? '已打单 · 未揽收' : '', // 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。 logisticId: x.logisticId, logisticCompany: x.logisticCompany, centerKey: x.center.key, centerName: x.center.name, warehouseName: x.warehouseName, // 已等时长(小时,保留 1 位)与分钟(排序都用分钟) waitedHours: Math.round((x.waited / 60) * 10) / 10, overdueMinutes: x.waited, }; }); const heldOrders = heldDetail.map((x) => { const from = x.benchmarkAt || ''; return { oId: String(x.row.o_id || ''), soId: String(x.row.so_id || ''), shopName: String(x.row.shop_name || ''), createdAt: x.createdAt, sendTime: formatDateTime(x.row.send_date), payTime: x.payAt, auditTime: x.auditAt, benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, holdReason: x.holdReason, logisticStatus: x.logisticId ? '已打单 · 未揽收' : '', logisticId: x.logisticId, logisticCompany: x.logisticCompany, centerKey: x.center.key, centerName: x.center.name, warehouseName: x.warehouseName, heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0, }; }); const centers = centerList.map((c) => { const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 }; return { key: c.key, name: c.name, placeholder: c.placeholder, warehouseCount: c.warehouses.length, warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), overdueUnshipped: b.pendingCount, oldestDays: b.oldestDays, heldCount: b.heldCount, // 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00) windowEnd: registry.centerWindowEnd(c.key, round), }; }); // 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组) const windowOverrides = centerList .filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null) .map((c) => ({ key: c.key, name: c.name, hour: registry.centerWindowEndHour(c.key, round.key), windowEnd: registry.centerWindowEnd(c.key, round), })); const summary = centers.reduce((a, c) => { a.overdueUnshipped += c.overdueUnshipped; if (c.overdueUnshipped > 0) a.overdueCenterCount += 1; if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays; a.heldCount += c.heldCount || 0; return a; }, emptySummary()); summary.voidCount = voidCount; summary.carrierSkipped = carrierSkipped; summary.stateShippedSkipped = stateShippedSkipped; summary.unknownWarehouse = unknownWarehouse; // 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表) // 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false —— // 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新), // 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。 // 必须把滞后暴露到页面上,否则业务只会以为判定写错。 const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null; const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES; // 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 // 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; const freshness = { stateMaxQuery: snapshotAt, ageMinutes: snapshotAgeMin, stale, coversDeadline, // 数据源已经换成订单表,文案跟着改(别再写「状态镜像」) source: 'jst_Orders', hint: [ stale ? `订单表最后更新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。` : '', !coversDeadline ? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:` + '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。' : '', ].filter(Boolean).join(' '), }; const payload = { generatedAt: formatDateTime(now), date: round.date, round: { key: round.key, label: round.label, verifyHour: round.verifyHour, windowEnd: round.windowEnd, }, notStarted: false, isPreviousDay: false, rounds, deadline: round.deadline, // boundary='oa_confirm':轮次归属、已等时长都按**审核时间**算 // (状态表 last_oa_confirm_time;取不到时回退订单 created,见 benchmarkSource) // 业务 2026-10-28 下午二次定版;上午那版按 created 已被 9837824 推翻。 window: { start: round.windowStart, end: round.windowEnd, includeHistory: true, boundary: 'oa_confirm', measuredBy: 'oa_confirm', // 审核时间缺失、回退订单创建时间的单量(页面/钉钉需要标出来) fallbackToCreated: orders.filter((o) => o.benchmarkSource === 'created').length, fallbackDegraded: oaConfirmDegraded, // 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮) overrides: windowOverrides, }, summary, centers, // ⚠️ 全量(未截断);出口由 withDetailLimit 按 limit 切,并回填 detailTotal / heldTotal orders, // 不考核的快递(前端文案直接用,避免两边各写一份) excludedCarriers: EXCLUDED_CARRIERS, // 不计考核清单(待审核 / 异常)—— 与 orders 互斥 heldOrders, truncated: false, detailLimit: detailRows.length, detailDegraded: false, freshness, cached: false, cacheAgeSec: 0, note: '', }; resultCache = { key: cacheKey, at: Date.now(), data: payload }; return withDetailLimit(payload, limit); } /** * 导出明细行(纯函数,便于单测)。 * * 口径必须与页面明细**完全一致** —— 所以直接把 `getShipTimeliness().orders` 摊平, * 不重新查库、不重新过滤,否则导出的行和页面上看到的会对不上。 * * 比页面多带两列「快递公司 / 运单号」:业务拿 Excel 去追单时要能直接看到 * 是哪个快递、有没有打单(有运单号 = 已打单未揽收)。 * * @param {Array} orders service 返回的 orders(已经是考核口径内的超时未发单) * @returns {Array} 字段名与前端 EXPORT_COLUMNS 的 dataIndex 一一对应 */ function buildShipTimelinessExportRows(orders = []) { const list = Array.isArray(orders) ? orders : []; // 一律转字符串:单号是数字时 Excel 会显示成科学计数法(5127…E+18),业务没法拿它去搜单 const text = (v) => (v === null || v === undefined ? '' : String(v)); return list.map((o) => ({ oId: text(o?.oId), soId: text(o?.soId), shopName: text(o?.shopName), // centerKey 只用于前端「按选中仓过滤」,不进导出列 centerKey: text(o?.centerKey), centerName: text(o?.centerName), warehouseName: text(o?.warehouseName), logisticCompany: text(o?.logisticCompany), logisticId: text(o?.logisticId), logisticStatus: text(o?.logisticStatus), state: text(o?.state), sendTime: text(o?.sendTime), auditTime: text(o?.auditTime), payTime: text(o?.payTime), benchmarkTime: text(o?.benchmarkTime), benchmarkSource: text(o?.benchmarkSource), overdueMinutes: Number(o?.overdueMinutes) || 0, waitedHours: Number(o?.waitedHours) || 0, })); } /** * 「导出明细」的数据源:**不分页、不截断**。 * * 与 `/list` 共用 `getShipTimeliness`(同一份缓存 → 页面刚刷过就是秒回), * 唯一区别是 `fullDetail: true` 让出口不按 limit 切。 */ async function listShipTimelinessExportRows(query = {}) { const payload = await getShipTimeliness({ round: query.round, force: query.force, // now 只是给单测注入固定时刻用的(HTTP 上传不进来也不会解析字符串); // 生产走 beijingNow(),见 getShipTimeliness 里的时区说明 now: query.now, fullDetail: true, }); const rows = buildShipTimelinessExportRows(payload.orders); return { rows, total: rows.length, notStarted: payload.notStarted === true, date: payload.date, round: payload.round, deadline: payload.deadline, generatedAt: payload.generatedAt, summary: payload.summary || emptySummary(), excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS, freshness: payload.freshness || null, }; } module.exports = { getShipTimeliness, listShipTimelinessExportRows, clearShipTimelinessCache, DEFAULT_DETAIL_LIMIT, MAX_DETAIL_LIMIT, // 钉钉推送正文要列「剔除了哪几个快递」,直接复用服务层的清单,别在 job 里再抄一份 EXCLUDED_CARRIERS, _private: { EXCLUDED_CARRIERS, buildShipTimelinessExportRows, withDetailLimit, }, };