Compare commits

...

27 Commits

Author SHA1 Message Date
a839d78d56 Merge pull request 'fix(wle): 转仓代发(转x00005)不再计入原仓发货量' (#85) from yanhui-wle-transfer-denominator-20261024 into main 2026-10-24 12:26:44 +08:00
dd58364593 fix(wle): 转仓代发(转x00005)不再计入原仓发货量
业务 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%),异常率理论上有微幅上升,
  但剔掉的本来就是「必然异常」的假单,整体更准。
- 与现场拦截的口径现在明确分家:**现场拦截留分母,转仓代发不留**。
2026-10-24 12:25:29 +08:00
d1bf4047e2 Merge pull request 'fix(wle): 转仓代发(转x00005)不算转运异常 + 导出复用页面缓存' (#84) from yanhui-wle-transfer-cache-20261023 into main 2026-10-23 16:03:15 +08:00
e02d1f6d66 fix(wle): 转仓代发(转x00005)不算转运异常 + 导出复用页面缓存
两件事,都是 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 行
2026-10-23 10:46:26 +08:00
38b902e35a Merge pull request 'fix(wle): 现场拦截(售后已签收)不再算仓储物流转运异常' (#83) 2026-10-23 08:47:17 +08:00
538f8edcbf Merge remote-tracking branch 'origin/main' into yanhui-wle-intercept-20261023 2026-10-23 08:44:48 +08:00
c9e84f2151 Merge pull request 'fix: 缺货补货监控改为只统计待发订单(排除已发/取消/拆单/合单),不限日期' (#81) from codex/lunanting-bin-replenish-pending-004 into main 2026-10-23 08:43:38 +08:00
9d80b4c73e fix(wle): 现场拦截(售后已签收)不再算仓储物流转运异常
业务口径(2026-10-23):发货后被快递当场拦下、货被售后仓签收的单,物流环节
其实已经结束,不该报「转运异常」。判据 = 售后单**签收单号 `l_id` 非空** 且
明细**实退数量 `r_qty` 合计 > 0**。

改动:
- registry 新增 INTERCEPT_AFTERSALE_SOURCES,登记两张互斥的店铺售后表
  (jst_returnAndRefundInfo/Item、jst_qmcust_refundlist/Item);
- service 新增 loadInterceptedOrders(),在主循环前一次性批量判定:
  · 只查「本来会报异常」的 oId(9800 单 → 几十单);
  · 两张表**并发**查(两表在 rpt 副本里都是堆表,全表扫 4.5s + 8.5s,并发后取 8.5s);
  · 售后表查询失败自动降级为「不剔除」,不让整页挂掉;
- 命中即从异常清单摘掉,但**保留发货分母**(货确实发出去了),异常率不被放大;
- exclusions 新增 interceptPackages/Orders/Reasons,页面页脚与钉钉推送各补一行说明。

实测(2026-10-22 批次):异常 36 → 33,剔除 3 单(9854409/9850736/9858367,
全是「拒收退货 + 实退 1 件 + 签收单号=原运单号 + 卖家已收货」);
10-21 批次剔除 2 单;同时 r_qty=0 的 9859198/9871523 正确地没被剔。

验证:
- node --test warehouseLogisticsException(+Notify):47/47 通过;
- 本地 5173 真渲染:表格 33 行、页脚显示「另有 3 单属现场拦截(售后已签收)」、无 pageerror;
- 后端 npm run check:469/471,2 个失败为既有平台差异(secretStore 权限位、cloudDatabase 负缓存)。
2026-10-23 08:43:35 +08:00
e43f17fde4 fix: 缺货补货监控改为「崔桥主仓·仅当日待补」并修正指标
- 看板只展示当日待补 SKU(待发订单>拣货位库存,需存储→拣货补货),去除充足/无待发干扰行
- 库存收紧到崔桥工厂仓主仓(jst_packInventoryInfo 加 wh_id=1),数据源对齐「箱及仓位库存·主仓」
- 补齐「有打单拣货待发单但货位库存视图无记录」的 SKU(拣货位为0也应进待补明细)
- 指标卡改为四项:实时代发订单数 / 拣货库存数 / 存储库存数 / 缺货数(缺货=待补SKU数,脚注含存储不足子项)
- 各库底待补明细=need+short 行;范围筛「待补货/仅缺货」;移除多仓选择器,固定崔桥
- 待发订单仍取 op_OrderNotDispatched 打单拣货/发货中 每日快照(两步 querySlow)
- 副本实测:当日快照待补 45 SKU(need42/short3),待发132件/拣货40件/存储2381件/缺口5件
2026-10-23 08:35:11 +08:00
db1185c19f Merge pull request 'fix(wst): 发货时效剔除「已作废」单 —— 问题单 / 发货前售后 / 售后已作废三条兜底' (#82) from yanhui-wst-void-fix-20261022 into main 2026-10-22 17:55:35 +08:00
4e4f8890c1 fix(wst): 发货时效剔除「已作废」单 —— 问题单 / 发货前售后 / 售后已作废三条兜底
业务反馈 3 单(so_id 3655421011854634 / 3658479011191285 / 110215007857459363)
在聚水潭后台都已作废,却仍出现在「仓储中心 24 小时发货时效」的超时明细里。

根因:
- 页面只认状态表 jst_order_express_details,这三单 order_status_name 仍是「发货中」
  + send_time 为空;售后状态是「暂无售后」—— 上游没把作废同步过来;
- 现有的作废兜底只认订单表 status ∈ Merged/Split/Cancelled 以及 is_merge/is_split,
  覆盖不到 status=Question(聚水潭后台显示「已作废/问题单」)和 labels 含「发货前售后」。

改动:
- registry 新增 VOID_QUESTION_STATUS='Question'、VOID_ORDER_LABEL_KEYWORDS=['发货前售后']、
  AFTERSALE_VOID_STATUS='已作废';
- 回订单表补事实时增投一个 `CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END`
  布尔列(不传 labels 原文,避免宽列回表;实测冷查 42.6s,与改前同量级);
- 状态表主查询增投一列 aftersale_status_name;
- 主循环在「发货中」分支之后判这三种作废证据,命中则并入 voidCount、不进考核。

⚠️ Question 不能直接塞进 VOID_ORDER_STATUS:那个数组在状态分类之前判定,
订单表 Question 的单有一大批在状态表是「异常」,会被从「异常待处理」清单里一起吞掉
(实测 heldCount 472 → 10,少掉 462 单)。故单独放在 SHIPPING_STATUS 之后判。

验证:
- 单测 19/19 通过(新增 38/39/40 三条作废形态用例,锁定 heldCount 不被影响);
- 真实库 13:00 轮:超时 3 单 → 1 单(9823980 / 9880810 已剔除),
  heldCount 保持 472,voidCount 4442 → 4444(恰好 +2);
- 剩余 9889575 未剔除:订单表 Delivering、无出库单、无任何作废标记(见 PR 正文说明)。

风险与回退:
- 仅收紧「发货中」分支的作废判定,不回退任何既有判据;
- 若业务认为 Question 单仍需考核,改 VOID_QUESTION_STATUS 一处即可恢复。
2026-10-22 17:54:34 +08:00
5b0c44c9e2 fix: 缺货补货监控「待发订单」改为聚水潭打单拣货/发货中快照口径
- loadPendingOrderMap 改用两步 querySlow(先取该仓最新快照日,再按日汇总 status='发货中'),
  替换会 60s 超时的 CTE+CROSS JOIN 写法(副本库实测 ~12s 返回 697 SKU/1598 件)
- 待发口径对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的发货中单据(每日 07:00 快照)
- 仅聚水潭线上店铺渠道,不含全民(qmcust) B2B 客户单(属预期收窄,前端已注明)
- 前端口径说明补充渠道范围与「不含全民」提示
2026-10-22 16:22:02 +08:00
d74b4b649a fix: 缺货补货监控改为只统计待发订单(排除已发/取消/拆单/合单),不限日期
All checks were successful
[Do] try bracketed context
Do auto-pass for待发订单 PR
2026-10-22 15:07:29 +08:00
8c96877244 Merge pull request 'fix: 修正缺货补货监控今日无单计数键不一致' (#80) from codex/lunanting-bin-replenish-fix-003 into main 2026-10-22 14:08:17 +08:00
a0b075dfdd fix: 修正缺货补货监控 summary 中「今日无单」计数(no_order 与 noOrder 键不一致导致漏计) 2026-10-22 14:07:22 +08:00
88913260a3 Merge pull request 'feat(wle): 钉钉推送明细按仓聚集,并给每个仓保底 1 条' (#79) 2026-10-22 14:04:50 +08:00
fe195114e4 feat(wle): 钉钉推送明细按仓聚集,并给每个仓保底 1 条
业务要求:推送明细「按照仓库来,相同仓库排序在一起」,方便各仓在群里只看自己那一段。

改动:
1. 明细按仓分桶后连续输出(groupRowsByWarehouse / bucketRowsByWarehouse)
   - 组间:按该仓异常包裹数降序,与上面「按仓(异常包裹)」块顺序一致;数量相同按首次出现顺序
   - 组内:保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),不重新排序
   - 仓名缺失的行统一归到「未知仓」一组,不会被拆成散行
2. 取前 N 条改为按仓配额(pickDetailRows)
   - 真数据验证时发现:140 条异常里山东青岗集仓占大头,直接 slice(0,40) 会让
     40 条明细全是同一个仓、其它 6 个仓一条都看不到
   - 规则:仓数 >= 额度时每仓 1 条(砍掉异常最少的仓);否则每仓先保底 1 条,
     剩余额度按各仓剩余条数比例分(最大余数法,总数正好等于额度)
3. 明细仍是 4 段「仓|快递单号|快递|异常类型」,编号全局连续,不带时间/时长

验证:
- 单测 19/19 通过(新增 4 条:同仓相邻+组内不乱序、未知仓归组、大仓不吃满额度、额度少于仓数)
- 本地真数据干跑(140 条异常 / 列 40 条 / 4219 字节):明细覆盖 7 个仓,
  同仓全部相邻、无任何仓被拆散;明细段数集合 [4]
  各仓条数:青岗集 31 / 崔桥 2 / 洛宁 2 / 睢宁 2 / 山东品牌 1 / 新苏北 1 / 崔桥品牌 1
- backend check-syntax:194 文件通过

风险与回退:只改推送正文的组装顺序与取数配额,不动查询、不动页面列表;
页面仍按时长降序展示全部明细。回退只需 revert 本提交。
2026-10-22 14:02:43 +08:00
1957330ebd Merge pull request 'feat: 缺货补货监控改为订单驱动(崔桥当日订单>拣货位库存即需从存储位补货)' (#78) from codex/lunanting-bin-replenish-order-002 into main 2026-10-22 14:02:19 +08:00
c374cbc713 feat: 缺货补货监控改为订单驱动(崔桥当日订单>拣货位库存即需从存储位补货)
- 货位口径按前缀:XA/XD/XE=拣货位,XX/X9/X5/X4=存储位
- 判断逻辑改为当日订单量>拣货位库存的超出部分=需从存储位补到拣货位的量(上限存储位库存)
- 订单取自 rpt.jst_Orders/jst_qmcust_orderlist(含组合装展开),按最近订单日
- 状态:需补货/存储不足/拣货位充足/今日无单;移除旧的货位清单导入(前缀规则取代)
- 前端列与说明同步更新
2026-10-22 14:01:35 +08:00
e9923d7383 Merge pull request 'fix(wle): 钉钉推送明细精简为「仓|快递单号|快递|异常类型」' (#76) from codex/yanhui-wle-detail-no-time-20261022 into main 2026-10-22 13:10:31 +08:00
9991b92325 Merge pull request 'fix(router): 补回 BinReplenishMonitor 的 lazy 声明,修复生产全站白屏' (#77) from codex/hotfix-bin-replenish-lazy-20261022 into main 2026-10-22 12:08:20 +08:00
9b1ec36c70 fix(router): 补回 BinReplenishMonitor 的 lazy 声明,修复全站白屏
现象:生产 https://ck.yydd.ai:8443 打开任意页面都是白屏,连登录页都进不去。
控制台报 `BinReplenishMonitor is not defined`,一个 /api 请求都没发出去。

根因:PR #74 新增「缺货补货监控」时,在 router/index.jsx 的路由表里写了
`element: renderLazyComponent(BinReplenishMonitor)`,但没有像其它 22 个页面那样
在文件顶部补 `const BinReplenishMonitor = lazy(() => import('../pages/BinReplenishMonitor'))`。
该行在模块顶层求值 → 加载即抛 ReferenceError → 整个 React 应用挂掉。
`vite build` 不会报错(未声明标识符在 JS 里是合法语法),所以一路构建通过、部署后白屏。
PR #75 只补了后端的 binReplenishRoutes 引入,前端的这处漏声明一直没人发现。

修复:
1. router/index.jsx 补上 lazy 声明,并留下注释说明「漏一个就是全站白屏」。
2. 新增 frontend/scripts/verify-router-lazy.mjs,静态扫描 router/index.jsx:
   凡是 renderLazyComponent(X) 用到的 X,必须在同文件有 `const X = lazy(` 声明,
   否则构建直接失败并指出缺少的组件名与行号。已挂到 `npm run build` 前置。

验证:
- 该检查对修复前的 origin/main 版本报错并准确指出
  `BinReplenishMonitor (router/index.jsx:143)`;对修复后版本通过(23 used / 23 declared)。
- eslint 通过;vite build 成功(4270 modules / 34s)。
- playwright 真渲染:修复前 /login 白屏 + pageerror;修复后 /login 完整渲染登录表单
  (123 字、3 个输入框),两个业务路由正常回到登录引导,无致命错误。
2026-10-22 12:06:48 +08:00
7939816b64 fix(wle): 推送明细再去掉末尾「待处理小时数」
业务三次要求(2026-10-22):明细最后面的超时时间也不要。

- buildDetailLine 现在是 4 段:仓|快递单号|快递|异常类型
- 删除仅此处使用的 formatHours()(不留死代码)
- 「无快递单号时退回内部单号」的兜底按业务确认保留
- 文件头/函数注释补第三条口径,单测断言正好 4 段且不含 17.3h 这类时长
2026-10-22 11:56:11 +08:00
b223db7961 fix(wle): 推送明细第二列改为快递单号
业务二次要求(2026-10-22):明细里显示快递单号,不要内部单号。

- buildDetailLine 第二段从 `oId` 改为 `trackingNo`(上游 `logistic_id`)
- 未揽收 / 无物流单的包裹本来就没有快递单号 → 退回 `oId`,
  否则这条在群里无法定位(不让它变成「无单号」)
- 单测:样例带 trackingNo 并断言内部单号不出现;补「无 trackingNo 时退回 oId」用例
- 文件头与函数注释同步口径
2026-10-22 11:46:06 +08:00
57debbe022 fix(wle): 钉钉推送明细去掉发货时间列
业务反馈(2026-10-22):推送明细里不需要发货时间。

- buildDetailLine 从「仓|内部单号|快递|异常类型|发货时间|待处理」
  改为「仓|内部单号|快递|异常类型|待处理小时数」
- 删除仅此处使用的 shortTime() 及其导出,不留死代码
- 文件头补口径说明,防止以后把 sendTime 加回去
- 单测同步:断言新列序,并显式断言消息里不出现 sendTime(传了也不能渲染)
2026-10-22 11:40:11 +08:00
03fd3f97f3 Merge pull request 'fix: 补回 binReplenishRoutes 引入,修复后端启动失败' (#75) from codex/lunanting-bin-replenish-001 into main 2026-10-22 11:14:30 +08:00
f786a92387 Merge pull request 'feat: 新增「缺货补货监控」看板(拣货位缺货 → 从存货位补货)' (#74) from codex/lunanting-bin-replenish-001 into main
Merge pull request #74 from codex/lunanting-bin-replenish-001
2026-10-22 11:10:31 +08:00
17 changed files with 1158 additions and 772 deletions

View File

@ -1,22 +1,13 @@
'use strict'; 'use strict';
const express = require('express'); const express = require('express');
const multer = require('multer');
const { const {
clearPickBins,
getPickBinMeta,
getReplenishExportRows, getReplenishExportRows,
getReplenishList, getReplenishList,
importPickBins,
resolveWarehouseKey,
} = require('../services/binReplenishService'); } = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook'); const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router(); const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
});
const EXPORT_COLUMNS = [ const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' }, { key: 'skuId', title: '商品编码' },
@ -24,16 +15,15 @@ const EXPORT_COLUMNS = [
{ key: 'supplier', title: '供应商' }, { key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' }, { key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' }, { key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存货位库存' }, { key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' }, { key: 'totalQty', title: '现有库存合计' },
{ key: 'targetQty', title: '目标拣货位库存' }, { key: 'orderQty', title: '待发订单' },
{ key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' }, { key: 'suggestQty', title: '建议补货量' },
{ key: 'avgSale', title: '近15天日销' }, { key: 'shortfall', title: '存储缺口' },
{ key: 'pickBinText', title: '拣货位(货位号/数量)' },
{ key: 'storageBinText', title: '存货位(前5个/数量)' },
]; ];
/** GET /api/bin-replenish/list —— 缺货补货监控列表 */ /** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
router.get('/list', async (req, res, next) => { router.get('/list', async (req, res, next) => {
try { try {
const payload = await getReplenishList({ const payload = await getReplenishList({
@ -42,7 +32,6 @@ router.get('/list', async (req, res, next) => {
status: req.query.status, status: req.query.status,
page: req.query.page, page: req.query.page,
pageSize: req.query.pageSize, pageSize: req.query.pageSize,
targetDays: req.query.targetDays,
refresh: req.query.refresh, refresh: req.query.refresh,
}); });
res.json({ success: true, data: payload }); res.json({ success: true, data: payload });
@ -59,21 +48,16 @@ router.get('/export', async (req, res, next) => {
warehouse: req.query.warehouse, warehouse: req.query.warehouse,
keyword: req.query.keyword, keyword: req.query.keyword,
status: req.query.status, status: req.query.status,
targetDays: req.query.targetDays,
}); });
const rows = payload.rows.map((row) => ({ const fileName = `${payload.warehouse.shortName}待补明细`;
...row,
pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
}));
sendWorkbookDownload(res, { sendWorkbookDownload(res, {
fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`, fileName,
format, format,
sheets: [{ sheets: [{
name: '缺货补货监控', name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title), header: EXPORT_COLUMNS.map((column) => column.title),
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])), rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}], }],
}); });
} catch (error) { } catch (error) {
@ -81,51 +65,6 @@ router.get('/export', async (req, res, next) => {
} }
}); });
/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */
router.get('/pick-bins', async (req, res, next) => {
try {
const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse));
res.json({ success: true, data: meta });
} catch (error) {
next(error);
}
});
/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */
router.post('/pick-bins', upload.single('file'), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' });
return;
}
// multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8
const rawName = req.file.originalname || '';
const fileName = /[\u00c0-\u00ff]/.test(rawName)
? Buffer.from(rawName, 'latin1').toString('utf8')
: rawName;
const result = await importPickBins({
buffer: req.file.buffer,
fileName,
warehouseKey: req.body?.warehouse || req.query.warehouse,
importedBy: req.user?.username || req.user?.name || null,
});
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */
router.delete('/pick-bins', async (req, res, next) => {
try {
const result = await clearPickBins(req.query.warehouse);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */ /** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => { router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } }); res.json({ success: true, data: { ok: true } });

View File

@ -9,6 +9,13 @@
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。 * ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。 * 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。 * N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置, * ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
@ -74,34 +81,97 @@ function cleanCell(value, fallback = '') {
return text || fallback; return text || fallback;
} }
/** '2026-10-21 16:00:00' → '10-21 16:00';空值给「—」 */ /**
function shortTime(value) { * 明细一行:仓|快递单号|快递|异常类型
const text = cleanCell(value); * ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
if (!text) return '—'; * 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/); * ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
if (!match) return text.slice(0, 16); * 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
return `${match[2]}-${match[3]} ${match[4]}:${match[5]}`; * ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
} */
function formatHours(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return `${Math.round(num * 10) / 10}h`;
}
/** 明细一行:仓|内部单号|快递|异常类型|发货时间|待处理 */
function buildDetailLine(row, index) { function buildDetailLine(row, index) {
const parts = [ const parts = [
cleanCell(row?.warehouseName, '未知仓'), cleanCell(row?.warehouseName, '未知仓'),
cleanCell(row?.oId, '无单号'), cleanCell(row?.trackingNo || row?.oId, '无单号'),
cleanCell(row?.carrier, '未知快递'), cleanCell(row?.carrier, '未知快递'),
cleanCell(row?.exceptionLabel, '异常'), cleanCell(row?.exceptionLabel, '异常'),
shortTime(row?.sendTime),
formatHours(row?.pendingHours),
]; ];
return `${index}. ${parts.join('|')}`; return `${index}. ${parts.join('|')}`;
} }
/**
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
* 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。
*/
function bucketRowsByWarehouse(rows) {
const buckets = new Map();
const firstSeen = new Map();
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
const key = cleanCell(row?.warehouseName, '未知仓');
if (!buckets.has(key)) {
buckets.set(key, []);
firstSeen.set(key, index);
}
buckets.get(key).push(row);
});
return Array.from(buckets.entries())
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
}
/**
* 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。
* - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。
* - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。
*/
function groupRowsByWarehouse(rows) {
return bucketRowsByWarehouse(rows).flatMap((group) => group.items);
}
/**
* 按仓分配明细额度,返回**已按仓聚集**的行。
* ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度,
* 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。
* 规则:
* ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓;
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。
*/
function pickDetailRows(rows, limit) {
const groups = bucketRowsByWarehouse(rows);
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
if (quota === 0) return [];
if (quota >= total) return groups.flatMap((group) => group.items);
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
const remaining = quota - groups.length;
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
if (remaining > 0 && leftTotal > 0) {
alloc.forEach((item) => {
item.exact = (item.left / leftTotal) * remaining;
item.extra = Math.min(item.left, Math.floor(item.exact));
});
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
// 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度
[...alloc]
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
.forEach((item) => {
if (assigned >= remaining || item.extra >= item.left) return;
item.extra += 1;
assigned += 1;
});
alloc.forEach((item) => {
item.take += item.extra;
});
}
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
}
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/ /** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
function buildCenterLines(data = {}) { function buildCenterLines(data = {}) {
const rows = Array.isArray(data.rows) ? data.rows : []; const rows = Array.isArray(data.rows) ? data.rows : [];
@ -181,14 +251,21 @@ function buildNotifyText(data = {}, options = {}) {
const excludedCount = (Number(exclusions.carrierPackages) || 0) const excludedCount = (Number(exclusions.carrierPackages) || 0)
+ (Number(exclusions.voidedPackages) || 0) + (Number(exclusions.voidedPackages) || 0)
+ (Number(exclusions.nonRealPackages) || 0); + (Number(exclusions.nonRealPackages) || 0)
+ (Number(exclusions.transferPackages) || 0);
if (excludedCount > 0) { if (excludedCount > 0) {
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`); head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
}
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
const interceptCount = Number(exclusions.interceptPackages) || 0;
if (interceptCount > 0) {
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
} }
const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1)); // 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
const wantCount = Math.min(maxDetailRows, detailAll.length); const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
const wantCount = detailAll.length;
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出) // 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
const buildTail = (listed) => { const buildTail = (listed) => {
@ -425,9 +502,10 @@ module.exports = {
buildTypeLine, buildTypeLine,
chinaDateKey, chinaDateKey,
chinaHourMinute, chinaHourMinute,
groupRowsByWarehouse,
pickDetailRows,
cleanCell, cleanCell,
pushWarehouseLogisticsExceptionNotify, pushWarehouseLogisticsExceptionNotify,
runWarehouseLogisticsExceptionNotifyJob, runWarehouseLogisticsExceptionNotifyJob,
shortTime,
startWarehouseLogisticsExceptionNotifyScheduler, startWarehouseLogisticsExceptionNotifyScheduler,
}; };

View File

@ -1,60 +1,59 @@
'use strict'; 'use strict';
/** /**
* 缺货补货监控 —— 拣货位缺货 / 从存货位补货 * 缺货补货监控(订单驱动版,2026-10-22 重写)
* ========================================================================== * ==========================================================================
* 业务需求(2026-10-22 用户确认): * 业务需求(用户确认):
* 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。 * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
* *
* ── 数据源 ─────────────────────────────────────────────────────────── * ── 数据源 ───────────────────────────────────────────────────────────
* rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id * 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
* · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱) * —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
* · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级) * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), * 按 wh_id 会把多仓混成一个桶。
* 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。
* ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW),
* 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动),
* 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热,
* 不要把它改回「每次请求都回源」。
* *
* ── 拣货位怎么认定(关键)───────────────────────────────────────────── * ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
* 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径: * 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
* 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空 * 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
* (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位) * 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
* 本页默认就用这个口径;如果业务另有一份拣货位清单, * 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
* 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。 * **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
* *
* ── 指标口径 ───────────────────────────────────────────────────────── * ── 判断逻辑(核心)──────────────────────────────────────────────────
* 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计 * 对每一个 SKU(在拣货/存储位上有库存):
* 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计 * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* 现有库存 totalQty = pickQty + storageQty * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7 * 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
* 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整 * 超出订单数 excess = max(0, orderQty − pickQty)
* —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
* *
* 状态: * 状态:
* pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补) * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购) * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* low 拣货位偏低(0 < pickQty < target,存货位有货) * enough 拣货位充足(0 < orderQty ≤ pickQty)
* ok 正常 * no_order 无待发单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
* *
* 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale), * ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
* 只用于算目标和排序;取不到时目标按 10 件兜底。 * 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/ */
const crypto = require('crypto');
const XLSX = require('xlsx');
const cloudDb = require('../config/cloudDatabase'); const cloudDb = require('../config/cloudDatabase');
const database = require('../config/database');
/** /**
* 仓库清单:**按 wms_co_id 取数,不能用 wh_id** * 仓库清单:**按 wms_co_id 取数**。
* --------------------------------------------------------------------------- * 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
* 2026-10-22 实测:jst_packInventoryInfo.wh_id 是「货主」(简尚=1), * 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
* 崔桥/山东/横林二/苏北/河南的仓位记录**全部挂在 wh_id=1 底下**,
* 按 wh_id 汇总会把 10 个仓混成一个桶。真实仓库是 wms_co_id。
* 这里只列「有真实拣选位」的仓(拣选位 = pack_type='Bin' 且 bin_group 为空),
* 口径与「拣选库位动销分析」保持一致。
*/ */
const WAREHOUSES = Object.freeze({ const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 }, cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
@ -72,28 +71,39 @@ const WAREHOUSES = Object.freeze({
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order); const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
const DEFAULT_TARGET_DAYS = 7;
const MIN_TARGET_QTY = 10;
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐 const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000; const MAX_PAGE_SIZE = 5000;
// ── 货位前缀规则(用户指定)────────────────────────────────────────
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
// ── 待发口径:聚水潭「打单拣货/发货中」快照(rpt.op_OrderNotDispatched)────────
// 该表是每日快照;取该仓「最新快照日」且 status='发货中' 的行。
// 历史标签在 2026-03 之前叫「发货中(打单拣货)」,两个都认,避免换名漏数。
// 不含:已发/取消/删除/拆单/合单、异常、已付款待审核、待付款。
const PENDING_STATUS_LIST = Object.freeze(['发货中', '发货中(打单拣货)']);
const PENDING_STATUS_IN = PENDING_STATUS_LIST.map((s) => `'${s}'`).join(',');
const PENDING_ORDER_SCOPE = '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)';
const STATUS = Object.freeze({ const STATUS = Object.freeze({
PICK_OUT: 'pickout', SHORT: 'short',
EMPTY: 'empty', NEED: 'need',
LOW: 'low', ENOUGH: 'enough',
NO_PICK: 'nopick', NO_ORDER: 'no_order',
OK: 'ok',
}); });
const STATUS_LABELS = Object.freeze({ const STATUS_LABELS = Object.freeze({
pickout: '拣货位缺货', short: '存储不足',
empty: '无货可补', need: '需补货',
low: '拣货位偏低', enough: '拣货位充足',
nopick: '未设拣货位', no_order: '无待发单',
ok: '正常',
}); });
const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 }); // 排序权重:最紧急的排最前
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
const cacheStore = new Map(); const cacheStore = new Map();
let cacheEpoch = 0; let cacheEpoch = 0;
@ -111,10 +121,6 @@ function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100; return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
} }
function normalizeBin(value) {
return toText(value, 64).toUpperCase().replace(/\s+/g, '');
}
function resolveWarehouseKey(value) { function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase(); const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY; return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
@ -132,331 +138,198 @@ function resolveCacheTtlMs() {
return num; return num;
} }
function resolveTargetDays(value) {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS;
return Math.min(Math.round(num), 90);
}
// ── 拣货位清单(本地库)────────────────────────────────────────────
async function loadPickBinRows(warehouseKey) {
const rows = await database.local.query(
'SELECT bin_code AS binCode, imported_at AS importedAt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
[warehouseKey]
);
return rows || [];
}
async function loadLatestPickBinBatch(warehouseKey) {
const rows = await database.local.query(
`SELECT batch_id AS batchId, file_name AS fileName, imported_rows AS importedRows,
imported_by AS importedBy, imported_at AS importedAt
FROM bin_replenish_import_batch WHERE warehouse_key = ?
ORDER BY imported_at DESC LIMIT 1`,
[warehouseKey]
);
return rows?.[0] || null;
}
async function getPickBinMeta(warehouseKey) {
const [bins, batch] = await Promise.all([
loadPickBinRows(warehouseKey),
loadLatestPickBinBatch(warehouseKey),
]);
return {
count: bins.length,
sample: bins.slice(0, 20).map((row) => row.binCode),
lastImport: batch,
source: bins.length > 0 ? 'import' : 'fallback',
};
}
/** 从任意表格/文本里抽取货位号:扫描所有单元格,按逗号/分号/换行拆开,保留 ASCII 货位形态 */
const BIN_TOKEN_PATTERN = /^[0-9A-Z]+(?:-[0-9A-Z]+){0,5}$/;
// 表头 / 常见非货位词,避免把标题行当成货位导进来
const BIN_TOKEN_BLOCKLIST = new Set(['BIN', 'CODE', 'SKU', 'SNO', 'NO', 'ID', 'LOCATION', 'LOC', 'POSITION']);
function extractBinCodes(matrix = []) {
const codes = new Set();
let candidates = 0;
matrix.forEach((row) => {
(Array.isArray(row) ? row : [row]).forEach((cell) => {
if (cell === null || cell === undefined) return;
if (cell instanceof Date) return;
const text = String(cell).trim();
if (!text) return;
const tokens = text.split(/[,,;;\s\r\n\t|、]+/u).filter(Boolean);
tokens.forEach((token) => {
const code = normalizeBin(token);
if (!code) return;
candidates += 1;
if (code.length < 3 || code.length > 64) return;
if (BIN_TOKEN_BLOCKLIST.has(code)) return;
if (!BIN_TOKEN_PATTERN.test(code)) return;
// 纯数字/纯字母也放过(有些仓用 A01、1234 这种),但单个字符的不要
if (/^\d+$/.test(code) && code.length < 2) return;
codes.add(code);
});
});
});
return { codes: [...codes], candidates };
}
async function importPickBins({ buffer, fileName, warehouseKey, importedBy }) {
if (!buffer || !buffer.length) {
const error = new Error('文件内容为空,请重新选择');
error.statusCode = 400;
throw error;
}
const key = resolveWarehouseKey(warehouseKey);
let workbook;
try {
workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false });
} catch (error) {
const err = new Error(`文件解析失败:${error.message}`);
err.statusCode = 400;
throw err;
}
const matrix = [];
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
rows.forEach((row) => matrix.push(row));
});
const { codes, candidates } = extractBinCodes(matrix);
if (!codes.length) {
const error = new Error('没有识别到货位号。清单里请只放货位号(如 XA01-01-11),或把它放在单独一列。');
error.statusCode = 400;
throw error;
}
const batchId = crypto.randomBytes(8).toString('hex');
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
const CHUNK = 500;
for (let i = 0; i < codes.length; i += CHUNK) {
const chunk = codes.slice(i, i + CHUNK);
const placeholders = chunk.map(() => '(?,?,?,NOW())').join(',');
const params = [];
chunk.forEach((code) => {
params.push(key, code, batchId);
});
// eslint-disable-next-line no-await-in-loop
await database.local.query(
`INSERT INTO bin_replenish_pick_bin (warehouse_key, bin_code, batch_id, imported_at) VALUES ${placeholders}`,
params
);
}
await database.local.query(
`INSERT INTO bin_replenish_import_batch
(batch_id, warehouse_key, file_name, total_cells, imported_rows, imported_by, imported_at)
VALUES (?,?,?,?,?,?,NOW())`,
[batchId, key, toText(fileName, 255) || null, candidates, codes.length, toText(importedBy, 100) || null]
);
invalidateCache(key);
return {
batchId,
warehouse: WAREHOUSES[key],
importedRows: codes.length,
totalCells: candidates,
fileName: toText(fileName, 255) || null,
sample: codes.slice(0, 20),
};
}
async function clearPickBins(warehouseKey) {
const key = resolveWarehouseKey(warehouseKey);
const rows = await database.local.query(
'SELECT COUNT(*) AS cnt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
[key]
);
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
invalidateCache(key);
return { warehouse: WAREHOUSES[key], removed: Number(rows?.[0]?.cnt || 0) };
}
// ── 副本取数 ──────────────────────────────────────────────────────── // ── 副本取数 ────────────────────────────────────────────────────────
/** /**
* 按 SKU + 货位 + 记录类型聚合。 * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
* 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s, * 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
* 聚合后 23,751 行约 43s,是当前可用的最快形态。
*/ */
async function loadReplicaRows(warehouse) { async function loadReplicaInventory(warehouse) {
const sql = ` const sql = `
SELECT p.sku_id AS skuId, SELECT p.sku_id AS skuId,
p.bin AS bin, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
p.pack_type AS packType, THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
p.bin_group AS binGroup, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
SUM(p.qty) AS qty, THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
MAX(s.name) AS productName, MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId WHERE p.wms_co_id = @wmsCoId
GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`; AND p.wh_id = 1
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`;
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
} }
/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */ /**
async function loadDailySaleMap(warehouse) { * 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
const rows = await cloudDb.query( * 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
`SELECT sku_id AS skuId, fifteenAvgSale AS avgSale */
FROM rpt.pm_stockMonitor async function loadSkuNames(skuIds) {
WHERE warehouse = @warehouseName const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
AND CONVERT(varchar(10), date, 23) = ( if (!uniq.length) return new Map();
SELECT CONVERT(varchar(10), MAX(date), 23) const map = new Map();
FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName const CHUNK = 400;
)`, for (let i = 0; i < uniq.length; i += CHUNK) {
{ warehouseName: warehouse.name }, const chunk = uniq.slice(i, i + CHUNK);
{ requestTimeout: 300000 } const params = {};
const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
chunk.forEach((id, idx) => { params[`s${idx}`] = id; });
const rows = await cloudDb.querySlow(
`SELECT sku_id AS skuId, name AS productName, supplier_name AS supplier
FROM rpt.jst_sku WHERE sku_id IN (${ph})`,
params,
{ requestTimeout: 120000 }
);
(rows || []).forEach((r) => {
const id = toText(r.skuId, 120);
if (id) map.set(id, { productName: toText(r.productName, 300), supplier: toText(r.supplier, 120) });
});
}
return map;
}
/**
* 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
* 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
* 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
*
* ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
* 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
* 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
*/
async function loadPendingOrderMap(warehouse) {
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const slowOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 300000 };
// step1:该仓最新快照日(local_updated≈每日 07:00 一批,MAX 落在最新那批)
const snapRows = await runQuery(
`SELECT CAST(MAX(local_updated) AS date) AS snapDate
FROM rpt.op_OrderNotDispatched
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId: warehouse.wmsCoId },
slowOpts
);
const snapDate = snapRows && snapRows[0] && snapRows[0].snapDate;
if (!snapDate) return new Map();
// step2:最新快照日当天的「发货中」待发量,按 sku_id 汇总(组合装已展开)
const rows = await runQuery(
`SELECT o.sku_id AS skuId,
SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.op_OrderNotDispatched o
WHERE o.wms_co_id = @wmsCoId
AND o.status IN (${PENDING_STATUS_IN})
AND o.local_updated >= @snapDate
AND o.local_updated < DATEADD(day, 1, @snapDate)
AND ISNULL(o.sku_id, '') <> ''
GROUP BY o.sku_id`,
{ wmsCoId: warehouse.wmsCoId, snapDate },
slowOpts
); );
const map = new Map(); const map = new Map();
(rows || []).forEach((row) => { (rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120); const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.avgSale)); if (skuId) map.set(skuId, toNumber(row.orderQty));
}); });
return map; return map;
} }
// ── 组装 ──────────────────────────────────────────────────────────── // ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) { function buildSkuRows({ invRows, orderMap }) {
const skuMap = new Map(); const rows = [];
(invRows || []).forEach((row) => {
(replicaRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120); const skuId = toText(row.skuId, 120);
if (!skuId) return; if (!skuId) return;
const bin = normalizeBin(row.bin); const pickQty = round2(row.pickQty);
const qty = toNumber(row.qty); const storageQty = round2(row.storageQty);
const isPick = useFallback const orderQty = round2(toNumber(orderMap.get(skuId), 0));
// 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空 const excess = Math.max(0, round2(orderQty - pickQty));
// (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位)
? (toText(row.packType) === 'Bin' && !toText(row.binGroup))
: (bin ? pickBinSet.has(bin) : false);
let entry = skuMap.get(skuId); let status = STATUS.NO_ORDER;
if (!entry) { let suggestQty = 0;
entry = { let shortfall = 0;
skuId, let pickEmpty = false;
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '', if (orderQty > 0) {
pickQty: 0, if (pickQty <= 0) pickEmpty = true;
storageQty: 0, if (excess > 0) {
pickBins: [], const can = Math.min(excess, storageQty);
storageBins: [], suggestQty = round2(can);
}; if (storageQty >= excess) {
skuMap.set(skuId, entry); status = STATUS.NEED;
} else {
status = STATUS.SHORT;
shortfall = round2(excess - storageQty);
}
} else {
status = STATUS.ENOUGH;
}
} }
if (!entry.productName) entry.productName = toText(row.productName, 300) || '';
if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || '';
if (isPick) {
entry.pickQty += qty;
entry.pickBins.push({ bin: bin || '未维护', qty });
} else {
entry.storageQty += qty;
entry.storageBins.push({ bin: bin || '未维护', qty });
}
});
const rows = [];
skuMap.forEach((entry) => {
const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0);
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays));
const pickQty = round2(entry.pickQty);
const storageQty = round2(entry.storageQty);
let status = STATUS.OK;
if (entry.pickBins.length === 0) {
// 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」,
// 不能混进待补货清单,否则会刷出几千条误报。
status = STATUS.NO_PICK;
} else if (pickQty <= 0) {
status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY;
} else if (pickQty < targetQty && storageQty > 0) {
status = STATUS.LOW;
}
const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW
? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty)))
: 0;
const pickBins = entry.pickBins
.slice()
.sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin));
const storageBins = entry.storageBins
.filter((item) => item.qty > 0)
.sort((a, b) => b.qty - a.qty)
.slice(0, 5);
rows.push({ rows.push({
skuId: entry.skuId, skuId,
productName: entry.productName, productName: toText(row.productName, 300) || '',
supplier: entry.supplier, supplier: toText(row.supplier, 120) || '',
status, status,
statusLabel: STATUS_LABELS[status], statusLabel: STATUS_LABELS[status],
pickQty, pickQty,
storageQty, storageQty,
totalQty: round2(pickQty + storageQty), totalQty: round2(pickQty + storageQty),
targetQty, orderQty,
excess,
suggestQty, suggestQty,
avgSale: round2(avgSale), shortfall,
pickBinCount: entry.pickBins.length, pickEmpty,
pickBins,
storageBins,
hasPickSlot: entry.pickBins.length > 0,
}); });
}); });
return rows; return rows;
} }
/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */ /**
function applyTargetDays(rows, targetDays) { * 汇总只统计「待补」SKU(need / short)——
if (targetDays === DEFAULT_TARGET_DAYS) return rows; * 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
return rows.map((row) => { * 实时代发订单数 = 待补 SKU 待发件数合计
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(row.avgSale * targetDays)); * 拣货库存数 = 待补 SKU 拣货位库存合计
const suggestQty = (row.status === STATUS.PICK_OUT || row.status === STATUS.LOW) * 存储库存数 = 待补 SKU 存储位库存合计
? Math.max(0, Math.min(Math.ceil(row.storageQty), Math.ceil(targetQty - row.pickQty))) * 缺货数 = 存储也补不齐的 SKU 数(status=short)
: 0; */
return { ...row, targetQty, suggestQty }; function buildSummary(rows) {
});
}
function buildSummary(rows, meta) {
const summary = { const summary = {
skuTotal: rows.length, skuTotal: 0,
pickout: 0, need: 0,
empty: 0, short: 0,
low: 0, orderQtyTotal: 0,
nopick: 0, pickTotal: 0,
ok: 0, storageTotal: 0,
suggestQtyTotal: 0, suggestQtyTotal: 0,
pickoutQtyTotal: 0, exceedTotal: 0,
storageQtyTotal: 0, shortfallTotal: 0,
withPickSlot: 0,
source: meta.source,
pickBinCount: meta.pickBinCount,
}; };
rows.forEach((row) => { rows.forEach((row) => {
if (summary[row.status] !== undefined) summary[row.status] += 1; summary.skuTotal += 1;
if (row.status === STATUS.NEED) summary.need += 1;
else if (row.status === STATUS.SHORT) summary.short += 1;
summary.orderQtyTotal += row.orderQty || 0;
summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0;
summary.suggestQtyTotal += row.suggestQty || 0; summary.suggestQtyTotal += row.suggestQty || 0;
summary.storageQtyTotal += row.storageQty || 0; summary.exceedTotal += row.excess || 0;
if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0; summary.shortfallTotal += row.shortfall || 0;
if (row.hasPickSlot) summary.withPickSlot += 1;
}); });
summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal);
summary.suggestQtyTotal = round2(summary.suggestQtyTotal); summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal); summary.exceedTotal = round2(summary.exceedTotal);
summary.storageQtyTotal = round2(summary.storageQtyTotal); summary.shortfallTotal = round2(summary.shortfallTotal);
return summary; return summary;
} }
async function loadDataset(warehouse, { refresh = false } = {}) { async function loadDataset(warehouse, { refresh = false } = {}) {
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
const cacheKey = warehouse.key; const cacheKey = warehouse.key;
const cached = cacheStore.get(cacheKey); const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs(); const ttl = resolveCacheTtlMs();
@ -466,31 +339,36 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
} }
const epoch = cacheEpoch; const epoch = cacheEpoch;
const [replicaRows, pickBins, dailySaleMap] = await Promise.all([ const [invRows, orderMap] = await Promise.all([
loadReplicaRows(warehouse), loadReplicaInventory(warehouse),
loadPickBinRows(warehouse.key), loadPendingOrderMap(warehouse).catch(() => new Map()),
loadDailySaleMap(warehouse).catch(() => new Map()),
]); ]);
const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean)); // 把「有打单拣货待发单、但货位库存视图里没有记录」的 SKU 也补进明细,
const useFallback = pickBinSet.size === 0; // 否则这批最该补货(拣货位为 0)的行会被漏掉。
const rows = buildSkuRows({ const invKeys = new Set((invRows || []).map((r) => toText(r.skuId, 120)));
replicaRows, const orderOnly = [...orderMap.keys()]
pickBinSet, .filter((k) => !invKeys.has(k))
dailySaleMap, .map((k) => ({ skuId: k, pickQty: 0, storageQty: 0, productName: null, supplier: null }));
targetDays: DEFAULT_TARGET_DAYS, const combined = (invRows || []).concat(orderOnly);
useFallback, if (orderOnly.length) {
}); const nameMap = await loadSkuNames(orderOnly.map((r) => r.skuId));
orderOnly.forEach((r) => {
const n = nameMap.get(r.skuId);
if (n) { r.productName = n.productName; r.supplier = n.supplier; }
});
}
const rows = buildSkuRows({ invRows: combined, orderMap });
const dataset = { const dataset = {
at: now, at: now,
rows, rows,
warehouse, warehouse,
orderScope: PENDING_ORDER_SCOPE,
meta: { meta: {
source: useFallback ? 'fallback' : 'import',
pickBinCount: pickBinSet.size,
dailySaleSkus: dailySaleMap.size,
rowCount: rows.length, rowCount: rows.length,
orderSkuCount: orderMap.size,
}, },
}; };
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据 // 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
@ -504,28 +382,28 @@ function invalidateCache(warehouseKey) {
else cacheStore.clear(); else cacheStore.clear();
} }
function matchesStatus(row, status) {
const key = toText(status, 20).toLowerCase();
if (!key || key === 'all') return true;
if (key === 'action' || key === 'todo') {
return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY;
}
if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW;
return row.status === key;
}
function sortRows(rows) { function sortRows(rows) {
return rows.slice().sort((a, b) => ( return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0) (STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0) || (b.suggestQty || 0) - (a.suggestQty || 0)
|| (b.excess || 0) - (a.excess || 0)
|| String(a.skuId).localeCompare(String(b.skuId)) || String(a.skuId).localeCompare(String(b.skuId))
)); ));
} }
function filterRows(rows, { keyword, status }) { /**
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 SKU(need / short)。
* status='short' 时仅保留存储不足(缺货)的 SKU。
*/
function filterReplenish(rows, { keyword, status }) {
const kw = toText(keyword, 120).toLowerCase(); const kw = toText(keyword, 120).toLowerCase();
const onlyShort = toText(status, 12).toLowerCase() === 'short';
return rows.filter((row) => { return rows.filter((row) => {
if (!matchesStatus(row, status)) return false; if (onlyShort) {
if (row.status !== STATUS.SHORT) return false;
} else if (row.status !== STATUS.NEED && row.status !== STATUS.SHORT) {
return false;
}
if (!kw) return true; if (!kw) return true;
return row.skuId.toLowerCase().includes(kw) return row.skuId.toLowerCase().includes(kw)
|| String(row.productName || '').toLowerCase().includes(kw) || String(row.productName || '').toLowerCase().includes(kw)
@ -535,55 +413,42 @@ function filterRows(rows, { keyword, status }) {
async function getReplenishList(options = {}) { async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse); const warehouse = resolveWarehouse(options.warehouse);
const targetDays = resolveTargetDays(options.targetDays);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const replenished = filterReplenish(dataset.rows, options);
// targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量 const sorted = sortRows(replenished);
const rows = applyTargetDays(dataset.rows, targetDays); const summary = buildSummary(replenished);
const filtered = sortRows(filterRows(rows, options));
const pickBinMeta = await getPickBinMeta(warehouse.key);
const summary = buildSummary(rows, {
source: pickBinMeta.source,
pickBinCount: pickBinMeta.count,
});
const page = Math.max(1, Number.parseInt(options.page, 10) || 1); const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50)); const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return { return {
rows: filtered.slice(start, start + pageSize), rows: sorted.slice(start, start + pageSize),
total: filtered.length, total: sorted.length,
page, page,
pageSize, pageSize,
summary, summary,
warehouse, warehouse,
warehouses: Object.values(WAREHOUSES), warehouses: Object.values(WAREHOUSES),
targetDays, orderScope: PENDING_ORDER_SCOPE,
cacheHit: dataset.cacheHit, cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(), updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(), cacheTtlMs: resolveCacheTtlMs(),
pickBinMeta,
dailySaleSkus: dataset.meta.dailySaleSkus,
}; };
} }
async function getReplenishExportRows(options = {}) { async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse); const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const targetDays = resolveTargetDays(options.targetDays); const replenished = filterReplenish(dataset.rows, options);
const rows = applyTargetDays(dataset.rows, targetDays);
return { return {
warehouse, warehouse,
rows: sortRows(filterRows(rows, options)), orderScope: PENDING_ORDER_SCOPE,
summary: buildSummary(rows, { rows: sortRows(replenished),
source: dataset.meta.source, summary: buildSummary(replenished),
pickBinCount: dataset.meta.pickBinCount,
}),
}; };
} }
/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */ /** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() { async function warmBinReplenishCache() {
try { try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
@ -597,16 +462,15 @@ async function warmBinReplenishCache() {
module.exports = { module.exports = {
DEFAULT_WAREHOUSE_KEY, DEFAULT_WAREHOUSE_KEY,
PICK_PREFIXES,
STORE_PREFIXES,
STATUS, STATUS,
STATUS_LABELS, STATUS_LABELS,
WAREHOUSES, WAREHOUSES,
clearPickBins, clearPickBins: async () => ({ removed: 0 }),
getPickBinMeta,
getReplenishExportRows, getReplenishExportRows,
getReplenishList, getReplenishList,
importPickBins,
loadDataset, loadDataset,
normalizeBin,
resolveWarehouse, resolveWarehouse,
resolveWarehouseKey, resolveWarehouseKey,
warmBinReplenishCache, warmBinReplenishCache,

View File

@ -158,6 +158,59 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
{ keyword: '转空包', label: '信誉单' }, { keyword: '转空包', label: '信誉单' },
]); ]);
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
// 聚水潭「转仓」操作会在 `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**。
//
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
//
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
//
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
// r_qty=0(货还在路上),正确地没被剔。
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
{
key: 'returnAndRefund',
header: 'dbo.jst_returnAndRefundInfo',
item: 'dbo.jst_returnAndRefundItem',
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
headerAsId: 'TRY_CONVERT(int, h.as_id)',
label: '售后单',
},
{
key: 'qmcustRefund',
header: 'dbo.jst_qmcust_refundlist',
item: 'dbo.jst_qmcust_refundlist_item',
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
headerAsId: 'h.as_id',
label: '客服售后单',
},
]);
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走 // 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
const SORTED_KEYWORDS = CENTERS const SORTED_KEYWORDS = CENTERS
.flatMap((center) => center.keywords.map((kw) => ({ kw, center }))) .flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
@ -316,6 +369,19 @@ function matchNonRealOrder(orderLabels) {
return null; 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, '');
}
/** /**
* 解析「核验批次」。 * 解析「核验批次」。
* *
@ -370,6 +436,7 @@ module.exports = {
EXCLUDED_ORDER_STATUS_KEYWORDS, EXCLUDED_ORDER_STATUS_KEYWORDS,
EXCLUDED_ORDER_LABEL_KEYWORDS, EXCLUDED_ORDER_LABEL_KEYWORDS,
EXCLUDED_ORDER_TYPE_LABELS, EXCLUDED_ORDER_TYPE_LABELS,
INTERCEPT_AFTERSALE_SOURCES,
EXCEPTION_TYPES, EXCEPTION_TYPES,
TYPE_MAP, TYPE_MAP,
TRANSIT_INFO_KEYWORDS, TRANSIT_INFO_KEYWORDS,
@ -379,6 +446,8 @@ module.exports = {
matchExcludedCarrier, matchExcludedCarrier,
matchVoidedOrder, matchVoidedOrder,
matchNonRealOrder, matchNonRealOrder,
matchTransferWarehouse,
TRANSFER_WAREHOUSE_LABEL_PATTERN,
resolveBatch, resolveBatch,
beijingNow, beijingNow,
formatDate, formatDate,

View File

@ -148,6 +148,72 @@ async function loadShippedPackages(batch) {
return rows || []; return rows || [];
} }
// 售后拦截判定:一次 IN 多少单 + 查询超时。
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
const INTERCEPT_QUERY_BATCH = 500;
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
/**
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
*
* 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。
* 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。
*
* @param {Array} rows 状态表原始行
* @returns {Promise<Set<string>>} 内部单号字符串集合
*/
async function loadInterceptedOrders(rows) {
const ids = [];
const seen = new Set();
for (const row of rows) {
// 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样
if (!classify(row)) continue;
const id = Number(row.inner_order_id);
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
const intercepted = new Set();
if (!ids.length) return intercepted;
const tasks = [];
for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) {
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
tasks.push((async () => {
const hits = await cloudDatabase.query(
`SELECT h.o_id
FROM ${source.header} h WITH (NOLOCK)
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
WHERE h.o_id IN (${oIds})
AND ISNULL(h.l_id, '') <> ''
GROUP BY h.o_id
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
{},
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
);
return hits || [];
})().catch((err) => {
// 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在
console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`);
return [];
}));
}
}
const results = await Promise.all(tasks);
for (const list of results) {
for (const r of list) {
if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id));
}
}
return intercepted;
}
function emptySummary() { function emptySummary() {
return { return {
shippedOrders: 0, shippedOrders: 0,
@ -183,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 = {}) { async function getLogisticsExceptions(options = {}) {
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined); const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows) // 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
@ -220,6 +305,12 @@ async function getLogisticsExceptions(options = {}) {
nonRealPackages: 0, nonRealPackages: 0,
nonRealOrders: 0, nonRealOrders: 0,
nonRealReasons: {}, nonRealReasons: {},
interceptPackages: 0,
interceptOrders: 0,
interceptReasons: {},
transferPackages: 0,
transferOrders: 0,
transferReasons: {},
}, },
orders: [], orders: [],
detailLimit: limit, detailLimit: limit,
@ -231,18 +322,24 @@ async function getLogisticsExceptions(options = {}) {
}; };
} }
const cacheKey = `${batch.verifyDate}|${limit}`; // 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
const cacheKey = batch.verifyDate;
const cachedEntry = resultCache.get(cacheKey); const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) { if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return { return finalizeDetail(
...cachedEntry.data, cachedEntry.data,
cached: true, limit,
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000), true,
}; Math.round((Date.now() - cachedEntry.at) / 1000)
);
} }
const rows = await loadShippedPackages(batch); const rows = await loadShippedPackages(batch);
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
const interceptedOrders = await loadInterceptedOrders(rows);
// ── 归属主仓 + 去重 ────────────────────────────────────────────────── // ── 归属主仓 + 去重 ──────────────────────────────────────────────────
const centerStat = new Map(); const centerStat = new Map();
for (const center of registry.CENTERS) { for (const center of registry.CENTERS) {
@ -266,6 +363,8 @@ async function getLogisticsExceptions(options = {}) {
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} }; const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} }; const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
const skippedByType = { 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) { for (const row of rows) {
const queryTime = formatDateTime(row.query_time); const queryTime = formatDateTime(row.query_time);
@ -309,6 +408,18 @@ async function getLogisticsExceptions(options = {}) {
continue; 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); const stat = centerStat.get(center.key);
stat.shippedOrders.add(oId); stat.shippedOrders.add(oId);
stat.packages += 1; stat.packages += 1;
@ -318,11 +429,23 @@ async function getLogisticsExceptions(options = {}) {
const type = classify(row); const type = classify(row);
if (!type) continue; if (!type) continue;
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
// ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了),
// 只从异常清单里摘掉,异常率才不会被错误地放大。
if (interceptedOrders.has(oId)) {
skippedByIntercept.packages += 1;
skippedByIntercept.orders.add(oId);
skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1;
continue;
}
const sendAt = formatDateTime(row.send_time); const sendAt = formatDateTime(row.send_time);
const logisticAt = formatDateTime(row.logistic_time); const logisticAt = formatDateTime(row.logistic_time);
const pendingMinutes = diffMinutes(sendAt, batch.deadline); const pendingMinutes = diffMinutes(sendAt, batch.deadline);
const staleMinutes = diffMinutes(logisticAt, batch.deadline); const staleMinutes = diffMinutes(logisticAt, batch.deadline);
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
stat.exceptionOrders.add(oId); stat.exceptionOrders.add(oId);
stat.byType[type] = (stat.byType[type] || 0) + 1; stat.byType[type] = (stat.byType[type] || 0) + 1;
@ -420,10 +543,19 @@ async function getLogisticsExceptions(options = {}) {
nonRealPackages: skippedByType.packages, nonRealPackages: skippedByType.packages,
nonRealOrders: skippedByType.orders.size, nonRealOrders: skippedByType.orders.size,
nonRealReasons: skippedByType.reasons, nonRealReasons: skippedByType.reasons,
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
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, detailLimit: limit,
truncated: exceptions.length > limit, truncated: false,
freshness: { freshness: {
snapshotAt, snapshotAt,
ageMinutes, ageMinutes,
@ -446,7 +578,7 @@ async function getLogisticsExceptions(options = {}) {
} }
if (oldestKey) resultCache.delete(oldestKey); if (oldestKey) resultCache.delete(oldestKey);
} }
return payload; return finalizeDetail(payload, limit, false, 0);
} }
/** /**

View File

@ -307,8 +307,30 @@ const CREATED_BACKFILL_BATCH = 200;
// 单子早随主单发走了,状态表还停在「发货中」。 // 单子早随主单发走了,状态表还停在「发货中」。
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位, // ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。 // 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']); const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
const VOID_QUESTION_STATUS = 'Question';
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
const AFTERSALE_VOID_STATUS = '已作废';
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。 // 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」, // 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。 // 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
@ -554,6 +576,9 @@ module.exports = {
CREATED_SOURCE_TABLES, CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH, CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS, VOID_ORDER_STATUS,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
SHIP_TIMELINESS_HOURS, SHIP_TIMELINESS_HOURS,
toNumber, toNumber,
}; };

View File

@ -129,9 +129,17 @@ const {
CREATED_SOURCE_TABLES, CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH, CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS, VOID_ORDER_STATUS,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
resolveCenter, resolveCenter,
} = registry; } = registry;
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
.map((k) => `labels LIKE N'%${k}%'`)
.join(' OR ');
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示 // 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500; const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000; const MAX_DETAIL_LIMIT = 2000;
@ -224,7 +232,7 @@ async function loadPendingStates(round) {
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原 // mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
// 就是库里存的北京时间,不会偏移 8 小时。 // 就是库里存的北京时间,不会偏移 8 小时。
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name, `SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.order_status_name, e.logistic_status_name, e.order_status_name, e.aftersale_status_name, e.logistic_status_name,
e.logistic_id, e.logistic_company_name, e.logistic_id, e.logistic_company_name,
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time, e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
e.send_time, e.query_time e.send_time, e.query_time
@ -293,7 +301,8 @@ async function backfillOrderFacts(oids) {
const results = await mapLimit(batches, 4, async (batch) => { const results = await mapLimit(batches, 4, async (batch) => {
try { try {
return await cloudDatabase.querySlow( return await cloudDatabase.querySlow(
`SELECT o_id, created, status, send_date, is_merge, is_split `SELECT o_id, created, status, send_date, is_merge, is_split,
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`, FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
{}, {},
{ connectRequestTimeoutMs: 300000 } { connectRequestTimeoutMs: 300000 }
@ -314,10 +323,15 @@ async function backfillOrderFacts(oids) {
createdAt: formatDateTime(r.created), createdAt: formatDateTime(r.created),
status, status,
sendDate, sendDate,
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货 // 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除
void: VOID_ORDER_STATUS.includes(status) void: VOID_ORDER_STATUS.includes(status)
|| Number(r.is_merge) === 1 || Number(r.is_merge) === 1
|| Number(r.is_split) === 1, || Number(r.is_split) === 1,
// 作废(软):问题单 Question / 标签含「发货前售后」。
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
question: status === VOID_QUESTION_STATUS,
presaleVoid: Number(r.presaleVoid) === 1,
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。 // 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。 // 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status), shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
@ -569,6 +583,19 @@ async function getShipTimeliness(options = {}) {
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示 if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|| (fact && (fact.question || fact.presaleVoid))) {
voidCount += 1;
continue;
}
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛) // ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
if (!benchmarkAt) { if (!benchmarkAt) {
bucket.heldCount += 1; bucket.heldCount += 1;

View File

@ -144,12 +144,16 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
// ── ④ 服务层(打桩数据库)────────────────────────────────────────────── // ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
const dbPath = require.resolve('../src/config/cloudDatabase'); const dbPath = require.resolve('../src/config/cloudDatabase');
function stubDb(rows) { function stubDb(rows, options = {}) {
const calls = { times: 0, sql: [], params: [] }; const calls = { times: 0, sql: [], params: [] };
const handler = async (sql, params) => { const handler = async (sql, params) => {
calls.times += 1; calls.times += 1;
calls.sql.push(String(sql)); calls.sql.push(String(sql));
calls.params.push(params || {}); calls.params.push(params || {});
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
return options.interceptRows || [];
}
return rows; return rows;
}; };
require.cache[dbPath] = { require.cache[dbPath] = {
@ -206,9 +210,9 @@ const rows = [
send_time: '2026-10-20 11:00:00' }), send_time: '2026-10-20 11:00:00' }),
]; ];
async function withStub(fn, stubRows = rows) { async function withStub(fn, stubRows = rows, options = {}) {
const realDb = require.cache[dbPath]; const realDb = require.cache[dbPath];
const calls = stubDb(stubRows); const calls = stubDb(stubRows, options);
delete require.cache[svcPath]; delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
try { try {
@ -276,18 +280,20 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async
test('结果缓存生效,force=1 绕过缓存重查', async () => { test('结果缓存生效,force=1 绕过缓存重查', async () => {
await withStub(async (svc, calls) => { 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) }; const opts = { date: '2026-10-21', now: at(21, 10, 0) };
const first = await svc.getLogisticsExceptions(opts); const first = await svc.getLogisticsExceptions(opts);
assert.equal(first.cached, false); assert.equal(first.cached, false);
assert.equal(calls.times, 1); assert.equal(mainQueries(), 1);
const second = await svc.getLogisticsExceptions(opts); const second = await svc.getLogisticsExceptions(opts);
assert.equal(second.cached, true); assert.equal(second.cached, true);
assert.equal(calls.times, 1, '第二次应命中缓存'); assert.equal(mainQueries(), 1, '第二次应命中缓存');
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 }); const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
assert.equal(third.cached, false); assert.equal(third.cached, false);
assert.equal(calls.times, 2, 'force=1 必须真查库'); assert.equal(mainQueries(), 2, 'force=1 必须真查库');
}); });
}); });
@ -320,19 +326,39 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
}); });
}); });
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => { test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => {
await withStub(async (svc, calls) => { 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) }; const opts = { date: '2026-10-21', now: at(21, 10, 0) };
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(calls.times, 1);
// 导出用另一个 limit —— 不能把页面那份缓存顶掉 const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
await svc.getLogisticsExceptionExportRows(opts); assert.equal(list.cached, false, '页面首次应查库');
assert.equal(calls.times, 2, '导出是首次查,应真查库'); assert.equal(mainQueries(), 1);
// 导出 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 }); const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(listAgain.cached, true, '页面那份缓存应还在'); assert.equal(listAgain.cached, true, '页面那份缓存应仍在');
assert.equal(calls.times, 2, '列表应命中缓存,不再查库'); 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);
}); });
}); });
@ -516,6 +542,45 @@ test('刷单的包裹不进分母也不算异常', async () => {
}, mixed); }, 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, '转仓单不计异常');
// 🔥 业务 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);
});
test('未到 09:00 也返回剔除口径,前端不用判空', async () => { test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
await withStub(async (svc) => { await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) }); const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
@ -523,5 +588,87 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
assert.ok(Array.isArray(data.exclusions.carriers)); assert.ok(Array.isArray(data.exclusions.carriers));
assert.ok(data.exclusions.carriers.includes('唯品顺丰')); assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
assert.equal(data.exclusions.carrierOrders, 0); assert.equal(data.exclusions.carrierOrders, 0);
assert.equal(data.exclusions.interceptOrders, 0, '未开跑也要给出拦截剔除字段');
}); });
}); });
// ── ⑦ 现场拦截(售后已签收)不算转运异常(业务 2026-10-23)─────────────
test('现场拦截判据:签收单号非空 + 实退数量>0 才剔,货还在路上的不剔', async () => {
const mixed = [
// 30:已揽收未中转 → 售后单有签收单号 + 实退 1 件 → 已签收,必须剔
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '已揽收',
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
last_info: '中通快递 已收取快件' }),
// 31:同形态但售后 r_qty=0(货还在路上)→ **不能剔**,仍是真异常
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '已揽收',
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
last_info: '中通快递 已收取快件' }),
// 32:未揽收 + 售后已签收 → 同样剔(不挑异常类型)
mk({ inner_order_id: 32, so_id: 'SO-32', logistic_status_name: '未揽收', logistic_time: null }),
// 33:正常已签收 → 本来就不算异常,不受影响
mk({ inner_order_id: 33, so_id: 'SO-33' }),
];
await withStub(async (svc, calls) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
// 30 / 32 被剔;31 仍是异常;33 本来正常
assert.deepEqual(data.orders.map((o) => o.oId), ['31']);
assert.equal(data.summary.exceptionOrders, 1);
// 🔥 发货分母不能掉:货确实发出去了,只是物流环节已结束
assert.equal(data.summary.shippedOrders, 4, '拦截单仍算发货分母,否则异常率被放大');
assert.equal(data.summary.exceptionRate, 25, '1 / 4');
assert.equal(data.exclusions.interceptPackages, 2);
assert.equal(data.exclusions.interceptOrders, 2);
assert.equal(data.exclusions.interceptReasons['已揽收未中转'], 1);
assert.equal(data.exclusions.interceptReasons['未揽收'], 1);
// 判据 SQL:两张店铺表都要查、都要 join 明细、都必须卡 l_id 与 r_qty
const asSql = calls.sql.filter((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
assert.equal(asSql.length, 2, `售后表要查两套(店铺互斥),实际 ${asSql.length}`);
assert.ok(asSql.some((s) => /jst_returnAndRefundItem/u.test(s)), '必须关联明细表拿实退数量');
assert.ok(asSql.some((s) => /jst_qmcust_refundlist_item/u.test(s)), '客服售后单也要关联明细表');
for (const s of asSql) {
assert.match(s, /l_id[\s\S]*<> ''/u, '必须有「签收单号非空」条件');
assert.match(s, /SUM\(ISNULL\(it\.r_qty, 0\)\) > 0/u, '必须有「实退数量 > 0」条件');
assert.doesNotMatch(s, /it\.qty[\s\S]*> 0/u, '不能拿申请数量 qty 当实退数量');
}
}, mixed, { interceptRows: [{ o_id: 30 }, { o_id: 32 }] });
});
test('售后表查询失败时降级:不剔单、不炸页面', async () => {
const mixed = [
mk({ inner_order_id: 40, so_id: 'SO-40', logistic_status_name: '未揽收', logistic_time: null }),
];
const realDb = require.cache[dbPath];
const calls = { times: 0, sql: [] };
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: {
query: async (sql) => {
calls.sql.push(String(sql));
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
throw new Error('售后表超时');
}
return mixed;
},
querySlow: async () => mixed,
},
};
try {
delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
const svc = require(svcPath);
svc.clearLogisticsExceptionCache();
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
assert.equal(data.summary.exceptionOrders, 1, '售后表挂了就该照常报异常,别把异常吞掉');
assert.equal(data.exclusions.interceptOrders, 0);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
}
});

View File

@ -118,10 +118,11 @@ test('群消息正文:抬头带「异常」关键词、数字与分类型、
assert.equal(built.truncated, false); assert.equal(built.truncated, false);
}); });
test('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => { test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
const line = job.buildDetailLine({ const line = job.buildDetailLine({
warehouseName: '崔桥工厂仓', warehouseName: '崔桥工厂仓',
oId: '9845001', oId: '9845001',
trackingNo: 'JDVA00123456789',
carrier: '京东快递', carrier: '京东快递',
exceptionLabel: '已揽收未中转', exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00', sendTime: '2026-10-21 16:00:00',
@ -129,17 +130,119 @@ test('明细行:仓|内部单号|快递|异常类型|发货时间|
lastLogisticInfo: '第一行\n第二行', lastLogisticInfo: '第一行\n第二行',
}, 3); }, 3);
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h'); assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
// 业务要求:第二段是快递单号,不是内部单号
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常) // 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁'); assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
assert.equal(
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
'1. 未知仓|9845002|中通|异常'
);
// 空值兜底:不能让消息里出现「undefined|null」 // 空值兜底:不能让消息里出现「undefined|null」
assert.equal( assert.equal(
job.buildDetailLine({}, 1), job.buildDetailLine({}, 1),
'1. 未知仓|无单号|未知快递|异常|—|—' '1. 未知仓|无单号|未知快递|异常'
); );
}); });
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
const rows = [
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
// 不丢行、不改行
assert.equal(grouped.length, rows.length);
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
const detailBlock = text.split('明细前')[1] || '';
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
// 同仓必须相邻:切成连续段后不应出现重复仓名
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
});
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
const rows = [
{ warehouseName: '', trackingNo: 'A1' },
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
{ trackingNo: 'A2' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 未知仓 2 条(含 undefined)> 睢宁 1 条
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
});
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [
...make('青岗集仓', 100),
...make('崔桥工厂仓', 20),
...make('睢宁仓', 8),
...make('新苏北仓', 6),
...make('横林二仓', 6),
];
const picked = job.pickDetailRows(rows, 40);
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
const counts = picked.reduce((acc, row) => {
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
return acc;
}, {});
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
}
// 大仓仍然拿得多,但不能吃满额度
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
// 同仓仍然相邻
const names = picked.map((row) => row.warehouseName);
const runs = names.filter((name, i) => name !== names[i - 1]);
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
});
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
const picked = job.pickDetailRows(rows, 2);
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
// 额度 0 / 超额度的边界
assert.deepEqual(job.pickDetailRows(rows, 0), []);
assert.equal(job.pickDetailRows(rows, 99).length, 17);
});
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => { test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
const data = sampleData({ const data = sampleData({
rows: [], rows: [],

View File

@ -207,6 +207,13 @@ function stubDb(dbPath, scenario = 'default') {
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30 // 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
// ── 2026-10-22 作废单三类形态 ──
// 38:订单表 Question(聚水潭「已作废/问题单」)
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
40: f('jst_Orders', { created: '2026-10-20 11:10:00' }),
}; };
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」 // 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
@ -227,6 +234,8 @@ function stubDb(dbPath, scenario = 'default') {
shop_name: '测试店', shop_name: '测试店',
logistic_status_name: '', logistic_status_name: '',
query_time: '2026-10-20 11:18:27', query_time: '2026-10-20 11:18:27',
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
aftersale_status_name: '暂无售后',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time // 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
first_oa_confirm_time: o.last_oa_confirm_time ?? null, first_oa_confirm_time: o.last_oa_confirm_time ?? null,
...o, ...o,
@ -326,6 +335,19 @@ function stubDb(dbPath, scenario = 'default') {
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓', mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00', order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
last_oa_confirm_time: '2026-09-22 09:05:00' }), last_oa_confirm_time: '2026-09-22 09:05:00' }),
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
last_oa_confirm_time: '2026-10-20 09:15:00' }),
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
last_oa_confirm_time: '2026-10-20 10:15:00' }),
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', aftersale_status_name: '已作废',
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }),
]; ];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00 // 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
@ -392,6 +414,7 @@ function stubDb(dbPath, scenario = 'default') {
send_date: x.send_date, send_date: x.send_date,
is_merge: x.is_merge, is_merge: x.is_merge,
is_split: x.is_split, is_split: x.is_split,
presaleVoid: x.presaleVoid || 0,
}; };
}); });
} }
@ -506,12 +529,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
// 被排除的仓不能算 // 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核'); assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单 // 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
for (const id of ['9', '10', '11', '12']) { // 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`); assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`); assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
} }
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent) // 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数'); assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20) // 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped'); assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
@ -561,13 +585,16 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用'); assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表'); assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列** // 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实'); assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
for (const bs of calls.backfillSql) { for (const bs of calls.backfillSql) {
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, assert.match(bs,
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`); /SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u, `补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s'); '多取一列就会把 0.2s 拖到 38s');
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
} }
// ⚠️ 窗口参数走 params,不在 SQL 里手写 // ⚠️ 窗口参数走 params,不在 SQL 里手写

View File

@ -7,8 +7,9 @@
"test": "node --test test/*.test.mjs", "test": "node --test test/*.test.mjs",
"prebuild": "node ./scripts/write-build-version.mjs", "prebuild": "node ./scripts/write-build-version.mjs",
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native", "dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
"build": "vite build && node ./scripts/verify-planning-entry.mjs", "build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs",
"check": "npm run lint && npm test && npm run build", "check": "npm run lint && npm test && npm run build",
"verify:router-lazy": "node ./scripts/verify-router-lazy.mjs",
"verify:public-build": "node ./scripts/verify-public-build.mjs", "verify:public-build": "node ./scripts/verify-public-build.mjs",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"

View File

@ -0,0 +1,70 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
/**
* 校验路由表里每个 renderLazyComponent(X) 用到的组件 X 都在同文件里
* 用 `const X = lazy(() => import(...))` 声明过。
*
* 为什么要这个检查(2026-10-22 生产事故):
* PR #74 新增「缺货补货监控」时,router/index.jsx 里写了
* `element: renderLazyComponent(BinReplenishMonitor)` 却漏了 lazy 声明。
* 这行在模块顶层执行 → 加载即抛 `ReferenceError: BinReplenishMonitor is not defined`
* → 整个 React 应用挂掉、**全站白屏,连登录页都渲染不出来**。
* 而 `vite build` 完全能构建成功(未声明标识符在 JS 里是合法语法),
* 所以必须在构建期用静态扫描拦住。
*/
export async function verifyRouterLazy(routerPath = path.resolve(process.cwd(), 'src/router/index.jsx')) {
const source = await readFile(routerPath, 'utf8');
const used = new Set();
for (const match of source.matchAll(/renderLazyComponent\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
used.add(match[1]);
}
const declared = new Set();
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\s*\(/g)) {
declared.add(match[1]);
}
const missing = [...used].filter((name) => !declared.has(name)).sort();
if (missing.length > 0) {
const lines = missing.map((name) => {
const idx = source.indexOf(`renderLazyComponent(${name}`);
const line = idx === -1 ? '?' : source.slice(0, idx).split('\n').length;
return ` - ${name} (router/index.jsx:${line} 使用,缺少 const ${name} = lazy(() => import('...')) 声明)`;
});
throw new Error(
'router/index.jsx 有组件被 renderLazyComponent 使用但未声明为 lazy,' +
'这会导致运行时 ReferenceError 并让整站白屏:\n' +
lines.join('\n'),
);
}
return { routerPath, used: [...used].sort(), declared: [...declared].sort() };
}
async function main() {
const routerArg = process.argv[2];
const routerPath = routerArg
? path.resolve(process.cwd(), routerArg)
: path.resolve(process.cwd(), 'src/router/index.jsx');
const result = await verifyRouterLazy(routerPath);
process.stdout.write(
`Verified router lazy declarations: ${result.used.length} used, ${result.declared.length} declared ` +
`(${path.relative(process.cwd(), result.routerPath)})\n`,
);
}
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
: false;
if (isDirectExecution) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
}

View File

@ -2,8 +2,8 @@ import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000; const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(拣货位缺货 → 从存货位补货) // 缺货补货监控(订单驱动版)
// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓) // 判断口径:待发订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
export const getBinReplenishList = (params = {}) => request.get( export const getBinReplenishList = (params = {}) => request.get(
'/api/bin-replenish/list', '/api/bin-replenish/list',
{ params, timeout: LONG_TIMEOUT_MS } { params, timeout: LONG_TIMEOUT_MS }
@ -18,23 +18,3 @@ export const exportBinReplenish = (params = {}) => request.get(
silentError: true, silentError: true,
} }
); );
export const getPickBinMeta = (warehouse) => request.get(
'/api/bin-replenish/pick-bins',
{ params: warehouse ? { warehouse } : {} }
);
export const importPickBins = (file, warehouse) => {
const formData = new FormData();
formData.append('file', file);
if (warehouse) formData.append('warehouse', warehouse);
return request.post('/api/bin-replenish/pick-bins', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: LONG_TIMEOUT_MS,
});
};
export const clearPickBins = (warehouse) => request.delete(
'/api/bin-replenish/pick-bins',
{ params: warehouse ? { warehouse } : {} }
);

View File

@ -1,14 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload, Alert, Button, Empty, Input, Segmented, Spin, Table, Tag, Tooltip,
message, message,
} from 'antd'; } from 'antd';
import { import {
DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins,
} from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common'; import { SycmPage } from '../../components/Common';
import './style.css'; import './style.css';
@ -18,48 +16,21 @@ const num = (value) => (value === null || value === undefined || value === ''
: Number(value).toLocaleString('zh-CN')); : Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = { const STATUS_TAGS = {
pickout: { color: 'red', label: '拣货位缺货' }, short: { color: 'red', label: '存储不足' },
empty: { color: 'default', label: '无货可补' }, need: { color: 'gold', label: '需补货' },
low: { color: 'orange', label: '拣货位偏低' }, enough: { color: 'green', label: '拣货位充足' },
nopick: { color: 'blue', label: '未设拣货位' }, no_order: { color: 'default', label: '无待发单' },
ok: { color: 'green', label: '正常' },
}; };
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' }, { label: '待补货', value: 'replenish' },
{ label: '无货可补', value: 'empty' }, { label: '仅缺货', value: 'short' },
{ label: '未设拣货位', value: 'nopick' },
{ label: '全部', value: 'all' },
]; ];
const DEFAULT_TARGET_DAYS = 7;
const renderBinTags = (bins = [], emptyText = '未维护', limit = 3) => {
if (!bins.length) return <span className="brp-muted">{emptyText}</span>;
const visible = bins.slice(0, limit);
const rest = bins.length - visible.length;
return (
<span className="brp-bins">
{visible.map((item) => (
<Tag key={`${item.bin}-${item.qty}`} className={`brp-bin${item.qty > 0 ? '' : ' is-zero'}`}>
{item.bin}
<b>{num(item.qty)}</b>
</Tag>
))}
{rest > 0 ? (
<Tooltip title={bins.slice(limit).map((item) => `${item.bin}(${num(item.qty)})`).join('、')}>
<Tag className="brp-bin brp-bin--more">+{rest}</Tag>
</Tooltip>
) : null}
</span>
);
};
const BinReplenishMonitor = () => { const BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao'); const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish'); const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [targetDays, setTargetDays] = useState(DEFAULT_TARGET_DAYS);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
@ -67,9 +38,8 @@ const BinReplenishMonitor = () => {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [data, setData] = useState({ const [data, setData] = useState({
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], pickBinMeta: null, updatedAt: '', cacheHit: false, rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
}); });
const [importing, setImporting] = useState(false);
const keywordTimer = useRef(null); const keywordTimer = useRef(null);
const load = useCallback(async (options = {}) => { const load = useCallback(async (options = {}) => {
@ -82,7 +52,6 @@ const BinReplenishMonitor = () => {
warehouse, warehouse,
status, status,
keyword, keyword,
targetDays,
page: options.page || page, page: options.page || page,
pageSize: options.pageSize || pageSize, pageSize: options.pageSize || pageSize,
refresh: isRefresh ? 'true' : undefined, refresh: isRefresh ? 'true' : undefined,
@ -94,7 +63,7 @@ const BinReplenishMonitor = () => {
summary: payload.summary || {}, summary: payload.summary || {},
warehouse: payload.warehouse || null, warehouse: payload.warehouse || null,
warehouses: payload.warehouses || [], warehouses: payload.warehouses || [],
pickBinMeta: payload.pickBinMeta || null, orderScope: payload.orderScope || '',
updatedAt: payload.updatedAt || '', updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit), cacheHit: Boolean(payload.cacheHit),
}); });
@ -105,12 +74,12 @@ const BinReplenishMonitor = () => {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}, [warehouse, status, keyword, targetDays, page, pageSize]); }, [warehouse, status, keyword, page, pageSize]);
useEffect(() => { useEffect(() => {
load(); load();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize, targetDays]); }, [warehouse, status, page, pageSize]);
useEffect(() => { useEffect(() => {
if (keywordTimer.current) clearTimeout(keywordTimer.current); if (keywordTimer.current) clearTimeout(keywordTimer.current);
@ -123,93 +92,65 @@ const BinReplenishMonitor = () => {
}, [keyword]); }, [keyword]);
const summary = data.summary; const summary = data.summary;
const pickBinMeta = data.pickBinMeta || {}; const needCount = (summary.need || 0) + (summary.short || 0);
const usingFallback = pickBinMeta.source !== 'import';
const metrics = useMemo(() => ([ const metrics = useMemo(() => ([
{ {
key: 'pickout', key: 'order',
tone: 'red',
label: '拣货位缺货',
value: summary.pickout || 0,
unit: '个 SKU',
foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`,
tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位',
},
{
key: 'empty',
tone: 'grey',
label: '无货可补',
value: summary.empty || 0,
unit: '个 SKU',
foot: '拣货位与存货位都为 0,需采购',
tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购',
},
{
key: 'low',
tone: 'orange',
label: '拣货位偏低',
value: summary.low || 0,
unit: '个 SKU',
foot: `低于 ${targetDays} 天销量目标`,
tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`,
},
{
key: 'qty',
tone: 'blue', tone: 'blue',
label: '建议补货合计', label: '实时代发订单数',
value: summary.suggestQtyTotal || 0, value: summary.orderQtyTotal || 0,
unit: '件', unit: '件',
foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`, foot: `待补 ${num(summary.skuTotal)} 个 SKU`,
tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)', tip: '待补 SKU 的待发订单件数合计(取聚水潭「打单拣货 → 发货中」每日快照,组合装已展开为子件;不含已发/异常/待审核)',
}, },
]), [summary, targetDays]); {
key: 'pick',
tone: 'green',
label: '拣货库存数',
value: summary.pickTotal || 0,
unit: '件',
foot: '待补 SKU 拣货位库存',
tip: '待补 SKU 在 XA / XD / XE 拣货位上的库存合计(库存取自聚水潭「箱及仓位库存 · 简尚-崔桥工厂仓 主仓」)',
},
{
key: 'store',
tone: 'orange',
label: '存储库存数',
value: summary.storageTotal || 0,
unit: '件',
foot: '待补 SKU 存储位库存',
tip: '待补 SKU 在 XX / X9 / X5 / X4 存储位上的库存合计(同上来源,主仓)',
},
{
key: 'short',
tone: 'red',
label: '缺货数',
value: summary.skuTotal || 0,
unit: '个',
foot: `其中存储不足 ${num(summary.short)} 个,缺口 ${num(summary.shortfallTotal)} 件`,
tip: '需要从存储位补到拣货位的 SKU 数(待发订单量 > 拣货位库存);其中存储位也补不齐的标为「缺货」,需采购 / 紧急调拨',
},
]), [summary, needCount]);
const handleExport = async () => { const handleExport = async () => {
try { try {
const response = await exportBinReplenish({ warehouse, status, keyword, targetDays }); const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`); downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_待发订单.xlsx`);
} catch (error_) { } catch (error_) {
const text = await readBlobErrorMessage(error_); const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败'); message.error(text || '导出失败');
} }
}; };
const handleImport = async (file) => {
setImporting(true);
try {
const response = await importPickBins(file, warehouse);
const result = response?.data?.data || {};
message.success(`已导入 ${result.importedRows || 0} 个拣货位,正在重新取数…`);
await load({ refresh: true, page: 1 });
setPage(1);
} catch (error_) {
message.error(error_?.message || '导入失败');
} finally {
setImporting(false);
}
return false;
};
const handleClear = async () => {
try {
await clearPickBins(warehouse);
message.success('已清除货位清单,回到「货位散件记录」回退口径');
setPage(1);
await load({ refresh: true, page: 1 });
} catch (error_) {
message.error(error_?.message || '清除失败');
}
};
const columns = [ const columns = [
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 112, width: 100,
fixed: 'left', fixed: 'left',
render: (value) => { render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.ok; const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>; return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
}, },
}, },
@ -225,30 +166,42 @@ const BinReplenishMonitor = () => {
), ),
}, },
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true }, { title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
{
title: '拣货位(货位号 / 数量)',
dataIndex: 'pickBins',
width: 300,
render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : <span className="brp-muted">未维护拣货位</span>),
},
{ {
title: '拣货位库存', title: '拣货位库存',
dataIndex: 'pickQty', dataIndex: 'pickQty',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => ( render: (value, row) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span> <span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
</span>
), ),
}, },
{ {
title: '存货位库存', title: '存储位库存',
dataIndex: 'storageQty', dataIndex: 'storageQty',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>, render: (value) => <span className="brp-qty">{num(value)}</span>,
}, },
{ {
title: '建议补货', title: '待发订单',
dataIndex: 'orderQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '超出订单数',
dataIndex: 'excess',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
),
},
{
title: '建议补货量',
dataIndex: 'suggestQty', dataIndex: 'suggestQty',
width: 110, width: 110,
align: 'right', align: 'right',
@ -259,24 +212,13 @@ const BinReplenishMonitor = () => {
), ),
}, },
{ {
title: '目标拣货位', title: '存储缺口',
dataIndex: 'targetQty', dataIndex: 'shortfall',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>, render: (value) => (
}, <span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
{ ),
title: '存货位(前 5 个 / 数量)',
dataIndex: 'storageBins',
width: 300,
render: (bins) => renderBinTags(bins, '无存货', 3),
},
{
title: '近15天日销',
dataIndex: 'avgSale',
width: 108,
align: 'right',
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
}, },
{ {
title: '现有库存', title: '现有库存',
@ -293,8 +235,9 @@ const BinReplenishMonitor = () => {
<div className="brp-hero__main"> <div className="brp-hero__main">
<h1 className="brp-hero__title">缺货补货监控</h1> <h1 className="brp-hero__title">缺货补货监控</h1>
<p className="brp-hero__desc"> <p className="brp-hero__desc">
盯的是<b>拣货位</b>:拣货位没货就拣不出来,需要从<b>存货位</b>把货补过去。 本看板只盯<b>崔桥工厂仓(主仓)</b>的<b>当日待补</b>数据:也就是那些待发订单超过拣货位库存、
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。 需要把货从<b>存储位</b>补到<b>拣货位</b>的 SKU。订单取自聚水潭「打单拣货 → 简尚-崔桥工厂仓」的发货中单据,
库存取自聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」。<b>已发货的不再计入</b>(货已发出、不再消耗拣货位)。
</p> </p>
</div> </div>
<div className="brp-hero__meta"> <div className="brp-hero__meta">
@ -302,18 +245,14 @@ const BinReplenishMonitor = () => {
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b> 仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div> </div>
<div> <div>
拣货位口径: 统计口径:<b>{data.orderScope || '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)'}</b>
<b className={usingFallback ? '' : 'brp-ok'}>
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
</b>
</div> </div>
<div> <div>
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b> 取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null} {data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
</div> </div>
<div> <div>
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中未设拣货位{' '} 共 <b>{num(summary.skuTotal)}</b> 个待补 SKU,其中存储不足(缺货)<b>{num(summary.short)}</b> 个
<b>{num(summary.nopick)}</b> 个(不参与待补货)
</div> </div>
</div> </div>
</div> </div>
@ -339,15 +278,7 @@ const BinReplenishMonitor = () => {
<div className="brp-toolbar"> <div className="brp-toolbar">
<span className="brp-toolbar__field"> <span className="brp-toolbar__field">
<span className="brp-toolbar__label">仓库</span> <span className="brp-toolbar__label">仓库</span>
<Select <span className="brp-toolbar__static">简尚-崔桥工厂仓(主仓)</span>
value={warehouse}
onChange={(value) => { setWarehouse(value); setPage(1); }}
style={{ width: 210 }}
options={(data.warehouses || []).map((item) => ({
value: item.key,
label: item.name,
}))}
/>
</span> </span>
<span className="brp-toolbar__field"> <span className="brp-toolbar__field">
<span className="brp-toolbar__label">范围</span> <span className="brp-toolbar__label">范围</span>
@ -357,23 +288,11 @@ const BinReplenishMonitor = () => {
options={STATUS_OPTIONS.map((item) => ({ options={STATUS_OPTIONS.map((item) => ({
...item, ...item,
label: item.value === 'replenish' label: item.value === 'replenish'
? `${item.label} ${num((summary.pickout || 0) + (summary.low || 0))}` ? `${item.label} ${num(needCount)}`
: item.value === 'empty' : `${item.label} ${num(summary.short || 0)}`,
? `${item.label} ${num(summary.empty || 0)}`
: item.label,
}))} }))}
/> />
</span> </span>
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">目标天数(天)</span>
<InputNumber
min={1}
max={90}
value={targetDays}
onChange={(value) => { setTargetDays(Number(value) || DEFAULT_TARGET_DAYS); setPage(1); }}
style={{ width: 90 }}
/>
</span>
<Input <Input
allowClear allowClear
value={keyword} value={keyword}
@ -383,14 +302,6 @@ const BinReplenishMonitor = () => {
style={{ width: 260 }} style={{ width: 260 }}
/> />
<span className="brp-toolbar__spacer" /> <span className="brp-toolbar__spacer" />
<Upload
accept=".xlsx,.xls,.csv,.txt"
showUploadList={false}
beforeUpload={handleImport}
disabled={importing}
>
<Button icon={<UploadOutlined />} loading={importing}>导入货位清单</Button>
</Upload>
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button> <Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button <Button
type="primary" type="primary"
@ -402,38 +313,19 @@ const BinReplenishMonitor = () => {
</Button> </Button>
</div> </div>
{usingFallback ? ( <Alert
<Alert type="info"
type="info" showIcon
showIcon className="brp-alert"
className="brp-alert" message="货位口径(崔桥工厂仓 主仓约定)"
message="拣货位口径:默认拣选位(pack_type = Bin 且非存货位),与「拣选库位动销分析」一致" description={(
description="这是系统当前能确定的最准口径。如果业务另有一份拣货位清单,可以上传覆盖(只放货位号,如 XA01-01-11),上传后按清单判定,其余货位都算存货位。" <span>
action={( 库存只取 <b>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
<Upload accept=".xlsx,.xls,.csv,.txt" showUploadList={false} beforeUpload={handleImport}> 其余货位(DB / Y* / Z* 等,占比极小)不计入。
<Button size="small" type="primary">上传清单</Button> 判断规则:<b>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</Upload> </span>
)} )}
/> />
) : (
<Alert
type="success"
showIcon
className="brp-alert"
message={`拣货位口径:导入清单(${num(pickBinMeta.count)} 个货位)`}
description={`最近导入:${pickBinMeta.lastImport?.fileName || '—'} · ${pickBinMeta.lastImport?.importedRows || 0} 个货位 · ${pickBinMeta.lastImport?.importedAt ? new Date(pickBinMeta.lastImport.importedAt).toLocaleString('zh-CN') : '—'}。清单变更后重新上传即可覆盖。`}
action={(
<Popconfirm
title="清除后回到「货位散件记录」回退口径,确定吗?"
onConfirm={handleClear}
okText="清除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />}>清除清单</Button>
</Popconfirm>
)}
/>
)}
{error ? ( {error ? (
<Alert <Alert
@ -447,7 +339,7 @@ const BinReplenishMonitor = () => {
<Spin <Spin
spinning={loading} spinning={loading}
tip="正在从仓位库存视图聚合,首次约 30~60 秒" tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin" wrapperClassName="brp-table-spin"
> >
<Table <Table
@ -456,7 +348,7 @@ const BinReplenishMonitor = () => {
className="brp-table" className="brp-table"
columns={columns} columns={columns}
dataSource={data.rows} dataSource={data.rows}
scroll={{ x: 1900 }} scroll={{ x: 1500 }}
locale={{ locale={{
emptyText: ( emptyText: (
<Empty <Empty
@ -485,28 +377,30 @@ const BinReplenishMonitor = () => {
<div className="brp-notes__title">数据来源与口径说明</div> <div className="brp-notes__title">数据来源与口径说明</div>
<ul> <ul>
<li> <li>
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。 <b>库存</b>:聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」(rpt.jst_packInventoryInfo,仅 <b>wh_id=1 主仓</b>),
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。 按商品编码(sku_id)聚合。「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;
「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li> </li>
<li> <li>
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为 <b>订单</b>:直接取聚水潭「<b>打单拣货 → 简尚-崔桥工厂仓</b>」的发货中单据快照 = rpt.op_OrderNotDispatched
pack_type = Bin 的记录(页面顶部会标出当前口径)。 (该仓<b>最新快照日</b>、status=发货中),SKU 级、组合装已展开为子件。
<b>已发货 / 异常挂起 / 已付款待审核 / 待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>。
</li> </li>
<li> <li>
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的 <b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 &gt; 拣货位库存」、需要从存储位补到拣货位的这部分
fifteenAvgSale,取不到的按 10 件兜底。 (含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
</li> </li>
<li> <li>
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字; <b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。 「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
</li> </li>
<li> <li>
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。 <b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。
</li> </li>
<li> <li>
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热); <b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等半分钟左右。 点「刷新数据」会强制回源,需要等约 15~30 秒。
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -250,6 +250,16 @@
font-weight: 600; font-weight: 600;
} }
.brp-qty--warn {
color: var(--brp-orange);
font-weight: 600;
}
.brp-qty--danger {
color: var(--brp-red);
font-weight: 700;
}
.brp-qty--muted { .brp-qty--muted {
color: var(--sycm-text-tertiary, #8c8c8c); color: var(--sycm-text-tertiary, #8c8c8c);
font-weight: 400; font-weight: 400;

View File

@ -779,14 +779,16 @@ function WarehouseLogisticsException() {
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</> ? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
: null} : null}
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b> ;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。 (按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
<b>现场拦截</b>(售后单有签收单号且实退数量 &gt; 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0 {exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|| exclusions.nonRealOrders > 0) ? ( || exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
<> <>
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0) {' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
+ (exclusions.nonRealOrders || 0)}</b> 单 + (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、 (承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
刷单/信誉单 {exclusions.nonRealOrders || 0} 单), 刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? ( {exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
<> <>
其中承运商剔除:<b> 其中承运商剔除:<b>
@ -800,6 +802,20 @@ function WarehouseLogisticsException() {
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。 剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
</> </>
) : null} ) : null}
{exclusions?.interceptOrders > 0 ? (
<>
{' '}另有 <b>{exclusions.interceptOrders}</b> 单属<b>现场拦截(售后已签收)</b>
{exclusions.interceptReasons && Object.keys(exclusions.interceptReasons).length ? (
<>(原本会算「
{Object.entries(exclusions.interceptReasons)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v} 单`)
.join(' / ')}
」)</>
) : null}
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
</>
) : null}
<br /> <br />
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失, 页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。 所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。

View File

@ -25,6 +25,10 @@ const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis')); const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor')); const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
// ⚠️ 每个在下面路由表里被 renderLazyComponent(...) 用到的页面都必须先在这里声明。
// 漏一个 = 模块顶层抛 ReferenceError(xxx is not defined)→ 整个应用白屏、连登录页都进不去。
// PR #74 就漏了 BinReplenishMonitor,导致生产 10-22 全站白屏(2026-10-22 修复)。
const BinReplenishMonitor = lazy(() => import('../pages/BinReplenishMonitor'));
const lazyLoadingElement = <PageLoadingFallback />; const lazyLoadingElement = <PageLoadingFallback />;