- 后端新增 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 条残留
439 lines
18 KiB
JavaScript
439 lines
18 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓储中心物流转运异常 —— 服务层
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 口径见 warehouseLogisticsExceptionRegistry.js 文件头。这里只讲实现要点:
|
||
*
|
||
* ① 一趟查询把「核验日前一天发货」的单全部取回来(窄列 + 轨迹截 300 字),
|
||
* Node 侧按仓库名归主仓、按物流状态判异常。实测:5400 行冷启动 ~20s、
|
||
* 热缓存 ~4s(视图谓词下推一般,走 querySlow 给足超时)。
|
||
* ② 一单多包裹会有多行(按 logistic_id 区分)——
|
||
* · 明细按 (内部单号 + 运单号) 去重,仓库能看到是哪个包裹出的问题;
|
||
* · 汇总按**内部单号**去重,否则 3 个包裹的单会被算成 3 单,异常率虚高。
|
||
* ③ 只判「揽收前」的两个卡点(未揽收 / 已揽收未中转),见 classify()。
|
||
* 运输中、派送中一律不考核,不再算 24h 停滞。
|
||
* ④ 结果缓存 5 分钟:页面 10 分钟自动刷一次,点「刷新」带 force=1 绕过。
|
||
*
|
||
* ── 剔除(业务 2026-10-21 定版)──────────────────────────────────────────────
|
||
* 剔掉的单**既不算异常、也不算发货分母**(否则异常率会被稀释/虚高):
|
||
* a. 承运商 ∈ EXCLUDED_CARRIERS(顺丰系全部 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 /
|
||
* 商家配送 / 京喜京东)—— 轨迹不回传,留着全是假异常(顺丰系各账号 52%~99.6% 假异常,
|
||
* 中铁、壹米滴答近 30 天 100%)。「武进安能」必须精确,别泛化成「安能」。
|
||
* a2. 有条件剔除 CONDITIONAL_EXCLUDED_CARRIERS:**「睢宁中通」只在睢宁仓考核**,
|
||
* 其它仓异地用它打单的剔(近 30 天非睢宁仓 490 单里 163 单未揽收,全是账号异地揽收)。
|
||
* b. 订单已终止:order_status_name 含「取消/作废」,或 order_labels 含
|
||
* 「发货后取消 / 作废 / 拦截」—— 单据都终止了,不用再跟物流。
|
||
* c. 刷单(标签含「刷单」)/ 信誉单(标签含「转空包」)—— 单据本身不是真实发货。
|
||
* 各类剔除量通过 `exclusions.*` 原样返回,页面页脚展示,方便业务核对口径。
|
||
*/
|
||
|
||
const cloudDatabase = require('../config/cloudDatabase');
|
||
const registry = require('./warehouseLogisticsExceptionRegistry');
|
||
|
||
const DEFAULT_DETAIL_LIMIT = 500;
|
||
const MAX_DETAIL_LIMIT = 2000;
|
||
|
||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||
const SNAPSHOT_STALE_MINUTES = 120;
|
||
|
||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||
let resultCache = { key: '', at: 0, data: null };
|
||
|
||
function clearLogisticsExceptionCache() {
|
||
resultCache = { key: '', at: 0, data: null };
|
||
}
|
||
|
||
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
|
||
function formatDateTime(value) {
|
||
if (!value) return '';
|
||
if (value instanceof Date) {
|
||
if (Number.isNaN(value.getTime())) return '';
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
|
||
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
|
||
}
|
||
const s = String(value);
|
||
if (!s) return '';
|
||
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
||
}
|
||
|
||
/** 两个时间文本相差多少分钟(to - from) */
|
||
function diffMinutes(fromText, toText) {
|
||
const a = formatDateTime(fromText);
|
||
const b = formatDateTime(toText);
|
||
if (!a || !b) return null;
|
||
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return null;
|
||
return Math.round((pb - pa) / 60000);
|
||
}
|
||
|
||
/**
|
||
* 按物流状态判异常类型(口径 2026-10-21 二次定版)。
|
||
*
|
||
* 只有两种「真异常」,其余状态一律不考核:
|
||
* · 没揽收 —— 状态停在「未揽收」(或压根没物流单)
|
||
* · 揽收了但没下一步中转 —— 状态停在「已揽收」,最后轨迹里也没有运输/中转字样
|
||
*
|
||
* 🔥 业务原话:「有『运输』字样的,往后中转记录不考核」——
|
||
* 所以 `运输中` / `派送中` **直接算正常**;不再用「距核验时刻 ≥24h 无新轨迹」
|
||
* 判停滞(干线、中转轨迹本就不回传聚水潭,按 24h 判全是假异常)。
|
||
*
|
||
* @returns {string|null} EXCEPTION_TYPES[].key;正常返回 null
|
||
*/
|
||
function classify(row) {
|
||
const status = String(row.logistic_status_name || '').trim();
|
||
const trackingNo = String(row.logistic_id || '').trim();
|
||
|
||
// 状态为空:有运单号但没轨迹 = 未揽收;连运单号都没有 = 无物流单
|
||
if (!status) return trackingNo ? 'NOT_PICKED' : 'NO_TRACKING';
|
||
|
||
if (status === '已签收' || status === '签收') return null;
|
||
if (status.includes('无物流')) return 'NO_TRACKING';
|
||
if (status.includes('未揽收')) return 'NOT_PICKED';
|
||
if (status.includes('拒收')) return 'RETURNED';
|
||
// 「其他」实测全是「包裹已换单退回」,和拒收同性质
|
||
if (status === '其他') return 'RETURNED';
|
||
if (status.includes('异常')) return 'CARRIER_EXCEPTION';
|
||
|
||
if (status.includes('已揽收')) {
|
||
// 揽收之后如果最后一条轨迹已经出现运输/中转/发往字样,就算「有下一步中转」
|
||
const info = String(row.last_info || row.last_logistic_info || '');
|
||
const moved = registry.TRANSIT_INFO_KEYWORDS.some((kw) => info.includes(kw));
|
||
return moved ? null : 'PICKED_NO_TRANSIT';
|
||
}
|
||
|
||
// 运输中 / 派送中(以及任何带「运输」字样的状态)= 已经动起来了,不考核
|
||
return null;
|
||
}
|
||
|
||
/** 拼 SQL 的仓库名 LIKE 条件(关键词取自 CENTERS,避免手写两套名单) */
|
||
function buildWarehouseFilters() {
|
||
const keywords = [];
|
||
for (const center of registry.CENTERS) {
|
||
for (const kw of center.keywords) {
|
||
if (!keywords.includes(kw)) keywords.push(kw);
|
||
}
|
||
}
|
||
const params = {};
|
||
const clauses = keywords.map((kw, i) => {
|
||
params[`w${i}`] = `%${kw}%`;
|
||
return `e.warehouse_name LIKE @w${i}`;
|
||
});
|
||
return { clauses, params, keywords };
|
||
}
|
||
|
||
async function loadShippedPackages(batch) {
|
||
const { clauses, params } = buildWarehouseFilters();
|
||
const rows = await cloudDatabase.querySlow(
|
||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||
e.logistic_id, e.logistic_company_name, e.logistic_status_name,
|
||
e.order_status_name, e.order_labels,
|
||
e.send_time, e.logistic_time, e.query_time,
|
||
LEFT(ISNULL(e.last_logistic_info, ''), 300) AS last_info
|
||
FROM dbo.jst_order_express_details e
|
||
WHERE e.send_time >= @shipStart AND e.send_time < @shipEnd
|
||
AND (${clauses.join(' OR ')})`,
|
||
{ ...params, shipStart: batch.windowStart, shipEnd: batch.windowEnd },
|
||
// 视图谓词下推一般,冷启动 20s;普通池 60s 池级超时 + 重试会把耗时翻倍
|
||
{ connectRequestTimeoutMs: 300000 }
|
||
);
|
||
return rows || [];
|
||
}
|
||
|
||
function emptySummary() {
|
||
return {
|
||
shippedOrders: 0,
|
||
packageCount: 0,
|
||
exceptionOrders: 0,
|
||
exceptionPackages: 0,
|
||
exceptionRate: 0,
|
||
centerCount: 0,
|
||
byType: {},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 剔除口径(前端页脚直接展示,别在页面里再写一份)。
|
||
* 两处返回(正常 / 未到核验时刻)共用,避免口径漂移。
|
||
*/
|
||
function exclusionMeta() {
|
||
return {
|
||
carriers: registry.EXCLUDED_CARRIERS,
|
||
// 有条件剔除:「睢宁中通」只在睢宁仓考核,其它仓异地打单的剔
|
||
conditionalCarriers: registry.CONDITIONAL_EXCLUDED_CARRIERS.map((r) => ({
|
||
keyword: r.keyword,
|
||
keepCenters: r.keepCenterKeys.map((key) => {
|
||
const center = registry.CENTERS.find((c) => c.key === key);
|
||
return center ? center.name : key;
|
||
}),
|
||
reason: r.reason,
|
||
})),
|
||
orderStatusKeywords: registry.EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||
orderLabelKeywords: registry.EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||
// 刷单 / 信誉单的标签判据
|
||
orderTypeLabels: registry.EXCLUDED_ORDER_TYPE_LABELS.map((t) => t.label),
|
||
};
|
||
}
|
||
|
||
async function getLogisticsExceptions(options = {}) {
|
||
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
||
const limit = Math.min(
|
||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||
MAX_DETAIL_LIMIT
|
||
);
|
||
const force = options.force === true || options.force === '1' || options.force === 1;
|
||
|
||
const generatedAt = formatDateTime(registry.beijingNow());
|
||
|
||
// 未到 09:00:本批次还没跑,不拿当前快照冒充「09:00 的结论」
|
||
if (!batch.started) {
|
||
return {
|
||
batch,
|
||
notStarted: true,
|
||
resolution: '09:00',
|
||
summary: emptySummary(),
|
||
centers: registry.CENTERS.map((c) => ({
|
||
key: c.key, name: c.name, shippedOrders: 0, exceptionOrders: 0, exceptionRate: 0, byType: {},
|
||
})),
|
||
types: registry.EXCEPTION_TYPES,
|
||
exclusions: {
|
||
...exclusionMeta(),
|
||
carrierPackages: 0,
|
||
carrierOrders: 0,
|
||
carrierReasons: {},
|
||
voidedPackages: 0,
|
||
voidedOrders: 0,
|
||
voidedReasons: {},
|
||
nonRealPackages: 0,
|
||
nonRealOrders: 0,
|
||
nonRealReasons: {},
|
||
},
|
||
orders: [],
|
||
detailLimit: limit,
|
||
truncated: false,
|
||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||
generatedAt,
|
||
cached: false,
|
||
cacheAgeSec: 0,
|
||
};
|
||
}
|
||
|
||
const cacheKey = `${batch.verifyDate}|${limit}`;
|
||
if (!force && resultCache.data && resultCache.key === cacheKey
|
||
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||
return {
|
||
...resultCache.data,
|
||
cached: true,
|
||
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||
};
|
||
}
|
||
|
||
const rows = await loadShippedPackages(batch);
|
||
|
||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||
const centerStat = new Map();
|
||
for (const center of registry.CENTERS) {
|
||
centerStat.set(center.key, {
|
||
key: center.key,
|
||
name: center.name,
|
||
shippedOrders: new Set(),
|
||
exceptionOrders: new Set(),
|
||
byType: {},
|
||
packages: 0,
|
||
});
|
||
}
|
||
|
||
const seenPackage = new Set();
|
||
const exceptions = [];
|
||
let packageCount = 0;
|
||
const shippedOrderIds = new Set();
|
||
let snapshotAt = '';
|
||
|
||
// 剔除统计(业务 2026-10-21:不考核的承运商 + 已取消/作废/拦截 + 刷单/信誉单)
|
||
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
||
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
||
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
||
|
||
for (const row of rows) {
|
||
const queryTime = formatDateTime(row.query_time);
|
||
if (queryTime && queryTime > snapshotAt) snapshotAt = queryTime;
|
||
|
||
const center = registry.resolveCenter(row.warehouse_name);
|
||
if (!center) continue;
|
||
|
||
const oId = String(row.inner_order_id || '');
|
||
const trackingNo = String(row.logistic_id || '');
|
||
const packageKey = `${oId}|${trackingNo || row.send_time || ''}`;
|
||
if (seenPackage.has(packageKey)) continue;
|
||
seenPackage.add(packageKey);
|
||
|
||
// ① 不参与考核的承运商(唯品顺丰 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 / 京喜京东)
|
||
// 这些要么轨迹不回传聚水潭、要么根本没有快递轨迹,留着只会刷假异常。
|
||
// 「睢宁中通」是有条件的:睢宁仓照常考核,其它仓异地打单的剔(传 center.key 区分)。
|
||
const carrierHit = registry.matchExcludedCarrier(row.logistic_company_name, center.key);
|
||
if (carrierHit) {
|
||
skippedByCarrier.packages += 1;
|
||
skippedByCarrier.orders.add(oId);
|
||
skippedByCarrier.reasons[carrierHit] = (skippedByCarrier.reasons[carrierHit] || 0) + 1;
|
||
continue;
|
||
}
|
||
|
||
// ② 已取消 / 作废 / 拦截的订单 —— 单据已终止,不用再跟物流
|
||
const voidReason = registry.matchVoidedOrder(row.order_status_name, row.order_labels);
|
||
if (voidReason) {
|
||
skippedByVoid.packages += 1;
|
||
skippedByVoid.orders.add(oId);
|
||
skippedByVoid.reasons[voidReason] = (skippedByVoid.reasons[voidReason] || 0) + 1;
|
||
continue;
|
||
}
|
||
|
||
// ③ 刷单 / 信誉单 —— 订单本身不是真实发货,揽不揽收不代表仓库的物流能力
|
||
const nonRealReason = registry.matchNonRealOrder(row.order_labels);
|
||
if (nonRealReason) {
|
||
skippedByType.packages += 1;
|
||
skippedByType.orders.add(oId);
|
||
skippedByType.reasons[nonRealReason] = (skippedByType.reasons[nonRealReason] || 0) + 1;
|
||
continue;
|
||
}
|
||
|
||
const stat = centerStat.get(center.key);
|
||
stat.shippedOrders.add(oId);
|
||
stat.packages += 1;
|
||
packageCount += 1;
|
||
shippedOrderIds.add(oId);
|
||
|
||
const type = classify(row);
|
||
if (!type) continue;
|
||
|
||
const sendAt = formatDateTime(row.send_time);
|
||
const logisticAt = formatDateTime(row.logistic_time);
|
||
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||
|
||
stat.exceptionOrders.add(oId);
|
||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||
|
||
exceptions.push({
|
||
oId,
|
||
soId: String(row.so_id || ''),
|
||
shopName: String(row.shop_name || ''),
|
||
warehouseName: String(row.warehouse_name || ''),
|
||
centerKey: center.key,
|
||
centerName: center.name,
|
||
carrier: String(row.logistic_company_name || ''),
|
||
trackingNo,
|
||
logisticStatus: String(row.logistic_status_name || ''),
|
||
exceptionType: type,
|
||
exceptionLabel: typeMeta.label,
|
||
exceptionDesc: typeMeta.desc || '',
|
||
tone: typeMeta.tone,
|
||
sendTime: sendAt,
|
||
lastLogisticTime: logisticAt,
|
||
lastLogisticInfo: String(row.last_info || '').trim(),
|
||
// 发货 → 核验时刻(小时,1 位小数):仓库看到「发了多久还没动静」
|
||
pendingHours: pendingMinutes === null ? null : Math.round((pendingMinutes / 60) * 10) / 10,
|
||
// 最后一条轨迹 → 核验时刻(小时):负数表示这条轨迹是核验之后才出现的
|
||
staleHours: staleMinutes === null ? null : Math.round((staleMinutes / 60) * 10) / 10,
|
||
});
|
||
}
|
||
|
||
// 异常单最久未处理的排前面
|
||
exceptions.sort((a, b) => (b.pendingHours || 0) - (a.pendingHours || 0));
|
||
|
||
// ── 汇总 ────────────────────────────────────────────────────────────
|
||
const summary = emptySummary();
|
||
const allExceptionOrders = new Set();
|
||
summary.shippedOrders = shippedOrderIds.size;
|
||
summary.packageCount = packageCount;
|
||
|
||
const centers = registry.CENTERS.map((center) => {
|
||
const stat = centerStat.get(center.key);
|
||
const byType = { ...stat.byType };
|
||
for (const oid of stat.exceptionOrders) allExceptionOrders.add(oid);
|
||
return {
|
||
key: center.key,
|
||
name: center.name,
|
||
shippedOrders: stat.shippedOrders.size,
|
||
exceptionOrders: stat.exceptionOrders.size,
|
||
exceptionRate: stat.shippedOrders.size
|
||
? Math.round((stat.exceptionOrders.size / stat.shippedOrders.size) * 1000) / 10
|
||
: 0,
|
||
byType,
|
||
};
|
||
});
|
||
|
||
summary.exceptionOrders = allExceptionOrders.size;
|
||
summary.exceptionPackages = exceptions.length;
|
||
summary.exceptionRate = summary.shippedOrders
|
||
? Math.round((summary.exceptionOrders / summary.shippedOrders) * 1000) / 10
|
||
: 0;
|
||
summary.centerCount = centers.filter((c) => c.exceptionOrders > 0).length;
|
||
for (const item of exceptions) {
|
||
summary.byType[item.exceptionType] = (summary.byType[item.exceptionType] || 0) + 1;
|
||
}
|
||
|
||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||
let hint = '';
|
||
if (coversDeadline === false) {
|
||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||
} else if (stale) {
|
||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)}(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新),`
|
||
+ '状态可能滞后,请以聚水潭实时物流为准。';
|
||
}
|
||
|
||
const payload = {
|
||
batch,
|
||
notStarted: false,
|
||
resolution: '09:00',
|
||
summary,
|
||
centers,
|
||
// 异常类型字典(前端图例/筛选器直接用,避免两边各写一套文案)
|
||
types: registry.EXCEPTION_TYPES,
|
||
// 业务指定的剔除口径(前端原样展示,别在页面里硬编码一份)
|
||
exclusions: {
|
||
...exclusionMeta(),
|
||
// 本批实际剔掉了多少(包裹 / 订单 / 原因明细)
|
||
carrierPackages: skippedByCarrier.packages,
|
||
carrierOrders: skippedByCarrier.orders.size,
|
||
carrierReasons: skippedByCarrier.reasons,
|
||
voidedPackages: skippedByVoid.packages,
|
||
voidedOrders: skippedByVoid.orders.size,
|
||
voidedReasons: skippedByVoid.reasons,
|
||
nonRealPackages: skippedByType.packages,
|
||
nonRealOrders: skippedByType.orders.size,
|
||
nonRealReasons: skippedByType.reasons,
|
||
},
|
||
orders: exceptions.slice(0, limit),
|
||
detailLimit: limit,
|
||
truncated: exceptions.length > limit,
|
||
freshness: {
|
||
snapshotAt,
|
||
ageMinutes,
|
||
stale,
|
||
coversDeadline,
|
||
hint,
|
||
},
|
||
generatedAt,
|
||
cached: false,
|
||
cacheAgeSec: 0,
|
||
};
|
||
|
||
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||
return payload;
|
||
}
|
||
|
||
module.exports = {
|
||
getLogisticsExceptions,
|
||
clearLogisticsExceptionCache,
|
||
classify,
|
||
formatDateTime,
|
||
diffMinutes,
|
||
};
|