cangku/backend/src/services/warehouseLogisticsExceptionService.js
yanhui 9d80b4c73e fix(wle): 现场拦截(售后已签收)不再算仓储物流转运异常
业务口径(2026-10-23):发货后被快递当场拦下、货被售后仓签收的单,物流环节
其实已经结束,不该报「转运异常」。判据 = 售后单**签收单号 `l_id` 非空** 且
明细**实退数量 `r_qty` 合计 > 0**。

改动:
- registry 新增 INTERCEPT_AFTERSALE_SOURCES,登记两张互斥的店铺售后表
  (jst_returnAndRefundInfo/Item、jst_qmcust_refundlist/Item);
- service 新增 loadInterceptedOrders(),在主循环前一次性批量判定:
  · 只查「本来会报异常」的 oId(9800 单 → 几十单);
  · 两张表**并发**查(两表在 rpt 副本里都是堆表,全表扫 4.5s + 8.5s,并发后取 8.5s);
  · 售后表查询失败自动降级为「不剔除」,不让整页挂掉;
- 命中即从异常清单摘掉,但**保留发货分母**(货确实发出去了),异常率不被放大;
- exclusions 新增 interceptPackages/Orders/Reasons,页面页脚与钉钉推送各补一行说明。

实测(2026-10-22 批次):异常 36 → 33,剔除 3 单(9854409/9850736/9858367,
全是「拒收退货 + 实退 1 件 + 签收单号=原运单号 + 卖家已收货」);
10-21 批次剔除 2 单;同时 r_qty=0 的 9859198/9871523 正确地没被剔。

验证:
- node --test warehouseLogisticsException(+Notify):47/47 通过;
- 本地 5173 真渲染:表格 33 行、页脚显示「另有 3 单属现场拦截(售后已签收)」、无 pageerror;
- 后端 npm run check:469/471,2 个失败为既有平台差异(secretStore 权限位、cloudDatabase 负缓存)。
2026-10-23 08:43:35 +08:00

584 lines
24 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.

'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;
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
const MAX_DETAIL_LIMIT = 2000;
const EXPORT_DETAIL_LIMIT = 5000;
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
const SNAPSHOT_STALE_MINUTES = 120;
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
const CACHE_MAX_ENTRIES = 8;
const resultCache = new Map();
function clearLogisticsExceptionCache() {
resultCache.clear();
}
/** 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 || [];
}
// 售后拦截判定:一次 IN 多少单 + 查询超时。
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
const INTERCEPT_QUERY_BATCH = 500;
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
/**
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
*
* 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。
* 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。
*
* @param {Array} rows 状态表原始行
* @returns {Promise<Set<string>>} 内部单号字符串集合
*/
async function loadInterceptedOrders(rows) {
const ids = [];
const seen = new Set();
for (const row of rows) {
// 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样
if (!classify(row)) continue;
const id = Number(row.inner_order_id);
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
const intercepted = new Set();
if (!ids.length) return intercepted;
const tasks = [];
for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) {
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
tasks.push((async () => {
const hits = await cloudDatabase.query(
`SELECT h.o_id
FROM ${source.header} h WITH (NOLOCK)
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
WHERE h.o_id IN (${oIds})
AND ISNULL(h.l_id, '') <> ''
GROUP BY h.o_id
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
{},
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
);
return hits || [];
})().catch((err) => {
// 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在
console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`);
return [];
}));
}
}
const results = await Promise.all(tasks);
for (const list of results) {
for (const r of list) {
if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id));
}
}
return intercepted;
}
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);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
const limitCap = Math.min(
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
EXPORT_DETAIL_LIMIT
);
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
limitCap
);
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: {},
interceptPackages: 0,
interceptOrders: 0,
interceptReasons: {},
},
orders: [],
detailLimit: limit,
truncated: false,
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
generatedAt,
cached: false,
cacheAgeSec: 0,
};
}
const cacheKey = `${batch.verifyDate}|${limit}`;
const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return {
...cachedEntry.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
};
}
const rows = await loadShippedPackages(batch);
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
const interceptedOrders = await loadInterceptedOrders(rows);
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
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: {} };
const skippedByIntercept = { 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 typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
// ④ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
// ⚠️ 与上面 a/b/c 三类不同:这里**保留发货分母**(货确实发出去了),
// 只从异常清单里摘掉,异常率才不会被错误地放大。
if (interceptedOrders.has(oId)) {
skippedByIntercept.packages += 1;
skippedByIntercept.orders.add(oId);
skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1;
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);
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,
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
interceptPackages: skippedByIntercept.packages,
interceptOrders: skippedByIntercept.orders.size,
interceptReasons: skippedByIntercept.reasons,
},
orders: exceptions.slice(0, limit),
detailLimit: limit,
truncated: exceptions.length > limit,
freshness: {
snapshotAt,
ageMinutes,
stale,
coversDeadline,
hint,
},
generatedAt,
cached: false,
cacheAgeSec: 0,
};
resultCache.set(cacheKey, { at: Date.now(), data: payload });
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
if (resultCache.size > CACHE_MAX_ENTRIES) {
let oldestKey = '';
let oldestAt = Infinity;
for (const [key, entry] of resultCache.entries()) {
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
}
if (oldestKey) resultCache.delete(oldestKey);
}
return payload;
}
/**
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
*
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
* 不另写一套查询,避免「导出和页面两套口径」。
*
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
*/
async function getLogisticsExceptionExportRows(options = {}) {
const data = await getLogisticsExceptions({
...options,
limit: EXPORT_DETAIL_LIMIT,
maxLimit: EXPORT_DETAIL_LIMIT,
});
const rows = Array.isArray(data.orders) ? data.orders : [];
const total = Number(data.summary?.exceptionPackages) || rows.length;
return {
rows,
total,
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
truncated: rows.length < total,
detailLimit: EXPORT_DETAIL_LIMIT,
batch: data.batch,
notStarted: data.notStarted,
summary: data.summary,
centers: data.centers,
types: data.types,
exclusions: data.exclusions,
freshness: data.freshness,
generatedAt: data.generatedAt,
};
}
module.exports = {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
clearLogisticsExceptionCache,
classify,
formatDateTime,
diffMinutes,
};