cangku/backend/src/services/warehouseShipTimelinessService.js
yanhui 983fee0d8a fix(wst): 起算点改为「审核时间 / 订单 modified 取较晚者」,修 13:00 轮 65 单假超时
## 业务问题

业务举证:13:00 轮不该有那么多单 —— 「9 点前的单 13:00 前已经发完了」。
线上实测 65 单,逐单核对全部不该在 13:00 轮。

## 根因(两个,都指向「起算点取错」)

### ① 状态表的审核时间会严重滞后

业务给的举证单 9978643:

| 字段 | 值 |
|---|---|
| `qmcust.modified` | **2026-10-28 14:52:29** |
| 聚水潭页面真实审核时间 | **2026-10-28 14:52:28**(秒级吻合) |
| 状态表 `last_oa_confirm_time` | 2026-10-27 17:08:43(旧了一天) |
| 状态表 `query_time` | 2026-10-28 10:29:11(10:29 后没再抓) |

只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时,冤枉仓库。

### ② 审核时间为空的单回退 created,把今天的单按历史单算

65 单里 57 单状态表审核时间为 NULL。回退 `created` 后:

| o_id | created | modified | 回退后算成 | 真实 |
|---|---|---|---|---|
| 9964841 | 10-26 17:22 | **10-28 13:40** | 10-26 审核 → 进 13:00 轮 | 今天下午才审核 |
| 9949424 | 10-25 17:03 | **10-28 13:50** | 同上 | 同上 |
| 9983773 | 10-27 23:23 | **10-28 13:59** | 同上 | 同上 |

57 单的 `modified` 全部落在今天 13:40~15:03 —— 都是今天下午批量审核的。

## 改动

- **起算点** = `max(状态表 last_oa_confirm_time, 订单表 modified, 订单表 created)`
  - 取较晚者:修状态表滞后
  - 纳入 created:个别单 `modified` 早于 `created`(脏数据),起算点不该早于下单时刻
  - `benchmarkSource` 透出 `oa_confirm` / `modified` / `created`,页面 tooltip 说明来源
- **新增 `laterOf()`**:同格式 `YYYY-MM-DD HH:mm:ss` 直接字典序比较,不转 Date(避开时区坑)
- **主查询 SQL 增加 `x.modified`** 列(订单两表都有,无需额外查询)

## 性能修复(同批,线上 /list 曾 300s 超时)

上版状态表查询**串行 9 批实测 135s**,叠加主查询 63~105s 直接把接口顶过 300s,
页面打不开(实测 curl `--max-time 300` 超时)。改为**并发 6**:

| 方式 | 耗时 |
|---|---|
| 串行 9 批 | 135s |
| 并发 3 | 28.8s |
| **并发 6(本版)** | **10~15s** |

并发 6 是生产实测的安全值(单批约 15s,不至于把 402 万行视图的连接池打爆)。

## 改后效果(10-28 17:00 真实库)

| 轮次 | 改前 | 改后 |
|---|---|---|
| 13:00 轮 | 65 单 | **0 单** |
| 18:00 轮 | — | 927 单(全部今天 07:59~16:00 审核,无历史单) |

- `fallbackToCreated` 从 57 → **0**(不再有回退单)
- 9978643 ✅ 已剔除
- 两个轮次都验证「今天窗口上界之后起算的 = 0」

## 验证

- 后端 `npm run check`:**493/495**(2 个失败是既有环境问题:TTL 计时、Windows 权限,改动前同样失败)
- 新增 2 个测试锁定该口径:
  - `modified` 晚于窗口 → 必须剔除(9978643 那类单)
  - 状态表审核时间晚于 `modified` → 以状态表为准
  - `modified` 早于 `created` → 取 `created`,不倒退
- 前端 lint + build 通过(35.2s)
- 真实库双轮验证通过

## 风险与回退

- **`modified` 被客服改单/改备注刷新**时起算点会推后 → 少报超时(放行方向,不冤枉仓库)。
  业务已知悉并接受;这是当前唯一能拿到「今天真实审核时间」的可靠字段。
- 回退方式:`git revert <merge commit>` 即回到只认状态表审核时间的版本。
2026-10-28 17:02:54 +08:00

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