From db1ba79416cfa750bf2210d78f9f3c30ab0c1bd8 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 22 Oct 2026 09:07:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E4=BB=93?= =?UTF-8?q?=E5=82=A8=E4=B8=AD=E5=BF=83=E7=89=A9=E6=B5=81=E8=BD=AC=E8=BF=90?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E3=80=8D=E9=A1=B5=E9=9D=A2=EF=BC=8812=20?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E4=BB=93=20=C2=B7=20=E6=AF=8F=E6=97=A5=2009:?= =?UTF-8?q?00=20=E6=A0=B8=E9=AA=8C=E5=89=8D=E4=B8=80=E6=97=A5=E5=B7=B2?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E7=89=A9=E6=B5=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 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 条残留 --- .../src/api/warehouseLogisticsException.js | 56 ++ backend/src/index.js | 3 + .../warehouseLogisticsExceptionRegistry.js | 382 +++++++++++++ .../warehouseLogisticsExceptionService.js | 438 +++++++++++++++ .../test/warehouseLogisticsException.test.js | 485 ++++++++++++++++ .../src/api/warehouseLogisticsException.js | 24 + frontend/src/config/sidebarNavigation.js | 1 + frontend/src/layouts/BasicLayout.jsx | 7 + .../WarehouseLogisticsException/index.jsx | 519 ++++++++++++++++++ .../WarehouseLogisticsException/style.css | 278 ++++++++++ frontend/src/router/index.jsx | 5 + 11 files changed, 2198 insertions(+) create mode 100644 backend/src/api/warehouseLogisticsException.js create mode 100644 backend/src/services/warehouseLogisticsExceptionRegistry.js create mode 100644 backend/src/services/warehouseLogisticsExceptionService.js create mode 100644 backend/test/warehouseLogisticsException.test.js create mode 100644 frontend/src/api/warehouseLogisticsException.js create mode 100644 frontend/src/pages/WarehouseLogisticsException/index.jsx create mode 100644 frontend/src/pages/WarehouseLogisticsException/style.css diff --git a/backend/src/api/warehouseLogisticsException.js b/backend/src/api/warehouseLogisticsException.js new file mode 100644 index 0000000..bc1671f --- /dev/null +++ b/backend/src/api/warehouseLogisticsException.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增) + * ──────────────────────────────────────────────────────────────────────────── + * GET /api/warehouse-logistics-exception/list + * query: + * date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天) + * limit 明细条数上限(默认 500,最大 2000) + * force 1 = 绕过 5 分钟结果缓存 + * + * 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware + */ + +const express = require('express'); +const { getLogisticsExceptions } = require('../services/warehouseLogisticsExceptionService'); +const { + CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR, +} = require('../services/warehouseLogisticsExceptionRegistry'); + +const router = express.Router(); + +function sendError(res, error, fallback) { + const status = Number(error?.statusCode) || 500; + console.error('[WarehouseLogisticsException]', { + message: error?.message || fallback, + code: error?.code || null, + status, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// 转运异常总览:12 个发货仓异常单量 + 明细 +router.get('/list', async (req, res) => { + try { + const data = await getLogisticsExceptions(req.query || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '加载物流转运异常失败'); + } +}); + +// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套 +router.get('/meta', (_req, res) => { + res.json({ + success: true, + data: { + verifyHour: VERIFY_HOUR, + lookbackDays: LOOKBACK_DAYS, + centers: CENTERS.map((c) => ({ key: c.key, name: c.name })), + exceptionTypes: EXCEPTION_TYPES, + }, + }); +}); + +module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index e5fa67d..b7c67f9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -36,6 +36,7 @@ const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness'); +const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException'); const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); @@ -675,6 +676,8 @@ async function startServer(options = {}) { cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); // 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发 cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes); + // 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录 + cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); diff --git a/backend/src/services/warehouseLogisticsExceptionRegistry.js b/backend/src/services/warehouseLogisticsExceptionRegistry.js new file mode 100644 index 0000000..bfa2d99 --- /dev/null +++ b/backend/src/services/warehouseLogisticsExceptionRegistry.js @@ -0,0 +1,382 @@ +'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 指定剔除。 +// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运` +// 是另外的账号,正常回传轨迹,不能连带剔掉。 +const EXCLUDED_CARRIERS = Object.freeze([ + // 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版): + // 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/ + // `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭 + // (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。 + // 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。 + // 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。 + '唯品顺丰', + '顺丰', + '武进安能', + '中铁', + '壹米滴答', + '自提', + '线下', + '商家配送', + '京喜京东', +]); + +// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 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: '信誉单' }, +]); + +// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走 +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, + EXCEPTION_TYPES, + TYPE_MAP, + TRANSIT_INFO_KEYWORDS, + VERIFY_HOUR, + LOOKBACK_DAYS, + resolveCenter, + matchExcludedCarrier, + matchVoidedOrder, + matchNonRealOrder, + resolveBatch, + beijingNow, + formatDate, + parseDate, + addDays, + pad2, +}; diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js new file mode 100644 index 0000000..f15272c --- /dev/null +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -0,0 +1,438 @@ +'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, +}; diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js new file mode 100644 index 0000000..a993aff --- /dev/null +++ b/backend/test/warehouseLogisticsException.test.js @@ -0,0 +1,485 @@ +/** + * 仓储中心物流转运异常 —— 口径回归测试(2026-10-21) + * + * 锁定几条最容易翻车的语义: + * ① 12 个发货仓的关键词归属:「山东青岗集仓唯品仓」不能被「山东青岗集仓」抢走; + * 品牌仓不能被主仓抢走;「京仓-横林二仓京东云仓」业务排除; + * 山东/河南的代发仓不能误并入。 + * ② 核验批次:09:00 核验**前一日**发货的单;未到 09:00 时默认看昨天那批, + * 不能拿今天的空批次冒充结论。 + * ③ 异常判定:已签收正常;未揽收/无物流单/异常/拒收/其他 → 异常; + * 运输中类只在「最后轨迹距核验时刻 ≥ 24h」才算运输停滞。 + * ④ 一单多包裹:明细按包裹保留,汇总按**内部单号**去重(异常率不能虚高)。 + * ⑤ 缓存:第二次命中缓存,force=1 必须绕过。 + * + * ⚠️ 本文件不连数据库,纯逻辑 + 打桩。 + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const registry = require('../src/services/warehouseLogisticsExceptionRegistry'); + +// ── ① 仓库归属 ────────────────────────────────────────────────────────── +test('12 个发货仓都能按关键词归位', () => { + const cases = [ + ['简尚-横林二仓', 'HL2'], + ['简尚-横林三仓', 'HL3'], + ['简尚-横林二仓品牌仓', 'HL2_BRAND'], + ['简尚-崔桥工厂仓', 'CQ'], + ['简尚-崔桥工厂品牌仓', 'CQ_BRAND'], + ['简尚-崔桥工厂半成品包装项目仓', 'CQ_WIP'], + ['简尚-新苏北仓', 'XSB'], + ['简尚-睢宁仓', 'SN'], + ['简尚-山东青岗集仓', 'SD_QGJ'], + ['简尚-山东品牌仓', 'SD_BRAND'], + ['简尚-山东青岗集仓唯品仓', 'SD_QGJ_VIP'], + ['简尚-河南洛宁仓', 'HN_LN'], + ]; + assert.equal(registry.CENTERS.length, 12); + for (const [name, key] of cases) { + assert.equal(registry.resolveCenter(name)?.key, key, name); + } +}); + +test('唯品仓 / 品牌仓不会被主仓的关键词抢走(最长匹配)', () => { + assert.notEqual( + registry.resolveCenter('简尚-山东青岗集仓唯品仓').key, + registry.resolveCenter('简尚-山东青岗集仓').key, + '唯品仓必须独立,不能并进青岗集仓' + ); + assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓').key, 'CQ_WIP'); +}); + +test('子仓能并入主仓,无关代发仓 / 京东云仓被排除', () => { + assert.equal(registry.resolveCenter('简尚-运营C横林二仓专用仓')?.key, 'HL2'); + assert.equal(registry.resolveCenter('简尚-分销部睢宁专用仓')?.key, 'SN'); + assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ'); + assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null, '业务指定排除'); + for (const name of ['工厂-山东菏泽森美代发仓', '工厂-河南千优代发仓', '简尚-苏州福利品仓']) { + assert.equal(registry.resolveCenter(name), null, `${name} 不该并入任何主仓`); + } +}); + +// ── ② 核验批次 ────────────────────────────────────────────────────────── +const at = (d, h, m = 0) => new Date(2026, 9, d, h, m, 0); // 2026-10-xx 本机时区 + +test('已过 09:00 → 核验今天这批(发货日是昨天)', () => { + const b = registry.resolveBatch(undefined, at(21, 9, 30)); + assert.equal(b.verifyDate, '2026-10-21'); + assert.equal(b.shipDate, '2026-10-20'); + assert.equal(b.deadline, '2026-10-21 09:00:00'); + assert.equal(b.windowStart, '2026-10-20 00:00:00'); + assert.equal(b.windowEnd, '2026-10-21 00:00:00'); + assert.equal(b.started, true); + assert.equal(b.isToday, true); +}); + +test('未到 09:00 → 今天这批还没跑,默认退回昨天那批', () => { + const b = registry.resolveBatch(undefined, at(21, 8, 30)); + assert.equal(b.verifyDate, '2026-10-20'); + assert.equal(b.shipDate, '2026-10-19'); + assert.equal(b.started, true, '昨天的批次早就到点了'); + assert.equal(b.lookback, true); +}); + +test('显式指定今天但还没到 09:00 → notStarted,不能给结论', async () => { + const b = registry.resolveBatch('2026-10-21', at(21, 8, 0)); + assert.equal(b.started, false); +}); + +test('批次护栏:未来日期压到今天,超过 30 天压到下限', () => { + const future = registry.resolveBatch('2026-12-01', at(21, 10, 0)); + assert.equal(future.verifyDate, '2026-10-21'); + const tooOld = registry.resolveBatch('2026-01-01', at(21, 10, 0)); + assert.equal(tooOld.verifyDate, '2026-09-22', `回看下限应为今天-29天,实际 ${tooOld.verifyDate}`); +}); + +// ── ③ 异常判定 ────────────────────────────────────────────────────────── +const batch = registry.resolveBatch('2026-10-21', at(21, 10, 0)); +const svcPath = require.resolve('../src/services/warehouseLogisticsExceptionService'); +const { classify } = require(svcPath); + +test('已签收 = 正常;未揽收/无物流单/异常/拒收/其他 = 异常', () => { + const row = (o) => ({ logistic_id: 'L', logistic_status_name: '', logistic_time: null, ...o }); + assert.equal(classify(row({ logistic_status_name: '已签收' }), batch), null); + assert.equal(classify(row({ logistic_status_name: '未揽收' }), batch), 'NOT_PICKED'); + assert.equal(classify(row({ logistic_status_name: '无物流单' }), batch), 'NO_TRACKING'); + assert.equal(classify(row({ logistic_status_name: '异常' }), batch), 'CARRIER_EXCEPTION'); + assert.equal(classify(row({ logistic_status_name: '拒收' }), batch), 'RETURNED'); + // 「其他」实测全是「包裹已换单退回」 + assert.equal(classify(row({ logistic_status_name: '其他' }), batch), 'RETURNED'); +}); + +test('运输中 / 派送中一律不考核(有「运输」字样,往后中转记录不看)', () => { + // 2026-10-21 二次定版:业务明确「有『运输』字样的,往后中转记录不考核」 + assert.equal(classify({ + logistic_id: 'L', logistic_status_name: '运输中', logistic_time: '2026-10-20 08:00:00', + }), null, '运输中哪怕 25h 没新轨迹也不算异常'); + assert.equal(classify({ + logistic_id: 'L', logistic_status_name: '派送中', logistic_time: '2026-10-20 08:00:00', + }), null); +}); + +test('已揽收:没有下一步中转才算异常,轨迹里出现运输/中转字样就算动起来了', () => { + const picked = (info) => classify({ + logistic_id: 'L', logistic_status_name: '已揽收', logistic_time: '2026-10-21 04:00:00', + last_info: info, + }); + // 只有「已揽收/已收取快件/库存中」,没有任何中转 → 卡在网点 + assert.equal(picked('京东快递 已收取快件'), 'PICKED_NO_TRANSIT'); + assert.equal(picked('中通快运【简尚家居】已揽收'), 'PICKED_NO_TRANSIT'); + assert.equal(picked(''), 'PICKED_NO_TRANSIT'); + // 轨迹里已经有运输/中转/发往 → 有下一步中转记录,不考核 + assert.equal(picked('快件已发往【常州中转部】'), null); + assert.equal(picked('运输中,预计明日送达'), null); +}); + +test('状态为空时按运单号区分:有号=未揽收,无号=无物流单', () => { + assert.equal(classify({ logistic_id: 'L1', logistic_status_name: '' }, batch), 'NOT_PICKED'); + assert.equal(classify({ logistic_id: '', logistic_status_name: '' }, batch), 'NO_TRACKING'); +}); + +// ── ④ 服务层(打桩数据库)────────────────────────────────────────────── +const dbPath = require.resolve('../src/config/cloudDatabase'); + +function stubDb(rows) { + const calls = { times: 0, sql: [], params: [] }; + const handler = async (sql, params) => { + calls.times += 1; + calls.sql.push(String(sql)); + calls.params.push(params || {}); + return rows; + }; + require.cache[dbPath] = { + id: dbPath, + filename: dbPath, + loaded: true, + exports: { query: handler, querySlow: handler }, + }; + return calls; +} + +const mk = (o) => ({ + so_id: 'SO', inner_order_id: 1, shop_name: '测试店', warehouse_name: '简尚-崔桥工厂仓', + logistic_id: 'L1', logistic_company_name: '中通', logistic_status_name: '已签收', + send_time: '2026-10-20 10:00:00', logistic_time: '2026-10-20 18:00:00', + query_time: '2026-10-21 09:05:00', last_info: '', ...o, +}); + +const rows = [ + // 1:一单两包裹 —— 一个未揽收、一个已签收 → 该单算异常,但只占 1 个「单」 + mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1A', logistic_status_name: '未揽收', logistic_time: null }), + mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1B', logistic_status_name: '已签收' }), + // 2:山东青岗集仓唯品仓 · 异常 + mk({ inner_order_id: 2, so_id: 'SO-2', warehouse_name: '简尚-山东青岗集仓唯品仓', + logistic_id: 'L2', logistic_status_name: '异常', logistic_time: '2026-10-20 20:00:00', + send_time: '2026-10-20 12:00:00' }), + // 3:横林二仓品牌仓 · 拒收 + mk({ inner_order_id: 3, so_id: 'SO-3', warehouse_name: '简尚-横林二仓品牌仓', + logistic_id: 'L3', logistic_status_name: '拒收', logistic_time: '2026-10-21 07:00:00', + send_time: '2026-10-20 15:00:00' }), + // 4:京东云仓 —— 业务排除,不进任何主仓 + mk({ inner_order_id: 4, so_id: 'SO-4', warehouse_name: '京仓-横林二仓京东云仓', + logistic_id: 'L4', logistic_status_name: '未揽收', logistic_time: null }), + // 5:代发仓 —— 不归属任何主仓 + mk({ inner_order_id: 5, so_id: 'SO-5', warehouse_name: '工厂-山东菏泽森美代发仓', + logistic_id: 'L5', logistic_status_name: '未揽收', logistic_time: null }), + // 6:新苏北仓 · 已揽收后没有任何中转记录(最后轨迹 10-20 02:00)→ 已揽收未中转 + mk({ inner_order_id: 6, so_id: 'SO-6', warehouse_name: '简尚-新苏北仓', + logistic_id: 'L6', logistic_status_name: '已揽收', logistic_time: '2026-10-20 02:00:00', + send_time: '2026-10-20 01:00:00', last_info: '京东快递 已收取快件' }), + // 7:睢宁仓 · 运输中 → 已动起来,不考核 + mk({ inner_order_id: 7, so_id: 'SO-7', warehouse_name: '简尚-睢宁仓', + logistic_id: 'L7', logistic_status_name: '运输中', logistic_time: '2026-10-21 04:00:00', + send_time: '2026-10-20 09:00:00' }), + // 8:山东青岗集仓 · 无物流单 + mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓', + logistic_id: '', logistic_status_name: '无物流单', logistic_time: null, + send_time: '2026-10-20 11:00:00' }), + // 9:横林三仓 · 已签收 → 正常 + mk({ inner_order_id: 9, so_id: 'SO-9', warehouse_name: '简尚-横林三仓' }), + // 10:完全重复行(同单同运单号)→ 必须去重 + mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓', + logistic_id: '', logistic_status_name: '无物流单', logistic_time: null, + send_time: '2026-10-20 11:00:00' }), +]; + +async function withStub(fn, stubRows = rows) { + const realDb = require.cache[dbPath]; + const calls = stubDb(stubRows); + delete require.cache[svcPath]; + delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; + try { + const svc = require(svcPath); + svc.clearLogisticsExceptionCache(); + return await fn(svc, calls); + } finally { + if (realDb) require.cache[dbPath] = realDb; + else delete require.cache[dbPath]; + delete require.cache[svcPath]; + delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; + } +} + +test('汇总按内部单号去重,明细按包裹保留;排除仓/代发仓不进统计', async () => { + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + + // 归属主仓的单:1,2,3,6,7,8,9 = 7 单;包裹 8 个(1 号单两包) + assert.equal(data.summary.shippedOrders, 7); + assert.equal(data.summary.packageCount, 8, '重复行要去重'); + // 异常单:1(未揽收) 2(异常) 3(拒收) 6(已揽收未中转) 8(无物流单) = 5 + assert.equal(data.summary.exceptionOrders, 5); + assert.equal(data.summary.exceptionPackages, 5); + assert.equal(data.summary.centerCount, 5); + assert.equal(data.summary.exceptionRate, 71.4); + + const typeCount = data.summary.byType; + assert.equal(typeCount.NOT_PICKED, 1); + assert.equal(typeCount.CARRIER_EXCEPTION, 1); + assert.equal(typeCount.RETURNED, 1); + assert.equal(typeCount.PICKED_NO_TRANSIT, 1); + assert.equal(typeCount.NO_TRACKING, 1); + + // 唯品仓独立成仓,不并进青岗集仓 + const vip = data.centers.find((c) => c.key === 'SD_QGJ_VIP'); + assert.equal(vip.shippedOrders, 1); + assert.equal(vip.exceptionOrders, 1); + const qgj = data.centers.find((c) => c.key === 'SD_QGJ'); + assert.equal(qgj.exceptionOrders, 1, '青岗集仓自己只有 1 单异常(无物流单那单)'); + + // 排除仓 / 代发仓不该出现在任何仓的统计里 + assert.equal(data.centers.reduce((s, c) => s + c.shippedOrders, 0), 7); + + // 明细按「发货→核验」等得最久的排前面(6 号单 10-20 01:00 发货,到 09:00 已 32 小时) + assert.equal(data.orders[0].oId, '6', `首行应为最久未动的单,实际 ${data.orders[0].oId}`); + assert.equal(data.orders[0].pendingHours, 32); + assert.equal(data.orders[0].staleHours, 31, '最后轨迹 10-20 02:00 → 距核验 31 小时'); + + // 数据源护栏:只查一张表、带发货日窗口 + assert.equal(data.freshness.coversDeadline, true); + assert.equal(data.freshness.snapshotAt, '2026-10-21 09:05:00'); + }); +}); + +test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async () => { + await withStub(async (svc, calls) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); + assert.equal(data.notStarted, true); + assert.equal(data.summary.exceptionOrders, 0); + assert.equal(calls.times, 0, '未到点不该打库'); + assert.equal(data.centers.length, 12); + }); +}); + +test('结果缓存生效,force=1 绕过缓存重查', async () => { + await withStub(async (svc, calls) => { + const opts = { date: '2026-10-21', now: at(21, 10, 0) }; + const first = await svc.getLogisticsExceptions(opts); + assert.equal(first.cached, false); + assert.equal(calls.times, 1); + + const second = await svc.getLogisticsExceptions(opts); + assert.equal(second.cached, true); + assert.equal(calls.times, 1, '第二次应命中缓存'); + + const third = await svc.getLogisticsExceptions({ ...opts, force: 1 }); + assert.equal(third.cached, false); + assert.equal(calls.times, 2, 'force=1 必须真查库'); + }); +}); + +test('明细 limit 生效并给出截断标记', async () => { + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 2 }); + assert.equal(data.orders.length, 2); + assert.equal(data.truncated, true); + assert.equal(data.detailLimit, 2); + }); +}); + +test('快照没覆盖核验时刻时给出红色告警文案', async () => { + const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' })); + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0) }); + assert.equal(data.freshness.coversDeadline, false); + assert.match(data.freshness.hint, /没覆盖/u); + }, staleRows); +}); + +// ── ⑥ 剔除口径(业务 2026-10-21 指定)─────────────────────────────────── +test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米滴答/自提/线下/商家配送/京喜京东', () => { + const hit = (name) => registry.matchExcludedCarrier(name); + for (const name of [ + '唯品顺丰', + '武进安能', + '武进安能大件', + '中铁物流', + '壹米滴答专线', + '自提', + '乐鑫达线下韵达配件', + '商家配送', + '京喜京东快递', + '京喜京东快运', + // 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏 + '顺丰速运', + '顺丰标快', + '洛宁顺丰干配', + '山东顺丰干配', + '苏北顺丰干配大件', + '苏北顺丰干配小件', + '山东顺丰大件', + '景瓴十方顺丰大件-莒南', + ]) { + assert.ok(hit(name), `${name} 应剔除,实际未命中`); + } + // 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除 + assert.equal(hit('唯品顺丰'), '唯品顺丰'); + assert.equal(hit('山东顺丰大件'), '顺丰'); + // 别的京东账号轨迹正常回传,不能连带剔 + assert.equal(hit('京东快递'), null); + assert.equal(hit('京东自营快递亿家达'), null); + assert.equal(hit('崔桥京东特惠'), null); + assert.equal(hit('曹县京东快运'), null); + // 非顺丰的常规承运商不能误剔 + assert.equal(hit('常州中通快运'), null); + assert.equal(hit('常州德邦'), null); + assert.equal(hit('百世快运'), null); +}); + +test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => { + const hit = (name, centerKey) => registry.matchExcludedCarrier(name, centerKey); + // 睢宁仓自己用 → 正常 + assert.equal(hit('睢宁中通', 'SN'), null); + // 异地打单(实测山东品牌仓 137 单未揽收、河南洛宁 20 单、崔桥工厂 6 单)→ 剔 + assert.ok(hit('睢宁中通', 'SD_BRAND')); + assert.ok(hit('睢宁中通', 'HN_LN')); + assert.ok(hit('睢宁中通', 'CQ')); + assert.ok(hit('睢宁中通', 'HL2')); + // 没给仓(保守)→ 不剔,宁可少剔也不能误剔睢宁仓 + assert.equal(hit('睢宁中通'), null); + // 别把「常州中通快运」等其它中通账号连带剔掉 + assert.equal(hit('常州中通快运', 'SD_BRAND'), null); +}); + +test('🔥 「武进安能」不能泛化成「安能」:常州安能新要正常考核', () => { + // 12 仓实测「常州安能新」3383 单/30 天,泛化「安能」会把整片正常单误剔 + assert.equal(registry.matchExcludedCarrier('常州安能新'), null); + assert.equal(registry.matchExcludedCarrier('雅柏安能'), null); + assert.equal(registry.matchExcludedCarrier('常州中通快运'), null); + assert.equal(registry.matchExcludedCarrier(''), null); + assert.equal(registry.matchExcludedCarrier(null), null); +}); + +test('已取消 / 作废 / 拦截的订单剔除', () => { + const hit = (status, labels) => registry.matchVoidedOrder(status, labels); + assert.ok(hit('已取消', ''), '订单状态已取消要剔'); + assert.ok(hit('已作废', '')); + assert.ok(hit('已发货', '["发货后取消","发货后售后"]'), '发货后取消要剔'); + assert.ok(hit('已发货', '["留言备注拦截","禁止拆分"]'), '留言备注拦截要剔'); + assert.ok(hit('已发货', '["作废"]')); + // 正常单不能被误剔 + assert.equal(hit('已发货', '["店铺-拼多多","已成团"]'), null); + assert.equal(hit('已发货', ''), null); + // 「取消预售」是营销标签,不是取消,不能剔 + assert.equal(hit('已发货', '["取消预售"]'), null); +}); + +test('剔除的单既不算异常,也不进发货分母', async () => { + const mixed = [ + // 10:唯品顺丰 · 未揽收 → 本来会误报,必须整单消失 + mk({ inner_order_id: 10, so_id: 'SO-10', logistic_company_name: '唯品顺丰', + logistic_status_name: '未揽收', logistic_time: null }), + // 11:中铁物流 · 异常 + mk({ inner_order_id: 11, so_id: 'SO-11', logistic_company_name: '中铁物流', + logistic_status_name: '异常' }), + // 12:武进安能 · 已签收 → 连带分母也要消失 + mk({ inner_order_id: 12, so_id: 'SO-12', logistic_company_name: '武进安能' }), + // 13:常州安能新 · 未揽收 → 必须留下(反例) + mk({ inner_order_id: 13, so_id: 'SO-13', logistic_company_name: '常州安能新', + logistic_status_name: '未揽收', logistic_time: null }), + // 14:正常承运商但订单已取消 → 剔 + mk({ inner_order_id: 14, so_id: 'SO-14', logistic_company_name: '常州中通快运', + logistic_status_name: '无物流单', logistic_time: null, + order_status_name: '已取消' }), + // 15:正常承运商但发货后取消 → 剔 + mk({ inner_order_id: 15, so_id: 'SO-15', logistic_company_name: '常州中通快运', + logistic_status_name: '未揽收', logistic_time: null, + order_labels: '["发货后取消","发货后售后"]' }), + // 16:正常单留作对照 + mk({ inner_order_id: 16, so_id: 'SO-16', logistic_company_name: '常州中通快运' }), + // 17:京喜京东快递 · 已揽收未中转 → 京喜自营轨迹不回传,剔 + mk({ inner_order_id: 17, so_id: 'SO-17', warehouse_name: '简尚-山东青岗集仓', + logistic_company_name: '京喜京东快递', logistic_status_name: '已揽收', logistic_time: '2026-10-20 20:00:00', + last_info: '快件已从【菏泽水岸城邦站】揽收完成' }), + // 18:睢宁中通在**非睢宁仓**(山东品牌仓)未揽收 → 异地打单,剔 + mk({ inner_order_id: 18, so_id: 'SO-18', warehouse_name: '简尚-山东品牌仓', + logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }), + // 19:睢宁中通在**睢宁仓**未揽收 → 本仓本账号,照常考核(反例,必须留) + mk({ inner_order_id: 19, so_id: 'SO-19', warehouse_name: '简尚-睢宁仓', + logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }), + ]; + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + + // 只剩 13、16、19 三单(10/11/12 承运商剔除,14/15 作废剔除,17 京喜、18 异地睢宁中通剔除) + assert.equal(data.summary.shippedOrders, 3, `分母应只剩 3 单,实际 ${data.summary.shippedOrders}`); + assert.equal(data.summary.exceptionOrders, 2, '只剩 13、19 两单异常'); + assert.equal(data.summary.exceptionRate, 66.7); + assert.deepEqual(data.orders.map((o) => o.oId).sort(), ['13', '19']); + + // 剔除量要如实返回,页面能展示 + assert.equal(data.exclusions.carrierOrders, 5, '唯品顺丰/中铁/武进安能/京喜京东/异地睢宁中通 5 单'); + assert.equal(data.exclusions.voidedOrders, 2, '已取消 + 发货后取消 2 单'); + assert.equal(data.exclusions.carrierPackages, 5); + assert.equal(data.exclusions.voidedPackages, 2); + assert.ok(data.exclusions.carrierReasons['睢宁中通(非睢宁仓异地揽收)']); + assert.ok(data.exclusions.voidedReasons['订单已取消']); + assert.ok(data.exclusions.voidedReasons['标签含「发货后取消」']); + }, mixed); +}); + +test('刷单 / 信誉单剔除(按订单标签,不按「特殊单」)', () => { + assert.equal(registry.matchNonRealOrder('["刷单待处理","特殊单","店铺-拼多多"]'), '刷单'); + assert.equal(registry.matchNonRealOrder('["特殊单","禁止拆分","转空包发货"]'), '信誉单(转空包)'); + // 反例:「特殊单」本身混着补差价/配件单,不能当判据 + assert.equal(registry.matchNonRealOrder('["特殊单","补差价"]'), null); + assert.equal(registry.matchNonRealOrder('["店铺-京东","晚发赔"]'), null); + assert.equal(registry.matchNonRealOrder(''), null); + assert.equal(registry.matchNonRealOrder(null), null); +}); + +test('刷单的包裹不进分母也不算异常', async () => { + const mixed = [ + // 20:刷单 · 未揽收 → 假异常,必须消失 + mk({ inner_order_id: 20, so_id: 'SO-20', logistic_status_name: '未揽收', logistic_time: null, + order_labels: '["刷单待处理","特殊单","店铺-拼多多"]' }), + // 21:信誉单(转空包)· 无物流单 → 消失 + mk({ inner_order_id: 21, so_id: 'SO-21', logistic_status_name: '无物流单', logistic_time: null, + order_labels: '["特殊单","禁止拆分","转空包发货"]' }), + // 22:带「特殊单」但不是刷单/信誉单 → 必须留下 + mk({ inner_order_id: 22, so_id: 'SO-22', logistic_status_name: '未揽收', logistic_time: null, + order_labels: '["特殊单","补差价"]' }), + ]; + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + assert.equal(data.summary.shippedOrders, 1, '只剩 22 号单'); + assert.deepEqual(data.orders.map((o) => o.oId), ['22']); + assert.equal(data.exclusions.nonRealOrders, 2); + assert.equal(data.exclusions.nonRealReasons['刷单'], 1); + assert.equal(data.exclusions.nonRealReasons['信誉单(转空包)'], 1); + assert.deepEqual(data.exclusions.orderTypeLabels, ['刷单', '信誉单']); + }, mixed); +}); + +test('未到 09:00 也返回剔除口径,前端不用判空', async () => { + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); + assert.equal(data.notStarted, true); + assert.ok(Array.isArray(data.exclusions.carriers)); + assert.ok(data.exclusions.carriers.includes('唯品顺丰')); + assert.equal(data.exclusions.carrierOrders, 0); + }); +}); diff --git a/frontend/src/api/warehouseLogisticsException.js b/frontend/src/api/warehouseLogisticsException.js new file mode 100644 index 0000000..47b8f0d --- /dev/null +++ b/frontend/src/api/warehouseLogisticsException.js @@ -0,0 +1,24 @@ +import request from './request'; + +// 转运异常要扫一遍「昨日发货」的物流宽表(rpt 下全是视图),冷启动实测 20 秒左右, +// 超时放大到 3 分钟,避免前端先断开、后端还在跑。 +const TIMEOUT_MS = 3 * 60 * 1000; + +// 仓储中心物流转运异常:12 个发货仓的异常单量 + 明细 +// params.date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天) +// params.limit 明细条数上限 +// params.force 1 = 绕过 5 分钟结果缓存 +export const getWarehouseLogisticsExceptions = (params = {}) => request.get( + '/api/warehouse-logistics-exception/list', + { + params, + timeout: TIMEOUT_MS, + headers: { 'Cache-Control': 'no-cache' }, + } +); + +// 口径元数据(12 个仓、异常类型、回看天数),不打库,秒回 +export const getWarehouseLogisticsExceptionMeta = () => request.get( + '/api/warehouse-logistics-exception/meta', + { timeout: 30000 } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index eede766..0fd54f9 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -37,6 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { ], }, { key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 }, + { key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 }, { key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, children: [ diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index dd90ce7..26960f6 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -23,6 +23,7 @@ import { TeamOutlined, UserOutlined, SunOutlined, + WarningOutlined, } from '@ant-design/icons'; import { Dropdown, Switch } from 'antd'; import { verifyToken } from '../api/auth'; @@ -70,6 +71,7 @@ const SIDEBAR_ICON_MAP = { shopping: ShoppingOutlined, table: TableOutlined, team: TeamOutlined, + warning: WarningOutlined, }; const SIDEBAR_ROUTE_MAP = { @@ -124,6 +126,11 @@ const SIDEBAR_ROUTE_MAP = { activePath: '/planning/operation/warehouse-ship-timeliness', matchMode: 'prefix', }, + 'warehouse-logistics-exception': { + path: '/planning/operation/warehouse-logistics-exception', + activePath: '/planning/operation/warehouse-logistics-exception', + matchMode: 'prefix', + }, 'daily-inventory': { path: '/planning/operation/daily-inventory' }, 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, 'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' }, diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx new file mode 100644 index 0000000..59a05ec --- /dev/null +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -0,0 +1,519 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, DatePicker, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, +} from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { SycmPage } from '../../components/Common'; +import { getWarehouseLogisticsExceptions } from '../../api/warehouseLogisticsException'; +import './style.css'; + +const { Title, Text } = Typography; + +// 核验点每天只有 09:00 一个,10 分钟刷一次足够;接口冷启动要 20 秒,别跟着秒级轮询跑 +const AUTO_REFRESH_MS = 10 * 60 * 1000; +// 回看窗口(与后端 LOOKBACK_DAYS 对齐:今天是第 1 天,往前 29 天) +const LOOKBACK_DAYS = 30; + +const number = (value, digits = 0) => ( + value === null || value === undefined || value === '' + ? '0' + : Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }) +); + +// 小时数 → 「32 小时」/「1 天 8 小时」 +const formatHours = (hours) => { + const h = Number(hours); + if (!Number.isFinite(h)) return '—'; + if (h < 0) return '核验后'; + if (h < 1) return '不到 1 小时'; + if (h < 24) return `${Math.round(h)} 小时`; + const d = Math.floor(h / 24); + const rest = Math.round(h % 24); + return rest ? `${d} 天 ${rest} 小时` : `${d} 天`; +}; + +const DETAIL_COLUMNS = [ + { + title: '发货仓', + dataIndex: 'warehouseName', + width: 190, + ellipsis: true, + render: (v) => v || '—', + }, + { title: '内部单号', dataIndex: 'oId', width: 96 }, + { + title: '线上订单号', + dataIndex: 'soId', + width: 178, + ellipsis: true, + render: (v) => (v ? {v} : '—'), + }, + { title: '店铺', dataIndex: 'shopName', width: 150, ellipsis: true, render: (v) => v || '—' }, + { title: '快递公司', dataIndex: 'carrier', width: 130, ellipsis: true, render: (v) => v || '—' }, + { + title: ( + + 运单号 + + ), + dataIndex: 'trackingNo', + width: 150, + ellipsis: true, + render: (v) => (v ? v : 无运单号), + }, + { + title: '物流状态', + dataIndex: 'logisticStatus', + width: 100, + render: (v) => (v ? {v} : 无状态), + }, + { + title: ( + + 异常类型 + + ), + dataIndex: 'exceptionLabel', + width: 116, + render: (v, row) => ( + + {v} + + ), + }, + { title: '发货时间', dataIndex: 'sendTime', width: 152, ellipsis: true }, + { + title: ( + + 最后物流时间 + + ), + dataIndex: 'lastLogisticTime', + width: 152, + ellipsis: true, + render: (v) => v || '—', + }, + { + title: ( + + 发货后待处理 + + ), + dataIndex: 'pendingHours', + width: 118, + render: (v) => {formatHours(v)}, + }, + { + title: '最后物流描述', + dataIndex: 'lastLogisticInfo', + width: 280, + ellipsis: true, + render: (v) => (v ? ( + + {v} + + ) : '—'), + }, +]; + +/** + * 仓储中心物流转运异常(独立页面) + * ──────────────────────────────────────────────────────────────── + * 业务口径(yanhui 2026-10-21,二次收敛): + * · 12 个发货仓,子仓并入主仓一起算; + * · **每天 09:00 核验前一日已发货订单的物流记录** —— 到 9 点还卡在 + * 「未揽收」或「已揽收但没有下一步中转」就是转运异常,按仓归口; + * · 运输中 / 派送中 / 已签收 = 正常,不进列表;不再按「24h 无新轨迹」判停滞; + * · 刷单、信誉单、已取消/作废/拦截、指定承运商不参与考核; + * · 可回看近 30 天,但历史批次是按**当前**物流快照重算的(页面会提示)。 + * + * 数据来自 /api/warehouse-logistics-exception/list(service 层见 + * backend/src/services/warehouseLogisticsExceptionService.js)。 + */ +function WarehouseLogisticsException() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + // 选中的仓:为空时展示全部仓 + const [activeCenter, setActiveCenter] = useState(''); + // 异常类型筛选:为空 = 全部 + const [activeType, setActiveType] = useState(''); + // 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批) + const [verifyDate, setVerifyDate] = useState(null); + + const load = useCallback(async (force = false) => { + setLoading(true); + setError(''); + try { + const result = await getWarehouseLogisticsExceptions({ + limit: 1000, + force: force ? 1 : 0, + date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined, + }); + if (!result || result.success === false) { + setError(result?.error || '加载物流转运异常失败'); + return; + } + setData(result.data || null); + } catch (loadError) { + setError(loadError?.response?.data?.error || loadError?.message || '加载物流转运异常失败'); + } finally { + setLoading(false); + } + }, [verifyDate]); + + useEffect(() => { + load(); + const timer = window.setInterval(() => load(), AUTO_REFRESH_MS); + return () => window.clearInterval(timer); + }, [load]); + + const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]); + const allOrders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]); + const types = useMemo(() => (Array.isArray(data?.types) ? data.types : []), [data]); + const summary = data?.summary || {}; + const batch = data?.batch || null; + const freshness = data?.freshness || null; + // 剔除口径 + 本批实际剔除量(后端给什么就展示什么,页面不硬编码名单) + const exclusions = data?.exclusions || null; + const notStarted = Boolean(data?.notStarted); + + const activeCenterName = useMemo(() => { + if (!activeCenter) return ''; + const hit = centers.find((c) => c.key === activeCenter); + return hit ? hit.name : activeCenter; + }, [activeCenter, centers]); + + const detailOrders = useMemo(() => allOrders.filter((o) => ( + (!activeCenter || o.centerKey === activeCenter) + && (!activeType || o.exceptionType === activeType) + )), [activeCenter, activeType, allOrders]); + + const disabledDate = (current) => ( + current && (current > dayjs().endOf('day') + || current < dayjs().startOf('day').subtract(LOOKBACK_DAYS - 1, 'day')) + ); + + return ( + +
+ 仓储中心物流转运异常 + + 每天 09:00 核验 12 个发货仓「前一日已发货」订单的物流记录,只看异常,正常签收的不列。 + 核验批次 = 核验日 09:00 + 前一天发货的单。 + 到核验时刻物流还卡在揽收前就算转运异常,分五类: + 未揽收(发了货快递没来揽)、已揽收未中转(揽了但没下一步中转,卡在网点)、 + 无物流单(连运单号都没建)、快递报异常(如寄件人取消寄件、异常签收)、 + 退回(拒收 / 换单退回)。 + 运输中、派送中、已签收 = 正常,不进列表 —— 有「运输」字样之后的中转记录不回传,不作考核。 + 一单多包裹按包裹列明细、按内部单号统计,异常率不会被多包裹单放大。 + 不参与考核:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、壹米滴答、 + 京喜京东(快递·快运)、自提、线下、商家配送 + (轨迹不回传或本身无快递轨迹); + 睢宁中通 只在睢宁仓考核,其它仓异地打单的剔出; + 已取消 / 作废 / 拦截的订单(单据已终止)、刷单 / 信誉单(本身不是真实发货)。 + 历史批次是按当前物流快照回算的 —— 昨天是异常、今天已签收的单会显示成正常, + 要留档结论请以当天 09:00 看到的为准。 + +
+ + +
+
+ {notStarted ? ( + <> + 未到核验时间 + + {String(batch?.deadline || '').slice(5, 16)} 才开始核验 + + + ) : ( + <> + {batch?.label || '—'} + + 核验 {batch?.shipDate || '—'} 已发货订单的物流记录 + + {batch?.lookback ? ( + 历史批次 · 按当前快照回算 + ) : null} + + )} +
+ + setVerifyDate(value ? value.startOf('day') : null)} + style={{ width: 148 }} + placeholder="核验日" + /> + {data?.generatedAt ? ( + + 数据更新于 {String(data.generatedAt).slice(11, 19)} + {data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s)` : ''} + + ) : null} + + +
+
+ + {error ? ( + load(true)}>重试} + /> + ) : null} + + {freshness && (freshness.coversDeadline === false || freshness.stale) ? ( + + ) : null} + +
+ + + 前一日发货 + + )} + value={notStarted ? 0 : (summary.shippedOrders ?? 0)} + suffix="单" + formatter={(v) => (notStarted ? '—' : number(v))} + /> + + 共 {notStarted ? '—' : number(summary.packageCount)} 个包裹 + + + + + 转运异常 + + )} + value={notStarted ? 0 : (summary.exceptionOrders ?? 0)} + suffix="单" + formatter={(v) => (notStarted ? '—' : number(v))} + valueStyle={{ color: (summary.exceptionOrders || 0) > 0 ? '#cf1322' : undefined }} + /> + + 涉及 {notStarted ? '—' : number(summary.exceptionPackages)} 个包裹 + + + + (notStarted ? '—' : number(v, 1))} + valueStyle={{ color: (summary.exceptionRate || 0) >= 10 ? '#d46b08' : undefined }} + /> + + 异常单 ÷ 前一日发货单 + + + + (notStarted ? '—' : number(v))} + /> + + 有转运异常的仓 + + +
+ + {types.length ? ( +
+ {types.map((t) => { + const count = summary.byType?.[t.key] || 0; + const isActive = activeType === t.key; + return ( + + + + ); + })} + {activeType ? ( + + ) : null} +
+ ) : null} + +
+ 点仓库看明细} + > + +
    + {centers.map((c) => { + const isActive = activeCenter === c.key; + return ( +
  • + + + +
  • + ); + })} +
+
+
+ + + 转运异常明细 + + {[activeCenterName, activeType ? types.find((t) => t.key === activeType)?.label : ''] + .filter(Boolean).join(' · ') || '全部仓库'} + {' · '} + {detailOrders.length} 条 + {data?.truncated && allOrders.length >= (data?.detailLimit || 0) + ? `(已截断,仅显示前 ${data.detailLimit} 条)` + : ''} + + + )} + extra={(activeCenter || activeType) ? ( + + ) : null} + > + + {notStarted ? ( + + ) : detailOrders.length ? ( + `${r.oId}-${r.trackingNo || r.sendTime}-${r.exceptionType}`} + size="small" + pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }} + dataSource={detailOrders} + columns={DETAIL_COLUMNS} + scroll={{ x: 1900 }} + /> + ) : ( + + )} + + + + + + 说明:每天 09:00 核验前一日已发货订单的物流记录,只看两个卡点 —— + 未揽收(快递没来揽)和已揽收未中转(揽了但没有下一步中转记录,卡在网点), + 外加 无物流单 / 快递报异常 / 退回。 + 运输中、派送中、已签收一律正常:「运输」字样之后的中转记录本来就不回传聚水潭,不作考核。 + 子仓并入主仓统计;京仓-横林二仓京东云仓 与各代发仓不在 12 个发货仓范围内。 +
+ 不参与考核: + {exclusions?.carriers?.length ? ( + <>承运商 {exclusions.carriers.join(' / ')}(这些专线轨迹不回传聚水潭或本身无快递轨迹); + ) : null} + {exclusions?.conditionalCarriers?.length ? ( + <> + {exclusions.conditionalCarriers.map((r) => ( + + {r.keyword} 只在 {(r.keepCenters || []).join(' / ')} 考核, + 其它仓异地用同一账号打单的剔出({r.reason}); + + ))} + + ) : null} + 已取消 / 作废 / 拦截的订单 + {exclusions?.orderLabelKeywords?.length + ? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」) + : null} + ;{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'} + (按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。 + {exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0 + || exclusions.nonRealOrders > 0) ? ( + <> + {' '}本批已剔除 {(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0) + + (exclusions.nonRealOrders || 0)} 单 + (承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、 + 刷单/信誉单 {exclusions.nonRealOrders || 0} 单), + {exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? ( + <> + 其中承运商剔除: + {Object.entries(exclusions.carrierReasons) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v} 单`) + .join(' / ')} + ; + + ) : null} + 剔除的单既不算异常也不算发货分母,所以异常率不会被稀释。 + + ) : null} +
+ 页面按当前物流快照计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失, + 所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。 +
+ + ); +} + +export default WarehouseLogisticsException; diff --git a/frontend/src/pages/WarehouseLogisticsException/style.css b/frontend/src/pages/WarehouseLogisticsException/style.css new file mode 100644 index 0000000..d6b2611 --- /dev/null +++ b/frontend/src/pages/WarehouseLogisticsException/style.css @@ -0,0 +1,278 @@ +.wle-page { + padding: 16px 20px 24px; +} + +.wle-header { + margin-bottom: 14px; +} + +.wle-header .ant-typography { + margin-bottom: 4px; +} + +/* 口径说明:页面顶部是深色渐变,直接放浅色小字看不清,给一块浅底 */ +.wle-note { + display: block; + max-width: 1180px; + margin-top: 8px; + padding: 10px 14px; + background: var(--sycm-bg-container); + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + font-size: 12px; + line-height: 1.75; +} + +/* ============ 批次状态条 ============ */ +.wle-status { + margin-bottom: 12px; +} + +.wle-status__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.wle-status__batch { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.wle-status__badge { + padding: 1px 8px; + border-radius: 4px; + background: rgba(22, 119, 255, 0.12); + color: #0958d9; + font-size: 12px; + font-weight: 600; + line-height: 20px; +} + +.wle-status__badge--idle { + background: rgba(136, 135, 128, 0.14); + color: var(--sycm-text-tertiary); +} + +.wle-status__label { + font-size: 13px; + color: var(--sycm-text-primary); +} + +.wle-status__window, +.wle-status__time { + font-size: 12px; + color: var(--sycm-text-tertiary); +} + +.wle-alert { + margin-bottom: 12px; +} + +/* ============ 统计卡 ============ */ +.wle-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +@media (max-width: 1200px) { + .wle-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +.wle-stat { + height: 100%; +} + +.wle-stat__sub { + display: block; + margin-top: 4px; + font-size: 12px; +} + +/* ============ 异常类型筛选 ============ */ +.wle-types { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.wle-type { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px 2px 2px; + border: 1px solid var(--sycm-border-color); + border-radius: 16px; + background: var(--sycm-bg-container); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.wle-type:hover { + border-color: rgba(22, 119, 255, 0.45); +} + +.wle-type.is-active { + border-color: rgba(22, 119, 255, 0.6); + background: rgba(22, 119, 255, 0.08); +} + +.wle-type.is-empty { + opacity: 0.55; +} + +.wle-type__count { + font-size: 12px; + font-weight: 600; + color: var(--sycm-text-primary); + font-variant-numeric: tabular-nums; +} + +.wle-type__clear { + padding: 0; + border: none; + background: transparent; + font-size: 12px; + color: #1677ff; + cursor: pointer; +} + +/* ============ 主从两栏 ============ */ +.wle-body { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.wle-centers { + flex: none; + width: 272px; +} + +.wle-centers__hint { + font-size: 11px; +} + +.wle-centers__list { + margin: 0; + padding: 0; + list-style: none; +} + +.wle-center { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + margin-bottom: 2px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + font-size: 13px; + text-align: left; + color: var(--sycm-text-primary); + cursor: pointer; + transition: background 0.12s ease; +} + +.wle-center:hover { + background: var(--sycm-bg-subtle); +} + +.wle-center.is-active { + border-color: rgba(22, 119, 255, 0.45); + background: rgba(22, 119, 255, 0.08); +} + +.wle-center.is-clean .wle-center__name { + color: var(--sycm-text-tertiary); +} + +.wle-center__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.wle-center__badge { + flex: none; + min-width: 24px; + padding: 0 6px; + border-radius: 9px; + background: rgba(136, 135, 128, 0.12); + font-size: 11px; + font-weight: 600; + line-height: 17px; + text-align: center; + color: var(--sycm-text-tertiary); + font-variant-numeric: tabular-nums; +} + +.wle-center__badge.is-warn { + background: rgba(255, 77, 79, 0.16); + color: #cf1322; +} + +.wle-detail { + flex: 1 1 auto; + min-width: 0; +} + +.wle-detail__scope { + margin-left: 8px; + font-size: 12px; + font-weight: 400; + color: var(--sycm-text-tertiary); +} + +.wle-detail__clear { + padding: 0; + border: none; + background: transparent; + font-size: 12px; + color: #1677ff; + cursor: pointer; +} + +.wle-detail__hours { + color: #cf1322; + font-variant-numeric: tabular-nums; +} + +.wle-detail__soid { + font-variant-numeric: tabular-nums; +} + +.wle-foot { + display: block; + margin-top: 12px; + font-size: 12px; + color: var(--sycm-text-tertiary); +} + +/* 窄屏:仓库列表改两列网格,明细整行下移 */ +@media (max-width: 1200px) { + .wle-body { + flex-direction: column; + } + + .wle-centers { + width: 100%; + } + + .wle-centers__list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 10px; + } +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 8d537c5..82f03c3 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -20,6 +20,7 @@ const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const MkhBomData = lazy(() => import('../pages/MkhBomData')); const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness')); +const WarehouseLogisticsException = lazy(() => import('../pages/WarehouseLogisticsException')); const DailyInventory = lazy(() => import('../pages/DailyInventory')); const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis')); @@ -120,6 +121,10 @@ const router = createBrowserRouter([ path: 'planning/operation/warehouse-ship-timeliness', element: renderLazyComponent(WarehouseShipTimeliness), }, + { + path: 'planning/operation/warehouse-logistics-exception', + element: renderLazyComponent(WarehouseLogisticsException), + }, { path: 'planning/operation/daily-inventory', element: renderLazyComponent(DailyInventory), -- 2.47.2