Merge pull request 'feat: 仓储物流转运异常页新增「导出异常明细」(Excel / CSV)' (#72)
This commit is contained in:
commit
b30c4e949c
@ -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({
|
||||
|
||||
@ -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 曾短暂要求全剔,随即撤回)。
|
||||
'京喜京东',
|
||||
]);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => {
|
||||
|
||||
@ -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' },
|
||||
}
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user