cangku/backend/test/warehouseShipTimeliness.test.js
yanhui 776a2cbde2 feat(wst): 发货时效改用订单两表 + 起算点改为订单创建时间
业务 2026-10-28 定版:「核验抓取的销售订单表是错误的,应该在数据库抓
jst_Orders(订单)+ jst_OrderItem(明细),审核时间就是 jst_Orders 里的创建时间」。

## 为什么换数据源(状态镜像表的硬伤,均有实测)

1. `send_time` 漏回写:近 7 天「状态表说发货中+未发」的 4157 单里,
   订单表已有 `send_date` 的 563 单、status 已是 Sent 的 492 单 —— 发了还算超时。
2. 被合并 / 拆单父单不回写:89 + 755 单被冤枉。
3. 会整批停更:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮推出的
   34 单里混着已发货的。
4. 两张表发货时间其实同源:27387 单逐条比对**零偏差** ⟹ 订单表才是权威。

## 改了什么

后端
- 主查询改打 `rpt.jst_Orders` + `rpt.jst_qmcust_orderlist`(店铺互斥,UNION ALL,
  按 o_id 去重),SQL 侧过滤 `send_date IS NULL` + `status IN (Delivering/WaitConfirm/Question)`。
- 起算点由状态表 `last_oa_confirm_time` 改为订单表 `created`;
  实测 created 100% 早于审核时间,正常单差 7~40 分钟,夜间下单次日审的单差 6~9 小时
  —— 换口径后单量与「已等时长」都会变大,业务已知悉。
- 仓库归属:主查询只回 `wms_co_id`,Node 侧用仓表缓存换仓名再走 `resolveCenter()`
  (实测 SQL 里 LEFT JOIN 仓表会把查询从 28s 拖到 61~105s)。
- `OPTION (RECOMPILE)` + 时间字面量:qmcust 不加 RECOMPILE 实测 33~50s,加上 0.4s。
- 删除 `backfillOrderFacts()` 与 `mapLimit()`(起算点/状态/仓名已由主查询一次取全)。
- 不再按 `is_split / is_merge` 过滤 —— 实测 `is_split=1` 恰是拆单**子单**
  (同一 so_id 多行、各自要发货,近 30 天 1096 行),老逻辑 `is_split=0` 会把它们全排掉。
- 钉钉明细第 5 段「审核 HH:mm」→「创建 HH:mm」,无值文案「未审核」→「无创建时间」。

前端
- 明细/挂起清单/导出列「审核时间」→「创建时间」,Tooltip 与说明文案同步改写;
- 顶部说明补「数据源 = 订单两表」「已发未发取 send_date」;
- 「已支付待审核」统一为「待审核」(新数据源里就是 WaitConfirm)。

测试
- 两个测试文件的桩改成「订单两表 UNION」的返回形状,断言改为
  created 起算、候选状态白名单、SQL 必须带 RECOMPILE / 不含状态镜像表 / 不 JOIN 仓表。

## 验证

- `npm --prefix backend run check`:491/493 通过。2 项失败是既有环境问题:
  `resilientSecretStore`(Windows 文件权限 0o666≠0o600)、
  `cloudDatabaseConfigCache` 负缓存 TTL(依赖隧道状态),均与本改动无关,单独跑同样失败。
- 前端 eslint + build 通过。
- 真实库:NOON 1553 单 / AFTERNOON 1866 单(本地 2026-10-28),
  数据质量:无仓名 0 / 时间异常 0 / 含 undefined|null|NaN 0;
  单次查询耗时 35~38s(30 天护栏,冷缓存)。
- 浏览器真渲染(playwright,强制 round=NOON):
  表头 `…发货时间 / 创建时间 / 付款时间…`、轮次提示「核验今天 07:00 前创建的订单」、
  「导出明细」导出 CSV 表头含「创建时间」共 1551 行数据 + 1 行表头,无 pageerror。

## 风险与回退

- 口径变化是可感知的:单量上升,夜间下单/次日审核的单「已等时长」增加 6~9 小时,
  属业务确认后的预期结果。
- 状态表「售后已作废」判据随换源放弃(实测在「发货中+未发」里只命中 4 单),
  这类单现在按正常单算超时;如业务反馈,可单独补查。
- 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
2026-10-28 11:34:45 +08:00

616 lines
35 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') {
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 分
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const calls = { orders: 0, warehouse: 0, sql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
calls.warehouse += 1;
return warehouseRows;
}
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') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario);
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, 'created', '窗口按创建时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'created', '已等时长按创建时间算');
}));
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, 'created', '窗口按创建时间判');
// 仓级例外只在 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);
});