cangku/backend/src/services/warehouseShipTimelinessService.js
yanhui 54518ab4d3 fix(wst): 把销售出库单纳入作废判据与停更监控
业务 14:34 点名 9992097 / 9992098 / 9992924 / 9992925 / 9992926 /
9993284 / 9993285 / 9995464 / 9995465「洛宁仓 16 单都是作废订单,为啥还在
考核之内」,并指明「在聚水潭的销售出库单里面找一下这个作废单据」。

## 查出来的事实

这 9 单在库里**三处全部显示「还在发货」**:

  订单表 qmcust      status=Delivering、send_date 空
  销售出库单         status=WaitConfirm(待出库)
  状态镜像表         order_status_name=发货中、logistics=无物流单

不是判据漏了,是**三条数据源全停在作废动作之前**(2026-10-29 15:13 实测):

  jst_Orders             14:45  正常
  qmcust                 13:25  滞后 1.8h
  状态镜像表              11:52  滞后 3.4h
  出库单(聚水潭/奇门)      03:10  滞后 12.0h  ← 作废就落在这张表上

聚水潭的作废落在**出库单** `status='Delete'` 上,而这张表今天凌晨 3 点后
零写入,所以上午作的废一条都没进来

## 改动

1. 新增 `loadOrderIoStatus()`:按 o_id 批量取出库单状态(两表、
   分批 500 / 并发 3),判据是 **「该 o_id 下没有任何非 Delete 的出库单」**。
   - 只对「发货中」生效:全库「出库单全 Delete」的候选单里 127/131
     状态表是「异常」,那批还挂着待处理,删出库单更可能是重开单,
     当成作废剔会让业务在「不计考核」里也看不到 → 丢可见性比漏剔更糟
   - 拆包/重开的单有多张出库单,删一张不算作废(实测 51 单里 7 单是这种)
   - **「没有出库单」绝不判作废**:近 3 天未发单 82%(1467/1790)本来就没出库单
2. 两张出库单表纳入 `loadSourceFreshness()`(用 local_updated / updateTime
   灌库时刻,**不能用 modified**,否则没变更时天天误报)
3. 新增 `freshness.voidJudgeReliable` + hint 明说「作废单/取消单可能判不出来」,
   告诉业务去聚水潭「销售出库单」核对
4. 明细加「销售出库单」列(已作废/待出库/已出库/未建单),导出同步加列

## 验证

- WST 单测 27/27(新增:出库单全删必剔 / 拆包重开不剔 / 异常单不剔 /
  无出库单不剔 / 停更时提示作废判不出来 / 源正常时不误报)
- 后端全量 498/500(2 个既有失败:负缓存 TTL、Windows 文件权限)
- 真实数据:ioVoidSkipped=11(发货中 + 出库单全删),overdue 仍 16 单
- 本地真渲染:表头有「销售出库单」、首行显示「待出库」、
  红条列出 5 个数据源停更状态、**pageerror 0**

## 没解决(已在页面明说,不掩盖)

这 9 单今天仍然判不出作废 —— 数据没进来,判据再准也没用。
根子在 ETL:出库单表停 12 小时。ETL 恢复后判据立即生效,
但那 9 单是否作废仍需 IT 修同步或业务在聚水潭复核。
2026-10-29 15:16:25 +08:00

1401 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<last 24 单(最长推后 5.8 天)。
* ⚠️ 这意味着**重复审核会把已累计的超时冲掉**,业务已知悉并接受。
*
* 审核时间为空(实测 599 单里 209 单,多为天猫/抖音/拼多多,136 单其实已打单)
* → **回退 order created** 参与分轮与计时,`benchmarkSource='created'`,
* 页面该列打 `*` 标注,钉钉/导出同步标注。别默默丢掉这些单。
*
* ── 三条铁律(不变)──────────────────────────────────────────────────────
* ① 轮次归属看起算点:今天 07:00 前**审核** → 13:00 轮;
* 截止今天 16:00 前**审核** → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。
* **历史未发单一律算**(昨天及更早审核、至今没发的,两个轮次都要看到)。
* ② 没有「满 24 小时」门槛:到核验时刻仍未发就是超时。
* ③ 自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核(按快递名包含匹配,
* 武进安能只剔这一个网点,**别泛化成「安能」**,常州安能新等仍考核),
* 剔除量记 summary.carrierSkipped。
*
* 已等时长 = 审校起算点 → 本轮核验时刻(13:00 / 18:00)。
*
* ── 订单表字段口径(已逐一实测)───────────────────────────────────────────
* · created —— 聚水潭订单创建时间(**只做兜底与展示**,不是起算点)
* · order_date —— 平台下单时间(客户侧,比 created 早 15~30 秒)
* · pay_date —— 付款时间(并列展示)
* · send_date —— 发货时间(**未发单必为空**,已发 100% 有值)
* · status —— 聚水潭**实时状态**:Delivering 发货中 / WaitConfirm 待审核 /
* Question 异常 / Sent 已发货 / Cancelled 已取消 /
* Split 拆单父单 / Merged 被合并 / WaitOuterSent 等外仓
* · wms_co_id —— ✅ **发货仓**(join jst_warehouseInfo 取名字,比状态表的仓名准)
* · logistics_company —— 快递公司(不考核快递就按它匹配)
* · l_id —— 运单号(有值 = 仓库已打单但还没回传发货时间)
* · labels —— 标签长文本(只在 SQL 里算成 presaleVoid 布尔,不外传)
* · updateTime / local_updated —— 数仓灌库时间(判数据新鲜度用)
* · modified —— ⚠️ 每天凌晨被批量刷新,**不可用**
*
* ── 三态分流(互斥,全部由订单表 status 决定)─────────────────────────────
* 超时未发货 = status='Delivering' + send_date 空 + 审核时间已过窗口上界 → orders
* 不计考核 = status='WaitConfirm'(待审核)/ 'Question'(异常) → heldOrders
* 不展示 = Sent / Cancelled / Split / Merged / WaitOuterSent → SQL 侧已排除
* ⚠️ 作废(Cancelled,业务叫「作废单」)近 30 天 66643 单,是 Delivering 1446 单的 46 倍,
* 漏排除整页都是客户取消掉的单。
*
* ── 性能(2026-10-28 实测,两个新坑)──────────────────────────────────────
* ① 🔥 **必须带 OPTION (RECOMPILE)**:同一条 qmcust 查询,
* 不加 → 33~50s(参数嗅探选错执行计划);加 → 0.4s。
* ② 时间条件拼**字面量**(服务端自己算的 YYYY-MM-DD HH:mm:ss,经正则校验),
* 字面量 + RECOMPILE 两个措施一起上才稳。
* ③ 只投影窄列,labels 在 SQL 里算成布尔;SELECT * 更是灾难。
* ④ 走 querySlow,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
* 实测总耗时:jst_Orders 1.8s + qmcust 0.4s + 仓表 join ≈ 3s(旧版 39~60s)。
*
* ── 时区(2026-10-21 生产实测踩坑,别再裸用 new Date())──────────────────
* · 生产容器 /etc/timezone = Etc/UTC,而 MSSQL GETDATE() 返回**北京时间**,
* mssql 驱动 useUTC:false 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**。
* · 统一用 registry.beijingNow() 取「现在」。**不要**去改容器 TZ。
* · 所有时间列都是**东八区本地时间**;**不要**在 Node 侧用 UTC 日期判断。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseShipTimelinessRegistry');
const {
ORDER_PENDING_STATUS,
ORDER_UNAUDITED_STATUS,
ORDER_QUESTION_STATUS,
ORDER_CANDIDATE_STATUSES,
VOID_ORDER_LABEL_KEYWORDS,
STATE_ORDER_VOID_STATUS,
STATE_ORDER_SHIPPED_STATUS,
STATE_LOGISTIC_SHIPPED_STATUSES,
IO_VOID_STATUS,
resolveCenter,
} = registry;
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000;
// 数据源超过这个分钟数没更新 → 页面提示「数据滞后」。
// 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。
const STATE_STALE_MINUTES = 90;
/**
* 单个**订单表数据源**的滞后阈值(分钟)。
*
* 🔥 为什么必须按源单独判(2026-10-28 业务第二次质疑「18:00 轮基本都发了」时定型):
* 订单两表是**各自独立同步**的,同一天可以一个正常一个停更 ——
* 18:35 实测:`jst_Orders` 最后更新 **14:40**(停 4 小时),
* 而 `jst_qmcust_orderlist` 更新到 **17:30**(正常)。
* 只取候选行的 `MAX(updated_at)` 会被"碰巧新"的行掩盖,必须直接问源表。
*
* 影响:该源停更 ⟹ 停更之后发出的单,`send_date` 仍是 NULL ⟹ 被判「未发货」。
* 18:00 轮 546 单里 **540 单来自 jst_Orders**,全部是这种假超时。
*
* 所以停更源的单在页面上必须**明确标注**,绝不静默当成「确认未发」。
*/
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WST_SOURCE_STALE_MINUTES) || 60;
/**
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
* 自提 —— 客户自己来拉,仓库不负责派送
* 中铁 —— 中铁物流(大件专线)
* 壹米滴答 —— 专线物流(大件)
* 武进安能 —— 加盟制快运网点,揽收与在途都不受仓库控制(业务 2026-10-27 追加)
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
* 只影响「超时未发」判定,不影响「已发」判定。
*
* 🔥 「武进安能」**必须精确**,别泛化成「安能」(与 warehouseLogisticsExceptionRegistry
* 同一口径,那里也写着这句)。实测近 30 天名字里带「安能」的有 8 个:
* 常州安能新 4207 / 武进安能 808 / 安能物流 110 / 名屋居安能快运 22 /
* 华韵安能 22 / 武进安能大件 15 / 盛强安能快运 5 / 雅柏安能 3
* 业务 2026-10-27 只要求剔除「武进安能」(含「武进安能大件」,因为包含匹配会一起命中),
* 其余安能网点(尤其「常州安能新」单量最大)**照旧考核**。
*/
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能'];
/**
* 订单表英文 status → 业务看的**中文状态**。
*
* 前端「发货状态」列、钉钉推送正文都用这份映射,别在别处再写一遍。
* 只有「发货中」进考核;待审核 / 异常进「不计考核」清单;其余在 SQL 侧就排除了。
*/
const ORDER_STATE_LABELS = Object.freeze({
Delivering: '发货中',
WaitConfirm: '待审核',
Question: '异常',
Sent: '已发货',
Cancelled: '已取消',
Split: '拆单父单',
Merged: '被合并',
WaitOuterSent: '等外仓发货',
});
/**
* 已发判定(`status = 'Sent'`)。
*
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
* 销售订单页面显示「已发货」= status `Sent`。
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
* 正是本页要考核的对象;算进来整页就空了。
*
* 2026-10-28:主查询已经直接按 `status IN ('Delivering','WaitConfirm','Question')`
* + `send_date IS NULL` 过滤,已发单在 SQL 侧就被排除,这里不再单独判一次。
*/
/**
* 结果缓存。
*
* 主查询实测 3~40 秒(取决于库负载),页面又是 `AUTO_REFRESH_MS` 定时轮询 ——
* 不缓存的话每轮轮询都会重压一次库,页面也会一直转圈。
* 缓存 key = 「轮次 + 日期」(**不含 limit**,见 withDetailLimit 的说明),
* 业务点「刷新」时前端带 `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 };
}
/**
* 明细「出口截断」:缓存里存的是**全量**行,这里才按本次请求的 limit 切。
*
* 🔥 为什么缓存不按 limit 分键(2026-10-27 改):
* 旧写法把 limit 拼进缓存键 —— 页面用 limit=1000、导出要全量 2000,
* 两边永远互不命中,每点一次「导出」都要重查一遍视图(冷启动 40~60 秒)。
* 但行数根本不影响 SQL 成本(`loadPendingStates` 一次就取回全量候选),
* 只有最后那一次 slice 受影响。所以:**缓存存全量、出口截断**。
* (WLE 的核验缓存早就这么改了,同一个教训。)
*
* `detailTotal` / `heldTotal` 是截断前的真实总数 —— 页面的「共 N 单」必须用它,
* 不能用 `orders.length`(截断后就是 limit,等于把「已截断」写成「一共就这么多」)。
*/
function withDetailLimit(payload, limit) {
const allOrders = Array.isArray(payload?.orders) ? payload.orders : [];
const allHeld = Array.isArray(payload?.heldOrders) ? payload.heldOrders : [];
const cap = Number.isFinite(limit) ? Math.max(0, limit) : allOrders.length;
return {
...payload,
orders: allOrders.slice(0, cap),
heldOrders: allHeld.slice(0, cap),
truncated: allOrders.length > 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<Map<number, {status:string, statuses:string[], allDeleted:boolean, count:number}>>}
*/
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<Array<{key:string,label:string,lastUpdate:string,lagMinutes:number|null,stale:boolean,error?:string}>>}
*/
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<object>} 字段名与前端 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,
},
};