feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流) #71
56
backend/src/api/warehouseLogisticsException.js
Normal file
56
backend/src/api/warehouseLogisticsException.js
Normal file
@ -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;
|
||||
@ -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);
|
||||
|
||||
382
backend/src/services/warehouseLogisticsExceptionRegistry.js
Normal file
382
backend/src/services/warehouseLogisticsExceptionRegistry.js
Normal file
@ -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,
|
||||
};
|
||||
438
backend/src/services/warehouseLogisticsExceptionService.js
Normal file
438
backend/src/services/warehouseLogisticsExceptionService.js
Normal file
@ -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,
|
||||
};
|
||||
485
backend/test/warehouseLogisticsException.test.js
Normal file
485
backend/test/warehouseLogisticsException.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
24
frontend/src/api/warehouseLogisticsException.js
Normal file
24
frontend/src/api/warehouseLogisticsException.js
Normal file
@ -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 }
|
||||
);
|
||||
@ -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: [
|
||||
|
||||
@ -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' },
|
||||
|
||||
519
frontend/src/pages/WarehouseLogisticsException/index.jsx
Normal file
519
frontend/src/pages/WarehouseLogisticsException/index.jsx
Normal file
@ -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 ? <span className="wle-detail__soid">{v}</span> : '—'),
|
||||
},
|
||||
{ title: '店铺', dataIndex: 'shopName', width: 150, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '快递公司', dataIndex: 'carrier', width: 130, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="聚水潭回传的运单号。空的说明仓库已推单但快递没建单">
|
||||
<span>运单号</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'trackingNo',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? v : <Tag bordered={false} color="red">无运单号</Tag>),
|
||||
},
|
||||
{
|
||||
title: '物流状态',
|
||||
dataIndex: 'logisticStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : <Tag bordered={false} color="red">无状态</Tag>),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="判定到核验时刻(当天 09:00)物流卡在哪一步;点类型图例可只看某一类">
|
||||
<span>异常类型</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'exceptionLabel',
|
||||
width: 116,
|
||||
render: (v, row) => (
|
||||
<Tooltip title={row?.exceptionDesc || v}>
|
||||
<Tag bordered={false} color={row?.tone || 'default'}>{v}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{ title: '发货时间', dataIndex: 'sendTime', width: 152, ellipsis: true },
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="这条运单最后一条物流轨迹的时间。空白说明快递还没揽收、没有任何轨迹">
|
||||
<span>最后物流时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'lastLogisticTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="从发货时间到核验时刻(核验日 09:00)经过多久 —— 仓库看到「发了多久物流还没动静」">
|
||||
<span>发货后待处理</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'pendingHours',
|
||||
width: 118,
|
||||
render: (v) => <span className="wle-detail__hours">{formatHours(v)}</span>,
|
||||
},
|
||||
{
|
||||
title: '最后物流描述',
|
||||
dataIndex: 'lastLogisticInfo',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? (
|
||||
<Tooltip title={v}>
|
||||
<span>{v}</span>
|
||||
</Tooltip>
|
||||
) : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常(独立页面)
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 业务口径(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 (
|
||||
<SycmPage className="wle-page">
|
||||
<header className="wle-header">
|
||||
<Title level={3}>仓储中心物流转运异常</Title>
|
||||
<Text type="secondary" className="wle-note">
|
||||
<b>每天 09:00 核验 12 个发货仓「前一日已发货」订单的物流记录</b>,只看异常,正常签收的不列。
|
||||
核验批次 = <b>核验日 09:00</b> + <b>前一天发货的单</b>。
|
||||
到核验时刻物流还卡在<b>揽收前</b>就算转运异常,分五类:
|
||||
<b>未揽收</b>(发了货快递没来揽)、<b>已揽收未中转</b>(揽了但没下一步中转,卡在网点)、
|
||||
<b>无物流单</b>(连运单号都没建)、<b>快递报异常</b>(如寄件人取消寄件、异常签收)、
|
||||
<b>退回</b>(拒收 / 换单退回)。
|
||||
<b>运输中、派送中、已签收 = 正常,不进列表</b> —— 有「运输」字样之后的中转记录不回传,不作考核。
|
||||
一单多包裹按包裹列明细、按内部单号统计,异常率不会被多包裹单放大。
|
||||
<b>不参与考核</b>:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、壹米滴答、
|
||||
京喜京东(快递·快运)、自提、线下、商家配送
|
||||
(轨迹不回传或本身无快递轨迹);
|
||||
<b>睢宁中通</b> 只在睢宁仓考核,其它仓异地打单的剔出;
|
||||
<b>已取消 / 作废 / 拦截</b>的订单(单据已终止)、<b>刷单 / 信誉单</b>(本身不是真实发货)。
|
||||
<b>历史批次是按当前物流快照回算的</b> —— 昨天是异常、今天已签收的单会显示成正常,
|
||||
要留档结论请以当天 09:00 看到的为准。
|
||||
</Text>
|
||||
</header>
|
||||
|
||||
<Card className="wle-status" bordered={false}>
|
||||
<div className="wle-status__row">
|
||||
<div className="wle-status__batch">
|
||||
{notStarted ? (
|
||||
<>
|
||||
<span className="wle-status__badge wle-status__badge--idle">未到核验时间</span>
|
||||
<span className="wle-status__label">
|
||||
{String(batch?.deadline || '').slice(5, 16)} 才开始核验
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="wle-status__badge">{batch?.label || '—'}</span>
|
||||
<span className="wle-status__label">
|
||||
核验 {batch?.shipDate || '—'} 已发货订单的物流记录
|
||||
</span>
|
||||
{batch?.lookback ? (
|
||||
<span className="wle-status__window">历史批次 · 按当前快照回算</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
<DatePicker
|
||||
value={verifyDate || (batch?.verifyDate ? dayjs(batch.verifyDate) : null)}
|
||||
disabledDate={disabledDate}
|
||||
onChange={(value) => setVerifyDate(value ? value.startOf('day') : null)}
|
||||
style={{ width: 148 }}
|
||||
placeholder="核验日"
|
||||
/>
|
||||
{data?.generatedAt ? (
|
||||
<Text type="secondary" className="wle-status__time">
|
||||
数据更新于 {String(data.generatedAt).slice(11, 19)}
|
||||
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s)` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={error}
|
||||
action={<Button size="small" onClick={() => load(true)}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
||||
<Alert
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="wle-stats">
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="核验日的前一天,12 个发货仓发出的订单总数(按内部单号去重)">
|
||||
<span>前一日发货</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.shippedOrders ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="到 09:00 核验时刻物流仍不正常的订单(按内部单号去重)">
|
||||
<span>转运异常</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.exceptionOrders ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.exceptionOrders || 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
涉及 {notStarted ? '—' : number(summary.exceptionPackages)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title="异常率"
|
||||
value={notStarted ? 0 : (summary.exceptionRate ?? 0)}
|
||||
suffix="%"
|
||||
formatter={(v) => (notStarted ? '—' : number(v, 1))}
|
||||
valueStyle={{ color: (summary.exceptionRate || 0) >= 10 ? '#d46b08' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
异常单 ÷ 前一日发货单
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title="涉及仓库"
|
||||
value={notStarted ? 0 : (summary.centerCount ?? 0)}
|
||||
suffix={`/ ${centers.length || 12}`}
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
有转运异常的仓
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{types.length ? (
|
||||
<div className="wle-types">
|
||||
{types.map((t) => {
|
||||
const count = summary.byType?.[t.key] || 0;
|
||||
const isActive = activeType === t.key;
|
||||
return (
|
||||
<Tooltip key={t.key} title={t.desc || t.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={`wle-type${isActive ? ' is-active' : ''}${count ? '' : ' is-empty'}`}
|
||||
onClick={() => setActiveType(isActive ? '' : t.key)}
|
||||
>
|
||||
<Tag bordered={false} color={t.tone}>{t.label}</Tag>
|
||||
<span className="wle-type__count">{notStarted ? '—' : count}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{activeType ? (
|
||||
<button type="button" className="wle-type__clear" onClick={() => setActiveType('')}>
|
||||
取消筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wle-body">
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wle-centers"
|
||||
title="发货仓"
|
||||
extra={<Text type="secondary" className="wle-centers__hint">点仓库看明细</Text>}
|
||||
>
|
||||
<Spin spinning={loading && !data} size="small">
|
||||
<ul className="wle-centers__list">
|
||||
{centers.map((c) => {
|
||||
const isActive = activeCenter === c.key;
|
||||
return (
|
||||
<li key={c.key}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={`${c.name}:前一日发货 ${c.shippedOrders} 单,转运异常 ${c.exceptionOrders} 单`
|
||||
+ `${c.shippedOrders ? `(异常率 ${c.exceptionRate}%)` : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'wle-center',
|
||||
isActive ? 'is-active' : '',
|
||||
c.exceptionOrders ? '' : 'is-clean',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setActiveCenter(isActive ? '' : c.key)}
|
||||
>
|
||||
<span className="wle-center__name">{c.name}</span>
|
||||
<span className={`wle-center__badge${c.exceptionOrders > 0 ? ' is-warn' : ''}`}>
|
||||
{notStarted ? '—' : (c.exceptionOrders > 0 ? c.exceptionOrders : '—')}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wle-detail"
|
||||
title={(
|
||||
<span>
|
||||
转运异常明细
|
||||
<span className="wle-detail__scope">
|
||||
{[activeCenterName, activeType ? types.find((t) => t.key === activeType)?.label : '']
|
||||
.filter(Boolean).join(' · ') || '全部仓库'}
|
||||
{' · '}
|
||||
{detailOrders.length} 条
|
||||
{data?.truncated && allOrders.length >= (data?.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={(activeCenter || activeType) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="wle-detail__clear"
|
||||
onClick={() => { setActiveCenter(''); setActiveType(''); }}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
) : null}
|
||||
>
|
||||
<Spin spinning={loading && !data}>
|
||||
{notStarted ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={`未到核验时间,${String(batch?.deadline || '').slice(5, 16)} 开始核验`}
|
||||
/>
|
||||
) : detailOrders.length ? (
|
||||
<Table
|
||||
rowKey={(r) => `${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 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={activeCenter || activeType
|
||||
? '这个筛选条件下没有转运异常'
|
||||
: `前一日发货的订单物流都正常(或还没到核验时刻)`}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" className="wle-foot">
|
||||
说明:每天 <b>09:00</b> 核验<b>前一日已发货</b>订单的物流记录,只看两个卡点 ——
|
||||
<b>未揽收</b>(快递没来揽)和<b>已揽收未中转</b>(揽了但没有下一步中转记录,卡在网点),
|
||||
外加 <b>无物流单 / 快递报异常 / 退回</b>。
|
||||
<b>运输中、派送中、已签收一律正常</b>:「运输」字样之后的中转记录本来就不回传聚水潭,不作考核。
|
||||
子仓并入主仓统计;<b>京仓-横林二仓京东云仓</b> 与各代发仓不在 12 个发货仓范围内。
|
||||
<br />
|
||||
<b>不参与考核</b>:
|
||||
{exclusions?.carriers?.length ? (
|
||||
<>承运商 <b>{exclusions.carriers.join(' / ')}</b>(这些专线轨迹不回传聚水潭或本身无快递轨迹);</>
|
||||
) : null}
|
||||
{exclusions?.conditionalCarriers?.length ? (
|
||||
<>
|
||||
{exclusions.conditionalCarriers.map((r) => (
|
||||
<span key={r.keyword}>
|
||||
<b>{r.keyword}</b> 只在 <b>{(r.keepCenters || []).join(' / ')}</b> 考核,
|
||||
其它仓异地用同一账号打单的剔出({r.reason});
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<b>已取消 / 作废 / 拦截</b>的订单
|
||||
{exclusions?.orderLabelKeywords?.length
|
||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||
: null}
|
||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||
|| exclusions.nonRealOrders > 0) ? (
|
||||
<>
|
||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||
+ (exclusions.nonRealOrders || 0)}</b> 单
|
||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
|
||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||
<>
|
||||
其中承运商剔除:<b>
|
||||
{Object.entries(exclusions.carrierReasons)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v} 单`)
|
||||
.join(' / ')}
|
||||
</b>;
|
||||
</>
|
||||
) : null}
|
||||
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
||||
</>
|
||||
) : null}
|
||||
<br />
|
||||
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
||||
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
||||
</Text>
|
||||
</SycmPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default WarehouseLogisticsException;
|
||||
278
frontend/src/pages/WarehouseLogisticsException/style.css
Normal file
278
frontend/src/pages/WarehouseLogisticsException/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user