cangku/backend/test/warehouseShipTimeliness.test.js
yanhui 54518ab4d3 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 修同步或业务在聚水潭复核。
2026-10-29 15:16:25 +08:00

864 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-28 第九版:换数据源 + 换起算点)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
* ③ 🔥 数据源必须是**订单两表** `jst_Orders` + `jst_qmcust_orderlist`(业务 2026-10-28:
* 「销售订单表是错误的,应该在数据库抓 jst_Orders + jst_OrderItem」)。
* 原来打的状态镜像表 `jst_order_express_details` 有三处硬伤(send_time 漏回写 /
* 合并拆单不回写 / 整批停更 6.8 小时),已废弃;
* 两张订单表**店铺互斥**(交集 0),必须 UNION ALL 都读,按 o_id 去重;
* ④ 🔥 起算点 = `jst_Orders.created`(**订单创建时间**)。
* 业务原话:「审核时间 jst_Orders 是这个表里面的创建时间」。
* 实测 created 100% 早于状态表的 last_oa_confirm_time:正常单差 7~40 分钟,
* 夜间下单次日才审的单差 6~9 小时 —— 所以换口径后单量与时长都会变大,这是**预期**;
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时;
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核(WaitConfirm)·异常(Question)=不计考核;
* 作废单(labels 含「发货前售后」)不考核;
* ⑦ 性能护栏:订单两表只允许 **1 次** querySlow,不得 JOIN 仓表、不得投影宽列;
* ⑧ **不按 is_split / is_merge 过滤** —— 实测 is_split=1 是拆单**子单**(要发货的),
* 按老逻辑 is_split=0 会把近 30 天 1096 行真单排掉。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试(注入假 cloudDatabase)。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const registry = require('../src/services/warehouseShipTimelinessRegistry');
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
});
test('resolveCenter 能捞到各种命名的子仓', () => {
const cases = [
['简尚-运营C横林二仓专用仓', 'HL2'],
['简尚-横林二仓京东仓', 'HL2'],
['京仓-横林二仓京东自营仓', 'HL2'],
['简尚-崔桥工厂仓唯品仓', 'CQ'],
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
['简尚-新苏北临时仓', 'XSB'],
['简尚-分销部睢宁专用仓', 'SN'],
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
['简尚-河南洛宁仓', 'HN_LN'],
];
for (const [name, expected] of cases) {
assert.equal(registry.resolveCenter(name)?.key, expected, name);
}
});
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
});
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
const shouldBeNull = [
'工厂-山东菏泽森美代发仓',
'工厂-山东恒思代发仓',
'工厂-河南千优代发仓',
'工厂-河北霸州派雅世佳代发仓',
'简尚-苏州福利品仓',
];
for (const name of shouldBeNull) {
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
}
});
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
assert.ok(c, 'CQ_WIP 必须存在');
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
});
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
});
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
const r = registry.resolveActiveRound(at(h, m));
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
assert.equal(r.key, 'NOON');
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
assert.equal(r.deadline, '2026-10-20 13:00:00');
assert.equal(r.verifyHour, 13);
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
assert.equal(r.includeHistory, true);
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
}
});
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
const r = registry.resolveActiveRound(at(18, 0));
assert.equal(r.key, 'AFTERNOON');
assert.equal(r.deadline, '2026-10-20 18:00:00');
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
});
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
const rounds = registry.describeRounds(at(16, 5));
assert.deepEqual(
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
);
assert.equal(rounds[0].active, true);
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
});
test('创建晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
const [noon, afternoon] = registry.describeRounds(at(16, 5));
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
'18:00 是当天最后一轮 → 次日 13:00');
// 跨月边界也要对
assert.equal(
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
'2026-11-01 13:00:00'
);
});
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
test('数据源必须是订单两表,不再是状态镜像表', () => {
assert.deepEqual(
[...registry.ORDER_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表店铺互斥(交集 0),必须都读'
);
// 状态表的常量还留着(兜底 / 老代码引用),但服务层不许再用它取候选
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
});
test('候选状态白名单:发货中 / 待审核 / 异常 三种', () => {
assert.equal(registry.ORDER_PENDING_STATUS, 'Delivering');
assert.equal(registry.ORDER_UNAUDITED_STATUS, 'WaitConfirm');
assert.equal(registry.ORDER_QUESTION_STATUS, 'Question');
assert.equal(registry.ORDER_SHIPPED_STATUS, 'Sent');
assert.deepEqual(
[...registry.ORDER_CANDIDATE_STATUSES],
['Delivering', 'WaitConfirm', 'Question'],
'Sent / Cancelled / Split / Merged 一律不进候选'
);
// 中文状态只是给人看的
assert.equal(registry.SHIPPING_STATUS, '发货中');
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
assert.equal(registry.ABNORMAL_STATUS, '异常');
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
});
test('起算点是 orders.created,且只从订单两表取', () => {
assert.deepEqual(
[...registry.CREATED_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表都要查(店铺互斥,交集为 0)'
);
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '常量保留(分批 IN 的批量上限)');
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) {
const WMS = {
CQ: 15038426,
SD_QGJ: 15137523,
SD_BRAND: 15182705,
HN_LN: 15539084,
JD_CLOUD: 99999901,
};
const warehouseRows = [
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: WMS.CQ },
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: WMS.SD_QGJ },
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: WMS.SD_BRAND },
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: WMS.HN_LN },
// 业务指定排除的京东云仓:仓表里有它,但 resolveCenter 返回 null
{ Id: 5, name: '京仓-横林二仓京东云仓', wms_co_id: WMS.JD_CLOUD },
// 代发仓:归不到主仓 → 计入 unknownWarehouse
{ Id: 6, name: '工厂-山东菏泽森美代发仓', wms_co_id: 88880001 },
];
// 订单行 = 主查询的投影(SQL 已经按 send_date IS NULL + status IN 过滤过)
const o = (id, wmsCoId, created, extra = {}) => ({
o_id: id,
so_id: `SO-${id}`,
shop_name: '测试店',
status: 'Delivering',
created,
pay_date: created,
logistics_company: '',
l_id: '',
presaleVoid: 0,
src: 'jst_Orders',
updated_at: '2026-10-20 18:02:00',
wms_co_id: wmsCoId,
...extra,
});
const defaultRows = [
// 1:创建 09-25 00:50 → 到 18:00 核验 = 25 天 17 小时 = 37020 分,超时
o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00' }),
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验
o(2, WMS.CQ, '2026-10-20 16:20:00'),
// 3:没创建时间(真实 SQL 里 created >= 30 天护栏会挡掉,这里测 JS 兜底分支)→ 不计考核
o(3, WMS.CQ, null, { pay_date: '2026-09-26 00:10:00' }),
// 4:WaitConfirm 待审核 → 不计考核
o(4, WMS.CQ, '2026-09-23 01:00:00', { status: 'WaitConfirm' }),
// 5:Question 异常 → 不计考核
o(5, WMS.CQ, '2026-09-23 01:05:00', { status: 'Question' }),
// 6:创建 17:00 晚于 16:00 → 本轮不核验
o(6, WMS.CQ, '2026-10-20 17:00:00'),
// 7:京东云仓(业务排除)→ 不计入任何主仓
o(7, WMS.JD_CLOUD, '2026-09-25 00:10:00'),
// 8:与 1 同 so_id + 同创建时间(拆包多行)→ 必须去重成 1 单
o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00', so_id: 'SO-1' }),
// 13:创建 15:00 → 18:00 只等 3 小时,也必须算超时(无 24h 门槛)
o(13, WMS.CQ, '2026-10-20 15:00:00'),
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
o(31, WMS.SD_QGJ, '2026-10-20 15:30:00'), // 晚于 15:00 上界 → 不核验
o(32, WMS.SD_BRAND, '2026-10-20 14:30:00'), // 早于 15:00 上界 → 核验(210 分)
o(33, WMS.HN_LN, '2026-10-20 13:30:00'), // 晚于 13:00 上界 → 不核验
o(34, WMS.HN_LN, '2026-10-20 12:30:00'), // 早于 13:00 上界 → 核验(330 分)
// 26:昨天中午创建、至今未发 → 历史未发池,超时 3240 分
o(26, WMS.CQ, '2026-10-18 12:00:00'),
// 25:前天一早就创建里挂着 → 超时 1920 分
o(25, WMS.CQ, '2026-10-19 10:00:00'),
// 35/36/41/42:不考核快递 —— 状态/时间都够超时,靠快递名剔除
o(35, WMS.CQ, '2026-09-25 01:00:00', { logistics_company: '中铁物流' }),
o(36, WMS.CQ, '2026-09-25 02:00:00', { logistics_company: '壹米滴答专线' }),
o(41, WMS.CQ, '2026-09-25 03:00:00', { logistics_company: '武进安能' }),
o(42, WMS.CQ, '2026-09-25 04:00:00', { logistics_company: '武进安能大件' }),
// 38:Question(聚水潭后台「已作废/问题单」)→ 不计考核
o(38, WMS.CQ, '2026-10-20 09:10:00', { status: 'Question' }),
// 39:labels 含「发货前售后」(SQL 已折成布尔)→ 作废,进 voidCount
o(39, WMS.CQ, '2026-10-20 10:10:00', { presaleVoid: 1 }),
// 40:创建 11:10 → 410 分。⚠️ 状态表那条「售后已作废」判据已随数据源切换放弃
// (实测只命中 4 单,不值得为它多扫 402 万行),所以这类单现在按正常单算超时
o(40, WMS.CQ, '2026-10-20 11:10:00'),
// ── 以下由 SQL 过滤掉(status 不在白名单 / send_date 有值),
// 这里照样塞进最后端,验证「SQL 过滤 + JS 兜底」两道闸都不会漏 ──
o(9, WMS.CQ, '2026-09-21 08:00:00', { status: 'Cancelled' }),
o(10, WMS.CQ, '2026-09-21 09:59:00', { status: 'Merged', send_date: '2026-09-22 06:54:49' }),
o(11, WMS.CQ, '2026-09-21 10:59:00', { send_date: '2026-09-22 08:00:00' }),
o(37, WMS.CQ, '2026-09-22 08:50:00', { status: 'Sent' }),
// 拆单子单(is_split=true 也要算,别按 is_split 过滤)
o(43, WMS.CQ, '2026-10-20 13:00:00', { is_split: true }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
const noonRows = [
o(21, WMS.CQ, '2026-10-20 06:45:00', { updated_at: '2026-10-20 11:18:27' }), // 375 分
o(22, WMS.CQ, '2026-10-20 05:55:00', { updated_at: '2026-10-20 11:18:27' }), // 425 分
o(23, WMS.CQ, '2026-10-19 19:55:00', { updated_at: '2026-10-20 11:18:27' }), // 1025 分
o(24, WMS.CQ, '2026-10-20 07:30:00', { updated_at: '2026-10-20 11:18:27' }), // 晚于 07:00 → 不核验
o(25, WMS.CQ, '2026-10-19 10:00:00', { updated_at: '2026-10-20 11:18:27' }), // 1620 分
o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分
];
// 「起算点 = max(审核时间, 订单 modified)」专用场景(2026-10-28 下午定版)
// 🔥 为什么必须取较晚者:状态表的 last_oa_confirm_time 会滞后。
// 实测 9978643:状态表写 10-27 17:08,聚水潭真实审核 10-28 14:52,
// 只信状态表 → 把「今天下午才审核的单」算进 13:00 轮,冤枉仓库。
const modifiedRows = [
// 51:created 06:45(7 点前),但 modified 是今天 14:52 → 起算点 14:52 晚于窗口 07:00 → 剔除
o(51, WMS.CQ, '2026-10-20 06:45:00', { modified: '2026-10-20 14:52:29' }),
// 52:历史单 created 10-18,状态表无审核时间,modified 今天 06:00 → 算超时,起算点 06:00
o(52, WMS.CQ, '2026-10-18 12:00:00', { modified: '2026-10-20 06:00:00' }),
// 53:modified 比 created 还早(异常数据)→ 起算点回落到 created
o(53, WMS.CQ, '2026-10-20 05:00:00', { modified: '2026-10-19 23:00:00' }),
];
// 终态校验专用场景(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 text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
calls.warehouse += 1;
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.sql.push(text);
calls.params.push(params || {});
// 模拟 SQL 侧过滤:send_date IS NULL 且 status 在白名单里
return rows.filter((r) => r.send_date == null
&& ['Delivering', 'WaitConfirm', 'Question'].includes(r.status));
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return { calls, WMS };
}
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, ioRows);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
await fn(svc, ctx);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
}
};
}
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
// 18:05 → 18:00 轮:窗口上界 16:00(按创建时间),核验时刻 18:00
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
const c = data.centers.find((x) => x.key === 'CQ');
assert.ok(c, '应能归到崔桥工厂仓');
// 崔桥工厂仓超时 = 1(09-25) + 13(15:00) + 25(10-19 10:00) + 26(10-18 12:00) + 40(11:10) + 43(13:00)
// 2/6 创建晚于 16:00、3 没创建时间、4/5/38 不计考核、7 非主仓、8 去重
assert.equal(c.overdueUnshipped, 6, '1、13、25、26、40、43 六单算超时');
assert.equal(c.oldestDays, 25, '最久一单 09-25 00:50 创建,到 18:00 核验是 25 天');
assert.equal(c.heldCount, 4, '待审核 1 + 异常 2 + 缺创建时间 1');
// 全局:崔桥 6 + 山东品牌 1 + 河南洛宁 1 = 8
assert.equal(data.summary.overdueUnshipped, 8);
assert.equal(data.summary.overdueCenterCount, 3);
assert.equal(data.summary.oldestDays, 25);
assert.equal(data.summary.heldCount, 4);
// 京东云仓归不到主仓 → unknownWarehouse(代发仓不在本场景行里,故只有 1 单)
assert.equal(data.summary.unknownWarehouse, 1);
// 明细:最久的排最前;起算点就是创建时间
assert.equal(data.orders.length, 8);
assert.equal(String(data.orders[0].oId), '1');
assert.equal(data.orders[0].auditTime, '2026-09-25 00:50:00', '审核时间 = 订单创建时间');
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00');
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
assert.equal(data.orders[0].benchmarkSource, 'created', '起算点必须是 created');
// 09-25 00:50 → 10-20 18:00 = 25 天 + 17 小时 10 分 = 37030 分钟
assert.equal(data.orders[0].overdueMinutes, 37030);
// 13 号单:15:00 创建 → 18:00 核验只等了 180 分钟,旧版 24h 门槛会漏,本版必须算
const fresh = data.orders.find((o) => String(o.oId) === '13');
assert.ok(fresh, '只等 3 小时也必须算超时(无 24h 门槛)');
assert.equal(fresh.overdueMinutes, 180);
assert.equal(fresh.waitedHours, 3);
// 26 号单:10-18 12:00 创建的老单 → 历史未发池,超时 3240 分
const stale = data.orders.find((o) => String(o.oId) === '26');
assert.ok(stale, '历史未发单必须进池');
assert.equal(stale.overdueMinutes, 3240);
// 43 号:拆单子单(is_split=true)也必须算 —— 别按 is_split 过滤
assert.ok(data.orders.some((o) => String(o.oId) === '43'),
'is_split=1 是拆单子单(真单),必须算超时');
// 3 号单没创建时间 → 归「不计考核」,原因写清楚
const noCreated = data.heldOrders.find((o) => String(o.oId) === '3');
assert.ok(noCreated, '缺起算点(审核时间与创建时间都没有)的单不能被静默丢掉');
assert.equal(noCreated.holdReason, '缺审核时间与创建时间');
// 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00)
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
assert.deepEqual(
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
);
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
// 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
assert.ok(!data.heldOrders.some((o) => String(o.oId) === '7'), '京东云仓也不进挂起清单');
// 9(已取消) / 10(Merged) / 11(已发) / 37(Sent) 都不能进任何清单
for (const id of ['9', '10', '11', '37']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
}
// 作废(labels 发货前售后)只计 39 这一单;9/10/11/37 由 SQL 直接过滤,不在这张清单里
assert.equal(data.summary.voidCount, 1, 'only 39 (labels 发货前售后)');
// 不考核快递:中铁物流、壹米滴答专线、武进安能必须整单跳过
// (业务 2026-10-20 定前两个,2026-10-27 追加武进安能)
assert.equal(data.summary.carrierSkipped, 4, '中铁/壹米滴答/武进安能 两种写法 要计入 carrierSkipped');
assert.ok(
!data.orders.some((o) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(o.soId)),
'不考核快递的单不能出现在超时明细里'
);
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
// 🔥 「武进安能」只剔这一个网点,**不能**泛化成「安能」:
// 库里带「安能」的还有 常州安能新 / 安能物流 / 华韵安能 / 雅柏安能…(常州安能新单量最大)
assert.ok(
!svc._private.EXCLUDED_CARRIERS.some((k) => '常州安能新'.includes(k)),
'常州安能新必须照旧考核,别把关键词放大成「安能」'
);
assert.ok(
svc._private.EXCLUDED_CARRIERS.some((k) => '武进安能大件'.includes(k)),
'武进安能大件要能被「武进安能」包含命中'
);
// 不计考核清单
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(
reasons,
['缺审核时间与创建时间', '待审核', '订单异常', '订单异常'].sort(),
'4 单:缺起算点 1 + 待审核 1 + 异常 2'
);
// 新鲜度:取自主查询的 MAX(updated_at)
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 18:02:00');
assert.equal(data.freshness.source, 'jst_Orders', '数据源已换成订单表');
assert.equal(data.freshness.ageMinutes, 3, '18:05 − 18:02 = 3 分钟');
assert.equal(data.freshness.stale, false, '3 分钟不算滞后');
assert.equal(data.freshness.coversDeadline, true, '18:02 已覆盖 18:00 核验时刻');
assert.equal(data.freshness.hint, '', '没异常就不要吓人');
// ── 性能 / 口径护栏 ──────────────────────────────────────────────────
assert.equal(calls.orders, 1, '订单两表只允许查一次(UNION ALL 合成一条 SQL)');
assert.equal(calls.warehouse, 1, '仓名映射查一次缓存表');
const sql = calls.sql[0];
assert.match(sql, /rpt\.jst_Orders\s+o/u, '主数据源必须含 jst_Orders');
assert.match(sql, /rpt\.jst_qmcust_orderlist\s+c/u, '第二张订单表必须 UNION ALL 进来');
assert.match(sql, /UNION ALL/u, '两张表是并集,不是 JOIN');
assert.match(sql, /o\.send_date IS NULL/u, '已发货靠 send_date 判');
assert.match(sql, /c\.send_date IS NULL/u, '第二张表也要判未发货');
assert.match(sql, /o\.status IN \(N'Delivering', N'WaitConfirm', N'Question'\)/u,
'候选状态白名单必须下推到 SQL');
assert.match(sql, /o\.created >= '/u, '窗口下界按创建时间卡(30 天护栏)');
assert.match(sql, /OPTION \(RECOMPILE\)/u,
'🔥 必须带 RECOMPILE —— qmcust 不加会被参数嗅探拖到 33~50s');
// 起算点列必须在投影里
assert.match(sql, /\bo\.created\b/u, '起算点必须取订单创建时间');
// 🔥 不许再 JOIN 仓表:实测 JOIN 让同一条查询从 28s 恶化到 61~105s
assert.doesNotMatch(sql, /JOIN\s+dbo\.jst_warehouseInfo/iu, '仓名在 Node 侧换,不许 JOIN 仓表');
// 🔥 不许再用状态镜像表取候选
assert.doesNotMatch(sql, /jst_order_express_details/u, '状态镜像表已废弃');
// 🔥 不许按 is_split / is_merge 过滤(is_split=1 是子单,要考的)
assert.doesNotMatch(sql, /is_split|is_merge/u, '拆单子单也要考核,不许拿这两个字段过滤');
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
assert.doesNotMatch(sql, /buyer_message|remark/u, '不能投影长文本列');
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// labels 只允许出现在 LIKE 里折成布尔,不许直接投影原文
assert.match(sql, /CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END/u,
'labels 只用来判「发货前售后」,不外传原文');
assert.doesNotMatch(sql, /\blabels\s*,/u, 'labels 长文本不许直接投影');
assert.doesNotMatch(sql, /SELECT \*/u, '不许 SELECT *');
// ⚠️ 时间条件走**字面量**(已用正则校验过格式),不再走参数 —— 参数嗅探会走错计划
assert.match(sql, /created >= '2026-09-20 00:00:00'/u, '下界是近 30 天,写进字面量');
assert.deepEqual(calls.params[0], {}, '这条查询不带参数');
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'oa_confirm', '已等时长按审核时间算');
}));
test('13:00 轮:只核验「今天 07:00 前审核」的单 + 历史未发', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
assert.equal(data.round.key, 'NOON');
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
// 24 号:今天 07:30 创建 → 晚于 7 点,不属于 13:00 轮
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
'今天 07:30 创建 → 不进 13:00 轮');
// 21 号:今天 06:45 创建 → 13:00 只等 375 分钟,必须超时(无 24h 门槛)
const a = data.orders.find((o) => String(o.oId) === '21');
assert.ok(a, '7 点前创建、13:00 未发就是超时');
assert.equal(a.benchmarkSource, 'created');
assert.equal(a.overdueMinutes, 375);
assert.equal(a.waitedHours, 6.3);
// 22 号:05:55 创建 → 425 分(旧口径按审核时间 06:40 算只有 380 分,换口径后更严)
const b = data.orders.find((o) => String(o.oId) === '22');
assert.equal(b.overdueMinutes, 425, '起算点提前到创建时间,已等时长跟着变长');
// 23 号:昨天创建、至今未发 → 历史未发池也要看到
const d = data.orders.find((o) => String(o.oId) === '23');
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
assert.equal(d.overdueMinutes, 1025);
// 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单
// (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算)
assert.equal(data.summary.overdueUnshipped, 5);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判(取不到回退创建时间)');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
// 快照只到 11:18(早于 13:00 核验时刻)→ 必须标滞后 + 明确「没覆盖核验时刻」
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
assert.equal(data.freshness.stale, true, '107 分钟 > 90 分钟阈值,必须标 stale');
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时必须为 false');
assert.match(data.freshness.hint, /未刷新|没覆盖|未覆盖/u, '要给出人话提示');
}, 'noon'));
test('服务层 13:00 前返回 notStarted,且不去查库', withStubbedService(async (svc, { calls }) => {
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
assert.equal(data.notStarted, true);
assert.equal(data.round, null);
assert.equal(data.orders.length, 0);
assert.equal(data.summary.overdueUnshipped, 0);
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
assert.equal(data.centers.length, registry.CENTERS.length);
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
assert.equal(calls.orders, 0, '未到核验时间不应查订单表');
assert.match(data.note, /13:00/u);
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
}));
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
const base = new Date('2026-10-21T00:04:00Z');
const bj = registry.beijingNow(base);
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+ `-${String(d.getDate()).padStart(2, '0')} `
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
'北京时间 00:00~08:00 不能取成前一天');
});
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
assert.equal(noon.key, 'NOON');
assert.equal(noon.date, '2026-10-21');
assert.equal(noon.deadline, '2026-10-21 13:00:00');
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
});
// ── ⑨ 导出明细(2026-10-27 新增「导出明细」按钮的后端口径)──────────────────
// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍订单表 ——
// 所以缓存键不含 limit(存全量、出口截断)。这个用例专门锁这两点。
test('导出取数:不受 limit 截断,且与列表共用同一份缓存', withStubbedService(async (svc, { calls }) => {
// 列表按 limit=3 取(页面就是这样截断的)
const limited = await svc.getShipTimeliness({ now: at(18, 5), limit: 3 });
assert.equal(limited.orders.length, 3, 'limit=3 只给 3 行');
assert.equal(limited.truncated, true, '被截断必须打标记');
assert.equal(limited.detailTotal, 8, '截断后仍要给出真实总数(页面「共 N 单」用它)');
assert.equal(calls.orders, 1, '第一次查询打了一次订单表');
// 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 8 行
const exported = await svc.listShipTimelinessExportRows({ round: 'AFTERNOON', now: at(18, 6) });
assert.equal(calls.orders, 1, '导出不能再扫一遍订单表(旧实现把 limit 拼进缓存键,每次导出都重查)');
assert.equal(exported.total, 8, '导出必须是全部超时单,不能被列表的 limit 带跑');
assert.equal(exported.rows.length, 8);
// 导出列里业务要能直接看到快递 / 运单号(追单用),centerKey 供前端按仓过滤
for (const row of exported.rows) {
for (const key of ['oId', 'soId', 'shopName', 'centerKey', 'centerName',
'warehouseName', 'logisticCompany', 'logisticId', 'auditTime', 'overdueMinutes']) {
assert.ok(key in row, `导出缺字段 ${key}`);
}
}
// 不考核快递的单不能出现在导出里(口径与页面一致)
assert.ok(!exported.rows.some((r) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(r.soId)),
'不考核快递的单不能导出');
assert.deepEqual(exported.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
}));
test('导出行为纯函数:缺字段一律给空串,不导出 undefined', () => {
const service = require('../src/services/warehouseShipTimelinessService');
const rows = service._private.buildShipTimelinessExportRows([
{ oId: 1, soId: 'SO-1', overdueMinutes: null },
null,
]);
assert.equal(rows.length, 2, '空行也要占位,别让行号错位');
assert.equal(rows[0].oId, '1', '数字单号要转成字符串,避免 Excel 科学计数法');
assert.equal(rows[0].logisticCompany, '');
assert.equal(rows[0].overdueMinutes, 0, 'null 时长按 0 处理,别给 NaN');
assert.equal(rows[1].oId, '');
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
});
test('起算点 = max(审核时间, 订单 modified):状态表滞后时以 modified 为准', withStubbedService(async (svc) => {
// 13:05 → 13:00 轮,窗口上界 07:00
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
// 51 的 modified 是今天 14:52,晚于窗口 07:00 → 必须剔除(这就是 9978643 那类单)
assert.ok(!data.orders.some((x) => String(x.oId) === '51'),
'modified 晚于窗口的单不能算进 13:00 轮(状态表滞后会把它冤枉成 7 点前审核)');
// 52:状态表无审核时间,modified 今天 06:00 → 起算点 06:00,不是 created 10-18 12:00
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时(modified 06:00 早于窗口 07:00)');
assert.equal(r52.auditTime, '2026-10-20 06:00:00', '起算点取 modified');
assert.equal(r52.benchmarkSource, 'modified', '来源必须是 modified');
assert.equal(r52.createdAt, '2026-10-18 12:00:00', '下单时间照旧展示');
assert.equal(r52.overdueMinutes, 420, '06:00 → 13:00 = 420 分钟(不是按下单时间算的 4380)');
// 53:modified 早于 created → 取较晚者 = created 05:00
const r53 = data.orders.find((x) => String(x.oId) === '53');
assert.ok(r53, '53 应算超时');
assert.equal(r53.auditTime, '2026-10-20 05:00:00', 'modified 比 created 早时不能倒退,取 created');
assert.equal(r53.benchmarkSource, 'created');
}, 'modified'));
test('状态表审核时间比 modified 更晚时,以状态表为准', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时');
assert.equal(r52.auditTime, '2026-10-20 06:30:00', '审核时间 06:30 晚于 modified 06:00,取审核时间');
assert.equal(r52.benchmarkSource, 'oa_confirm');
}, 'modified', [
{ inner_order_id: 52, first_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), last_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), send_time: null },
]));
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');
});