两件事,都是 2026-10-23 业务反馈直接引起的。
## 1. 转仓代发不再核对异常
业务反馈:崔桥工厂仓的单 351668937728 被判「未揽收」异常,快递却是「曹县韵达小件」,
而且这单在后台是信誉订单。
实查宽表:这单 `warehouse_name=简尚-崔桥工厂仓`,但 `order_labels` 里带一条
`转x00005` —— 聚水潭「转仓」操作留下的痕迹。记账仓还是崔桥,货却由目标仓
(x00005 = 曹县)实际发出,承运账号也跟着变成曹县韵达小件。结果就是原仓的
物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
判据:`order_labels` 命中 `/转\s*[xX]\s*\d{3,}/`(不写死单个编码,后续可能出现
x00006)。实测近 30 天 12 仓:`转x00005` 438 单,报未揽收类 46 单(已签收 371 /
无物流单 31 / 运输中 20 / 未揽收 15);快递分布 曹县韵达小件 133、邮政快递包裹POSTB 124。
口径要点:
- 与「转空包」(信誉单,发空包)严格区分 —— 转仓是**真发货**,只是不从记账仓发;
- 所以**保留发货分母**,只从异常清单摘掉(与「现场拦截」同类,不稀释异常率);
- 新增 `exclusions.transferPackages/transferOrders/transferReasons`,
前端页脚与钉钉正文各加一行说明。
## 2. 缓存键只按核验日(修导出卡顿)
原来键是 `${核验日}|${limit}`:页面传 1000、导出传 5000 → 两份缓存互不命中,
点一次导出就要把整轮重算一遍。可 `limit` 只影响最后 `slice(0, limit)`,
异常实际才几十行,扫库成本一模一样。
改为:缓存只存**全量明细**(键 = 核验日),limit 在出口 `finalizeDetail()` 里截断。
生产实测(修复前,容器内直调):页面冷启 189s、命中缓存 0ms、
**页面已缓存时点导出仍要 317s**;修复后导出复用页面结果 1~3ms。
## 验证
- 后端单测 50/50(新增:转仓标签识别 8 条断言、转仓单不进异常但进分母、
导出复用页面缓存不重查、小 limit 不污染缓存)
- 全量 `npm run check` 474 项 / 472 过,2 项为既有环境失败(Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、`npm run build` 通过(含 router lazy 静态检查)
- 真实库(10-23 批):异常 73 单,转仓剔除 1 单(正是 9894520),目标单已消失;
导出在页面加载后 3ms 返回 73 行
457 lines
24 KiB
JavaScript
457 lines
24 KiB
JavaScript
'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 指定:不用核对异常)──────────────────────────
|
||
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
||
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
||
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
||
// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
|
||
//
|
||
// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%);
|
||
// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。
|
||
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
||
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
||
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**,
|
||
// 只是不从记账仓发 —— 所以保留发货分母,只从异常清单摘掉(同「现场拦截」)。
|
||
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
||
|
||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 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} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||
* @returns {string|null} 命中的标签原文(如 `转x00005`),便于页脚直接展示;不命中返回 null
|
||
*/
|
||
function matchTransferWarehouse(orderLabels) {
|
||
const labels = String(orderLabels || '');
|
||
if (!labels) return null;
|
||
const hit = labels.match(TRANSFER_WAREHOUSE_LABEL_PATTERN);
|
||
if (!hit) return null;
|
||
return hit[0].replace(/\s+/g, '');
|
||
}
|
||
|
||
/**
|
||
* 解析「核验批次」。
|
||
*
|
||
* @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,
|
||
matchTransferWarehouse,
|
||
TRANSFER_WAREHOUSE_LABEL_PATTERN,
|
||
resolveBatch,
|
||
beijingNow,
|
||
formatDate,
|
||
parseDate,
|
||
addDays,
|
||
pad2,
|
||
};
|