/** * 仓储中心物流转运异常 —— 口径回归测试(2026-10-21) * * 锁定几条最容易翻车的语义: * ① 12 个发货仓的关键词归属:「山东青岗集仓唯品仓」不能被「山东青岗集仓」抢走; * 品牌仓不能被主仓抢走;「京仓-横林二仓京东云仓」业务排除; * 山东/河南的代发仓不能误并入。 * ② 核验批次:09:00 核验**前一日**发货的单;未到 09:00 时默认看昨天那批, * 不能拿今天的空批次冒充结论。 * ③ 异常判定:已签收正常;未揽收/无物流单/异常/拒收/其他 → 异常; * 运输中类只在「最后轨迹距核验时刻 ≥ 24h」才算运输停滞。 * ④ 一单多包裹:明细按包裹保留,汇总按**内部单号**去重(异常率不能虚高)。 * ⑤ 缓存:第二次命中缓存,force=1 必须绕过。 * * ⚠️ 本文件不连数据库,纯逻辑 + 打桩。 */ const test = require('node:test'); const assert = require('node:assert/strict'); const registry = require('../src/services/warehouseLogisticsExceptionRegistry'); // ── ① 仓库归属 ────────────────────────────────────────────────────────── test('12 个发货仓都能按关键词归位', () => { const cases = [ ['简尚-横林二仓', 'HL2'], ['简尚-横林三仓', 'HL3'], ['简尚-横林二仓品牌仓', 'HL2_BRAND'], ['简尚-崔桥工厂仓', 'CQ'], ['简尚-崔桥工厂品牌仓', 'CQ_BRAND'], ['简尚-崔桥工厂半成品包装项目仓', 'CQ_WIP'], ['简尚-新苏北仓', 'XSB'], ['简尚-睢宁仓', 'SN'], ['简尚-山东青岗集仓', 'SD_QGJ'], ['简尚-山东品牌仓', 'SD_BRAND'], ['简尚-山东青岗集仓唯品仓', 'SD_QGJ_VIP'], ['简尚-河南洛宁仓', 'HN_LN'], ]; assert.equal(registry.CENTERS.length, 12); for (const [name, key] of cases) { assert.equal(registry.resolveCenter(name)?.key, key, name); } }); test('唯品仓 / 品牌仓不会被主仓的关键词抢走(最长匹配)', () => { assert.notEqual( registry.resolveCenter('简尚-山东青岗集仓唯品仓').key, registry.resolveCenter('简尚-山东青岗集仓').key, '唯品仓必须独立,不能并进青岗集仓' ); assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND'); assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND'); assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓').key, 'CQ_WIP'); }); test('子仓能并入主仓,无关代发仓 / 京东云仓被排除', () => { assert.equal(registry.resolveCenter('简尚-运营C横林二仓专用仓')?.key, 'HL2'); assert.equal(registry.resolveCenter('简尚-分销部睢宁专用仓')?.key, 'SN'); assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ'); assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null, '业务指定排除'); for (const name of ['工厂-山东菏泽森美代发仓', '工厂-河南千优代发仓', '简尚-苏州福利品仓']) { assert.equal(registry.resolveCenter(name), null, `${name} 不该并入任何主仓`); } }); // ── ② 核验批次 ────────────────────────────────────────────────────────── const at = (d, h, m = 0) => new Date(2026, 9, d, h, m, 0); // 2026-10-xx 本机时区 test('已过 09:00 → 核验今天这批(发货日是昨天)', () => { const b = registry.resolveBatch(undefined, at(21, 9, 30)); assert.equal(b.verifyDate, '2026-10-21'); assert.equal(b.shipDate, '2026-10-20'); assert.equal(b.deadline, '2026-10-21 09:00:00'); assert.equal(b.windowStart, '2026-10-20 00:00:00'); assert.equal(b.windowEnd, '2026-10-21 00:00:00'); assert.equal(b.started, true); assert.equal(b.isToday, true); }); test('未到 09:00 → 今天这批还没跑,默认退回昨天那批', () => { const b = registry.resolveBatch(undefined, at(21, 8, 30)); assert.equal(b.verifyDate, '2026-10-20'); assert.equal(b.shipDate, '2026-10-19'); assert.equal(b.started, true, '昨天的批次早就到点了'); assert.equal(b.lookback, true); }); test('显式指定今天但还没到 09:00 → notStarted,不能给结论', async () => { const b = registry.resolveBatch('2026-10-21', at(21, 8, 0)); assert.equal(b.started, false); }); test('批次护栏:未来日期压到今天,超过 30 天压到下限', () => { const future = registry.resolveBatch('2026-12-01', at(21, 10, 0)); assert.equal(future.verifyDate, '2026-10-21'); const tooOld = registry.resolveBatch('2026-01-01', at(21, 10, 0)); assert.equal(tooOld.verifyDate, '2026-09-22', `回看下限应为今天-29天,实际 ${tooOld.verifyDate}`); }); // ── ③ 异常判定 ────────────────────────────────────────────────────────── const batch = registry.resolveBatch('2026-10-21', at(21, 10, 0)); const svcPath = require.resolve('../src/services/warehouseLogisticsExceptionService'); const { classify } = require(svcPath); test('已签收 = 正常;未揽收/无物流单/异常/拒收/其他 = 异常', () => { const row = (o) => ({ logistic_id: 'L', logistic_status_name: '', logistic_time: null, ...o }); assert.equal(classify(row({ logistic_status_name: '已签收' }), batch), null); assert.equal(classify(row({ logistic_status_name: '未揽收' }), batch), 'NOT_PICKED'); assert.equal(classify(row({ logistic_status_name: '无物流单' }), batch), 'NO_TRACKING'); assert.equal(classify(row({ logistic_status_name: '异常' }), batch), 'CARRIER_EXCEPTION'); assert.equal(classify(row({ logistic_status_name: '拒收' }), batch), 'RETURNED'); // 「其他」实测全是「包裹已换单退回」 assert.equal(classify(row({ logistic_status_name: '其他' }), batch), 'RETURNED'); }); test('运输中 / 派送中一律不考核(有「运输」字样,往后中转记录不看)', () => { // 2026-10-21 二次定版:业务明确「有『运输』字样的,往后中转记录不考核」 assert.equal(classify({ logistic_id: 'L', logistic_status_name: '运输中', logistic_time: '2026-10-20 08:00:00', }), null, '运输中哪怕 25h 没新轨迹也不算异常'); assert.equal(classify({ logistic_id: 'L', logistic_status_name: '派送中', logistic_time: '2026-10-20 08:00:00', }), null); }); test('已揽收:没有下一步中转才算异常,轨迹里出现运输/中转字样就算动起来了', () => { const picked = (info) => classify({ logistic_id: 'L', logistic_status_name: '已揽收', logistic_time: '2026-10-21 04:00:00', last_info: info, }); // 只有「已揽收/已收取快件/库存中」,没有任何中转 → 卡在网点 assert.equal(picked('京东快递 已收取快件'), 'PICKED_NO_TRANSIT'); assert.equal(picked('中通快运【简尚家居】已揽收'), 'PICKED_NO_TRANSIT'); assert.equal(picked(''), 'PICKED_NO_TRANSIT'); // 轨迹里已经有运输/中转/发往 → 有下一步中转记录,不考核 assert.equal(picked('快件已发往【常州中转部】'), null); assert.equal(picked('运输中,预计明日送达'), null); }); test('状态为空时按运单号区分:有号=未揽收,无号=无物流单', () => { assert.equal(classify({ logistic_id: 'L1', logistic_status_name: '' }, batch), 'NOT_PICKED'); assert.equal(classify({ logistic_id: '', logistic_status_name: '' }, batch), 'NO_TRACKING'); }); // ── ④ 服务层(打桩数据库)────────────────────────────────────────────── const dbPath = require.resolve('../src/config/cloudDatabase'); function stubDb(rows) { const calls = { times: 0, sql: [], params: [] }; const handler = async (sql, params) => { calls.times += 1; calls.sql.push(String(sql)); calls.params.push(params || {}); return rows; }; require.cache[dbPath] = { id: dbPath, filename: dbPath, loaded: true, exports: { query: handler, querySlow: handler }, }; return calls; } const mk = (o) => ({ so_id: 'SO', inner_order_id: 1, shop_name: '测试店', warehouse_name: '简尚-崔桥工厂仓', logistic_id: 'L1', logistic_company_name: '中通', logistic_status_name: '已签收', send_time: '2026-10-20 10:00:00', logistic_time: '2026-10-20 18:00:00', query_time: '2026-10-21 09:05:00', last_info: '', ...o, }); const rows = [ // 1:一单两包裹 —— 一个未揽收、一个已签收 → 该单算异常,但只占 1 个「单」 mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1A', logistic_status_name: '未揽收', logistic_time: null }), mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1B', logistic_status_name: '已签收' }), // 2:山东青岗集仓唯品仓 · 异常 mk({ inner_order_id: 2, so_id: 'SO-2', warehouse_name: '简尚-山东青岗集仓唯品仓', logistic_id: 'L2', logistic_status_name: '异常', logistic_time: '2026-10-20 20:00:00', send_time: '2026-10-20 12:00:00' }), // 3:横林二仓品牌仓 · 拒收 mk({ inner_order_id: 3, so_id: 'SO-3', warehouse_name: '简尚-横林二仓品牌仓', logistic_id: 'L3', logistic_status_name: '拒收', logistic_time: '2026-10-21 07:00:00', send_time: '2026-10-20 15:00:00' }), // 4:京东云仓 —— 业务排除,不进任何主仓 mk({ inner_order_id: 4, so_id: 'SO-4', warehouse_name: '京仓-横林二仓京东云仓', logistic_id: 'L4', logistic_status_name: '未揽收', logistic_time: null }), // 5:代发仓 —— 不归属任何主仓 mk({ inner_order_id: 5, so_id: 'SO-5', warehouse_name: '工厂-山东菏泽森美代发仓', logistic_id: 'L5', logistic_status_name: '未揽收', logistic_time: null }), // 6:新苏北仓 · 已揽收后没有任何中转记录(最后轨迹 10-20 02:00)→ 已揽收未中转 mk({ inner_order_id: 6, so_id: 'SO-6', warehouse_name: '简尚-新苏北仓', logistic_id: 'L6', logistic_status_name: '已揽收', logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00', last_info: '京东快递 已收取快件' }), // 7:睢宁仓 · 运输中 → 已动起来,不考核 mk({ inner_order_id: 7, so_id: 'SO-7', warehouse_name: '简尚-睢宁仓', logistic_id: 'L7', logistic_status_name: '运输中', logistic_time: '2026-10-21 04:00:00', send_time: '2026-10-20 09:00:00' }), // 8:山东青岗集仓 · 无物流单 mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓', logistic_id: '', logistic_status_name: '无物流单', logistic_time: null, send_time: '2026-10-20 11:00:00' }), // 9:横林三仓 · 已签收 → 正常 mk({ inner_order_id: 9, so_id: 'SO-9', warehouse_name: '简尚-横林三仓' }), // 10:完全重复行(同单同运单号)→ 必须去重 mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓', logistic_id: '', logistic_status_name: '无物流单', logistic_time: null, send_time: '2026-10-20 11:00:00' }), ]; async function withStub(fn, stubRows = rows) { const realDb = require.cache[dbPath]; const calls = stubDb(stubRows); delete require.cache[svcPath]; delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; try { const svc = require(svcPath); svc.clearLogisticsExceptionCache(); return await fn(svc, calls); } finally { if (realDb) require.cache[dbPath] = realDb; else delete require.cache[dbPath]; delete require.cache[svcPath]; delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; } } test('汇总按内部单号去重,明细按包裹保留;排除仓/代发仓不进统计', async () => { await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); // 归属主仓的单:1,2,3,6,7,8,9 = 7 单;包裹 8 个(1 号单两包) assert.equal(data.summary.shippedOrders, 7); assert.equal(data.summary.packageCount, 8, '重复行要去重'); // 异常单:1(未揽收) 2(异常) 3(拒收) 6(已揽收未中转) 8(无物流单) = 5 assert.equal(data.summary.exceptionOrders, 5); assert.equal(data.summary.exceptionPackages, 5); assert.equal(data.summary.centerCount, 5); assert.equal(data.summary.exceptionRate, 71.4); const typeCount = data.summary.byType; assert.equal(typeCount.NOT_PICKED, 1); assert.equal(typeCount.CARRIER_EXCEPTION, 1); assert.equal(typeCount.RETURNED, 1); assert.equal(typeCount.PICKED_NO_TRANSIT, 1); assert.equal(typeCount.NO_TRACKING, 1); // 唯品仓独立成仓,不并进青岗集仓 const vip = data.centers.find((c) => c.key === 'SD_QGJ_VIP'); assert.equal(vip.shippedOrders, 1); assert.equal(vip.exceptionOrders, 1); const qgj = data.centers.find((c) => c.key === 'SD_QGJ'); assert.equal(qgj.exceptionOrders, 1, '青岗集仓自己只有 1 单异常(无物流单那单)'); // 排除仓 / 代发仓不该出现在任何仓的统计里 assert.equal(data.centers.reduce((s, c) => s + c.shippedOrders, 0), 7); // 明细按「发货→核验」等得最久的排前面(6 号单 10-20 01:00 发货,到 09:00 已 32 小时) assert.equal(data.orders[0].oId, '6', `首行应为最久未动的单,实际 ${data.orders[0].oId}`); assert.equal(data.orders[0].pendingHours, 32); assert.equal(data.orders[0].staleHours, 31, '最后轨迹 10-20 02:00 → 距核验 31 小时'); // 数据源护栏:只查一张表、带发货日窗口 assert.equal(data.freshness.coversDeadline, true); assert.equal(data.freshness.snapshotAt, '2026-10-21 09:05:00'); }); }); test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async () => { await withStub(async (svc, calls) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); assert.equal(data.notStarted, true); assert.equal(data.summary.exceptionOrders, 0); assert.equal(calls.times, 0, '未到点不该打库'); assert.equal(data.centers.length, 12); }); }); test('结果缓存生效,force=1 绕过缓存重查', async () => { await withStub(async (svc, calls) => { const opts = { date: '2026-10-21', now: at(21, 10, 0) }; const first = await svc.getLogisticsExceptions(opts); assert.equal(first.cached, false); assert.equal(calls.times, 1); const second = await svc.getLogisticsExceptions(opts); assert.equal(second.cached, true); assert.equal(calls.times, 1, '第二次应命中缓存'); const third = await svc.getLogisticsExceptions({ ...opts, force: 1 }); assert.equal(third.cached, false); assert.equal(calls.times, 2, 'force=1 必须真查库'); }); }); test('明细 limit 生效并给出截断标记', async () => { await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 2 }); assert.equal(data.orders.length, 2); assert.equal(data.truncated, true); assert.equal(data.detailLimit, 2); }); }); test('导出:放开明细上限、返回全量异常(不受列表 2000 上限限制)', async () => { await withStub(async (svc) => { const data = await svc.getLogisticsExceptionExportRows({ date: '2026-10-21', now: at(21, 10, 0), }); assert.equal(data.detailLimit, 5000, '导出的明细上限应放到 5000'); assert.equal(data.truncated, false); assert.equal(data.rows.length, data.summary.exceptionPackages, '导出行数应等于异常包裹数'); assert.ok(data.batch?.label, '导出要带批次信息给文件名用'); assert.ok(Array.isArray(data.exclusions?.carriers), '导出也要带剔除口径'); // 明细字段够导出用(页面上那几列) const row = data.rows[0]; for (const key of ['warehouseName', 'oId', 'soId', 'carrier', 'trackingNo', 'logisticStatus', 'exceptionLabel', 'sendTime', 'pendingHours']) { assert.ok(key in row, `导出缺字段 ${key}`); } }); }); test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => { await withStub(async (svc, calls) => { const opts = { date: '2026-10-21', now: at(21, 10, 0) }; await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); assert.equal(calls.times, 1); // 导出用另一个 limit —— 不能把页面那份缓存顶掉 await svc.getLogisticsExceptionExportRows(opts); assert.equal(calls.times, 2, '导出是首次查,应真查库'); const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); assert.equal(listAgain.cached, true, '页面那份缓存应还在'); assert.equal(calls.times, 2, '列表应命中缓存,不再查库'); }); }); test('快照没覆盖核验时刻时给出红色告警文案', async () => { const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' })); await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0) }); assert.equal(data.freshness.coversDeadline, false); assert.match(data.freshness.hint, /没覆盖/u); }, staleRows); }); // ── ⑥ 剔除口径(业务 2026-10-21 指定)─────────────────────────────────── test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米滴答/自提/线下/商家配送/京喜京东', () => { const hit = (name) => registry.matchExcludedCarrier(name); for (const name of [ '唯品顺丰', '武进安能', '武进安能大件', '中铁物流', '壹米滴答专线', '自提', '乐鑫达线下韵达配件', '商家配送', // 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏 '顺丰速运', '顺丰标快', '洛宁顺丰干配', '山东顺丰干配', '苏北顺丰干配大件', '苏北顺丰干配小件', '山东顺丰大件', '景瓴十方顺丰大件-莒南', // 京东系只有京喜自营那条线剔(10-22 曾要求全剔,业务随即撤回) '京喜京东快递', '京喜京东快运', ]) { assert.ok(hit(name), `${name} 应剔除,实际未命中`); } // 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除 assert.equal(hit('唯品顺丰'), '唯品顺丰'); assert.equal(hit('山东顺丰大件'), '顺丰'); assert.equal(hit('京喜京东快递'), '京喜京东'); // 🔥 别把「京喜京东」放大成「京东」:这些京东系账号要正常考核 assert.equal(hit('京东快递'), null); assert.equal(hit('京东自营快递'), null); assert.equal(hit('京东自营快递亿家达'), null); assert.equal(hit('京东自营快递朴美'), null); assert.equal(hit('崔桥京东特惠'), null); assert.equal(hit('曹县京东快运'), null); // 非顺丰/非京东的常规承运商不能误剔 assert.equal(hit('常州中通快运'), null); assert.equal(hit('常州德邦'), null); assert.equal(hit('百世快运'), null); assert.equal(hit('睢宁德邦'), null); assert.equal(hit('曹县德邦小件'), null); }); test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => { const hit = (name, centerKey) => registry.matchExcludedCarrier(name, centerKey); // 睢宁仓自己用 → 正常 assert.equal(hit('睢宁中通', 'SN'), null); // 异地打单(实测山东品牌仓 137 单未揽收、河南洛宁 20 单、崔桥工厂 6 单)→ 剔 assert.ok(hit('睢宁中通', 'SD_BRAND')); assert.ok(hit('睢宁中通', 'HN_LN')); assert.ok(hit('睢宁中通', 'CQ')); assert.ok(hit('睢宁中通', 'HL2')); // 没给仓(保守)→ 不剔,宁可少剔也不能误剔睢宁仓 assert.equal(hit('睢宁中通'), null); // 别把「常州中通快运」等其它中通账号连带剔掉 assert.equal(hit('常州中通快运', 'SD_BRAND'), null); }); test('🔥 「武进安能」不能泛化成「安能」:常州安能新要正常考核', () => { // 12 仓实测「常州安能新」3383 单/30 天,泛化「安能」会把整片正常单误剔 assert.equal(registry.matchExcludedCarrier('常州安能新'), null); assert.equal(registry.matchExcludedCarrier('雅柏安能'), null); assert.equal(registry.matchExcludedCarrier('常州中通快运'), null); assert.equal(registry.matchExcludedCarrier(''), null); assert.equal(registry.matchExcludedCarrier(null), null); }); test('已取消 / 作废 / 拦截的订单剔除', () => { const hit = (status, labels) => registry.matchVoidedOrder(status, labels); assert.ok(hit('已取消', ''), '订单状态已取消要剔'); assert.ok(hit('已作废', '')); assert.ok(hit('已发货', '["发货后取消","发货后售后"]'), '发货后取消要剔'); assert.ok(hit('已发货', '["留言备注拦截","禁止拆分"]'), '留言备注拦截要剔'); assert.ok(hit('已发货', '["作废"]')); // 正常单不能被误剔 assert.equal(hit('已发货', '["店铺-拼多多","已成团"]'), null); assert.equal(hit('已发货', ''), null); // 「取消预售」是营销标签,不是取消,不能剔 assert.equal(hit('已发货', '["取消预售"]'), null); }); test('剔除的单既不算异常,也不进发货分母', async () => { const mixed = [ // 10:唯品顺丰 · 未揽收 → 本来会误报,必须整单消失 mk({ inner_order_id: 10, so_id: 'SO-10', logistic_company_name: '唯品顺丰', logistic_status_name: '未揽收', logistic_time: null }), // 11:中铁物流 · 异常 mk({ inner_order_id: 11, so_id: 'SO-11', logistic_company_name: '中铁物流', logistic_status_name: '异常' }), // 12:武进安能 · 已签收 → 连带分母也要消失 mk({ inner_order_id: 12, so_id: 'SO-12', logistic_company_name: '武进安能' }), // 13:常州安能新 · 未揽收 → 必须留下(反例) mk({ inner_order_id: 13, so_id: 'SO-13', logistic_company_name: '常州安能新', logistic_status_name: '未揽收', logistic_time: null }), // 14:正常承运商但订单已取消 → 剔 mk({ inner_order_id: 14, so_id: 'SO-14', logistic_company_name: '常州中通快运', logistic_status_name: '无物流单', logistic_time: null, order_status_name: '已取消' }), // 15:正常承运商但发货后取消 → 剔 mk({ inner_order_id: 15, so_id: 'SO-15', logistic_company_name: '常州中通快运', logistic_status_name: '未揽收', logistic_time: null, order_labels: '["发货后取消","发货后售后"]' }), // 16:正常单留作对照 mk({ inner_order_id: 16, so_id: 'SO-16', logistic_company_name: '常州中通快运' }), // 17:京喜京东快递 · 已揽收未中转 → 京喜自营轨迹不回传,剔 mk({ inner_order_id: 17, so_id: 'SO-17', warehouse_name: '简尚-山东青岗集仓', logistic_company_name: '京喜京东快递', logistic_status_name: '已揽收', logistic_time: '2026-10-20 20:00:00', last_info: '快件已从【菏泽水岸城邦站】揽收完成' }), // 18:睢宁中通在**非睢宁仓**(山东品牌仓)未揽收 → 异地打单,剔 mk({ inner_order_id: 18, so_id: 'SO-18', warehouse_name: '简尚-山东品牌仓', logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }), // 19:睢宁中通在**睢宁仓**未揽收 → 本仓本账号,照常考核(反例,必须留) mk({ inner_order_id: 19, so_id: 'SO-19', warehouse_name: '简尚-睢宁仓', logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }), ]; await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); // 只剩 13、16、19 三单(10/11/12 承运商剔除,14/15 作废剔除,17 京喜、18 异地睢宁中通剔除) assert.equal(data.summary.shippedOrders, 3, `分母应只剩 3 单,实际 ${data.summary.shippedOrders}`); assert.equal(data.summary.exceptionOrders, 2, '只剩 13、19 两单异常'); assert.equal(data.summary.exceptionRate, 66.7); assert.deepEqual(data.orders.map((o) => o.oId).sort(), ['13', '19']); // 剔除量要如实返回,页面能展示 assert.equal(data.exclusions.carrierOrders, 5, '唯品顺丰/中铁/武进安能/京喜京东/异地睢宁中通 5 单'); assert.equal(data.exclusions.voidedOrders, 2, '已取消 + 发货后取消 2 单'); assert.equal(data.exclusions.carrierPackages, 5); assert.equal(data.exclusions.voidedPackages, 2); assert.ok(data.exclusions.carrierReasons['睢宁中通(非睢宁仓异地揽收)']); assert.ok(data.exclusions.voidedReasons['订单已取消']); assert.ok(data.exclusions.voidedReasons['标签含「发货后取消」']); }, mixed); }); test('刷单 / 信誉单剔除(按订单标签,不按「特殊单」)', () => { assert.equal(registry.matchNonRealOrder('["刷单待处理","特殊单","店铺-拼多多"]'), '刷单'); assert.equal(registry.matchNonRealOrder('["特殊单","禁止拆分","转空包发货"]'), '信誉单(转空包)'); // 反例:「特殊单」本身混着补差价/配件单,不能当判据 assert.equal(registry.matchNonRealOrder('["特殊单","补差价"]'), null); assert.equal(registry.matchNonRealOrder('["店铺-京东","晚发赔"]'), null); assert.equal(registry.matchNonRealOrder(''), null); assert.equal(registry.matchNonRealOrder(null), null); }); test('刷单的包裹不进分母也不算异常', async () => { const mixed = [ // 20:刷单 · 未揽收 → 假异常,必须消失 mk({ inner_order_id: 20, so_id: 'SO-20', logistic_status_name: '未揽收', logistic_time: null, order_labels: '["刷单待处理","特殊单","店铺-拼多多"]' }), // 21:信誉单(转空包)· 无物流单 → 消失 mk({ inner_order_id: 21, so_id: 'SO-21', logistic_status_name: '无物流单', logistic_time: null, order_labels: '["特殊单","禁止拆分","转空包发货"]' }), // 22:带「特殊单」但不是刷单/信誉单 → 必须留下 mk({ inner_order_id: 22, so_id: 'SO-22', logistic_status_name: '未揽收', logistic_time: null, order_labels: '["特殊单","补差价"]' }), ]; await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); assert.equal(data.summary.shippedOrders, 1, '只剩 22 号单'); assert.deepEqual(data.orders.map((o) => o.oId), ['22']); assert.equal(data.exclusions.nonRealOrders, 2); assert.equal(data.exclusions.nonRealReasons['刷单'], 1); assert.equal(data.exclusions.nonRealReasons['信誉单(转空包)'], 1); assert.deepEqual(data.exclusions.orderTypeLabels, ['刷单', '信誉单']); }, mixed); }); test('未到 09:00 也返回剔除口径,前端不用判空', async () => { await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); assert.equal(data.notStarted, true); assert.ok(Array.isArray(data.exclusions.carriers)); assert.ok(data.exclusions.carriers.includes('唯品顺丰')); assert.equal(data.exclusions.carrierOrders, 0); }); });