forked from lunanting/cangku
## 业务问题
业务举证: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>` 即回到只认状态表审核时间的版本。
989 lines
48 KiB
JavaScript
989 lines
48 KiB
JavaScript
'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,
|
||
},
|
||
};
|