cangku/backend/src/services/warehouseLogisticsExceptionRegistry.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

427 lines
22 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';
/**
* 仓储中心物流转运异常(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-21):
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
* 下属 12 个发货仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
* 山东青岗集唯品仓 / 河南洛宁仓
* 核验规则:**当日 09:00 核验前一日已发货订单的物流记录** ——
* 昨天发了货,到今天早上 9 点物流还没有正常轨迹(没揽收 / 没物流单 / 快递报异常 /
* 被退回 / 揽收后长时间无新轨迹),就是「物流转运异常」,按仓归口。
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* `rpt.jst_order_express_details`(聚水潭订单状态镜像,402 万行,视图)
* 这是一张「订单 + 物流」合并的宽表,一单多包裹会有多行(按 logistic_id 区分)。
* 本页用到的列:
* send_time 发货时间(聚水潭回传)
* logistic_status_name 物流状态:已签收 / 运输中 / 派送中 / 已揽收 /
* 未揽收 / 无物流单 / 异常 / 拒收 / 其他
* logistic_time 最后一条物流轨迹时间
* last_logistic_info 最后一条轨迹描述(nvarchar 6000,SQL 里截 300 字)
* query_time ETL 快照时间(每行最后一次被刷新的时刻)
* ⚠️ 别用 `logistic_time_to_now_h` 判「停滞」:那是相对**查询时刻**算的,
* 而本页的核验基准是**核验日的 09:00**(回看历史批次时会飘),统一在 Node 侧按
* `logistic_time → deadline` 重算。
*
* ── 异常判定(2026-10-21 定版 · 二次收敛)────────────────────────────────
* 业务口径:「统计的是**未揽收**和**已揽收、但没有下一步中转记录**的」,
* 「有『运输』字样的,往后中转记录不考核」。
* `NO_TRACKING` 无物流单 —— 发了货但没生成运单号(打单环节漏了)
* `NOT_PICKED` 未揽收 —— 到核验时刻快递还没揽收
* `PICKED_NO_TRANSIT` 已揽收未中转 —— 揽收后没有下一步中转/运输记录,卡在网点
* `CARRIER_EXCEPTION` 快递报异常 —— 状态=异常(寄件人取消寄件、异常签收等)
* `RETURNED` 退回 —— 状态=拒收,或「其他」(实测基本是「换单退回」)
* 已签收 / 运输中 / 派送中 → 正常,不进列表。
* 🔥 原来那套「揽收后 24h 无新轨迹 = 运输停滞」已**废弃**:
* 干线与中转轨迹本来就不回传聚水潭,按 24h 判全是假异常。
*
* ── 仓库归属 ──────────────────────────────────────────────────────────────
* 与「24 小时发货时效」同一套:按仓库名关键词**最长匹配**归主仓,子仓自动并入;
* 「京仓-横林二仓京东云仓」业务指定排除(与发货时效页保持一致)。
* 山东青岗集唯品仓的关键词必须比「山东青岗集」更长,否则会被青岗集仓抢走。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 🔥 生产容器是 `Etc/UTC`,DB 的 `GETDATE()` 是北京时间 —— 详见
* warehouseShipTimelinessRegistry.beijingNow() 的说明。
* 本模块一律用 `beijingNow()` 取「今天」,**不要**直接 `new Date()`。
*
* ── 批次 ──────────────────────────────────────────────────────────────────
* 「核验批次」= 核验日 + 发货日(核验日的前一天)。
* 默认:现在已过 09:00 → 今天这批(核验昨天发货);未到 09:00 → 昨天那批。
* 可回看近 30 天。
* ⚠️ 历史批次是**按当前物流快照重算**的 —— 昨天是异常、今天已经签收的单,
* 回看时会显示成正常。页面会明确标注,别拿它当「当时的留档」。
*/
const BEIJING_OFFSET_MINUTES = 480;
// ── 12 个发货仓(业务给定,顺序即展示顺序)────────────────────────────────
const CENTERS = Object.freeze([
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌'] },
// 🔥 关键词要比「山东青岗集」长,否则「简尚-山东青岗集仓唯品仓」会被青岗集仓抢走
{ key: 'SD_QGJ_VIP', name: '山东青岗集唯品仓', keywords: ['山东青岗集仓唯品', '山东青岗集唯品'] },
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
]);
// 业务指定排除:京仓(平台仓)不算自营发货仓的考核范围(与发货时效页一致)
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云仓']);
// ── 不参与考核的承运商(业务 2026-10-21 指定)──────────────────────────────
// 判据 = 状态表 `logistic_company_name` **包含**关键词(不区分大小写)。
// 🔥 「武进安能」**不能**泛化成「安能」:12 仓另有「常州安能新」3383 单/30 天,
// 那是要正常考核的承运商,泛化会把整片正常单误剔。
// 🔥 「中铁」「壹米滴答」这类专线物流**轨迹不回传聚水潭**,实测近 30 天
// 中铁物流 104 单 100% 报「异常」、壹米滴答 33 单 100% 报「异常」、
// 唯品顺丰 2510 单 2500 单报「异常」(99.6%)—— 全是假异常,必须剔。
// 「自提」「线下」当前 12 仓实测 0 单(历史/其它仓才有),保留判定防止以后出现。
// 🔥 「京喜京东」(含 `京喜京东快递` / `京喜京东快运`)是京喜自营那条线,
// 实测山东青岗集仓的京喜单揽收后轨迹全停在站点、不回传聚水潭,
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
// 是另外的账号,正常回传轨迹,不能连带剔掉。
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
const EXCLUDED_CARRIERS = Object.freeze([
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
// `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭
// (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。
// 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。
// 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。
'唯品顺丰',
'顺丰',
'武进安能',
'中铁',
'壹米滴答',
'自提',
'线下',
'商家配送',
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
// (2026-10-22 曾短暂要求全剔,随即撤回)。
'京喜京东',
]);
// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 2026-10-21 追加)────────
// 「睢宁中通」是全公司唯一的中通面单账号,开在睢宁网点。
// · 睢宁仓自己用 → 正常揽收、轨迹完整,**照常考核**;
// · 山东品牌仓 / 河南洛宁仓 / 崔桥工厂仓 异地用它打单 → 揽收不畅,
// 近 30 天非睢宁仓 490 单里 163 单「未揽收」(33%),全是账号异地揽收造成的假异常。
// 所以:`keepCenterKeys` 里的仓照常考核,其它仓命中即剔除。
const CONDITIONAL_EXCLUDED_CARRIERS = Object.freeze([
{
keyword: '睢宁中通',
keepCenterKeys: ['SN'],
reason: '睢宁中通(非睢宁仓异地揽收)',
},
]);
// ── 已终止的订单不算转运异常(业务 2026-10-21 指定)──────────────────────
// ① 订单状态:状态表 `order_status_name` 实测只有「已发货 / 已取消」两个值,
// 「已取消」在近 30 天 12 仓里 8 单,其中 6 单会误报未揽收/无物流单。
const EXCLUDED_ORDER_STATUS_KEYWORDS = Object.freeze(['取消', '作废']);
// ② 订单标签:`order_labels`(JSON 数组字符串)
// · 「发货后取消」= 客户已申请退款取消,货在路上但不用再跟物流
// · 「作废」= 单据作废
// · 「拦截」= 买家/客服要求拦截(实测标签为「留言备注拦截」)
// ⚠️ 别把「取消」直接当关键词匹配标签:会连带「取消预售」等无关标签。
const EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze([
'发货后取消',
'作废',
'拦截',
]);
// ── 刷单 / 信誉单不算转运异常(业务 2026-10-21 追加)──────────────────────
// 判据 = 状态表 `order_labels` 里的**订单类型标签**(不是状态、不是备注):
// · 含「刷单」 = 刷单待处理 → 假发货,本来就不会真揽收
// · 含「转空包」= 信誉单(信誉订单仓那批单都带这个标签,发空包不走真货)
// 实测(近 30 天 12 仓):带「刷单」411 单,其中 148 单报未揽收 —— 全是假异常;
// 带「转空包」0 单(都在「简尚-信誉订单仓」,那本来不在 12 个考核仓里)。
// ⚠️ 别用「特殊单」当判据:它和补差价/配件单混在一起,剔了会漏掉真异常。
const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
{ keyword: '刷单', label: '刷单' },
{ keyword: '转空包', label: '信誉单' },
]);
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是
// **售后单有「签收单号」且「实退数量」不为 0**。
//
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
//
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
//
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
// r_qty=0(货还在路上),正确地没被剔。
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
{
key: 'returnAndRefund',
header: 'dbo.jst_returnAndRefundInfo',
item: 'dbo.jst_returnAndRefundItem',
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
headerAsId: 'TRY_CONVERT(int, h.as_id)',
label: '售后单',
},
{
key: 'qmcustRefund',
header: 'dbo.jst_qmcust_refundlist',
item: 'dbo.jst_qmcust_refundlist_item',
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
headerAsId: 'h.as_id',
label: '客服售后单',
},
]);
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
const SORTED_KEYWORDS = CENTERS
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
.sort((a, b) => b.kw.length - a.kw.length);
// ── 核验时刻 ──────────────────────────────────────────────────────────────
const VERIFY_HOUR = 9;
const LOOKBACK_DAYS = 30;
// ── 异常类型(2026-10-21 二次定版:只看「揽收前」的两个卡点)───────────────
// 业务原话:「这里的物流异常统计的是未揽收和已揽收、但是没有下一步中转记录的」,
// 以及「有『运输』字样的,往后中转记录不考核,不属于物流异常」。
// 所以:运输中 / 派送中 / 已签收 **一律正常**,不再用「24h 无新轨迹」判停滞
// (干线/中转轨迹本来就不回传,按 24h 判只会刷屏)。
// label 必须**自带结论**(业务要求「写上是什么异常」),不要只写「物流异常」这种笼统词。
const EXCEPTION_TYPES = Object.freeze([
{
key: 'NO_TRACKING',
label: '无物流单',
tone: 'red',
desc: '已发货但连运单号都没有 —— 打单/推单环节就没生成物流单',
},
{
key: 'NOT_PICKED',
label: '未揽收',
tone: 'orange',
desc: '有运单号,到核验时刻快递还没上门揽收',
},
{
key: 'PICKED_NO_TRANSIT',
label: '已揽收未中转',
tone: 'gold',
desc: '快递已揽收,但之后没有下一步中转/运输记录 —— 卡在网点没发出去',
},
{
key: 'CARRIER_EXCEPTION',
label: '快递报异常',
tone: 'red',
desc: '快递回传「异常」(寄件人取消寄件、异常签收等),需要人工介入',
},
{
key: 'RETURNED',
label: '退回',
tone: 'volcano',
desc: '拒收,或包裹已「换单退回」',
},
]);
// 「已经动起来了」的轨迹关键词:状态停在「已揽收」但最后一条轨迹里已经出现
// 运输/中转/发往等字样 —— 说明有下一步中转记录,按业务口径**不算异常**。
const TRANSIT_INFO_KEYWORDS = Object.freeze(['运输', '中转', '发往', '已发出', '分拨', '干线']);
const TYPE_MAP = new Map(EXCEPTION_TYPES.map((t) => [t.key, t]));
function pad2(n) {
return String(n).padStart(2, '0');
}
/**
* 取「按北京时间读出来」的当前时刻(容器可能跑在 UTC)。
* @param {Date} [base]
* @returns {Date} 本地 getter 读出来正好是北京时间的 Date
*/
function beijingNow(base = new Date()) {
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
}
function formatDate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
/** 'YYYY-MM-DD' → 当日 00:00 的 Date(本地,不做时区换算) */
function parseDate(text) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(text || '').trim());
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0);
return Number.isNaN(d.getTime()) ? null : d;
}
function addDays(date, days) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0);
}
/** 仓库名 → 主仓;被排除或无法归属返回 null */
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
const name = String(warehouseName);
for (const kw of EXCLUDED_WAREHOUSE_KEYWORDS) {
if (name.includes(kw)) return null;
}
for (const item of SORTED_KEYWORDS) {
if (name.includes(item.kw)) return item.center;
}
return null;
}
/**
* 承运商是否不参与考核。
* @param {string} companyName 状态表 `logistic_company_name`
* @param {string} [centerKey] 该行所属仓(CONDITIONAL_EXCLUDED_CARRIERS 用);
* 不传时**保守不剔** —— 宁可少剔,不能把本仓正常单误剔
* @returns {string|null} 命中原因/关键词;不剔除返回 null
*/
function matchExcludedCarrier(companyName, centerKey) {
const name = String(companyName || '');
if (!name) return null;
// ① 无条件剔除
for (const kw of EXCLUDED_CARRIERS) {
if (name.includes(kw)) return kw;
}
// ② 有条件剔除:只在该承运商「本仓」正常时才留(如睢宁中通 → 睢宁仓)
for (const rule of CONDITIONAL_EXCLUDED_CARRIERS) {
if (!name.includes(rule.keyword)) continue;
if (!centerKey) return null;
if (rule.keepCenterKeys.includes(centerKey)) return null;
return rule.reason;
}
return null;
}
/**
* 订单是否已终止(已取消 / 作废 / 拦截),终止的不用跟物流。
* @param {string} orderStatus 状态表 `order_status_name`
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中原因(人话),不剔除返回 null
*/
function matchVoidedOrder(orderStatus, orderLabels) {
const status = String(orderStatus || '');
for (const kw of EXCLUDED_ORDER_STATUS_KEYWORDS) {
if (status.includes(kw)) return `订单${status}`;
}
const labels = String(orderLabels || '');
if (!labels) return null;
for (const kw of EXCLUDED_ORDER_LABEL_KEYWORDS) {
if (labels.includes(kw)) return `标签含「${kw}」`;
}
return null;
}
/**
* 刷单 / 信誉单 → 不是真实发货,不用跟物流。
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中的人话原因;不剔除返回 null
*/
function matchNonRealOrder(orderLabels) {
const labels = String(orderLabels || '');
if (!labels) return null;
for (const item of EXCLUDED_ORDER_TYPE_LABELS) {
if (labels.includes(item.keyword)) {
return item.label === '刷单' ? '刷单' : '信誉单(转空包)';
}
}
return null;
}
/**
* 解析「核验批次」。
*
* @param {string} [dateInput] 核验日 'YYYY-MM-DD';不传按当前时刻自动判定
* @param {Date} [nowBase] 当前时刻(真实时刻,内部会转北京时间)
* @returns {object} 批次描述
*/
function resolveBatch(dateInput, nowBase = new Date()) {
const now = beijingNow(nowBase);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
let verifyDay = null;
if (dateInput) {
const parsed = parseDate(dateInput);
if (parsed) verifyDay = parsed;
}
if (!verifyDay) {
// 未到 09:00 时,今天的批次还没跑 → 默认看昨天那批(核验前日发货)
const beforeCheckpoint = now.getHours() < VERIFY_HOUR;
verifyDay = beforeCheckpoint ? addDays(today, -1) : today;
}
// 护栏:不能晚于今天,也不能早于回看下限
if (verifyDay > today) verifyDay = today;
const earliest = addDays(today, -LOOKBACK_DAYS + 1);
if (verifyDay < earliest) verifyDay = earliest;
const shipDay = addDays(verifyDay, -1);
const verifyDate = formatDate(verifyDay);
const shipDate = formatDate(shipDay);
const deadline = `${verifyDate} ${pad2(VERIFY_HOUR)}:00:00`;
const nowText = `${formatDate(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
return {
verifyDate,
shipDate,
deadline,
windowStart: `${shipDate} 00:00:00`,
windowEnd: `${verifyDate} 00:00:00`,
label: `${verifyDate.slice(5)} 09:00 核验 · ${shipDate.slice(5)} 发货`,
started: nowText >= deadline,
isToday: verifyDate === formatDate(today),
lookback: verifyDate !== formatDate(today),
lookbackDays: Math.round((today - verifyDay) / 86400000),
};
}
module.exports = {
CENTERS,
EXCLUDED_WAREHOUSE_KEYWORDS,
EXCLUDED_CARRIERS,
CONDITIONAL_EXCLUDED_CARRIERS,
EXCLUDED_ORDER_STATUS_KEYWORDS,
EXCLUDED_ORDER_LABEL_KEYWORDS,
EXCLUDED_ORDER_TYPE_LABELS,
INTERCEPT_AFTERSALE_SOURCES,
EXCEPTION_TYPES,
TYPE_MAP,
TRANSIT_INFO_KEYWORDS,
VERIFY_HOUR,
LOOKBACK_DAYS,
resolveCenter,
matchExcludedCarrier,
matchVoidedOrder,
matchNonRealOrder,
resolveBatch,
beijingNow,
formatDate,
parseDate,
addDays,
pad2,
};