cangku/backend/src/services/warehouseShipTimelinessService.js
yanhui 4e4f8890c1 fix(wst): 发货时效剔除「已作废」单 —— 问题单 / 发货前售后 / 售后已作废三条兜底
业务反馈 3 单(so_id 3655421011854634 / 3658479011191285 / 110215007857459363)
在聚水潭后台都已作废,却仍出现在「仓储中心 24 小时发货时效」的超时明细里。

根因:
- 页面只认状态表 jst_order_express_details,这三单 order_status_name 仍是「发货中」
  + send_time 为空;售后状态是「暂无售后」—— 上游没把作废同步过来;
- 现有的作废兜底只认订单表 status ∈ Merged/Split/Cancelled 以及 is_merge/is_split,
  覆盖不到 status=Question(聚水潭后台显示「已作废/问题单」)和 labels 含「发货前售后」。

改动:
- registry 新增 VOID_QUESTION_STATUS='Question'、VOID_ORDER_LABEL_KEYWORDS=['发货前售后']、
  AFTERSALE_VOID_STATUS='已作废';
- 回订单表补事实时增投一个 `CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END`
  布尔列(不传 labels 原文,避免宽列回表;实测冷查 42.6s,与改前同量级);
- 状态表主查询增投一列 aftersale_status_name;
- 主循环在「发货中」分支之后判这三种作废证据,命中则并入 voidCount、不进考核。

⚠️ Question 不能直接塞进 VOID_ORDER_STATUS:那个数组在状态分类之前判定,
订单表 Question 的单有一大批在状态表是「异常」,会被从「异常待处理」清单里一起吞掉
(实测 heldCount 472 → 10,少掉 462 单)。故单独放在 SHIPPING_STATUS 之后判。

验证:
- 单测 19/19 通过(新增 38/39/40 三条作废形态用例,锁定 heldCount 不被影响);
- 真实库 13:00 轮:超时 3 单 → 1 单(9823980 / 9880810 已剔除),
  heldCount 保持 472,voidCount 4442 → 4444(恰好 +2);
- 剩余 9889575 未剔除:订单表 Delivering、无出库单、无任何作废标记(见 PR 正文说明)。

风险与回退:
- 仅收紧「发货中」分支的作废判定,不回退任何既有判据;
- 若业务认为 Question 单仍需考核,改 VOID_QUESTION_STATUS 一处即可恢复。
2026-10-22 17:54:34 +08:00

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