'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_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-29 扩展:**不止取时间,还要取终态**。 * 订单两表会各自停更(当天 qmcust 停在 07:25),停更期间聚水潭里的「取消 / 发货」 * 订单表根本看不到 —— 订单 status 仍是 Delivering、send_date 仍是空。 * 而状态表虽也停(11:52),却比订单表新鲜 4 小时,`order_status_name` 已经变成 * 「已取消」/「已发货」。业务当天报的「作废单还在考核里」11 单就是这么来的: * 订单表 labels 里**没有**「发货前售后」,`presaleVoid` 完全失效。 * * 业务口径(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 / 剩余。 * 多取 4 个语义列(`order_status_name` 等)是同一批 IN 同一次扫描, * **不增加往返**,实测耗时无变化。 * * 失败降级:抛错由调用方 catch → 全部回退 created,并在 payload 里标 degraded, * **不要因为状态表挂了就整页 500**(候选池在订单表,本来就有数据)。 */ async function loadStateInfo(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, order_status_name, aftersale_status_name, logistic_status_name, order_labels, query_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), // ── 终态校验(2026-10-29 新增)──────────────────────────────── // 这两个是**聚水潭订单状态**的直接映射,订单表停更时只有它靠得住 orderStatus: String(r.order_status_name || '').trim(), aftersaleStatus: String(r.aftersale_status_name || '').trim(), logisticStatus: String(r.logistic_status_name || '').trim(), // 状态表自己的抓取时刻:同一单可能 08:50 抓过就没再抓,判据可信度要看它 queryTime: formatDateTime(r.query_time), }); } } } return map; } /** * 批量取**销售出库单**的状态 —— 判「这单在聚水潭里被作废了吗」。 * * 🔥 2026-10-29 新增。业务原话:「9992097…这些订单都是作废订单,为啥还在考核之内」, * 并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。 * * 为什么必须补这一层(订单表 + 状态镜像表都不够): * · 订单表 `labels` 的「发货前售后」判据,在停更/正常数据上都会漏(15:00 那批 0 命中); * · 状态镜像表 `order_status_name='已取消'` 一天只抓几次,抓不到的作废就漏; * · **出库单 `status='Delete'` 才是仓库侧最贴身的作废痕迹** —— 近 7 天实测: * 出库单被删的 2209 单里,**55 单订单表仍是 `Delivering`、36 单 `Question`**, * 这批人只能靠出库单判出来。 * * 判据边界(血泪): * · 一个订单可能有多张出库单(拆包 / 重开)→ **必须「该 o_id 下没有任何非 Delete 的出库单」** * 才算作废。实测近 7 天 51 个「订单发货中但出库单被删」的单里,有 7 个挂着 2~8 张 * 出库单且全为 Delete(拆包重开),不能凭「有一张 Delete」就剔。 * · **没有出库单 ≠ 作废**:近 3 天未发单 **82%(1467/1790)压根没有出库单** * (订单还没推下游),拿它判作废会把正常待发单全放跑。 * * 性能:按来源表分别 `o_id IN (...)`,一批 500、并发 3(出库单表比状态表小得多, * 实测单批 1~3s)。查询失败 → 返回空 Map,**不阻断页面**(判据退化成「不判」)。 * * 🔴 2026-10-29 13:10 实测:两张出库单表 `local_updated` 一起停在 **03:10**(停更 11h), * `modified` 也全是凌晨的。停更期间业务新作废的单在这里**永远是 WaitConfirm** —— * 所以出库单也必须进 `loadSourceFreshness`,否则「查不到作废」会被误读成「没作废」。 * * @param {Array} orderRows 候选订单行(含 `o_id` / `src`) * @returns {Promise>} */ async function loadOrderIoStatus(orderRows) { const TABLE_BY_SRC = { jst_Orders: 'rpt.jst_salesOutboundInfo', jst_qmcust_orderlist: 'rpt.jst_qmcust_salesoutbound', }; // src → 去重后的 o_id 列表 const idsBySrc = new Map(); for (const r of orderRows || []) { const src = String(r.src || ''); if (!TABLE_BY_SRC[src]) continue; const oid = Number(r.o_id); if (!Number.isFinite(oid)) continue; if (!idsBySrc.has(src)) idsBySrc.set(src, new Set()); idsBySrc.get(src).add(oid); } const result = new Map(); if (!idsBySrc.size) return result; const BATCH = 500; const CONCURRENCY = 3; const tasks = []; for (const [src, idSet] of idsBySrc) { const ids = [...idSet]; for (let i = 0; i < ids.length; i += BATCH) { tasks.push({ src, chunk: ids.slice(i, i + BATCH) }); } } for (let i = 0; i < tasks.length; i += CONCURRENCY) { const group = tasks.slice(i, i + CONCURRENCY); // eslint-disable-next-line no-await-in-loop const settled = await Promise.allSettled(group.map((t) => cloudDatabase.querySlow( // o_id 已用 Number.isFinite 过滤,拼进 IN 是纯数字,无注入面 `SELECT o_id, io_id, status FROM ${TABLE_BY_SRC[t.src]} WITH (NOLOCK) WHERE o_id IN (${t.chunk.join(',')})`, {}, { connectRequestTimeoutMs: 120000 }, ))); for (const s of settled) { if (s.status !== 'fulfilled') continue; // 单批失败不影响其它批 for (const r of s.value || []) { const oid = Number(r.o_id); if (!Number.isFinite(oid)) continue; const st = String(r.status || '').trim(); if (!result.has(oid)) result.set(oid, { statuses: [], alive: 0, dead: 0 }); const rec = result.get(oid); if (st && !rec.statuses.includes(st)) rec.statuses.push(st); if (st === IO_VOID_STATUS) rec.dead += 1; else rec.alive += 1; // 含空状态:认不出就当还活着(宁可漏判,不误剔) } } } const out = new Map(); for (const [oid, rec] of result) { out.set(oid, { // 页面展示用:优先显示「还有效」的那个状态(业务一眼看出这单的进度) status: rec.alive > 0 ? (rec.statuses.find((s) => s !== IO_VOID_STATUS) || '') : IO_VOID_STATUS, statuses: rec.statuses, // 唯一可信的作废判据:有出库单、且一张都没活着 allDeleted: rec.dead > 0 && rec.alive === 0, count: rec.dead + rec.alive, }); } return out; } /** * 各数据源**各自**的最后更新时间,用于判断「这个源还活着吗」。 * * 🔥 为什么不能只看候选行:候选池本身就是 `send_date IS NULL` 的单, * 某张表停更后它的候选单 `updateTime` 会集体冻在停更那一刻,看起来「都一样旧」; * 但若停更表里恰好有几单新改过,`MAX` 就假装新鲜了 —— 必须直接问源表。 * * 口径: * · `jst_Orders` → `MAX(updateTime)`(5 分钟一批的增量同步时间) * · `jst_qmcust_orderlist` → `MAX(local_updated)`(该表没有 updateTime 列) * · 只扫 30 天窗口(`round.windowStart` 起),不然要全表扫描 * · 任一源查询失败 → 该源记 `lastUpdate:null`、`stale:false`, * **不谎报停更**(查不到 ≠ 停更),但会在 `error` 里留痕 * * @param {object} round 轮次(用 windowStart 做扫描下界) * @param {Date} now 当前时间(已按北京时间取) * @returns {Promise>} */ async function loadSourceFreshness(round, now) { const startLit = String(round.windowStart || '').slice(0, 19); const nowText = formatDateTime(now); const defs = [ { key: 'jst_Orders', label: 'jst_Orders', sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_Orders WITH (NOLOCK) WHERE updateTime >= '${startLit}'`, }, { key: 'jst_qmcust_orderlist', label: 'qmcust', sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_orderlist WITH (NOLOCK) WHERE local_updated >= '${startLit}'`, }, { // 🔥 2026-10-29 新增:状态镜像表也必须监控。 // 它是「这单取消了吗 / 发出了吗」的唯一权威来源(订单表会停更), // 当天它停在 11:52,而 13:00 轮的超时判定就建立在这份 11:52 的快照上。 // 不监控它 ⟹ 页面只说「订单表停了」,实际判据也已经过期 1.5 小时。 key: 'jst_order_express_details', label: '状态镜像表', // 这张表没有 updateTime 列,用 `updatedAt`(ETL 写入时刻) sql: `SELECT MAX(updatedAt) AS maxUpd FROM rpt.jst_order_express_details WITH (NOLOCK) WHERE updatedAt >= '${startLit}'`, }, { // 🔥 2026-10-29 新增:两张**销售出库单**表也必须监控。 // 它是「单据作废了吗」的唯一来源(订单表停更、状态表一天只抓几次)。 // 实测当天它俩的 `local_updated` **一起停在 03:10**(停更 11 小时), // 而业务上午在聚水潭作的废全落在这张表上 —— 页面看到的是 11 小时前的 // `WaitConfirm`(待出库),于是那批单永远「查不出作废」。 // ⚠️ 判断必须用 `local_updated / updateTime`(灌库时刻), // **不能用 `modified`**(业务字段)—— 没有单据变更时它天然是旧的, // 拿它判停更会天天误报(这个坑在 `jst_inventoryInfo` 上踩过一次)。 key: 'jst_salesOutboundInfo', label: '出库单(聚水潭)', sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_salesOutboundInfo WITH (NOLOCK) WHERE updateTime >= '${startLit}'`, }, { key: 'jst_qmcust_salesoutbound', label: '出库单(奇门)', sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_salesoutbound WITH (NOLOCK) WHERE local_updated >= '${startLit}'`, }, ]; // 两个源并行查(各自 60s 上限,任一个慢不拖累另一个) const settled = await Promise.allSettled(defs.map((d) => cloudDatabase.querySlow( d.sql, {}, { connectRequestTimeoutMs: 120000 }, ))); return defs.map((d, i) => { const s = settled[i]; if (s.status !== 'fulfilled') { return { key: d.key, label: d.label, lastUpdate: '', lagMinutes: null, stale: false, error: String((s.reason && s.reason.message) || s.reason || '查询失败').slice(0, 160), }; } const rows = s.value || []; const lastUpdate = formatDateTime(rows[0] && rows[0].maxUpd); const lagMinutes = lastUpdate ? Math.max(0, diffMinutes(lastUpdate, nowText)) : null; return { key: d.key, label: d.label, lastUpdate, lagMinutes, // 查不到更新时间时**不判停更**(宁可漏报,也不谎报) stale: lagMinutes != null && lagMinutes > SOURCE_STALE_MINUTES, }; }); } function emptySummary() { return { // 超时未发货(考核仓库的部分) overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, // 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任 heldCount: 0, // 已剔除的作废 / 已发单(页面只作说明,不进考核) voidCount: 0, // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能) carrierSkipped: 0, // 已剔除的「其实已发」单量:状态表 send_time 有值 / order_status_name='已发货' // / 物流已揽收+ (订单表 send_date 空但聚水潭已经发了) stateShippedSkipped: 0, // 已剔除的「订单已取消」单量:状态表 order_status_name='已取消' // ⚠️ 订单表停更期间这是**唯一**能判出作废的来源(labels 判据会整体失效) stateVoidSkipped: 0, // 已剔除的「出库单整体作废」单量:销售出库单 status 全为 Delete // 🔥 2026-10-29 新增 —— 业务点名「洛宁仓 16 单都是作废订单」,聚水潭的作废落在出库单上 ioVoidSkipped: 0, // 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。 // 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。 sourceStaleUnshipped: 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, dataSources: [], 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 stateInfoMap = new Map(); let oaConfirmDegraded = false; try { stateInfoMap = await loadStateInfo(stateRows); } catch (e) { oaConfirmDegraded = true; console.warn('[WST] 状态镜像表查询失败,全量回退订单创建时间:', e.message); } // ③.55 销售出库单状态:判「单据作废」(业务 2026-10-29 点名要求) // ⚠️ 查不到不阻断页面 —— 判据退化成「不判作废」,由停更告警兜底提示不可信 let ioStatusMap = new Map(); let ioDegraded = false; try { ioStatusMap = await loadOrderIoStatus(stateRows); } catch (e) { ioDegraded = true; console.warn('[WST] 销售出库单查询失败,作废判据降级:', e.message); } // ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ────────── // 🔥 2026-10-28 18:35 实测:`jst_Orders` 停更在 14:40(4 小时), // `jst_qmcust_orderlist` 正常到 17:30。那批「未发 546 单」里 540 单来自 // 停更的表 —— 它们的 send_date 全空纯粹是没同步,业务说「基本都发了」是对的。 // 只取候选行的 MAX(updated_at) 会被"碰巧新"的行掩盖,所以直接查源表。 let sourceFreshness = []; try { sourceFreshness = await loadSourceFreshness(round, now); } catch (e) { // 查不到新鲜度不阻断页面,但要在日志里留痕(页面会退化成"不标注") console.warn('[WST] 数据源新鲜度查询失败:', e.message); } const sourceMap = new Map(sourceFreshness.map((s) => [s.key, s])); // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) let voidCount = 0; // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账 let carrierSkipped = 0; // 订单表 send_date 为空、但状态表 send_time 有值(漏回写)→ 已发,剔除 let stateShippedSkipped = 0; // 状态表 `order_status_name='已取消'` → 订单已作废,剔除(订单表停更时唯一可靠来源) let stateVoidSkipped = 0; // 出库单全部为 `Delete` → 单据作废,剔除(2026-10-29 新增,业务点名的作废单) let ioVoidSkipped = 0; // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) let unknownWarehouse = 0; // 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信 let sourceStaleUnshipped = 0; // 按来源表分开计数(提示里要说「jst_Orders 540 单、qmcust 6 单」,不能只说「这张表」) const sourceStaleBySrc = {}; // ④ 分桶 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 stateInfo = stateInfoMap.get(Number(row.o_id)); // 状态镜像表里的聚水潭终态(订单表停更时唯一可信的「这单还发不发」) const stateOrderStatus = stateInfo ? (stateInfo.orderStatus || '') : ''; const stateLogisticStatus = stateInfo ? (stateInfo.logisticStatus || '') : ''; // 销售出库单状态(2026-10-29 新增)—— 判「单据作废」的唯一可靠来源 const ioInfo = ioStatusMap.get(Number(row.o_id)) || null; const ioStatus = ioInfo ? (ioInfo.status || '') : ''; const ioAllDeleted = Boolean(ioInfo && ioInfo.allDeleted); const oaConfirmAt = stateInfo ? (stateInfo.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); // ══ 终态校验(2026-10-29 新增,**必须排在窗口判断和「不计考核」之前**)════ // 🔥 背景:业务第三次报「作废的、已经发货的还在考核之内」。根因不是判定写错, // 而是**订单两表各自停更**:当天 qmcust 停在 07:25(6 小时),停更之后在 // 聚水潭做的「取消 / 发货」,订单表里完全没反映 —— status 仍是 Delivering、 // send_date 仍是空,于是被当成真超时。13:00 轮 159 单**全部**来自停更的 qmcust。 // // 状态镜像表虽也停(当天 11:52),但比订单表新鲜 4 小时,`order_status_name` // 已经是聚水潭的订单终态。实测这 159 单里 11 单 `order_status_name='已取消'`, // 而这 11 单在订单表 `labels` 里**根本没有**「发货前售后」—— // 原来的 `presaleVoid` 判据(订单表 labels 含关键词)在这批数据上 0 命中。 // // ⚠️ **判据只用 `order_status_name`**,别用 `aftersale_status_name`: // · `order_status_name='已取消'` → 订单真的取消了 → 不考核 // · `aftersale_status_name='已作废'` → **售后单被撤销,订单照常发货** → 仍要考核 // (近 2 天 1387 单全是这种,混为一谈会把该考核的单全放跑) // // 顺序:**先判取消、再判已发**。发了又被取消的单,两边都不该考核仓库, // 但归到「作废」比归到「已发」更贴合业务口径(业务对账看的是作废量)。 if (stateOrderStatus === STATE_ORDER_VOID_STATUS) { voidCount += 1; stateVoidSkipped += 1; continue; } // ── 作废②:销售出库单整体作废(2026-10-29 新增,业务点名的那批单)────────── // 🔥 业务原话:「9992097 / 9992098 / 9992924 … 洛宁仓 16 单,这些订单都是作废订单, // 为啥还在考核之内」,并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。 // // 聚水潭的作废落在**出库单**上(`status='Delete'`),而不是订单表的 labels。 // 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`** —— // 订单表和状态表都可能抓不到,只有出库单能判出来。 // // ⚠️ **必须「该 o_id 下没有任何非 Delete 出库单」才算作废**: // 拆包 / 重开的单会有多张出库单,删掉旧的那张、新的还在 WaitConfirm —— 照常要发。 // 实测近 7 天 51 个「订单发货中但出库单被删」的单里,7 个挂着 2~8 张全 Delete 的出库单。 // ⚠️ **「没有出库单」绝不能判作废**:近 3 天未发单 82%(1467/1790)压根没出库单。 // // 🔴 **只对「发货中」生效,异常单/待审核单不碰**(判据放在 here、held 判断之前用 // `statusRaw === ORDER_PENDING_STATUS` 卡住)。原因:实测全库「出库单全 Delete」 // 的候选单里 **127/131 状态表是「异常」**,那批单在聚水潭还挂着待处理, // 出库单被删更可能是「重新建单」,不是作废 —— 当成作废剔掉会让业务 // 在「不计考核」里也看不到它们,是**丢可见性**,比漏剔更糟。 if (ioAllDeleted && statusRaw === ORDER_PENDING_STATUS) { voidCount += 1; ioVoidSkipped += 1; continue; } // ── 已发:三条任一成立即算(状态表比订单表可信得多)──────────────────── // ① `send_time` 有值 —— 订单表 send_date 漏回写时的兜底 // (2026-10-28 实测 144 单里 6 单是这种) // ② `order_status_name='已发货'` —— 聚水潭订单终态(近 2 天 15.4 万单) // ③ `logistic_status_name` ∈ 已揽收 / 运输中 / 派送中 / 已签收 / 拒收 // —— 快递已经把货拿走了。`拒收` 也算:仓库的发货动作已完成。 // ⚠️ **`未揽收` / `无物流单` 都不算**:前者只是打了单快递没来取, // 后者连快递单都没录(当天 39/159 单是这种),都是真未发。 // ⚠️ 也别拿 `logistic_time` 当发货时间 —— 它多数是「快递员将上门取件」的预告。 const shippedByState = (stateInfo && stateInfo.sendTime) || stateOrderStatus === STATE_ORDER_SHIPPED_STATUS || STATE_LOGISTIC_SHIPPED_STATUSES.includes(stateLogisticStatus); if (shippedByState) { stateShippedSkipped += 1; continue; } // ── 本轮核验范围(窗口上界按**起算点**卡)────────────────────────────── // 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 || ''), // 该单来自哪张订单表、这张表是否已经停更 ──────────────────────────── // 🔥 停更 ⟹ 停更之后发出的单在这里仍是 send_date 空 ⟹ 被判「未发」, // 但真实情况很可能是已经发了。所以**不能静默当确认未发**, // 页面要标出来(2026-10-28 18:00 轮 546 单里 540 单是这种)。 srcLabel: (sourceMap.get(String(row.src || '')) || {}).label || String(row.src || ''), sourceStale: Boolean((sourceMap.get(String(row.src || '')) || {}).stale), createdAt, oaConfirmAt, payAt, // 状态镜像表里的聚水潭终态 —— 订单表停更时,页面靠它告诉业务「这单其实取消了/发了」 stateOrderStatus, stateLogisticStatus, stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '', stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '', // 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库) // 业务核对时直接对照聚水潭「销售出库单」页面 ioStatus, ioCount: ioInfo ? ioInfo.count : 0, // 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算 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; if (base.sourceStale) { sourceStaleUnshipped += 1; sourceStaleBySrc[base.srcLabel] = (sourceStaleBySrc[base.srcLabel] || 0) + 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, // 聚水潭侧终态(来自状态镜像表):订单表停更时,这一列才是真的 // 业务核对时看这个 —— 「已取消」本该被剔除,出现在明细里就说明状态表也没抓到 stateOrderStatus: x.stateOrderStatus, stateLogisticStatus: x.stateLogisticStatus, stateQueryTime: x.stateQueryTime, // 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库) // 🔥 业务在聚水潭「销售出库单」页面看到的作废,就对应这里的 Delete ioStatus: x.ioStatus, ioCount: x.ioCount, // 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记 src: x.src, srcLabel: x.srcLabel, sourceStale: x.sourceStale, // 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单 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; // 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效) summary.stateVoidSkipped = stateVoidSkipped; // 由**销售出库单整体 Delete** 剔除的作废单量(业务 2026-10-29 点名的作废单) summary.ioVoidSkipped = ioVoidSkipped; summary.sourceStaleUnshipped = sourceStaleUnshipped; 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; // 停更判定要**两个口径取或**: // ① 候选行的 MAX(updated_at) 太旧(老口径,只能反映"候选池里最新的那条") // ② 任一订单表自己停更(新口径,直接问源表 —— 候选行会骗人,见 SOURCE_STALE_MINUTES 注释) const staleSources = sourceFreshness.filter((s) => s.stale); const stale = (snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES) || staleSources.length > 0; // 停更源的一句话描述:「jst_Orders 停止同步于 14:40(已 4.3 小时)」 const staleSrcText = staleSources .map((s) => `${s.label} 停止同步于 ${String(s.lastUpdate || '').slice(11, 16) || '—'}` + `(已 ${Math.round((s.lagMinutes || 0) / 6) / 10} 小时)`) .join('、'); // 停更源各贡献了多少超时单(540 来自 jst_Orders、6 来自 qmcust 这种要说清,别只写「这张表」) const staleBySrcText = Object.entries(sourceStaleBySrc) .map(([k, v]) => `${k} ${v} 单`) .join('、'); // 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 // 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; // 🔴 出库单/状态表停更 ⟹ **「这单作废了吗」这件事本身查不出来**(2026-10-29 新增) // 业务当天点名 9992097 等 9 单「都是作废订单,为啥还在考核之内」: // 聚水潭里已经作废,但我们库里订单表还是 Delivering、出库单还是 WaitConfirm、 // 状态表还是「发货中」—— 三个来源全停在作废动作**之前**,所以判据再准也判不出来。 // ⟹ 这种情况必须**明说判据失效**,不能让业务以为「系统认定这单没作废」。 const voidJudgeStale = ['jst_order_express_details', 'jst_salesOutboundInfo', 'jst_qmcust_salesoutbound'] .map((k) => sourceMap.get(k)) .filter((s) => s && s.stale); const freshness = { stateMaxQuery: snapshotAt, ageMinutes: snapshotAgeMin, stale, coversDeadline, // 数据源已经换成订单表,文案跟着改(别再写「状态镜像」) source: 'jst_Orders', // 作废/取消判据是否可信(false = 出库单或状态表停更,作废单可能没剔干净) voidJudgeReliable: voidJudgeStale.length === 0, hint: [ // 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事 staleSrcText ? `数据源停更:${staleSrcText}。` : '', sourceStaleUnshipped > 0 ? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):` + '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。' : '', voidJudgeStale.length > 0 ? `⚠️ ${voidJudgeStale.map((s) => s.label).join('、')} 已停止同步,` + '作废单 / 取消单可能判不出来(聚水潭里的作废动作还没同步到本系统),' + '下面凡是显示「发货中 · 无物流单」的单,请先在聚水潭「销售出库单」里核对是否已作废。' : '', !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, // 销售出库单查询失败 ⟹ 作废判据降级(页面据此提示「作废单可能没剔干净」) ioDegraded: ioDegraded, // 仓级例外窗口(山东青岗集/山东品牌 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, // 两个订单表各自的同步新鲜度(页面据此出红条告警) dataSources: sourceFreshness, 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, // 导出也要能说明「这批单里有多少来自停更的源」,否则导出的 Excel 脱离页面就没了上下文 dataSources: payload.dataSources || [], }; } module.exports = { getShipTimeliness, listShipTimelinessExportRows, clearShipTimelinessCache, DEFAULT_DETAIL_LIMIT, MAX_DETAIL_LIMIT, // 钉钉推送正文要列「剔除了哪几个快递」,直接复用服务层的清单,别在 job 里再抄一份 EXCLUDED_CARRIERS, _private: { EXCLUDED_CARRIERS, buildShipTimelinessExportRows, withDetailLimit, }, };