fix(wst): 用聚水潭状态终态 + 销售出库单判作废/已发 #93
@ -378,6 +378,64 @@ const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
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 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -651,6 +709,10 @@ module.exports = {
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
@ -107,6 +107,10 @@ const {
|
||||
ORDER_QUESTION_STATUS,
|
||||
ORDER_CANDIDATE_STATUSES,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
resolveCenter,
|
||||
} = registry;
|
||||
|
||||
@ -357,7 +361,7 @@ async function loadPendingOrders(round) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取**审核时间**(起算点)—— 唯一来源是状态镜像表 `jst_order_express_details`。
|
||||
* 批量取**状态镜像表**里的单号级信息(起算点 + 终态校验)。
|
||||
*
|
||||
* 为什么必须回状态表拿:
|
||||
* · `jst_Orders` / `jst_qmcust_orderlist` **都没有审核时间字段**(85 列逐一看过,
|
||||
@ -367,17 +371,26 @@ async function loadPendingOrders(round) {
|
||||
* ⚠️ 只从它拿**这一个字段**,候选取舍(哪些单未发货)仍然只看订单两表 ——
|
||||
* 状态表的 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 loadOaConfirmInfo(stateRows) {
|
||||
async function loadStateInfo(stateRows) {
|
||||
const ids = [...new Set(
|
||||
(stateRows || []).map((r) => Number(r.o_id)).filter((n) => Number.isFinite(n))
|
||||
)];
|
||||
@ -401,7 +414,9 @@ async function loadOaConfirmInfo(stateRows) {
|
||||
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
|
||||
`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(',')})`,
|
||||
{},
|
||||
@ -416,6 +431,13 @@ async function loadOaConfirmInfo(stateRows) {
|
||||
// 顺带带回来做「已发」二次兜底:订单表 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -424,7 +446,103 @@ async function loadOaConfirmInfo(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` 会集体冻在停更那一刻,看起来「都一样旧」;
|
||||
@ -457,6 +575,37 @@ async function loadSourceFreshness(round, now) {
|
||||
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 上限,任一个慢不拖累另一个)
|
||||
@ -502,8 +651,15 @@ function emptySummary() {
|
||||
voidCount: 0,
|
||||
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能)
|
||||
carrierSkipped: 0,
|
||||
// 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写)
|
||||
// 已剔除的「其实已发」单量:状态表 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,
|
||||
@ -619,15 +775,26 @@ async function getShipTimeliness(options = {}) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ③.5 起算点:批量取审核时间(最后审核时间优先,缺失回退订单创建时间)
|
||||
// ③.5 状态镜像表:起算点(审核时间)+ 终态(已取消 / 已发货)
|
||||
// ⚠️ 状态表挂了不阻断页面:全量回退 created,并在 payload 里标降级
|
||||
let oaConfirmMap = new Map();
|
||||
let stateInfoMap = new Map();
|
||||
let oaConfirmDegraded = false;
|
||||
try {
|
||||
oaConfirmMap = await loadOaConfirmInfo(stateRows);
|
||||
stateInfoMap = await loadStateInfo(stateRows);
|
||||
} catch (e) {
|
||||
oaConfirmDegraded = true;
|
||||
console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message);
|
||||
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 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ──────────
|
||||
@ -650,6 +817,10 @@ async function getShipTimeliness(options = {}) {
|
||||
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 为空…)
|
||||
let unknownWarehouse = 0;
|
||||
// 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信
|
||||
@ -711,21 +882,16 @@ async function getShipTimeliness(options = {}) {
|
||||
// (少报超时,不冤枉仓库),业务已知悉。
|
||||
const createdAt = formatDateTime(row.created);
|
||||
const modifiedAt = formatDateTime(row.modified);
|
||||
const oaInfo = oaConfirmMap.get(Number(row.o_id));
|
||||
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);
|
||||
|
||||
// ── ② 已发兜底:状态表说发了,就不再算未发 ─────────────────────────────
|
||||
// PR #88 换源时记录的是「状态表 send_time 漏回写」这个方向,但**反方向也会漏**:
|
||||
// 2026-10-28 实测 13:00 轮 144 单里 **6 单**状态表 send_time 有值、且有
|
||||
// 物流揽收轨迹,而订单表 send_date 仍是 NULL —— 只信订单表就会冤枉仓库。
|
||||
// ⟹ 口径:**任一方有发货时间即视为已发**,剔除量记 summary.stateShippedSkipped。
|
||||
// ⚠️ 只认 send_time,**不要**拿 `logistic_time` 当发货时间:它多数是
|
||||
// 「快递员将上门取件」的预告(实测 9983162 有轨迹但并未收件),会提前放行。
|
||||
if (oaInfo && oaInfo.sendTime) {
|
||||
stateShippedSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const oaConfirmAt = oaInfo ? (oaInfo.last || '') : '';
|
||||
const oaConfirmAt = stateInfo ? (stateInfo.last || '') : '';
|
||||
// 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。
|
||||
// · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据);
|
||||
// · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据),
|
||||
@ -742,6 +908,71 @@ async function getShipTimeliness(options = {}) {
|
||||
if (seenSo.has(dedupeKey)) continue;
|
||||
seenSo.add(dedupeKey);
|
||||
|
||||
// ══ 终态校验(2026-10-29 新增,**必须排在窗口判断和「不计考核」之前**)════
|
||||
// 🔥 背景:业务第三次报「作废的、已经发货的还在考核之内」。根因不是判定写错,
|
||||
// 而是**订单两表各自停更**:当天 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)
|
||||
@ -766,6 +997,15 @@ async function getShipTimeliness(options = {}) {
|
||||
createdAt,
|
||||
oaConfirmAt,
|
||||
payAt,
|
||||
// 状态镜像表里的聚水潭终态 —— 订单表停更时,页面靠它告诉业务「这单其实取消了/发了」
|
||||
stateOrderStatus,
|
||||
stateLogisticStatus,
|
||||
stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '',
|
||||
stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '',
|
||||
// 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库)
|
||||
// 业务核对时直接对照聚水潭「销售出库单」页面
|
||||
ioStatus,
|
||||
ioCount: ioInfo ? ioInfo.count : 0,
|
||||
// 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算
|
||||
benchmarkAt,
|
||||
benchmarkSource,
|
||||
@ -865,6 +1105,15 @@ async function getShipTimeliness(options = {}) {
|
||||
benchmarkTime: x.benchmarkAt,
|
||||
benchmarkSource: x.benchmarkSource,
|
||||
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,
|
||||
@ -943,6 +1192,10 @@ async function getShipTimeliness(options = {}) {
|
||||
summary.voidCount = voidCount;
|
||||
summary.carrierSkipped = carrierSkipped;
|
||||
summary.stateShippedSkipped = stateShippedSkipped;
|
||||
// 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效)
|
||||
summary.stateVoidSkipped = stateVoidSkipped;
|
||||
// 由**销售出库单整体 Delete** 剔除的作废单量(业务 2026-10-29 点名的作废单)
|
||||
summary.ioVoidSkipped = ioVoidSkipped;
|
||||
summary.sourceStaleUnshipped = sourceStaleUnshipped;
|
||||
summary.unknownWarehouse = unknownWarehouse;
|
||||
|
||||
@ -970,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,
|
||||
@ -977,6 +1240,8 @@ async function getShipTimeliness(options = {}) {
|
||||
coversDeadline,
|
||||
// 数据源已经换成订单表,文案跟着改(别再写「状态镜像」)
|
||||
source: 'jst_Orders',
|
||||
// 作废/取消判据是否可信(false = 出库单或状态表停更,作废单可能没剔干净)
|
||||
voidJudgeReliable: voidJudgeStale.length === 0,
|
||||
hint: [
|
||||
// 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事
|
||||
staleSrcText ? `数据源停更:${staleSrcText}。` : '',
|
||||
@ -984,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)}:`
|
||||
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
|
||||
@ -1016,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,
|
||||
@ -294,9 +294,51 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
o(53, WMS.CQ, '2026-10-20 05:00:00', { modified: '2026-10-19 23:00:00' }),
|
||||
];
|
||||
|
||||
const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows);
|
||||
// 终态校验专用场景(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'), // 状态表停更,无终态信息 → 按未发算
|
||||
];
|
||||
|
||||
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] };
|
||||
// 出库单作废专用场景(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 text = String(sql);
|
||||
@ -305,14 +347,24 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-
|
||||
return warehouseRows;
|
||||
}
|
||||
// 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` /
|
||||
// `SELECT MAX(local_updated) FROM jst_qmcust_orderlist`(2026-10-28 新增)。
|
||||
// `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)) {
|
||||
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)) {
|
||||
@ -336,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 {
|
||||
@ -675,6 +727,113 @@ test('状态表审核时间比 modified 更晚时,以状态表为准', withStu
|
||||
{ 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 单来自它,
|
||||
// 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。
|
||||
|
||||
@ -288,23 +288,70 @@ const DETAIL_COLUMNS = [
|
||||
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
// 发货状态:订单表 status(翻译成中文)
|
||||
// 发货状态:**优先显示聚水潭终态**(状态镜像表),订单表状态只作对照
|
||||
// 🔥 2026-10-29:订单两表会各自停更,停更期间聚水潭里已经「已取消 / 已发货」的单,
|
||||
// 订单表里仍是「发货中」——业务看到的「作废的也在考核里」就是这么来的。
|
||||
// 状态镜像表比订单表新鲜,`order_status_name` 才是聚水潭的真状态。
|
||||
title: (
|
||||
<Tooltip title="订单表 jst_Orders.status 的实时状态。发货中 = 仓库还没发货,即考核对象">
|
||||
<Tooltip title="优先显示聚水潭订单终态(状态镜像表,订单表停更时只有它靠得住)。⚠ = 与订单表 jst_Orders.status 不一致">
|
||||
<span>发货状态</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'state',
|
||||
width: 104,
|
||||
render: (v) => (
|
||||
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
|
||||
),
|
||||
dataIndex: 'stateOrderStatus',
|
||||
width: 128,
|
||||
render: (v, row) => {
|
||||
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,
|
||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
|
||||
// 物流状态:优先用状态表的真实物流节点(未揽收 / 已揽收 / 运输中 / 已签收…),
|
||||
// 订单表没有物流轨迹,只能靠「有没有运单号」猜「已打单 · 未揽收」
|
||||
title: (
|
||||
<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: (
|
||||
@ -335,8 +382,23 @@ const EXPORT_COLUMNS = [
|
||||
{ title: '发货仓', dataIndex: 'warehouseName' },
|
||||
{ title: '快递公司', dataIndex: 'logisticCompany', 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: '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 || '—' },
|
||||
@ -773,11 +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={(
|
||||
<>
|
||||
@ -1179,9 +1247,16 @@ function WarehouseShipTimeliness() {
|
||||
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||
轮次与「已等时长」都按<b>审核时间</b>算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者,
|
||||
审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 <b>*</b>。
|
||||
发货 = 订单表 send_date(为空才算未发,但状态表 send_time 有值的也判已发);状态只取「发货中」。
|
||||
<b>作废单不考核</b>(labels 含「发货前售后」= 客户发货前取消)
|
||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||
发货 = 订单表 send_date(为空才算未发)。<b>已发货不考核</b>:状态表 send_time 有值、
|
||||
聚水潭状态「已发货」、或物流已揽收及之后,任一成立即判已发
|
||||
{summary.stateShippedSkipped > 0 ? ` —— 本轮已剔除 ${summary.stateShippedSkipped} 单` : ''}。
|
||||
状态只取「发货中」。
|
||||
<b>作废单不考核</b>:聚水潭状态「已取消」,或订单表 labels 含「发货前售后」
|
||||
{summary.voidCount > 0
|
||||
? ` —— 本轮已自动剔除 ${summary.voidCount} 单`
|
||||
+ `${summary.stateVoidSkipped > 0 ? `(其中 ${summary.stateVoidSkipped} 单靠聚水潭状态判出,订单表当时没同步)` : ''}`
|
||||
: ''};
|
||||
⚠️ 售后状态「已作废」<b>不算</b>作废单 —— 那是售后单被撤销、订单照常发货,仍要考核。
|
||||
<b>{excludedCarrierText}不考核</b>
|
||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||
待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user