Compare commits

..

34 Commits

Author SHA1 Message Date
b955e78ba2 Merge pull request 'fix(wst): 用聚水潭状态终态 + 销售出库单判作废/已发' (#93) from yanhui-wst-void-1029 into main 2026-10-29 15:38:51 +08:00
54518ab4d3 fix(wst): 把销售出库单纳入作废判据与停更监控
业务 14:34 点名 9992097 / 9992098 / 9992924 / 9992925 / 9992926 /
9993284 / 9993285 / 9995464 / 9995465「洛宁仓 16 单都是作废订单,为啥还在
考核之内」,并指明「在聚水潭的销售出库单里面找一下这个作废单据」。

## 查出来的事实

这 9 单在库里**三处全部显示「还在发货」**:

  订单表 qmcust      status=Delivering、send_date 空
  销售出库单         status=WaitConfirm(待出库)
  状态镜像表         order_status_name=发货中、logistics=无物流单

不是判据漏了,是**三条数据源全停在作废动作之前**(2026-10-29 15:13 实测):

  jst_Orders             14:45  正常
  qmcust                 13:25  滞后 1.8h
  状态镜像表              11:52  滞后 3.4h
  出库单(聚水潭/奇门)      03:10  滞后 12.0h  ← 作废就落在这张表上

聚水潭的作废落在**出库单** `status='Delete'` 上,而这张表今天凌晨 3 点后
零写入,所以上午作的废一条都没进来

## 改动

1. 新增 `loadOrderIoStatus()`:按 o_id 批量取出库单状态(两表、
   分批 500 / 并发 3),判据是 **「该 o_id 下没有任何非 Delete 的出库单」**。
   - 只对「发货中」生效:全库「出库单全 Delete」的候选单里 127/131
     状态表是「异常」,那批还挂着待处理,删出库单更可能是重开单,
     当成作废剔会让业务在「不计考核」里也看不到 → 丢可见性比漏剔更糟
   - 拆包/重开的单有多张出库单,删一张不算作废(实测 51 单里 7 单是这种)
   - **「没有出库单」绝不判作废**:近 3 天未发单 82%(1467/1790)本来就没出库单
2. 两张出库单表纳入 `loadSourceFreshness()`(用 local_updated / updateTime
   灌库时刻,**不能用 modified**,否则没变更时天天误报)
3. 新增 `freshness.voidJudgeReliable` + hint 明说「作废单/取消单可能判不出来」,
   告诉业务去聚水潭「销售出库单」核对
4. 明细加「销售出库单」列(已作废/待出库/已出库/未建单),导出同步加列

## 验证

- WST 单测 27/27(新增:出库单全删必剔 / 拆包重开不剔 / 异常单不剔 /
  无出库单不剔 / 停更时提示作废判不出来 / 源正常时不误报)
- 后端全量 498/500(2 个既有失败:负缓存 TTL、Windows 文件权限)
- 真实数据:ioVoidSkipped=11(发货中 + 出库单全删),overdue 仍 16 单
- 本地真渲染:表头有「销售出库单」、首行显示「待出库」、
  红条列出 5 个数据源停更状态、**pageerror 0**

## 没解决(已在页面明说,不掩盖)

这 9 单今天仍然判不出作废 —— 数据没进来,判据再准也没用。
根子在 ETL:出库单表停 12 小时。ETL 恢复后判据立即生效,
但那 9 单是否作废仍需 IT 修同步或业务在聚水潭复核。
2026-10-29 15:16:25 +08:00
1139e4c364 fix(wst): 用聚水潭状态终态判作废/已发,修「作废单已发单还在考核里」
业务 2026-10-29 第三次报同一件事:13:00 轮里作废的单、已经发货的单还在考核。
根因不是判定写错,而是**订单两表各自停更**,停更期间聚水潭做的「取消 / 发货」
订单表完全看不到 —— status 仍是 Delivering、send_date 仍是空。

## 现场证据(2026-10-29 13:00 轮)

- 当天 qmcust 停在 07:25(6 小时),13:00 轮 159 单**全部**来自它
- 订单表侧:159 单 status 全 Delivering、send_date 全空、labels 里**没有**
  「发货前售后」⟹ 原来的 `presaleVoid` 判据(订单表 labels 含关键词)完全失效
- 状态镜像表(停在 11:52,比订单表新鲜 4 小时):
  `order_status_name='已取消'` **11 单**,`order_labels` 里才有「发货前售后」
  「已打印被取消」;其余 145 单「发货中」

qmcust 13:25 补数后重算,误判量比想象大得多:
**overdue 159 → 16,作废剔除 195 单、已发剔除 17 单。**

## 改动

判据(新增,排在窗口判断与「不计考核」之前):

| 判据 | 处理 |
|---|---|
| `order_status_name='已取消'` | 作废,剔除(计 `stateVoidSkipped`) |
| `send_time` 有值 | 已发,剔除 |
| `order_status_name='已发货'` | 已发,剔除 |
| `logistic_status_name` ∈ 已揽收/运输中/派送中/已签收/拒收 | 已发,剔除 |

- `loadOaConfirmInfo` → `loadStateInfo`,同一次 IN 多取 4 个语义列,**不增加往返**
- ⚠️ **判据只用 `order_status_name`**:`aftersale_status_name='已作废'` 是「售后单被撤销、
  订单照常发货」,近 2 天 1387 单全是这种,混为一谈会把该考核的单全放跑
- ⚠️ `未揽收` / `无物流单` **不算已发**(后者是连快递单都没录,当天 39/159 单是这种)
- 状态镜像表纳入 `dataSources` 停更监控(它停了,「取消了吗」的依据就过期了)
- 明细/导出新增「聚水潭状态」「物流状态(聚水潭)」「状态表抓取时间」;
  页面「发货状态」列改优先显示聚水潭终态,与订单表不一致时打 ⚠

## 验证

- 后端 `npm run check` 496/498(2 个失败是老问题:负缓存 TTL、Windows 文件权限)
- WST 单测 **25/25**,新增两组:终态剔除(含「售后已作废不能误剔」的反向保护)、
  状态表纳入新鲜度监控
- 真实数据(本地直连生产 MSSQL,2026-10-29 13:00 轮):
  `overdue=16 / stateVoidSkipped=195 / stateShippedSkipped=17`,
  明细里「已取消」残留 **0**;剩余 16 单全是河南洛宁仓「发货中 + 无物流单」的真积压
- 作废证据交叉核对:状态表近 2 天「已取消」59263 单,94% 带「发货前售后」标签,
  与业务口径一致
2026-10-29 13:45:41 +08:00
c0e48688c1 Merge pull request 'feat(wst): 数据源停更熔断——订单表停更时醒目标注,别把「没同步」当「没发货」' (#92) from yanhui-wst-stale-1029 into main 2026-10-29 09:13:26 +08:00
cb7d06c20c feat(wst): 数据源停更熔断——订单表停更时醒目标注,别把「没同步」当「没发货」
## 业务问题

10-28 18:00 轮页面核验出 546 单超时未发,业务核对「基本都发了,怎么会核验出未发」。

## 根因(不是判定写错,是数据源停更)

`jst_Orders` 当时**从 14:40 就停止同步了**(停了 4 小时),停更之后发出的单
`send_date` 一直是空,因此全被判成「未发货」:

| 来源表 | 超时单数 | 该表状态 |
|---|---|---|
| `jst_Orders` | 540 | 最后更新 14:40(停 4 小时) |
| `jst_qmcust_orderlist` | 6 | 17:30 正常 |

两个订单表**各自独立同步**,同一天完全可能一个正常一个停更。原来只拿候选行的
`MAX(updated_at)` 判快照滞后,会被「碰巧新」的行掩盖 —— 必须直接问源表。

## 改动

后端 `warehouseShipTimelinessService.js`:

- 新增 `loadSourceFreshness()`:分别直接查 `jst_Orders.MAX(updateTime)` 与
  `jst_qmcust_orderlist.MAX(local_updated)`(限轮次窗口起 30 天,避免全表扫描),
  两源并行;按 `CANGKU_WST_SOURCE_STALE_MINUTES`(默认 60 分钟)判停更
  - 查询失败或取不到更新时间 → **不判停更**(宁可漏报,不谎报为停更)
- 明细行新增 `src` / `srcLabel` / `sourceStale`;`summary` 新增 `sourceStaleUnshipped`
- `freshness.stale` 改为「候选行滞后 **OR** 任一源停更」;`hint` 说清停更源、
  停更时刻、以及各源各贡献多少单(不能只写「这张表」)
- 导出数据带 `dataSources`,Excel 脱离页面也有上下文
- 响应新增 `dataSources` 数组(每源 label / lastUpdate / lagMinutes / stale)

前端 `WarehouseShipTimeliness/index.jsx`:

- 红条 Alert:「N 单所在订单表已停止同步,这些单很可能已经发货」(error 级),
  描述里逐源列出「最后同步 14:40:05(已 3.9 小时)」
- 超时统计卡下方小字:「其中 N 单数据未同步」(悬停有说明)
- 明细「内部单号」旁「未同步」红标签 + tooltip,一眼看出哪几行不可信
- 导出新增「数据源 / 数据源是否停更」两列(`是(该单可能已发)`)

## 验证

- 后端 `npm run check`:**494/496**,2 个失败是既有环境问题(负缓存 TTL 计时、
  Windows 文件权限 0o600),改动前同样失败
- `warehouseShipTimeliness.test.js` **23/23** 通过(含新增「停更表超时单必须打标
  并计入 sourceStaleUnshipped」用例)
- 本地真渲染(Playwright + 手签 token,localhost:5173):
  - 真数据:13:00 前 `notStarted` 正常展示「—」,无 pageerror
  - mock 停更数据:红条 / 逐源行 / 统计小字 / 3 行「未同步」标签全部渲染,无 pageerror
- 真实接口 10-28 13:00 轮:`dataSources` 正确识别 `jst_Orders` 停更(滞后 96 分钟)
  而 qmcust 正常;`sourceStaleUnshipped=1146` 与明细逐行打标同源同口径

## 风险与回退

- 只新增字段与展示,**不改动分轮、起算点、候选池口径** —— 超时单量本身不变
- 阈值可用 `CANGKU_WST_SOURCE_STALE_MINUTES` 调;不配默认 60 分钟
- 低峰期(凌晨)可能出现「1 小时无新单」被判停更,但页面只在 13:00 / 18:00
  核验时刻展示超时数据,这两个时点 1 小时不同步即属异常
- 回退:revert 本提交即可,无数据迁移、无新表、无新依赖
2026-10-29 08:25:41 +08:00
6b6dfdd1da Merge pull request 'fix(wst): 起算点改为「审核时间 / 订单 modified 取较晚者」,修 13:00 轮 65 单假超时' (#91) from yanhui-wst-modified-1028 into main 2026-10-28 17:07:11 +08:00
983fee0d8a fix(wst): 起算点改为「审核时间 / 订单 modified 取较晚者」,修 13:00 轮 65 单假超时
## 业务问题

业务举证:13:00 轮不该有那么多单 —— 「9 点前的单 13:00 前已经发完了」。
线上实测 65 单,逐单核对全部不该在 13:00 轮。

## 根因(两个,都指向「起算点取错」)

### ① 状态表的审核时间会严重滞后

业务给的举证单 9978643:

| 字段 | 值 |
|---|---|
| `qmcust.modified` | **2026-10-28 14:52:29** |
| 聚水潭页面真实审核时间 | **2026-10-28 14:52:28**(秒级吻合) |
| 状态表 `last_oa_confirm_time` | 2026-10-27 17:08:43(旧了一天) |
| 状态表 `query_time` | 2026-10-28 10:29:11(10:29 后没再抓) |

只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时,冤枉仓库。

### ② 审核时间为空的单回退 created,把今天的单按历史单算

65 单里 57 单状态表审核时间为 NULL。回退 `created` 后:

| o_id | created | modified | 回退后算成 | 真实 |
|---|---|---|---|---|
| 9964841 | 10-26 17:22 | **10-28 13:40** | 10-26 审核 → 进 13:00 轮 | 今天下午才审核 |
| 9949424 | 10-25 17:03 | **10-28 13:50** | 同上 | 同上 |
| 9983773 | 10-27 23:23 | **10-28 13:59** | 同上 | 同上 |

57 单的 `modified` 全部落在今天 13:40~15:03 —— 都是今天下午批量审核的。

## 改动

- **起算点** = `max(状态表 last_oa_confirm_time, 订单表 modified, 订单表 created)`
  - 取较晚者:修状态表滞后
  - 纳入 created:个别单 `modified` 早于 `created`(脏数据),起算点不该早于下单时刻
  - `benchmarkSource` 透出 `oa_confirm` / `modified` / `created`,页面 tooltip 说明来源
- **新增 `laterOf()`**:同格式 `YYYY-MM-DD HH:mm:ss` 直接字典序比较,不转 Date(避开时区坑)
- **主查询 SQL 增加 `x.modified`** 列(订单两表都有,无需额外查询)

## 性能修复(同批,线上 /list 曾 300s 超时)

上版状态表查询**串行 9 批实测 135s**,叠加主查询 63~105s 直接把接口顶过 300s,
页面打不开(实测 curl `--max-time 300` 超时)。改为**并发 6**:

| 方式 | 耗时 |
|---|---|
| 串行 9 批 | 135s |
| 并发 3 | 28.8s |
| **并发 6(本版)** | **10~15s** |

并发 6 是生产实测的安全值(单批约 15s,不至于把 402 万行视图的连接池打爆)。

## 改后效果(10-28 17:00 真实库)

| 轮次 | 改前 | 改后 |
|---|---|---|
| 13:00 轮 | 65 单 | **0 单** |
| 18:00 轮 | — | 927 单(全部今天 07:59~16:00 审核,无历史单) |

- `fallbackToCreated` 从 57 → **0**(不再有回退单)
- 9978643 ✅ 已剔除
- 两个轮次都验证「今天窗口上界之后起算的 = 0」

## 验证

- 后端 `npm run check`:**493/495**(2 个失败是既有环境问题:TTL 计时、Windows 权限,改动前同样失败)
- 新增 2 个测试锁定该口径:
  - `modified` 晚于窗口 → 必须剔除(9978643 那类单)
  - 状态表审核时间晚于 `modified` → 以状态表为准
  - `modified` 早于 `created` → 取 `created`,不倒退
- 前端 lint + build 通过(35.2s)
- 真实库双轮验证通过

## 风险与回退

- **`modified` 被客服改单/改备注刷新**时起算点会推后 → 少报超时(放行方向,不冤枉仓库)。
  业务已知悉并接受;这是当前唯一能拿到「今天真实审核时间」的可靠字段。
- 回退方式:`git revert <merge commit>` 即回到只认状态表审核时间的版本。
2026-10-28 17:02:54 +08:00
85c48ebe77 Merge pull request 'fix(wst): 起算点改回「最后审核时间」,并剔除订单表漏回写的已发单' (#90) 2026-10-28 15:40:56 +08:00
e7e0b96b46 fix(wst): 起算点改回「最后审核时间」,并剔除订单表漏回写的已发单
业务 2026-10-28 下午拿 9837824 指出:页面「创建时间」显示 10-19 23:44,
而该单实际是 10-28 09:58:42 才审核。逐字段核对确认起算点口径错了 ——
上午那版按 jst_Orders.created 算,等于把客服审核积压算到仓库头上。

- 起算点 = 状态表 last_oa_confirm_time(业务选「最后审核时间」);
  取不到审核时间的单回退 created,页面 / 导出 / 钉钉该列打 *
- 已发判定补一条兜底:订单表 send_date 空、但状态表 send_time 有值的按已发剔除
  (实测 144 单里 6 单属此类,且有揽收轨迹)
- 明细与导出列改为「审核时间 / 下单时间 / 发货时间」并列,去掉旧「创建时间」列
- 钉钉明细第五段由「创建」改回「审核」;抬头「核验 XX 前创建」改「前审核」
- 回归测试同步改断言;stub 增加审核时间查询分支(默认空 = 全量回退 created)

实测(10-28 13:00 轮):599 → 144(按审核时间分轮)→ 138(剔除漏回写已发单)。
599 单里 376 单是当天 08:10~10:30 才批量审核的积压单,491 单其实已有运单号。

验收:后端 check 491/493(2 个失败为既有 Windows 权限 / TTL 用例,改动前同样失败);
前端 eslint 通过、build 23.92s 通过;本地真渲染 138 单、表头含「审核时间 / 下单时间」、
回退单带 *、无 pageerror。
2026-10-28 15:34:29 +08:00
6a5f6e8ae0 refactor(wst): 页面名去掉「24 小时」,改为「发货时效(到点未发即超时)」
规则完全不动,只改名字,消除误导。

## 为什么改

页面原名「仓储中心 24 小时发货时效」,但判定规则是「到 13:00 / 18:00
核验时刻仍未发货即超时」,**没有满 24 小时的门槛**(业务 2026-10-20 已三次纠偏定版)。
名字和规则打架,导致看的人以为「不满 24 小时不该算超时」。

实测例子:10-27 16:00 创建的单,今天 13:00 核验时只等了 **21 小时**就被判超时;
13:00 轮 599 单里有 416 单属于 10-27 16 点后创建。业务看到就问「创建时间都在 7 点后,
为什么算超时」—— 其实是名字里的「24 小时」在误导(那些是**昨天**晚上的单,
不是今天的)。

## 改了什么

用户可见(2 处):
- 侧栏菜单:`仓储中心24小时发货时效` → `仓储中心发货时效`
  (不加括号:侧栏现有最长 label 12 字,加了会到 18 字溢出)
- 页面标题:`仓储中心 24 小时发货时效` → `仓储中心发货时效(到点未发即超时)`

其余为注释同步(8 个文件),代码逻辑与判定口径**零改动**:
`warehouseShipTimelinessRegistry/Service/NotifyJob/NotifySettings`、
`warehouseLogisticsExceptionRegistry`、`frontend/api/warehouseShipTimeliness`、`backend/index`。

## 不变的部分

- 核验时点、窗口上界(07:00 / 16:00)、仓级例外(山东 15:00、洛宁 13:00)
- 数据源与起算点(订单两表 + `created`)
- 钉钉推送抬头本来就是「【仓储中心发货时效】」,不用改

## 验证

- 前端 eslint 通过、`npm run build` 通过(31.86s)
- 浏览器真渲染(本地 5173,等足 75 秒数据返回):
  - 标题 = `仓储中心发货时效(到点未发即超时)`
  - 侧栏项 = `仓储中心发货时效`,全页 `body` 内已无「24小时」字样
  - 数据正常渲染:超时未发 599 单 / 不计考核 849 / 涉及仓库 8 个 / 最久未发 13 天
  - 明细「创建时间」列完整显示 `2026-10-27 19:xx` 这类日期,不存在时区错位
  - 无 pageerror
- 后端 `npm run check`:本次只动注释,不影响
2026-10-28 14:30:54 +08:00
6c2686784d Merge pull request #88: 发货时效改用订单两表 + 起算点改为订单创建时间 2026-10-28 13:08:28 +08:00
776a2cbde2 feat(wst): 发货时效改用订单两表 + 起算点改为订单创建时间
业务 2026-10-28 定版:「核验抓取的销售订单表是错误的,应该在数据库抓
jst_Orders(订单)+ jst_OrderItem(明细),审核时间就是 jst_Orders 里的创建时间」。

## 为什么换数据源(状态镜像表的硬伤,均有实测)

1. `send_time` 漏回写:近 7 天「状态表说发货中+未发」的 4157 单里,
   订单表已有 `send_date` 的 563 单、status 已是 Sent 的 492 单 —— 发了还算超时。
2. 被合并 / 拆单父单不回写:89 + 755 单被冤枉。
3. 会整批停更:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮推出的
   34 单里混着已发货的。
4. 两张表发货时间其实同源:27387 单逐条比对**零偏差** ⟹ 订单表才是权威。

## 改了什么

后端
- 主查询改打 `rpt.jst_Orders` + `rpt.jst_qmcust_orderlist`(店铺互斥,UNION ALL,
  按 o_id 去重),SQL 侧过滤 `send_date IS NULL` + `status IN (Delivering/WaitConfirm/Question)`。
- 起算点由状态表 `last_oa_confirm_time` 改为订单表 `created`;
  实测 created 100% 早于审核时间,正常单差 7~40 分钟,夜间下单次日审的单差 6~9 小时
  —— 换口径后单量与「已等时长」都会变大,业务已知悉。
- 仓库归属:主查询只回 `wms_co_id`,Node 侧用仓表缓存换仓名再走 `resolveCenter()`
  (实测 SQL 里 LEFT JOIN 仓表会把查询从 28s 拖到 61~105s)。
- `OPTION (RECOMPILE)` + 时间字面量:qmcust 不加 RECOMPILE 实测 33~50s,加上 0.4s。
- 删除 `backfillOrderFacts()` 与 `mapLimit()`(起算点/状态/仓名已由主查询一次取全)。
- 不再按 `is_split / is_merge` 过滤 —— 实测 `is_split=1` 恰是拆单**子单**
  (同一 so_id 多行、各自要发货,近 30 天 1096 行),老逻辑 `is_split=0` 会把它们全排掉。
- 钉钉明细第 5 段「审核 HH:mm」→「创建 HH:mm」,无值文案「未审核」→「无创建时间」。

前端
- 明细/挂起清单/导出列「审核时间」→「创建时间」,Tooltip 与说明文案同步改写;
- 顶部说明补「数据源 = 订单两表」「已发未发取 send_date」;
- 「已支付待审核」统一为「待审核」(新数据源里就是 WaitConfirm)。

测试
- 两个测试文件的桩改成「订单两表 UNION」的返回形状,断言改为
  created 起算、候选状态白名单、SQL 必须带 RECOMPILE / 不含状态镜像表 / 不 JOIN 仓表。

## 验证

- `npm --prefix backend run check`:491/493 通过。2 项失败是既有环境问题:
  `resilientSecretStore`(Windows 文件权限 0o666≠0o600)、
  `cloudDatabaseConfigCache` 负缓存 TTL(依赖隧道状态),均与本改动无关,单独跑同样失败。
- 前端 eslint + build 通过。
- 真实库:NOON 1553 单 / AFTERNOON 1866 单(本地 2026-10-28),
  数据质量:无仓名 0 / 时间异常 0 / 含 undefined|null|NaN 0;
  单次查询耗时 35~38s(30 天护栏,冷缓存)。
- 浏览器真渲染(playwright,强制 round=NOON):
  表头 `…发货时间 / 创建时间 / 付款时间…`、轮次提示「核验今天 07:00 前创建的订单」、
  「导出明细」导出 CSV 表头含「创建时间」共 1551 行数据 + 1 行表头,无 pageerror。

## 风险与回退

- 口径变化是可感知的:单量上升,夜间下单/次日审核的单「已等时长」增加 6~9 小时,
  属业务确认后的预期结果。
- 状态表「售后已作废」判据随换源放弃(实测在「发货中+未发」里只命中 4 单),
  这类单现在按正常单算超时;如业务反馈,可单独补查。
- 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
2026-10-28 11:34:45 +08:00
913827000a Merge pull request 'feat(wst): 超时未发加「导出明细」+ 武进安能不考核 + 钉钉明细带快递;修复推送时区' (#87) from yanhui-wst-export-20261027 into main 2026-10-27 16:33:03 +08:00
f749bcc03c feat(wst): 超时未发加「导出明细」+ 武进安能不考核 + 钉钉明细带快递;修复推送时区
① 导出明细(业务要求:超时未发要能导出核对)
  · 新增 GET /api/warehouse-ship-timeliness/export:不分页、不截断,返回本轮全部行
  · 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV)
  · 选中某个仓时只导该仓(前端按 centerKey 过滤,与后端主仓归属同一套)
  · 导出列比页面多「快递公司 / 运单号」(追单要看有没有打单),单号一律转字符串
    避免 Excel 显示成科学计数法

② 缓存改成「存全量、出口截断」
  · 缓存键去掉 limit(原为企业级教训:WLE 核验缓存早就这么改了)
  · 页面 limit=1000、导出要全量,旧实现两边永不命中 → 每点一次导出重查一遍视图
  · 实测:首次查询 86.3s,导出命中缓存 5ms
  · 顺带修 detailTotal / heldTotal:明细被截断时页面仍显示真实总数,
    不再把「只显示前 1000 条」写成「一共就这么点」

③ 不考核快递追加「武进安能」(业务 2026-10-27)
  · 包含匹配,同时覆盖「武进安能大件」
  · ⚠️ 严格不泛化成「安能」:实测库里带「安能」的有 8 个写法,
    常州安能新 4207 单 / 安能物流 110 单等仍照旧考核(与
    warehouseLogisticsExceptionRegistry 同一口径,那里也写着这句)

④ 钉钉推送明细行在「线上单号」后加「快递」
  · 一行 = 仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
  · 群里直接能判断是哪家快递、要不要走站点,不用回页面逐单点开
  · 「已剔除 N 单不考核快递」的快递清单改成读服务层,别再各写一份

⑤ 修复推送任务时区(生产容器 /etc/timezone = Etc/UTC)
  · 症状:pushShipTimelinessNotify 把裸 new Date() 传给 getShipTimeliness,
    而 service / registry 内部用 getDate()/getHours() 直读 →
    北京 13:05 = UTC 05:05,被判「未到 13:00 核验时间」→ 两轮推送永不触发
  · 改法:rawNow(真实时刻,给 Intl / toISOString)+ bjNow(平移后,
    给本地 getter 与 service 调用)双轨,与 2026-10-21 那次同源问题的修法一致
2026-10-27 16:29:08 +08:00
33bfc7eacc Merge pull request 'feat(wst): 发货时效两轮核验完成后各推一次钉钉群' (#86) from yanhui-wst-notify-20261027 into main 2026-10-27 15:45:40 +08:00
f766549b1a feat(wst): 发货时效两轮核验完成后各推一次钉钉群
业务原话(2026-10-27):「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,
核验完成后通知;18:00 核验的订单,核验完成后也通知。」

## 做了什么

- 新增 `warehouseShipTimelinessNotifySettings`:配置与状态存取,两个轮次各一个推送时刻
  (NOON 默认 13:05 / AFTERNOON 默认 18:05),加签密钥走 resilientSecretStore。
- 新增 `warehouseShipTimelinessNotifyJob`:正文组装 + 每分钟 tick + 启动后 30 分钟补推窗口。
- 路由:`GET/PUT /api/warehouse-ship-timeliness/notify/settings`、
  `GET .../notify/state`、`POST .../notify/run`(dryRun / probe / force)。
- `index.js` 注册 `startShipTimelinessNotifyScheduler()`。
- 前端:页面右上「钉钉通知」→ 弹窗(开关、两个时刻、webhook、加签密钥、明细条数、
  @所有人、页面链接、受控预览块、上次推送)。顺带把 held 明细弹窗的 `destroyOnClose`
  改成 `destroyOnHidden`(antd 5.25 已弃用)。

## 口径要点

- **一天两推,各绑一个轮次**:13:00 轮核验「今天 07:00 前审核」、18:00 轮核验
  「截止 16:00 前审核」(都含历史未发)。两轮互不影响,两个时刻配成同一个 HH:mm 时两轮都推。
- **默认核验点 + 5 分钟**,不是整点:状态镜像 `query_time` 要略晚于核验时刻,
  整点推容易 `coversDeadline=false`(数据没覆盖核验点、超时数偏大)。
  这种时候消息里会带一行提示,仍照发(业务要的是「核验完就通知」)。
- **去重键 `<北京日期>|<轮次键>`**,一天两轮各自独立;推送成功才写状态,失败不写。
- **0 单超时也推**(群里看到「本轮 0 单」= 任务没挂)。
- **明细一行 = 仓|内部单号|线上单号|审核 时间|已等 X 小时**;
  历史单的审核时间带 MM-DD —— 13:00 轮含历史未发单,只写「审核 16:13」会被读成今天。
- 明细按仓聚集 + 每仓保底 1 条,钉钉 text 超长时从末尾砍。

## 验证

- `backend/test/warehouseShipTimelinessNotify.test.js` 新增 17 条,全过。
- 后端 `check-syntax` 通过;`node --test test/*.test.js` 491 条 / 489 过 / 2 失败,
  失败两条是既有环境问题(`cloudDatabaseConfigCache` 负缓存用例依赖数据库不可达、
  `resilientSecretStore` 的 0600 权限断言在 Windows 恒为 0o666),与本改动无关。
- 前端 `eslint src/pages/WarehouseShipTimeliness src/api/warehouseShipTimeliness.js` 0 错;
  `vite build` 通过(含 `verify-router-lazy`)。
- 本地真渲染:页面右上出现「钉钉通知」,弹窗完整渲染、点「预览正文」出正文块,无 pageerror
  (验收时把 `/notify/**` 全部 mock —— 本地系统库与生产是同一个,避免写坏生产配置)。
- 本地实数据预览(13:00 轮,冷查 46s):超时 15 单 / 4 个仓 / 不计考核 672 / 剔除 1;
  同一时刻请求 18:00 轮返回 `before_deadline`(未到 18:00 不推半成品)。

## 风险与回退

- 默认关闭(`enabled=false`)且未配 webhook 时一切接口只返回 `not_configured`,不会发消息。
- 想彻底停掉:`DISABLE_WST_NOTIFY=true` 或把推送开关关掉;两者都不影响页面本身。
- 回退:删掉 `startShipTimelinessNotifyScheduler()` 一行即可停调度,新增文件无副作用。
2026-10-27 15:43:18 +08:00
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
22 changed files with 4914 additions and 1046 deletions

View File

@ -17,7 +17,7 @@ const EXPORT_COLUMNS = [
{ key: 'pickQty', title: '拣货位库存' }, { key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存储位库存' }, { key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' }, { key: 'totalQty', title: '现有库存合计' },
{ key: 'orderQty', title: '当日订单' }, { key: 'orderQty', title: '待发订单' },
{ key: 'excess', title: '超出订单数' }, { key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' }, { key: 'suggestQty', title: '建议补货量' },
{ key: 'shortfall', title: '存储缺口' }, { key: 'shortfall', title: '存储缺口' },
@ -49,7 +49,7 @@ router.get('/export', async (req, res, next) => {
keyword: req.query.keyword, keyword: req.query.keyword,
status: req.query.status, status: req.query.status,
}); });
const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`; const fileName = `${payload.warehouse.shortName}待补明细`;
sendWorkbookDownload(res, { sendWorkbookDownload(res, {
fileName, fileName,

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
/** /**
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增) * 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
* ──────────────────────────────────────────────────────────────────────────── * ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-ship-timeliness/list * GET /api/warehouse-ship-timeliness/list
* query: * query:
@ -11,12 +11,33 @@
* GET /api/warehouse-ship-timeliness/rounds * GET /api/warehouse-ship-timeliness/rounds
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回) * 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
* *
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
* query: round / force
*
* ── 两轮核验完成后推钉钉群(2026-10-27 新增)────────────────────────────────
* GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码)
* PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变)
* GET /api/warehouse-ship-timeliness/notify/state 上次推送结果
* POST /api/warehouse-ship-timeliness/notify/run 立即推送一次(dryRun / probe / force)
*
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware * 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
*/ */
const express = require('express'); const express = require('express');
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService'); const {
getShipTimeliness,
listShipTimelinessExportRows,
} = require('../services/warehouseShipTimelinessService');
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry'); const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
const {
getShipTimelinessNotifyAdminConfig,
getShipTimelinessNotifyRuntimeConfig,
loadShipTimelinessNotifyState,
missingFields,
saveShipTimelinessNotifyConfig,
} = require('../services/warehouseShipTimelinessNotifySettings');
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
const router = express.Router(); const router = express.Router();
@ -59,4 +80,95 @@ router.get('/rounds', async (_req, res) => {
} }
}); });
/**
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
*
* query: round(同 /list)、force=1(绕过缓存重查)
*
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
*/
router.get('/export', async (req, res) => {
try {
const data = await listShipTimelinessExportRows(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '导出超时未发明细失败');
}
});
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
router.get('/notify/settings', async (_req, res) => {
try {
const [config, state] = await Promise.all([
getShipTimelinessNotifyAdminConfig(),
loadShipTimelinessNotifyState(),
]);
res.json({ success: true, data: { ...config, lastState: state || {} } });
} catch (error) {
sendError(res, error, '读取发货时效通知配置失败');
}
});
// 保存配置;加签密钥留空表示「沿用已存的」,不必每次重填
router.put('/notify/settings', async (req, res) => {
try {
const data = await saveShipTimelinessNotifyConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
const status = Number(error?.status) || 400;
console.error('[WarehouseShipTimeliness] 保存通知配置失败:', error?.message);
res.status(status).json({ success: false, error: error.message || '保存发货时效通知配置失败' });
}
});
router.get('/notify/state', async (_req, res) => {
try {
const state = await loadShipTimelinessNotifyState();
res.json({ success: true, data: state || {} });
} catch (error) {
sendError(res, error, '读取推送状态失败');
}
});
/**
* 立即推送一次(页面「立即推送」/「预览正文」/「测试通道」)。
* body:
* round 'NOON' | 'AFTERNOON',不传按当前时刻自动判定
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
* force true = 忽略「本轮已推过」(默认手动触发就是 true,除非显式给 false)
*/
router.post('/notify/run', async (req, res) => {
try {
const body = req.body || {};
const dryRun = body.dryRun === true || body.dryRun === 'true';
const probe = body.probe === true || body.probe === 'true';
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
if (!dryRun) {
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
const missing = missingFields(config);
if (missing.length) {
res.status(400).json({
success: false,
error: `请先补全并保存:${missing.join(' / ')}`,
data: { missingFields: missing },
});
return;
}
}
const result = await pushShipTimelinessNotify({
dryRun,
probe,
force,
round: body.round || undefined,
});
res.json({ success: true, data: result });
} catch (error) {
sendError(res, error, '推送发货时效通知失败');
}
});
module.exports = router; module.exports = router;

View File

@ -72,6 +72,9 @@ const {
const { const {
startWarehouseLogisticsExceptionNotifyScheduler, startWarehouseLogisticsExceptionNotifyScheduler,
} = require('./jobs/warehouseLogisticsExceptionNotifyJob'); } = require('./jobs/warehouseLogisticsExceptionNotifyJob');
const {
startShipTimelinessNotifyScheduler,
} = require('./jobs/warehouseShipTimelinessNotifyJob');
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
const { ensureLocalSchema } = require('./services/localSchema'); const { ensureLocalSchema } = require('./services/localSchema');
const { const {
@ -678,7 +681,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发 // 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes); cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录 // 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes); cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
@ -798,6 +801,7 @@ async function startServer(options = {}) {
startAlibaba1688ImageSearchExpiryScheduler(); startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler(); startMkhReplenishAlertScheduler();
startWarehouseLogisticsExceptionNotifyScheduler(); startWarehouseLogisticsExceptionNotifyScheduler();
startShipTimelinessNotifyScheduler();
startDailyInventorySnapshotScheduler(); startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema // Keep the first selection-pool page request from paying the remote schema

View File

@ -12,6 +12,10 @@
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。 * 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。 * ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。 * ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置, * ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
@ -95,6 +99,79 @@ function buildDetailLine(row, index) {
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 : [];
@ -174,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) => {
@ -418,6 +502,8 @@ module.exports = {
buildTypeLine, buildTypeLine,
chinaDateKey, chinaDateKey,
chinaHourMinute, chinaHourMinute,
groupRowsByWarehouse,
pickDetailRows,
cleanCell, cleanCell,
pushWarehouseLogisticsExceptionNotify, pushWarehouseLogisticsExceptionNotify,
runWarehouseLogisticsExceptionNotifyJob, runWarehouseLogisticsExceptionNotifyJob,

View File

@ -0,0 +1,693 @@
'use strict';
/**
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-27 原话):
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
* 18:00 核验的订单,核验完成后也通知。」
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
*
* 消息内容:
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
*
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
*
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
*/
const cron = require('node-cron');
const {
getShipTimeliness,
DEFAULT_DETAIL_LIMIT,
EXCLUDED_CARRIERS,
} = require('../services/warehouseShipTimelinessService');
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
const {
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME_AFTERNOON,
DEFAULT_SEND_TIME_NOON,
buildPushSlot,
getShipTimelinessNotifyRuntimeConfig,
isShipTimelinessNotifyReady,
loadShipTimelinessNotifyState,
saveShipTimelinessNotifyState,
trimSentMap,
} = require('../services/warehouseShipTimelinessNotifySettings');
const TIMEZONE = 'Asia/Shanghai';
const DEFAULT_TICK_CRON = '* * * * *';
// 钉钉 text 上限 20000 字节;留 300 字节给尾巴,避免临界失败
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
// 启动补推窗口:后端在 13:05 那一刻没跑(重启 / 部署),30 分钟内起来还补一次
const CATCH_UP_WINDOW_MINUTES = 30;
let tickRunning = false;
function pad2(value) {
return String(value).padStart(2, '0');
}
/** 北京时间的 YYYY-MM-DD */
function chinaDateKey(date = new Date()) {
const base = date instanceof Date ? date : new Date(date);
if (!Number.isFinite(base.getTime())) return '';
return new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(base);
}
/** 北京时间的 HH:mm —— 用来跟配置里的推送时刻比对 */
function chinaHourMinute(date = new Date()) {
const base = date instanceof Date ? date : new Date(date);
if (!Number.isFinite(base.getTime())) return '';
return new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(base);
}
/** 北京时间的 HH:mm 换算成「当天第几分钟」,方便做 +/- 比较 */
function minutesOfDay(date = new Date()) {
const text = chinaHourMinute(date);
if (!text) return null;
const [hour, minute] = text.split(':').map((part) => Number(part));
return hour * 60 + minute;
}
function parseSendTime(text) {
const [hour, minute] = String(text || '').split(':').map((part) => Number(part));
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return hour * 60 + minute;
}
/** 字段清洗:换行 / 连续空白压成单空格(避免一条明细被劈成两行) */
function cleanCell(value, fallback = '') {
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
return text || fallback;
}
/** 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」,与页面同一套写法 */
function formatOverdue(minutes) {
const m = Number(minutes);
if (!Number.isFinite(m) || m <= 0) return '—';
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
const rest = m % 60;
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
const d = Math.floor(h / 24);
const hr = h % 24;
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
}
/**
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
*
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
*/
function auditWhen(auditTime, roundDate) {
const text = cleanCell(auditTime);
if (!text) return '';
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
if (!hit) {
const clock = text.match(/(\d{2}:\d{2})/);
return clock ? clock[1] : text;
}
const [, , month, day, hour, minute] = hit;
const sameDay = Boolean(roundDate) && String(roundDate).slice(5, 10) === `${month}-${day}`;
return sameDay ? `${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`;
}
/**
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
* 两者业务都要,别只留一个。
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
* 不用再回页面一单单点开确认。
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
*/
function buildDetailLine(row, index, roundDate) {
const when = auditWhen(row?.auditTime, roundDate);
const auditText = when
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
: '审核 —';
const parts = [
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
cleanCell(row?.oId, '无单号'),
cleanCell(row?.soId, '—'),
cleanCell(row?.logisticCompany, '—'),
auditText,
`已等 ${formatOverdue(row?.overdueMinutes)}`,
];
return `${index}. ${parts.join('|')}`;
}
/** 按仓分桶(用主仓名),组间按超时单数降序,组内保持服务层原顺序(已按时长降序) */
function bucketRowsByCenter(rows) {
const buckets = new Map();
const firstSeen = new Map();
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
const key = cleanCell(row?.centerName || 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);
}
/**
* 按仓分配明细额度,返回已按仓聚集的行。
* 同 WLE 的理由:某仓超时特别多时会一口气吃满额度,其它仓一条都看不到。
* ① 仓数 ≥ 额度 → 每仓 1 条;
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法)。
*/
function pickDetailRows(rows, limit) {
const groups = bucketRowsByCenter(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));
}
/** 按仓汇总块:只列超时 > 0 的仓,降序,最多 12 行 */
function buildCenterLines(data = {}) {
return (data.centers || [])
.map((center) => ({
name: cleanCell(center.name || center.key, '未知仓'),
overdue: Number(center.overdueUnshipped) || 0,
oldestDays: Number(center.oldestDays) || 0,
}))
.filter((item) => item.overdue > 0)
.sort((a, b) => b.overdue - a.overdue)
.slice(0, 12)
.map((item, index) => (
`${index + 1}. ${item.name} ${item.overdue} 单`
+ (item.oldestDays > 0 ? `(最久 ${item.oldestDays} 天)` : '')
));
}
function buildPageLink(pageUrl, roundKey) {
const base = String(pageUrl || DEFAULT_PAGE_URL);
if (!roundKey) return base;
const glue = base.includes('?') ? '&' : '?';
return `${base}${glue}round=${encodeURIComponent(roundKey)}`;
}
/**
* 组装群消息正文(纯函数,便于单测)。
*
* @param {object} data getShipTimeliness 的返回
* @param {object} options maxDetailRows / pageUrl / roundKey
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
*/
function buildNotifyText(data = {}, options = {}) {
const summary = data.summary || {};
const round = data.round || {};
const window = data.window || {};
const rows = Array.isArray(data.orders) ? data.orders : [];
const freshness = data.freshness || {};
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
: DEFAULT_MAX_DETAIL_ROWS;
const roundKey = options.roundKey || round.key || '';
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, roundKey);
const overdue = Number(summary.overdueUnshipped) || 0;
const centerCount = Number(summary.overdueCenterCount) || 0;
const oldestDays = Number(summary.oldestDays) || 0;
const heldCount = Number(summary.heldCount) || 0;
const roundLabel = cleanCell(round.label, '本轮核验');
const windowEnd = String(window.end || '').slice(11, 16);
const head = [
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
];
if (overdue === 0) {
head.push('本轮没有超时未发货的订单,全部按时发出。');
}
if (heldCount > 0) {
head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`);
}
const carrierSkipped = Number(summary.carrierSkipped) || 0;
if (carrierSkipped > 0) {
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
.filter(Boolean);
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
}
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
if (freshness.coversDeadline === false) {
head.push(
`⚠️ 数据快照只到 ${String(freshness.stateMaxQuery || '').slice(11, 16) || '未知时间'},`
+ `尚未覆盖本轮核验时刻 ${String(data.deadline || '').slice(11, 16)},超时单量偏大,请以聚水潭实时状态为准。`
);
}
const centerLines = overdue > 0 ? buildCenterLines(data) : [];
const roundDate = String(data.date || round.date || '').slice(0, 10);
const detailAll = pickDetailRows(rows, maxDetailRows)
.map((row, index) => buildDetailLine(row, index + 1, roundDate));
const wantCount = detailAll.length;
const buildTail = (listed) => {
const lines = [];
if (overdue > 0) {
if (listed === 0) {
lines.push('');
lines.push(`明细共 ${overdue} 条,消息里不放,请点下面链接查看。`);
} else if (overdue > listed) {
lines.push(`…其余 ${overdue - listed} 条见页面。`);
}
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
}
return lines;
};
const assemble = (listed, withCenters) => {
const blocks = [...head];
if (withCenters && centerLines.length) {
blocks.push('');
blocks.push('按仓(超时单):');
blocks.push(...centerLines);
}
if (listed > 0) {
blocks.push('');
blocks.push(`明细前 ${listed} 条:`);
blocks.push(...detailAll.slice(0, listed));
}
const tail = buildTail(listed);
if (tail.length) blocks.push(...tail);
const text = blocks.join('\n');
return { text, bytes: Buffer.byteLength(text, 'utf8') };
};
// 超长就从明细末尾往前砍(先砍明细,再砍分仓块)
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」
let listed = wantCount;
let withCenters = centerLines.length > 0;
let result = assemble(listed, withCenters);
let truncated = false;
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
listed -= 1;
truncated = true;
result = assemble(listed, withCenters);
}
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
withCenters = false;
result = assemble(listed, withCenters);
}
if (result.bytes > SAFE_CONTENT_BYTES) {
const chars = Array.from(result.text);
let bytes = 0;
let cut = '';
for (const char of chars) {
const size = Buffer.byteLength(char, 'utf8');
if (bytes + size > SAFE_CONTENT_BYTES) break;
bytes += size;
cut += char;
}
result = { text: cut, bytes };
truncated = true;
}
return {
text: result.text,
rowCount: overdue,
listedCount: listed,
bytes: result.bytes,
truncated,
};
}
/** 取某一轮次今天的元信息(deadline / windowEnd / label) */
function resolveRoundMeta(roundKey, now = new Date()) {
const key = String(roundKey || '').toUpperCase();
const target = ROUNDS.find((round) => round.key === key);
if (!target) return null;
const described = describeRounds(now).find((round) => round.key === key);
if (!described) return null;
return {
key: target.key,
label: described.label || target.label,
date: described.date,
verifyHour: target.verifyHour,
deadline: described.deadline,
windowEnd: described.windowEnd,
};
}
/** 某个轮次今天该在几点推(读配置) */
function sendTimeForRound(config, roundKey) {
const key = String(roundKey || '').toUpperCase();
if (key === 'AFTERNOON') return config.afternoonSendTime || DEFAULT_SEND_TIME_AFTERNOON;
return config.noonSendTime || DEFAULT_SEND_TIME_NOON;
}
/**
* 执行一次推送。参数:
* round 'NOON' | 'AFTERNOON'(不传 = 按当前时刻自动判定)
* now 指定时间(测试用)
* force 忽略「今天这轮已推过」(页面「立即推送」用)
* dryRun 只组装消息不发送(预览 / 单测用)
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
*/
async function pushShipTimelinessNotify(options = {}) {
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
const bjNow = beijingNow(rawNow);
const pushDate = chinaDateKey(rawNow);
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
if (options.probe === true) {
if (!isShipTimelinessNotifyReady(config)) {
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
}
const delivery = await sendTextToWebhook({
webhookUrl: config.webhookUrl,
secret: config.webhookSecret,
content: '【仓储中心发货时效】通知通道测试:收到这条说明 webhook 配置可用。',
});
return { sent: true, probe: true, delivery };
}
// 轮次:显式指定优先;否则按当前时刻自动判定(13:00 前 = 没有轮次)
const explicitRound = String(options.round || '').toUpperCase();
let roundKey = explicitRound;
if (!roundKey) {
const minutes = minutesOfDay(rawNow);
let active = null;
for (const round of ROUNDS) {
if (minutes >= round.verifyHour * 60) active = round;
}
if (!active) return { sent: false, reason: 'not_started' };
roundKey = active.key;
}
const meta = resolveRoundMeta(roundKey, bjNow);
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
}
const slot = buildPushSlot(pushDate, roundKey);
const state = await loadShipTimelinessNotifyState();
const sentMap = (state && typeof state.sent === 'object' && state.sent) ? state.sent : {};
if (sentMap[slot] && options.force !== true) {
return {
sent: false,
reason: 'already_sent',
slot,
lastSentAt: sentMap[slot].sentAt || null,
round: roundKey,
};
}
if (!config.enabled && options.force !== true) {
return { sent: false, reason: 'disabled', round: roundKey };
}
const dryRun = options.dryRun === true;
if (!dryRun && !isShipTimelinessNotifyReady(config)) {
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
}
// 明细条数用默认 500(= 页面同一档),这样与页面共用结果缓存:
// 推送完业务点进页面几乎是热的,不会为了推一条消息额外扫一次订单视图。
// summary/centers 都是全量算的,limit 只截断明细,不影响数字。
const data = await getShipTimeliness({
round: roundKey,
limit: DEFAULT_DETAIL_LIMIT,
now: bjNow,
});
if (data?.notStarted) {
return { sent: false, reason: 'not_started', round: roundKey };
}
const built = buildNotifyText(data, {
maxDetailRows: config.maxDetailRows,
pageUrl: config.pageUrl,
roundKey,
});
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
round: roundKey,
roundLabel: data.round?.label || meta.label,
deadline: data.deadline || meta.deadline,
text: built.text,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
summary: data.summary || null,
freshness: data.freshness || null,
};
}
const delivery = await sendTextToWebhook({
webhookUrl: config.webhookUrl,
secret: config.webhookSecret,
content: built.text,
atAll: config.atAll === true,
});
const nextSent = {
...sentMap,
[slot]: {
sentAt: rawNow.toISOString(),
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
deadline: data.deadline || meta.deadline || '',
roundLabel: data.round?.label || meta.label || '',
},
};
await saveShipTimelinessNotifyState({
version: 1,
sent: trimSentMap(nextSent),
lastSentAt: rawNow.toISOString(),
lastStatus: 'sent',
lastError: '',
lastRound: roundKey,
lastRoundLabel: data.round?.label || meta.label || '',
lastSlot: slot,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
});
return {
sent: true,
round: roundKey,
roundLabel: data.round?.label || meta.label,
slot,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
text: built.text,
summary: data.summary || null,
delivery,
};
}
async function runShipTimelinessNotifyJob(source = 'scheduled', options = {}) {
try {
const result = await pushShipTimelinessNotify(options);
if (result.sent) {
console.log(
`[WstNotify] ${source} 已推送 ${result.round}(超时 ${result.rowCount} 单,列出 ${result.listedCount} 条,${result.bytes} 字节)`
);
} else {
console.log(`[WstNotify] ${source} 跳过: ${result.reason}${result.round ? ` (${result.round})` : ''}`);
}
return result;
} catch (error) {
console.error(`[WstNotify] ${source} 执行失败:`, error.message || error);
try {
await saveShipTimelinessNotifyState({
...(await loadShipTimelinessNotifyState()),
lastError: String(error.message || error).slice(0, 500),
lastFailedAt: new Date().toISOString(),
});
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
return { sent: false, reason: 'error', error: error.message || String(error) };
}
}
/**
* 判断此刻该推哪些轮次(返回数组 —— 两个时刻万一配成同一个 HH:mm,两轮都要推)。
* · 精确命中 HH:mm → 推
* · 刚启动的补推窗口(sendTime ~ sendTime+30min 内)→ 推(是否重复由调用方按 slot 去重)
* 之所以不用固定 cron:业务在页面上改时刻后要立即生效,不能等重启。
*/
function resolveDueRounds(config, now = new Date(), { catchUp = false } = {}) {
const minutes = minutesOfDay(now);
if (minutes == null) return [];
const due = [];
for (const round of ROUNDS) {
const target = parseSendTime(sendTimeForRound(config, round.key));
if (target == null) continue;
if (minutes === target) {
due.push(round.key);
continue;
}
if (catchUp && minutes > target && minutes <= target + CATCH_UP_WINDOW_MINUTES) {
due.push(round.key);
}
}
return due;
}
/**
* 调度:每分钟 tick,比对两个轮次各自的推送时刻。
* 启动时额外做一次「补推」检查:13:05 那一刻后端没在跑(重启 / 部署)也能补上。
*/
function startShipTimelinessNotifyScheduler() {
const disabled = String(process.env.DISABLE_WST_NOTIFY || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] 发货时效钉钉推送已禁用');
return;
}
const expression = String(process.env.WST_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
const tick = async ({ catchUp = false } = {}) => {
if (tickRunning) return;
tickRunning = true;
try {
const now = new Date();
const config = await getShipTimelinessNotifyRuntimeConfig();
if (!config.enabled) return;
const due = resolveDueRounds(config, now, { catchUp });
if (!due.length) return;
const state = catchUp ? await loadShipTimelinessNotifyState() : null;
for (const roundKey of due) {
if (catchUp) {
// 补推只在「今天这轮确实还没推过」时才动手,避免重启就把消息重发一遍
const slot = buildPushSlot(chinaDateKey(now), roundKey);
if (state?.sent && state.sent[slot]) continue;
console.log(`[WstNotify] 启动补推检查命中 ${roundKey}`);
}
await runShipTimelinessNotifyJob(catchUp ? 'catch_up' : 'scheduled', { now, round: roundKey });
}
} catch (error) {
console.error('[WstNotify] 定时检查失败:', error.message || error);
} finally {
tickRunning = false;
}
};
cron.schedule(expression, () => { tick(); }, { timezone: TIMEZONE });
// 启动后 45s 跑一次补推检查(等业务库/隧道就绪,别和启动预热抢)
setTimeout(() => { tick({ catchUp: true }); }, 45 * 1000);
console.log(
`[Scheduler] 发货时效钉钉推送已注册: 每分钟检查,13:00 轮→默认 13:05、18:00 轮→默认 18:05 (${TIMEZONE})`
);
}
module.exports = {
CATCH_UP_WINDOW_MINUTES,
DEFAULT_TICK_CRON,
SAFE_CONTENT_BYTES,
TIMEZONE,
auditWhen,
buildCenterLines,
buildDetailLine,
buildNotifyText,
buildPageLink,
bucketRowsByCenter,
chinaDateKey,
chinaHourMinute,
cleanCell,
formatOverdue,
minutesOfDay,
parseSendTime,
pickDetailRows,
pushShipTimelinessNotify,
resolveDueRounds,
resolveRoundMeta,
runShipTimelinessNotifyJob,
sendTimeForRound,
startShipTimelinessNotifyScheduler,
};

View File

@ -4,7 +4,7 @@
* 缺货补货监控(订单驱动版,2026-10-22 重写) * 缺货补货监控(订单驱动版,2026-10-22 重写)
* ========================================================================== * ==========================================================================
* 业务需求(用户确认): * 业务需求(用户确认):
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣, * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
* 需要从「存储库位」把货补过去。 * 需要从「存储库位」把货补过去。
* *
* ── 货位口径(前缀规则,用户指定)────────────────────────────────────── * ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
@ -14,21 +14,26 @@
* *
* ── 数据源 ─────────────────────────────────────────────────────────── * ── 数据源 ───────────────────────────────────────────────────────────
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储) * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* 订单:rpt.jst_Orders × jst_OrderItem(线上店铺) * 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
* + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B) * —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
* + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 按 wh_id 会把多仓混成一个桶。 * 按 wh_id 会把多仓混成一个桶。
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」, *
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。 * ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
* 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
* 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
* 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
* 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
* **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
* *
* ── 判断逻辑(核心)────────────────────────────────────────────────── * ── 判断逻辑(核心)──────────────────────────────────────────────────
* 对每一个 SKU(在拣货/存储位上有库存): * 对每一个 SKU(在拣货/存储位上有库存):
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计 * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计 * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开) * 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
* 超出订单数 excess = max(0, orderQty − pickQty) * 超出订单数 excess = max(0, orderQty − pickQty)
* —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位 * —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量) * 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调) * 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
* *
@ -36,10 +41,10 @@
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急 * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess) * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* enough 拣货位充足(0 < orderQty ≤ pickQty) * enough 拣货位充足(0 < orderQty ≤ pickQty)
* no_order 今日无单(orderQty = 0) * no_order 无待发单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调) * (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
* *
* ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min, * ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。 * 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/ */
@ -75,6 +80,14 @@ const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(','); const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_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({
SHORT: 'short', SHORT: 'short',
NEED: 'need', NEED: 'need',
@ -86,7 +99,7 @@ const STATUS_LABELS = Object.freeze({
short: '存储不足', short: '存储不足',
need: '需补货', need: '需补货',
enough: '拣货位充足', enough: '拣货位充足',
no_order: '今日无单', no_order: '无待发单',
}); });
// 排序权重:最紧急的排最前 // 排序权重:最紧急的排最前
@ -125,14 +138,6 @@ function resolveCacheTtlMs() {
return num; return num;
} }
/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
function orderWindow(orderDate) {
const d = new Date(`${orderDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
const next = d.toISOString().slice(0, 10);
return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
}
// ── 副本取数 ──────────────────────────────────────────────────────── // ── 副本取数 ────────────────────────────────────────────────────────
/** /**
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。 * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
@ -150,89 +155,79 @@ async function loadReplicaInventory(warehouse) {
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
AND p.wh_id = 1
AND ISNULL(p.bin, '') <> '' AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN}) AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`; GROUP BY p.sku_id`;
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); // 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
} }
/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */ /**
async function loadOrderDate(warehouse) { * 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
const rows = await cloudDb.query( * 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
`SELECT MAX(d) AS d FROM ( */
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d async function loadSkuNames(skuIds) {
FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
UNION ALL if (!uniq.length) return new Map();
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d const map = new Map();
FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId const CHUNK = 400;
) t`, for (let i = 0; i < uniq.length; i += CHUNK) {
{ wmsCoId: warehouse.wmsCoId }, const chunk = uniq.slice(i, i + CHUNK);
{ requestTimeout: 120000 } const params = {};
); const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
return (rows && rows[0] && rows[0].d) || null; 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 的订单量:4 个来源分支,组合装展开为子件。 */ /**
async function loadTodayOrderMap(warehouse, orderDate) { * 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
if (!orderDate) return new Map(); * 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
const { shipFrom, shipTo } = orderWindow(orderDate); * 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
const sql = ` *
WITH ship AS ( * ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
-- 1) 线上店铺订单:普通商品行 * 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
SELECT oi.sku_id AS skuId, * 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty */
FROM rpt.jst_OrderItem oi async function loadPendingOrderMap(warehouse) {
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
-- 2) 线上店铺订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
SELECT oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
-- 4) 全民客户订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
)
SELECT skuId, SUM(orderQty) AS orderQty
FROM ship
GROUP BY skuId`;
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query; 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( const rows = await runQuery(
sql, `SELECT o.sku_id AS skuId,
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo }, SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
{ requestTimeout: 300000 } 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) => {
@ -243,7 +238,7 @@ async function loadTodayOrderMap(warehouse, orderDate) {
} }
// ── 组装 ──────────────────────────────────────────────────────────── // ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ invRows, orderMap, orderDate }) { function buildSkuRows({ invRows, orderMap }) {
const rows = []; const rows = [];
(invRows || []).forEach((row) => { (invRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120); const skuId = toText(row.skuId, 120);
@ -293,45 +288,49 @@ function buildSkuRows({ invRows, orderMap, orderDate }) {
return rows; return rows;
} }
function buildSummary(rows, orderSkuCount) { /**
* 汇总只统计「待补」SKU(need / short)——
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
* 实时代发订单数 = 待补 SKU 待发件数合计
* 拣货库存数 = 待补 SKU 拣货位库存合计
* 存储库存数 = 待补 SKU 存储位库存合计
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
*/
function buildSummary(rows) {
const summary = { const summary = {
skuTotal: rows.length, skuTotal: 0,
need: 0, need: 0,
short: 0, short: 0,
enough: 0,
noOrder: 0,
pickEmpty: 0,
suggestQtyTotal: 0,
orderSkuCount: orderSkuCount || 0,
orderQtyTotal: 0, orderQtyTotal: 0,
exceedTotal: 0,
shortfallTotal: 0,
pickTotal: 0, pickTotal: 0,
storageTotal: 0, storageTotal: 0,
suggestQtyTotal: 0,
exceedTotal: 0,
shortfallTotal: 0,
}; };
rows.forEach((row) => { rows.forEach((row) => {
if (summary[row.status] !== undefined) summary[row.status] += 1; summary.skuTotal += 1;
summary.suggestQtyTotal += row.suggestQty || 0; if (row.status === STATUS.NEED) summary.need += 1;
else if (row.status === STATUS.SHORT) summary.short += 1;
summary.orderQtyTotal += row.orderQty || 0; summary.orderQtyTotal += row.orderQty || 0;
summary.exceedTotal += row.excess || 0;
summary.shortfallTotal += row.shortfall || 0;
summary.pickTotal += row.pickQty || 0; summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0; summary.storageTotal += row.storageQty || 0;
if (row.pickEmpty) summary.pickEmpty += 1; summary.suggestQtyTotal += row.suggestQty || 0;
summary.exceedTotal += row.excess || 0;
summary.shortfallTotal += row.shortfall || 0;
}); });
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.orderQtyTotal = round2(summary.orderQtyTotal); summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
summary.pickTotal = round2(summary.pickTotal); summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal); summary.storageTotal = round2(summary.storageTotal);
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
return summary; return summary;
} }
async function loadDataset(warehouse, { refresh = false } = {}) { async function loadDataset(warehouse, { refresh = false } = {}) {
const orderDate = await loadOrderDate(warehouse); // 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL const cacheKey = warehouse.key;
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
const cached = cacheStore.get(cacheKey); const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs(); const ttl = resolveCacheTtlMs();
const now = Date.now(); const now = Date.now();
@ -342,16 +341,31 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
const epoch = cacheEpoch; const epoch = cacheEpoch;
const [invRows, orderMap] = await Promise.all([ const [invRows, orderMap] = await Promise.all([
loadReplicaInventory(warehouse), loadReplicaInventory(warehouse),
loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()), loadPendingOrderMap(warehouse).catch(() => new Map()),
]); ]);
const rows = buildSkuRows({ invRows, orderMap, orderDate }); // 把「有打单拣货待发单、但货位库存视图里没有记录」的 SKU 也补进明细,
// 否则这批最该补货(拣货位为 0)的行会被漏掉。
const invKeys = new Set((invRows || []).map((r) => toText(r.skuId, 120)));
const orderOnly = [...orderMap.keys()]
.filter((k) => !invKeys.has(k))
.map((k) => ({ skuId: k, pickQty: 0, storageQty: 0, productName: null, supplier: null }));
const combined = (invRows || []).concat(orderOnly);
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,
orderDate, orderScope: PENDING_ORDER_SCOPE,
meta: { meta: {
rowCount: rows.length, rowCount: rows.length,
orderSkuCount: orderMap.size, orderSkuCount: orderMap.size,
@ -368,15 +382,6 @@ 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' || key === 'replenish') {
return row.status === STATUS.NEED || row.status === STATUS.SHORT;
}
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)
@ -386,10 +391,19 @@ function sortRows(rows) {
)); ));
} }
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)
@ -400,21 +414,22 @@ function filterRows(rows, { keyword, status }) {
async function getReplenishList(options = {}) { async function getReplenishList(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 filtered = sortRows(filterRows(dataset.rows, options)); const replenished = filterReplenish(dataset.rows, options);
const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount); const sorted = sortRows(replenished);
const summary = buildSummary(replenished);
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),
orderDate: dataset.orderDate, 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(),
@ -424,15 +439,16 @@ async function getReplenishList(options = {}) {
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 replenished = filterReplenish(dataset.rows, options);
return { return {
warehouse, warehouse,
orderDate: dataset.orderDate, orderScope: PENDING_ORDER_SCOPE,
rows: sortRows(filterRows(dataset.rows, options)), rows: sortRows(replenished),
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount), summary: buildSummary(replenished),
}; };
} }
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */ /** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() { async function warmBinReplenishCache() {
try { try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];

View File

@ -4,7 +4,7 @@
* 仓储中心物流转运异常(2026-10-21 新增) * 仓储中心物流转运异常(2026-10-21 新增)
* ──────────────────────────────────────────────────────────────────────────── * ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-21): * 需求(yanhui 2026-10-21):
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面), * 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
* 下属 12 个发货仓: * 下属 12 个发货仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 / * 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / * 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
@ -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

@ -0,0 +1,373 @@
'use strict';
/**
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-27):
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
* ⟹ **一天两推**,各自绑一个核验轮次:
* NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推
* AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推
* ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻,
* 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。
* 业务要改时刻在页面上改,不必重启后端。
*
* 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。
* 配置分两层存(与 yydNotificationCenterSettings 同一套做法):
* · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
*
* 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 ——
* 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。
*/
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config';
const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret';
const STATE_KEY = 'warehouse_ship_timeliness_notify_state';
// 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明)
const DEFAULT_SEND_TIME_NOON = '13:05';
const DEFAULT_SEND_TIME_AFTERNOON = '18:05';
// 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量
const DEFAULT_MAX_DETAIL_ROWS = 30;
const MAX_DETAIL_ROWS_LIMIT = 50;
// 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名
const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness';
const CACHE_TTL_MS = 30 * 1000;
// 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长)
const MAX_STATE_ENTRIES = 60;
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
}
function maskSecret(value) {
const text = normalizeText(value);
if (!text) return '';
if (text.length <= 8) return `${text.slice(0, 2)}******`;
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
}
/** HH:mm(00:00 ~ 23:59);非法输入退回默认值,不抛错(避免把定时任务配挂) */
function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) {
const text = normalizeText(value, 5);
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
return fallback;
}
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
function normalizeMaxDetailRows(value) {
const num = Number(value);
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
}
function normalizePageUrl(value) {
const text = normalizeText(value, 1024);
if (!text) return DEFAULT_PAGE_URL;
try {
const url = new URL(text);
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
return url.toString();
} catch (_error) {
return DEFAULT_PAGE_URL;
}
}
function normalizeBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const text = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
if (['0', 'false', 'no', 'off'].includes(text)) return false;
return fallback;
}
async function upsertConfigRow(value) {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
const text = JSON.stringify(value);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[text, 'json', CONFIG_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: CONFIG_KEY,
config_value: text,
config_type: 'json',
});
}
}
async function readConfigRow() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
return safeJsonParse(rows[0]?.configValue, {});
} catch (error) {
console.warn('[WstNotify] 读取推送配置失败:', error.message);
return {};
}
}
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
async function getShipTimelinessNotifyRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
const stored = await readConfigRow();
const secret = await getResilientSecret(SECRET_KEY, {
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
fallbackValues: [normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
});
cachedConfig = {
enabled: normalizeBoolean(stored.enabled, false),
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|| normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024),
webhookSecret: secret.value,
webhookSecretSource: secret.source,
// 两轮各自的推送时刻
noonSendTime: normalizeSendTime(
stored.noonSendTime || stored.noon_send_time,
DEFAULT_SEND_TIME_NOON
),
afternoonSendTime: normalizeSendTime(
stored.afternoonSendTime || stored.afternoon_send_time,
DEFAULT_SEND_TIME_AFTERNOON
),
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
source: Object.keys(stored).length > 0 ? 'database' : 'default',
};
cachedAt = Date.now();
return { ...cachedConfig };
}
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
function missingFields(config = {}) {
const missing = [];
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
return missing;
}
function isShipTimelinessNotifyReady(config = {}) {
return missingFields(config).length === 0;
}
function toAdminConfig(config = {}) {
return {
enabled: config.enabled === true,
webhookUrl: config.webhookUrl || '',
webhookSecretConfigured: Boolean(config.webhookSecret),
webhookSecretPreview: maskSecret(config.webhookSecret),
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON),
afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON),
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
atAll: config.atAll === true,
pageUrl: normalizePageUrl(config.pageUrl),
ready: isShipTimelinessNotifyReady(config),
missingFields: missingFields(config),
source: config.source || 'default',
};
}
async function getShipTimelinessNotifyAdminConfig() {
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
return toAdminConfig(config);
}
/**
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
*/
async function saveShipTimelinessNotifyConfig(input = {}) {
const current = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
const config = {
enabled: normalizeBoolean(input.enabled, current.enabled),
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined
? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON)
: normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON),
afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined
? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON)
: normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON),
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
? normalizeMaxDetailRows(current.maxDetailRows)
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
pageUrl: input.pageUrl === undefined && input.page_url === undefined
? normalizePageUrl(current.pageUrl)
: normalizePageUrl(input.pageUrl ?? input.page_url),
};
if (config.enabled) {
const missing = missingFields(config);
if (missing.length) {
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
}
}
if (config.webhookUrl) {
// 这里只做「像不像」的浅校验;真正的连通性由「测试通道」按钮验证
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
try {
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
} catch (error) {
throw Object.assign(new Error(error.message), { status: 400 });
}
}
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
await upsertConfigRow({
enabled: config.enabled,
webhookUrl: config.webhookUrl,
noonSendTime: config.noonSendTime,
afternoonSendTime: config.afternoonSendTime,
maxDetailRows: config.maxDetailRows,
atAll: config.atAll,
pageUrl: config.pageUrl,
updatedAt: new Date().toISOString(),
});
cachedConfig = {
...config,
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
source: 'database',
};
cachedAt = Date.now();
return toAdminConfig(cachedConfig);
}
/**
* 推送状态。
* 结构(version 1):
* {
* version: 1,
* sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... },
* lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel
* }
* sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。
*/
async function loadShipTimelinessNotifyState() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
const state = safeJsonParse(rows[0]?.configValue, {});
if (!state || typeof state !== 'object') return {};
return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} };
} catch (error) {
console.warn('[WstNotify] 读取推送状态失败:', error.message);
return {};
}
}
/** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */
function buildPushSlot(dateKey, roundKey) {
const date = normalizeText(dateKey, 10);
const round = normalizeText(roundKey, 20).toUpperCase();
if (!date || !round) return '';
return `${date}|${round}`;
}
async function saveShipTimelinessNotifyState(next = {}) {
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
try {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', STATE_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: STATE_KEY,
config_value: value,
config_type: 'json',
});
}
} catch (error) {
console.warn('[WstNotify] 保存推送状态失败:', error.message);
}
}
/** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */
function trimSentMap(sent = {}) {
const entries = Object.entries(sent || {});
if (entries.length <= MAX_STATE_ENTRIES) return sent;
entries.sort((a, b) => {
const da = String(a[1]?.sentAt || '');
const db = String(b[1]?.sentAt || '');
return db.localeCompare(da);
});
return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES));
}
function clearShipTimelinessNotifyConfigCache() {
cachedConfig = null;
cachedAt = 0;
}
module.exports = {
CONFIG_KEY,
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME_AFTERNOON,
DEFAULT_SEND_TIME_NOON,
MAX_DETAIL_ROWS_LIMIT,
MAX_STATE_ENTRIES,
SECRET_KEY,
STATE_KEY,
buildPushSlot,
clearShipTimelinessNotifyConfigCache,
getShipTimelinessNotifyAdminConfig,
getShipTimelinessNotifyRuntimeConfig,
isShipTimelinessNotifyReady,
loadShipTimelinessNotifyState,
maskSecret,
missingFields,
normalizeMaxDetailRows,
normalizePageUrl,
normalizeSendTime,
saveShipTimelinessNotifyConfig,
saveShipTimelinessNotifyState,
toAdminConfig,
trimSentMap,
};

View File

@ -1,10 +1,10 @@
'use strict'; 'use strict';
/** /**
* 仓储中心 24 小时发货时效(2026-10-20 新增) * 仓储中心发货时效(2026-10-20 新增)
* ──────────────────────────────────────────────────────────────────────────── * ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20): * 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓: * MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 / * 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓 * 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。 * 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
@ -32,7 +32,10 @@
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会 * rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 * rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准): * 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
* · 创建时间:**销售出库单的 `created`**(出库单创建时间) * 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
* 是老口径,仅作历史记录,不要再照着实现**。
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
* ⚠️ 不是 `order_date`(平台下单时间); * ⚠️ 不是 `order_date`(平台下单时间);
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成 * ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40, * 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
@ -262,6 +265,48 @@ const OUTBOUND_TABLES = Object.freeze({
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。 // ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
const ORDER_STATE_TABLE = 'jst_order_express_details'; const ORDER_STATE_TABLE = 'jst_order_express_details';
// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」────────────────
// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓
// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是
// jst_Orders 里的创建时间」。
//
// 为什么原来那张状态镜像表不能用(实测证据):
// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 ——
// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。
// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。
// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,
// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。
// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date
// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。
//
// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。
// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。
// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。
const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点
// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明):
// Delivering 发货中 —— **唯一该考核仓库的状态**
// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时
// Question 疑问/异常 —— 单列「异常待处理」不计超时
// Sent 已发货 → 剔除
// Cancelled 已取消(业务叫「作废」)→ 剔除
// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货)
// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发)
// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 /
// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45
const ORDER_PENDING_STATUS = 'Delivering';
const ORDER_UNAUDITED_STATUS = 'WaitConfirm';
const ORDER_QUESTION_STATUS = 'Question';
const ORDER_SHIPPED_STATUS = 'Sent';
// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node)
const ORDER_CANDIDATE_STATUSES = Object.freeze([
ORDER_PENDING_STATUS,
ORDER_UNAUDITED_STATUS,
ORDER_QUESTION_STATUS,
]);
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态** // 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
const SHIPPING_STATUS = '发货中'; const SHIPPING_STATUS = '发货中';
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」) // 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
@ -276,24 +321,26 @@ const ABNORMAL_STATUS = '异常';
// 漏掉这个排除,整个页面会被作废单淹没。 // 漏掉这个排除,整个页面会被作废单淹没。
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']); const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
// ── 起算点:聚水潭「创建时间」────────────────────────────────────────────── // ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。 // 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
// 起算点是**审核时间**,不是订单创建时间。证据:
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
// //
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补: // ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。 // 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51), // 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
// 是**同步落库时间**,用了会把起算点整体推后几小时。
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
// 要换掉。
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
// 这才是聚水潭订单列表里「创建时间」那一列。
// //
// 性能(2026-10-20 实测 9,676 个候选): // 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。 // 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。 // 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']); const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 分批大小:200 实测最快(再大收益递减,再小批数太多) // 分批大小:200 实测最快(再大收益递减,再小批数太多)
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
const CREATED_BACKFILL_BATCH = 200; const CREATED_BACKFILL_BATCH = 200;
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ───────────────── // ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
@ -307,8 +354,88 @@ 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 = '已作废';
/**
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
*
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
*
* 实测(2026-10-29 13:00 轮 159 单):
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
*
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
*/
const STATE_ORDER_VOID_STATUS = '已取消';
const STATE_ORDER_SHIPPED_STATUS = '已发货';
/**
* 销售出库单 `status` 里**等于单据作废**的值。
*
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
*
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
* 其中 `Delete` 就是单据作废。
*
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
*
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
*
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
*/
const IO_VOID_STATUS = 'Delete';
/**
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
*
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
* `未揽收` 也不算(只是打了单,快递还没来取)。
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
*/
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
'已揽收', '运输中', '派送中', '已签收', '拒收',
]);
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。 // 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」, // 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。 // 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
@ -417,12 +544,29 @@ async function loadCenterWarehouseMap(force = false) {
warehouseCache = { warehouseCache = {
at: Date.now(), at: Date.now(),
rows: list, rows: list,
// 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。
// 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s),
// 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。
allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })),
unmatched,
unmatchedCount: unmatched.length, unmatchedCount: unmatched.length,
excluded, excluded,
}; };
return list; return list;
} }
/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */
async function loadWarehouseNameMap() {
if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) {
await loadCenterWarehouseMap();
}
const map = new Map();
for (const r of warehouseCache.allNames || []) {
if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name);
}
return map;
}
function clearWarehouseCache() { function clearWarehouseCache() {
warehouseCache = { at: 0, rows: [] }; warehouseCache = { at: 0, rows: [] };
} }
@ -535,6 +679,7 @@ module.exports = {
centerWindowEnd, centerWindowEnd,
nextRoundDeadline, nextRoundDeadline,
loadCenterWarehouseMap, loadCenterWarehouseMap,
loadWarehouseNameMap,
clearWarehouseCache, clearWarehouseCache,
getWarehouseCache: () => warehouseCache, getWarehouseCache: () => warehouseCache,
resolveActiveRound, resolveActiveRound,
@ -547,6 +692,13 @@ module.exports = {
NOT_SPLIT_MERGED_SQL, NOT_SPLIT_MERGED_SQL,
OUTBOUND_TABLES, OUTBOUND_TABLES,
ORDER_STATE_TABLE, ORDER_STATE_TABLE,
// 2026-10-28 起主数据源 = 订单两表(状态表退居兜底)
ORDER_SOURCE_TABLES,
ORDER_PENDING_STATUS,
ORDER_UNAUDITED_STATUS,
ORDER_QUESTION_STATUS,
ORDER_SHIPPED_STATUS,
ORDER_CANDIDATE_STATUSES,
SHIPPING_STATUS, SHIPPING_STATUS,
UNAUDITED_STATUS, UNAUDITED_STATUS,
ABNORMAL_STATUS, ABNORMAL_STATUS,
@ -554,6 +706,13 @@ 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,
STATE_ORDER_VOID_STATUS,
STATE_ORDER_SHIPPED_STATUS,
STATE_LOGISTIC_SHIPPED_STATUSES,
IO_VOID_STATUS,
SHIP_TIMELINESS_HOURS, SHIP_TIMELINESS_HOURS,
toNumber, toNumber,
}; };

File diff suppressed because it is too large Load Diff

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

@ -154,6 +154,95 @@ test('明细行:仓|快递单号|快递|异常类型(4 段,不带发
); );
}); });
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: [],

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,294 @@
'use strict';
/**
* 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知;
* 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。
*
* 这里只测五件容易悄悄坏掉的事:
* ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界);
* ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现);
* ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂);
* ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
* ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。
*
* ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const job = require('../src/jobs/warehouseShipTimelinessNotifyJob');
const settings = require('../src/services/warehouseShipTimelinessNotifySettings');
function sampleData(overrides = {}) {
return {
round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' },
deadline: '2026-10-27 13:00:00',
date: '2026-10-27',
window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' },
summary: {
overdueUnshipped: 3,
overdueCenterCount: 2,
oldestDays: 4,
heldCount: 5,
voidCount: 12,
carrierSkipped: 2,
},
centers: [
{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 },
{ key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 },
{ key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 },
],
orders: [
{
oId: '9855123',
soId: '261021-301318827670255',
shopName: '京东旗舰店',
warehouseName: '简尚-崔桥工厂仓',
centerName: '崔桥工厂仓',
logisticCompany: '武进韵达',
auditTime: '2026-10-27 06:32:11',
overdueMinutes: 388,
},
{
oId: '9855200',
soId: 'SO20261027001',
shopName: '天猫店',
warehouseName: '简尚-崔桥工厂仓',
centerName: '崔桥工厂仓',
logisticCompany: '常州安能新',
auditTime: '2026-10-26 09:05:00',
overdueMinutes: 2875,
},
{
oId: '9855300',
soId: '',
shopName: '抖音店',
warehouseName: '简尚-横林二仓',
centerName: '横林二仓',
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
logisticCompany: '',
auditTime: '',
overdueMinutes: 120,
},
],
freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false },
...overrides,
};
}
function config(overrides = {}) {
return {
enabled: true,
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test',
webhookSecret: '',
noonSendTime: '13:05',
afternoonSendTime: '18:05',
maxDetailRows: 30,
atAll: false,
pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness',
...overrides,
};
}
/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */
function bj(year, month, day, hour, minute) {
return new Date(year, month - 1, day, hour, minute, 0, 0);
}
// ── ① 两个轮次各自的推送时刻 ──────────────────────────────────────────────
test('两个轮次按各自配置的时刻命中', () => {
const cfg = config();
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']);
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']);
// 非命中时刻一律不推(免得每分钟都轰炸一次)
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []);
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []);
});
test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => {
const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' });
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']);
});
test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => {
const cfg = config();
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']);
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []);
// 关掉补推时,错过就错过
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []);
});
// ── ② 正文内容 ────────────────────────────────────────────────────────────
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
assert.match(built.text, /核验 07:00 前审核/);
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
assert.equal(built.rowCount, 3);
});
test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /按仓(超时单):/);
assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/);
assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/);
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
});
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
assert.doesNotMatch(built.text, /undefined|NaN/);
});
test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
assert.ok(line, '明细第 2 行不见了');
const parts = line.replace(/^\d+\.\s*/, '').split('|');
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
});
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
// 同一天的就只给 HH:mm,别啰嗦
assert.match(built.text, /审核 06:32/);
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
// 直接测函数本身的跨天判定
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
assert.equal(job.auditWhen('', '2026-10-27'), '');
});
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /另有 5 单不计考核/);
assert.match(built.text, /已剔除 2 单不考核快递/);
assert.doesNotMatch(built.text, /⚠️/);
const stale = job.buildNotifyText(sampleData({
freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true },
}), { maxDetailRows: 30 });
assert.match(stale.text, /⚠️ 数据快照只到 12:31/);
assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/);
assert.match(stale.text, /超时单量偏大/);
});
// ── ③ 0 单也要推 ──────────────────────────────────────────────────────────
test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => {
const built = job.buildNotifyText(sampleData({
summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 },
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }],
orders: [],
}), { maxDetailRows: 30 });
assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/);
assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/);
assert.doesNotMatch(built.text, /明细前/);
assert.doesNotMatch(built.text, /查看全部/);
assert.equal(built.rowCount, 0);
assert.equal(built.listedCount, 0);
});
// ── ④ 明细额度:按仓保底 + 超长精简 ───────────────────────────────────────
test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => {
const many = [];
for (let i = 0; i < 40; i += 1) {
many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i });
}
for (let i = 0; i < 2; i += 1) {
many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 });
}
const picked = job.pickDetailRows(many, 6);
assert.equal(picked.length, 6);
// 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1)
assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1);
assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5);
// 组间按仓聚集:条数多的仓排前面,所以首行是崔桥
assert.equal(picked[0].centerName, '崔桥工厂仓');
});
test('明细条数 0 = 只发汇总不带明细', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
assert.equal(built.listedCount, 0);
assert.match(built.text, /明细共 3 条,消息里不放/);
});
test('超长时从明细末尾往前砍,砍到能发为止', () => {
// 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能),
// 保证 50 条明细 + 分仓块必然超过 20000 字节上限
const rows = [];
for (let i = 0; i < 300; i += 1) {
rows.push({
oId: `98${String(i).padStart(6, '0')}`,
soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`,
centerName: '崔桥工厂仓',
warehouseName: '简尚-崔桥工厂仓',
auditTime: '2026-10-27 06:32:11',
overdueMinutes: 400 + i,
});
}
const built = job.buildNotifyText(sampleData({
summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 },
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }],
orders: rows,
}), { maxDetailRows: 50 });
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`);
assert.equal(built.truncated, true);
assert.ok(built.listedCount < 50);
assert.match(built.text, /其余 \d+ 条见页面/);
});
// ── ⑤ 去重键 ──────────────────────────────────────────────────────────────
test('去重键是「日期|轮次」,两轮互不覆盖', () => {
const noon = settings.buildPushSlot('2026-10-27', 'NOON');
const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON');
assert.equal(noon, '2026-10-27|NOON');
assert.equal(afternoon, '2026-10-27|AFTERNOON');
assert.notEqual(noon, afternoon);
// 小写也归一
assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON');
assert.equal(settings.buildPushSlot('', 'NOON'), '');
});
test('推送时刻归一化:非法值退回默认,不抛错', () => {
assert.equal(settings.normalizeSendTime('13:05'), '13:05');
assert.equal(settings.normalizeSendTime('8:5'), '13:05');
assert.equal(settings.normalizeSendTime('25:00'), '13:05');
assert.equal(settings.normalizeSendTime(''), '13:05');
assert.equal(settings.normalizeSendTime('', '18:05'), '18:05');
assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05');
assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05');
});
test('超长文案格式化:分钟 / 小时 / 跨天', () => {
assert.equal(job.formatOverdue(30), '30 分钟');
assert.equal(job.formatOverdue(120), '2 小时');
assert.equal(job.formatOverdue(388), '6 小时 28 分');
assert.equal(job.formatOverdue(1500), '1 天 1 小时');
assert.equal(job.formatOverdue(0), '—');
assert.equal(job.formatOverdue(null), '—');
});
test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => {
const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON');
assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON');
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
assert.match(built.text, /\?round=NOON/);
});
test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => {
const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5));
const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5));
assert.equal(noon.deadline, '2026-10-27 13:00:00');
assert.equal(noon.windowEnd, '2026-10-27 07:00:00');
assert.equal(afternoon.deadline, '2026-10-27 18:00:00');
assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00');
assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null);
});

View File

@ -3,7 +3,7 @@ import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000; const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(订单驱动版) // 缺货补货监控(订单驱动版)
// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量 // 判断口径:待发订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
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 }

View File

@ -4,7 +4,7 @@ import request from './request';
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。 // 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000; const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细 // 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定) // params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
// params.limit 明细条数上限 // params.limit 明细条数上限
export const getWarehouseShipTimeliness = (params = {}) => request.get( export const getWarehouseShipTimeliness = (params = {}) => request.get(
@ -21,3 +21,40 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
'/api/warehouse-ship-timeliness/rounds', '/api/warehouse-ship-timeliness/rounds',
{ timeout: 30000 } { timeout: 30000 }
); );
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
'/api/warehouse-ship-timeliness/export',
{
params,
timeout: TIMELINESS_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)────────────────────────
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
export const getShipTimelinessNotifySettings = () => request.get(
'/api/warehouse-ship-timeliness/notify/settings',
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
);
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
export const saveShipTimelinessNotifySettings = (payload = {}) => request.put(
'/api/warehouse-ship-timeliness/notify/settings',
payload,
{ timeout: 30000 }
);
// 立即推送 / 预览正文 / 测试通道
// dryRun=true 只组装消息不发送(返回正文全文)
// probe=true 只发一句通道测试,不查业务数据
// 冷启动要全量扫订单状态视图(生产实测数十秒到几分钟),超时给足
export const runShipTimelinessNotify = (payload = {}) => request.post(
'/api/warehouse-ship-timeliness/notify/run',
payload,
{ timeout: 10 * 60 * 1000 }
);

View File

@ -37,7 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
], ],
}, },
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 }, { key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 }, { key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
{ {
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip, Alert, Button, Empty, Input, Segmented, Spin, Table, Tag, Tooltip,
message, message,
} from 'antd'; } from 'antd';
import { import {
@ -19,15 +19,12 @@ const STATUS_TAGS = {
short: { color: 'red', label: '存储不足' }, short: { color: 'red', label: '存储不足' },
need: { color: 'gold', label: '需补货' }, need: { color: 'gold', label: '需补货' },
enough: { color: 'green', label: '拣货位充足' }, enough: { color: 'green', label: '拣货位充足' },
no_order: { color: 'default', label: '今日无单' }, no_order: { color: 'default', label: '无待发单' },
}; };
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' }, { label: '待补货', value: 'replenish' },
{ label: '存储不足', value: 'short' }, { label: '仅缺货', value: 'short' },
{ label: '拣货位充足', value: 'enough' },
{ label: '今日无单', value: 'no_order' },
{ label: '全部', value: 'all' },
]; ];
const BinReplenishMonitor = () => { const BinReplenishMonitor = () => {
@ -41,7 +38,7 @@ 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: [], orderDate: '', updatedAt: '', cacheHit: false, rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
}); });
const keywordTimer = useRef(null); const keywordTimer = useRef(null);
@ -66,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 || [],
orderDate: payload.orderDate || '', orderScope: payload.orderScope || '',
updatedAt: payload.updatedAt || '', updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit), cacheHit: Boolean(payload.cacheHit),
}); });
@ -99,47 +96,47 @@ const BinReplenishMonitor = () => {
const metrics = useMemo(() => ([ const metrics = useMemo(() => ([
{ {
key: 'replenish', key: 'order',
tone: 'red', tone: 'blue',
label: '待补货 SKU', label: '实时代发订单数',
value: needCount, value: summary.orderQtyTotal || 0,
unit: '个', unit: '件',
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`, foot: `待补 ${num(summary.skuTotal)} 个 SKU`,
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来', tip: '待补 SKU 的待发订单件数合计(取聚水潭「打单拣货 → 发货中」每日快照,组合装已展开为子件;不含已发/异常/待审核)',
},
{
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', key: 'short',
tone: 'grey', tone: 'red',
label: '其中存储不足', label: '缺货数',
value: summary.short || 0, value: summary.skuTotal || 0,
unit: '个', unit: '个',
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`, foot: `其中存储不足 ${num(summary.short)} 个,缺口 ${num(summary.shortfallTotal)} 件`,
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨', tip: '需要从存储位补到拣货位的 SKU 数(待发订单量 > 拣货位库存);其中存储位也补不齐的标为「缺货」,需采购 / 紧急调拨',
},
{
key: 'exceed',
tone: 'orange',
label: '超出订单数合计',
value: summary.exceedTotal || 0,
unit: '件',
foot: '今日拣货位缺口订单量',
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
},
{
key: 'order',
tone: 'blue',
label: '当日有单 SKU',
value: summary.orderSkuCount || 0,
unit: '个',
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
}, },
]), [summary, needCount]); ]), [summary, needCount]);
const handleExport = async () => { const handleExport = async () => {
try { try {
const response = await exportBinReplenish({ warehouse, status, keyword }); const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.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 || '导出失败');
@ -188,7 +185,7 @@ const BinReplenishMonitor = () => {
render: (value) => <span className="brp-qty">{num(value)}</span>, render: (value) => <span className="brp-qty">{num(value)}</span>,
}, },
{ {
title: '当日订单', title: '待发订单',
dataIndex: 'orderQty', dataIndex: 'orderQty',
width: 100, width: 100,
align: 'right', align: 'right',
@ -238,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">
@ -247,15 +245,14 @@ const BinReplenishMonitor = () => {
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b> 仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div> </div>
<div> <div>
订单日期:<b>{data.orderDate || '--'}</b> 统计口径:<b>{data.orderScope || '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)'}</b>
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
</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.noOrder)}</b> 个 共 <b>{num(summary.skuTotal)}</b> 个待补 SKU,其中存储不足(缺货)<b>{num(summary.short)}</b> 个
</div> </div>
</div> </div>
</div> </div>
@ -281,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>
@ -300,11 +289,7 @@ const BinReplenishMonitor = () => {
...item, ...item,
label: item.value === 'replenish' label: item.value === 'replenish'
? `${item.label} ${num(needCount)}` ? `${item.label} ${num(needCount)}`
: item.value === 'short' : `${item.label} ${num(summary.short || 0)}`,
? `${item.label} ${num(summary.short || 0)}`
: item.value === 'no_order'
? `${item.label} ${num(summary.noOrder || 0)}`
: item.label,
}))} }))}
/> />
</span> </span>
@ -332,11 +317,12 @@ const BinReplenishMonitor = () => {
type="info" type="info"
showIcon showIcon
className="brp-alert" className="brp-alert"
message="货位口径(崔桥工厂仓约定)" message="货位口径(崔桥工厂仓 主仓约定)"
description={( description={(
<span> <span>
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。 库存只取 <b>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
判断规则:<b>当日订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。 其余货位(DB / Y* / Z* 等,占比极小)不计入。
判断规则:<b>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span> </span>
)} )}
/> />
@ -353,7 +339,7 @@ const BinReplenishMonitor = () => {
<Spin <Spin
spinning={loading} spinning={loading}
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒" tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin" wrapperClassName="brp-table-spin"
> >
<Table <Table
@ -391,24 +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),按商品编码(sku_id)聚合。 <b>库存</b>:聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」(rpt.jst_packInventoryInfo,仅 <b>wh_id=1 主仓</b>),
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。 按商品编码(sku_id)聚合。「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;
「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li> </li>
<li> <li>
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B), <b>订单</b>:直接取聚水潭「<b>打单拣货 → 简尚-崔桥工厂仓</b>」的发货中单据快照 = rpt.op_OrderNotDispatched
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。 (该仓<b>最新快照日</b>、status=发货中),SKU 级、组合装已展开为子件。
<b>已发货 / 异常挂起 / 已付款待审核 / 待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>。
</li> </li>
<li> <li>
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量; <b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 &gt; 拣货位库存」、需要从存储位补到拣货位的这部分
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。 (含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
</li> </li>
<li> <li>
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空; <b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
页面顶部明确标注统计的是哪一天。 「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
</li> </li>
<li> <li>
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热); <b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
点「刷新数据」会强制回源,需要等 1 分钟左右。 </li>
<li>
<b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等约 15~30 秒。
</li> </li>
</ul> </ul>
</div> </div>

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 点落一份快照。

File diff suppressed because it is too large Load Diff

View File

@ -111,6 +111,45 @@
color: #1677ff; color: #1677ff;
} }
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
.wst-stat__warn {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: #cf1322;
cursor: help;
}
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
.wst-alert__sources {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.wst-alert__source {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
/* 明细里的「内部单号 + 未同步标记」 */
.wst-detail__oid {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wst-detail__stale {
margin-inline-end: 0;
font-size: 11px;
line-height: 16px;
padding-inline: 5px;
cursor: help;
}
/* ============ 主从两栏 ============ */ /* ============ 主从两栏 ============ */
.wst-body { .wst-body {
display: flex; display: flex;
@ -253,3 +292,70 @@
gap: 2px 10px; gap: 2px 10px;
} }
} }
/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */
.wst-notify__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wst-notify__label {
flex: 0 0 88px;
font-size: 13px;
color: var(--sycm-text-secondary);
}
.wst-notify__last {
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
}
.wst-notify__error {
margin-top: 4px;
}
.wst-notify__help {
font-size: 12px;
line-height: 1.8;
color: var(--sycm-text-secondary);
}
.wst-notify__help code {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
}
.wst-notify__preview {
padding: 10px 12px;
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
line-height: 1.8;
}
.wst-notify__preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wst-notify__preview pre {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-bg-container, #fff);
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.7;
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
max-height: 280px;
overflow: auto;
}