/** * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增) * * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义: * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走, * 否则品牌仓的订单会被重复计入两个仓; * ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用 * 7:00 窗口、16:00 后用 16:00 窗口; * ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单, * 永不发货,不排除会把一年前的单全算成超时未发); * ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。 * * ⚠️ 本文件**不连数据库**,纯逻辑测试。 */ 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 = [ ['京仓-横林二仓京东云仓', 'HL2'], ['简尚-运营C横林二仓专用仓', '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('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => { // 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」, // 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。 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, true); assert.deepEqual(c.keywords, []); }); // ── ② 核验轮次 ────────────────────────────────────────────────────────── const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区) test('12:00 前不做核验,返回 null(不回填昨日数据)', () => { // 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验, // 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。 assert.equal(registry.resolveActiveRound(at(9, 30)), null); assert.equal(registry.resolveActiveRound(at(11, 59)), null); assert.equal(registry.resolveActiveRound(at(0, 5)), null); }); test('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => { const r = registry.resolveActiveRound(at(12, 0)); assert.ok(r); assert.equal(r.key, 'NOON'); assert.equal(r.deadline, '2026-10-20 12:00:00'); assert.equal(r.windowStart, '2026-10-20 00:00:00'); assert.equal(r.windowEnd, '2026-10-20 07:00:00'); }); test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => { const r = registry.resolveActiveRound(at(16, 0)); assert.equal(r.key, 'AFTERNOON'); assert.equal(r.deadline, '2026-10-20 16:00:00'); assert.equal(r.windowEnd, '2026-10-20 16:00:00'); // 深夜仍沿用当天 16:00 轮(午夜后又回到新的一天) assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON'); }); // ── ③ 状态排除 ────────────────────────────────────────────────────────── test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => { const sql = registry.EXCLUDED_STATUS_SQL; assert.match(sql, /Cancelled/); assert.match(sql, /Merged/); assert.match(sql, /Split/); assert.doesNotMatch(sql, /Question/); }); // ── ④ 服务层分桶(注入假查询,不连库)── function stubDb(dbPath, realDb) { const wmsId = 15038426; // 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」, // 所以 stub 要返回**明细行**而不是聚合行。三态各造几条: // · 超时未发 3 单 / 合计 300 // · 已发但超时 1 单(send_date 晚于 16:00) // · 按时 6 单(send_date 早于 16:00) const mk = (sendDate, amount, oId) => ({ src: 'jst_Orders', wms_co_id: wmsId, o_id: oId, so_id: String(oId), shop_name: '测试店', order_date: '2026-10-20 01:00:00', send_date: sendDate, status: 'Delivering', shop_status: '', pay_amount: amount, labels: '', ob_io_date: null, }); const stubRows = [ mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3), mk('2026-10-20 18:30:00', 50, 4), mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6), mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8), mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10), ]; // 记录业务表被查了几次 —— 12:00 前应当为 0 // ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist), // 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条, // 这也顺带验证了「跨表同 o_id 去重」逻辑。 const calls = { order: 0 }; require.cache[dbPath] = { id: dbPath, filename: dbPath, loaded: true, exports: { query: async (sql) => { if (/jst_warehouseInfo/u.test(sql)) { return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }]; } if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) { // 新鲜度查询:返回空,走 stale 分支也不影响主结果 return [{ outbound_max: '2026-10-19 23:00:00' }]; } calls.order += 1; return stubRows; }, }, }; void realDb; return { calls, wmsId }; } test('服务层三态分桶互斥,按时率不为负', async () => { const dbPath = require.resolve('../src/config/cloudDatabase'); const realDb = require.cache[dbPath]; stubDb(dbPath, realDb); delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; try { const svc = require('../src/services/warehouseShipTimelinessService'); const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 }); const c = data.centers.find((x) => x.key === 'CQ'); assert.ok(c, '应能归到崔桥工厂仓'); assert.equal(c.overdueUnshipped, 3); assert.equal(c.overdueShipped, 1); assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime'); assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)'); // 汇总口径与各仓一致,且按时率不会是负数 assert.equal(data.summary.overdueUnshipped, 3); assert.equal(data.summary.windowOrderCount, 10); assert.equal(data.summary.onTimeRate, 60); assert.ok(data.summary.onTimeRate >= 0); // 前端要展示「核验 16:00 前」,round 必须带出 windowEnd —— // 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到) assert.ok(data.round, 'round 不能为空'); assert.equal(data.round.key, 'AFTERNOON'); assert.equal(data.round.cutoffHour, 16); assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻'); } 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('服务层 12:00 前返回 notStarted,且不去查订单表', async () => { const dbPath = require.resolve('../src/config/cloudDatabase'); const realDb = require.cache[dbPath]; const { calls } = stubDb(dbPath, realDb); delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; try { const svc = require('../src/services/warehouseShipTimelinessService'); 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((c) => c.overdueUnshipped === 0)); assert.equal(calls.order, 0, '未到核验时间不应扫订单表'); assert.match(data.note, /12:00/); // 下一轮核验时刻要告诉前端,方便提示「12:00 开始」 assert.equal(data.nextRoundAt, '2026-10-20 12:00:00'); } 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')]; } });