diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 1e117f1..9e9041f 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -260,6 +260,11 @@ function buildNotifyText(data = {}, options = {}) { if (interceptCount > 0) { head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`); } + // 转仓代发(转x00005):记账仓下单、目标仓实际发出,本仓永远停在「未揽收」,不算异常 + const transferCount = Number(exclusions.transferPackages) || 0; + if (transferCount > 0) { + head.push(`另有 ${transferCount} 个包裹属转仓代发(转x00005),不计异常`); + } const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; // 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序 diff --git a/backend/src/services/warehouseLogisticsExceptionRegistry.js b/backend/src/services/warehouseLogisticsExceptionRegistry.js index 77ddd11..00fcdc0 100644 --- a/backend/src/services/warehouseLogisticsExceptionRegistry.js +++ b/backend/src/services/warehouseLogisticsExceptionRegistry.js @@ -158,6 +158,21 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([ { keyword: '转空包', label: '信誉单' }, ]); +// ── 转仓代发(业务 2026-10-23 指定:不用核对异常)────────────────────────── +// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签 +// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓 +// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。 +// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。 +// +// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%); +// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。 +// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 …… +// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006), +// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。 +// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**, +// 只是不从记账仓发 —— 所以保留发货分母,只从异常清单摘掉(同「现场拦截」)。 +const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/; + // ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)──────── // 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是 // **售后单有「签收单号」且「实退数量」不为 0**。 @@ -353,6 +368,19 @@ function matchNonRealOrder(orderLabels) { return null; } +/** + * 转仓代发 → 本仓不用核对物流异常。 + * @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串) + * @returns {string|null} 命中的标签原文(如 `转x00005`),便于页脚直接展示;不命中返回 null + */ +function matchTransferWarehouse(orderLabels) { + const labels = String(orderLabels || ''); + if (!labels) return null; + const hit = labels.match(TRANSFER_WAREHOUSE_LABEL_PATTERN); + if (!hit) return null; + return hit[0].replace(/\s+/g, ''); +} + /** * 解析「核验批次」。 * @@ -417,6 +445,8 @@ module.exports = { matchExcludedCarrier, matchVoidedOrder, matchNonRealOrder, + matchTransferWarehouse, + TRANSFER_WAREHOUSE_LABEL_PATTERN, resolveBatch, beijingNow, formatDate, diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js index 9707483..8cddacb 100644 --- a/backend/src/services/warehouseLogisticsExceptionService.js +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -249,6 +249,25 @@ function exclusionMeta() { }; } +/** + * 缓存里存的是**全量明细**(不截断),limit 只在出口生效。 + * + * 🔥 2026-10-23 修:缓存键以前是 `${核验日}|${limit}`,页面传 1000、导出传 5000 + * → 两份缓存互不命中,点一次导出就要把整轮重算一遍(生产实测 317s,而实际 + * 异常只有 33 行,limit 根本不影响扫库成本)。现在键只按核验日,出口再 slice。 + */ +function finalizeDetail(data, limit, cached, cacheAgeSec) { + const all = Array.isArray(data.orders) ? data.orders : []; + return { + ...data, + orders: all.slice(0, limit), + detailLimit: limit, + truncated: all.length > limit, + cached, + cacheAgeSec, + }; +} + async function getLogisticsExceptions(options = {}) { const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined); // 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows) @@ -289,6 +308,9 @@ async function getLogisticsExceptions(options = {}) { interceptPackages: 0, interceptOrders: 0, interceptReasons: {}, + transferPackages: 0, + transferOrders: 0, + transferReasons: {}, }, orders: [], detailLimit: limit, @@ -300,14 +322,16 @@ async function getLogisticsExceptions(options = {}) { }; } - const cacheKey = `${batch.verifyDate}|${limit}`; + // 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果 + const cacheKey = batch.verifyDate; const cachedEntry = resultCache.get(cacheKey); if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) { - return { - ...cachedEntry.data, - cached: true, - cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000), - }; + return finalizeDetail( + cachedEntry.data, + limit, + true, + Math.round((Date.now() - cachedEntry.at) / 1000) + ); } const rows = await loadShippedPackages(batch); @@ -340,6 +364,7 @@ async function getLogisticsExceptions(options = {}) { const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} }; const skippedByType = { packages: 0, orders: new Set(), reasons: {} }; const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} }; + const skippedByTransfer = { packages: 0, orders: new Set(), reasons: {} }; for (const row of rows) { const queryTime = formatDateTime(row.query_time); @@ -394,10 +419,21 @@ async function getLogisticsExceptions(options = {}) { const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' }; - // ④ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收, + // ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓发出, + // 原仓物流状态必然停在「未揽收 / 无物流单」→ 全是假异常(业务 2026-10-23 指定不核对)。 + // ⚠️ 与 a/b/c 三类不同:货**确实发了**,所以保留发货分母,只从异常清单摘掉。 + const transferReason = registry.matchTransferWarehouse(row.order_labels); + if (transferReason) { + skippedByTransfer.packages += 1; + skippedByTransfer.orders.add(oId); + skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1; + continue; + } + + // ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收, // 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。 - // ⚠️ 与上面 a/b/c 三类不同:这里**保留发货分母**(货确实发出去了), - // 只从异常清单里摘掉,异常率才不会被错误地放大。 + // ⚠️ 同④:**保留发货分母**(货确实发出去了),只从异常清单里摘掉, + // 异常率才不会被错误地放大。 if (interceptedOrders.has(oId)) { skippedByIntercept.packages += 1; skippedByIntercept.orders.add(oId); @@ -510,10 +546,15 @@ async function getLogisticsExceptions(options = {}) { interceptPackages: skippedByIntercept.packages, interceptOrders: skippedByIntercept.orders.size, interceptReasons: skippedByIntercept.reasons, + // 转仓代发(转x00005)—— 同上,只摘异常、保留发货分母 + transferPackages: skippedByTransfer.packages, + transferOrders: skippedByTransfer.orders.size, + transferReasons: skippedByTransfer.reasons, }, - orders: exceptions.slice(0, limit), + // 缓存里存全量,limit 由 finalizeDetail 在出口截断 + orders: exceptions, detailLimit: limit, - truncated: exceptions.length > limit, + truncated: false, freshness: { snapshotAt, ageMinutes, @@ -536,7 +577,7 @@ async function getLogisticsExceptions(options = {}) { } if (oldestKey) resultCache.delete(oldestKey); } - return payload; + return finalizeDetail(payload, limit, false, 0); } /** diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js index 5b261f6..72a6f55 100644 --- a/backend/test/warehouseLogisticsException.test.js +++ b/backend/test/warehouseLogisticsException.test.js @@ -326,20 +326,39 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上 }); }); -test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => { +test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => { await withStub(async (svc, calls) => { const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length; const opts = { date: '2026-10-21', now: at(21, 10, 0) }; - await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); + + const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); + assert.equal(list.cached, false, '页面首次应查库'); assert.equal(mainQueries(), 1); - // 导出用另一个 limit —— 不能把页面那份缓存顶掉 - await svc.getLogisticsExceptionExportRows(opts); - assert.equal(mainQueries(), 2, '导出是首次查,应真查库'); + // 导出 limit=5000 与页面 1000 不同,但扫库成本一样 → 必须复用同一份全量缓存 + // (2026-10-23 修:以前键里带 limit,这里会再扫一遍,生产实测多花 317s) + const exp = await svc.getLogisticsExceptionExportRows(opts); + assert.equal(mainQueries(), 1, '导出不该重新扫库'); + assert.equal(exp.detailLimit, 5000); + assert.equal(exp.rows.length, list.summary.exceptionPackages, '导出仍要拿到全部明细'); const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); - assert.equal(listAgain.cached, true, '页面那份缓存应还在'); - assert.equal(mainQueries(), 2, '列表应命中缓存,不再查库'); + assert.equal(listAgain.cached, true, '页面那份缓存应仍在'); + assert.equal(mainQueries(), 1); + }); +}); + +test('缓存存全量:先按小 limit 取,后面大 limit 仍拿到完整明细', async () => { + await withStub(async (svc) => { + const opts = { date: '2026-10-21', now: at(21, 10, 0) }; + const small = await svc.getLogisticsExceptions({ ...opts, limit: 2 }); + assert.equal(small.orders.length, 2); + assert.equal(small.truncated, true); + + const big = await svc.getLogisticsExceptions({ ...opts, limit: 5000, maxLimit: 5000 }); + assert.equal(big.cached, true, '应命中同一份缓存'); + assert.equal(big.orders.length, big.summary.exceptionPackages, '缓存不能只存被截断的那 2 条'); + assert.equal(big.truncated, false); }); }); @@ -523,6 +542,39 @@ test('刷单的包裹不进分母也不算异常', async () => { }, mixed); }); +test('转仓标签识别:转x00005(含大小写/空格)都认,转空包不认', () => { + assert.equal(registry.matchTransferWarehouse('["京喜","转x00005"]'), '转x00005'); + assert.equal(registry.matchTransferWarehouse('["转X00005"]'), '转X00005'); + assert.equal(registry.matchTransferWarehouse('["转 x00005"]'), '转x00005'); + // 后续可能出现别的目标仓编码 + assert.equal(registry.matchTransferWarehouse('["转x00006"]'), '转x00006'); + // 反例:转空包是信誉单(发空包),不是转仓代发 + assert.equal(registry.matchTransferWarehouse('["转空包发货"]'), null); + assert.equal(registry.matchTransferWarehouse('["京喜","线上发货"]'), null); + assert.equal(registry.matchTransferWarehouse(''), null); + assert.equal(registry.matchTransferWarehouse(null), null); +}); + +test('转仓代发不算异常,但仍计入发货分母', async () => { + const mixed = [ + // 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,本仓永远「未揽收」,必须不报异常 + mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null, + logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }), + // 31:同样「未揽收」但没转仓标签 → 必须留下 + mk({ inner_order_id: 31, so_id: 'SO-31', 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.deepEqual(data.orders.map((o) => o.oId), ['31'], '只留非转仓那单'); + assert.equal(data.exclusions.transferOrders, 1); + assert.equal(data.exclusions.transferPackages, 1); + assert.equal(data.exclusions.transferReasons['转x00005'], 1); + assert.equal(data.summary.exceptionPackages, 1, '转仓单不计异常'); + assert.equal(data.summary.packageCount, 2, '转仓单仍计入发货分母(货确实发了)'); + }, mixed); +}); + test('未到 09:00 也返回剔除口径,前端不用判空', async () => { await withStub(async (svc) => { const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index 8120995..51d180e 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -815,6 +815,22 @@ function WarehouseLogisticsException() { —— 货是被快递当场拦下原车拉回、由售后仓签收的,仍计入发货分母、只是不再报异常。 ) : null} + {exclusions?.transferOrders > 0 ? ( + <> + {' '}另有 {exclusions.transferOrders} 单属转仓代发 + {exclusions.transferReasons && Object.keys(exclusions.transferReasons).length ? ( + <>( + {Object.entries(exclusions.transferReasons) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `标签${k} ${v} 单`) + .join(' / ')} + ) + + ) : null} + —— 记账仓下单、实际由目标仓发出,本仓物流状态必然停在「未揽收」, + 仍计入发货分母、只是不再报异常。 + + ) : null}
页面按当前物流快照计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失, 所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。