forked from lunanting/cangku
业务 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 修同步或业务在聚水潭复核。
1401 lines
72 KiB
JavaScript
1401 lines
72 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,
|
||
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,
|
||
},
|
||
};
|