cangku/backend/test/warehouseLogisticsException.test.js
yanhui 85e2db9f92 feat: 仓储中心物流转运异常页新增「导出异常明细」(Excel / CSV)
- 后端新增 GET /api/warehouse-logistics-exception/export:复用列表同一套口径
  (getLogisticsExceptions),只把明细上限从列表 2000 放宽到 5000,不分页,
  判定/剔除逻辑一行未改,避免「导出与页面两套口径」。
- 结果缓存由单槽改为按 `核验日|明细上限` 分键的 Map(上限 8 条):
  页面按默认条数取、导出按 5000 取,单槽会互相踢掉 —— 导出一次,页面下次
  自动刷新就得重查库(20~40s)。分键后两者互不影响。
- 前端把明细过滤抽成 filterDetailOrders,表格与导出共用同一个函数,
  保证「导出的就是页面看到的」。
- 导出列单独定义 EXPORT_COLUMNS(13 列纯文本标题):页面列用了 Tooltip,
  直接复用列定义会把表头导出成 [object Object];运单号/物流状态/最后轨迹
  为空时导出为「无运单号 / 无状态 / 无轨迹」,Excel 里不留空格。
- 导出跟随当前核验日 + 选中仓 + 选中异常类型;文件名带核验日与筛选范围
  (仓储物流转运异常_<核验日>_<仓>_<类型>);0 行时提示且不生成空文件;
  超过 5000 行时如实提示「已截断」。导出按钮做 loading,防重复点。
- 测试:新增 2 个用例 —— ①导出行数等于异常包裹数、字段齐全、带剔除口径;
  ②缓存按 limit 分键(页面 1000 条那份不被导出 5000 条顶掉)。
  warehouseLogisticsException.test.js 26/26 通过。

口径未变:承运商剔除(顺丰系全剔、京喜京东、睢宁中通按仓、武进安能/中铁/
壹米滴答/自提/线下/商家配送)、订单终止与刷单信誉单剔除、异常 5 类判定,
全部沿用 #71 已合并版本。
2026-10-22 09:58:22 +08:00

528 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 仓储中心物流转运异常 —— 口径回归测试(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);
});
});