业务 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 单), 这类单现在按正常单算超时;如业务反馈,可单独补查。 - 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
616 lines
35 KiB
JavaScript
616 lines
35 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') {
|
||
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);
|
||
});
|