diff --git a/backend/src/api/warehouseLogisticsException.js b/backend/src/api/warehouseLogisticsException.js index bc1671f..24cee90 100644 --- a/backend/src/api/warehouseLogisticsException.js +++ b/backend/src/api/warehouseLogisticsException.js @@ -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({ diff --git a/backend/src/services/warehouseLogisticsExceptionRegistry.js b/backend/src/services/warehouseLogisticsExceptionRegistry.js index bfa2d99..21adb2e 100644 --- a/backend/src/services/warehouseLogisticsExceptionRegistry.js +++ b/backend/src/services/warehouseLogisticsExceptionRegistry.js @@ -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 曾短暂要求全剔,随即撤回)。 '京喜京东', ]); diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js index f15272c..74303b4 100644 --- a/backend/src/services/warehouseLogisticsExceptionService.js +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -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, diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js index a993aff..d197f60 100644 --- a/backend/test/warehouseLogisticsException.test.js +++ b/backend/test/warehouseLogisticsException.test.js @@ -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('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => { diff --git a/frontend/src/api/warehouseLogisticsException.js b/frontend/src/api/warehouseLogisticsException.js index 47b8f0d..0204a65 100644 --- a/frontend/src/api/warehouseLogisticsException.js +++ b/frontend/src/api/warehouseLogisticsException.js @@ -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' }, + } +); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index 59a05ec..92202e3 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -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 = ``;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 (
@@ -258,6 +342,13 @@ function WarehouseLogisticsException() { ) : null} + handleExport(key) }} + > + +