业务 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 修同步或业务在聚水潭复核。
864 lines
51 KiB
JavaScript
864 lines
51 KiB
JavaScript
/**
|
||
* 仓储中心 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');
|
||
});
|