Compare commits

..

No commits in common. "main" and "yanhui-wst-order-source-1028" have entirely different histories.

12 changed files with 151 additions and 420 deletions

View File

@ -1,7 +1,7 @@
'use strict';
/**
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-ship-timeliness/list
* query:

View File

@ -681,7 +681,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);

View File

@ -1,18 +1,17 @@
'use strict';
/**
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
* 仓储中心 24 小时发货时效 · 两轮核验完成后把超时未发单推到钉钉群
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-27 原话):
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
* 18:00 核验的订单,核验完成后也通知。」
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
* NOON 13:00 核验「今天 07:00 前创建」→ 默认 13:05 推
* AFTERNOON 18:00 核验「截止 16:00 前创建」→ 默认 18:05 推
* ⚠️ 2026-10-28 起数据源换成订单两表、起算点换成 `jst_Orders.created`
* (业务:「审核时间 jst_Orders 是这个表里面的创建时间」),
* 所以推送正文写的是「创建 HH:mm」,不再叫「审核」。
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
@ -122,18 +121,18 @@ function formatOverdue(minutes) {
}
/**
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
* 起算时间(= 订单创建时间 `jst_Orders.created`)的展示:
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
* 明细里会出现昨天 16:13 创建的单 —— 只写「创建 16:13」会被读成「今天 16:13」,
* 看着像「创建时间晚于窗口,不该算超时」,业务会对不上账。
*
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
* ⚠️ 2026-10-28 数据源切换后,本函数的入参 `auditTime` 实际是**订单创建时间**
* (服务层把 created 同时写进 auditTime / createdAt,业务口径两者等同)。
*/
function auditWhen(auditTime, roundDate) {
const text = cleanCell(auditTime);
if (!text) return '';
if (!text) return '无创建时间';
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
if (!hit) {
const clock = text.match(/(\d{2}:\d{2})/);
@ -145,27 +144,22 @@ function auditWhen(auditTime, roundDate) {
}
/**
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
* 明细一行:仓|内部单号|线上单号|快递|创建 时间|已等 X 小时
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
* 两者业务都要,别只留一个。
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
* 不用再回页面一单单点开确认。
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
* 🔥 2026-10-28 第五段从「审核」改「创建」:起算点已换成订单创建时间。
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
*/
function buildDetailLine(row, index, roundDate) {
const when = auditWhen(row?.auditTime, roundDate);
const auditText = when
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
: '审核 —';
const parts = [
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
cleanCell(row?.oId, '无单号'),
cleanCell(row?.soId, '—'),
cleanCell(row?.logisticCompany, '—'),
auditText,
`创建 ${auditWhen(row?.auditTime, roundDate)}`,
`已等 ${formatOverdue(row?.overdueMinutes)}`,
];
return `${index}. ${parts.join('|')}`;
@ -280,7 +274,7 @@ function buildNotifyText(data = {}, options = {}) {
const windowEnd = String(window.end || '').slice(11, 16);
const head = [
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前创建` : ''}`,
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
];

View File

@ -4,7 +4,7 @@
* 仓储中心物流转运异常(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-21):
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
* 下属 12 个发货仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /

View File

@ -1,7 +1,7 @@
'use strict';
/**
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
* 「仓储中心 24 小时发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-27):
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」

View File

@ -1,10 +1,10 @@
'use strict';
/**
* 仓储中心发货时效(2026-10-20 新增)
* 仓储中心 24 小时发货时效(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
@ -32,10 +32,7 @@
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
* 是老口径,仅作历史记录,不要再照着实现**。
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
* ⚠️ 不是 `order_date`(平台下单时间);
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
@ -321,23 +318,18 @@ const ABNORMAL_STATUS = '异常';
// 漏掉这个排除,整个页面会被作废单淹没。
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
// 起算点是**审核时间**,不是订单创建时间。证据:
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
// 🔥 2026-10-28 **业务再次确认**:「审核时间是 jst_Orders 这个表里面的创建时间」。
// 这与 2026-10-20 的最初定版一致(本段下面就是那次的原话),中间一段
// 因「9818476 创建 10-18、审核 10-20」被临时改成按审核时间,现已改回创建时间。
//
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
// 实测差异(近 7 天 26086 单):`created` **100% 早于** `last_oa_confirm_time`,
// 正常单只差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时(极端的差 2 天)。
// 影响量化(2026-10-27 18:00 轮):按审核 147 单 / 平均已等 4.5h;
// 按创建 272 单 / 平均已等 8.5h —— 业务已知悉并要求按创建时间算。
//
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
// 主数据源已经切成订单表(见上面 ORDER_SOURCE_TABLES),`created` 直接从主查询拿,
// **不再需要 backfill**。
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。

View File

@ -1,16 +1,22 @@
'use strict';
/**
* 仓储中心发货时效 —— 服务层
* 仓储中心 24 小时发货时效 —— 服务层
*
* ═══════════════════════════════════════════════════════════════════════════
* 🔥 2026-10-28 数据源切换 + 起算点二次纠偏(业务 yanhui 定版)
* 🔥 2026-10-28 数据源与起算点「双切换」(业务 yanhui 定版)
* ═══════════════════════════════════════════════════════════════════════════
* 数据源(已定版,不要动):
* rpt.jst_order_express_details(状态镜像)→
* rpt.jst_Orders + rpt.jst_qmcust_orderlist(**店铺互斥,必须都读**)
* 业务原话:
* ① 「核验抓取的销售订单表是错误的,应该在数据库抓取:订单 jst_Orders、
* 订单明细 jst_OrderItem,o_id → o_id」
* ② 「审核时间是 jst_Orders 这个表里面的创建时间」
*
* 为什么状态镜像表不能当**候选池**(实测证据,2026-10-27/28):
* 于是本版做两件事:
* · 主数据源:rpt.jst_order_express_details(状态镜像)→
* rpt.jst_Orders + rpt.jst_qmcust_orderlist(**店铺互斥,必须都读**)
* · 起算点:last_oa_confirm_time(审核时间)→ created(订单创建时间)
*
* 为什么状态镜像表不能用(实测证据,2026-10-27/28):
* ① send_time **漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
* 订单表已有 send_date 的 **563 单**、status 已是 Sent 的 492 单;
* ② 被合并 / 拆单父单**不回写**:另有 Merged 89 + Split 755 单被冤枉;
@ -19,48 +25,27 @@
* ④ 两表发货时间**同源**:send_time vs send_date 27387 单逐条比对**零偏差**
* ⟹ 订单表才是权威,状态表只是它的滞后副本。
*
* 🔥 起算点:**审核时间 `last_oa_confirm_time`**(2026-10-28 下午二次纠偏)
* ─────────────────────────────────────────────────────────────────────────
* 下午那一版曾把起算点改成 `created`(订单创建时间),理由是「审核时间就是
* 订单创建时间」。**这个判断是错的**,业务当场拿 9837824 打回来:
* 起算点换 created 的影响(业务已确认):
* created 100% 早于 last_oa_confirm_time(近 7 天 26086 单无一例外),
* 正常单差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时。
* 2026-10-27 18:00 轮实测:按审核 147 单 / 均等 4.5h → 按创建 272 单 / 均等 8.5h。
*
* 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'`,
* 页面该列打 `*` 标注,钉钉/导出同步标注。别默默丢掉这些单。
* ⚠️ 2026-10-20 那次「按审核时间算」的纠偏已被本版覆盖,别再把基准时间
* 改回 auditAt —— 那是上一轮的定版,业务 2026-10-28 明确要求按创建时间。
*
* ── 三条铁律(不变)──────────────────────────────────────────────────────
* ① 轮次归属看起算点:今天 07:00 前**审核** → 13:00 轮;
* 截止今天 16:00 前**审核** → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。
* **历史未发单一律算**(昨天及更早审核、至今没发的,两个轮次都要看到)。
* ① 轮次归属看起算点:今天 07:00 前创建 → 13:00 轮;
* 截止今天 16:00 前创建 → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。
* **历史未发单一律算**(昨天及更早创建、至今没发的,两个轮次都要看到)。
* ② 没有「满 24 小时」门槛:到核验时刻仍未发就是超时。
* ③ 自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核(按快递名包含匹配,
* 武进安能只剔这一个网点,**别泛化成「安能」**,常州安能新等仍考核),
* 剔除量记 summary.carrierSkipped。
*
* 已等时长 = 审校起算点 → 本轮核验时刻(13:00 / 18:00)。
* 已等时长 = 创建时间 → 本轮核验时刻(13:00 / 18:00)。
*
* ── 订单表字段口径(已逐一实测)───────────────────────────────────────────
* · created —— 聚水潭订单创建时间(**只做兜底与展示**,不是起算点)
* · created —— ✅ **本页起算点**(聚水潭订单创建时间)
* · order_date —— 平台下单时间(客户侧,比 created 早 15~30 秒)
* · pay_date —— 付款时间(并列展示)
* · send_date —— 发货时间(**未发单必为空**,已发 100% 有值)
@ -75,7 +60,7 @@
* · modified —— ⚠️ 每天凌晨被批量刷新,**不可用**
*
* ── 三态分流(互斥,全部由订单表 status 决定)─────────────────────────────
* 超时未发货 = status='Delivering' + send_date 空 + 审核时间已过窗口上界 → orders
* 超时未发货 = status='Delivering' + send_date 空 + created 已过窗口上界 → orders
* 不计考核 = status='WaitConfirm'(待审核)/ 'Question'(异常) → heldOrders
* 不展示 = Sent / Cancelled / Split / Merged / WaitOuterSent → SQL 侧已排除
* ⚠️ 作废(Cancelled,业务叫「作废单」)近 30 天 66643 单,是 Delivering 1446 单的 46 倍,
@ -223,20 +208,6 @@ function formatDateTime(value) {
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);
@ -265,11 +236,6 @@ function diffMinutes(fromText, toText) {
* 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()` 关键词归属 —— 比状态表的仓名更准(同一仓在状态表里有
@ -308,13 +274,12 @@ async function loadPendingOrders(round) {
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
x.updated_at, x.wms_co_id
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
@ -325,7 +290,6 @@ async function loadPendingOrders(round) {
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
@ -340,73 +304,6 @@ async function loadPendingOrders(round) {
return rows || [];
}
/**
* 批量取**审核时间**(起算点)—— 唯一来源是状态镜像表 `jst_order_express_details`。
*
* 为什么必须回状态表拿:
* · `jst_Orders` / `jst_qmcust_orderlist` **都没有审核时间字段**(85 列逐一看过,
* 只有 order_date / pay_date / created / modified / updateTime,前者是下单、
* 后者是灌库时间,`modified` 还会被每天凌晨批量刷新);
* · 状态表 `first_oa_confirm_time` / `last_oa_confirm_time` 才是聚水潭的审核时间。
* ⚠️ 只从它拿**这一个字段**,候选取舍(哪些单未发货)仍然只看订单两表 ——
* 状态表的 send_time 漏回写、会整批停更,那些坑一个都不能重新踩回来。
*
* 业务口径(2026-10-28):起算点用 `last_oa_confirm_time`(业务选「最后审核时间」,
* 理由见文件头)。空值不在这里兜,交给调用方回退 `created`。
*
* 性能:`inner_order_id IN (...)` 一次 600 个约 **15s**(全表扫一次,402 万行视图)。
* 🔥 千万别改成逐单查 —— 实测单条 **14.5s**,599 条要 2.4 小时。
* 分批 600 是因为 T-SQL 单条语句 IN 列表太长会解析超时;两批各 600 / 剩余。
*
* 失败降级:抛错由调用方 catch → 全部回退 created,并在 payload 里标 degraded,
* **不要因为状态表挂了就整页 500**(候选池在订单表,本来就有数据)。
*/
async function loadOaConfirmInfo(stateRows) {
const ids = [...new Set(
(stateRows || []).map((r) => Number(r.o_id)).filter((n) => Number.isFinite(n))
)];
const map = new Map();
if (!ids.length) return map;
// 🔥 2026-10-28 下午:串行 9 批实测 **135s**(全并行 9 批只要 6s,并行 3 批 28.8s)。
// 生产上「主查询 63~105s + 状态表 135s」直接把 /list 顶过 300s 超时,
// 页面打不开 —— 所以**必须并发**。并发 6 是实测过的安全值:
// 单批约 15s,6 并发后整体 10~15s,且不至于把 402 万行视图的连接池打爆。
const BATCH = 600;
const CONCURRENCY = 6;
const chunks = [];
for (let i = 0; i < ids.length; i += BATCH) {
chunks.push(ids.slice(i, i + BATCH));
}
for (let i = 0; i < chunks.length; i += CONCURRENCY) {
const group = chunks.slice(i, i + CONCURRENCY);
// eslint-disable-next-line no-await-in-loop
const results = await Promise.all(group.map((chunk) => {
// ids 已用 Number.isFinite 过滤过,拼进 IN 是纯数字,无注入面
return cloudDatabase.querySlow(
`SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time
FROM rpt.jst_order_express_details WITH (NOLOCK)
WHERE inner_order_id IN (${chunk.join(',')})`,
{},
{ connectRequestTimeoutMs: 300000 }
);
}));
for (const rows of results) {
for (const r of rows || []) {
map.set(Number(r.inner_order_id), {
first: formatDateTime(r.first_oa_confirm_time),
last: formatDateTime(r.last_oa_confirm_time),
// 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况
// (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空)
sendTime: formatDateTime(r.send_time),
});
}
}
}
return map;
}
function emptySummary() {
return {
@ -420,8 +317,6 @@ function emptySummary() {
voidCount: 0,
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能)
carrierSkipped: 0,
// 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写)
stateShippedSkipped: 0,
// 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到)
unknownWarehouse: 0,
};
@ -533,23 +428,10 @@ async function getShipTimeliness(options = {}) {
throw err;
}
// ③.5 起算点:批量取审核时间(最后审核时间优先,缺失回退订单创建时间)
// ⚠️ 状态表挂了不阻断页面:全量回退 created,并在 payload 里标降级
let oaConfirmMap = new Map();
let oaConfirmDegraded = false;
try {
oaConfirmMap = await loadOaConfirmInfo(stateRows);
} catch (e) {
oaConfirmDegraded = true;
console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message);
}
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
let voidCount = 0;
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
let carrierSkipped = 0;
// 订单表 send_date 为空、但状态表 send_time 有值(漏回写)→ 已发,剔除
let stateShippedSkipped = 0;
// 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…)
let unknownWarehouse = 0;
@ -585,51 +467,11 @@ async function getShipTimeliness(options = {}) {
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` 是聚水潭源端的「最后修改时间」,
// 审核动作一定会刷新它;因客服改单/改备注而推后的情况属「放行」方向
// (少报超时,不冤枉仓库),业务已知悉。
// ── 起算点 = **订单创建时间**(业务 2026-10-28 定版)────────────────────
// 业务原话:「审核时间是 jst_Orders 这个表里面的创建时间」。
// 实测 created 100% 早于状态表的 last_oa_confirm_time,正常差 7~40 分钟,
// 夜间下单次日才审的单差 6~9 小时(详见 registry 起算点段的量化)。
const createdAt = formatDateTime(row.created);
const modifiedAt = formatDateTime(row.modified);
const oaInfo = oaConfirmMap.get(Number(row.o_id));
// ── ② 已发兜底:状态表说发了,就不再算未发 ─────────────────────────────
// PR #88 换源时记录的是「状态表 send_time 漏回写」这个方向,但**反方向也会漏**:
// 2026-10-28 实测 13:00 轮 144 单里 **6 单**状态表 send_time 有值、且有
// 物流揽收轨迹,而订单表 send_date 仍是 NULL —— 只信订单表就会冤枉仓库。
// ⟹ 口径:**任一方有发货时间即视为已发**,剔除量记 summary.stateShippedSkipped。
// ⚠️ 只认 send_time,**不要**拿 `logistic_time` 当发货时间:它多数是
// 「快递员将上门取件」的预告(实测 9983162 有轨迹但并未收件),会提前放行。
if (oaInfo && oaInfo.sendTime) {
stateShippedSkipped += 1;
continue;
}
const oaConfirmAt = oaInfo ? (oaInfo.last || '') : '';
// 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。
// · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据);
// · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据),
// 起算点绝不该早于下单时刻。
const benchmarkAt = laterOf(laterOf(oaConfirmAt, modifiedAt), createdAt) || createdAt;
let benchmarkSource = 'created';
if (benchmarkAt && benchmarkAt === modifiedAt) benchmarkSource = 'modified';
else if (benchmarkAt && benchmarkAt === oaConfirmAt) benchmarkSource = 'oa_confirm';
const payAt = formatDateTime(row.pay_date);
const statusRaw = String(row.status || '');
@ -638,14 +480,14 @@ async function getShipTimeliness(options = {}) {
if (seenSo.has(dedupeKey)) continue;
seenSo.add(dedupeKey);
// ── 本轮核验范围(窗口上界按**起算点**卡)──────────────────────────────
// 13:00 轮:今天 07:00 前审核的单 + 历史未发
// 18:00 轮:今天 16:00 前审核的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00)
// ── 本轮核验范围(窗口上界按**创建时间**卡)────────────────────────────
// 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;
if (createdAt && createdAt < round.windowStart) continue; // 超出 30 天护栏
if (createdAt && createdAt >= todayStart && createdAt > windowEndAt) continue;
const base = {
row,
@ -654,14 +496,12 @@ async function getShipTimeliness(options = {}) {
statusRaw,
src: String(row.src || ''),
createdAt,
oaConfirmAt,
payAt,
// 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算
benchmarkAt,
benchmarkSource,
// 页面「审核时间」列显示的就是起算点(回退时会打 `*`)
auditAt: benchmarkAt,
windowAt: benchmarkAt,
// 「审核时间」= 创建时间(业务口径),前端那一列显示的就是它
auditAt: createdAt,
windowAt: createdAt,
benchmarkAt: createdAt,
benchmarkSource: 'created',
warehouseName,
logisticCompany: String(row.logistics_company || '').trim(),
logisticId: String(row.l_id || '').trim(),
@ -700,13 +540,13 @@ async function getShipTimeliness(options = {}) {
}
// ── ④ 超时判定:到核验时刻仍未发货即超时(没有「满 24 小时」门槛)────────
if (!benchmarkAt) {
if (!createdAt) {
bucket.heldCount += 1;
heldRows.push({ ...base, holdReason: '缺审核时间与创建时间' });
heldRows.push({ ...base, holdReason: '缺创建时间' });
continue;
}
// waited = 起算点(审核时间)→ 本轮核验时刻(13:00 / 18:00)
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
// waited = 创建时间 → 本轮核验时刻(13:00 / 18:00)
const waited = Math.max(0, diffMinutes(createdAt, round.deadline));
bucket.pendingCount += 1;
const days = Math.floor(waited / 1440);
@ -739,14 +579,13 @@ async function getShipTimeliness(options = {}) {
oId: String(r.o_id || ''),
soId: String(r.so_id || ''),
shopName: String(r.shop_name || ''),
// 订单创建时间(聚水潭建单)—— 只做展示与兜底,不是考核起算点
// 创建时间 = 订单表 `created`(**本页的考核起算点**,业务 2026-10-28 定版)
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,
@ -824,7 +663,6 @@ async function getShipTimeliness(options = {}) {
}, emptySummary());
summary.voidCount = voidCount;
summary.carrierSkipped = carrierSkipped;
summary.stateShippedSkipped = stateShippedSkipped;
summary.unknownWarehouse = unknownWarehouse;
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
@ -868,18 +706,14 @@ async function getShipTimeliness(options = {}) {
isPreviousDay: false,
rounds,
deadline: round.deadline,
// boundary='oa_confirm':轮次归属、已等时长都按**审核时间**算
// (状态表 last_oa_confirm_time;取不到时回退订单 created,见 benchmarkSource)
// 业务 2026-10-28 下午二次定版;上午那版按 created 已被 9837824 推翻。
// boundary='created':轮次归属、已等时长都按**订单创建时间**(jst_Orders.created)算
// 业务 2026-10-28 定版;此前一段用的是状态表的 last_oa_confirm_time,已废弃。
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,
boundary: 'created',
measuredBy: 'created',
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
overrides: windowOverrides,
},

View File

@ -184,7 +184,7 @@ test('起算点是 orders.created,且只从订单两表取', () => {
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, scenario = 'default', oaRows = []) {
function stubDb(dbPath, scenario = 'default') {
const WMS = {
CQ: 15038426,
SD_QGJ: 15137523,
@ -281,22 +281,9 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分
];
// 「起算点 = max(审核时间, 订单 modified)」专用场景(2026-10-28 下午定版)
// 🔥 为什么必须取较晚者:状态表的 last_oa_confirm_time 会滞后。
// 实测 9978643:状态表写 10-27 17:08,聚水潭真实审核 10-28 14:52,
// 只信状态表 → 把「今天下午才审核的单」算进 13:00 轮,冤枉仓库。
const modifiedRows = [
// 51:created 06:45(7 点前),但 modified 是今天 14:52 → 起算点 14:52 晚于窗口 07:00 → 剔除
o(51, WMS.CQ, '2026-10-20 06:45:00', { modified: '2026-10-20 14:52:29' }),
// 52:历史单 created 10-18,状态表无审核时间,modified 今天 06:00 → 算超时,起算点 06:00
o(52, WMS.CQ, '2026-10-18 12:00:00', { modified: '2026-10-20 06:00:00' }),
// 53:modified 比 created 还早(异常数据)→ 起算点回落到 created
o(53, WMS.CQ, '2026-10-20 05:00:00', { modified: '2026-10-19 23:00:00' }),
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows);
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, sql: [], params: [] };
const calls = { orders: 0, warehouse: 0, sql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
@ -304,12 +291,6 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
calls.warehouse += 1;
return warehouseRows;
}
// 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间,
// 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows
if (/jst_order_express_details/u.test(text)) {
calls.oaConfirm += 1;
return oaRows;
}
calls.orders += 1;
calls.sql.push(text);
calls.params.push(params || {});
@ -327,11 +308,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
return { calls, WMS };
}
function withStubbedService(fn, scenario = 'default', oaRows = []) {
function withStubbedService(fn, scenario = 'default') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario, oaRows);
const ctx = stubDb(dbPath, scenario);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
@ -393,8 +374,8 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
// 3 号单没创建时间 → 归「不计考核」,原因写清楚
const noCreated = data.heldOrders.find((o) => String(o.oId) === '3');
assert.ok(noCreated, '缺起算点(审核时间与创建时间都没有)的单不能被静默丢掉');
assert.equal(noCreated.holdReason, '缺审核时间与创建时间');
assert.ok(noCreated, '缺创建时间的单不能被静默丢掉');
assert.equal(noCreated.holdReason, '缺创建时间');
// 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00)
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
@ -450,8 +431,8 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(
reasons,
['缺审核时间与创建时间', '待审核', '订单异常', '订单异常'].sort(),
'4 单:缺起算点 1 + 待审核 1 + 异常 2'
['缺创建时间', '待审核', '订单异常', '订单异常'].sort(),
'4 单:缺创建时间 1 + 待审核 1 + 异常 2'
);
// 新鲜度:取自主查询的 MAX(updated_at)
@ -501,11 +482,11 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'oa_confirm', '已等时长按审核时间算');
assert.equal(data.window.boundary, 'created', '窗口按创建时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'created', '已等时长按创建时间算');
}));
test('13:00 轮:只核验「今天 07:00 前审核」的单 + 历史未发', withStubbedService(async (svc) => {
test('13:00 轮:只核验「今天 07:00 前创建」的单 + 历史未发', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
assert.equal(data.round.key, 'NOON');
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
@ -533,7 +514,7 @@ test('13:00 轮:只核验「今天 07:00 前审核」的单 + 历史未发', w
// 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单
// (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算)
assert.equal(data.summary.overdueUnshipped, 5);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判(取不到回退创建时间)');
assert.equal(data.window.boundary, 'created', '窗口按创建时间判');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
@ -632,36 +613,3 @@ test('导出行为纯函数:缺字段一律给空串,不导出 undefined', (
assert.equal(rows[1].oId, '');
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
});
test('起算点 = max(审核时间, 订单 modified):状态表滞后时以 modified 为准', withStubbedService(async (svc) => {
// 13:05 → 13:00 轮,窗口上界 07:00
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
// 51 的 modified 是今天 14:52,晚于窗口 07:00 → 必须剔除(这就是 9978643 那类单)
assert.ok(!data.orders.some((x) => String(x.oId) === '51'),
'modified 晚于窗口的单不能算进 13:00 轮(状态表滞后会把它冤枉成 7 点前审核)');
// 52:状态表无审核时间,modified 今天 06:00 → 起算点 06:00,不是 created 10-18 12:00
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时(modified 06:00 早于窗口 07:00)');
assert.equal(r52.auditTime, '2026-10-20 06:00:00', '起算点取 modified');
assert.equal(r52.benchmarkSource, 'modified', '来源必须是 modified');
assert.equal(r52.createdAt, '2026-10-18 12:00:00', '下单时间照旧展示');
assert.equal(r52.overdueMinutes, 420, '06:00 → 13:00 = 420 分钟(不是按下单时间算的 4380)');
// 53:modified 早于 created → 取较晚者 = created 05:00
const r53 = data.orders.find((x) => String(x.oId) === '53');
assert.ok(r53, '53 应算超时');
assert.equal(r53.auditTime, '2026-10-20 05:00:00', 'modified 比 created 早时不能倒退,取 created');
assert.equal(r53.benchmarkSource, 'created');
}, 'modified'));
test('状态表审核时间比 modified 更晚时,以状态表为准', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时');
assert.equal(r52.auditTime, '2026-10-20 06:30:00', '审核时间 06:30 晚于 modified 06:00,取审核时间');
assert.equal(r52.benchmarkSource, 'oa_confirm');
}, 'modified', [
{ inner_order_id: 52, first_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), last_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), send_time: null },
]));

View File

@ -125,7 +125,7 @@ test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补'
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
assert.match(built.text, /核验 07:00 前审核/);
assert.match(built.text, /核验 07:00 前创建/);
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
assert.equal(built.rowCount, 3);
});
@ -138,11 +138,11 @@ test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
});
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
test('明细一行 = 仓|内部单号|线上单号|快递|创建 时间|已等', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|创建 06:32|已等 6 小时 28 分/);
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|创建 无创建时间|已等 2 小时/);
assert.doesNotMatch(built.text, /undefined|NaN/);
});
@ -151,20 +151,20 @@ test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
assert.ok(line, '明细第 2 行不见了');
const parts = line.replace(/^\d+\.\s*/, '').split('|');
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '创建 10-26 09:05', '已等 1 天 23 小时']);
});
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
test('历史未发单的创建时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|创建 10-26 09:05|已等 1 天 23 小时/);
// 同一天的就只给 HH:mm,别啰嗦
assert.match(built.text, /审核 06:32/);
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
assert.match(built.text, /创建 06:32/);
assert.doesNotMatch(built.text, /创建 10-27 06:32/);
// 直接测函数本身的跨天判定
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
assert.equal(job.auditWhen('', '2026-10-27'), '');
assert.equal(job.auditWhen('', '2026-10-27'), '无创建时间');
});
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {

View File

@ -4,7 +4,7 @@ import request from './request';
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
// params.limit 明细条数上限
export const getWarehouseShipTimeliness = (params = {}) => request.get(

View File

@ -37,7 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
{
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,

View File

@ -80,7 +80,7 @@ const NOTIFY_HELP_ITEMS = [
label: '③ 为什么一天推两次、时间怎么定',
children: (
<div className="wst-notify__help">
业务口径:<b>13:00 核验 7 点前审核的超时单</b>、<b>18:00 核验 16 点前审核的单</b>,
业务口径:<b>13:00 核验 7 点前创建的超时单</b>、<b>18:00 核验 16 点前创建的单</b>,
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
<br />
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻,
@ -165,20 +165,12 @@ const HELD_COLUMNS = [
},
},
{
title: (
<Tooltip title="聚水潭「审核时间」= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者(状态表会滞后,9978643 旧值比真实审核早一天)。两者都没有才回退订单创建时间,标 *">
<span>审核时间</span>
</Tooltip>
),
title: '创建时间',
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v, row) => {
if (!v) return <span className="wst-detail__fallback">无审核时间</span>;
return row?.benchmarkSource === 'created' ? `${v} *` : v;
},
render: (v) => (v || <span className="wst-detail__fallback">无创建时间</span>),
},
{ title: '下单时间', dataIndex: 'createdAt', width: 152, ellipsis: true, render: (v) => v || '—' },
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
];
@ -232,39 +224,22 @@ const DETAIL_COLUMNS = [
},
},
{
// 审核时间 = 本页的**考核起算点**(业务 2026-10-28 下午三次定版)
// = max(状态表 last_oa_confirm_time, 订单表 modified),都没有才回退创建时间并打 `*`
// 创建时间 = 本页的**考核起算点**(业务 2026-10-28 定版:审核时间就是订单创建时间)
title: (
<Tooltip title="聚水潭「审核时间」= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者。状态表会滞后(9978643 状态表是 10-27 17:08,真实审核 10-28 14:52),所以取较晚的那个。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含历史未发单)18:00 核验。带 * 表示两者都没有,回退订单创建时间">
<span>审核时间</span>
<Tooltip title="取订单表 jst_Orders 的创建时间(业务口径:审核时间即订单创建时间)。今天 7:00 前创建的 13:00 核验;截止 16:00 前创建的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
<span>创建时间</span>
</Tooltip>
),
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v, row) => {
if (!v) return <span className="wst-detail__fallback">—</span>;
if (row?.benchmarkSource === 'created') {
return (
<Tooltip title="状态表审核时间和订单表 modified 都取不到,已回退订单创建时间参与分轮与计时(`*` 为标记)">
<span>{v} *</span>
</Tooltip>
);
}
return v;
},
},
// 下单时间 = 客户在平台下单那一刻(只做参考对账,不参与计时)
{
title: (
<Tooltip title="聚水潭订单创建时间(jst_Orders.created),即客户下单时刻。只做参考,不参与分轮与计时">
<span>下单时间</span>
</Tooltip>
),
dataIndex: 'createdAt',
width: 152,
ellipsis: true,
render: (v) => v || '—',
render: (v, row) => (v
? v
: (
<Tooltip title="没取到创建时间,已回退付款时间参与分轮与计时">
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
</Tooltip>
)),
},
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
@ -289,7 +264,7 @@ const DETAIL_COLUMNS = [
},
{
title: (
<Tooltip title="从审核时间(仓库接到单的时刻)到本轮核验时刻(13:00 / 18:00)等了多久">
<Tooltip title="从订单创建时间到本轮核验时刻(13:00 / 18:00)等了多久">
<span>已等时长</span>
</Tooltip>
),
@ -320,16 +295,11 @@ const EXPORT_COLUMNS = [
{ title: '发货状态', dataIndex: 'state' },
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
{
title: '审核时间',
title: '创建时间',
dataIndex: 'auditTime',
// 起算点 = 审核时间;没取到时回退订单创建时间,导出用 `*` 标出来,否则对账对不上
exportValue: (v, row) => {
const t = v || row?.benchmarkTime || '';
if (!t) return '无审核时间';
return row?.benchmarkSource === 'created' ? `${t} *` : t;
},
// 创建时间缺失时页面会回退付款时间并打 `*`,导出也保持一致,否则对账对不上
exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '无创建时间'),
},
{ title: '下单时间', dataIndex: 'createdAt', exportValue: (v) => v || '—' },
{ title: '付款时间', dataIndex: 'payTime' },
// 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化
{ title: '已等(分钟)', dataIndex: 'overdueMinutes' },
@ -350,20 +320,17 @@ const roundFileText = (roundInfo) => (
);
/**
* 仓储中心发货时效(独立页面)
* 仓储中心 24 小时发货时效(独立页面)
* ────────────────────────────────────────────────────────────────
* 业务口径(yanhui 2026-10-20 定轮次;2026-10-28 定起算点):
* · 11 个主仓,子仓并入主仓一起算;
* · 数据源 = 订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,都要读);
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
* (含 7 点前审核未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
* 今天之前仍未发的(历史未发)两个轮次都算;
* · 今天 07:00 前**创建**的订单 13:00 核验,截止今天 16:00 前创建的单 18:00 核验
* (含 7 点前未发出的、含今天之前创建至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
* 今天之前创建仍未发的(历史未发)两个轮次都算;
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
* 轮次归属和「已等时长」都按**审核时间**算 ——
* 业务 2026-10-28 下午三次定版:审核时间 = max(状态表 last_oa_confirm_time, 订单表 modified)。
* 只取状态表会漏 —— 状态表会滞后(9978643 状态表写的是 10-27 17:08,聚水潭真实审核 10-28 14:52,
* 整整早一天),导致「今天下午才审核的单」被算进 13:00 轮,冤枉仓库;
* 两者都没有才回退订单创建时间,页面上带 `*` 标记;
* 轮次归属和「已等时长」都按**创建时间**(jst_Orders.created)算 ——
* 业务口径:聚水潭「审核时间」指的就是订单创建时间;
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
* · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选);
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
@ -654,26 +621,23 @@ function WarehouseShipTimeliness() {
return (
<SycmPage className="wst-page">
<header className="wst-header">
<Title level={3}>仓储中心发货时效(到点未发即超时)</Title>
<Title level={3}>仓储中心 24 小时发货时效</Title>
<Text type="secondary">
按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
{/* 2026-10-28 业务定:名字里不再出现「24 小时」—— 规则本身没有 24h 门槛,
原名会让 10-27 16:00 创建(只等了 21 小时)的单看着像「没到时限也被判超时」。*/}
</Text>
<Text type="secondary" className="wst-note">
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
每天 <b>13:00</b> 核验「<b>今天 07:00 前创建</b>」、<b>18:00</b> 核验「<b>截止 16:00 前创建</b>」、
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前创建、至今未发的历史未发单</b>
(18:00 轮也会带上 7 点前创建、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
13:00 之前不核验,显示「未到核验时间」。
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
<b>轮次与「已等时长」都按审核时间算</b>(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者:
审核才是仓库接到单的时刻,按下单时间算会把客服审核积压算到仓库头上;
状态表的审核时间会滞后,所以要和订单 modified 取较晚的那个);
明细并列「审核时间 / 下单时间 / 发货时间」,方便直接和聚水潭对账。
<b>已发 / 未发以订单表 send_date 为准</b>(有值即已发,SQL 侧直接过滤不进候选);
订单表 send_date 没回写、但状态表 send_time 有值的,也按已发剔除(不漏冤仓库)。
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前创建</b>的单、
<b>河南洛宁仓</b> 只看 <b>13:00 前创建</b>的单(核验时刻仍是 18:00)。
<b>轮次与「已等时长」都按创建时间算</b>(= 聚水潭「审核时间」,业务口径即订单创建时间 jst_Orders.created);
明细并列「创建时间 / 发货时间」,方便直接和聚水潭对账。
<b>已发 / 未发直接取订单表 send_date</b>:send_date 有值就是已发(SQL 侧直接过滤,
根本不进候选),为空的才是未发;不再读状态镜像表的 send_time ——
那张表会漏回写(近 7 天 563 单已发却仍算未发)甚至整批停更,是这次换源的根因。
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
@ -690,7 +654,7 @@ function WarehouseShipTimeliness() {
<>
<span className="wst-status__badge">{round.label}</span>
<span className="wst-status__label">
核验今天 {cutoffText} 前审核的订单
核验今天 {cutoffText} 前创建的订单
</span>
<span className="wst-status__window">
含历史未发 · 到点未发即超时
@ -702,7 +666,7 @@ function WarehouseShipTimeliness() {
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
<span className="wst-status__label">
{nextRoundText} 开始首轮核验
(核验今天 {nextWindowEndText} 前审核的订单)
(核验今天 {nextWindowEndText} 前创建的订单)
</span>
</>
)}
@ -745,7 +709,7 @@ function WarehouseShipTimeliness() {
<Card bordered={false} className="wst-stat">
<Statistic
title={(
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按订单创建时间分轮),不设满 24 小时门槛">
<span>超时未发货</span>
</Tooltip>
)}
@ -811,7 +775,7 @@ function WarehouseShipTimeliness() {
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
: notStarted
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前创建的单,`
+ `超时未发货 ${c.overdueUnshipped} 单`
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
@ -993,7 +957,7 @@ function WarehouseShipTimeliness() {
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟</Text>
<Text type="secondary">核验「今天 07:00 前创建」的超时单,默认核验点 + 5 分钟</Text>
</div>
<div className="wst-notify__row">
@ -1009,7 +973,7 @@ function WarehouseShipTimeliness() {
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
<Text type="secondary">核验「截止 16:00 前创建」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
</div>
<div className="wst-notify__row">
@ -1111,12 +1075,11 @@ function WarehouseShipTimeliness() {
<Text type="secondary" className="wst-foot">
说明:数据源 = 聚水潭<b>订单表 jst_Orders + jst_qmcust_orderlist</b>(两张表店铺互斥,按 o_id 去重)。
<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
<b>今天 7:00 前创建</b>的订单 13:00 核验;<b>截止 16:00 前创建</b>的单 18:00 核验
(含 7 点前创建、13:00 还没发出的,也含今天之前创建至今未发的历史单)。
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
轮次与「已等时长」都按<b>审核时间</b>算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者,
审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 <b>*</b>。
发货 = 订单表 send_date(为空才算未发,但状态表 send_time 有值的也判已发);状态只取「发货中」。
轮次与「已等时长」都按创建时间(jst_Orders.created)算 —— 业务口径的「审核时间」即订单创建时间。
发货 = 订单表 send_date(为空才算未发);状态只取「发货中」。
<b>作废单不考核</b>(labels 含「发货前售后」= 客户发货前取消)
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
<b>{excludedCarrierText}不考核</b>