- 后端新增 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 已合并版本。
494 lines
20 KiB
JavaScript
494 lines
20 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;
|
||
// 列表上限 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 || [];
|
||
}
|
||
|
||
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: {},
|
||
},
|
||
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);
|
||
|
||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||
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.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,
|
||
};
|