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