From e02d1f6d66b7e52aaf7a171ee6de3eae8172101f Mon Sep 17 00:00:00 2001
From: yanhui <2353496031@qq.com>
Date: Fri, 23 Oct 2026 10:46:26 +0800
Subject: [PATCH] =?UTF-8?q?fix(wle):=20=E8=BD=AC=E4=BB=93=E4=BB=A3?=
=?UTF-8?q?=E5=8F=91=EF=BC=88=E8=BD=ACx00005=EF=BC=89=E4=B8=8D=E7=AE=97?=
=?UTF-8?q?=E8=BD=AC=E8=BF=90=E5=BC=82=E5=B8=B8=20+=20=E5=AF=BC=E5=87=BA?=
=?UTF-8?q?=E5=A4=8D=E7=94=A8=E9=A1=B5=E9=9D=A2=E7=BC=93=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
两件事,都是 2026-10-23 业务反馈直接引起的。
## 1. 转仓代发不再核对异常
业务反馈:崔桥工厂仓的单 351668937728 被判「未揽收」异常,快递却是「曹县韵达小件」,
而且这单在后台是信誉订单。
实查宽表:这单 `warehouse_name=简尚-崔桥工厂仓`,但 `order_labels` 里带一条
`转x00005` —— 聚水潭「转仓」操作留下的痕迹。记账仓还是崔桥,货却由目标仓
(x00005 = 曹县)实际发出,承运账号也跟着变成曹县韵达小件。结果就是原仓的
物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
判据:`order_labels` 命中 `/转\s*[xX]\s*\d{3,}/`(不写死单个编码,后续可能出现
x00006)。实测近 30 天 12 仓:`转x00005` 438 单,报未揽收类 46 单(已签收 371 /
无物流单 31 / 运输中 20 / 未揽收 15);快递分布 曹县韵达小件 133、邮政快递包裹POSTB 124。
口径要点:
- 与「转空包」(信誉单,发空包)严格区分 —— 转仓是**真发货**,只是不从记账仓发;
- 所以**保留发货分母**,只从异常清单摘掉(与「现场拦截」同类,不稀释异常率);
- 新增 `exclusions.transferPackages/transferOrders/transferReasons`,
前端页脚与钉钉正文各加一行说明。
## 2. 缓存键只按核验日(修导出卡顿)
原来键是 `${核验日}|${limit}`:页面传 1000、导出传 5000 → 两份缓存互不命中,
点一次导出就要把整轮重算一遍。可 `limit` 只影响最后 `slice(0, limit)`,
异常实际才几十行,扫库成本一模一样。
改为:缓存只存**全量明细**(键 = 核验日),limit 在出口 `finalizeDetail()` 里截断。
生产实测(修复前,容器内直调):页面冷启 189s、命中缓存 0ms、
**页面已缓存时点导出仍要 317s**;修复后导出复用页面结果 1~3ms。
## 验证
- 后端单测 50/50(新增:转仓标签识别 8 条断言、转仓单不进异常但进分母、
导出复用页面缓存不重查、小 limit 不污染缓存)
- 全量 `npm run check` 474 项 / 472 过,2 项为既有环境失败(Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、`npm run build` 通过(含 router lazy 静态检查)
- 真实库(10-23 批):异常 73 单,转仓剔除 1 单(正是 9894520),目标单已消失;
导出在页面加载后 3ms 返回 73 行
---
.../warehouseLogisticsExceptionNotifyJob.js | 5 ++
.../warehouseLogisticsExceptionRegistry.js | 30 +++++++++
.../warehouseLogisticsExceptionService.js | 65 ++++++++++++++----
.../test/warehouseLogisticsException.test.js | 66 +++++++++++++++++--
.../WarehouseLogisticsException/index.jsx | 16 +++++
5 files changed, 163 insertions(+), 19 deletions(-)
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 点落一份快照。
--
2.47.2