/** * 仓储中心 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') { 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' }), ]; const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows); const calls = { orders: 0, oaConfirm: 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`(2026-10-28 新增)。 // 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。 if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)) { calls.freshness += 1; // 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警); // 要测停更场景就把 freshnessUpd 传成当天更早的时刻 return [{ maxUpd: freshnessUpd }]; } // 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间, // 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 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') { return async () => { const dbPath = require.resolve('../src/config/cloudDatabase'); const realDb = require.cache[dbPath]; const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd); 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('数据源停更:来自停更表的超时单必须打标,并计入 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'); });