'use strict'; /** * 仓储中心 24 小时发货时效 —— 服务层 * * ═══════════════════════════════════════════════════════════════════════════ * 口径(2026-10-20 第九次修正,业务最终定版) * ═══════════════════════════════════════════════════════════════════════════ * 业务原话(按时间顺序,最后一条为准): * 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」 * 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验; * 然后再看历史未发的订单」 * * ⟹ 三条铁律: * ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值): * 今天 00:00~07:00 审核 → 13:00 轮 * **截止今天 16:00 前审核**的单 → 18:00 轮 * (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单, * 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、 * 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的; * 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry) * ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06) * 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的, * 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。 * ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到 * (它们早就该发了,不该等今天的分段窗口)。 * ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。 * 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。 * ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充): * 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」), * 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。 * * 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。 * 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)—— * 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。 * 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致: * 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。 * 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮, * 已等自然用 18:00 算,不会出现「0 分」。 * 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**, * 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。 * * 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。 * **不要**改用运单号(logistic_id)判已发:实测同一店铺里, * 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。 * 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。 * * 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行) * * 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`): * · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**, * mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。 * · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**, * 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。 * · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。 * **不要**去改容器 TZ —— 会波及全部既有定时任务。 * * · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值; * 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。 * · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created` * 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补: * · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用** * · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒) * · 已发货 = `send_time` 非空(已发货单 100% 有值) * · 待发货 = `send_time` 为空 且 `order_status_name = 发货中` * · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓) * * ── 为什么弃用 jst_Orders.status(前三版全栽在这)────────────────────────── * 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、 * 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单: * 订单表 status 全是 `WaitConfirm`(看起来像待发货), * 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。 * ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。 * * 时间列语义(已逐一实测,别再猜): * · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间) * · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒) * · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟) * · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用 * · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退) * · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)** * · `send_time` —— **发货时间** * · `query_time` —— 本表快照时间(判数据新鲜度) * * ── 三态分流(互斥)───────────────────────────────────────────────────── * 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00) * → orders(考核仓库) * 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders * 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃 * 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍, * 不排除的话整页都是客户取消掉的单。 * * ── 核验轮次(业务给定)────────────────────────────────────────────────── * `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它 * `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry) * ⚠️ 两者不是同一个东西,别合成一个字段 * ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回) * ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」 * 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 → * 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就 * 只该有早上那批,不是数据不对。 * * ── 性能(2026-10-20 实测,四个坑都踩过)────────────────────────────────── * ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含 * `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做 * 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。 * ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测 * **38.7s**。发货时效不需要金额,直接不做。 * (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。) * ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。 * 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。 * ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。 * ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。 * * ── 时区 ───────────────────────────────────────────────────────────────── * 所有时间列都是**东八区本地时间**;服务与库都在东八区。 * **不要**在 Node 侧用 UTC 日期判断。 */ const cloudDatabase = require('../config/cloudDatabase'); const registry = require('./warehouseShipTimelinessRegistry'); const { ORDER_STATE_TABLE, SHIPPING_STATUS, UNAUDITED_STATUS, ABNORMAL_STATUS, IGNORED_STATES, 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; // 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。 // 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。 const STATE_STALE_MINUTES = 90; /** * 不参与发货时效考核的快递(业务 2026-10-20 指定): * 自提 —— 客户自己来拉,仓库不负责派送 * 中铁 —— 中铁物流(大件专线) * 壹米滴答 —— 专线物流(大件) * 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。 * 只影响「超时未发」判定,不影响「已发」判定。 */ const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答']; /** * 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。 * * 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」—— * 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写 * (实测 103 单里 103 单都是空),所以必须把 status 也当依据。 * ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」, * 正是本页要考核的对象;算进来整页就空了。 */ const ORDER_SHIPPED_STATUS = ['Sent']; /** * 结果缓存。 * * 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是 * `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库, * 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」, * 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。 */ const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000; let resultCache = { key: '', at: 0, data: null }; /** 清缓存(切轮次 / 手工排查时用) */ function clearShipTimelinessCache() { resultCache = { key: '', at: 0, data: null }; } /** 把 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); } /** 两个时间文本相差多少分钟(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); } /** * 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。 * * 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。 * 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。 * * ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本 * 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。 * ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。 * * 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。 * 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选; * 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来 * 判断窗口归属,不再参与「等了多少小时」的门槛。 */ async function loadPendingStates(round) { const rows = await cloudDatabase.querySlow( // 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串: // CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+; // 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.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 FROM dbo.${ORDER_STATE_TABLE} e WHERE e.send_time IS NULL AND e.pay_time >= @windowStart AND e.order_status_name IN (@shipStatus, @st1, @st2)`, { shipStatus: SHIPPING_STATUS, st1: UNAUDITED_STATUS, st2: ABNORMAL_STATUS, windowStart: round.windowStart, }, // 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s) { connectRequestTimeoutMs: 300000 } ); return rows || []; } /** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */ async function mapLimit(items, limit, worker) { const out = []; let cursor = 0; const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { for (;;) { const i = cursor; cursor += 1; if (i >= items.length) return; out[i] = await worker(items[i], i); } }); await Promise.all(runners); return out; } /** * 回订单表补「起算点 + 作废事实」。 * * 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库 * 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取 * `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。 * * 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时): * 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了, * 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的 * `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。 * * ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s, * 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。 * * @param {number[]} oids 内部单号 * @returns {Promise>} */ async function backfillOrderFacts(oids) { const map = new Map(); if (!oids.length) return map; let remaining = oids; for (const table of CREATED_SOURCE_TABLES) { if (!remaining.length) break; const batches = []; for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) { batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH)); } const results = await mapLimit(batches, 4, async (batch) => { try { return await cloudDatabase.querySlow( `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 } ); } catch (e) { // 补不到不致命:调用方会回退到付款时间,不整页失败 return []; } }); for (const rows of results) { for (const r of rows || []) { const id = Number(r.o_id); if (!Number.isFinite(id)) continue; const status = String(r.status || ''); const sendDate = formatDateTime(r.send_date); map.set(id, { 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), }); } } remaining = remaining.filter((n) => !map.has(n)); } return map; } function emptySummary() { return { // 超时未发货(考核仓库的部分) overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, // 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任 heldCount: 0, // 已剔除的作废 / 已发单(页面只作说明,不进考核) voidCount: 0, // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答) carrierSkipped: 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(); const limit = Math.min( Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1), MAX_DETAIL_LIMIT ); // ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词) const centerList = await registry.loadCenterWarehouseMap(); 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: limit, detailDegraded: false, freshness: null, note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`, }; } // ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时 // 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用 const force = options.force === true || ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase()); const cacheKey = `${round.key}|${round.date}|${limit}`; if (!force && resultCache.data && resultCache.key === cacheKey && Date.now() - resultCache.at < CACHE_TTL_MS) { return { ...resultCache.data, cached: true, cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000), }; } let stateRows = []; try { stateRows = await loadPendingStates(round); } catch (e) { const err = new Error(`聚水潭订单状态表查询失败:${e.message}`); err.statusCode = 502; throw err; } // ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取) const oids = [...new Set( stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n)) )]; const orderFacts = await backfillOrderFacts(oids); // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) let voidCount = 0; // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账 let carrierSkipped = 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) { // 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表) const qt = formatDateTime(row.query_time); if (qt > snapshotAt) snapshotAt = qt; const center = resolveCenter(row.warehouse_name); if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…) const bucket = acc.get(center.key); if (!bucket) continue; const state = String(row.order_status_name || ''); // 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。 // 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023: // 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、 // 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。 const fact = orderFacts.get(Number(row.inner_order_id)); if (fact && (fact.void || fact.shipped)) { voidCount += 1; continue; } // SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉 if (IGNORED_STATES.includes(state)) continue; // 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 —— // 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、 // 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。 // 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。 const carrier = String(row.logistic_company_name || '').trim(); const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k)); if (hitCarrier) { carrierSkipped += 1; continue; } // ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)──── // 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」 // 实测案例(业务亲自指出的两单): // · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现 // · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮 // ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮, // 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。 // ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。 // ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。 const createdAt = fact ? fact.createdAt : ''; const payAt = formatDateTime(row.pay_time); const auditFirst = formatDateTime(row.first_oa_confirm_time); const auditLast = formatDateTime(row.last_oa_confirm_time); // 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。 // 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」, // 根因就是我们之前取错了列(first_oa = 首次审核): // 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38 // 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31 // 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11 // ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。 const auditAt = auditLast || auditFirst; const windowAt = auditAt || createdAt || payAt; const benchmarkAt = windowAt; const benchmarkSource = auditAt ? (auditLast ? 'last_audit' : 'first_audit') : (createdAt ? 'created' : 'pay'); // 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重 const dedupeKey = `${row.so_id}|${windowAt}`; if (seenSo.has(dedupeKey)) continue; seenSo.add(dedupeKey); // ── 本轮核验范围(业务 2026-10-20 最终定版)──────────────────────────── // ① **今天的单按审核时间分段**: // 今天 00:00 ~ 07:00 审核 → 13:00 轮核验 // **截止今天 16:00 前审核**的单 → 18:00 轮核验 // (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。 // 山东两仓 15:00、河南洛宁 13:00 为仓级例外) // ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476) // **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单) // ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时, // 两个轮次都该看到,不受「今天 00:00 起」的限制。 // ③ 30 天护栏只为查询性能,不是业务边界。 const windowEndAt = registry.centerWindowEnd(center.key, round); const todayStart = `${round.date} 00:00:00`; if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏 if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue; const base = { row, center, state, createdAt, payAt, auditAt, windowAt, benchmarkAt, benchmarkSource, warehouseName: String(row.warehouse_name || ''), }; // ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ────── if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) { bucket.heldCount += 1; heldRows.push({ ...base, holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常', }); continue; } 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; heldRows.push({ ...base, holdReason: '缺审核时间' }); continue; } // waited = 审核时间 → 本轮核验时刻(13:00 / 18:00) // ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline, // 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮: // 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。 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; }); const detailRows = pendingRows.slice(0, limit); const heldDetail = heldRows.slice(0, limit); const orders = detailRows.map((x) => { const r = x.row; return { oId: String(r.inner_order_id || ''), soId: String(r.so_id || ''), shopName: String(r.shop_name || ''), // 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准) createdAt: x.createdAt, // 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」 sendTime: formatDateTime(r.send_time), // 付款时间(并列展示,方便对账) payTime: x.payAt, // 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算 auditTime: x.auditAt, // 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记 benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, logisticStatus: String(r.logistic_status_name || ''), // 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。 // 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。 logisticId: String(r.logistic_id || '').trim(), logisticCompany: String(r.logistic_company_name || '').trim(), 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 r = x.row; const from = x.benchmarkAt || ''; return { oId: String(r.inner_order_id || ''), soId: String(r.so_id || ''), shopName: String(r.shop_name || ''), createdAt: x.createdAt, sendTime: formatDateTime(r.send_time), payTime: x.payAt, auditTime: x.auditAt, benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, holdReason: x.holdReason, logisticStatus: String(r.logistic_status_name || ''), logisticId: String(r.logistic_id || '').trim(), logisticCompany: String(r.logistic_company_name || '').trim(), 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; // 新鲜度:快照时间取自上面主查询的 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-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」, // 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; const freshness = { stateMaxQuery: snapshotAt, ageMinutes: snapshotAgeMin, stale, coversDeadline, 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='audit':轮次归属、已等时长都按**首次审核时间**算 window: { start: round.windowStart, end: round.windowEnd, includeHistory: true, boundary: 'audit', measuredBy: 'audit', // 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮) overrides: windowOverrides, }, summary, centers, orders, // 不考核的快递(前端文案直接用,避免两边各写一份) excludedCarriers: EXCLUDED_CARRIERS, // 不计考核清单(待审核 / 异常)—— 与 orders 互斥 heldOrders, truncated: pendingRows.length > orders.length, detailLimit: limit, detailDegraded: false, freshness, cached: false, cacheAgeSec: 0, note: '', }; resultCache = { key: cacheKey, at: Date.now(), data: payload }; return payload; } module.exports = { getShipTimeliness, clearShipTimelinessCache, DEFAULT_DETAIL_LIMIT, MAX_DETAIL_LIMIT, };