'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, };