/** * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版) * * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义: * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走; * ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、 * 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段; * ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)—— * 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错; * ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**: * 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。 * ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做, * 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废; * ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。 * 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回; * ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃; * **作废单(已取消 / 合并 / 拆单父单)不考核**; * ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。 * * ⚠️ 本文件**不连数据库**,纯逻辑测试。 */ 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.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details'); }); test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => { 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('作废单(已取消)明确不考核', () => { assert.ok( registry.IGNORED_STATES.includes('已取消'), '「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍' ); assert.ok(registry.IGNORED_STATES.includes('已发货')); assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货')); }); test('创建时间要从订单表补,且只补窄列', () => { 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 wmsId = 15038426; // 创建时间(展示)+ 作废事实来源:o_id → 订单表行 // 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间 const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o }); const defaultFacts = { 1: f('jst_Orders', { created: '2026-09-25 00:50:00' }), 2: f('jst_Orders', { created: '2026-10-20 16:20:00' }), 6: f('jst_Orders', { created: '2026-10-20 17:00:00' }), 7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }), 9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }), // 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例 // 5127276315127080023(内部单号 9486414)就是这样被误判成超时的 10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }), // 11:状态表 send_time 空,订单表 send_date 有值(已发) 11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }), // 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货) // —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」 37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }), // 12:拆单父单,status 不是 Split 但 is_split=1 12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }), 13: f('jst_Orders', { created: '2026-10-20 15:00:00' }), // 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮) 31: f('jst_Orders', { created: '2026-10-20 15:30:00' }), 32: f('jst_Orders', { created: '2026-10-20 14:30:00' }), 33: f('jst_Orders', { created: '2026-10-20 13:30:00' }), 34: f('jst_Orders', { created: '2026-10-20 12:30:00' }), // 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476) // 只能进 18:00 轮,不能进 13:00 轮 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), // 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), // ── 2026-10-22 作废单三类形态 ── // 38:订单表 Question(聚水潭「已作废/问题单」) 38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }), // 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转) 39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }), // 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常 40: f('jst_Orders', { created: '2026-10-20 11:10:00' }), }; // 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」 const noonFacts = { 21: f('jst_Orders', { created: '2026-10-20 06:45:00' }), 22: f('jst_Orders', { created: '2026-10-20 05:55:00' }), 23: f('jst_Orders', { created: '2026-10-19 19:55:00' }), 24: f('jst_Orders', { created: '2026-10-20 07:30:00' }), // 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), // 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), }; const factsMap = scenario === 'noon' ? noonFacts : defaultFacts; const mk = (o) => ({ shop_name: '测试店', logistic_status_name: '', query_time: '2026-10-20 11:18:27', // 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到 aftersale_status_name: '暂无售后', // 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time first_oa_confirm_time: o.last_oa_confirm_time ?? null, ...o, }); const defaultRows = [ // 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时 mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', last_oa_confirm_time: '2026-09-25 01:00:00' }), // 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间) mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 16:10:00', last_oa_confirm_time: '2026-10-20 16:30:00' }), // 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时 mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-26 00:10:00', last_oa_confirm_time: '2026-09-26 00:15:00' }), // 4:已支付待审核(还没审单、没有审核时间)→ 不计考核 mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00', last_oa_confirm_time: null }), // 5:异常 → 不计考核 mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00', last_oa_confirm_time: '2026-09-23 01:05:00' }), // 6:审核 17:05 晚于窗口上界 → 本轮不该核验 mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 16:55:00', last_oa_confirm_time: '2026-10-20 17:05:00' }), // 7:被业务排除的京东云仓 → 不归属任何主仓 mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓', order_status_name: '发货中', pay_time: '2026-09-25 00:00:00', last_oa_confirm_time: '2026-09-25 00:10:00' }), // 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重 mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', last_oa_confirm_time: '2026-09-25 01:00:00' }), // 9:作废单(已取消)—— 业务定性不考核 mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '已取消', pay_time: '2026-09-21 08:00:00', last_oa_confirm_time: '2026-09-21 08:10:00' }), // 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除 mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-21 09:59:00', last_oa_confirm_time: '2026-09-21 10:05:00' }), // 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除 mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-21 10:59:00', last_oa_confirm_time: '2026-09-21 11:05:00' }), // 12:拆单父单(is_split=1)—— 自己不发货,必须剔除 mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-21 11:59:00', last_oa_confirm_time: '2026-09-21 12:05:00' }), // 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉, // 本版必须算超时(业务口径:到点未发就算超时) mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 14:59:00', last_oa_confirm_time: '2026-10-20 15:30:00' }), // ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ── // 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验 mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓', order_status_name: '发货中', pay_time: '2026-10-20 15:29:00', last_oa_confirm_time: '2026-10-20 15:35:00' }), // 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时 mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓', order_status_name: '发货中', pay_time: '2026-10-20 14:29:00', last_oa_confirm_time: '2026-10-20 14:35:00' }), // 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验 mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓', order_status_name: '发货中', pay_time: '2026-10-20 13:29:00', last_oa_confirm_time: '2026-10-20 13:35:00' }), // 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时 mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓', order_status_name: '发货中', pay_time: '2026-10-20 12:29:00', last_oa_confirm_time: '2026-10-20 12:35:00' }), // 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。 // 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分 mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', first_oa_confirm_time: '2026-10-20 13:10:00', last_oa_confirm_time: '2026-10-20 13:10:00' }), // 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10) mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', first_oa_confirm_time: '2026-10-19 10:10:00', last_oa_confirm_time: '2026-10-20 13:30:00' }), // 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。 // 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-25 01:00:00', last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }), mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-25 02:00:00', last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }), // 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除 mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-09-22 08:50:00', last_oa_confirm_time: '2026-09-22 09:05:00' }), // ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)── // 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810 mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 09:10:00', last_oa_confirm_time: '2026-10-20 09:15:00' }), // 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980 mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 10:10:00', last_oa_confirm_time: '2026-10-20 10:15:00' }), // 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除 mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', aftersale_status_name: '已作废', pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }), ]; // 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00 const noonRows = [ // 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分 mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 06:40:00', last_oa_confirm_time: '2026-10-20 07:20:00' }), // 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛) mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 05:50:00', last_oa_confirm_time: '2026-10-20 06:40:00' }), // 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到 mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-19 19:50:00', last_oa_confirm_time: '2026-10-19 20:10:00' }), // 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间) mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-20 07:29:00', last_oa_confirm_time: '2026-10-20 07:40:00' }), // 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。 // 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分 mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', first_oa_confirm_time: '2026-10-19 10:10:00', last_oa_confirm_time: '2026-10-20 13:30:00' }), // 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算, // 不能显示 0 分(真实案例:创建 10-18 的单今天才审) mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', first_oa_confirm_time: '2026-10-20 13:10:00', last_oa_confirm_time: '2026-10-20 13:10:00' }), ]; const rows = scenario === 'noon' ? noonRows : defaultRows; const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] }; const handler = async (sql, params) => { const text = String(sql); if (/jst_warehouseInfo/u.test(text)) { return [ { Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }, { Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 }, { Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 }, { Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 }, ]; } // 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表 if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) { calls.backfill += 1; calls.backfillSql.push(text); const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders'; const m = text.match(/IN \(([0-9,\s]+)\)/u); const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : []; return ids .filter((id) => factsMap[id] && factsMap[id].table === table) .map((id) => { const x = factsMap[id]; return { o_id: id, created: x.created, status: x.status, send_date: x.send_date, is_merge: x.is_merge, is_split: x.is_split, presaleVoid: x.presaleVoid || 0, }; }); } calls.state += 1; calls.sql.push(text); calls.params.push(params || {}); return rows; }; require.cache[dbPath] = { id: dbPath, filename: dbPath, loaded: true, exports: { query: handler, querySlow: handler }, }; return { calls, wmsId }; } 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 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时) // + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核) // = 6 单 // 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发 // 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的 assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时'); assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天'); assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单'); assert.equal(data.summary.overdueUnshipped, 7); assert.equal(data.summary.overdueCenterCount, 3); assert.equal(data.summary.oldestDays, 25); assert.equal(data.summary.heldCount, 2); // 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示 assert.equal(data.orders.length, 7); assert.equal(String(data.orders[0].oId), '1'); assert.equal(data.orders[0].auditTime, '2026-09-25 01:00: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, 'last_audit'); // 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟 assert.equal(data.orders[0].overdueMinutes, 37020); // 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算 const fresh = data.orders.find((o) => String(o.oId) === '13'); assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)'); assert.equal(fresh.overdueMinutes, 150); assert.equal(fresh.waitedHours, 2.5); // 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290 const lateAudit = data.orders.find((o) => String(o.oId) === '26'); assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)'); assert.equal(lateAudit.overdueMinutes, 290); // 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10 const reAudited = data.orders.find((o) => String(o.oId) === '25'); assert.ok(reAudited, '重审单也要算'); assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核'); assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30'); // 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间 const third = data.orders.find((o) => String(o.oId) === '3'); assert.ok(third, '补不到创建时间的单也要算超时'); assert.equal(third.createdAt, '', '确实没补到'); assert.equal(third.benchmarkSource, 'last_audit'); assert.equal(third.payTime, '2026-09-26 00:10:00'); // 创建时间晚于窗口上界 → 本轮不核验(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'), '京东云仓不参与考核'); // 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单 // 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废) for (const id of ['9', '10', '11', '12', '38', '39', '40']) { 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} 不该进挂起清单`); } // 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类) assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数'); // 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20) assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped'); assert.ok( !data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)), '不考核快递的单不能出现在超时明细里' ); assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']); // 不计考核清单 assert.equal(data.heldOrders.length, 2); const reasons = data.heldOrders.map((h) => h.holdReason).sort(); assert.deepEqual(reasons, ['待审核', '订单异常'].sort()); // 新鲜度直接取自主查询的 query_time,不再额外扫表 assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27'); // 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新, // 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。 assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟'); assert.equal( data.freshness.stale, data.freshness.ageMinutes > 90, '滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)' ); if (data.freshness.stale) { assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示'); } // 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」 assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false'); assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚'); // ── 性能护栏 ──────────────────────────────────────────────────────── assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)'); const sql = calls.sql[0]; assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表'); assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判'); // 本版基准是审核时间 → 两列审核时间都必须在投影里 assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列'); assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底'); // 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消) assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数'); assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断'); assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用'); // 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列'); assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列'); assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用'); assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表'); // 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来) assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实'); for (const bs of calls.backfillSql) { assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, `补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`); assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u, '多取一列就会把 0.2s 拖到 38s'); // labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文 assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔'); } // ⚠️ 窗口参数走 params,不在 SQL 里手写 assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天'); assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除'); assert.equal(calls.params[0].shipStatus, '发货中'); // round 必须带出 windowEnd,否则页面渲染成「核验 前」 assert.equal(data.round.key, 'AFTERNOON'); assert.match(String(data.round.windowEnd), /16:00:00$/u); assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端'); assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算'); })); test('13:00 轮:只核验「今天 00: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'); // 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验) assert.ok(!data.orders.some((o) => String(o.oId) === '21'), '今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现'); // 24 号:今天 07:40 审核 → 同理不核验 assert.ok(!data.orders.some((o) => String(o.oId) === '24'), '今天 07:40 审核 → 不进 13:00 轮'); // 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮 assert.ok(!data.orders.some((o) => String(o.oId) === '26'), '下午才审核的老单不能进 13:00 轮'); // 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛) const b = data.orders.find((o) => String(o.oId) === '22'); assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时'); assert.equal(b.benchmarkSource, 'last_audit'); assert.equal(b.overdueMinutes, 380); assert.equal(b.waitedHours, 6.3); // 23 号:昨天审核、至今未发 → 历史未发池也要看到 const d = data.orders.find((o) => String(o.oId) === '23'); assert.ok(d, '含历史未发单(历史不受 00:00 起限制)'); assert.equal(d.overdueMinutes, 1010); // 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验 assert.ok(!data.orders.some((o) => String(o.oId) === '25'), '重审到 13:30 的单归 18:00 轮,不进 13:00 轮'); // 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单 assert.equal(data.summary.overdueUnshipped, 2); assert.equal(data.window.boundary, 'audit', '窗口按审核时间判'); // 仓级例外只在 18:00 轮生效,13:00 轮没有例外 assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外'); }, '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.state, 0, '未到核验时间不应查库'); assert.equal(calls.backfill, 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'); });