- 后端新增 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 条残留
486 lines
25 KiB
JavaScript
486 lines
25 KiB
JavaScript
/**
|
||
* 仓储中心物流转运异常 —— 口径回归测试(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);
|
||
});
|
||
});
|