cangku/backend/test/warehouseLogisticsException.test.js
yanhui db1ba79416 feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流)
- 后端新增 warehouse-logistics-exception 模块(Registry / Service / Api),
  挂载 /api/warehouse-logistics-exception;前端新增独立页面并接入左侧栏
  「仓储中心物流转运异常」(排在「仓储中心 24 小时发货时效」下面)。
- 核验口径:核验日 09:00 看**前一日已发货**订单的物流记录;12 个发货仓按仓库名
  最长匹配归口(子仓并入主仓,「京仓-横林二仓京东云仓」排除)。
  只统计揽收前的卡点:无物流单 / 未揽收 / 已揽收未中转 / 快递报异常 / 退回;
  运输中、派送中、已签收一律正常(干线与中转轨迹本就不回传,不再按 24h 判停滞)。
  明细按包裹、汇总按内部单号去重;结果缓存 5 分钟,force=1 可绕过。
- 剔除口径(业务 2026-10-21 / 10-22 定版,剔掉的既不算异常也不算发货分母):
  · 承运商:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、
    壹米滴答、京喜京东(快递·快运)、自提、线下、商家配送;
  · 有条件剔除:睢宁中通只在睢宁仓考核,其它仓异地打单的剔出;
  · 订单已终止:状态含取消/作废,或标签含「发货后取消」「作废」「拦截」;
  · 刷单 / 信誉单:订单标签含「刷单」「转空包」。
  「武进安能」「京喜京东」均按精确粒度匹配,不泛化成「安能」「京东」,避免误剔
  常州安能新、京东自营快递亿家达等正常账号。
- 时区:模块内统一用 Asia/Shanghai 取「今天」,避免生产容器 Etc/UTC 导致核验批次错位。
- 页面:分仓统计卡 + 异常类型筛选 + 明细(承运商 / 运单号 / 物流状态 / 异常标签 /
  最后轨迹 / 发货时间 / 待处理小时),页脚展示剔除口径与本批剔除量;
  物流快照停更按黄/红两级告警,并标注「历史批次按当前物流快照重算」。

验证(本地隔离环境,非生产库):
- node --test backend/test/warehouseLogisticsException.test.js → 24/24 通过
- npm --prefix frontend run check → 通过
- 本地真实数据接口实测:顺丰系、睢宁中通(异地仓)明细 0 条残留
2026-10-22 09:07:49 +08:00

486 lines
25 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('快照没覆盖核验时刻时给出红色告警文案', 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「涉及到顺丰的快递全部剔除」,账号一个不漏
'顺丰速运',
'顺丰标快',
'洛宁顺丰干配',
'山东顺丰干配',
'苏北顺丰干配大件',
'苏北顺丰干配小件',
'山东顺丰大件',
'景瓴十方顺丰大件-莒南',
]) {
assert.ok(hit(name), `${name} 应剔除,实际未命中`);
}
// 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除
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);
});
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);
});
});