cangku/backend/src/services/warehouseLogisticsExceptionService.js
yanhui db1ba79416 feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流)
- 后端新增 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 条残留
2026-10-22 09:07:49 +08:00

439 lines
18 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;
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,
};