From dd583645932807723f48994e365b50ce465cbb14 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 24 Oct 2026 12:25:29 +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=E5=86=8D?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=E5=8E=9F=E4=BB=93=E5=8F=91=E8=B4=A7=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务 2026-10-24 补充口径:转仓单的货不是从记账仓(原仓)走的, 原仓的发货量也不该算 —— 记在原仓分母里只会把分母做大、把异常率稀释。 ## 改了什么 原先 PR #84 把转仓代发当「只摘异常、保留分母」(与现场拦截同类)。 这次把判定从「异常判定之后」**提到发货分母计数之前**,改成**整单不计**: | | 改前(PR #84) | 改后 | |---|---|---| | 算异常 | ❌ | ❌ | | 进原仓发货分母 | ✅ | ❌ | | 归类 | 独立一行「另有 N 单」 | 并入「本批已剔除 N 单」 | 判定逻辑只挪了位置,`matchTransferWarehouse()` 本身没变 (`/转\s*[xX]\s*\d{3,}/`,仍不写死 `转x00005`)。 ## 三处文案同步 - 前端页脚:删掉原来「仍计入发货分母、只是不再报异常」的独立段落, 改为并入「本批已剔除 N 单(……转仓代发 N 单)」,口径句也改成 「不算本仓异常,也不算本仓发货量」。 - 钉钉正文:转仓代发并入「已剔除 N 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发)」, 删掉「另有 N 个包裹属转仓代发」那一行。 - registry 注释同步更新,明确写清**与现场拦截的区别**: 现场拦截是本仓发的货(留分母),转仓代发不是(不留分母)。 ## 验证 - 后端单测 50/50。`转仓代发整单不计` 用例改为断言: `exceptionPackages=1`、**`packageCount=1`**(不是 2)、`shippedOrders=1`, 崔桥仓 `shippedOrders=1 / exceptionOrders=1`。 - 全量 `npm run check` 474 项 / 472 过(2 项为既有环境失败:Windows 权限位、负缓存需隧道故障态) - 前端 eslint 0;`npm run build` 通过 - **真实库(10-23 批)**:发货包裹 5337 → **5332**(扣掉 5 个转仓包裹), 异常 15 单不变,异常率 0.3%;转仓剔除 `packages=5 orders=5 {"转x00005":5}`; 崔桥工厂仓 发货单 2824 / 异常 6 / 0.2%。 ## 影响面 - 分母会略微变小(10-23 批 -5 个包裹,约 0.09%),异常率理论上有微幅上升, 但剔掉的本来就是「必然异常」的假单,整体更准。 - 与现场拦截的口径现在明确分家:**现场拦截留分母,转仓代发不留**。 --- .../warehouseLogisticsExceptionNotifyJob.js | 10 ++----- .../warehouseLogisticsExceptionRegistry.js | 7 +++-- .../warehouseLogisticsExceptionService.js | 29 ++++++++++--------- .../test/warehouseLogisticsException.test.js | 12 ++++++-- .../WarehouseLogisticsException/index.jsx | 25 ++++------------ 5 files changed, 36 insertions(+), 47 deletions(-) diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 9e9041f..28618f8 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -251,20 +251,16 @@ function buildNotifyText(data = {}, options = {}) { const excludedCount = (Number(exclusions.carrierPackages) || 0) + (Number(exclusions.voidedPackages) || 0) - + (Number(exclusions.nonRealPackages) || 0); + + (Number(exclusions.nonRealPackages) || 0) + + (Number(exclusions.transferPackages) || 0); if (excludedCount > 0) { - head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`); + head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`); } // 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母 const interceptCount = Number(exclusions.interceptPackages) || 0; 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 00fcdc0..3ab18aa 100644 --- a/backend/src/services/warehouseLogisticsExceptionRegistry.js +++ b/backend/src/services/warehouseLogisticsExceptionRegistry.js @@ -158,7 +158,7 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([ { keyword: '转空包', label: '信誉单' }, ]); -// ── 转仓代发(业务 2026-10-23 指定:不用核对异常)────────────────────────── +// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)── // 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签 // (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓 // (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。 @@ -169,8 +169,9 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([ // 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 …… // 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006), // 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。 -// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**, -// 只是不从记账仓发 —— 所以保留发货分母,只从异常清单摘掉(同「现场拦截」)。 +// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。 +// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母 +// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。 const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/; // ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)──────── diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js index 8cddacb..4917ac8 100644 --- a/backend/src/services/warehouseLogisticsExceptionService.js +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -408,6 +408,18 @@ async function getLogisticsExceptions(options = {}) { continue; } + // ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。 + // 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的, + // 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理 + // (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。 + 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; + } + const stat = centerStat.get(center.key); stat.shippedOrders.add(oId); stat.packages += 1; @@ -419,21 +431,10 @@ async function getLogisticsExceptions(options = {}) { const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' }; - // ④ 转仓代发(`转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 —— 货已被售后仓签收, // 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。 - // ⚠️ 同④:**保留发货分母**(货确实发出去了),只从异常清单里摘掉, - // 异常率才不会被错误地放大。 + // ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了), + // 只从异常清单里摘掉,异常率才不会被错误地放大。 if (interceptedOrders.has(oId)) { skippedByIntercept.packages += 1; skippedByIntercept.orders.add(oId); @@ -546,7 +547,7 @@ async function getLogisticsExceptions(options = {}) { interceptPackages: skippedByIntercept.packages, interceptOrders: skippedByIntercept.orders.size, interceptReasons: skippedByIntercept.reasons, - // 转仓代发(转x00005)—— 同上,只摘异常、保留发货分母 + // 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母 transferPackages: skippedByTransfer.packages, transferOrders: skippedByTransfer.orders.size, transferReasons: skippedByTransfer.reasons, diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js index 72a6f55..cc43d13 100644 --- a/backend/test/warehouseLogisticsException.test.js +++ b/backend/test/warehouseLogisticsException.test.js @@ -555,9 +555,9 @@ test('转仓标签识别:转x00005(含大小写/空格)都认,转空包 assert.equal(registry.matchTransferWarehouse(null), null); }); -test('转仓代发不算异常,但仍计入发货分母', async () => { +test('转仓代发整单不计:不算异常,也不进原仓发货分母', async () => { const mixed = [ - // 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,本仓永远「未揽收」,必须不报异常 + // 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,货不是从原仓走的,整单不计 mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null, logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }), // 31:同样「未揽收」但没转仓标签 → 必须留下 @@ -571,7 +571,13 @@ test('转仓代发不算异常,但仍计入发货分母', async () => { 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, '转仓单仍计入发货分母(货确实发了)'); + // 🔥 业务 2026-10-24:货不从原仓走,原仓发货量也不该算 + assert.equal(data.summary.packageCount, 1, '转仓单不进原仓发货分母'); + assert.equal(data.summary.shippedOrders, 1, '转仓单不算原仓发货单'); + // 仓维度:崔桥仓发货单数也只剩 31 那单(30 是转仓、不计) + const center = data.centers.find((c) => c.key === 'CQ'); + assert.equal(center.shippedOrders, 1, '崔桥仓发货单数不应包含转仓单'); + assert.equal(center.exceptionOrders, 1, '崔桥仓异常只算 31'); }, mixed); }); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index 51d180e..e8de790 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -780,14 +780,15 @@ function WarehouseLogisticsException() { : null} ;{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'} (按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流; - 现场拦截(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常。 + 现场拦截(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常; + 转仓代发(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。 {exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0 - || exclusions.nonRealOrders > 0) ? ( + || exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? ( <> {' '}本批已剔除 {(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0) - + (exclusions.nonRealOrders || 0)} 单 + + (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)} 单 (承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、 - 刷单/信誉单 {exclusions.nonRealOrders || 0} 单), + 刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单), {exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? ( <> 其中承运商剔除: @@ -815,22 +816,6 @@ 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