fix(wst): 把销售出库单纳入作废判据与停更监控
业务 14:34 点名 9992097 / 9992098 / 9992924 / 9992925 / 9992926 /
9993284 / 9993285 / 9995464 / 9995465「洛宁仓 16 单都是作废订单,为啥还在
考核之内」,并指明「在聚水潭的销售出库单里面找一下这个作废单据」。
## 查出来的事实
这 9 单在库里**三处全部显示「还在发货」**:
订单表 qmcust status=Delivering、send_date 空
销售出库单 status=WaitConfirm(待出库)
状态镜像表 order_status_name=发货中、logistics=无物流单
不是判据漏了,是**三条数据源全停在作废动作之前**(2026-10-29 15:13 实测):
jst_Orders 14:45 正常
qmcust 13:25 滞后 1.8h
状态镜像表 11:52 滞后 3.4h
出库单(聚水潭/奇门) 03:10 滞后 12.0h ← 作废就落在这张表上
聚水潭的作废落在**出库单** `status='Delete'` 上,而这张表今天凌晨 3 点后
零写入,所以上午作的废一条都没进来
## 改动
1. 新增 `loadOrderIoStatus()`:按 o_id 批量取出库单状态(两表、
分批 500 / 并发 3),判据是 **「该 o_id 下没有任何非 Delete 的出库单」**。
- 只对「发货中」生效:全库「出库单全 Delete」的候选单里 127/131
状态表是「异常」,那批还挂着待处理,删出库单更可能是重开单,
当成作废剔会让业务在「不计考核」里也看不到 → 丢可见性比漏剔更糟
- 拆包/重开的单有多张出库单,删一张不算作废(实测 51 单里 7 单是这种)
- **「没有出库单」绝不判作废**:近 3 天未发单 82%(1467/1790)本来就没出库单
2. 两张出库单表纳入 `loadSourceFreshness()`(用 local_updated / updateTime
灌库时刻,**不能用 modified**,否则没变更时天天误报)
3. 新增 `freshness.voidJudgeReliable` + hint 明说「作废单/取消单可能判不出来」,
告诉业务去聚水潭「销售出库单」核对
4. 明细加「销售出库单」列(已作废/待出库/已出库/未建单),导出同步加列
## 验证
- WST 单测 27/27(新增:出库单全删必剔 / 拆包重开不剔 / 异常单不剔 /
无出库单不剔 / 停更时提示作废判不出来 / 源正常时不误报)
- 后端全量 498/500(2 个既有失败:负缓存 TTL、Windows 文件权限)
- 真实数据:ioVoidSkipped=11(发货中 + 出库单全删),overdue 仍 16 单
- 本地真渲染:表头有「销售出库单」、首行显示「待出库」、
红条列出 5 个数据源停更状态、**pageerror 0**
## 没解决(已在页面明说,不掩盖)
这 9 单今天仍然判不出作废 —— 数据没进来,判据再准也没用。
根子在 ETL:出库单表停 12 小时。ETL 恢复后判据立即生效,
但那 9 单是否作废仍需 IT 修同步或业务在聚水潭复核。
This commit is contained in:
parent
1139e4c364
commit
54518ab4d3
@ -401,6 +401,29 @@ const AFTERSALE_VOID_STATUS = '已作废';
|
||||
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` 里**等于已发**的值。
|
||||
*
|
||||
@ -689,6 +712,7 @@ module.exports = {
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
@ -110,6 +110,7 @@ const {
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
resolveCenter,
|
||||
} = registry;
|
||||
|
||||
@ -445,7 +446,103 @@ async function loadStateInfo(stateRows) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个订单表**各自**的最后更新时间,用于判断「这个源还活着吗」。
|
||||
* 批量取**销售出库单**的状态 —— 判「这单在聚水潭里被作废了吗」。
|
||||
*
|
||||
* 🔥 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` 会集体冻在停更那一刻,看起来「都一样旧」;
|
||||
@ -489,6 +586,26 @@ async function loadSourceFreshness(round, now) {
|
||||
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 上限,任一个慢不拖累另一个)
|
||||
@ -540,6 +657,9 @@ function emptySummary() {
|
||||
// 已剔除的「订单已取消」单量:状态表 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,
|
||||
@ -666,6 +786,17 @@ async function getShipTimeliness(options = {}) {
|
||||
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 单来自
|
||||
@ -688,6 +819,8 @@ async function getShipTimeliness(options = {}) {
|
||||
let stateShippedSkipped = 0;
|
||||
// 状态表 `order_status_name='已取消'` → 订单已作废,剔除(订单表停更时唯一可靠来源)
|
||||
let stateVoidSkipped = 0;
|
||||
// 出库单全部为 `Delete` → 单据作废,剔除(2026-10-29 新增,业务点名的作废单)
|
||||
let ioVoidSkipped = 0;
|
||||
// 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…)
|
||||
let unknownWarehouse = 0;
|
||||
// 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信
|
||||
@ -753,6 +886,10 @@ async function getShipTimeliness(options = {}) {
|
||||
// 状态镜像表里的聚水潭终态(订单表停更时唯一可信的「这单还发不发」)
|
||||
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 / 下单时间 三者取最大。
|
||||
@ -795,6 +932,30 @@ async function getShipTimeliness(options = {}) {
|
||||
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 单是这种)
|
||||
@ -841,6 +1002,10 @@ async function getShipTimeliness(options = {}) {
|
||||
stateLogisticStatus,
|
||||
stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '',
|
||||
stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '',
|
||||
// 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库)
|
||||
// 业务核对时直接对照聚水潭「销售出库单」页面
|
||||
ioStatus,
|
||||
ioCount: ioInfo ? ioInfo.count : 0,
|
||||
// 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算
|
||||
benchmarkAt,
|
||||
benchmarkSource,
|
||||
@ -945,6 +1110,10 @@ async function getShipTimeliness(options = {}) {
|
||||
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,
|
||||
@ -1025,6 +1194,8 @@ async function getShipTimeliness(options = {}) {
|
||||
summary.stateShippedSkipped = stateShippedSkipped;
|
||||
// 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效)
|
||||
summary.stateVoidSkipped = stateVoidSkipped;
|
||||
// 由**销售出库单整体 Delete** 剔除的作废单量(业务 2026-10-29 点名的作废单)
|
||||
summary.ioVoidSkipped = ioVoidSkipped;
|
||||
summary.sourceStaleUnshipped = sourceStaleUnshipped;
|
||||
summary.unknownWarehouse = unknownWarehouse;
|
||||
|
||||
@ -1052,6 +1223,16 @@ async function getShipTimeliness(options = {}) {
|
||||
// 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||
// 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。
|
||||
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||
|
||||
// 🔴 出库单/状态表停更 ⟹ **「这单作废了吗」这件事本身查不出来**(2026-10-29 新增)
|
||||
// 业务当天点名 9992097 等 9 单「都是作废订单,为啥还在考核之内」:
|
||||
// 聚水潭里已经作废,但我们库里订单表还是 Delivering、出库单还是 WaitConfirm、
|
||||
// 状态表还是「发货中」—— 三个来源全停在作废动作**之前**,所以判据再准也判不出来。
|
||||
// ⟹ 这种情况必须**明说判据失效**,不能让业务以为「系统认定这单没作废」。
|
||||
const voidJudgeStale = ['jst_order_express_details', 'jst_salesOutboundInfo', 'jst_qmcust_salesoutbound']
|
||||
.map((k) => sourceMap.get(k))
|
||||
.filter((s) => s && s.stale);
|
||||
|
||||
const freshness = {
|
||||
stateMaxQuery: snapshotAt,
|
||||
ageMinutes: snapshotAgeMin,
|
||||
@ -1059,6 +1240,8 @@ async function getShipTimeliness(options = {}) {
|
||||
coversDeadline,
|
||||
// 数据源已经换成订单表,文案跟着改(别再写「状态镜像」)
|
||||
source: 'jst_Orders',
|
||||
// 作废/取消判据是否可信(false = 出库单或状态表停更,作废单可能没剔干净)
|
||||
voidJudgeReliable: voidJudgeStale.length === 0,
|
||||
hint: [
|
||||
// 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事
|
||||
staleSrcText ? `数据源停更:${staleSrcText}。` : '',
|
||||
@ -1066,6 +1249,11 @@ async function getShipTimeliness(options = {}) {
|
||||
? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):`
|
||||
+ '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。'
|
||||
: '',
|
||||
voidJudgeStale.length > 0
|
||||
? `⚠️ ${voidJudgeStale.map((s) => s.label).join('、')} 已停止同步,`
|
||||
+ '作废单 / 取消单可能判不出来(聚水潭里的作废动作还没同步到本系统),'
|
||||
+ '下面凡是显示「发货中 · 无物流单」的单,请先在聚水潭「销售出库单」里核对是否已作废。'
|
||||
: '',
|
||||
!coversDeadline
|
||||
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
|
||||
@ -1098,6 +1286,8 @@ async function getShipTimeliness(options = {}) {
|
||||
// 审核时间缺失、回退订单创建时间的单量(页面/钉钉需要标出来)
|
||||
fallbackToCreated: orders.filter((o) => o.benchmarkSource === 'created').length,
|
||||
fallbackDegraded: oaConfirmDegraded,
|
||||
// 销售出库单查询失败 ⟹ 作废判据降级(页面据此提示「作废单可能没剔干净」)
|
||||
ioDegraded: ioDegraded,
|
||||
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||
overrides: windowOverrides,
|
||||
},
|
||||
|
||||
@ -184,7 +184,7 @@ test('起算点是 orders.created,且只从订单两表取', () => {
|
||||
});
|
||||
|
||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||
function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') {
|
||||
function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) {
|
||||
const WMS = {
|
||||
CQ: 15038426,
|
||||
SD_QGJ: 15137523,
|
||||
@ -309,11 +309,36 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
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 : defaultRows));
|
||||
: (scenario === 'terminal' ? terminalRows
|
||||
: (scenario === 'io' ? ioScenarioRows : defaultRows)));
|
||||
|
||||
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] };
|
||||
const calls = { orders: 0, oaConfirm: 0, io: 0, warehouse: 0, freshness: 0, sql: [], params: [] };
|
||||
|
||||
const handler = async (sql, params) => {
|
||||
const text = String(sql);
|
||||
@ -323,7 +348,9 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
}
|
||||
// 数据源新鲜度探测:`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(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)) {
|
||||
@ -332,6 +359,12 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
// 要测停更场景就把 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)) {
|
||||
@ -355,11 +388,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
return { calls, WMS };
|
||||
}
|
||||
|
||||
function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') {
|
||||
function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) {
|
||||
return async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd);
|
||||
const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd, ioRows);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
try {
|
||||
@ -739,7 +772,65 @@ test('状态表也纳入数据源新鲜度监控', async () => {
|
||||
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'), '状态镜像表要作为独立数据源透出');
|
||||
assert.equal(data.dataSources.length, 3, '两个订单表 + 状态镜像表');
|
||||
// 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');
|
||||
});
|
||||
|
||||
|
||||
@ -329,6 +329,30 @@ const DETAIL_COLUMNS = [
|
||||
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: (
|
||||
<Tooltip title="从审核时间(仓库接到单的时刻)到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
@ -362,6 +386,18 @@ const EXPORT_COLUMNS = [
|
||||
{ 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 单「未发」去追仓库,实际人家早就发了。
|
||||
@ -799,12 +835,17 @@ function WarehouseShipTimeliness() {
|
||||
message={
|
||||
staleUnshipped > 0
|
||||
? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货`
|
||||
: (staleSources.length > 0
|
||||
// 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了
|
||||
? '数据源已停止同步(订单表 / 状态镜像表)'
|
||||
: (freshness?.coversDeadline === false
|
||||
? '数据未覆盖本轮核验时刻,超时单量偏大'
|
||||
: '发货数据可能滞后'))
|
||||
: (freshness?.voidJudgeReliable === false
|
||||
// 🔥 2026-10-29:出库单/状态表停更 ⟹ 作废判据失效。
|
||||
// 业务当天报「洛宁仓 16 单都是作废单」,但我们三个来源全停在作废动作之前,
|
||||
// 判据再准也判不出来 —— 必须明说,别让业务以为是系统认定「没作废」。
|
||||
? '出库单数据源已停止同步,作废单可能判不出来'
|
||||
: (staleSources.length > 0
|
||||
// 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了
|
||||
? '数据源已停止同步(订单表 / 状态镜像表)'
|
||||
: (freshness?.coversDeadline === false
|
||||
? '数据未覆盖本轮核验时刻,超时单量偏大'
|
||||
: '发货数据可能滞后')))
|
||||
}
|
||||
description={(
|
||||
<>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user