feat: 仓储中心物流转运异常页新增「导出异常明细」(Excel / CSV)

- 后端新增 GET /api/warehouse-logistics-exception/export:复用列表同一套口径
  (getLogisticsExceptions),只把明细上限从列表 2000 放宽到 5000,不分页,
  判定/剔除逻辑一行未改,避免「导出与页面两套口径」。
- 结果缓存由单槽改为按 `核验日|明细上限` 分键的 Map(上限 8 条):
  页面按默认条数取、导出按 5000 取,单槽会互相踢掉 —— 导出一次,页面下次
  自动刷新就得重查库(20~40s)。分键后两者互不影响。
- 前端把明细过滤抽成 filterDetailOrders,表格与导出共用同一个函数,
  保证「导出的就是页面看到的」。
- 导出列单独定义 EXPORT_COLUMNS(13 列纯文本标题):页面列用了 Tooltip,
  直接复用列定义会把表头导出成 [object Object];运单号/物流状态/最后轨迹
  为空时导出为「无运单号 / 无状态 / 无轨迹」,Excel 里不留空格。
- 导出跟随当前核验日 + 选中仓 + 选中异常类型;文件名带核验日与筛选范围
  (仓储物流转运异常_<核验日>_<仓>_<类型>);0 行时提示且不生成空文件;
  超过 5000 行时如实提示「已截断」。导出按钮做 loading,防重复点。
- 测试:新增 2 个用例 —— ①导出行数等于异常包裹数、字段齐全、带剔除口径;
  ②缓存按 limit 分键(页面 1000 条那份不被导出 5000 条顶掉)。
  warehouseLogisticsException.test.js 26/26 通过。

口径未变:承运商剔除(顺丰系全剔、京喜京东、睢宁中通按仓、武进安能/中铁/
壹米滴答/自提/线下/商家配送)、订单终止与刷单信誉单剔除、异常 5 类判定,
全部沿用 #71 已合并版本。
This commit is contained in:
yanhui 2026-10-22 09:58:22 +08:00
parent 9cba2f8140
commit 85e2db9f92
6 changed files with 238 additions and 20 deletions

View File

@ -13,7 +13,10 @@
*/
const express = require('express');
const { getLogisticsExceptions } = require('../services/warehouseLogisticsExceptionService');
const {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
} = require('../services/warehouseLogisticsExceptionService');
const {
CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR,
} = require('../services/warehouseLogisticsExceptionRegistry');
@ -40,6 +43,17 @@ router.get('/list', async (req, res) => {
}
});
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
router.get('/export', async (req, res) => {
try {
const data = await getLogisticsExceptionExportRows(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '导出物流转运异常失败');
}
});
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
router.get('/meta', (_req, res) => {
res.json({

View File

@ -92,6 +92,8 @@ const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
// 是另外的账号,正常回传轨迹,不能连带剔掉。
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
const EXCLUDED_CARRIERS = Object.freeze([
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
@ -107,6 +109,10 @@ const EXCLUDED_CARRIERS = Object.freeze([
'自提',
'线下',
'商家配送',
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
// (2026-10-22 曾短暂要求全剔,随即撤回)。
'京喜京东',
]);

View File

@ -32,16 +32,22 @@ const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseLogisticsExceptionRegistry');
const DEFAULT_DETAIL_LIMIT = 500;
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
const MAX_DETAIL_LIMIT = 2000;
const EXPORT_DETAIL_LIMIT = 5000;
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
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 };
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
const CACHE_MAX_ENTRIES = 8;
const resultCache = new Map();
function clearLogisticsExceptionCache() {
resultCache = { key: '', at: 0, data: null };
resultCache.clear();
}
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
@ -179,9 +185,14 @@ function exclusionMeta() {
async function getLogisticsExceptions(options = {}) {
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
const limitCap = Math.min(
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
EXPORT_DETAIL_LIMIT
);
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
MAX_DETAIL_LIMIT
limitCap
);
const force = options.force === true || options.force === '1' || options.force === 1;
@ -221,12 +232,12 @@ async function getLogisticsExceptions(options = {}) {
}
const cacheKey = `${batch.verifyDate}|${limit}`;
if (!force && resultCache.data && resultCache.key === cacheKey
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return {
...resultCache.data,
...cachedEntry.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
};
}
@ -425,12 +436,56 @@ async function getLogisticsExceptions(options = {}) {
cacheAgeSec: 0,
};
resultCache = { key: cacheKey, at: Date.now(), data: payload };
resultCache.set(cacheKey, { at: Date.now(), data: payload });
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
if (resultCache.size > CACHE_MAX_ENTRIES) {
let oldestKey = '';
let oldestAt = Infinity;
for (const [key, entry] of resultCache.entries()) {
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
}
if (oldestKey) resultCache.delete(oldestKey);
}
return payload;
}
/**
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
*
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
* 不另写一套查询,避免「导出和页面两套口径」。
*
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
*/
async function getLogisticsExceptionExportRows(options = {}) {
const data = await getLogisticsExceptions({
...options,
limit: EXPORT_DETAIL_LIMIT,
maxLimit: EXPORT_DETAIL_LIMIT,
});
const rows = Array.isArray(data.orders) ? data.orders : [];
const total = Number(data.summary?.exceptionPackages) || rows.length;
return {
rows,
total,
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
truncated: rows.length < total,
detailLimit: EXPORT_DETAIL_LIMIT,
batch: data.batch,
notStarted: data.notStarted,
summary: data.summary,
centers: data.centers,
types: data.types,
exclusions: data.exclusions,
freshness: data.freshness,
generatedAt: data.generatedAt,
};
}
module.exports = {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
clearLogisticsExceptionCache,
classify,
formatDateTime,

View File

@ -300,6 +300,42 @@ test('明细 limit 生效并给出截断标记', async () => {
});
});
test('导出:放开明细上限、返回全量异常(不受列表 2000 上限限制)', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptionExportRows({
date: '2026-10-21',
now: at(21, 10, 0),
});
assert.equal(data.detailLimit, 5000, '导出的明细上限应放到 5000');
assert.equal(data.truncated, false);
assert.equal(data.rows.length, data.summary.exceptionPackages, '导出行数应等于异常包裹数');
assert.ok(data.batch?.label, '导出要带批次信息给文件名用');
assert.ok(Array.isArray(data.exclusions?.carriers), '导出也要带剔除口径');
// 明细字段够导出用(页面上那几列)
const row = data.rows[0];
for (const key of ['warehouseName', 'oId', 'soId', 'carrier', 'trackingNo',
'logisticStatus', 'exceptionLabel', 'sendTime', 'pendingHours']) {
assert.ok(key in row, `导出缺字段 ${key}`);
}
});
});
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
await withStub(async (svc, calls) => {
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(calls.times, 1);
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
await svc.getLogisticsExceptionExportRows(opts);
assert.equal(calls.times, 2, '导出是首次查,应真查库');
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
});
});
test('快照没覆盖核验时刻时给出红色告警文案', async () => {
const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' }));
await withStub(async (svc) => {
@ -321,8 +357,6 @@ test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米
'自提',
'乐鑫达线下韵达配件',
'商家配送',
'京喜京东快递',
'京喜京东快运',
// 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏
'顺丰速运',
'顺丰标快',
@ -332,21 +366,29 @@ test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米
'苏北顺丰干配小件',
'山东顺丰大件',
'景瓴十方顺丰大件-莒南',
// 京东系只有京喜自营那条线剔(10-22 曾要求全剔,业务随即撤回)
'京喜京东快递',
'京喜京东快运',
]) {
assert.ok(hit(name), `${name} 应剔除,实际未命中`);
}
// 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除
assert.equal(hit('唯品顺丰'), '唯品顺丰');
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);
assert.equal(hit('常州德邦'), null);
assert.equal(hit('百世快运'), null);
assert.equal(hit('睢宁德邦'), null);
assert.equal(hit('曹县德邦小件'), null);
});
test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => {

View File

@ -22,3 +22,13 @@ export const getWarehouseLogisticsExceptionMeta = () => request.get(
'/api/warehouse-logistics-exception/meta',
{ timeout: 30000 }
);
// 导出用:同一套口径、不分页(明细上限放到 5000),比列表更慢,超时再放大
export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request.get(
'/api/warehouse-logistics-exception/export',
{
params,
timeout: 5 * 60 * 1000,
headers: { 'Cache-Control': 'no-cache' },
}
);

View File

@ -1,11 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, DatePicker, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
Alert, Button, Card, DatePicker, Dropdown, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import { getWarehouseLogisticsExceptions } from '../../api/warehouseLogisticsException';
import {
getWarehouseLogisticsExceptions,
getWarehouseLogisticsExceptionExportRows,
} from '../../api/warehouseLogisticsException';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
@ -36,6 +40,44 @@ const formatHours = (hours) => {
return rest ? `${d} 天 ${rest} 小时` : `${d} 天`;
};
/**
* 明细过滤(仓 + 异常类型)。
* ⚠️ 页面表格和「导出」必须共用这一个函数 —— 否则导出的行和页面看到的对不上。
*/
const filterDetailOrders = (orders, { centerKey = '', typeKey = '' } = {}) => (
(Array.isArray(orders) ? orders : []).filter((o) => (
(!centerKey || o.centerKey === centerKey)
&& (!typeKey || o.exceptionType === typeKey)
))
);
// 导出列:标题必须是纯文本(页面列用了 Tooltip,直接复用会导出成 [object Object])
const EXPORT_COLUMNS = [
{ title: '发货仓', dataIndex: 'warehouseName' },
{ title: '内部单号', dataIndex: 'oId' },
{ title: '线上订单号', dataIndex: 'soId' },
{ title: '店铺', dataIndex: 'shopName' },
{ title: '快递公司', dataIndex: 'carrier' },
{ title: '运单号', dataIndex: 'trackingNo', exportValue: (v) => v || '无运单号' },
{ title: '物流状态', dataIndex: 'logisticStatus', exportValue: (v) => v || '无状态' },
{ title: '异常类型', dataIndex: 'exceptionLabel' },
{ title: '异常说明', dataIndex: 'exceptionDesc' },
{ title: '发货时间', dataIndex: 'sendTime' },
{ title: '最后物流时间', dataIndex: 'lastLogisticTime', exportValue: (v) => v || '无轨迹' },
{
title: '发货后待处理(小时)',
dataIndex: 'pendingHours',
exportValue: (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v) * 10) / 10 : ''),
},
{ title: '最后物流描述', dataIndex: 'lastLogisticInfo' },
];
// 导出菜单:key = `<format>`;sheet/文件名里带核验日,业务归档好找
const EXPORT_MENU_ITEMS = [
{ key: 'xlsx', label: '导出 Excel(.xlsx)' },
{ key: 'csv', label: '导出 CSV(.csv)' },
];
const DETAIL_COLUMNS = [
{
title: '发货仓',
@ -144,6 +186,8 @@ function WarehouseLogisticsException() {
const [activeType, setActiveType] = useState('');
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
const [verifyDate, setVerifyDate] = useState(null);
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
const [exporting, setExporting] = useState(false);
const load = useCallback(async (force = false) => {
setLoading(true);
@ -188,16 +232,56 @@ function WarehouseLogisticsException() {
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 detailOrders = useMemo(
() => filterDetailOrders(allOrders, { centerKey: activeCenter, typeKey: activeType }),
[activeCenter, activeType, allOrders]
);
const disabledDate = (current) => (
current && (current > dayjs().endOf('day')
|| current < dayjs().startOf('day').subtract(LOOKBACK_DAYS - 1, 'day'))
);
/**
* 导出异常明细。
* 口径要点:**按当前核验日 + 当前筛选(仓 / 异常类型)导出全部**,不是只导当前屏。
* 空表头 / 空筛选时导出的是「本批全部异常」,文件名叫「仓储物流转运异常_<核验日>」。
*/
const handleExport = async (format) => {
setExporting(true);
try {
const result = await getWarehouseLogisticsExceptionExportRows({
date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined,
});
if (!result || result.success === false) {
message.error(result?.error || '导出失败');
return;
}
const payload = result.data || {};
const rows = filterDetailOrders(payload.rows, {
centerKey: activeCenter,
typeKey: activeType,
});
if (!rows.length) {
message.warning('当前筛选条件下没有可导出的异常明细');
return;
}
const typeLabel = activeType ? (types.find((t) => t.key === activeType)?.label || activeType) : '';
const scope = [activeCenterName, typeLabel].filter(Boolean).join('_');
const verifyDateText = payload.batch?.verifyDate || dayjs().format('YYYY-MM-DD');
const fileName = `仓储物流转运异常_${verifyDateText}${scope ? `_${scope}` : ''}`;
await exportTableFile(format, fileName, EXPORT_COLUMNS, rows, '转运异常明细');
const truncatedHint = payload.truncated
? `;明细超过上限 ${payload.detailLimit} 行,已截断`
: '';
message.success(`已导出 ${rows.length} 行(${String(format).toUpperCase()})${truncatedHint}`);
} catch (exportError) {
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
} finally {
setExporting(false);
}
};
return (
<SycmPage className="wle-page">
<header className="wle-header">
@ -258,6 +342,13 @@ function WarehouseLogisticsException() {
</Text>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
<Dropdown
trigger={['click']}
disabled={exporting || notStarted}
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
>
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
</Dropdown>
</Space>
</div>
</Card>