Compare commits
14 Commits
yanhui-wst
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ad6aad89 | |||
| 555ac6b116 | |||
| b955e78ba2 | |||
| 54518ab4d3 | |||
| 1139e4c364 | |||
| c0e48688c1 | |||
| cb7d06c20c | |||
| 6b6dfdd1da | |||
| 983fee0d8a | |||
| 85c48ebe77 | |||
| e7e0b96b46 | |||
| 6a5f6e8ae0 | |||
| 6c2686784d | |||
| 913827000a |
@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* GET /api/warehouse-ship-timeliness/list
|
* GET /api/warehouse-ship-timeliness/list
|
||||||
* query:
|
* query:
|
||||||
|
|||||||
@ -681,7 +681,7 @@ async function startServer(options = {}) {
|
|||||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||||
|
|||||||
@ -245,8 +245,26 @@ function buildNotifyText(data = {}, options = {}) {
|
|||||||
const typeLine = buildTypeLine(data);
|
const typeLine = buildTypeLine(data);
|
||||||
if (typeLine) head.push(`类型:${typeLine}`);
|
if (typeLine) head.push(`类型:${typeLine}`);
|
||||||
|
|
||||||
|
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
|
||||||
|
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
|
||||||
|
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
|
||||||
|
const freshness = data.freshness || {};
|
||||||
if (totalPackages === 0) {
|
if (totalPackages === 0) {
|
||||||
head.push('本批无转运异常,全部正常。');
|
if (freshness.emptyWindow && freshness.sourceStale) {
|
||||||
|
const hours = Number(freshness.sourceAgeMinutes) > 0
|
||||||
|
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
|
||||||
|
: '';
|
||||||
|
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
|
||||||
|
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
|
||||||
|
} else if (freshness.emptyWindow) {
|
||||||
|
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
|
||||||
|
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
|
||||||
|
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
|
||||||
|
} else if (shipped === 0) {
|
||||||
|
head.push('本批发货 0 单,无异常可核。');
|
||||||
|
} else {
|
||||||
|
head.push('本批无转运异常,全部正常。');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 业务口径(2026-10-27 原话):
|
* 业务口径(2026-10-27 原话):
|
||||||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||||||
* 18:00 核验的订单,核验完成后也通知。」
|
* 18:00 核验的订单,核验完成后也通知。」
|
||||||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||||||
* NOON 13:00 核验「今天 07:00 前创建」→ 默认 13:05 推
|
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||||||
* AFTERNOON 18:00 核验「截止 16:00 前创建」→ 默认 18:05 推
|
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||||||
* ⚠️ 2026-10-28 起数据源换成订单两表、起算点换成 `jst_Orders.created`
|
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||||||
* (业务:「审核时间 jst_Orders 是这个表里面的创建时间」),
|
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||||||
* 所以推送正文写的是「创建 HH:mm」,不再叫「审核」。
|
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||||||
|
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||||||
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||||||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||||||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||||||
@ -121,18 +122,18 @@ function formatOverdue(minutes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 起算时间(= 订单创建时间 `jst_Orders.created`)的展示:
|
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||||||
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||||||
* 明细里会出现昨天 16:13 创建的单 —— 只写「创建 16:13」会被读成「今天 16:13」,
|
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||||||
* 看着像「创建时间晚于窗口,不该算超时」,业务会对不上账。
|
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||||||
*
|
*
|
||||||
* ⚠️ 2026-10-28 数据源切换后,本函数的入参 `auditTime` 实际是**订单创建时间**
|
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||||||
* (服务层把 created 同时写进 auditTime / createdAt,业务口径两者等同)。
|
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||||||
*/
|
*/
|
||||||
function auditWhen(auditTime, roundDate) {
|
function auditWhen(auditTime, roundDate) {
|
||||||
const text = cleanCell(auditTime);
|
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})/);
|
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
const clock = text.match(/(\d{2}:\d{2})/);
|
const clock = text.match(/(\d{2}:\d{2})/);
|
||||||
@ -144,22 +145,27 @@ function auditWhen(auditTime, roundDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 明细一行:仓|内部单号|线上单号|快递|创建 时间|已等 X 小时
|
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||||
* 两者业务都要,别只留一个。
|
* 两者业务都要,别只留一个。
|
||||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||||
* 不用再回页面一单单点开确认。
|
* 不用再回页面一单单点开确认。
|
||||||
* 🔥 2026-10-28 第五段从「审核」改「创建」:起算点已换成订单创建时间。
|
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||||||
|
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||||||
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||||||
*/
|
*/
|
||||||
function buildDetailLine(row, index, roundDate) {
|
function buildDetailLine(row, index, roundDate) {
|
||||||
|
const when = auditWhen(row?.auditTime, roundDate);
|
||||||
|
const auditText = when
|
||||||
|
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||||||
|
: '审核 —';
|
||||||
const parts = [
|
const parts = [
|
||||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||||
cleanCell(row?.oId, '无单号'),
|
cleanCell(row?.oId, '无单号'),
|
||||||
cleanCell(row?.soId, '—'),
|
cleanCell(row?.soId, '—'),
|
||||||
cleanCell(row?.logisticCompany, '—'),
|
cleanCell(row?.logisticCompany, '—'),
|
||||||
`创建 ${auditWhen(row?.auditTime, roundDate)}`,
|
auditText,
|
||||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||||||
];
|
];
|
||||||
return `${index}. ${parts.join('|')}`;
|
return `${index}. ${parts.join('|')}`;
|
||||||
@ -274,7 +280,7 @@ function buildNotifyText(data = {}, options = {}) {
|
|||||||
const windowEnd = String(window.end || '').slice(11, 16);
|
const windowEnd = String(window.end || '').slice(11, 16);
|
||||||
|
|
||||||
const head = [
|
const head = [
|
||||||
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前创建` : ''}`,
|
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
|
||||||
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
||||||
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 需求(yanhui 2026-10-21):
|
* 需求(yanhui 2026-10-21):
|
||||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
|
||||||
* 下属 12 个发货仓:
|
* 下属 12 个发货仓:
|
||||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||||
|
|||||||
@ -38,6 +38,10 @@ const EXPORT_DETAIL_LIMIT = 5000;
|
|||||||
|
|
||||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||||
const SNAPSHOT_STALE_MINUTES = 120;
|
const SNAPSHOT_STALE_MINUTES = 120;
|
||||||
|
// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同:
|
||||||
|
// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」,
|
||||||
|
// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。
|
||||||
|
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120;
|
||||||
|
|
||||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||||
@ -148,6 +152,36 @@ async function loadShippedPackages(batch) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
|
||||||
|
*
|
||||||
|
* 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||||
|
* 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**,
|
||||||
|
* 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时
|
||||||
|
* `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成
|
||||||
|
* 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。
|
||||||
|
*
|
||||||
|
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
|
||||||
|
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
|
||||||
|
*
|
||||||
|
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
|
||||||
|
*/
|
||||||
|
async function loadSourceLastUpdate() {
|
||||||
|
try {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT MAX(e.query_time) AS max_q
|
||||||
|
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
|
||||||
|
{},
|
||||||
|
{ timeoutMs: 240000 }
|
||||||
|
);
|
||||||
|
return formatDateTime(rows?.[0]?.max_q);
|
||||||
|
} catch (err) {
|
||||||
|
// 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间
|
||||||
|
console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
||||||
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
||||||
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
||||||
@ -315,7 +349,17 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
orders: [],
|
orders: [],
|
||||||
detailLimit: limit,
|
detailLimit: limit,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
freshness: {
|
||||||
|
snapshotAt: '',
|
||||||
|
ageMinutes: null,
|
||||||
|
stale: false,
|
||||||
|
coversDeadline: null,
|
||||||
|
emptyWindow: false,
|
||||||
|
sourceLastUpdate: '',
|
||||||
|
sourceAgeMinutes: null,
|
||||||
|
sourceStale: false,
|
||||||
|
hint: '',
|
||||||
|
},
|
||||||
generatedAt,
|
generatedAt,
|
||||||
cached: false,
|
cached: false,
|
||||||
cacheAgeSec: 0,
|
cacheAgeSec: 0,
|
||||||
@ -510,11 +554,33 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||||
|
// 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。
|
||||||
|
// 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='')
|
||||||
|
// 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。
|
||||||
|
// 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。
|
||||||
|
const emptyWindow = rows.length === 0;
|
||||||
|
const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : '';
|
||||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||||
|
const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null;
|
||||||
|
const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES;
|
||||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (coversDeadline === false) {
|
if (emptyWindow) {
|
||||||
|
const lastText = sourceLastUpdate || '';
|
||||||
|
const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10;
|
||||||
|
if (sourceStale) {
|
||||||
|
hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。`
|
||||||
|
+ ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。'
|
||||||
|
+ ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。';
|
||||||
|
} else if (lastText) {
|
||||||
|
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。`
|
||||||
|
+ ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。';
|
||||||
|
} else {
|
||||||
|
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——`
|
||||||
|
+ ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。';
|
||||||
|
}
|
||||||
|
} else if (coversDeadline === false) {
|
||||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||||
} else if (stale) {
|
} else if (stale) {
|
||||||
@ -561,6 +627,12 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
ageMinutes,
|
ageMinutes,
|
||||||
stale,
|
stale,
|
||||||
coversDeadline,
|
coversDeadline,
|
||||||
|
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
|
||||||
|
emptyWindow,
|
||||||
|
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
|
||||||
|
sourceLastUpdate,
|
||||||
|
sourceAgeMinutes,
|
||||||
|
sourceStale,
|
||||||
hint,
|
hint,
|
||||||
},
|
},
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 业务口径(2026-10-27):
|
* 业务口径(2026-10-27):
|
||||||
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
* 仓储中心发货时效(2026-10-20 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 需求(yanhui 2026-10-20):
|
* 需求(yanhui 2026-10-20):
|
||||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
|
||||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||||
@ -32,7 +32,10 @@
|
|||||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
|
||||||
|
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
|
||||||
|
* 是老口径,仅作历史记录,不要再照着实现**。
|
||||||
|
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
|
||||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||||
@ -318,18 +321,23 @@ const ABNORMAL_STATUS = '异常';
|
|||||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||||
|
|
||||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
|
||||||
// 🔥 2026-10-28 **业务再次确认**:「审核时间是 jst_Orders 这个表里面的创建时间」。
|
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
|
||||||
// 这与 2026-10-20 的最初定版一致(本段下面就是那次的原话),中间一段
|
// 起算点是**审核时间**,不是订单创建时间。证据:
|
||||||
// 因「9818476 创建 10-18、审核 10-20」被临时改成按审核时间,现已改回创建时间。
|
// 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)。
|
||||||
//
|
//
|
||||||
// 实测差异(近 7 天 26086 单):`created` **100% 早于** `last_oa_confirm_time`,
|
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
|
||||||
// 正常单只差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时(极端的差 2 天)。
|
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
|
||||||
// 影响量化(2026-10-27 18:00 轮):按审核 147 单 / 平均已等 4.5h;
|
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
|
||||||
// 按创建 272 单 / 平均已等 8.5h —— 业务已知悉并要求按创建时间算。
|
|
||||||
//
|
//
|
||||||
// 主数据源已经切成订单表(见上面 ORDER_SOURCE_TABLES),`created` 直接从主查询拿,
|
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
|
||||||
// **不再需要 backfill**。
|
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
|
||||||
|
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
|
||||||
|
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
|
||||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||||
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
||||||
@ -370,6 +378,64 @@ const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
|||||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||||
const AFTERSALE_VOID_STATUS = '已作废';
|
const AFTERSALE_VOID_STATUS = '已作废';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
|
||||||
|
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
|
||||||
|
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
|
||||||
|
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
|
||||||
|
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
|
||||||
|
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
|
||||||
|
*
|
||||||
|
* 实测(2026-10-29 13:00 轮 159 单):
|
||||||
|
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
|
||||||
|
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
|
||||||
|
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
|
||||||
|
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
|
||||||
|
*
|
||||||
|
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
|
||||||
|
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
|
||||||
|
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
|
||||||
|
*/
|
||||||
|
const STATE_ORDER_VOID_STATUS = '已取消';
|
||||||
|
const STATE_ORDER_SHIPPED_STATUS = '已发货';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销售出库单 `status` 里**等于单据作废**的值。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
|
||||||
|
*
|
||||||
|
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
|
||||||
|
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
|
||||||
|
* 其中 `Delete` 就是单据作废。
|
||||||
|
*
|
||||||
|
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
|
||||||
|
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
|
||||||
|
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
|
||||||
|
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
|
||||||
|
*
|
||||||
|
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
|
||||||
|
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
|
||||||
|
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
|
||||||
|
*
|
||||||
|
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
|
||||||
|
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
|
||||||
|
*/
|
||||||
|
const IO_VOID_STATUS = 'Delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
|
||||||
|
*
|
||||||
|
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
|
||||||
|
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
|
||||||
|
* `未揽收` 也不算(只是打了单,快递还没来取)。
|
||||||
|
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
|
||||||
|
*/
|
||||||
|
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
|
||||||
|
'已揽收', '运输中', '派送中', '已签收', '拒收',
|
||||||
|
]);
|
||||||
|
|
||||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||||
@ -643,6 +709,10 @@ module.exports = {
|
|||||||
VOID_QUESTION_STATUS,
|
VOID_QUESTION_STATUS,
|
||||||
VOID_ORDER_LABEL_KEYWORDS,
|
VOID_ORDER_LABEL_KEYWORDS,
|
||||||
AFTERSALE_VOID_STATUS,
|
AFTERSALE_VOID_STATUS,
|
||||||
|
STATE_ORDER_VOID_STATUS,
|
||||||
|
STATE_ORDER_SHIPPED_STATUS,
|
||||||
|
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||||
|
IO_VOID_STATUS,
|
||||||
SHIP_TIMELINESS_HOURS,
|
SHIP_TIMELINESS_HOURS,
|
||||||
toNumber,
|
toNumber,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效 —— 服务层
|
* 仓储中心发货时效 —— 服务层
|
||||||
*
|
*
|
||||||
* ═══════════════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
* 🔥 2026-10-28 数据源与起算点「双切换」(业务 yanhui 定版)
|
* 🔥 2026-10-28 数据源切换 + 起算点二次纠偏(业务 yanhui 定版)
|
||||||
* ═══════════════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
* 业务原话:
|
* 数据源(已定版,不要动):
|
||||||
* ① 「核验抓取的销售订单表是错误的,应该在数据库抓取:订单 jst_Orders、
|
* rpt.jst_order_express_details(状态镜像)→
|
||||||
* 订单明细 jst_OrderItem,o_id → o_id」
|
* rpt.jst_Orders + rpt.jst_qmcust_orderlist(**店铺互斥,必须都读**)
|
||||||
* ② 「审核时间是 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_time **漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
|
||||||
* 订单表已有 send_date 的 **563 单**、status 已是 Sent 的 492 单;
|
* 订单表已有 send_date 的 **563 单**、status 已是 Sent 的 492 单;
|
||||||
* ② 被合并 / 拆单父单**不回写**:另有 Merged 89 + Split 755 单被冤枉;
|
* ② 被合并 / 拆单父单**不回写**:另有 Merged 89 + Split 755 单被冤枉;
|
||||||
@ -25,27 +19,48 @@
|
|||||||
* ④ 两表发货时间**同源**:send_time vs send_date 27387 单逐条比对**零偏差**
|
* ④ 两表发货时间**同源**:send_time vs send_date 27387 单逐条比对**零偏差**
|
||||||
* ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
* ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
||||||
*
|
*
|
||||||
* 起算点换 created 的影响(业务已确认):
|
* 🔥 起算点:**审核时间 `last_oa_confirm_time`**(2026-10-28 下午二次纠偏)
|
||||||
* created 100% 早于 last_oa_confirm_time(近 7 天 26086 单无一例外),
|
* ─────────────────────────────────────────────────────────────────────────
|
||||||
* 正常单差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时。
|
* 下午那一版曾把起算点改成 `created`(订单创建时间),理由是「审核时间就是
|
||||||
* 2026-10-27 18:00 轮实测:按审核 147 单 / 均等 4.5h → 按创建 272 单 / 均等 8.5h。
|
* 订单创建时间」。**这个判断是错的**,业务当场拿 9837824 打回来:
|
||||||
*
|
*
|
||||||
* ⚠️ 2026-10-20 那次「按审核时间算」的纠偏已被本版覆盖,别再把基准时间
|
* 9837824(崔桥工厂仓,京喜-橡野里家具工厂店)逐字段:
|
||||||
* 改回 auditAt —— 那是上一轮的定版,业务 2026-10-28 明确要求按创建时间。
|
* order_date 10-19 23:43:35 / created 10-19 23:44:22 / pay_date 10-19 23:44:05
|
||||||
|
* last_oa_confirm_time **10-28 09:58:42**(= first_oa_confirm_time)
|
||||||
|
* 运单 JDVB71664383839,10:09:55 快递员上门取件
|
||||||
|
* ⟹ 10-19 下的单压了 9 天才审核,仓库审核后 11 分钟就打单,
|
||||||
|
* 按 created 算它「超时 205 小时」,纯属把审核积压算到仓库头上。
|
||||||
|
*
|
||||||
|
* 量化(10-28 13:00 轮 599 单):376 单是**今天 07:00 之后**才审核的
|
||||||
|
* (08:10~10:30 集中批量审核,多为 10-17~10-27 积压单),491 单其实已有运单号。
|
||||||
|
* 改口径后实测:599 → 144,再剔除 6 单「状态表 send_time 有值、订单表 send_date 空」
|
||||||
|
* 的漏回写单 → **138**。
|
||||||
|
*
|
||||||
|
* ⚠️ 别再被「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟」误导:
|
||||||
|
* 那只统计了当期正常单,长尾积压单能差 8~11 天。判断起算点必须看全量分布。
|
||||||
|
*
|
||||||
|
* 为什么用 `last_oa_confirm_time` 而不是 `first_oa_confirm_time`(业务 2026-10-28 选定):
|
||||||
|
* 客服改单重审会刷新 last,业务认为重审后订单重新进仓库作业流,应从最后一次
|
||||||
|
* 审核重新计时。实测 599 单里 first==last 366 单,first<last 24 单(最长推后 5.8 天)。
|
||||||
|
* ⚠️ 这意味着**重复审核会把已累计的超时冲掉**,业务已知悉并接受。
|
||||||
|
*
|
||||||
|
* 审核时间为空(实测 599 单里 209 单,多为天猫/抖音/拼多多,136 单其实已打单)
|
||||||
|
* → **回退 order created** 参与分轮与计时,`benchmarkSource='created'`,
|
||||||
|
* 页面该列打 `*` 标注,钉钉/导出同步标注。别默默丢掉这些单。
|
||||||
*
|
*
|
||||||
* ── 三条铁律(不变)──────────────────────────────────────────────────────
|
* ── 三条铁律(不变)──────────────────────────────────────────────────────
|
||||||
* ① 轮次归属看起算点:今天 07:00 前创建 → 13:00 轮;
|
* ① 轮次归属看起算点:今天 07:00 前**审核** → 13:00 轮;
|
||||||
* 截止今天 16:00 前创建 → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。
|
* 截止今天 16:00 前**审核** → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。
|
||||||
* **历史未发单一律算**(昨天及更早创建、至今没发的,两个轮次都要看到)。
|
* **历史未发单一律算**(昨天及更早审核、至今没发的,两个轮次都要看到)。
|
||||||
* ② 没有「满 24 小时」门槛:到核验时刻仍未发就是超时。
|
* ② 没有「满 24 小时」门槛:到核验时刻仍未发就是超时。
|
||||||
* ③ 自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核(按快递名包含匹配,
|
* ③ 自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核(按快递名包含匹配,
|
||||||
* 武进安能只剔这一个网点,**别泛化成「安能」**,常州安能新等仍考核),
|
* 武进安能只剔这一个网点,**别泛化成「安能」**,常州安能新等仍考核),
|
||||||
* 剔除量记 summary.carrierSkipped。
|
* 剔除量记 summary.carrierSkipped。
|
||||||
*
|
*
|
||||||
* 已等时长 = 创建时间 → 本轮核验时刻(13:00 / 18:00)。
|
* 已等时长 = 审校起算点 → 本轮核验时刻(13:00 / 18:00)。
|
||||||
*
|
*
|
||||||
* ── 订单表字段口径(已逐一实测)───────────────────────────────────────────
|
* ── 订单表字段口径(已逐一实测)───────────────────────────────────────────
|
||||||
* · created —— ✅ **本页起算点**(聚水潭订单创建时间)
|
* · created —— 聚水潭订单创建时间(**只做兜底与展示**,不是起算点)
|
||||||
* · order_date —— 平台下单时间(客户侧,比 created 早 15~30 秒)
|
* · order_date —— 平台下单时间(客户侧,比 created 早 15~30 秒)
|
||||||
* · pay_date —— 付款时间(并列展示)
|
* · pay_date —— 付款时间(并列展示)
|
||||||
* · send_date —— 发货时间(**未发单必为空**,已发 100% 有值)
|
* · send_date —— 发货时间(**未发单必为空**,已发 100% 有值)
|
||||||
@ -60,7 +75,7 @@
|
|||||||
* · modified —— ⚠️ 每天凌晨被批量刷新,**不可用**
|
* · modified —— ⚠️ 每天凌晨被批量刷新,**不可用**
|
||||||
*
|
*
|
||||||
* ── 三态分流(互斥,全部由订单表 status 决定)─────────────────────────────
|
* ── 三态分流(互斥,全部由订单表 status 决定)─────────────────────────────
|
||||||
* 超时未发货 = status='Delivering' + send_date 空 + created 已过窗口上界 → orders
|
* 超时未发货 = status='Delivering' + send_date 空 + 审核时间已过窗口上界 → orders
|
||||||
* 不计考核 = status='WaitConfirm'(待审核)/ 'Question'(异常) → heldOrders
|
* 不计考核 = status='WaitConfirm'(待审核)/ 'Question'(异常) → heldOrders
|
||||||
* 不展示 = Sent / Cancelled / Split / Merged / WaitOuterSent → SQL 侧已排除
|
* 不展示 = Sent / Cancelled / Split / Merged / WaitOuterSent → SQL 侧已排除
|
||||||
* ⚠️ 作废(Cancelled,业务叫「作废单」)近 30 天 66643 单,是 Delivering 1446 单的 46 倍,
|
* ⚠️ 作废(Cancelled,业务叫「作废单」)近 30 天 66643 单,是 Delivering 1446 单的 46 倍,
|
||||||
@ -92,6 +107,10 @@ const {
|
|||||||
ORDER_QUESTION_STATUS,
|
ORDER_QUESTION_STATUS,
|
||||||
ORDER_CANDIDATE_STATUSES,
|
ORDER_CANDIDATE_STATUSES,
|
||||||
VOID_ORDER_LABEL_KEYWORDS,
|
VOID_ORDER_LABEL_KEYWORDS,
|
||||||
|
STATE_ORDER_VOID_STATUS,
|
||||||
|
STATE_ORDER_SHIPPED_STATUS,
|
||||||
|
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||||
|
IO_VOID_STATUS,
|
||||||
resolveCenter,
|
resolveCenter,
|
||||||
} = registry;
|
} = registry;
|
||||||
|
|
||||||
@ -103,6 +122,22 @@ const MAX_DETAIL_LIMIT = 2000;
|
|||||||
// 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。
|
// 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。
|
||||||
const STATE_STALE_MINUTES = 90;
|
const STATE_STALE_MINUTES = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个**订单表数据源**的滞后阈值(分钟)。
|
||||||
|
*
|
||||||
|
* 🔥 为什么必须按源单独判(2026-10-28 业务第二次质疑「18:00 轮基本都发了」时定型):
|
||||||
|
* 订单两表是**各自独立同步**的,同一天可以一个正常一个停更 ——
|
||||||
|
* 18:35 实测:`jst_Orders` 最后更新 **14:40**(停 4 小时),
|
||||||
|
* 而 `jst_qmcust_orderlist` 更新到 **17:30**(正常)。
|
||||||
|
* 只取候选行的 `MAX(updated_at)` 会被"碰巧新"的行掩盖,必须直接问源表。
|
||||||
|
*
|
||||||
|
* 影响:该源停更 ⟹ 停更之后发出的单,`send_date` 仍是 NULL ⟹ 被判「未发货」。
|
||||||
|
* 18:00 轮 546 单里 **540 单来自 jst_Orders**,全部是这种假超时。
|
||||||
|
*
|
||||||
|
* 所以停更源的单在页面上必须**明确标注**,绝不静默当成「确认未发」。
|
||||||
|
*/
|
||||||
|
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WST_SOURCE_STALE_MINUTES) || 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
|
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
|
||||||
* 自提 —— 客户自己来拉,仓库不负责派送
|
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||||
@ -208,6 +243,20 @@ function formatDateTime(value) {
|
|||||||
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
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 */
|
/** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */
|
||||||
function diffMinutes(fromText, toText) {
|
function diffMinutes(fromText, toText) {
|
||||||
const a = formatDateTime(fromText);
|
const a = formatDateTime(fromText);
|
||||||
@ -236,6 +285,11 @@ function diffMinutes(fromText, toText) {
|
|||||||
* Sent/Cancelled/Split/Merged/WaitOuterSent 直接不进候选
|
* Sent/Cancelled/Split/Merged/WaitOuterSent 直接不进候选
|
||||||
* · `created >= windowStart` —— 30 天护栏(`HISTORY_LOOKBACK_DAYS`)
|
* · `created >= windowStart` —— 30 天护栏(`HISTORY_LOOKBACK_DAYS`)
|
||||||
*
|
*
|
||||||
|
* ⚠️ 这里的 SQL 只是**粗筛**:起算点是审核时间(在状态表里),SQL 拿不到,
|
||||||
|
* 所以「哪一轮」由 Node 侧按起算点再判一次。护栏按 `created` 卡,
|
||||||
|
* 理论上「创建超过 30 天、今天才审核」的单会被漏掉 —— 实测极罕见
|
||||||
|
* (最老积压单创建 10-17,审核 10-28,差 11 天),暂不放宽,别顺手改成去掉护栏。
|
||||||
|
*
|
||||||
* 仓库:订单表给的是 `wms_co_id`(不是仓名文本),主查询**只回这个 id**,
|
* 仓库:订单表给的是 `wms_co_id`(不是仓名文本),主查询**只回这个 id**,
|
||||||
* 由 Node 侧用 `registry.loadWarehouseNameMap()` 的缓存换仓名,再走
|
* 由 Node 侧用 `registry.loadWarehouseNameMap()` 的缓存换仓名,再走
|
||||||
* `resolveCenter()` 关键词归属 —— 比状态表的仓名更准(同一仓在状态表里有
|
* `resolveCenter()` 关键词归属 —— 比状态表的仓名更准(同一仓在状态表里有
|
||||||
@ -274,12 +328,13 @@ async function loadPendingOrders(round) {
|
|||||||
const rows = await cloudDatabase.querySlow(
|
const rows = await cloudDatabase.querySlow(
|
||||||
`SELECT x.o_id, x.so_id, x.shop_name, x.status, x.created, x.pay_date,
|
`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.logistics_company, x.l_id, x.presaleVoid, x.src,
|
||||||
x.updated_at, x.wms_co_id
|
x.updated_at, x.wms_co_id, x.modified
|
||||||
FROM (
|
FROM (
|
||||||
SELECT o.o_id, o.so_id, o.shop_name, o.status, o.created, o.pay_date,
|
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,
|
o.logistics_company, o.l_id, o.wms_co_id,
|
||||||
${presaleSql} AS presaleVoid,
|
${presaleSql} AS presaleVoid,
|
||||||
o.updateTime AS updated_at,
|
o.updateTime AS updated_at,
|
||||||
|
o.modified,
|
||||||
N'jst_Orders' AS src
|
N'jst_Orders' AS src
|
||||||
FROM rpt.jst_Orders o WITH (NOLOCK)
|
FROM rpt.jst_Orders o WITH (NOLOCK)
|
||||||
WHERE o.send_date IS NULL
|
WHERE o.send_date IS NULL
|
||||||
@ -290,6 +345,7 @@ async function loadPendingOrders(round) {
|
|||||||
c.logistics_company, c.l_id, c.wms_co_id,
|
c.logistics_company, c.l_id, c.wms_co_id,
|
||||||
${presaleSql} AS presaleVoid,
|
${presaleSql} AS presaleVoid,
|
||||||
c.local_updated AS updated_at,
|
c.local_updated AS updated_at,
|
||||||
|
c.modified,
|
||||||
N'jst_qmcust_orderlist' AS src
|
N'jst_qmcust_orderlist' AS src
|
||||||
FROM rpt.jst_qmcust_orderlist c WITH (NOLOCK)
|
FROM rpt.jst_qmcust_orderlist c WITH (NOLOCK)
|
||||||
WHERE c.send_date IS NULL
|
WHERE c.send_date IS NULL
|
||||||
@ -304,6 +360,284 @@ async function loadPendingOrders(round) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量取**状态镜像表**里的单号级信息(起算点 + 终态校验)。
|
||||||
|
*
|
||||||
|
* 为什么必须回状态表拿:
|
||||||
|
* · `jst_Orders` / `jst_qmcust_orderlist` **都没有审核时间字段**(85 列逐一看过,
|
||||||
|
* 只有 order_date / pay_date / created / modified / updateTime,前者是下单、
|
||||||
|
* 后者是灌库时间,`modified` 还会被每天凌晨批量刷新);
|
||||||
|
* · 状态表 `first_oa_confirm_time` / `last_oa_confirm_time` 才是聚水潭的审核时间。
|
||||||
|
* ⚠️ 只从它拿**这一个字段**,候选取舍(哪些单未发货)仍然只看订单两表 ——
|
||||||
|
* 状态表的 send_time 漏回写、会整批停更,那些坑一个都不能重新踩回来。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 扩展:**不止取时间,还要取终态**。
|
||||||
|
* 订单两表会各自停更(当天 qmcust 停在 07:25),停更期间聚水潭里的「取消 / 发货」
|
||||||
|
* 订单表根本看不到 —— 订单 status 仍是 Delivering、send_date 仍是空。
|
||||||
|
* 而状态表虽也停(11:52),却比订单表新鲜 4 小时,`order_status_name` 已经变成
|
||||||
|
* 「已取消」/「已发货」。业务当天报的「作废单还在考核里」11 单就是这么来的:
|
||||||
|
* 订单表 labels 里**没有**「发货前售后」,`presaleVoid` 完全失效。
|
||||||
|
*
|
||||||
|
* 业务口径(2026-10-28):起算点用 `last_oa_confirm_time`(业务选「最后审核时间」,
|
||||||
|
* 理由见文件头)。空值不在这里兜,交给调用方回退 `created`。
|
||||||
|
*
|
||||||
|
* 性能:`inner_order_id IN (...)` 一次 600 个约 **15s**(全表扫一次,402 万行视图)。
|
||||||
|
* 🔥 千万别改成逐单查 —— 实测单条 **14.5s**,599 条要 2.4 小时。
|
||||||
|
* 分批 600 是因为 T-SQL 单条语句 IN 列表太长会解析超时;两批各 600 / 剩余。
|
||||||
|
* 多取 4 个语义列(`order_status_name` 等)是同一批 IN 同一次扫描,
|
||||||
|
* **不增加往返**,实测耗时无变化。
|
||||||
|
*
|
||||||
|
* 失败降级:抛错由调用方 catch → 全部回退 created,并在 payload 里标 degraded,
|
||||||
|
* **不要因为状态表挂了就整页 500**(候选池在订单表,本来就有数据)。
|
||||||
|
*/
|
||||||
|
async function loadStateInfo(stateRows) {
|
||||||
|
const ids = [...new Set(
|
||||||
|
(stateRows || []).map((r) => Number(r.o_id)).filter((n) => Number.isFinite(n))
|
||||||
|
)];
|
||||||
|
const map = new Map();
|
||||||
|
if (!ids.length) return map;
|
||||||
|
|
||||||
|
// 🔥 2026-10-28 下午:串行 9 批实测 **135s**(全并行 9 批只要 6s,并行 3 批 28.8s)。
|
||||||
|
// 生产上「主查询 63~105s + 状态表 135s」直接把 /list 顶过 300s 超时,
|
||||||
|
// 页面打不开 —— 所以**必须并发**。并发 6 是实测过的安全值:
|
||||||
|
// 单批约 15s,6 并发后整体 10~15s,且不至于把 402 万行视图的连接池打爆。
|
||||||
|
const BATCH = 600;
|
||||||
|
const CONCURRENCY = 6;
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < ids.length; i += BATCH) {
|
||||||
|
chunks.push(ids.slice(i, i + BATCH));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i += CONCURRENCY) {
|
||||||
|
const group = chunks.slice(i, i + CONCURRENCY);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const results = await Promise.all(group.map((chunk) => {
|
||||||
|
// ids 已用 Number.isFinite 过滤过,拼进 IN 是纯数字,无注入面
|
||||||
|
return cloudDatabase.querySlow(
|
||||||
|
`SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time,
|
||||||
|
order_status_name, aftersale_status_name, logistic_status_name,
|
||||||
|
order_labels, query_time
|
||||||
|
FROM rpt.jst_order_express_details WITH (NOLOCK)
|
||||||
|
WHERE inner_order_id IN (${chunk.join(',')})`,
|
||||||
|
{},
|
||||||
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
for (const rows of results) {
|
||||||
|
for (const r of rows || []) {
|
||||||
|
map.set(Number(r.inner_order_id), {
|
||||||
|
first: formatDateTime(r.first_oa_confirm_time),
|
||||||
|
last: formatDateTime(r.last_oa_confirm_time),
|
||||||
|
// 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况
|
||||||
|
// (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空)
|
||||||
|
sendTime: formatDateTime(r.send_time),
|
||||||
|
// ── 终态校验(2026-10-29 新增)────────────────────────────────
|
||||||
|
// 这两个是**聚水潭订单状态**的直接映射,订单表停更时只有它靠得住
|
||||||
|
orderStatus: String(r.order_status_name || '').trim(),
|
||||||
|
aftersaleStatus: String(r.aftersale_status_name || '').trim(),
|
||||||
|
logisticStatus: String(r.logistic_status_name || '').trim(),
|
||||||
|
// 状态表自己的抓取时刻:同一单可能 08:50 抓过就没再抓,判据可信度要看它
|
||||||
|
queryTime: formatDateTime(r.query_time),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量取**销售出库单**的状态 —— 判「这单在聚水潭里被作废了吗」。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 新增。业务原话:「9992097…这些订单都是作废订单,为啥还在考核之内」,
|
||||||
|
* 并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。
|
||||||
|
*
|
||||||
|
* 为什么必须补这一层(订单表 + 状态镜像表都不够):
|
||||||
|
* · 订单表 `labels` 的「发货前售后」判据,在停更/正常数据上都会漏(15:00 那批 0 命中);
|
||||||
|
* · 状态镜像表 `order_status_name='已取消'` 一天只抓几次,抓不到的作废就漏;
|
||||||
|
* · **出库单 `status='Delete'` 才是仓库侧最贴身的作废痕迹** —— 近 7 天实测:
|
||||||
|
* 出库单被删的 2209 单里,**55 单订单表仍是 `Delivering`、36 单 `Question`**,
|
||||||
|
* 这批人只能靠出库单判出来。
|
||||||
|
*
|
||||||
|
* 判据边界(血泪):
|
||||||
|
* · 一个订单可能有多张出库单(拆包 / 重开)→ **必须「该 o_id 下没有任何非 Delete 的出库单」**
|
||||||
|
* 才算作废。实测近 7 天 51 个「订单发货中但出库单被删」的单里,有 7 个挂着 2~8 张
|
||||||
|
* 出库单且全为 Delete(拆包重开),不能凭「有一张 Delete」就剔。
|
||||||
|
* · **没有出库单 ≠ 作废**:近 3 天未发单 **82%(1467/1790)压根没有出库单**
|
||||||
|
* (订单还没推下游),拿它判作废会把正常待发单全放跑。
|
||||||
|
*
|
||||||
|
* 性能:按来源表分别 `o_id IN (...)`,一批 500、并发 3(出库单表比状态表小得多,
|
||||||
|
* 实测单批 1~3s)。查询失败 → 返回空 Map,**不阻断页面**(判据退化成「不判」)。
|
||||||
|
*
|
||||||
|
* 🔴 2026-10-29 13:10 实测:两张出库单表 `local_updated` 一起停在 **03:10**(停更 11h),
|
||||||
|
* `modified` 也全是凌晨的。停更期间业务新作废的单在这里**永远是 WaitConfirm** ——
|
||||||
|
* 所以出库单也必须进 `loadSourceFreshness`,否则「查不到作废」会被误读成「没作废」。
|
||||||
|
*
|
||||||
|
* @param {Array} orderRows 候选订单行(含 `o_id` / `src`)
|
||||||
|
* @returns {Promise<Map<number, {status:string, statuses:string[], allDeleted:boolean, count:number}>>}
|
||||||
|
*/
|
||||||
|
async function loadOrderIoStatus(orderRows) {
|
||||||
|
const TABLE_BY_SRC = {
|
||||||
|
jst_Orders: 'rpt.jst_salesOutboundInfo',
|
||||||
|
jst_qmcust_orderlist: 'rpt.jst_qmcust_salesoutbound',
|
||||||
|
};
|
||||||
|
// src → 去重后的 o_id 列表
|
||||||
|
const idsBySrc = new Map();
|
||||||
|
for (const r of orderRows || []) {
|
||||||
|
const src = String(r.src || '');
|
||||||
|
if (!TABLE_BY_SRC[src]) continue;
|
||||||
|
const oid = Number(r.o_id);
|
||||||
|
if (!Number.isFinite(oid)) continue;
|
||||||
|
if (!idsBySrc.has(src)) idsBySrc.set(src, new Set());
|
||||||
|
idsBySrc.get(src).add(oid);
|
||||||
|
}
|
||||||
|
const result = new Map();
|
||||||
|
if (!idsBySrc.size) return result;
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
const CONCURRENCY = 3;
|
||||||
|
const tasks = [];
|
||||||
|
for (const [src, idSet] of idsBySrc) {
|
||||||
|
const ids = [...idSet];
|
||||||
|
for (let i = 0; i < ids.length; i += BATCH) {
|
||||||
|
tasks.push({ src, chunk: ids.slice(i, i + BATCH) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < tasks.length; i += CONCURRENCY) {
|
||||||
|
const group = tasks.slice(i, i + CONCURRENCY);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const settled = await Promise.allSettled(group.map((t) => cloudDatabase.querySlow(
|
||||||
|
// o_id 已用 Number.isFinite 过滤,拼进 IN 是纯数字,无注入面
|
||||||
|
`SELECT o_id, io_id, status FROM ${TABLE_BY_SRC[t.src]} WITH (NOLOCK)
|
||||||
|
WHERE o_id IN (${t.chunk.join(',')})`,
|
||||||
|
{},
|
||||||
|
{ connectRequestTimeoutMs: 120000 },
|
||||||
|
)));
|
||||||
|
for (const s of settled) {
|
||||||
|
if (s.status !== 'fulfilled') continue; // 单批失败不影响其它批
|
||||||
|
for (const r of s.value || []) {
|
||||||
|
const oid = Number(r.o_id);
|
||||||
|
if (!Number.isFinite(oid)) continue;
|
||||||
|
const st = String(r.status || '').trim();
|
||||||
|
if (!result.has(oid)) result.set(oid, { statuses: [], alive: 0, dead: 0 });
|
||||||
|
const rec = result.get(oid);
|
||||||
|
if (st && !rec.statuses.includes(st)) rec.statuses.push(st);
|
||||||
|
if (st === IO_VOID_STATUS) rec.dead += 1;
|
||||||
|
else rec.alive += 1; // 含空状态:认不出就当还活着(宁可漏判,不误剔)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Map();
|
||||||
|
for (const [oid, rec] of result) {
|
||||||
|
out.set(oid, {
|
||||||
|
// 页面展示用:优先显示「还有效」的那个状态(业务一眼看出这单的进度)
|
||||||
|
status: rec.alive > 0 ? (rec.statuses.find((s) => s !== IO_VOID_STATUS) || '') : IO_VOID_STATUS,
|
||||||
|
statuses: rec.statuses,
|
||||||
|
// 唯一可信的作废判据:有出库单、且一张都没活着
|
||||||
|
allDeleted: rec.dead > 0 && rec.alive === 0,
|
||||||
|
count: rec.dead + rec.alive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 各数据源**各自**的最后更新时间,用于判断「这个源还活着吗」。
|
||||||
|
*
|
||||||
|
* 🔥 为什么不能只看候选行:候选池本身就是 `send_date IS NULL` 的单,
|
||||||
|
* 某张表停更后它的候选单 `updateTime` 会集体冻在停更那一刻,看起来「都一样旧」;
|
||||||
|
* 但若停更表里恰好有几单新改过,`MAX` 就假装新鲜了 —— 必须直接问源表。
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* · `jst_Orders` → `MAX(updateTime)`(5 分钟一批的增量同步时间)
|
||||||
|
* · `jst_qmcust_orderlist` → `MAX(local_updated)`(该表没有 updateTime 列)
|
||||||
|
* · 只扫 30 天窗口(`round.windowStart` 起),不然要全表扫描
|
||||||
|
* · 任一源查询失败 → 该源记 `lastUpdate:null`、`stale:false`,
|
||||||
|
* **不谎报停更**(查不到 ≠ 停更),但会在 `error` 里留痕
|
||||||
|
*
|
||||||
|
* @param {object} round 轮次(用 windowStart 做扫描下界)
|
||||||
|
* @param {Date} now 当前时间(已按北京时间取)
|
||||||
|
* @returns {Promise<Array<{key:string,label:string,lastUpdate:string,lagMinutes:number|null,stale:boolean,error?:string}>>}
|
||||||
|
*/
|
||||||
|
async function loadSourceFreshness(round, now) {
|
||||||
|
const startLit = String(round.windowStart || '').slice(0, 19);
|
||||||
|
const nowText = formatDateTime(now);
|
||||||
|
const defs = [
|
||||||
|
{
|
||||||
|
key: 'jst_Orders',
|
||||||
|
label: 'jst_Orders',
|
||||||
|
sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_Orders WITH (NOLOCK)
|
||||||
|
WHERE updateTime >= '${startLit}'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jst_qmcust_orderlist',
|
||||||
|
label: 'qmcust',
|
||||||
|
sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_orderlist WITH (NOLOCK)
|
||||||
|
WHERE local_updated >= '${startLit}'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 🔥 2026-10-29 新增:状态镜像表也必须监控。
|
||||||
|
// 它是「这单取消了吗 / 发出了吗」的唯一权威来源(订单表会停更),
|
||||||
|
// 当天它停在 11:52,而 13:00 轮的超时判定就建立在这份 11:52 的快照上。
|
||||||
|
// 不监控它 ⟹ 页面只说「订单表停了」,实际判据也已经过期 1.5 小时。
|
||||||
|
key: 'jst_order_express_details',
|
||||||
|
label: '状态镜像表',
|
||||||
|
// 这张表没有 updateTime 列,用 `updatedAt`(ETL 写入时刻)
|
||||||
|
sql: `SELECT MAX(updatedAt) AS maxUpd FROM rpt.jst_order_express_details WITH (NOLOCK)
|
||||||
|
WHERE updatedAt >= '${startLit}'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 🔥 2026-10-29 新增:两张**销售出库单**表也必须监控。
|
||||||
|
// 它是「单据作废了吗」的唯一来源(订单表停更、状态表一天只抓几次)。
|
||||||
|
// 实测当天它俩的 `local_updated` **一起停在 03:10**(停更 11 小时),
|
||||||
|
// 而业务上午在聚水潭作的废全落在这张表上 —— 页面看到的是 11 小时前的
|
||||||
|
// `WaitConfirm`(待出库),于是那批单永远「查不出作废」。
|
||||||
|
// ⚠️ 判断必须用 `local_updated / updateTime`(灌库时刻),
|
||||||
|
// **不能用 `modified`**(业务字段)—— 没有单据变更时它天然是旧的,
|
||||||
|
// 拿它判停更会天天误报(这个坑在 `jst_inventoryInfo` 上踩过一次)。
|
||||||
|
key: 'jst_salesOutboundInfo',
|
||||||
|
label: '出库单(聚水潭)',
|
||||||
|
sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_salesOutboundInfo WITH (NOLOCK)
|
||||||
|
WHERE updateTime >= '${startLit}'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jst_qmcust_salesoutbound',
|
||||||
|
label: '出库单(奇门)',
|
||||||
|
sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_salesoutbound WITH (NOLOCK)
|
||||||
|
WHERE local_updated >= '${startLit}'`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 两个源并行查(各自 60s 上限,任一个慢不拖累另一个)
|
||||||
|
const settled = await Promise.allSettled(defs.map((d) => cloudDatabase.querySlow(
|
||||||
|
d.sql, {}, { connectRequestTimeoutMs: 120000 },
|
||||||
|
)));
|
||||||
|
|
||||||
|
return defs.map((d, i) => {
|
||||||
|
const s = settled[i];
|
||||||
|
if (s.status !== 'fulfilled') {
|
||||||
|
return {
|
||||||
|
key: d.key,
|
||||||
|
label: d.label,
|
||||||
|
lastUpdate: '',
|
||||||
|
lagMinutes: null,
|
||||||
|
stale: false,
|
||||||
|
error: String((s.reason && s.reason.message) || s.reason || '查询失败').slice(0, 160),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rows = s.value || [];
|
||||||
|
const lastUpdate = formatDateTime(rows[0] && rows[0].maxUpd);
|
||||||
|
const lagMinutes = lastUpdate ? Math.max(0, diffMinutes(lastUpdate, nowText)) : null;
|
||||||
|
return {
|
||||||
|
key: d.key,
|
||||||
|
label: d.label,
|
||||||
|
lastUpdate,
|
||||||
|
lagMinutes,
|
||||||
|
// 查不到更新时间时**不判停更**(宁可漏报,也不谎报)
|
||||||
|
stale: lagMinutes != null && lagMinutes > SOURCE_STALE_MINUTES,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function emptySummary() {
|
function emptySummary() {
|
||||||
return {
|
return {
|
||||||
@ -317,6 +651,18 @@ function emptySummary() {
|
|||||||
voidCount: 0,
|
voidCount: 0,
|
||||||
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能)
|
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能)
|
||||||
carrierSkipped: 0,
|
carrierSkipped: 0,
|
||||||
|
// 已剔除的「其实已发」单量:状态表 send_time 有值 / order_status_name='已发货'
|
||||||
|
// / 物流已揽收+ (订单表 send_date 空但聚水潭已经发了)
|
||||||
|
stateShippedSkipped: 0,
|
||||||
|
// 已剔除的「订单已取消」单量:状态表 order_status_name='已取消'
|
||||||
|
// ⚠️ 订单表停更期间这是**唯一**能判出作废的来源(labels 判据会整体失效)
|
||||||
|
stateVoidSkipped: 0,
|
||||||
|
// 已剔除的「出库单整体作废」单量:销售出库单 status 全为 Delete
|
||||||
|
// 🔥 2026-10-29 新增 —— 业务点名「洛宁仓 16 单都是作废订单」,聚水潭的作废落在出库单上
|
||||||
|
ioVoidSkipped: 0,
|
||||||
|
// 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。
|
||||||
|
// 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。
|
||||||
|
sourceStaleUnshipped: 0,
|
||||||
// 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到)
|
// 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到)
|
||||||
unknownWarehouse: 0,
|
unknownWarehouse: 0,
|
||||||
};
|
};
|
||||||
@ -399,6 +745,7 @@ async function getShipTimeliness(options = {}) {
|
|||||||
heldTotal: 0,
|
heldTotal: 0,
|
||||||
detailDegraded: false,
|
detailDegraded: false,
|
||||||
freshness: null,
|
freshness: null,
|
||||||
|
dataSources: [],
|
||||||
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -428,12 +775,58 @@ async function getShipTimeliness(options = {}) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ③.5 状态镜像表:起算点(审核时间)+ 终态(已取消 / 已发货)
|
||||||
|
// ⚠️ 状态表挂了不阻断页面:全量回退 created,并在 payload 里标降级
|
||||||
|
let stateInfoMap = new Map();
|
||||||
|
let oaConfirmDegraded = false;
|
||||||
|
try {
|
||||||
|
stateInfoMap = await loadStateInfo(stateRows);
|
||||||
|
} catch (e) {
|
||||||
|
oaConfirmDegraded = true;
|
||||||
|
console.warn('[WST] 状态镜像表查询失败,全量回退订单创建时间:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③.55 销售出库单状态:判「单据作废」(业务 2026-10-29 点名要求)
|
||||||
|
// ⚠️ 查不到不阻断页面 —— 判据退化成「不判作废」,由停更告警兜底提示不可信
|
||||||
|
let ioStatusMap = new Map();
|
||||||
|
let ioDegraded = false;
|
||||||
|
try {
|
||||||
|
ioStatusMap = await loadOrderIoStatus(stateRows);
|
||||||
|
} catch (e) {
|
||||||
|
ioDegraded = true;
|
||||||
|
console.warn('[WST] 销售出库单查询失败,作废判据降级:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ──────────
|
||||||
|
// 🔥 2026-10-28 18:35 实测:`jst_Orders` 停更在 14:40(4 小时),
|
||||||
|
// `jst_qmcust_orderlist` 正常到 17:30。那批「未发 546 单」里 540 单来自
|
||||||
|
// 停更的表 —— 它们的 send_date 全空纯粹是没同步,业务说「基本都发了」是对的。
|
||||||
|
// 只取候选行的 MAX(updated_at) 会被"碰巧新"的行掩盖,所以直接查源表。
|
||||||
|
let sourceFreshness = [];
|
||||||
|
try {
|
||||||
|
sourceFreshness = await loadSourceFreshness(round, now);
|
||||||
|
} catch (e) {
|
||||||
|
// 查不到新鲜度不阻断页面,但要在日志里留痕(页面会退化成"不标注")
|
||||||
|
console.warn('[WST] 数据源新鲜度查询失败:', e.message);
|
||||||
|
}
|
||||||
|
const sourceMap = new Map(sourceFreshness.map((s) => [s.key, s]));
|
||||||
|
|
||||||
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||||
let voidCount = 0;
|
let voidCount = 0;
|
||||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
|
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
|
||||||
let carrierSkipped = 0;
|
let carrierSkipped = 0;
|
||||||
|
// 订单表 send_date 为空、但状态表 send_time 有值(漏回写)→ 已发,剔除
|
||||||
|
let stateShippedSkipped = 0;
|
||||||
|
// 状态表 `order_status_name='已取消'` → 订单已作废,剔除(订单表停更时唯一可靠来源)
|
||||||
|
let stateVoidSkipped = 0;
|
||||||
|
// 出库单全部为 `Delete` → 单据作废,剔除(2026-10-29 新增,业务点名的作废单)
|
||||||
|
let ioVoidSkipped = 0;
|
||||||
// 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…)
|
// 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…)
|
||||||
let unknownWarehouse = 0;
|
let unknownWarehouse = 0;
|
||||||
|
// 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信
|
||||||
|
let sourceStaleUnshipped = 0;
|
||||||
|
// 按来源表分开计数(提示里要说「jst_Orders 540 单、qmcust 6 单」,不能只说「这张表」)
|
||||||
|
const sourceStaleBySrc = {};
|
||||||
|
|
||||||
// ④ 分桶
|
// ④ 分桶
|
||||||
const acc = new Map(centerList.map((c) => [c.key, {
|
const acc = new Map(centerList.map((c) => [c.key, {
|
||||||
@ -467,11 +860,46 @@ async function getShipTimeliness(options = {}) {
|
|||||||
const bucket = acc.get(center.key);
|
const bucket = acc.get(center.key);
|
||||||
if (!bucket) continue;
|
if (!bucket) continue;
|
||||||
|
|
||||||
// ── 起算点 = **订单创建时间**(业务 2026-10-28 定版)────────────────────
|
// ── 起算点 = **审核时间**(业务 2026-10-28 定版)────────────────────────
|
||||||
// 业务原话:「审核时间是 jst_Orders 这个表里面的创建时间」。
|
// 业务原话:「9837824 这个单的创建时间是 2026-10-28 09:58:42」—— 那是
|
||||||
// 实测 created 100% 早于状态表的 last_oa_confirm_time,正常差 7~40 分钟,
|
// 状态表的 last_oa_confirm_time,不是订单表的 created(10-19 23:44:22)。
|
||||||
// 夜间下单次日才审的单差 6~9 小时(详见 registry 起算点段的量化)。
|
// 积压单 created → 审核能差 8~11 天,按 created 算等于把审核积压
|
||||||
|
// 算到仓库头上(详见文件头 9837824 的证据)。
|
||||||
|
//
|
||||||
|
// 🔥 2026-10-28 下午再纠偏:**状态表的审核时间会严重滞后,必须和订单表
|
||||||
|
// `modified` 取较晚者**。铁证(9978643,业务给的):
|
||||||
|
// · qmcust.modified = 2026-10-28 14:52:29
|
||||||
|
// · 聚水潭页面真实审核时间 = 2026-10-28 14:52:28 ← 秒级吻合
|
||||||
|
// · 状态表 last_oa_confirm = 2026-10-27 17:08:43 ← 旧值,早了一天
|
||||||
|
// · 状态表 query_time = 2026-10-28 10:29:11 ← 10:29 后就没再抓过
|
||||||
|
// 只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时(冤枉仓库)。
|
||||||
|
// 更惨的是**审核时间为空的单**(13:00 轮 65 单里 57 单):回退 created 后,
|
||||||
|
// 明明是今天 13:40~15:03 才审核的单,被按 10-25~10-27 的下单时间算超时。
|
||||||
|
//
|
||||||
|
// ⟹ 口径:起算点 = max(状态表 last_oa_confirm_time, 订单表 modified),
|
||||||
|
// 两者都没有才回退 created。`modified` 是聚水潭源端的「最后修改时间」,
|
||||||
|
// 审核动作一定会刷新它;因客服改单/改备注而推后的情况属「放行」方向
|
||||||
|
// (少报超时,不冤枉仓库),业务已知悉。
|
||||||
const createdAt = formatDateTime(row.created);
|
const createdAt = formatDateTime(row.created);
|
||||||
|
const modifiedAt = formatDateTime(row.modified);
|
||||||
|
const stateInfo = stateInfoMap.get(Number(row.o_id));
|
||||||
|
// 状态镜像表里的聚水潭终态(订单表停更时唯一可信的「这单还发不发」)
|
||||||
|
const stateOrderStatus = stateInfo ? (stateInfo.orderStatus || '') : '';
|
||||||
|
const stateLogisticStatus = stateInfo ? (stateInfo.logisticStatus || '') : '';
|
||||||
|
// 销售出库单状态(2026-10-29 新增)—— 判「单据作废」的唯一可靠来源
|
||||||
|
const ioInfo = ioStatusMap.get(Number(row.o_id)) || null;
|
||||||
|
const ioStatus = ioInfo ? (ioInfo.status || '') : '';
|
||||||
|
const ioAllDeleted = Boolean(ioInfo && ioInfo.allDeleted);
|
||||||
|
|
||||||
|
const oaConfirmAt = stateInfo ? (stateInfo.last || '') : '';
|
||||||
|
// 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。
|
||||||
|
// · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据);
|
||||||
|
// · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据),
|
||||||
|
// 起算点绝不该早于下单时刻。
|
||||||
|
const benchmarkAt = laterOf(laterOf(oaConfirmAt, modifiedAt), createdAt) || createdAt;
|
||||||
|
let benchmarkSource = 'created';
|
||||||
|
if (benchmarkAt && benchmarkAt === modifiedAt) benchmarkSource = 'modified';
|
||||||
|
else if (benchmarkAt && benchmarkAt === oaConfirmAt) benchmarkSource = 'oa_confirm';
|
||||||
const payAt = formatDateTime(row.pay_date);
|
const payAt = formatDateTime(row.pay_date);
|
||||||
const statusRaw = String(row.status || '');
|
const statusRaw = String(row.status || '');
|
||||||
|
|
||||||
@ -480,14 +908,79 @@ async function getShipTimeliness(options = {}) {
|
|||||||
if (seenSo.has(dedupeKey)) continue;
|
if (seenSo.has(dedupeKey)) continue;
|
||||||
seenSo.add(dedupeKey);
|
seenSo.add(dedupeKey);
|
||||||
|
|
||||||
// ── 本轮核验范围(窗口上界按**创建时间**卡)────────────────────────────
|
// ══ 终态校验(2026-10-29 新增,**必须排在窗口判断和「不计考核」之前**)════
|
||||||
// 13:00 轮:今天 07:00 前创建的单 + 历史未发
|
// 🔥 背景:业务第三次报「作废的、已经发货的还在考核之内」。根因不是判定写错,
|
||||||
// 18:00 轮:今天 16:00 前创建的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00)
|
// 而是**订单两表各自停更**:当天 qmcust 停在 07:25(6 小时),停更之后在
|
||||||
|
// 聚水潭做的「取消 / 发货」,订单表里完全没反映 —— status 仍是 Delivering、
|
||||||
|
// send_date 仍是空,于是被当成真超时。13:00 轮 159 单**全部**来自停更的 qmcust。
|
||||||
|
//
|
||||||
|
// 状态镜像表虽也停(当天 11:52),但比订单表新鲜 4 小时,`order_status_name`
|
||||||
|
// 已经是聚水潭的订单终态。实测这 159 单里 11 单 `order_status_name='已取消'`,
|
||||||
|
// 而这 11 单在订单表 `labels` 里**根本没有**「发货前售后」——
|
||||||
|
// 原来的 `presaleVoid` 判据(订单表 labels 含关键词)在这批数据上 0 命中。
|
||||||
|
//
|
||||||
|
// ⚠️ **判据只用 `order_status_name`**,别用 `aftersale_status_name`:
|
||||||
|
// · `order_status_name='已取消'` → 订单真的取消了 → 不考核
|
||||||
|
// · `aftersale_status_name='已作废'` → **售后单被撤销,订单照常发货** → 仍要考核
|
||||||
|
// (近 2 天 1387 单全是这种,混为一谈会把该考核的单全放跑)
|
||||||
|
//
|
||||||
|
// 顺序:**先判取消、再判已发**。发了又被取消的单,两边都不该考核仓库,
|
||||||
|
// 但归到「作废」比归到「已发」更贴合业务口径(业务对账看的是作废量)。
|
||||||
|
if (stateOrderStatus === STATE_ORDER_VOID_STATUS) {
|
||||||
|
voidCount += 1;
|
||||||
|
stateVoidSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 作废②:销售出库单整体作废(2026-10-29 新增,业务点名的那批单)──────────
|
||||||
|
// 🔥 业务原话:「9992097 / 9992098 / 9992924 … 洛宁仓 16 单,这些订单都是作废订单,
|
||||||
|
// 为啥还在考核之内」,并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。
|
||||||
|
//
|
||||||
|
// 聚水潭的作废落在**出库单**上(`status='Delete'`),而不是订单表的 labels。
|
||||||
|
// 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`** ——
|
||||||
|
// 订单表和状态表都可能抓不到,只有出库单能判出来。
|
||||||
|
//
|
||||||
|
// ⚠️ **必须「该 o_id 下没有任何非 Delete 出库单」才算作废**:
|
||||||
|
// 拆包 / 重开的单会有多张出库单,删掉旧的那张、新的还在 WaitConfirm —— 照常要发。
|
||||||
|
// 实测近 7 天 51 个「订单发货中但出库单被删」的单里,7 个挂着 2~8 张全 Delete 的出库单。
|
||||||
|
// ⚠️ **「没有出库单」绝不能判作废**:近 3 天未发单 82%(1467/1790)压根没出库单。
|
||||||
|
//
|
||||||
|
// 🔴 **只对「发货中」生效,异常单/待审核单不碰**(判据放在 here、held 判断之前用
|
||||||
|
// `statusRaw === ORDER_PENDING_STATUS` 卡住)。原因:实测全库「出库单全 Delete」
|
||||||
|
// 的候选单里 **127/131 状态表是「异常」**,那批单在聚水潭还挂着待处理,
|
||||||
|
// 出库单被删更可能是「重新建单」,不是作废 —— 当成作废剔掉会让业务
|
||||||
|
// 在「不计考核」里也看不到它们,是**丢可见性**,比漏剔更糟。
|
||||||
|
if (ioAllDeleted && statusRaw === ORDER_PENDING_STATUS) {
|
||||||
|
voidCount += 1;
|
||||||
|
ioVoidSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 已发:三条任一成立即算(状态表比订单表可信得多)────────────────────
|
||||||
|
// ① `send_time` 有值 —— 订单表 send_date 漏回写时的兜底
|
||||||
|
// (2026-10-28 实测 144 单里 6 单是这种)
|
||||||
|
// ② `order_status_name='已发货'` —— 聚水潭订单终态(近 2 天 15.4 万单)
|
||||||
|
// ③ `logistic_status_name` ∈ 已揽收 / 运输中 / 派送中 / 已签收 / 拒收
|
||||||
|
// —— 快递已经把货拿走了。`拒收` 也算:仓库的发货动作已完成。
|
||||||
|
// ⚠️ **`未揽收` / `无物流单` 都不算**:前者只是打了单快递没来取,
|
||||||
|
// 后者连快递单都没录(当天 39/159 单是这种),都是真未发。
|
||||||
|
// ⚠️ 也别拿 `logistic_time` 当发货时间 —— 它多数是「快递员将上门取件」的预告。
|
||||||
|
const shippedByState = (stateInfo && stateInfo.sendTime)
|
||||||
|
|| stateOrderStatus === STATE_ORDER_SHIPPED_STATUS
|
||||||
|
|| STATE_LOGISTIC_SHIPPED_STATUSES.includes(stateLogisticStatus);
|
||||||
|
if (shippedByState) {
|
||||||
|
stateShippedSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 本轮核验范围(窗口上界按**起算点**卡)──────────────────────────────
|
||||||
|
// 13:00 轮:今天 07:00 前审核的单 + 历史未发
|
||||||
|
// 18:00 轮:今天 16:00 前审核的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00)
|
||||||
// ⚠️ 历史未发单一律算 —— 昨天、前天乃至更早挂着没发的都要进超时列表。
|
// ⚠️ 历史未发单一律算 —— 昨天、前天乃至更早挂着没发的都要进超时列表。
|
||||||
const windowEndAt = registry.centerWindowEnd(center.key, round);
|
const windowEndAt = registry.centerWindowEnd(center.key, round);
|
||||||
const todayStart = `${round.date} 00:00:00`;
|
const todayStart = `${round.date} 00:00:00`;
|
||||||
if (createdAt && createdAt < round.windowStart) continue; // 超出 30 天护栏
|
if (benchmarkAt && benchmarkAt < round.windowStart) continue; // 超出 30 天护栏
|
||||||
if (createdAt && createdAt >= todayStart && createdAt > windowEndAt) continue;
|
if (benchmarkAt && benchmarkAt >= todayStart && benchmarkAt > windowEndAt) continue;
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
row,
|
row,
|
||||||
@ -495,13 +988,30 @@ async function getShipTimeliness(options = {}) {
|
|||||||
state: ORDER_STATE_LABELS[statusRaw] || statusRaw,
|
state: ORDER_STATE_LABELS[statusRaw] || statusRaw,
|
||||||
statusRaw,
|
statusRaw,
|
||||||
src: String(row.src || ''),
|
src: String(row.src || ''),
|
||||||
|
// 该单来自哪张订单表、这张表是否已经停更 ────────────────────────────
|
||||||
|
// 🔥 停更 ⟹ 停更之后发出的单在这里仍是 send_date 空 ⟹ 被判「未发」,
|
||||||
|
// 但真实情况很可能是已经发了。所以**不能静默当确认未发**,
|
||||||
|
// 页面要标出来(2026-10-28 18:00 轮 546 单里 540 单是这种)。
|
||||||
|
srcLabel: (sourceMap.get(String(row.src || '')) || {}).label || String(row.src || ''),
|
||||||
|
sourceStale: Boolean((sourceMap.get(String(row.src || '')) || {}).stale),
|
||||||
createdAt,
|
createdAt,
|
||||||
|
oaConfirmAt,
|
||||||
payAt,
|
payAt,
|
||||||
// 「审核时间」= 创建时间(业务口径),前端那一列显示的就是它
|
// 状态镜像表里的聚水潭终态 —— 订单表停更时,页面靠它告诉业务「这单其实取消了/发了」
|
||||||
auditAt: createdAt,
|
stateOrderStatus,
|
||||||
windowAt: createdAt,
|
stateLogisticStatus,
|
||||||
benchmarkAt: createdAt,
|
stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '',
|
||||||
benchmarkSource: 'created',
|
stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '',
|
||||||
|
// 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库)
|
||||||
|
// 业务核对时直接对照聚水潭「销售出库单」页面
|
||||||
|
ioStatus,
|
||||||
|
ioCount: ioInfo ? ioInfo.count : 0,
|
||||||
|
// 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算
|
||||||
|
benchmarkAt,
|
||||||
|
benchmarkSource,
|
||||||
|
// 页面「审核时间」列显示的就是起算点(回退时会打 `*`)
|
||||||
|
auditAt: benchmarkAt,
|
||||||
|
windowAt: benchmarkAt,
|
||||||
warehouseName,
|
warehouseName,
|
||||||
logisticCompany: String(row.logistics_company || '').trim(),
|
logisticCompany: String(row.logistics_company || '').trim(),
|
||||||
logisticId: String(row.l_id || '').trim(),
|
logisticId: String(row.l_id || '').trim(),
|
||||||
@ -540,15 +1050,19 @@ async function getShipTimeliness(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── ④ 超时判定:到核验时刻仍未发货即超时(没有「满 24 小时」门槛)────────
|
// ── ④ 超时判定:到核验时刻仍未发货即超时(没有「满 24 小时」门槛)────────
|
||||||
if (!createdAt) {
|
if (!benchmarkAt) {
|
||||||
bucket.heldCount += 1;
|
bucket.heldCount += 1;
|
||||||
heldRows.push({ ...base, holdReason: '缺创建时间' });
|
heldRows.push({ ...base, holdReason: '缺审核时间与创建时间' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// waited = 创建时间 → 本轮核验时刻(13:00 / 18:00)
|
// waited = 起算点(审核时间)→ 本轮核验时刻(13:00 / 18:00)
|
||||||
const waited = Math.max(0, diffMinutes(createdAt, round.deadline));
|
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
|
||||||
|
|
||||||
bucket.pendingCount += 1;
|
bucket.pendingCount += 1;
|
||||||
|
if (base.sourceStale) {
|
||||||
|
sourceStaleUnshipped += 1;
|
||||||
|
sourceStaleBySrc[base.srcLabel] = (sourceStaleBySrc[base.srcLabel] || 0) + 1;
|
||||||
|
}
|
||||||
const days = Math.floor(waited / 1440);
|
const days = Math.floor(waited / 1440);
|
||||||
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
||||||
pendingRows.push({ ...base, waited });
|
pendingRows.push({ ...base, waited });
|
||||||
@ -579,17 +1093,31 @@ async function getShipTimeliness(options = {}) {
|
|||||||
oId: String(r.o_id || ''),
|
oId: String(r.o_id || ''),
|
||||||
soId: String(r.so_id || ''),
|
soId: String(r.so_id || ''),
|
||||||
shopName: String(r.shop_name || ''),
|
shopName: String(r.shop_name || ''),
|
||||||
// 创建时间 = 订单表 `created`(**本页的考核起算点**,业务 2026-10-28 定版)
|
// 订单创建时间(聚水潭建单)—— 只做展示与兜底,不是考核起算点
|
||||||
createdAt: x.createdAt,
|
createdAt: x.createdAt,
|
||||||
// 发货时间:候选本身就是 send_date IS NULL,所以恒为空 → 前端显示「未发货」
|
// 发货时间:候选本身就是 send_date IS NULL,所以恒为空 → 前端显示「未发货」
|
||||||
sendTime: formatDateTime(r.send_date),
|
sendTime: formatDateTime(r.send_date),
|
||||||
// 付款时间(并列展示,方便对账)
|
// 付款时间(并列展示,方便对账)
|
||||||
payTime: x.payAt,
|
payTime: x.payAt,
|
||||||
// 「审核时间」= 创建时间(业务口径:审核时间就是订单创建时间)
|
// 「审核时间」= 起算点:状态表 last_oa_confirm_time;缺失回退 createdAt
|
||||||
|
// benchmarkSource='created' 时前端该列会打 `*` 提示是回退值
|
||||||
auditTime: x.auditAt,
|
auditTime: x.auditAt,
|
||||||
benchmarkTime: x.benchmarkAt,
|
benchmarkTime: x.benchmarkAt,
|
||||||
benchmarkSource: x.benchmarkSource,
|
benchmarkSource: x.benchmarkSource,
|
||||||
state: x.state,
|
state: x.state,
|
||||||
|
// 聚水潭侧终态(来自状态镜像表):订单表停更时,这一列才是真的
|
||||||
|
// 业务核对时看这个 —— 「已取消」本该被剔除,出现在明细里就说明状态表也没抓到
|
||||||
|
stateOrderStatus: x.stateOrderStatus,
|
||||||
|
stateLogisticStatus: x.stateLogisticStatus,
|
||||||
|
stateQueryTime: x.stateQueryTime,
|
||||||
|
// 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库)
|
||||||
|
// 🔥 业务在聚水潭「销售出库单」页面看到的作废,就对应这里的 Delete
|
||||||
|
ioStatus: x.ioStatus,
|
||||||
|
ioCount: x.ioCount,
|
||||||
|
// 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记
|
||||||
|
src: x.src,
|
||||||
|
srcLabel: x.srcLabel,
|
||||||
|
sourceStale: x.sourceStale,
|
||||||
// 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单
|
// 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单
|
||||||
logisticStatus: x.logisticId ? '已打单 · 未揽收' : '',
|
logisticStatus: x.logisticId ? '已打单 · 未揽收' : '',
|
||||||
// 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。
|
// 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。
|
||||||
@ -663,6 +1191,12 @@ async function getShipTimeliness(options = {}) {
|
|||||||
}, emptySummary());
|
}, emptySummary());
|
||||||
summary.voidCount = voidCount;
|
summary.voidCount = voidCount;
|
||||||
summary.carrierSkipped = carrierSkipped;
|
summary.carrierSkipped = carrierSkipped;
|
||||||
|
summary.stateShippedSkipped = stateShippedSkipped;
|
||||||
|
// 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效)
|
||||||
|
summary.stateVoidSkipped = stateVoidSkipped;
|
||||||
|
// 由**销售出库单整体 Delete** 剔除的作废单量(业务 2026-10-29 点名的作废单)
|
||||||
|
summary.ioVoidSkipped = ioVoidSkipped;
|
||||||
|
summary.sourceStaleUnshipped = sourceStaleUnshipped;
|
||||||
summary.unknownWarehouse = unknownWarehouse;
|
summary.unknownWarehouse = unknownWarehouse;
|
||||||
|
|
||||||
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
||||||
@ -671,10 +1205,34 @@ async function getShipTimeliness(options = {}) {
|
|||||||
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
||||||
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
||||||
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
||||||
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
|
// 停更判定要**两个口径取或**:
|
||||||
|
// ① 候选行的 MAX(updated_at) 太旧(老口径,只能反映"候选池里最新的那条")
|
||||||
|
// ② 任一订单表自己停更(新口径,直接问源表 —— 候选行会骗人,见 SOURCE_STALE_MINUTES 注释)
|
||||||
|
const staleSources = sourceFreshness.filter((s) => s.stale);
|
||||||
|
const stale = (snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES)
|
||||||
|
|| staleSources.length > 0;
|
||||||
|
// 停更源的一句话描述:「jst_Orders 停止同步于 14:40(已 4.3 小时)」
|
||||||
|
const staleSrcText = staleSources
|
||||||
|
.map((s) => `${s.label} 停止同步于 ${String(s.lastUpdate || '').slice(11, 16) || '—'}`
|
||||||
|
+ `(已 ${Math.round((s.lagMinutes || 0) / 6) / 10} 小时)`)
|
||||||
|
.join('、');
|
||||||
|
// 停更源各贡献了多少超时单(540 来自 jst_Orders、6 来自 qmcust 这种要说清,别只写「这张表」)
|
||||||
|
const staleBySrcText = Object.entries(sourceStaleBySrc)
|
||||||
|
.map(([k, v]) => `${k} ${v} 单`)
|
||||||
|
.join('、');
|
||||||
// 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
// 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||||
// 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。
|
// 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。
|
||||||
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||||
|
|
||||||
|
// 🔴 出库单/状态表停更 ⟹ **「这单作废了吗」这件事本身查不出来**(2026-10-29 新增)
|
||||||
|
// 业务当天点名 9992097 等 9 单「都是作废订单,为啥还在考核之内」:
|
||||||
|
// 聚水潭里已经作废,但我们库里订单表还是 Delivering、出库单还是 WaitConfirm、
|
||||||
|
// 状态表还是「发货中」—— 三个来源全停在作废动作**之前**,所以判据再准也判不出来。
|
||||||
|
// ⟹ 这种情况必须**明说判据失效**,不能让业务以为「系统认定这单没作废」。
|
||||||
|
const voidJudgeStale = ['jst_order_express_details', 'jst_salesOutboundInfo', 'jst_qmcust_salesoutbound']
|
||||||
|
.map((k) => sourceMap.get(k))
|
||||||
|
.filter((s) => s && s.stale);
|
||||||
|
|
||||||
const freshness = {
|
const freshness = {
|
||||||
stateMaxQuery: snapshotAt,
|
stateMaxQuery: snapshotAt,
|
||||||
ageMinutes: snapshotAgeMin,
|
ageMinutes: snapshotAgeMin,
|
||||||
@ -682,9 +1240,19 @@ async function getShipTimeliness(options = {}) {
|
|||||||
coversDeadline,
|
coversDeadline,
|
||||||
// 数据源已经换成订单表,文案跟着改(别再写「状态镜像」)
|
// 数据源已经换成订单表,文案跟着改(别再写「状态镜像」)
|
||||||
source: 'jst_Orders',
|
source: 'jst_Orders',
|
||||||
|
// 作废/取消判据是否可信(false = 出库单或状态表停更,作废单可能没剔干净)
|
||||||
|
voidJudgeReliable: voidJudgeStale.length === 0,
|
||||||
hint: [
|
hint: [
|
||||||
stale
|
// 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事
|
||||||
? `订单表最后更新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
|
staleSrcText ? `数据源停更:${staleSrcText}。` : '',
|
||||||
|
sourceStaleUnshipped > 0
|
||||||
|
? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):`
|
||||||
|
+ '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。'
|
||||||
|
: '',
|
||||||
|
voidJudgeStale.length > 0
|
||||||
|
? `⚠️ ${voidJudgeStale.map((s) => s.label).join('、')} 已停止同步,`
|
||||||
|
+ '作废单 / 取消单可能判不出来(聚水潭里的作废动作还没同步到本系统),'
|
||||||
|
+ '下面凡是显示「发货中 · 无物流单」的单,请先在聚水潭「销售出库单」里核对是否已作废。'
|
||||||
: '',
|
: '',
|
||||||
!coversDeadline
|
!coversDeadline
|
||||||
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||||
@ -706,14 +1274,20 @@ async function getShipTimeliness(options = {}) {
|
|||||||
isPreviousDay: false,
|
isPreviousDay: false,
|
||||||
rounds,
|
rounds,
|
||||||
deadline: round.deadline,
|
deadline: round.deadline,
|
||||||
// boundary='created':轮次归属、已等时长都按**订单创建时间**(jst_Orders.created)算
|
// boundary='oa_confirm':轮次归属、已等时长都按**审核时间**算
|
||||||
// 业务 2026-10-28 定版;此前一段用的是状态表的 last_oa_confirm_time,已废弃。
|
// (状态表 last_oa_confirm_time;取不到时回退订单 created,见 benchmarkSource)
|
||||||
|
// 业务 2026-10-28 下午二次定版;上午那版按 created 已被 9837824 推翻。
|
||||||
window: {
|
window: {
|
||||||
start: round.windowStart,
|
start: round.windowStart,
|
||||||
end: round.windowEnd,
|
end: round.windowEnd,
|
||||||
includeHistory: true,
|
includeHistory: true,
|
||||||
boundary: 'created',
|
boundary: 'oa_confirm',
|
||||||
measuredBy: 'created',
|
measuredBy: 'oa_confirm',
|
||||||
|
// 审核时间缺失、回退订单创建时间的单量(页面/钉钉需要标出来)
|
||||||
|
fallbackToCreated: orders.filter((o) => o.benchmarkSource === 'created').length,
|
||||||
|
fallbackDegraded: oaConfirmDegraded,
|
||||||
|
// 销售出库单查询失败 ⟹ 作废判据降级(页面据此提示「作废单可能没剔干净」)
|
||||||
|
ioDegraded: ioDegraded,
|
||||||
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||||
overrides: windowOverrides,
|
overrides: windowOverrides,
|
||||||
},
|
},
|
||||||
@ -729,6 +1303,8 @@ async function getShipTimeliness(options = {}) {
|
|||||||
detailLimit: detailRows.length,
|
detailLimit: detailRows.length,
|
||||||
detailDegraded: false,
|
detailDegraded: false,
|
||||||
freshness,
|
freshness,
|
||||||
|
// 两个订单表各自的同步新鲜度(页面据此出红条告警)
|
||||||
|
dataSources: sourceFreshness,
|
||||||
cached: false,
|
cached: false,
|
||||||
cacheAgeSec: 0,
|
cacheAgeSec: 0,
|
||||||
note: '',
|
note: '',
|
||||||
@ -803,6 +1379,8 @@ async function listShipTimelinessExportRows(query = {}) {
|
|||||||
summary: payload.summary || emptySummary(),
|
summary: payload.summary || emptySummary(),
|
||||||
excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS,
|
excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS,
|
||||||
freshness: payload.freshness || null,
|
freshness: payload.freshness || null,
|
||||||
|
// 导出也要能说明「这批单里有多少来自停更的源」,否则导出的 Excel 脱离页面就没了上下文
|
||||||
|
dataSources: payload.dataSources || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -154,6 +154,10 @@ function stubDb(rows, options = {}) {
|
|||||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||||
return options.interceptRows || [];
|
return options.interceptRows || [];
|
||||||
}
|
}
|
||||||
|
// 窗口 0 行时的「表级最后灌库时刻」探测
|
||||||
|
if (/MAX\(\s*e\.query_time\s*\)/iu.test(String(sql))) {
|
||||||
|
return options.sourceMaxQueryTime === undefined ? [] : [{ max_q: options.sourceMaxQueryTime }];
|
||||||
|
}
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
require.cache[dbPath] = {
|
require.cache[dbPath] = {
|
||||||
@ -672,3 +676,42 @@ test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
|||||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ⑧ 窗口 0 行:0 单不能被当成「当天没发货 / 全部正常」(2026-11-02)──────
|
||||||
|
// 起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||||
|
// 实况是镜像表 11-01 07:55 之后就没再灌库,11-01 的 12 仓发货记录一条都没进库。
|
||||||
|
test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说「数据没进库」', async () => {
|
||||||
|
const nov2 = new Date(2026, 10, 2, 10, 0, 0); // 2026-11-02 10:00(本机时区)
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||||
|
assert.equal(data.summary.shippedOrders, 0);
|
||||||
|
assert.equal(data.freshness.emptyWindow, true);
|
||||||
|
assert.equal(data.freshness.sourceLastUpdate, '2026-11-01 07:55:04');
|
||||||
|
assert.ok(data.freshness.sourceAgeMinutes > 120, `应算出停更小时数,实际 ${data.freshness.sourceAgeMinutes}`);
|
||||||
|
assert.equal(data.freshness.sourceStale, true);
|
||||||
|
assert.match(data.freshness.hint, /一条都没有/);
|
||||||
|
assert.match(data.freshness.hint, /不代表当天没发货/);
|
||||||
|
// 只在空窗口才付那次全表 MAX(query_time)(实测 13.5s)
|
||||||
|
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 1);
|
||||||
|
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => {
|
||||||
|
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||||
|
await withStub(async (svc) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||||
|
assert.equal(data.freshness.emptyWindow, true);
|
||||||
|
assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更');
|
||||||
|
assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」');
|
||||||
|
assert.match(data.freshness.hint, /数据源本身正常/);
|
||||||
|
}, [], { sourceMaxQueryTime: '2026-11-02 09:58:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => {
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
assert.equal(data.freshness.emptyWindow, false);
|
||||||
|
assert.equal(data.freshness.sourceLastUpdate, '');
|
||||||
|
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -495,3 +495,50 @@ test('发文本:超长正文自动截断后仍会发出去(不是直接抛
|
|||||||
stub.restore();
|
stub.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)──────────────────────────
|
||||||
|
// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货,
|
||||||
|
// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。
|
||||||
|
test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
rows: [],
|
||||||
|
summary: {
|
||||||
|
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||||
|
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||||
|
},
|
||||||
|
exclusions: {},
|
||||||
|
freshness: {
|
||||||
|
emptyWindow: true,
|
||||||
|
sourceLastUpdate: '2026-11-01 07:55:04',
|
||||||
|
sourceAgeMinutes: 1565,
|
||||||
|
sourceStale: true,
|
||||||
|
hint: '11-01 的发货记录在物流镜像表里一条都没有',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /物流数据未同步/);
|
||||||
|
assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/);
|
||||||
|
assert.match(built.text, /已 26\.1 小时未同步/);
|
||||||
|
assert.match(built.text, /不代表当天没发货/);
|
||||||
|
assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」');
|
||||||
|
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||||||
|
assert.match(built.text, /【仓储物流转运异常】/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
rows: [],
|
||||||
|
summary: {
|
||||||
|
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||||
|
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||||
|
},
|
||||||
|
exclusions: {},
|
||||||
|
freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false },
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /在物流镜像表里 0 条发货记录/);
|
||||||
|
assert.match(built.text, /数据源本身正常/);
|
||||||
|
assert.match(built.text, /请到聚水潭核对/);
|
||||||
|
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
|
||||||
|
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
|
||||||
|
});
|
||||||
|
|||||||
@ -184,7 +184,7 @@ test('起算点是 orders.created,且只从订单两表取', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||||
function stubDb(dbPath, scenario = 'default') {
|
function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) {
|
||||||
const WMS = {
|
const WMS = {
|
||||||
CQ: 15038426,
|
CQ: 15038426,
|
||||||
SD_QGJ: 15137523,
|
SD_QGJ: 15137523,
|
||||||
@ -281,9 +281,64 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分
|
o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = scenario === 'noon' ? noonRows : defaultRows;
|
// 「起算点 = 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 calls = { orders: 0, warehouse: 0, sql: [], params: [] };
|
// 终态校验专用场景(2026-10-29)
|
||||||
|
// 🔥 现场:业务第三次报「作废的、已经发货的还在考核之内」。根因是订单两表各自停更,
|
||||||
|
// 停更之后聚水潭里的「取消 / 发货」订单表看不到,只有状态镜像表能反映。
|
||||||
|
// 这一组行**订单表侧全部干净**(status=Delivering + send_date 空 + labels 无关键词),
|
||||||
|
// 全靠状态表终态区分,专门验证「订单表谎报时状态表能救回来」。
|
||||||
|
const terminalRows = [
|
||||||
|
o(61, WMS.CQ, '2026-10-20 05:00:00'), // 状态表 已取消 → 剔除(作废)
|
||||||
|
o(62, WMS.CQ, '2026-10-20 05:10:00'), // 状态表 已发货 → 剔除(已发)
|
||||||
|
o(63, WMS.CQ, '2026-10-20 05:20:00'), // 物流 已揽收 → 剔除(已发)
|
||||||
|
o(64, WMS.CQ, '2026-10-20 05:30:00'), // 售后「已作废」+ 订单「发货中」→ **仍要考核**
|
||||||
|
o(65, WMS.CQ, '2026-10-20 05:40:00'), // 物流 未揽收 → **仍要考核**
|
||||||
|
o(66, WMS.CQ, '2026-10-20 05:50:00'), // send_time 有值(订单表漏回写)→ 剔除
|
||||||
|
o(67, WMS.CQ, '2026-10-20 06:00:00'), // 状态表停更,无终态信息 → 按未发算
|
||||||
|
];
|
||||||
|
|
||||||
|
// 出库单作废专用场景(2026-10-29)
|
||||||
|
// 🔥 现场:业务点名 9992097 等洛宁仓 9 单「都是作废订单,为啥还在考核之内」,
|
||||||
|
// 并指明「在聚水潭的**销售出库单**里面找这个作废单据」。
|
||||||
|
// 聚水潭作废落在出库单 `status='Delete'` 上,所以判据必须看出库单。
|
||||||
|
const ioDbRows = [
|
||||||
|
// 71:唯一出库单被删 → 单据作废,剔除
|
||||||
|
{ o_id: 71, io_id: 9001, status: 'Delete' },
|
||||||
|
// 72:拆包重开:旧单 Delete + 新单 WaitConfirm → **仍要考核**(不能凭一张 Delete 就剔)
|
||||||
|
{ o_id: 72, io_id: 9002, status: 'Delete' },
|
||||||
|
{ o_id: 72, io_id: 9003, status: 'WaitConfirm' },
|
||||||
|
// 73:异常单 + 出库单全删 → **仍进「不计考核」**,不能当作用废丢掉可见性
|
||||||
|
{ o_id: 73, io_id: 9004, status: 'Delete' },
|
||||||
|
// 74:压根没有出库单 → 仍要考核(近 3 天 82% 未发单都没有出库单)
|
||||||
|
// 75:出库单待出库 → 仍要考核(就是洛宁仓那批显示 WaitConfirm 的真积压)
|
||||||
|
{ o_id: 75, io_id: 9005, status: 'WaitConfirm' },
|
||||||
|
];
|
||||||
|
const ioScenarioRows = [
|
||||||
|
o(71, WMS.HN_LN, '2026-10-20 05:00:00'),
|
||||||
|
o(72, WMS.HN_LN, '2026-10-20 05:10:00'),
|
||||||
|
o(73, WMS.HN_LN, '2026-10-20 05:20:00', { status: 'Question' }),
|
||||||
|
o(74, WMS.HN_LN, '2026-10-20 05:30:00'),
|
||||||
|
o(75, WMS.HN_LN, '2026-10-20 05:40:00'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = scenario === 'modified' ? modifiedRows
|
||||||
|
: (scenario === 'noon' ? noonRows
|
||||||
|
: (scenario === 'terminal' ? terminalRows
|
||||||
|
: (scenario === 'io' ? ioScenarioRows : defaultRows)));
|
||||||
|
|
||||||
|
const calls = { orders: 0, oaConfirm: 0, io: 0, warehouse: 0, freshness: 0, sql: [], params: [] };
|
||||||
|
|
||||||
const handler = async (sql, params) => {
|
const handler = async (sql, params) => {
|
||||||
const text = String(sql);
|
const text = String(sql);
|
||||||
@ -291,6 +346,31 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
calls.warehouse += 1;
|
calls.warehouse += 1;
|
||||||
return warehouseRows;
|
return warehouseRows;
|
||||||
}
|
}
|
||||||
|
// 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` /
|
||||||
|
// `SELECT MAX(local_updated) FROM jst_qmcust_orderlist` /
|
||||||
|
// `SELECT MAX(updatedAt) FROM jst_order_express_details`(状态镜像表,2026-10-29 新增)/
|
||||||
|
// `SELECT MAX(updateTime) FROM jst_salesOutboundInfo`、`MAX(local_updated) FROM jst_qmcust_salesoutbound`
|
||||||
|
// (出库单两表,2026-10-29 新增)。
|
||||||
|
// 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。
|
||||||
|
if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)
|
||||||
|
|| /MAX\(updatedAt\)/iu.test(text)) {
|
||||||
|
calls.freshness += 1;
|
||||||
|
// 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警);
|
||||||
|
// 要测停更场景就把 freshnessUpd 传成当天更早的时刻
|
||||||
|
return [{ maxUpd: freshnessUpd }];
|
||||||
|
}
|
||||||
|
// 销售出库单状态(2026-10-29 新增):`SELECT o_id, io_id, status FROM jst_*salesoutbound`
|
||||||
|
// 默认空 → 服务层「没有出库单就不判作废」,不影响老用例
|
||||||
|
if (/jst_salesOutboundInfo|jst_qmcust_salesoutbound/u.test(text)) {
|
||||||
|
calls.io += 1;
|
||||||
|
return ioRows;
|
||||||
|
}
|
||||||
|
// 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间,
|
||||||
|
// 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows
|
||||||
|
if (/jst_order_express_details/u.test(text)) {
|
||||||
|
calls.oaConfirm += 1;
|
||||||
|
return oaRows;
|
||||||
|
}
|
||||||
calls.orders += 1;
|
calls.orders += 1;
|
||||||
calls.sql.push(text);
|
calls.sql.push(text);
|
||||||
calls.params.push(params || {});
|
calls.params.push(params || {});
|
||||||
@ -308,11 +388,11 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
return { calls, WMS };
|
return { calls, WMS };
|
||||||
}
|
}
|
||||||
|
|
||||||
function withStubbedService(fn, scenario = 'default') {
|
function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) {
|
||||||
return async () => {
|
return async () => {
|
||||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||||
const realDb = require.cache[dbPath];
|
const realDb = require.cache[dbPath];
|
||||||
const ctx = stubDb(dbPath, scenario);
|
const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd, ioRows);
|
||||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||||
try {
|
try {
|
||||||
@ -374,8 +454,8 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
|||||||
|
|
||||||
// 3 号单没创建时间 → 归「不计考核」,原因写清楚
|
// 3 号单没创建时间 → 归「不计考核」,原因写清楚
|
||||||
const noCreated = data.heldOrders.find((o) => String(o.oId) === '3');
|
const noCreated = data.heldOrders.find((o) => String(o.oId) === '3');
|
||||||
assert.ok(noCreated, '缺创建时间的单不能被静默丢掉');
|
assert.ok(noCreated, '缺起算点(审核时间与创建时间都没有)的单不能被静默丢掉');
|
||||||
assert.equal(noCreated.holdReason, '缺创建时间');
|
assert.equal(noCreated.holdReason, '缺审核时间与创建时间');
|
||||||
|
|
||||||
// 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00)
|
// 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00)
|
||||||
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
|
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
|
||||||
@ -431,8 +511,8 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
|||||||
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
|
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reasons,
|
reasons,
|
||||||
['缺创建时间', '待审核', '订单异常', '订单异常'].sort(),
|
['缺审核时间与创建时间', '待审核', '订单异常', '订单异常'].sort(),
|
||||||
'4 单:缺创建时间 1 + 待审核 1 + 异常 2'
|
'4 单:缺起算点 1 + 待审核 1 + 异常 2'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 新鲜度:取自主查询的 MAX(updated_at)
|
// 新鲜度:取自主查询的 MAX(updated_at)
|
||||||
@ -482,11 +562,11 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
|||||||
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
|
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
|
||||||
assert.equal(data.round.key, 'AFTERNOON');
|
assert.equal(data.round.key, 'AFTERNOON');
|
||||||
assert.match(String(data.round.windowEnd), /16:00:00$/u);
|
assert.match(String(data.round.windowEnd), /16:00:00$/u);
|
||||||
assert.equal(data.window.boundary, 'created', '窗口按创建时间判,要透出给前端');
|
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判,要透出给前端');
|
||||||
assert.equal(data.window.measuredBy, 'created', '已等时长按创建时间算');
|
assert.equal(data.window.measuredBy, 'oa_confirm', '已等时长按审核时间算');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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 });
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
assert.equal(data.round.key, 'NOON');
|
assert.equal(data.round.key, 'NOON');
|
||||||
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
|
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
|
||||||
@ -514,7 +594,7 @@ test('13:00 轮:只核验「今天 07:00 前创建」的单 + 历史未发', w
|
|||||||
// 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单
|
// 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单
|
||||||
// (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算)
|
// (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算)
|
||||||
assert.equal(data.summary.overdueUnshipped, 5);
|
assert.equal(data.summary.overdueUnshipped, 5);
|
||||||
assert.equal(data.window.boundary, 'created', '窗口按创建时间判');
|
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判(取不到回退创建时间)');
|
||||||
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
|
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
|
||||||
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
|
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
|
||||||
|
|
||||||
@ -613,3 +693,171 @@ test('导出行为纯函数:缺字段一律给空串,不导出 undefined', (
|
|||||||
assert.equal(rows[1].oId, '');
|
assert.equal(rows[1].oId, '');
|
||||||
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
|
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 },
|
||||||
|
]));
|
||||||
|
|
||||||
|
test('状态表终态:已取消 / 已发货 / 已揽收的单必须剔除,售后「已作废」不能误剔', async () => {
|
||||||
|
// 🔥 2026-10-29 现场(业务第三次报同一件事):
|
||||||
|
// 13:00 轮 159 单**全部**来自停更的 qmcust(停 07:25),订单表里
|
||||||
|
// status 仍是 Delivering、send_date 仍是空、labels 里**没有**「发货前售后」。
|
||||||
|
// 而状态镜像表(停 11:52,比订单表新鲜 4 小时)已经把它们标成「已取消」。
|
||||||
|
// ⟹ 判据必须补状态表终态,否则停更期的取消/发货永远判不出来。
|
||||||
|
const oa = [
|
||||||
|
// 已取消 → 作废
|
||||||
|
{ inner_order_id: 61, order_status_name: '已取消', aftersale_status_name: '已确认', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null },
|
||||||
|
// 已发货 → 已发
|
||||||
|
{ inner_order_id: 62, order_status_name: '已发货', aftersale_status_name: '暂无售后', logistic_status_name: '已签收', last_oa_confirm_time: null, send_time: null },
|
||||||
|
// 订单还「发货中」,但快递已揽收 → 已发
|
||||||
|
{ inner_order_id: 63, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '已揽收', last_oa_confirm_time: null, send_time: null },
|
||||||
|
// ⚠️ 售后「已作废」= 售后单被撤销,订单照常发货 → **仍要考核**
|
||||||
|
{ inner_order_id: 64, order_status_name: '发货中', aftersale_status_name: '已作废', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null },
|
||||||
|
// 未揽收 → 仍要考核
|
||||||
|
{ inner_order_id: 65, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null },
|
||||||
|
// 订单表 send_date 漏回写,但状态表 send_time 有值 → 已发
|
||||||
|
{ inner_order_id: 66, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: new Date(2026, 9, 20, 12, 0, 0) },
|
||||||
|
// 状态表没有这单的行(停更/未覆盖)→ 按未发算,不能因为查不到就放行
|
||||||
|
];
|
||||||
|
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 20 });
|
||||||
|
const ids = data.orders.map((x) => String(x.oId)).sort();
|
||||||
|
assert.deepEqual(ids, ['64', '65', '67'], '只该剩「售后已作废 / 未揽收 / 状态表无记录」三单');
|
||||||
|
|
||||||
|
assert.equal(data.summary.stateVoidSkipped, 1, '已取消要单独记账(业务对账看这个数)');
|
||||||
|
assert.equal(data.summary.stateShippedSkipped, 3, '已发货 + 已揽收 + send_time 有值 = 3 单剔为已发');
|
||||||
|
assert.equal(data.summary.voidCount, 1, '作废量含状态表判出的那一单');
|
||||||
|
|
||||||
|
// 明细行要把聚水潭终态透出来,业务核对时能一眼看出「这单其实取消了」
|
||||||
|
const r64 = data.orders.find((x) => String(x.oId) === '64');
|
||||||
|
assert.equal(r64.stateOrderStatus, '发货中');
|
||||||
|
assert.equal(r64.stateLogisticStatus, '未揽收');
|
||||||
|
}, 'terminal', oa);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('状态表也纳入数据源新鲜度监控', async () => {
|
||||||
|
// 状态表是「取消了吗 / 发出了吗」的唯一权威来源,它停更了判据就过期了,
|
||||||
|
// 页面必须一起暴露,不能只报订单表停更。
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
|
const keys = data.dataSources.map((x) => x.key);
|
||||||
|
assert.ok(keys.includes('jst_order_express_details'), '状态镜像表要作为独立数据源透出');
|
||||||
|
// 2026-10-29:加入两张销售出库单表(作废判据的唯一来源,实测一起停更 11 小时)
|
||||||
|
assert.ok(keys.includes('jst_salesOutboundInfo'), '聚水潭出库单要作为独立数据源透出');
|
||||||
|
assert.ok(keys.includes('jst_qmcust_salesoutbound'), '奇门出库单要作为独立数据源透出');
|
||||||
|
assert.equal(data.dataSources.length, 5, '两个订单表 + 状态镜像表 + 两张出库单表');
|
||||||
|
}, 'terminal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('出库单整体 Delete = 单据作废,必须剔除;拆包重开 / 无出库单 / 异常单不能误剔', async () => {
|
||||||
|
// 🔥 2026-10-29 现场:业务点名 9992097 / 9992098 / 9992924 … 洛宁仓 9 单
|
||||||
|
// 「这些订单都是作废订单,为啥还在考核之内」,并指明
|
||||||
|
// 「在聚水潭的**销售出库单**里面找一下这个作废单据」。
|
||||||
|
// 聚水潭的作废落在出库单 status='Delete' 上(订单表 labels 判据在这批单上 0 命中)。
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 20 });
|
||||||
|
|
||||||
|
// 71 = 唯一出库单被删 → 剔除;73 = 异常单(本就进「不计考核」)
|
||||||
|
const ids = data.orders.map((x) => String(x.oId)).sort();
|
||||||
|
assert.deepEqual(ids, ['72', '74', '75'],
|
||||||
|
'只剩:拆包重开(72) / 无出库单(74) / 出库单待出库(75)');
|
||||||
|
|
||||||
|
assert.equal(data.summary.ioVoidSkipped, 1, '出库单作废单独记账');
|
||||||
|
assert.equal(data.summary.voidCount, 1, '作废量含出库单判出的那一单');
|
||||||
|
|
||||||
|
// 🔴 关键反向保护:异常单 + 出库单全删 **不能**被剔成作废,
|
||||||
|
// 它必须留在「不计考核」里(业务要看到这批待处理的单)
|
||||||
|
const held = data.heldOrders.map((x) => String(x.oId));
|
||||||
|
assert.ok(held.includes('73'), '异常单即便出库单被删,也要留在不计考核清单里');
|
||||||
|
|
||||||
|
// 明细行要把出库单状态透出来,业务能直接对照聚水潭「销售出库单」页面
|
||||||
|
const r75 = data.orders.find((x) => String(x.oId) === '75');
|
||||||
|
assert.equal(r75.ioStatus, 'WaitConfirm', '待出库状态要透出');
|
||||||
|
const r72 = data.orders.find((x) => String(x.oId) === '72');
|
||||||
|
assert.equal(r72.ioStatus, 'WaitConfirm', '拆包重开时展示「还活着」的那张');
|
||||||
|
}, 'io', [], '2026-10-20 18:00:00', [
|
||||||
|
{ o_id: 71, io_id: 9001, status: 'Delete' },
|
||||||
|
{ o_id: 72, io_id: 9002, status: 'Delete' },
|
||||||
|
{ o_id: 72, io_id: 9003, status: 'WaitConfirm' },
|
||||||
|
{ o_id: 73, io_id: 9004, status: 'Delete' },
|
||||||
|
{ o_id: 75, io_id: 9005, status: 'WaitConfirm' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('出库单/状态表停更时,提示要明说「作废判不出来」', async () => {
|
||||||
|
// 🔥 2026-10-29 13:10 实测:两张出库单表 local_updated 一起停在 03:10(停更 11 小时),
|
||||||
|
// 业务当天在聚水潭作的废**一条都没同步进来** ——
|
||||||
|
// 页面必须说清「判据失效」,不能让业务以为「系统认定这单没作废」。
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
|
assert.equal(data.freshness.voidJudgeReliable, false, '出库单停更 → 作废判据不可信');
|
||||||
|
assert.match(String(data.freshness.hint), /作废单\/取消单可能判不出来/u,
|
||||||
|
'提示要明说作废判不出来');
|
||||||
|
assert.match(String(data.freshness.hint), /销售出库单/u, '要告诉业务去哪儿核对');
|
||||||
|
}, 'terminal', [], '2026-10-20 10:00:00');
|
||||||
|
|
||||||
|
// 反方向:源都正常时不能吓唬人
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
|
assert.equal(data.freshness.voidJudgeReliable, true, '源正常时判据可信');
|
||||||
|
assert.doesNotMatch(String(data.freshness.hint), /可能判不出来/u, '不能误报判据失效');
|
||||||
|
}, 'terminal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源停更:来自停更表的超时单必须打标,并计入 sourceStaleUnshipped', async () => {
|
||||||
|
// 🔥 2026-10-28 18:35 现场:`jst_Orders` 停更在 14:40,18:00 轮 546 单里 540 单来自它,
|
||||||
|
// 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。
|
||||||
|
// 用例 now=18:05,源表停在 10:00 → 滞后 8 小时 > 60 分钟阈值
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||||
|
assert.ok(data.summary.overdueUnshipped > 0, '前置:默认场景应有超时单');
|
||||||
|
assert.equal(
|
||||||
|
data.summary.sourceStaleUnshipped,
|
||||||
|
data.summary.overdueUnshipped,
|
||||||
|
'源停更时,全部超时单都要计入「数据未同步」',
|
||||||
|
);
|
||||||
|
assert.ok(data.dataSources.length >= 2, '两个订单表的新鲜度都要透出');
|
||||||
|
assert.ok(data.dataSources.every((x) => x.stale), '两张表都停在 10:00,都算停更');
|
||||||
|
assert.equal(data.orders[0].sourceStale, true, '明细行要带 sourceStale 标记');
|
||||||
|
assert.ok(data.orders[0].srcLabel, '明细行要带来源表名,前端才解释得清');
|
||||||
|
assert.match(String(data.freshness.hint), /停更/u, '页面提示要说明是哪张表停更');
|
||||||
|
}, 'default', [], '2026-10-20 10:00:00');
|
||||||
|
|
||||||
|
// 反方向:源正常时不能误标,否则业务会以为每天都在停更
|
||||||
|
await withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||||
|
assert.equal(data.summary.sourceStaleUnshipped, 0, '源正常时不能标停更');
|
||||||
|
assert.ok(data.dataSources.every((x) => !x.stale), '滞后 5 分钟不达阈值');
|
||||||
|
assert.equal(Boolean(data.orders[0].sourceStale), false, '明细行不带停更标记');
|
||||||
|
}, 'default');
|
||||||
|
});
|
||||||
|
|||||||
@ -125,7 +125,7 @@ test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补'
|
|||||||
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||||
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
|
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.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
|
||||||
assert.equal(built.rowCount, 3);
|
assert.equal(built.rowCount, 3);
|
||||||
});
|
});
|
||||||
@ -138,11 +138,11 @@ test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
|||||||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('明细一行 = 仓|内部单号|线上单号|快递|创建 时间|已等', () => {
|
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
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
|
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
|
||||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|创建 无创建时间|已等 2 小时/);
|
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
|
||||||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
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. 简尚-崔桥工厂仓'));
|
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||||||
assert.ok(line, '明细第 2 行不见了');
|
assert.ok(line, '明细第 2 行不见了');
|
||||||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
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,但口径「含历史未发」→ 明细里会出现昨天的单
|
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
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,别啰嗦
|
// 同一天的就只给 HH:mm,别啰嗦
|
||||||
assert.match(built.text, /创建 06:32/);
|
assert.match(built.text, /审核 06:32/);
|
||||||
assert.doesNotMatch(built.text, /创建 10-27 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-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-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('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import request from './request';
|
|||||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||||
// params.limit 明细条数上限
|
// params.limit 明细条数上限
|
||||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||||
|
|||||||
@ -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: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', 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: '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,
|
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||||
|
|||||||
@ -252,8 +252,15 @@ function WarehouseLogisticsException() {
|
|||||||
const [activeCenter, setActiveCenter] = useState('');
|
const [activeCenter, setActiveCenter] = useState('');
|
||||||
// 异常类型筛选:为空 = 全部
|
// 异常类型筛选:为空 = 全部
|
||||||
const [activeType, setActiveType] = useState('');
|
const [activeType, setActiveType] = useState('');
|
||||||
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
// 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||||
const [verifyDate, setVerifyDate] = useState(null);
|
// 🔥 钉钉消息里的链接带 `?date=YYYY-MM-DD`(见 buildPageLink),页面必须认这个参数 ——
|
||||||
|
// 否则群里点进来看到的是「今天这批」,跟消息里说的不是同一批,历史消息会误导人。
|
||||||
|
const [verifyDate, setVerifyDate] = useState(() => {
|
||||||
|
const raw = String(new URLSearchParams(window.location.search).get('date') || '').trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/u.test(raw)) return null;
|
||||||
|
const parsed = dayjs(raw);
|
||||||
|
return parsed.isValid() ? parsed : null;
|
||||||
|
});
|
||||||
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
||||||
@ -564,14 +571,16 @@ function WarehouseLogisticsException() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
{freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? (
|
||||||
<Alert
|
<Alert
|
||||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||||
showIcon
|
showIcon
|
||||||
className="wle-alert"
|
className="wle-alert"
|
||||||
message={freshness.coversDeadline === false
|
message={freshness.emptyWindow
|
||||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
|
||||||
: '物流数据可能滞后'}
|
: (freshness.coversDeadline === false
|
||||||
|
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||||
|
: '物流数据可能滞后')}
|
||||||
description={freshness.hint}
|
description={freshness.hint}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -589,7 +598,11 @@ function WarehouseLogisticsException() {
|
|||||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" className="wle-stat__sub">
|
<Text type="secondary" className="wle-stat__sub">
|
||||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
{notStarted
|
||||||
|
? '—'
|
||||||
|
: (freshness?.emptyWindow
|
||||||
|
? '数据未同步,见上方红条'
|
||||||
|
: `共 ${number(summary.packageCount)} 个包裹`)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card bordered={false} className="wle-stat">
|
<Card bordered={false} className="wle-stat">
|
||||||
|
|||||||
@ -80,7 +80,7 @@ const NOTIFY_HELP_ITEMS = [
|
|||||||
label: '③ 为什么一天推两次、时间怎么定',
|
label: '③ 为什么一天推两次、时间怎么定',
|
||||||
children: (
|
children: (
|
||||||
<div className="wst-notify__help">
|
<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>推一次,所以是两个独立时刻,互不影响。
|
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
|
||||||
<br />
|
<br />
|
||||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻,
|
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻,
|
||||||
@ -165,19 +165,46 @@ const HELD_COLUMNS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: (
|
||||||
|
<Tooltip title="聚水潭「审核时间」= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者(状态表会滞后,9978643 旧值比真实审核早一天)。两者都没有才回退订单创建时间,标 *">
|
||||||
|
<span>审核时间</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
dataIndex: 'auditTime',
|
dataIndex: 'auditTime',
|
||||||
width: 152,
|
width: 152,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (v) => (v || <span className="wst-detail__fallback">无创建时间</span>),
|
render: (v, row) => {
|
||||||
|
if (!v) return <span className="wst-detail__fallback">无审核时间</span>;
|
||||||
|
return row?.benchmarkSource === 'created' ? `${v} *` : v;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||||
{ title: '付款时间', dataIndex: 'payTime', 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} 天` : '—') },
|
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DETAIL_COLUMNS = [
|
const DETAIL_COLUMNS = [
|
||||||
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
||||||
{ title: '内部单号', dataIndex: 'oId', width: 110 },
|
{
|
||||||
|
title: '内部单号',
|
||||||
|
dataIndex: 'oId',
|
||||||
|
width: 158,
|
||||||
|
// 🔥 来自停更订单表的单必须打标(2026-10-28):
|
||||||
|
// 该表停更之后发出的单,send_date 仍是空 → 被判「未发」,实际很可能已经发了。
|
||||||
|
// 18:00 轮实测 546 单里 540 单是这种,不标出来业务会以为判定写错。
|
||||||
|
render: (v, row) => (
|
||||||
|
<span className="wst-detail__oid">
|
||||||
|
{v || '—'}
|
||||||
|
{row?.sourceStale ? (
|
||||||
|
<Tooltip
|
||||||
|
title={`这单来自 ${row?.srcLabel || '订单表'},该表已停止同步:停更之后发出的单在这里仍是「未发货」,很可能已经发了。请先在聚水潭核对。`}
|
||||||
|
>
|
||||||
|
<Tag bordered={false} color="error" className="wst-detail__stale">未同步</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
||||||
{
|
{
|
||||||
title: '线上订单号',
|
title: '线上订单号',
|
||||||
@ -224,47 +251,111 @@ const DETAIL_COLUMNS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 创建时间 = 本页的**考核起算点**(业务 2026-10-28 定版:审核时间就是订单创建时间)
|
// 审核时间 = 本页的**考核起算点**(业务 2026-10-28 下午三次定版)
|
||||||
|
// = max(状态表 last_oa_confirm_time, 订单表 modified),都没有才回退创建时间并打 `*`
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="取订单表 jst_Orders 的创建时间(业务口径:审核时间即订单创建时间)。今天 7:00 前创建的 13:00 核验;截止 16:00 前创建的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
<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>
|
<span>审核时间</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
dataIndex: 'auditTime',
|
dataIndex: 'auditTime',
|
||||||
width: 152,
|
width: 152,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (v, row) => (v
|
render: (v, row) => {
|
||||||
? v
|
if (!v) return <span className="wst-detail__fallback">—</span>;
|
||||||
: (
|
if (row?.benchmarkSource === 'created') {
|
||||||
<Tooltip title="没取到创建时间,已回退付款时间参与分轮与计时">
|
return (
|
||||||
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
|
<Tooltip title="状态表审核时间和订单表 modified 都取不到,已回退订单创建时间参与分轮与计时(`*` 为标记)">
|
||||||
</Tooltip>
|
<span>{v} *</span>
|
||||||
)),
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 下单时间 = 客户在平台下单那一刻(只做参考对账,不参与计时)
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<Tooltip title="聚水潭订单创建时间(jst_Orders.created),即客户下单时刻。只做参考,不参与分轮与计时">
|
||||||
|
<span>下单时间</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 152,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
// 发货状态:订单表 status(翻译成中文)
|
// 发货状态:**优先显示聚水潭终态**(状态镜像表),订单表状态只作对照
|
||||||
|
// 🔥 2026-10-29:订单两表会各自停更,停更期间聚水潭里已经「已取消 / 已发货」的单,
|
||||||
|
// 订单表里仍是「发货中」——业务看到的「作废的也在考核里」就是这么来的。
|
||||||
|
// 状态镜像表比订单表新鲜,`order_status_name` 才是聚水潭的真状态。
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="订单表 jst_Orders.status 的实时状态。发货中 = 仓库还没发货,即考核对象">
|
<Tooltip title="优先显示聚水潭订单终态(状态镜像表,订单表停更时只有它靠得住)。⚠ = 与订单表 jst_Orders.status 不一致">
|
||||||
<span>发货状态</span>
|
<span>发货状态</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
dataIndex: 'state',
|
dataIndex: 'stateOrderStatus',
|
||||||
width: 104,
|
width: 128,
|
||||||
render: (v) => (
|
render: (v, row) => {
|
||||||
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
|
const lake = v || '';
|
||||||
),
|
const ord = row?.state || '';
|
||||||
|
const diff = Boolean(lake && ord && lake !== ord);
|
||||||
|
const text = lake || ord || '—';
|
||||||
|
const color = text === '已取消' ? 'red' : (text === '发货中' ? 'orange' : 'default');
|
||||||
|
return (
|
||||||
|
<Tooltip title={`聚水潭:${lake || '—'}|订单表:${ord || '—'}${diff ? '(不一致,订单表没同步到这个状态)' : ''}`}>
|
||||||
|
<Tag bordered={false} color={color}>{text}{diff ? ' ⚠' : ''}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '物流状态',
|
// 物流状态:优先用状态表的真实物流节点(未揽收 / 已揽收 / 运输中 / 已签收…),
|
||||||
dataIndex: 'logisticStatus',
|
// 订单表没有物流轨迹,只能靠「有没有运单号」猜「已打单 · 未揽收」
|
||||||
width: 100,
|
title: (
|
||||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
|
<Tooltip title="聚水潭物流节点(状态镜像表)。已揽收及之后 = 货已经被快递拿走">
|
||||||
|
<span>物流状态</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'stateLogisticStatus',
|
||||||
|
width: 108,
|
||||||
|
render: (v, row) => {
|
||||||
|
const text = v || row?.logisticStatus || '';
|
||||||
|
if (!text) return '—';
|
||||||
|
const shipped = ['已揽收', '运输中', '派送中', '已签收', '拒收'].includes(v);
|
||||||
|
return <Tag bordered={false} color={shipped ? 'green' : 'default'}>{text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 销售出库单状态(2026-10-29 新增)
|
||||||
|
// 🔥 业务点名「这些订单都是作废订单」后挖出来的:聚水潭的作废**落在出库单上**
|
||||||
|
// (`status='Delete'`),订单表的 labels 判据在那批单上 0 命中。
|
||||||
|
// 这一列让业务能直接对照聚水潭「销售出库单」页面,不用再问「系统里为什么没有」。
|
||||||
|
title: (
|
||||||
|
<Tooltip title="聚水潭「销售出库单」的单据状态。已作废=单据被删(不考核);待出库=已生成单据但没出库;空白=还没建出库单(正常,近八成未发单本来就没有)">
|
||||||
|
<span>销售出库单</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'ioStatus',
|
||||||
|
width: 112,
|
||||||
|
render: (v) => {
|
||||||
|
const map = {
|
||||||
|
Delete: ['已作废', 'red'],
|
||||||
|
WaitConfirm: ['待出库', 'blue'],
|
||||||
|
Confirmed: ['已出库', 'green'],
|
||||||
|
OuterConfirming: ['外部确认中', 'default'],
|
||||||
|
};
|
||||||
|
const hit = map[v];
|
||||||
|
if (!hit) return '—';
|
||||||
|
return <Tag bordered={false} color={hit[1]}>{hit[0]}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="从订单创建时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
<Tooltip title="从审核时间(仓库接到单的时刻)到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||||
<span>已等时长</span>
|
<span>已等时长</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@ -291,15 +382,43 @@ const EXPORT_COLUMNS = [
|
|||||||
{ title: '发货仓', dataIndex: 'warehouseName' },
|
{ title: '发货仓', dataIndex: 'warehouseName' },
|
||||||
{ title: '快递公司', dataIndex: 'logisticCompany', exportValue: (v) => v || '未指定' },
|
{ title: '快递公司', dataIndex: 'logisticCompany', exportValue: (v) => v || '未指定' },
|
||||||
{ title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' },
|
{ title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' },
|
||||||
{ title: '物流状态', dataIndex: 'logisticStatus' },
|
{ title: '物流状态', dataIndex: 'stateLogisticStatus', exportValue: (v, row) => v || row?.logisticStatus || '' },
|
||||||
{ title: '发货状态', dataIndex: 'state' },
|
{ title: '发货状态', dataIndex: 'state' },
|
||||||
|
// 聚水潭终态与订单表状态分开导出:两者不一致的单,就是「订单表没同步」的证据
|
||||||
|
{ title: '聚水潭状态', dataIndex: 'stateOrderStatus', exportValue: (v) => v || '—' },
|
||||||
|
// 销售出库单状态:聚水潭的作废落在这一层(Delete=单据已作废),
|
||||||
|
// 业务对账时要拿这一列跟聚水潭「销售出库单」页面对
|
||||||
|
{
|
||||||
|
title: '销售出库单',
|
||||||
|
dataIndex: 'ioStatus',
|
||||||
|
exportValue: (v) => ({
|
||||||
|
Delete: '已作废',
|
||||||
|
WaitConfirm: '待出库',
|
||||||
|
Confirmed: '已出库',
|
||||||
|
OuterConfirming: '外部确认中',
|
||||||
|
}[v] || '未建单'),
|
||||||
|
},
|
||||||
|
{ title: '状态表抓取时间', dataIndex: 'stateQueryTime', exportValue: (v) => v || '—' },
|
||||||
|
// 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文,
|
||||||
|
// 拿着 540 单「未发」去追仓库,实际人家早就发了。
|
||||||
|
{ title: '数据源', dataIndex: 'srcLabel', exportValue: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '数据源是否停更',
|
||||||
|
dataIndex: 'sourceStale',
|
||||||
|
exportValue: (v) => (v ? '是(该单可能已发)' : '否'),
|
||||||
|
},
|
||||||
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '审核时间',
|
||||||
dataIndex: 'auditTime',
|
dataIndex: 'auditTime',
|
||||||
// 创建时间缺失时页面会回退付款时间并打 `*`,导出也保持一致,否则对账对不上
|
// 起算点 = 审核时间;没取到时回退订单创建时间,导出用 `*` 标出来,否则对账对不上
|
||||||
exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '无创建时间'),
|
exportValue: (v, row) => {
|
||||||
|
const t = v || row?.benchmarkTime || '';
|
||||||
|
if (!t) return '无审核时间';
|
||||||
|
return row?.benchmarkSource === 'created' ? `${t} *` : t;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', exportValue: (v) => v || '—' },
|
||||||
{ title: '付款时间', dataIndex: 'payTime' },
|
{ title: '付款时间', dataIndex: 'payTime' },
|
||||||
// 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化
|
// 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化
|
||||||
{ title: '已等(分钟)', dataIndex: 'overdueMinutes' },
|
{ title: '已等(分钟)', dataIndex: 'overdueMinutes' },
|
||||||
@ -320,17 +439,20 @@ const roundFileText = (roundInfo) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效(独立页面)
|
* 仓储中心发货时效(独立页面)
|
||||||
* ────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────
|
||||||
* 业务口径(yanhui 2026-10-20 定轮次;2026-10-28 定起算点):
|
* 业务口径(yanhui 2026-10-20 定轮次;2026-10-28 定起算点):
|
||||||
* · 11 个主仓,子仓并入主仓一起算;
|
* · 11 个主仓,子仓并入主仓一起算;
|
||||||
* · 数据源 = 订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,都要读);
|
* · 数据源 = 订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,都要读);
|
||||||
* · 今天 07:00 前**创建**的订单 13:00 核验,截止今天 16:00 前创建的单 18:00 核验
|
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
|
||||||
* (含 7 点前未发出的、含今天之前创建至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
* (含 7 点前审核未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
||||||
* 今天之前创建仍未发的(历史未发)两个轮次都算;
|
* 今天之前仍未发的(历史未发)两个轮次都算;
|
||||||
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
|
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
|
||||||
* 轮次归属和「已等时长」都按**创建时间**(jst_Orders.created)算 ——
|
* 轮次归属和「已等时长」都按**审核时间**算 ——
|
||||||
* 业务口径:聚水潭「审核时间」指的就是订单创建时间;
|
* 业务 2026-10-28 下午三次定版:审核时间 = max(状态表 last_oa_confirm_time, 订单表 modified)。
|
||||||
|
* 只取状态表会漏 —— 状态表会滞后(9978643 状态表写的是 10-27 17:08,聚水潭真实审核 10-28 14:52,
|
||||||
|
* 整整早一天),导致「今天下午才审核的单」被算进 13:00 轮,冤枉仓库;
|
||||||
|
* 两者都没有才回退订单创建时间,页面上带 `*` 标记;
|
||||||
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
|
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
|
||||||
* · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选);
|
* · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选);
|
||||||
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
|
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
|
||||||
@ -394,6 +516,11 @@ function WarehouseShipTimeliness() {
|
|||||||
const summary = data?.summary || {};
|
const summary = data?.summary || {};
|
||||||
const round = data?.round || null;
|
const round = data?.round || null;
|
||||||
const freshness = data?.freshness || null;
|
const freshness = data?.freshness || null;
|
||||||
|
// 🔥 超时单里「所在订单表已停更」的数量(2026-10-28 定型):
|
||||||
|
// 停更⟹停更之后发出的单 send_date 仍为空⟹被判「未发」,但实际很可能已发。
|
||||||
|
// 18:00 轮实测 546 单里 540 单属于这种,必须显著提示,否则业务会以为判定写错。
|
||||||
|
const staleUnshipped = Number(summary.sourceStaleUnshipped || 0);
|
||||||
|
const staleSources = Array.isArray(data?.dataSources) ? data.dataSources.filter((s) => s && s.stale) : [];
|
||||||
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
|
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
|
||||||
const notStarted = Boolean(data?.notStarted);
|
const notStarted = Boolean(data?.notStarted);
|
||||||
const hasRound = Boolean(round);
|
const hasRound = Boolean(round);
|
||||||
@ -621,23 +748,26 @@ function WarehouseShipTimeliness() {
|
|||||||
return (
|
return (
|
||||||
<SycmPage className="wst-page">
|
<SycmPage className="wst-page">
|
||||||
<header className="wst-header">
|
<header className="wst-header">
|
||||||
<Title level={3}>仓储中心 24 小时发货时效</Title>
|
<Title level={3}>仓储中心发货时效(到点未发即超时)</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||||
|
{/* 2026-10-28 业务定:名字里不再出现「24 小时」—— 规则本身没有 24h 门槛,
|
||||||
|
原名会让 10-27 16:00 创建(只等了 21 小时)的单看着像「没到时限也被判超时」。*/}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" className="wst-note">
|
<Text type="secondary" className="wst-note">
|
||||||
每天 <b>13:00</b> 核验「<b>今天 07:00 前创建</b>」、<b>18:00</b> 核验「<b>截止 16:00 前创建</b>」、
|
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
|
||||||
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前创建、至今未发的历史未发单</b>
|
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
|
||||||
(18:00 轮也会带上 7 点前创建、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
||||||
13:00 之前不核验,显示「未到核验时间」。
|
13:00 之前不核验,显示「未到核验时间」。
|
||||||
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
||||||
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前创建</b>的单、
|
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
|
||||||
<b>河南洛宁仓</b> 只看 <b>13:00 前创建</b>的单(核验时刻仍是 18:00)。
|
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
|
||||||
<b>轮次与「已等时长」都按创建时间算</b>(= 聚水潭「审核时间」,业务口径即订单创建时间 jst_Orders.created);
|
<b>轮次与「已等时长」都按审核时间算</b>(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者:
|
||||||
明细并列「创建时间 / 发货时间」,方便直接和聚水潭对账。
|
审核才是仓库接到单的时刻,按下单时间算会把客服审核积压算到仓库头上;
|
||||||
<b>已发 / 未发直接取订单表 send_date</b>:send_date 有值就是已发(SQL 侧直接过滤,
|
状态表的审核时间会滞后,所以要和订单 modified 取较晚的那个);
|
||||||
根本不进候选),为空的才是未发;不再读状态镜像表的 send_time ——
|
明细并列「审核时间 / 下单时间 / 发货时间」,方便直接和聚水潭对账。
|
||||||
那张表会漏回写(近 7 天 563 单已发却仍算未发)甚至整批停更,是这次换源的根因。
|
<b>已发 / 未发以订单表 send_date 为准</b>(有值即已发,SQL 侧直接过滤不进候选);
|
||||||
|
订单表 send_date 没回写、但状态表 send_time 有值的,也按已发剔除(不漏冤仓库)。
|
||||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||||
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
||||||
@ -654,7 +784,7 @@ function WarehouseShipTimeliness() {
|
|||||||
<>
|
<>
|
||||||
<span className="wst-status__badge">{round.label}</span>
|
<span className="wst-status__badge">{round.label}</span>
|
||||||
<span className="wst-status__label">
|
<span className="wst-status__label">
|
||||||
核验今天 {cutoffText} 前创建的订单
|
核验今天 {cutoffText} 前审核的订单
|
||||||
</span>
|
</span>
|
||||||
<span className="wst-status__window">
|
<span className="wst-status__window">
|
||||||
含历史未发 · 到点未发即超时
|
含历史未发 · 到点未发即超时
|
||||||
@ -666,7 +796,7 @@ function WarehouseShipTimeliness() {
|
|||||||
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
|
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
|
||||||
<span className="wst-status__label">
|
<span className="wst-status__label">
|
||||||
{nextRoundText} 开始首轮核验
|
{nextRoundText} 开始首轮核验
|
||||||
(核验今天 {nextWindowEndText} 前创建的订单)
|
(核验今天 {nextWindowEndText} 前审核的订单)
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -694,14 +824,44 @@ function WarehouseShipTimeliness() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
|
{(freshness && (freshness.stale || freshness.coversDeadline === false))
|
||||||
|
|| staleUnshipped > 0
|
||||||
|
|| staleSources.length > 0 ? (
|
||||||
<Alert
|
<Alert
|
||||||
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
|
// 快照没覆盖到核验时刻 / 有单来自停更的表 → 数字必然偏大,用红色;只是滞后用黄色
|
||||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
type={staleUnshipped > 0 || freshness?.coversDeadline === false ? 'error' : 'warning'}
|
||||||
showIcon
|
showIcon
|
||||||
className="wst-alert"
|
className="wst-alert"
|
||||||
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
|
message={
|
||||||
description={freshness.hint}
|
staleUnshipped > 0
|
||||||
|
? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货`
|
||||||
|
: (freshness?.voidJudgeReliable === false
|
||||||
|
// 🔥 2026-10-29:出库单/状态表停更 ⟹ 作废判据失效。
|
||||||
|
// 业务当天报「洛宁仓 16 单都是作废单」,但我们三个来源全停在作废动作之前,
|
||||||
|
// 判据再准也判不出来 —— 必须明说,别让业务以为是系统认定「没作废」。
|
||||||
|
? '出库单数据源已停止同步,作废单可能判不出来'
|
||||||
|
: (staleSources.length > 0
|
||||||
|
// 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了
|
||||||
|
? '数据源已停止同步(订单表 / 状态镜像表)'
|
||||||
|
: (freshness?.coversDeadline === false
|
||||||
|
? '数据未覆盖本轮核验时刻,超时单量偏大'
|
||||||
|
: '发货数据可能滞后')))
|
||||||
|
}
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
{freshness?.hint}
|
||||||
|
{staleSources.length ? (
|
||||||
|
<div className="wst-alert__sources">
|
||||||
|
{staleSources.map((s) => (
|
||||||
|
<span key={s.key} className="wst-alert__source">
|
||||||
|
{s.label}:最后同步 {String(s.lastUpdate || '').slice(11, 19) || '—'}
|
||||||
|
(已 {Math.round((s.lagMinutes || 0) / 6) / 10} 小时)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -709,7 +869,7 @@ function WarehouseShipTimeliness() {
|
|||||||
<Card bordered={false} className="wst-stat">
|
<Card bordered={false} className="wst-stat">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={(
|
title={(
|
||||||
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按订单创建时间分轮),不设满 24 小时门槛">
|
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
|
||||||
<span>超时未发货</span>
|
<span>超时未发货</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@ -718,6 +878,13 @@ function WarehouseShipTimeliness() {
|
|||||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
|
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
|
||||||
/>
|
/>
|
||||||
|
{staleUnshipped > 0 ? (
|
||||||
|
<Tooltip title="这批单来自已停止同步的订单表:停更之后发出的单,在库里 send_date 还是空,会被判成「未发」。先在聚水潭核对,别直接拿去追仓库。">
|
||||||
|
<div className="wst-stat__warn">
|
||||||
|
其中 {number(staleUnshipped)} 单数据未同步
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
bordered={false}
|
||||||
@ -775,7 +942,7 @@ function WarehouseShipTimeliness() {
|
|||||||
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
|
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
|
||||||
: notStarted
|
: notStarted
|
||||||
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
|
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
|
||||||
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前创建的单,`
|
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
|
||||||
+ `超时未发货 ${c.overdueUnshipped} 单`
|
+ `超时未发货 ${c.overdueUnshipped} 单`
|
||||||
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
|
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
|
||||||
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
|
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
|
||||||
@ -957,7 +1124,7 @@ function WarehouseShipTimeliness() {
|
|||||||
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
|
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary">核验「今天 07:00 前创建」的超时单,默认核验点 + 5 分钟</Text>
|
<Text type="secondary">核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
<div className="wst-notify__row">
|
||||||
@ -973,7 +1140,7 @@ function WarehouseShipTimeliness() {
|
|||||||
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
|
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary">核验「截止 16:00 前创建」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
|
<Text type="secondary">核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
<div className="wst-notify__row">
|
||||||
@ -1075,13 +1242,21 @@ function WarehouseShipTimeliness() {
|
|||||||
|
|
||||||
<Text type="secondary" className="wst-foot">
|
<Text type="secondary" className="wst-foot">
|
||||||
说明:数据源 = 聚水潭<b>订单表 jst_Orders + jst_qmcust_orderlist</b>(两张表店铺互斥,按 o_id 去重)。
|
说明:数据源 = 聚水潭<b>订单表 jst_Orders + jst_qmcust_orderlist</b>(两张表店铺互斥,按 o_id 去重)。
|
||||||
<b>今天 7:00 前创建</b>的订单 13:00 核验;<b>截止 16:00 前创建</b>的单 18:00 核验
|
<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||||
(含 7 点前创建、13:00 还没发出的,也含今天之前创建至今未发的历史单)。
|
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||||
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||||
轮次与「已等时长」都按创建时间(jst_Orders.created)算 —— 业务口径的「审核时间」即订单创建时间。
|
轮次与「已等时长」都按<b>审核时间</b>算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者,
|
||||||
发货 = 订单表 send_date(为空才算未发);状态只取「发货中」。
|
审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 <b>*</b>。
|
||||||
<b>作废单不考核</b>(labels 含「发货前售后」= 客户发货前取消)
|
发货 = 订单表 send_date(为空才算未发)。<b>已发货不考核</b>:状态表 send_time 有值、
|
||||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
聚水潭状态「已发货」、或物流已揽收及之后,任一成立即判已发
|
||||||
|
{summary.stateShippedSkipped > 0 ? ` —— 本轮已剔除 ${summary.stateShippedSkipped} 单` : ''}。
|
||||||
|
状态只取「发货中」。
|
||||||
|
<b>作废单不考核</b>:聚水潭状态「已取消」,或订单表 labels 含「发货前售后」
|
||||||
|
{summary.voidCount > 0
|
||||||
|
? ` —— 本轮已自动剔除 ${summary.voidCount} 单`
|
||||||
|
+ `${summary.stateVoidSkipped > 0 ? `(其中 ${summary.stateVoidSkipped} 单靠聚水潭状态判出,订单表当时没同步)` : ''}`
|
||||||
|
: ''};
|
||||||
|
⚠️ 售后状态「已作废」<b>不算</b>作废单 —— 那是售后单被撤销、订单照常发货,仍要考核。
|
||||||
<b>{excludedCarrierText}不考核</b>
|
<b>{excludedCarrierText}不考核</b>
|
||||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||||
待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||||
|
|||||||
@ -111,6 +111,45 @@
|
|||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
|
||||||
|
.wst-stat__warn {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #cf1322;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
|
||||||
|
.wst-alert__sources {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-alert__source {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 明细里的「内部单号 + 未同步标记」 */
|
||||||
|
.wst-detail__oid {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail__stale {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding-inline: 5px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 主从两栏 ============ */
|
/* ============ 主从两栏 ============ */
|
||||||
.wst-body {
|
.wst-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user