业务反馈 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 一处即可恢复。
803 lines
39 KiB
JavaScript
803 lines
39 KiB
JavaScript
'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,
|
||
};
|