Compare commits

...

249 Commits

Author SHA1 Message Date
54ad6aad89 Merge pull request 'fix(wle): 0 单不再冒充「全部正常」——窗口空时查数据源是否停更' (#94) from yanhui-wle-emptydata-1102 into main 2026-11-02 13:32:49 +08:00
555ac6b116 fix(wle): 0 单不再冒充「全部正常」——窗口空时查数据源是否停更
11-02 09:00 那批推到钉钉群的是「已发货 0 单 / 0 个包裹,异常 0 个包裹(0%)」,
业务问「怎么可能 0 单」。查库确认:物流镜像表 jst_order_express_details
最后灌库停在 2026-11-01 07:55,11-01 当天 12 个发货仓一条发货记录都没有
(订单表 jst_Orders 同日有 4601 单发货,说明货是真发了,只是物流没进库)。

根因不是口径,是「0 单没有告警」这个设计缺陷:freshness 只用本批窗口内行的
query_time 算,窗口一行都没有时 snapshotAt 为空串 → stale=false、hint='',
于是「上游没同步」和「当天真没发货」在页面和钉钉里长得完全一样,还写「全部正常」。

改动
- service:窗口 0 行时补查一次表级 MAX(query_time)(全表 13.5s,只在空窗口付这笔钱,
  随主结果进 5 分钟缓存),新增 freshness.emptyWindow / sourceLastUpdate /
  sourceAgeMinutes / sourceStale,并按「真停更 / 表新但当天无记录 / 取不到时间」
  三种情况给不同 hint。
- 钉钉正文:0 条时不再无脑写「本批无转运异常,全部正常」;真停更写
  「⚠️ 物流数据未同步:镜像表最后更新 …(已 N 小时未同步)… 0 单不代表当天没发货」,
  表新但当天无记录则说明「库里 0 条发货记录(数据源正常),请到聚水潭核对」。
- 前端:emptyWindow 走红条(原来只判 coversDeadline/stale,空窗口不告警),
  发货卡片副标改为「数据未同步,见上方红条」。
- 前端顺带修:钉钉消息链接带 ?date=YYYY-MM-DD,但页面从不读这个参数,
  点进去永远显示「今天这批」;现按 URL 初始化核验日。

验证
- npm --prefix backend run check:505 tests / 503 pass;新增 5 个用例
  (停更 hint、表新但 0 条、有行时不付全表 MAX、推送文案两态)全过。
  既有失败 2 个与本任务无关:cloudDatabaseConfigCache 负缓存 TTL、
  resilientSecretStore 的 Windows 0600 权限断言。
- 生产库实跑(本地 4002,直连 JSReportReplica):
  date=2026-11-02 → emptyWindow=true、sourceLastUpdate=2026-11-01 07:55:04、
  sourceAgeMinutes≈1590、hint 正确;date=2026-11-01 → 4390 单 / 9 异常、
  emptyWindow=false,原有「未覆盖核验时刻」提示不变。
- 钉钉 dryRun 正文实测:前 3 行即带「⚠️ 物流数据未同步 … 0 单不代表当天没发货」。
- Playwright 真渲染(5173):
  ?date=2026-11-02 → 红条 + 「数据未同步,见上方红条」,0 pageerror;
  ?date=2026-11-01 → 4,390 单 / 9 异常正常出表,且 URL 参数已生效。
- 前端 eslint 干净,vite build 通过。

风险与回退
- 只增字段与提示,不改任何统计口径;packageCount/shippedOrders 计算逻辑未动。
- 空窗口多一条全表 MAX 查询(13.5s),已用 5 分钟缓存兜住,且只在异常路径触发。
- 回退:单文件 revert 本提交即可,无数据结构变更。
2026-11-02 10:31:11 +08:00
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
c374cbc713 feat: 缺货补货监控改为订单驱动(崔桥当日订单>拣货位库存即需从存储位补货)
- 货位口径按前缀:XA/XD/XE=拣货位,XX/X9/X5/X4=存储位
- 判断逻辑改为当日订单量>拣货位库存的超出部分=需从存储位补到拣货位的量(上限存储位库存)
- 订单取自 rpt.jst_Orders/jst_qmcust_orderlist(含组合装展开),按最近订单日
- 状态:需补货/存储不足/拣货位充足/今日无单;移除旧的货位清单导入(前缀规则取代)
- 前端列与说明同步更新
2026-10-22 14:01:35 +08:00
e9923d7383 Merge pull request 'fix(wle): 钉钉推送明细精简为「仓|快递单号|快递|异常类型」' (#76) from codex/yanhui-wle-detail-no-time-20261022 into main 2026-10-22 13:10:31 +08:00
9991b92325 Merge pull request 'fix(router): 补回 BinReplenishMonitor 的 lazy 声明,修复生产全站白屏' (#77) from codex/hotfix-bin-replenish-lazy-20261022 into main 2026-10-22 12:08:20 +08:00
9b1ec36c70 fix(router): 补回 BinReplenishMonitor 的 lazy 声明,修复全站白屏
现象:生产 https://ck.yydd.ai:8443 打开任意页面都是白屏,连登录页都进不去。
控制台报 `BinReplenishMonitor is not defined`,一个 /api 请求都没发出去。

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

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

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

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

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

- buildDetailLine 从「仓|内部单号|快递|异常类型|发货时间|待处理」
  改为「仓|内部单号|快递|异常类型|待处理小时数」
- 删除仅此处使用的 shortTime() 及其导出,不留死代码
- 文件头补口径说明,防止以后把 sendTime 加回去
- 单测同步:断言新列序,并显式断言消息里不出现 sendTime(传了也不能渲染)
2026-10-22 11:40:11 +08:00
03fd3f97f3 Merge pull request 'fix: 补回 binReplenishRoutes 引入,修复后端启动失败' (#75) from codex/lunanting-bin-replenish-001 into main 2026-10-22 11:14:30 +08:00
a15f289ec6 fix: 补回 binReplenishRoutes 引入,修复后端启动失败 2026-10-22 11:14:00 +08:00
f786a92387 Merge pull request 'feat: 新增「缺货补货监控」看板(拣货位缺货 → 从存货位补货)' (#74) from codex/lunanting-bin-replenish-001 into main
Merge pull request #74 from codex/lunanting-bin-replenish-001
2026-10-22 11:10:31 +08:00
55b755c1d9 feat: 新增「缺货补货监控」看板(拣货位缺货 → 从存货位补货)
数据源:聚水潭「箱及仓位库存」rpt.jst_packInventoryInfo(副本里是视图,
按 SKU+货位聚合单次 35~45s,因此服务端缓存 10 分钟 + 启动预热)。

口径:
- 拣货位:按业务导入的货位清单判定(本地表 bin_replenish_pick_bin,按仓库隔离);
  副本没有货位类型主数据,未导入时回退为 pack_type='Bin' 的记录,页面明确标注。
- 拣货位库存 / 存货位库存 / 现有库存合计;
- 目标库存 = max(10, 近15天日销 × 目标天数(默认7));
- 建议补货量 = min(存货位库存, 目标 − 拣货位库存);
- 状态:拣货位缺货 / 无货可补 / 拣货位偏低 / 未设拣货位 / 正常。
  「未设拣货位」单列,避免把整箱直发商品误报成缺货。

后端:binReplenishService + binReplenishSchema + /api/bin-replenish
(list / export / pick-bins GET-POST-DELETE / ping),建表与预热挂在 localSchema。
前端:/planning/operation/bin-replenish-monitor 页面、侧栏入口、页面权限注册,
含 KPI 卡、状态分段筛选、关键字搜索、目标天数、货位清单导入/清除、服务端导出。
2026-10-22 11:08:20 +08:00
ac61802b07 Merge pull request 'feat(wle): 仓储转运异常每天定点推文本到钉钉群' (#73) from codex/yanhui-wle-notify-text-20261022 into main 2026-10-22 11:01:32 +08:00
bdc2cf9fd6 feat(wle): 仓储转运异常每天定点推文本到钉钉群
在「仓储中心物流转运异常」页新增「异常通知」:每天 09:10(可改)把当天核验批次
的异常情况以**文本消息**推到钉钉群,0 条也推(用来确认任务没挂)。

为什么是 text 而不是 Excel 附件:钉钉群自定义机器人 webhook 只支持
text/markdown/link/actionCard,发文件必须走企业内部应用(AppKey/AppSecret/
robotCode/openConversationId 四项凭据 + 应用权限 + 机器人进群)。业务确认
「群里看文本就够,要明细点链接去页面导出」,于是走最轻的 webhook。

后端
- 新增 services/dingtalkRobotWebhookSender.js:POST /robot/send + msgtype=text
  · 加签 urlEncode(base64(HmacSHA256(secret, "timestamp\nsecret"))),timestamp 毫秒
  · text.content 按 UTF-8 **字节**截断到 20000(钉钉硬上限),不切半个汉字
  · webhook 严格匹配 ^/robot/send$ 且必须有 access_token;errcode/errmsg 原样抛
- 新增 services/warehouseLogisticsExceptionNotifySettings.js:
  · webhookUrl 存在 system_config(完整回显,业务要核对);加签密钥走
    resilientSecretStore,接口只回 configured + 掩码,留空=沿用已存
  · sendTime 非法退回 09:10;maxDetailRows 0~50 默认 30
  · 状态 lastSentDate(按推送日去重)等存 system_config
- 新增 jobs/warehouseLogisticsExceptionNotifyJob.js:
  · buildNotifyText:抬头【仓储物流转运异常】→ 发货/包裹/异常/异常率 → 分类型
    → 剔除数 → 按仓(只列有异常的仓)→ 明细前 N 条 → 页面链接(带 date)
  · 明细行「仓|内部单号|快递|异常类型|发货时间|待处理」,字段内换行压平
  · 超长从明细末尾逐条砍并标记 truncated(truncated 只表示被字节上限砍过,
    不表示「按配置只列前 N 条」)
  · 每分钟 tick 比对北京时间 HH:mm == 配置 sendTime,改时间不用重启
- api:GET/PUT /notify/settings、GET /notify/state、POST /notify/run
  (dryRun 预览不校验凭据;probe 只发一句测试文本、不查业务数据)

前端
- 「导出」右侧新增「异常通知」(铃铛)弹窗:开关、推送时间、Webhook、加签密钥、
  明细条数、@所有人、页面链接、上次推送结果、三段帮助说明
- 「测试通道」「预览正文」「保存」「立即推送到群」
- 预览改成弹窗内受控区块:本项目跑 React 19 + antd v5,Modal.info 静态方法
  拿不到 context 会**静默不渲染**(只留一行 warning,看着像后端没返回)

同步与兼容
- 新配置首次读取为空 → ready=false,页面提示「Webhook 地址还没配」,不影响页面其它功能
- 定时任务只在 enabled=true 时生效;本地 ENABLE_BACKGROUND_JOBS=false 不跑,用页面按钮验

验证
- backend/test/warehouseLogisticsExceptionNotify.test.js 15/15:正文结构与数字、
  0 条平安消息、条数上限、字节精简、Webhook 校验、加签 crypto 手算对齐、errcode 透传
- 后端全量 465 项 / 463 过(2 项既有环境失败:Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、build 成功
- 真数据干跑:144 条异常 / 列 30 条 / 3768 字节 / 20s,按仓包裹数合计 144 对得上
- playwright 真渲染:弹窗字段 Webhook/加签密钥/明细条数/@所有人/页面链接,
  旧字段 AppKey/AppSecret/robotCode/群会话 ID 零残留;预览内联渲染出正文

风险与回退
- 钉钉侧失败只会记 lastState.lastError 并在页面展示,不影响页面取数
- 回退:删除 index.js 里的 startWarehouseLogisticsExceptionNotifyScheduler 注册即可停推送
2026-10-22 11:00:10 +08:00
b30c4e949c Merge pull request 'feat: 仓储物流转运异常页新增「导出异常明细」(Excel / CSV)' (#72) 2026-10-22 10:00:22 +08:00
85e2db9f92 feat: 仓储中心物流转运异常页新增「导出异常明细」(Excel / CSV)
- 后端新增 GET /api/warehouse-logistics-exception/export:复用列表同一套口径
  (getLogisticsExceptions),只把明细上限从列表 2000 放宽到 5000,不分页,
  判定/剔除逻辑一行未改,避免「导出与页面两套口径」。
- 结果缓存由单槽改为按 `核验日|明细上限` 分键的 Map(上限 8 条):
  页面按默认条数取、导出按 5000 取,单槽会互相踢掉 —— 导出一次,页面下次
  自动刷新就得重查库(20~40s)。分键后两者互不影响。
- 前端把明细过滤抽成 filterDetailOrders,表格与导出共用同一个函数,
  保证「导出的就是页面看到的」。
- 导出列单独定义 EXPORT_COLUMNS(13 列纯文本标题):页面列用了 Tooltip,
  直接复用列定义会把表头导出成 [object Object];运单号/物流状态/最后轨迹
  为空时导出为「无运单号 / 无状态 / 无轨迹」,Excel 里不留空格。
- 导出跟随当前核验日 + 选中仓 + 选中异常类型;文件名带核验日与筛选范围
  (仓储物流转运异常_<核验日>_<仓>_<类型>);0 行时提示且不生成空文件;
  超过 5000 行时如实提示「已截断」。导出按钮做 loading,防重复点。
- 测试:新增 2 个用例 —— ①导出行数等于异常包裹数、字段齐全、带剔除口径;
  ②缓存按 limit 分键(页面 1000 条那份不被导出 5000 条顶掉)。
  warehouseLogisticsException.test.js 26/26 通过。

口径未变:承运商剔除(顺丰系全剔、京喜京东、睢宁中通按仓、武进安能/中铁/
壹米滴答/自提/线下/商家配送)、订单终止与刷单信誉单剔除、异常 5 类判定,
全部沿用 #71 已合并版本。
2026-10-22 09:58:22 +08:00
9cba2f8140 Merge pull request 'feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流)' (#71)
feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流)
2026-10-22 09:08:58 +08:00
db1ba79416 feat: 新增「仓储中心物流转运异常」页面(12 发货仓 · 每日 09:00 核验前一日已发货物流)
- 后端新增 warehouse-logistics-exception 模块(Registry / Service / Api),
  挂载 /api/warehouse-logistics-exception;前端新增独立页面并接入左侧栏
  「仓储中心物流转运异常」(排在「仓储中心 24 小时发货时效」下面)。
- 核验口径:核验日 09:00 看**前一日已发货**订单的物流记录;12 个发货仓按仓库名
  最长匹配归口(子仓并入主仓,「京仓-横林二仓京东云仓」排除)。
  只统计揽收前的卡点:无物流单 / 未揽收 / 已揽收未中转 / 快递报异常 / 退回;
  运输中、派送中、已签收一律正常(干线与中转轨迹本就不回传,不再按 24h 判停滞)。
  明细按包裹、汇总按内部单号去重;结果缓存 5 分钟,force=1 可绕过。
- 剔除口径(业务 2026-10-21 / 10-22 定版,剔掉的既不算异常也不算发货分母):
  · 承运商:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、
    壹米滴答、京喜京东(快递·快运)、自提、线下、商家配送;
  · 有条件剔除:睢宁中通只在睢宁仓考核,其它仓异地打单的剔出;
  · 订单已终止:状态含取消/作废,或标签含「发货后取消」「作废」「拦截」;
  · 刷单 / 信誉单:订单标签含「刷单」「转空包」。
  「武进安能」「京喜京东」均按精确粒度匹配,不泛化成「安能」「京东」,避免误剔
  常州安能新、京东自营快递亿家达等正常账号。
- 时区:模块内统一用 Asia/Shanghai 取「今天」,避免生产容器 Etc/UTC 导致核验批次错位。
- 页面:分仓统计卡 + 异常类型筛选 + 明细(承运商 / 运单号 / 物流状态 / 异常标签 /
  最后轨迹 / 发货时间 / 待处理小时),页脚展示剔除口径与本批剔除量;
  物流快照停更按黄/红两级告警,并标注「历史批次按当前物流快照重算」。

验证(本地隔离环境,非生产库):
- node --test backend/test/warehouseLogisticsException.test.js → 24/24 通过
- npm --prefix frontend run check → 通过
- 本地真实数据接口实测:顺丰系、睢宁中通(异地仓)明细 0 条残留
2026-10-22 09:07:49 +08:00
42f60eb0ce Merge pull request 'fix: 仓储发货时效按北京时间取「今天」(生产容器 UTC 导致 00:00~08:00 轮次错位)' (#70) 2026-10-21 08:19:15 +08:00
f42f461430 fix: 仓储发货时效按北京时间取「今天」(生产容器是 UTC,每天 00:00~08:00 轮次错位)
问题(2026-10-21 生产实测,PR #69 上线后发现):
- 生产容器 `/etc/timezone = Etc/UTC`、`date` = UTC;而 MSSQL `GETDATE()` 返回北京时间,
  mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
- 于是**北京时间每天 00:00~08:00,服务端 `new Date()` 取到的「今天」是昨天**:
  round.date / deadline / windowEnd 整体错位。
  实测 08:04 跑 13:00 轮得 `overdueUnshipped=3895`(历史未发池被整段纳入),
  而本地(东八区)同一轮只有 6 单。

修法(只动这一个模块,不动容器时区):
- registry 新增 `beijingNow()`:把真实时刻平移成「本地 getter 读出来正好是北京时间」的 Date,
  本机已是东八区时平移量为 0,行为不变。
- `resolveActiveRound` / `describeRounds` 的 now 默认值改为 `beijingNow()`;
  service 的 `getShipTimeliness` 同样改用 `registry.beijingNow()`。
- ⚠️ 没改容器 TZ:`/opt/cangku/shared/backend.env` 里没有 TZ,改了会波及全部既有定时任务
  (dailyRefresh / dailyInventorySnapshot 等都各自显式写了 Asia/Shanghai)。

测试:
- 新增 2 个用例(跨日 00:00~08:00 不取成前一天;默认按北京时间判定 13:00 前 notStarted)。
- `node --test backend/test/warehouseShipTimeliness.test.js` → 19 / 19 pass。
2026-10-21 08:17:52 +08:00
4b02ad212d Merge pull request 'feat: 仓储中心发货时效独立页面 + 口径定版(审核时间分轮/快递剔除/停更告警)' (#69) 2026-10-21 07:55:56 +08:00
530d1ad27a feat: 仓储中心发货时效独立页面 + 口径定版(审核时间分轮/快递剔除/停更告警)
一、前端:从 MkhBomData 侧栏抽成独立页面
- 新增 frontend/src/pages/WarehouseShipTimeliness/{index.jsx,style.css},
  路由 /planning/operation/warehouse-ship-timeliness,侧栏导航与 BasicLayout 各加 1 行入口。
- 删除 frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx,
  并把 MkhBomData/index.jsx、style.css 里相关代码与样式一并摘除(-131 / -277 行)。

二、业务口径(2026-10-20 业务多次纠偏后的定版)
- 轮次归属改用「审核时间」= jst_order_express_details.last_oa_confirm_time
  (聚水潭订单列表「审核时间」列,最后一次审核,重审会刷新):
  13:00 轮 = 今天 07:00 前审核 + 历史未发;18:00 轮 = 截止 16:00 前审核 + 历史未发。
  不再按「创建时间」切自然日窗口,也不再有「满 24 小时」门槛。
- 仓级例外(仅 18:00 轮):山东青岗集仓 / 山东品牌仓 15:00 前审核、河南洛宁仓 13:00 前审核,
  核验时刻仍为 18:00(registry 新增 windowEndHourByRound)。
- 明细去掉「创建时间」,改显示「发货时间」(send_time);未发但有运单号的标「已打单 · 未揽收」。

三、已发判定与不考核快递
- 已发 = 状态表 send_time 非空 OR 订单表 status='Sent'(对齐聚水潭销售订单口径)。
  不再用运单号反推(已发货与未揽收单都会出现运单号,判不出来)。
- 自提 / 中铁 / 壹米滴答 三家快递不做未发考核,剔除量计入 summary.carrierSkipped。

四、数据源停更必须让用户看见
- freshness 由写死 stale:false 改为实算:快照距今 >90 分钟 → 黄告警;
  快照早于本轮核验时刻 → 红告警「数据未覆盖核验时刻,超时单量偏大」。

五、测试
- backend/test/warehouseShipTimeliness.test.js 扩到 17 用例(仓级例外、重审取 last_oa、
  快递剔除、Sent 判已发、coversDeadline 告警)。
2026-10-21 07:52:02 +08:00
6833b7bc79 Merge pull request 'feat: 新增「仓储中心24小时发货时效」侧栏(11 个主仓 · 12:00/16:00 双轮核验)' (#68) from codex/warehouse-ship-timeliness-1020 into main 2026-10-20 11:28:11 +08:00
893f4d6aca feat: 新增「仓储中心24小时发货时效」侧栏(11 个主仓 · 12:00/16:00 双轮核验)
需求(yanhui 2026-10-20):在 MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,
下列 11 个主仓(横林二仓、横林三仓、横林二仓品牌仓、崔桥工厂仓、崔桥工厂品牌仓、
崔桥工厂半成品仓、新苏北仓、睢宁仓、山东青岗集仓、山东品牌仓、河南洛宁仓),
从系统库存抓取已发货/未发货订单,按订单创建时间判定发货是否及时:
· 每天 12:00 核验「当天创建时间 7:00 之前」的订单是否已完成发货
· 每天 16:00 核验「当天创建时间 16:00 之前」的订单是否已完成发货

已确认的三条设计决策:
1. 子仓归属 → 连子仓一起算(京东云仓/自营仓/运营C专用仓/唯品仓等并入主仓)
2. 崔桥工厂半成品仓 → 系统无同名仓,先留空占位(不映射到配件仓 15718196)
3. 展示结构 → 只显示超时未发

实现要点
- 仓库归属用「关键词最长匹配」(不写死 wms_co_id),新增子仓只要名字含主仓关键词
  就自动归入,缓存 1h。实测 28 个子仓全部正确归入 11 个主仓,132 个代发仓零误并。
- 12:00 之前**不做核验**:resolveActiveRound 返回 null,服务层直接返回 notStarted,
  既不查订单表(接口 165ms)也不回填昨天的数据。前端显示「未到核验时间」+
  「12:00 开始首轮核验(核验当日 07:00 前创建的订单)」,11 个仓结构保留、数字显示「—」。
- 订单取数:两张表店铺互斥(jst_Orders = 京东/拼多多/抖音/得物/唯品会,
  jst_qmcust_orderlist = 天猫/淘宝/1688),并行分开查 + Node 侧按 o_id 去重合并,
  用 Promise.allSettled 保证单表失败时另一表数据仍可用(detailDegraded 标记)。
- 三态判定 pending / shippedLate / onTime:send_date 与出库表 io_date 取先到的发货痕迹,
  避免上游 send_date 回写滞后导致「已发的被判成没发」。
- 状态过滤:排除 Cancelled / Merged / **Split**(拆单父单永不发货,不排除会捞出一年前
  1.3 万条历史单),Question 仍计入待发。
- freshness 字段如实报告出库表最新 io_date,前端「发货数据可能滞后」告警,
  不拿一份可能滞后的数据去考核仓库。

排除的两次错误优化(均在代码注释里留了实测数据与复测脚本路径)
1. 用 created 替代 order_date 做窗口过滤(快 10 倍)—— 已证伪:2581 行里完全相等的只有 4 行,
   差 >1h 的 412 行,198 行跨日;典型形态 order_date=10-18 23:58 而 created=10-19 04:00:08
   (上游凌晨 4 点批量补录,created 是灌库时间不是下单时间)。业务口径是「订单创建时间」,
   只有 order_date 正确。
2. 「UNION ALL 一条 SQL 慢 5~8 倍」—— 被后续实测推翻(46.3s vs 并行 48.2s,基本持平)。

验证
- 新增 backend/test/warehouseShipTimeliness.test.js,10 条用例全绿,覆盖:
  最长匹配不误并、子仓命名、山东/河南高频前缀不误并、占位仓、12:00 前返回 null、
  12:00 轮窗口 00:00~07:00、16:00 轮窗口 00:00~16:00、状态排除表、三态分桶互斥、
  跨表重复 o_id 去重、round.windowEnd 必须带出(否则前端渲染成「核验 前」)。
- check-syntax 185 文件通过;全量 node --test 415 项 / 413 过 / 2 fail,
  两条失败均为已知环境因素(负缓存带 TTL 因 .env 有 CLOUD_DB_* 短路;
  resilientSecretStore 的 Windows 权限断言,基线恒失败)。
- 真渲染验收(组件级沙盒 + 真实接口数据):两态均 0 控制台错误;
  已核验态 358 单超时未发、8 个仓有数字、点仓切换选中/再点取消均正常;
  多分辨率验证 1920/1600 侧栏 208px、1440/1366 收窄 176px、列表始终竖向。
2026-10-20 11:23:08 +08:00
3d9b12eace Merge pull request 'fix: MKH-BOM 成品库存按仓白名单多仓求和(并入横林二仓品牌仓)' (#67) from codex/mkh-brand-warehouse-multi-1017 into main 2026-10-17 09:39:34 +08:00
c9715c0e5b fix: MKH-BOM 成品库存按仓白名单多仓求和(并入横林二仓品牌仓)
## 问题

业务反馈「MKHDNZ0409260600788 库存在横林二仓品牌仓,重新加了一个品牌仓库存,
需要同步这个仓库库存」。

根因:商品档案 `jst_sku.other_3` 记录的是**归属仓**,不是**实物所在仓**。
该批 MKH 成品的归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物已被整批搬到
2026-10-13 新建的「简尚-横林二仓品牌仓」(16132681)。归属仓库存为 0、新仓有货,
而 ⑦-d 的库存取数只查 `wms_co_id = 15870495` 单仓 → 这批成品库存全被算成 0。

实测影响(2026-10-17):
- 新仓 58 个 SKU **全是 MKH 成品**(`other_3` 全为崔桥品牌仓),合计 **525 件**
- 页面 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」**

## 改动

`backend/src/services/mkhBomService.js`
- 新增 `BRAND_WMS_CO_IDS = [15870495, 16132681]` 成品库存仓白名单(`BRAND_WMS_CO_ID`
  保留给仓库名文案与监控快照过滤)
- `jst_inventoryInfo` / `jst_packInventoryInfo` 两条取数改为
  `wms_co_id IN (白名单)` + `SUM(qty)` + `GROUP BY sku_id`,**按 SKU 多仓求和**
  - ⚠️ 原 `ORDER BY updateTime DESC`(单仓去重)在多仓下只会留一个仓,
    必须换成 SUM + GROUP BY —— 本次同时修掉这个隐患
- 配件库存**不动**:新仓一个配件都没有,横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,
  并入会让「配件不足」漏判

`backend/test/mkhBomService.test.js`
- 新增 ⑦-e 多仓求和测试(锁 SQL 白名单 + SUM/GROUP BY,并断言求和结果)
- 新增白名单常量测试(防止后续误删横林二仓 ID)

## 验证

- `check-syntax.js`:182 文件通过
- `node --test`:**405 tests / 404 pass / 1 fail**
  (唯一失败为 `resilientSecretStore` Windows 权限断言,环境固有,与本改动无关)
- **对照组实验**:stash 回修复前源码跑新测试 → 2 条**全部失败**,
  报错精确指向旧 SQL(`wms_co_id = @brandWmsCoId ... ORDER BY updateTime DESC`),证明测试非空过
- **同一数据快照双口径 diff**(生产库实测):参与比对 385 个 SKU,
  差异 58 个,**全部是新值 > 旧值,反向 0 个、来源标记变化 0 个**,
  合计补回 525 件(= 新仓总量,完全吻合)
- **新代码在生产容器实跑**:`total 4347`(不变)/ **`warning 30 → 23`** /
  **`outOfStock 21 → 16`**,与逐 SKU 分析结论一致(消除 5 个误报缺货 + 2 个误报预警)
- **性能**:`STATISTICS IO` 两边均为 `jst_inventoryInfo scan 1 / logical reads 4683`(不增加 IO);
  实测 355ms vs 旧 356~914ms,**无退化**

## 风险与回退

- 风险:白名单常量是硬编码,后续上游再新增同类「成品发货仓-真实库存」仓时需追加 ID。
  已在代码注释与文件头 ⑦-e 写明。
- 回退:单文件改动,revert 本提交即恢复单仓口径,无数据迁移、无状态残留。
2026-10-17 09:34:55 +08:00
c80d28c33f Merge pull request 'fix: 动销分析多来源销量先按 仓库+商品 汇总再 join' (#66) from codex/picking-bin-ship-agg-1014 into main 2026-10-14 13:57:59 +08:00
62cac712db fix: 动销分析多来源销量先按 仓库+商品 汇总再 join
4 个销量来源 union all 后各分支各自 GROUP BY,同一个 仓库+商品 命中多个
分支时外层层 LEFT JOIN 没有二次聚合,一个仓位行被拆成多行、每行只带某一
个分支的销量(实测 SJ038411 同时出现 sales=0 与 sales=44 两行)。

新增 shipAgg CTE:按 仓库+商品 对各来源求和成一行,再与仓位行 join。
 SalesQty/分摊销量/日销/等级随之统一。
2026-10-14 13:57:14 +08:00
e5030d4864 Merge pull request 'fix: 云库新增慢查询入口 querySlow(池级超时放宽)' (#65) from codex/cloud-db-slow-query-1014 into main 2026-10-14 13:12:40 +08:00
0a2fe1c3a3 fix: 云库新增慢查询入口 querySlow(池级超时放宽)
mssql 12.2.0 驱动 request.timeout(请求级)实测不生效,超过池默认
60s 的查询必被掐断。动销分析销量 union 4 来源后全仓窗口需 ~2 分钟,
触发 Timeout: Request failed to complete in 60000ms。

- queryWithCandidateConnection 支持 options.connectRequestTimeoutMs
  覆盖候选连接的池级 requestTimeout(池级实测生效)
- 新增 querySlow(sqlText, params, options):独立临时连接 + 池级超时
  默认 300s(env CLOUD_DB_SLOW_QUERY_TIMEOUT_MS 可调)
- 动销分析取数优先走 querySlow(mock 环境无该方法时回退 query)
2026-10-14 13:12:05 +08:00
1d4eecb03c Merge pull request 'fix: 动销分析销量补齐组合装子件与全民客户(B2B)渠道' (#64) from codex/picking-bin-combo-qmcust-1014 into main 2026-10-14 11:05:54 +08:00
2bdc4b5713 fix: 动销分析销量补齐组合装子件与全民客户(B2B)渠道
线上订单里组合装以 ZH-子件A-N-子件B-M 拼接编码存在,按 sku_id 精确
匹配永远查不到子件销量;全民客户(jst_qmcust_orderlist)渠道此前完全
未纳入。两处缺失导致大量 SKU(尤其组合装子件)窗口销量被算成 0。

销量 ship CTE 拆为 4 个 UNION ALL 分支:
1) jst_Orders 普通行(sku_type<>combine)
2) jst_Orders 组合装行 JOIN jst_comboProductItem 展开为子件x数量
3) jst_qmcust_orderlist 普通行(排除拆单父单 Split/Question)
4) qmcust 组合装行同样展开
均按 send_date 窗口,仓库过滤不变。等级/日销/分摊销量随数据源自动重算。
2026-10-14 11:05:13 +08:00
c6c7feaeea Merge pull request 'fix: 排除拣选位报表中的箱记录' (#63) from codex/picking-bin-pack-filter-1012 into main 2026-10-12 14:39:06 +08:00
134
fa0e604bf8 fix: 排除拣选位报表中的箱记录 2026-10-12 14:31:45 +08:00
cf8d5f68ae Merge pull request 'fix: 动销分析报表恢复「类型」列(恒为拣选),存货位仍不纳入' (#62) from codex/picking-bin-restore-type-col-1008 into main 2026-10-08 16:32:09 +08:00
e15898693f fix: 动销分析报表恢复「类型」列(恒为拣选),存货位仍不纳入
上一版为去掉冗余把「类型」整列删了,用户要求的报表列结构中该列仍在,
现恢复:导出与列表均保留「类型」列,值恒为「拣选」(存货位不参与统计,报表中不会出现)

验证(基准日 2026-10-08、30 天):
- 导出表头为 仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 /
  分摊销量 / 商品日销 / 动销等级 / 统计天数 / 统计基准日
- 11897 行,类型列取值集合仅 ['拣选'],空值 0 行;分摊销量合计 98087 = 去重商品销量合计 98087
- 列表接口 binTypes=['拣选'],明细类型集合仅 ['拣选']
- 单测 9/9 通过;语法检查 182 文件通过;eslint 无报错、vite build 成功
2026-10-08 16:31:20 +08:00
214ede07a5 Merge pull request 'feat: 拣选库位动销分析固定只统计拣选位,移除仓位类型切换' (#61) from codex/picking-bin-picking-only-1008 into main 2026-10-08 13:10:48 +08:00
99fa402092 feat: 拣选库位动销分析固定只统计拣选位,移除仓位类型切换
- 取数固定只取拣选位:bin CTE 直接过滤 bin_group 为空(更早过滤,存货位不再进入后续 CTE)
  移除可变的 binType IN 过滤与 resolveBinTypes,binType 字段恒为拣选
- 接口 payload 的 binTypes / binTypeOptions 固定为 ['拣选'];缓存键不再含类型
- 列表与导出移除「类型」列(全为拣选,冗余);页面移除类型多选器、底部文案改为「统计范围:仅拣选位」
- 口径说明补充统计范围;Tooltip 同步

验证(基准日 2026-10-08、30 天):
- 列表 11871 行 / 6821 个去重商品 / 库存 499141 / 去重商品销量 96847,明细类型集合仅为 ['拣选']
- 导出 11871 行,表头无「类型」列;分摊销量合计 96847 = 去重商品销量合计 96847(差异 -0.0017 浮点舍入)
- 旧参数 binType=存货 被忽略(仍返回拣选且命中同一缓存);7 / 60 天窗口与单仓过滤均正常
- 单测 9/9 通过;后端语法检查 182 文件通过;前端 eslint 无报错、vite build 成功
2026-10-08 13:09:49 +08:00
8f12b889c0 Merge pull request 'feat: 动销等级改为按商品日销统计,列表与导出统一到仓位×商品粒度' (#60) from codex/picking-bin-grade-by-sku-1008 into main 2026-10-08 10:59:14 +08:00
88a39975d3 feat: 动销等级改为按商品日销统计,列表与导出统一到仓位×商品粒度
- 取数统一为 fetchPickingBinSkuRows:一行 = 一个「仓位 × 商品编码」,列表与导出共用
  商品日销 = 商品窗口整档销量 ÷ 天数(不做仓位分摊);同一商品占几个仓位都是同一等级
  另输出分摊销量 = 商品销量 ÷ 占用同类型仓位数,供按行求和
- 列表页同步拆到商品粒度:新增商品编码列,去掉 SKU数列,搜索支持商品编码
- 汇总卡片主数值改为去重商品数,另显示明细行数;销量按去重商品口径统计
- 导出新增「分摊销量」「商品日销」列,表头口径标注更明确
- 性能:仓位类型选满时不拼 IN 过滤(避免参数嗅探走坏计划),全类型查询由超时降到约 30 秒

验证(基准日 2026-10-08、30 天、拣选位):
- 明细 11871 行 / 去重商品 6821 个 / 库存合计 499141(与旧口径一致)
- 跨多仓位的 2028 个商品,销量与等级不一致 0 个
- 导出行求和:分摊销量合计 96847 = 去重商品销量合计 96847(差异 -0.0017,浮点舍入)
- 单测 9/9 通过;语法检查 182 文件通过;前端 eslint 无报错、vite build 成功
2026-10-08 10:58:17 +08:00
195351deae Merge pull request 'feat: 拣选库位动销分析导出增加商品编码,按仓位×商品编码拆行' (#59) from codex/picking-bin-export-sku-1008 into main 2026-10-08 09:36:58 +08:00
31f184edd0 feat: 拣选库位动销分析导出增加商品编码,按仓位×商品编码拆行
- 新增 fetchPickingBinSkuRows:仓位 × 商品编码 粒度明细
  库存数量 = 该商品在此仓位的实际库存;销量 = 商品销量 ÷ 其占用同类仓位数(贡献)
- 导出列:仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级 / 统计天数 / 统计基准日
- SKU数、动销等级仍为仓位粒度(同一仓位多行重复),列表页口径不变(仍用 bin 级聚合)
- 同一次取数比对:7055 个仓位,列表与导出库存/销量 0 处差异,库存合计一致 497721
- 单测 11 条通过
2026-10-08 09:35:26 +08:00
ea2942f118 Merge pull request 'feat: 拣选库位动销分析默认按近 30 天销量划分等级' (#58) from codex/picking-bin-window-30d-1007 into main 2026-10-07 17:42:06 +08:00
817cc74a40 feat: 拣选库位动销分析默认按近 30 天销量划分等级
- 默认统计窗口 15 天 -> 30 天(后端 PICKING_BIN_DEFAULT_DAYS 与前端常量同步)
- 可切换窗口补充近 60 天(7 / 15 / 30 / 60)
- 等级阈值不变:爆款≥20 / 热卖 10-20 / 平销 3-10 / 滞销-1 1-3 / 滞销-2 (0,1) / 销售=0
- 单测断言同步更新;实测 30 天窗口:7091 个拣选库位,日销=销量÷30
2026-10-07 17:41:17 +08:00
e71607386d Merge pull request 'feat: 新增每日进销存业务库校验脚本' (#57) from codex/validate-daily-inventory-biz-1007 into main 2026-10-07 17:04:36 +08:00
7505a04e37 Merge pull request 'feat: 新增二级页面「拣选库位动销分析」(大货库存监控)' (#56) from codex/picking-bin-sales-analysis-1007 into main 2026-10-07 17:04:10 +08:00
0afcb26119 feat: 新增每日进销存业务库校验脚本
- backend/scripts/validateDailyInventoryFromBiz.js <业务日>...
- 直连业务库(凭据经隧道从系统库 database_configs 现取,不落盘):
  反算 4 个可还原的流水列(销售出仓/销售退货/采购进仓/采购退货),
  与 daily_inventory_official 逐仓比对;期末库存与 jst_inventoryInfo 全仓比对
- 约定:校验类需求默认走业务库,不启浏览器取聚水潭
2026-10-07 16:59:56 +08:00
70550b5499 feat: 新增二级页面「拣选库位动销分析」(大货库存监控)
- 后端:pickingBinSalesAnalysis 配置/服务/路由,仓位库存取 rpt.jst_packInventoryInfo
  (bin_group 空值=拣选位、存货=存货位),销量按「SKU 占用同类仓位数平均分摊」,
  恒等式「全部库位销量之和 = 有库位 SKU 销量之和」差异为 0
- 动销等级(近 N 天日销,默认 15):爆款>=20、热卖款 10-20、平销款 3-10、
  滞销款-1 1-3、滞销款-2 (0,1)、销售=0
- 前端:大货库存监控下新增二级菜单 + 页面(等级卡片、仓库/类型/天数/仓位筛选、
  分页表格、xlsx 导出),路由/菜单/页面权限四处注册
- 测试:后端 8 项单测通过;前端 lint/test/build 通过
2026-10-07 16:59:39 +08:00
ada8d09c27 Merge pull request 'fix: MKH-BOM 配件库存增加箱表兜底,修正「配件有货却判配件不足」' (#55) from codex/mkh-component-stock-pack-fallback-0930 into main 2026-09-30 19:32:34 +08:00
a93691f5d8 fix: MKH-BOM 配件库存增加箱表兜底,修正「配件有货却判配件不足」
根因
- 配件监控页取实时库存是三级兜底(inventoryInfo → 同仓箱表 packInventoryInfo
  → 监控快照 jsStock),而 MKH-BOM 的 loadComponentStock **只查 jst_inventoryInfo**,
  一级来源到底就结束。
- 上游 jst_inventoryInfo 存在滞后:2026-09-30 该行停在 09-28 14:51 的值 0,
  而 jst_packInventoryInfo 当日 10:30 已记 bin=ZT-30 qty=200。
- 结果:同一个仓(15718196)、同一个配件,配件监控页显示 200(判「正常」),
  MKH-BOM 判「配件不足」、可组装数 0。
  案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01(瓶颈唯一)。

改动
- backend/src/services/mkhBomService.js
  · loadComponentStock():一条语句同时取两张表(UNION ALL + src 标记),
    库存表有正库存以它为准;为 0 / 缺行时用同仓 jst_packInventoryInfo 的
    bin 非空行合计兜底。**只补 0、不覆盖正库存**,与配件监控页
    buildRealtimeStockSql 同口径;查询失败仍整体 degraded(不做判定)。
  · 文件头 ⑪:修正「不能用 jst_packInventoryInfo 当配件库存 —— 与 MKH 配件
    零交集」这条结论 —— 它是 09-17 的表状态(当时该表 bin 全空),
    09-18 上游整表重建后已失效。
  · 文件头 ⑤:同理修正该表的关联列与数据现状结论。PR #22 的提交信息声称
    已更新 ⑤,但实际 diff 未改,此处补齐(该表 wms_co_id 关联、目标仓
    727/794 命中、配件仓 743 个 SKU 有箱记录)。
- backend/test/mkhBomService.test.js
  · 新增 3 个用例:箱表兜底生效且不覆盖正库存 / SQL 形状防腐化(两张表 +
    bin 非空 + 限定仓)/ 查询失败仍降级不做判定。
    对照组已验证:在修复前的实现上前两个用例失败(expected 200、actual 0)。

影响与验证
- 仅影响 MKH-BOM 的「配件不足」标记与可组装数;不改预警级别、排序与预警总数。
- 同一数据快照下的新旧口径对照(监控日 2026-09-29):
  配件不足 26 → 24(2 行翻盘)、预警总数 29、缺货 23 两边一致,
  0 行反向变化、0 处其他字段变化、0 处级别变化。
- 真库实测:DNZ040947N1440V01 0 → 200、DNZ040959N1440V01 0 → 200;
  库存表本就有正数的对照配件(60 / 87)未被箱表覆盖。
- 负载(SET STATISTICS IO,300 编码一个分片):jst_inventoryInfo 4543 逻辑读 +
  jst_packInventoryInfo 947 逻辑读,物理读 0、Worktable 0;实测中位耗时
  215ms → 391ms(+176ms/分片,配件全集 784 个约 3 个分片,占冷查 61s 的 1% 以内,
  且结果按编码缓存 10 分钟)。
  说明:SHOWPLAN 权限被拒(账号无该权限),改用 STATISTICS IO + 实测耗时 + 数据量
  作为执行计划等价证据。
- 数据量边界:配件仓 jst_inventoryInfo 837 行、jst_packInventoryInfo 1163 行
  (bin 非空 1024 行)。
- 后端:check-syntax 179 文件通过;node --test 393 项 392 通过
  (唯一失败为既有环境问题 resilientSecretStore 权限断言,与本改动无关)。
2026-09-30 17:43:30 +08:00
b0b618e7df Merge pull request 'feat: returns board - add latest return-deadline column (workbench source, pending access)' (#54) from codex/lunanting-exit-date-001 into main 2026-09-28 16:07:13 +08:00
30554720b2 feat: 客退看板新增「最晚退厂时间」列(工作平台数据,先占位待接入)
- 新增 supplierExitDateProvider.js:按供应商名匹配「最晚退厂时间」,
  数据源为工作平台库 WorkbenchDataShared(同实例,另有 WorkbenchIdentity /
  DataPlatformControl)。当前副本账号 cangku 无该库访问权限、系统设置里没有它的
  连接配置、.env 的 JIAN_SUN_DB_* 与 WORK_ORDER_* 也为空,因此先返回不可用。
  接入方式:IT 开通后配 SUPPLIER_EXIT_DATE_ENABLED=true + SUPPLIER_EXIT_DATE_SQL
  (查询需返回 supplierName / exitDate 两列),或直接写死 CODE_* 常量,无需改其他文件。
- returnStockWarningService.js:composeRows 增加 latestExitDate /
  latestExitDateLabel / latestExitDateAvailable;列表响应新增 exitDateMeta;
  导出列在「供应商是否合作」后新增「最晚退厂时间」(未接入时导出显示「待接入」);
  口径说明补一条(含完整取证:工作平台库不可达、副本无任何退厂字段、
  fys_supplier.NoCoopTime 是不可用的停更快照)。
- StockWarning.jsx:表格新增「最晚退厂时间」列,有值显示日期、未接入显示「待接入」
  (Tooltip 说明数据来源与开通方式);monitor.css 补占位符样式。
2026-09-28 16:06:40 +08:00
901212a32c Merge pull request 'fix: returns board cache-hit path referenced undefined CACHE_TTL_MS (500)' (#53) from codex/lunanting-supplier-coop-001 into main 2026-09-28 15:41:35 +08:00
f7fc7fef4f fix: 修复客退看板缓存命中时引用未定义常量导致 500
缓存 TTL 改成可配(DEFAULT_CACHE_TTL_MS / resolveCacheTtlMs)时漏改了
loadReplicaRows 里的比较表达式,仍写 CACHE_TTL_MS。缓存为空时短路不触发,
所以本地首轮没暴露;生产被预热任务写过缓存后,第二次请求命中该行即
ReferenceError 500。改为调用 resolveCacheTtlMs()。
2026-09-28 15:40:57 +08:00
23e1b365d6 Merge pull request 'feat: returns board outbound column -> supplier cooperation status' (#52) from codex/lunanting-supplier-coop-001 into main 2026-09-28 15:36:14 +08:00
6c3b14fc10 客退看板「是否出库」列改为「供应商是否合作」
- returnStockWarningService.js: 新增 supplierCoopSign 取 js_supplier.coopersgn
  (LEFT JOIN dbo.js_supplier sp ON sp.scmname = sk.supplier_name),
  解析为 supplierCoop / supplierCoopLabel(1=合作 / 2=不合作 / 其它=未知),
  导出列「是否出库」同步替换为「供应商是否合作」
- StockWarning.jsx: 表格列替换,新增合作状态 Tag 与判断依据 Tooltip
- returnStockWarehouseRegistry.js: 更新崔桥工厂仓客退位复测结论
  (2026-09-27 已同步进副本:349 编码 / 507 件)
2026-09-28 15:34:55 +08:00
aeac4bcc81 Merge pull request 'feat: 库存动销分析「销售=0」导出按仓位拆行,库存数量按实际仓位统计' (#51) from codex/ssa-zero-export-bin-0928 into main 2026-09-28 09:57:18 +08:00
2745cf18ff feat: 库存动销分析「销售=0」导出按仓位拆行,库存数量按实际仓位统计
上一版把同一 SKU 的多个仓位用「、」拼在一格里,库存数量仍是 SKU 总量,
无法直接按仓位盘点。现改为每个仓位独立一行:

- bin CTE 改为按 (wms_co_id, sku_id, LTRIM(RTRIM(bin))) 分组,
  SUM(qty) 得到仓位级库存(各仓位行之和 = SKU 总库存,实测 240=60+69+35+76)
- 导出行 = graded(SKU 级「销售=0」判定)LEFT JOIN 仓位级明细:
  有仓位数据的 SKU 按仓位展开多行、库存数量取该仓位实际 qty(qty=0 的仓位行剔除);
  仓位表未覆盖的 SKU 保留一行(仓位为空、库存取总库存),不丢数据
- 排序:仓库 → SKU 总库存倒序 → SKU → 仓位名称,同一 SKU 的各行保持相邻
- 前端按钮提示同步更新拆行与兜底规则说明

验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 环境项);
前端 eslint/build 通过;
本地实测:单仓(睢宁 196 行,原 128 SKU 行)样例 SKU 正确拆为 4 行且合计 240;
全仓导出 6798 行(原 3650),有仓位行 6529、仓位为空兜底行 269,覆盖 26 仓
2026-09-28 09:55:45 +08:00
38804f6624 Merge pull request 'feat: 库存动销分析「销售=0」导出增加系统仓位列' (#50) from codex/ssa-zero-export-bin-0928 into main 2026-09-28 09:36:10 +08:00
fc280bfe27 feat: 库存动销分析「销售=0」导出增加系统仓位列
仓位用于定位滞销库存的实际存放位置,便于盘点与挪位处理。
数据源为 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全部为空、库存表无仓位字段,
这是业务库唯一可得的系统仓位):
- 按 (wms_co_id, sku_id) 聚合,LTRIM 去空格后 DISTINCT,STRING_AGG 按名称排序用「、」连接
- 排除无仓位(bin 为空)与零库存(qty=0)的行,避免无效仓位干扰
- LEFT JOIN 且按「仓库+SKU」双条件匹配,避免跨仓错配
- 导出列顺序:仓库 | SKU编码 | SKU名称 | 系统仓位 | 库存数量 | 销量 | 统计天数 | 统计基准日
- 前端按钮提示同步说明多仓位拼接规则与留空含义

验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 权限环境项);
前端 eslint/build/测试均通过;
本地实测导出 3650 行,系统仓位列就位,3381 行有仓位(92.6%,未覆盖的 7.4% 为
仓位表仅含 13 个仓库所致),销量列全为 0,最长仓位串 600 字符(Excel 上限内);
浏览器实测点击下载,文件名与列结构正确
2026-09-28 09:34:55 +08:00
4d419c5c7c Merge pull request 'feat: 库存动销分析「销售=0」档支持导出 SKU 明细' (#49) from codex/ssa-zero-export-0927 into main 2026-09-28 08:18:53 +08:00
2c8ed86c26 feat: 库存动销分析「销售=0」档支持导出 SKU 明细
- 后端新增 GET /api/stock-sales-analysis/zero-sales-export:
  复用与页面完全一致的分档口径(同一段 gradeCase 表达式 + 「有库存或有销量」过滤),
  输出仓库/SKU编码/SKU名称/库存数量/销量/统计天数/统计基准日的 SKU 级明细;
  支持.days 统计窗口、wmsCoId 逗号分隔过滤(仅允许仓库清单内的 ID,防任意拼参)、
  format=xlsx|csv,复用通用导出服务 exportWorkbook
- services 新增 fetchZeroSaleSkuRows:SKU 级「销售=0」查询,LEFT JOIN rpt.jst_sku 取名称,
  按仓库+库存倒序排列
- 前端工具栏新增「导出销售=0」按钮:跟随当前统计天数与仓库筛选,
  文件名含业务日期与仓库范围,失败时解出错误体给出可读提示
- 测试补充 3 条:防呆抛错、空参短路、SQL 口径与参数传递断言

验证:后端 syntax 通过、单测 8/8(全量 389 条仅既有 Windows 环境项失败);
前端 eslint/build/test 21/21 通过;
本地实测:全仓导出 3641 行、26 仓、销量列全为 0;
单仓(崔桥半成品包装 694 行)与页面「销售=0」SKU 数一致;
浏览器实测点击下载真实文件名「库存动销分析-销售0明细-20260926.xlsx」
2026-09-27 17:47:45 +08:00
151d420831 Merge pull request 'feat: 侧边栏新增二级「库存动销分析」页面' (#48) from codex/stock-sales-analysis-0927 into main 2026-09-27 16:31:47 +08:00
f20bd3a487 feat: 侧边栏新增二级「库存动销分析」页面
按《仓库库存动销分析.xlsx》:
- 一级菜单「大货库存监控」下新增二级「库存动销分析」(路由 /planning/operation/stock-sales-analysis)
- 表头与表格结构按需求表:仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量,
  每仓 6 个动销等级(爆款/热卖款/平销款/滞销款-1/滞销款-2/销售=0)+ 1 行汇总,仓库列跨行合并
- 分档口径:日销 = 统计窗口内已发货件数 ÷ 窗口天数(7/14/30 天可选,默认 30),
  库存取 rpt.jst_inventoryInfo 实时库存,销量取 jst_Orders×jst_OrderItem 按 send_date
  (排除 Delete/Cancelled,与每日进销存发货口径一致),只统计有库存或有销量的 SKU
- 后端 /api/stock-sales-analysis:单条 SQL 一次覆盖全部仓库×等级,5 分钟缓存 + refresh=1 强制重算;
  返回逐仓 + 全仓合计、占比、无数据仓数量
- 前端页面:统计天数/仓库筛选、口径说明弹窗、合计行高亮、无数据仓计数提示
- 页面权限:前后端 sidebarPages 注册 pageKey=stock_sales_analysis,默认 planning 角色可见

验证:后端 syntax + 单测 5/5;前端 eslint、build、npm test 21/21;
本地接口 30 天窗口实测 27 仓(崔桥汇总 2080 SKU / 90822 件 / 54076 件销量),
占比逐仓闭合为 100%;浏览器实测侧边栏入口、表头、197 行渲染与截图均正常
2026-09-27 16:29:58 +08:00
9e0612b7f3 Merge pull request 'fix: 每日进销存截图按业务日期归档,并补充幂等与执行记录测试' (#47) from codex/daily-inventory-notify-0928 into main 2026-09-27 14:55:33 +08:00
117d59c9f0 fix: 每日进销存截图按业务日期归档并补充幂等/执行记录测试
定时任务在凌晨 01:00 推送的是「昨日」数据,但截图对象键原先直接取执行时刻的北京日期,
导致归档目录与通知里的业务日期不一致(daily-inventory/2026-09-26/... 装的是 09-25 的数据)。
现改为由定时任务显式传入业务日期,目录与文件名统一用业务日期、时间仍用执行时刻:
  daily-inventory/2026-09-25/daily-inventory-20260925-010000.png

另补充验收要求 8 的测试:
  · buildScreenshotNames 归档命名(含业务日期与执行日期不同的场景)
  · markNotified / hasNotified 幂等:同业务日同用户只成功一次,跨日期互不影响
  · appendRunRecord 保留执行时间、截图地址、通知对象与失败原因
2026-09-27 14:53:05 +08:00
f815751634 Merge pull request 'feat: 每日进销存支持导入聚水潭官方进销存校正数据' (#46) from codex/daily-inventory-official-0927 into main 2026-09-27 14:29:20 +08:00
0e77a70c33 feat: 每日进销存支持导入聚水潭官方进销存校正数据
业务库只能还原 4/12 个流水列且口径与聚水潭不一致(采购按单据日期、销售按订单发货日期,
jst_salesOutboundInfo.qty 全为 0),调拨/盘点/加工/其它出入库无单据表,故以官方导出为准:

- 新增本地表 daily_inventory_official(biz_date + warehouse 存期初/12 流水列/期末)
- 新增解析与导入服务:xlsx 解析、仓库 ID 映射、「期初+流水=期末」恒等式校验
- GET /api/daily-inventory 支持 ?date= 指定业务日,官方值优先、缺失回退推算并返回来源标记
- 新增 POST /api/daily-inventory/official-import(xlsx 上传/JSON)与 GET /official 查询
- 导入后回写本地库存快照(官方期初→上一日期末、官方期末→当日期末),使推算也对齐
- 前端:业务日切换、官方/推算来源标记、导入弹窗、口径说明同步更新
- 仓库清单补入「简尚-横林三仓主仓」(聚水潭导出中存在)
- 命令行导入:npm run inventory:import-official -- <日期> <xlsx>
2026-09-27 14:16:06 +08:00
2d8ef49c12 Merge pull request 'fix: 期初兜底弃用 pm_stockMonitor,全部改用实时库存扣流水回推(#44 追随修复)' (#45) from codex/daily-inventory-complete-data-0927 into main 2026-09-27 10:09:53 +08:00
c47a25b002 fix: 期初兜底弃用 pm_stockMonitor,全部改用实时库存扣流水回推
口径校准(对照聚水潭进销存导出 09-23 期初基准):
  崔桥工厂仓 基准 92388 / pm 路径 114286 / 回推 ≈89650
  横林二仓   基准 63780 / pm 路径  1197  / 回推 ≈64029
  山东青岗集 基准 88377 / pm 路径  67339 / 回推 ≈88210
  河南洛宁   基准 83305 / pm 路径  76992 / 回推 ≈82473

pm_stockMonitor.jsStock 只覆盖纳入监控范围的 SKU 子集(横林二仓实测
1197 vs 全仓 63780),系统性偏低,不能充当全仓结存;「实时库存扣
流水回推」与基准偏差 0.3%~2.7%(来自缺失的 8 类单据),显著更准。
2026-09-27 10:09:19 +08:00
68d1fcec00 Merge pull request 'fix: 修复流水取数漏传 cloudDb 导致流水列静默为空(#43 追随修复)' (#44) from codex/daily-inventory-complete-data-0927 into main 2026-09-27 10:04:05 +08:00
952c57a84a fix: 修复流水取数漏传 cloudDb 导致流水列静默为空
路由调用 fetchFlows(wmsCoIds, from) 少了第一个参数 cloudDb,
导致形参错位(cloudDb=数组、wmsCoIds=日期、fromText=undefined),
函数静默返回空 Map 且无任何报错。补传参数,并为 fetchFlows 增加
防呆校验:首参非数据库实例时立即抛 TypeError。补 1 项回归测试。
2026-09-27 10:03:24 +08:00
dc12f6753d Merge pull request 'feat: 每日进销存补充流水与期初数据,修复合计行 NaN' (#43) from codex/daily-inventory-complete-data-0927 into main 2026-09-27 09:56:23 +08:00
c358aff781 feat: 每日进销存补充流水与期初数据,修复合计行 NaN
数据补充(全部来自数据库,无人工填数):
- 流水列接入 4/12:采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、
  采购退货(jst_purchaseout×Item,io_date)、销售出仓(jst_Orders×OrderItem,send_date,
  与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。
  其余 8 列业务库确无单据表(已核实全库表清单),保持「—」待 DBA 建表
- 期初快照缺失时自动兜底:优先用 rpt.pm_stockMonitor(报表库 01:00 跑批 T-1 库存,
  按 dbWarehouse 聚合 jsStock,精确);监控表没有的虚拟仓(运营专用/京东仓)用
  实时库存扣自期初时刻以来的带符号流水回推(近似),推导结果补写快照逐日滚动

缺陷修复:
- 合计行仓库名显示 NaN:isTotal 渲染分支拦截了所有列,'合计' 被强转数字;
  现仓库名原样显示,无数据来源的流水列合计也显示「—」而非误导性 0
- 合计行:某列全部仓库均无数据时合计置空,避免 0 误读

新增 4 项单元测试(流水符号约定、参数展开、期初双路径推导)。
2026-09-27 09:54:59 +08:00
485097354b Merge pull request 'fix: 每日进销存截图改用系统库专用服务账号身份' (#42) from codex/daily-inventory-notify-identity-0927 into main 2026-09-27 09:29:06 +08:00
f205cb9322 fix: 截图服务改用系统库专用服务账号身份
页面启动会调用 /api/auth/verify,该接口要求令牌 sub 必须是系统库 users 表中真实且 active
的用户。原实现签发的是一个不存在于用户表的合成身份,verify 返回 401,前端清除令牌并跳转
登录页,截图因此永远拿不到数据(等待数据行 3 分钟超时)。

改为在系统库维护一个仅供服务端使用的专用账号:
  · 账号不存在时按 role=admin、status=active、auth_source=service 创建
  · 密码哈希为随机值且无人知晓,账号无法通过密码登录
  · 已存在时复用 id,并仅对 auth_source=service 的本服务账号补齐管理员与启用状态
  · 令牌 sub 使用该账号 id,15 分钟过期,仍只注入 localStorage
  · 账号 id 进程内缓存,删除该行即可回收权限

新增 2 项单元测试:账号创建/复用与令牌 sub 校验。
2026-09-27 09:24:39 +08:00
daf3646470 Merge PR #33: 每日进销存增加定时截图通知
经仓库所有者 youruo 明确授权合并(2026-09-24 授权),授权覆盖最终提交 3bfc48b8b36a22ab422a6566c1637fc7d5f1c42a
2026-09-27 08:13:03 +08:00
3f382b49d5 Merge PR #32: 每日进销存改为全部从数据库取数
经仓库所有者 youruo 明确授权合并(2026-09-24 授权),授权覆盖最终提交 734b974f45fa96224ade9c8bc58ed0df3d978a2c
2026-09-27 08:11:25 +08:00
3bfc48b8b3 Merge branch 'codex/daily-inventory-snapshot-0924' into codex/daily-inventory-notify-0924
冲突文件:backend/src/index.js
处理理由:采用已解决好与他人 PR #40/#41 冲突的分支 A 版本,再补回本分支的通知接口
require、/api/daily-inventory-notification 路由注册与调度器启动,双方行为均保留。
2026-09-26 12:18:11 +08:00
734b974f45 Merge branch 'main' into codex/daily-inventory-snapshot-0924(同步 PR #40/#41)
冲突文件:backend/src/index.js
处理理由:main 侧(他人 PR #40、#41)新增崔桥工厂仓客退三类路由 require 与注册、统一 404 与 JSON 错误处理中间件;
本分支仅新增每日进销存快照任务的 require 与 startDailyInventorySnapshotScheduler() 调用。
采用 main 版本文件并补回本分支两处改动,双方行为均保留。
2026-09-26 12:16:30 +08:00
28cfb1b330 Merge pull request '客退看板首仓改为崔桥工厂仓(数据源:崔桥工厂客退临时上架仓)' (#41) from codex/lunanting-cuiqiao-returns-001 into main 2026-09-25 14:17:25 +08:00
b0f56b27f5 客退看板首仓改为崔桥工厂仓(数据源:崔桥工厂客退临时上架仓)
按 2026-09-25 业务口径,第一张子看板由「横林二仓客退数据监控」改为
「崔桥工厂仓客退数据监控」,取数仓位同步改为聚水潭「崔桥工厂客退临时上架仓」。

- 仓库配置:简尚-崔桥工厂仓 wms_co_id=15038426,客退位 customize_qty_1
- key 由 henglin2 改为 cuiqiao,同步改路由 / 侧边栏 / 路由映射 / 权限条目
- 旧路径 returns-inventory/henlin2* 保留为重定向,避免历史链接 404
- 核销看板(returnMonitorService)仍指向横林二仓,前端入口已下线,本次未动

副本取证:该仓客退位当前只有 2 编码 / 2 件(退货单有量:ERP 3115 条 +
奇门 1812 条),属「客退上架库存未同步进副本」,页面需靠导入功能补数据。
2026-09-25 14:16:11 +08:00
7abb75f803 Merge pull request #40: 客退数据监控五仓看板 + 本地运行稳定性加固
from codex/cangku-returns-monitor-001 into main
2026-09-24 16:06:04 +08:00
90fa54bd8c Merge branch 'main' into codex/cangku-returns-monitor-001 (sync 41 commits, keep both sides) 2026-09-24 15:37:59 +08:00
11ef98b8c1 Merge pull request #38: force MKH manual sync refresh 2026-09-24 14:51:06 +08:00
134
345214daed fix: force live refresh on MKH sync 2026-09-24 14:49:08 +08:00
d013f1e636 Merge pull request 'fix: 拒绝本地旧后端返回的过期 MKH 库存' (#37) from codex/mkh-fresh-contract-0924 into main
拒绝本地旧后端返回的过期 MKH 库存 (#37)
2026-09-24 14:03:11 +08:00
134
ba6ef97a40 fix: reject stale MKH backend responses 2026-09-24 14:02:24 +08:00
91e3d4040c Merge pull request 'feat: 显示 MKH 数据更新进度' (#36) from codex/mkh-refresh-progress-0924 into main
显示 MKH 数据更新进度 (#36)
2026-09-24 13:37:14 +08:00
134
22e7264438 feat: show MKH refresh progress 2026-09-24 13:36:44 +08:00
146f98e9ee Merge pull request 'fix: 页面刷新强制更新 MKH 运营数据' (#35) from codex/mkh-fresh-refresh-0924 into main
页面刷新强制更新 MKH 运营数据 (#35)
2026-09-24 13:31:06 +08:00
134
ea312d9070 fix: force fresh MKH operational data 2026-09-24 13:30:09 +08:00
1afa99a783 Merge pull request 'fix: MKH 页面自动刷新实时数据' (#34) from codex/mkh-auto-refresh-0924 into main
修复 MKH 页面自动刷新实时数据 (#34)
2026-09-24 13:17:52 +08:00
134
30f5df2206 fix: auto refresh MKH live data 2026-09-24 13:15:53 +08:00
3af6bd9ef2 Merge branch 'codex/daily-inventory-snapshot-0924' into codex/daily-inventory-notify-0924 2026-09-24 12:00:26 +08:00
6c753791ad Merge branch 'main' into codex/daily-inventory-snapshot-0924(同步 PR #31) 2026-09-24 12:00:14 +08:00
c4ea12e9ba fix: 修正截图站点地址与通知正文链接,生产镜像补充 Chromium 与中文字体
- 截图默认站点改为生产主站 ck.yydd.ai(原默认值指向无关域名)
- 通知正文补充「页面地址」,便于在通知中心直接打开
- 服务身份增加 daily_inventory 页面权限声明
- Dockerfile 安装 Chromium 与 fonts-noto-cjk,否则容器内无法截图且中文显示为方块
2026-09-24 11:59:36 +08:00
56fba389e4 feat: 每日进销存增加定时截图通知
设置(仅管理员)
- 可配置是否启用、通知时间(默认 01:00 Asia/Shanghai)、多名通知用户
- 收件人在后端规范化:去空、去重、限长限量,配置持久化到 system_config
- 接口不返回通知中心密钥与对象存储密钥,只给是否已配置与脱敏预览

定时任务
- 单实例锁(MySQL GET_LOCK),按配置时间触发,命中后 30 分钟内失败最多补跑 3 次
- 服务端签发 15 分钟短时效 JWT 作为专用身份,令牌只写入浏览器 localStorage
- 等数据真正渲染完成(有数据行、无加载中、无错误提示)才截图,展开滚动容器后整页截图
- 对象键形如 daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
- 截图上传成功后才逐用户发通知,单人失败不影响他人;正式推送按「业务日期+收件人」幂等
- 立即测试走同一链路但幂等键带随机数,不影响次日正式推送
- 每次执行记录时间、截图地址、通知对象、成功状态与失败原因

测试
- 覆盖收件人去重、通知时间校验、幂等键、业务日期与对象键、重试退避(17 项)
2026-09-24 11:22:51 +08:00
9a5c65419d docs: 补充业务库库存流水单据表需求单
列出每日进销存 12 个流水列无法从业务库还原的具体原因、
缺失的 4 类单据表、以及已有采购/销售单据口径不一致的实测偏差,供 DBA 补齐。
2026-09-24 11:20:57 +08:00
9dbe2793a5 feat: 每日进销存改为全部从数据库取数
- 期初数量改由本地快照表 daily_inventory_snapshot 提供(上一业务日期末),
  每日 01:30 定时任务落库,接口侧缺失时补写兜底,逐日自动滚动
- 新增 dailyInventorySnapshotDb / dailyInventorySnapshotJob 与手动补跑脚本
- dailyInventoryBaseline 瘦身为纯仓库映射,去掉所有硬编码业务数值
- 12 个流水列因业务库缺单据表返回空值,前端灰底显示「—」
- 修复 qty 全为 0 的仓库期末显示 null 的缺陷
- 新增 docs/库存流水单据表需求.md 给 DBA
2026-09-24 11:20:30 +08:00
52b74aafe9 Merge pull request #31: 修正 MKH 已入库库存与加工在途
保留任务提交历史;验证 346 项后端测试通过。
2026-09-24 09:20:30 +08:00
134
4227d9001e fix: reconcile MKH received stock and work orders 2026-09-24 09:19:21 +08:00
db2362307d Merge pull request 'feat: 新增每日进销存(大货库存监控)' (#30) from codex/daily-inventory-0923 into main 2026-09-24 08:01:48 +08:00
e4b6bbcf31 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:41:03 +08:00
eaa8489600 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:41:02 +08:00
b57fc1ad5b feat: 新增每日进销存(大货库存监控) 2026-09-23 17:41:01 +08:00
cd936f5e92 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:41:00 +08:00
9e2f26b1c9 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:58 +08:00
39c7933d2d feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:57 +08:00
2bd5dce3d7 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:56 +08:00
9fd0bcaf4b feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:55 +08:00
9a77d15448 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:54 +08:00
fce269828f feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:53 +08:00
a90c2fd4f0 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:52 +08:00
e579f73560 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:51 +08:00
570666f772 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:50 +08:00
fb1b433f94 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:40:22 +08:00
23e576d26e feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:48 +08:00
d3f36d5359 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:47 +08:00
819cd08de2 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:45 +08:00
855586d5de feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:44 +08:00
8655433e39 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:42 +08:00
f6b755aac4 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:41 +08:00
700d7ae96d feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:40 +08:00
37c1adf4f2 feat: 新增每日进销存(大货库存监控) 2026-09-23 17:37:39 +08:00
d52151d7dc Merge pull request 'refactor: 移除大货库存监控功能(26 仓)' (#29) from codex/remove-large-stock-monitor-0923 into main 2026-09-23 16:12:50 +08:00
592c52f891 refactor: 移除大货库存监控功能(26 仓)
应用户要求下线该功能组,侧边栏不再显示「大货库存监控」及 26 个仓库二级菜单。

移除内容:
- 前端页面 LargeStockMonitor、18 列定义、API 封装与样式
- 路由 /planning/large-stock-monitor/:warehouseSlug 与权限点 large_stock_monitor
- 侧边栏菜单组 stock-monitor-large 与 SIDEBAR_ROUTE_MAP 26 条映射
- 后端 /api/large-stock-monitor 路由挂载、largeStockMonitor.js、
  largeStockMonitorConfig.js、largeStockMonitorFlow.js 及端到端校验脚本

验证:
- 全仓无 largeStockMonitor 残留引用;后端 346 用例 345 通过
  (1 项失败为 resilientSecretStore.test.js,Windows 权限语义基线既有失败)
- 前端 lint + 测试 + 构建通过,产物中 LargeStockMonitor chunk 已消失
- 运行时验证:/api/large-stock-monitor/* 返回 404,页面与登录不受影响
2026-09-23 16:07:04 +08:00
d4e6c6e4a2 Merge pull request 'fix: 配件库存用最新箱仓数据补齐' (#28) from codex/component-pack-stock-fallback-0923 into main 2026-09-23 15:43:53 +08:00
134
66e0ddf709 fix: 用箱仓库存补齐滞后的配件库存 2026-09-23 15:42:46 +08:00
2e52e4a393 Merge pull request 'feat: 新增大货库存监控(26 仓)' (#27) from codex/large-stock-monitor-0923 into main 2026-09-23 14:01:44 +08:00
dcf3375dbe Merge branch 'main' into codex/cangku-returns-monitor-001 (sync #25 #26) 2026-09-23 13:50:26 +08:00
2bc949099b feat: 新增大货库存监控(26 仓)
按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面
(路由 /planning/large-stock-monitor/:warehouseSlug)。

后端:
- 新增 largeStockMonitorConfig.js:26 仓配置,含 wms_co_id / 快照仓名 / routeKey / slug 映射
- 新增 largeStockMonitor.js:config / overview / sku-list 三个接口
- 新增 largeStockMonitorFlow.js:采购进仓、采购退货、销售出仓、销售退货四类单据流水取数
- 新增 verify-large-stock-monitor.js:26 仓端到端校验脚本
- index.js:挂载 /api/large-stock-monitor 路由

口径说明:
- 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%,下沉到 SQL CASE WHEN 计算
- 四级分级(滞销预警/慢流/呆滞/僵尸)按库龄 + 周转率双维度判定
- 期末数量取聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓),
  不用 pm_stockMonitor.jsStock —— 后者 warehouse 是「归属仓」口径,
  会把已调拨到外仓的货仍计在本仓名下(崔桥工厂仓因此虚高约 2.2 万)
- 截止时刻统一为快照日次日 01:00(跑批时点),库龄/分级均以此为基准,不再用 GETDATE()

前端:
- 新增 LargeStockMonitor 页面、18 列定义、API 封装与样式
- 路由与侧边栏:26 条二级菜单 + 权限点

冲突处理:
- 主仓库已下线 /api/dashboard(backend/src/api/dashboard.js 被移除),
  相应删除 dashboardRoutes 的 require 与挂载,避免后端启动时 require 失败崩溃
- 保留主仓库新增的「模块化库存监控」菜单组及其子项
2026-09-23 13:42:58 +08:00
254d1dd9f9 Merge pull request 'docs: 明确前端冒烟检查的预期鉴权响应' (#26) from codex/deploy-doc-frontend-smoke-0923 into main 2026-09-23 11:54:13 +08:00
134
181ea0237d docs: 修正文档排版 2026-09-23 11:52:32 +08:00
134
783bb5182b docs: 明确登录页预期鉴权响应 2026-09-23 11:52:07 +08:00
ac44a00a49 Merge pull request 'docs: 补充前端骨架屏部署验收规范' (#25) from codex/deploy-doc-frontend-smoke-0923 into main 2026-09-23 11:45:04 +08:00
134
b3b38d4aa2 docs: 增加前端部署运行时验收规则 2026-09-23 11:43:29 +08:00
acc088dd1a Merge branch 'main' into codex/cangku-returns-monitor-001 2026-09-23 11:39:39 +08:00
5034cc5782 Merge pull request 'fix: 根治前端循环分块导致的永久骨架屏' (#24) from codex/web-bootstrap-fix-0923 into main 2026-09-23 11:36:49 +08:00
134
d32494ca12 fix: 消除前端循环分块导致的骨架屏 2026-09-23 11:35:47 +08:00
b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00
6b0bf59e74 Merge pull request 'fix: MKH 刷新实时读取订单库存并阻止旧请求覆盖' (#23) from codex/mkh-sync-freshness-0923 into main 2026-09-23 11:18:13 +08:00
134
b8e60a19d2 fix: MKH刷新改用实时订单库存口径 2026-09-23 11:12:08 +08:00
134
fa91b9242c fix: 根治MKH同步后旧请求覆盖最新数据 2026-09-23 11:05:28 +08:00
97a93766e8 Merge pull request 'fix: MKH-BOM 配件仓位仓名改用 wms_co_id 关联,修正误显示「常州简尚家居有限公司」' (#22) from codex/mkh-pack-warehouse-join-0919 into main 2026-09-22 23:58:40 +08:00
f4303c0aed Merge pull request 'fix: 隧道断线自愈 + 修复配置负缓存永久固化(根治「隧道一断即整栈不可用 / 必须重启后端」)' (#21) from codex/tunnel-selfheal-config-cache-0922 into main 2026-09-22 23:58:24 +08:00
a58fc7d67b Merge pull request 'fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据' (#20) from codex/mkh-cache-stale-write-fix-0922 into main 2026-09-22 23:58:04 +08:00
bbabda5016 Merge pull request 'fix: MKH-BOM 预警与库存口径修复:日销以出库为准 + 库存增加箱表兜底' (#19) 2026-09-22 23:14:37 +08:00
38fa3f2127 fix: 隧道断线自愈 + 修复配置负缓存永久固化
远端 RST 会把隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。原先的
实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。

1) scripts/start-remote.mjs —— 隧道断了却拉着应用陪葬

   隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉,于是每一次 RST 都
   升级成一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。

   改为「退避重连隧道本身,后端/前端进程不受影响」——这正是 autossh 的语义
   (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数
   退避,建立成功即重置退避。

   顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s,
   以便更快发现已被对端 RST 的死连接。

2) backend/src/config/cloudDatabase.js —— 配置负缓存被永久固化

   系统库读取失败时把「空候选」写进缓存,而入口判断是
   `if (cachedCloudConfigCandidates)` —— [] 在 JS 里是 truthy,于是这次失败被
   永久固化:之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用,
   必须重启后端。

   改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整):
   入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。
   缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清
   TTL 时间戳而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完
   配置点测试能立即看到真实结果。

   顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂
   20 分钟,用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。

验证:
- 新增 backend/test/cloudDatabaseConfigCache.test.js,锁定负缓存三段语义
  (TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰);
  test/remote-access.test.mjs 补隧道保活参数、退避曲线,以及「自愈循环不得调用
  stopChildren」的防腐化断言。
- 两处均做对照组实验:去掉保护后测试如实报错(负缓存 → 报 TTL 到期未重试;
  隧道 → 把 stopChildren 加回去即被用例抓住),证明用例真能拦住回归。
- 后端全量 340 项 / 339 通过,唯一失败为 resilientSecretStore 的 Windows 权限
  断言(环境固有,与本次无关);check-syntax 通过。
- 本地实测:kill 掉隧道进程后 1 秒内自动重建(PID 36268 → 15816),后端进程
  PID 未变(29580)、/health 仍返回 200。
2026-09-22 19:32:40 +08:00
add148192d fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据
问题:点「同步最新数据」后,页面在 2~10 分钟内仍显示旧数据,用户表现为「像在用缓存」。

根因:本页 10 个进程内缓存都是「先挂 pending → await 结果 → 无条件写回」的写法。
若某个查询 in-flight 期间用户点了同步(clearMkhBomCache 清空全部缓存),那个**发起于
清缓存之前**的查询读到的仍是旧快照,返回时会无条件把旧值写回缓存,覆盖掉清缓存后新
请求填入的新值 —— 谁后完成谁生效。并发越高(多人同时使用、或先点刷新再点同步)越容
易撞上,且清缓存反而把旧数据「钉」在缓存里 2~10 分钟(各缓存 TTL),表现为按钮失灵。

修法:引入缓存代际 cacheEpoch。
- clearMkhBomCache() 先自增代际,再清各缓存;
- 每个查询发起时记下当时的代际,写回缓存前比对:代际对不上说明中途被清过缓存,
  本次结果只返回给发起它的那个请求,不写缓存。
覆盖 7 个变量型缓存(officialBomIndex / businessBom / productBom / mkhProduct /
workOrderIndex / orderIndex / replenishIndex)与 3 个按 key 的 Map 缓存
(officialBomDetail / componentLocation / componentStock)。
POST /api/mkh-bom/sync 响应新增 cacheEpoch 字段,便于线上核对清缓存是否生效。

验证:
- 新增 2 个用例:① 复现竞态——对照组(去掉写回保护)报 expected 1 / actual 9,正是
  「点同步后拿到旧值」;加保护后通过。② 同代际内缓存照常命中的回归保护。
- 后端全量 node --test test/*.test.js:340 用例 / 339 通过,唯一失败为环境固有的
  resilientSecretStore Windows 权限断言(与基线一致)。
- 本地端到端:预热 24.3s → 缓存 58ms;sync 返回 cacheEpoch=1;冷查 8.5s 确实重查;
  再查 37ms 缓存重新生效;数据指纹前后一致。
2026-09-22 18:39:09 +08:00
5e029f831f fix: MKH-BOM 库存增加箱表兜底,修复「当日入库当天显示库存为 0」
现象:MKHDNZ0409260700491 当天已入库、实物有货,但页面上「现有库存」一直是 0;
点「同步最新数据」也不变。

根因:页面唯一的库存源 rpt.jst_inventoryInfo 是「有变化才写 updateTime」的同步表,
品牌仓尤其稀疏。该 SKU 在 inventoryInfo 里只有主仓 14138566 的一行 qty=0,
品牌仓连行都没有;而小时级的箱表 rpt.jst_packInventoryInfo 里,
品牌仓 bin='P-1' / qty=4 / createTime 当天 15:19 —— 当日入库的货只有箱表看得见。
页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
「同步最新数据」按钮只清服务端缓存,不触发上游同步,所以点多少次都是 0。

改法(只补 0,不覆盖有效值):
  ① inventoryInfo 品牌仓 qty > 0        → 用它(stockSource='realtime',旧口径不变)
  ② 否则箱表品牌仓 bin 非空行合计 > 0    → 用它(stockSource='pack')
  ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0
  ④ 否则回退监控快照 jsStock             → stockSource='monitor'

为什么不拿箱表当主源:两表都 > 0 且不等的 28 个成品里 25 个是 inventoryInfo 更大
(箱表只记已装箱的货,散货不计),拿它当主源会系统性低估库存、误报缺货。
另外必须过滤 bin 为空的行——那是装箱记录不是库存(品牌仓合计 21 件)。

影响面(实测):品牌仓 4,408 个成品里只有 3 个走箱表兜底
(MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4,
同一箱 115870495000001201、货位 P-1、均为当天新建行),
预警列表 27 → 24 条(缺货 21 → 18),少的正是这 3 条「入库当天被误判缺货」。

前端:「现有库存」列 tooltip 增加 pack 来源说明(当天入库、实时库存表尚未同步)。

验证:check-syntax 151 文件通过;node --test 339/340(唯一失败是既有的
resilientSecretStore Windows 权限断言);mkhBomService.test.js 47/47;
HTTP 实测该 SKU stock=4 / stockSource=pack,点「同步最新数据」后仍正确。

遗留(需业务/上游跟进):inventoryInfo 当天 06:41 后就没再更新过
(09-17 / 09-18 都是全天逐小时更新),疑似上游同步任务异常;
箱表兜底只解决「当日入库看不见」,存量库存仍依赖该表恢复。
2026-09-19 18:11:38 +08:00
0e0fcbb378 fix: MKH-BOM 日销兜底增加出库校验,标了「已发货」但无实际出库的单不计销量
业务口径:标了「已发货」但没有实际出库的 SKU,不需要出现缺货预警。
即在 ⑦-a 的金额/标签判据之上,再以出库表为准判定「有没有真出货」。

已发货分支计入销量需同时满足(待发货分支不变):
  1. 非样品/测试店铺(⑦-a)
  2. 非异常单:pay_amount <= 0.01 或 labels 含「特殊单」(⑦-a)
  3. 出库表能查到该 o_id:rpt.jst_salesOutboundInfo / rpt.jst_qmcust_salesoutbound

两个必须豁免的情形(否则会大面积误杀真实单):
  · 拆分单(status='Split'):出库记录挂在子单上,父单必然查不到。
    实测窗口内 40 张 Split 单 100% 无出库记录;不豁免会误杀
    「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单
  · 今天发货的单:当天出库尚未同步完(09-19 全天出库表仅 1 行,09-18 有 14,690 行)。
    豁免只到「昨天」为止——09-18 的 18 张无出库 Sent 单 100% 是特殊单,
    且出库表 09-18 数据完整,豁免 09-18 会放过刷单
  豁免只免「出库校验」,金额/标签判据照样生效。

真实数据验收(近 15 天窗口,1,086 张已发货 MKH 单):
  · 出库表命中 889 张 → 197 张无出库 = 40 张拆分单 + 157 张 Sent
  · 157 张里 131 张是 ≤0.01 元或「特殊单」(即 ⑦-a 已排除的刷单),
    唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消/发货后售后/风控订单」)
  · 兜底生效 SKU = 12 个(与只按金额/标签判据一致),兜底日销最大值 = 0.2
  · 10 个异常单 SKU 在预警列表中零出现;总预警 27 条(缺货 21 + 仅预警 6)

性能:1086 个订单号分 2 块并行 UNION 查询,冷缓存约 0.4~1.4s;
      整窗口拉全量 DISTINCT o_id 实测要 8s,故不采用。

降级策略:出库数据取不到时整体跳过出库校验(退化成只按金额/标签判据)并标 degraded。
漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。

注意:jst_salesOutboundInfo.qty 全表 1,119,606 行恒为 0,只能判「有没有出库行」,
不能用 SUM(qty)(永远得 0)。

验证:check-syntax 151 文件通过;node --test 338/339(唯一失败是既有的
resilientSecretStore Windows 权限断言,与本改动无关);mkhBomService.test.js 45/45。
2026-09-19 17:53:16 +08:00
da08b03dcb fix: MKH-BOM 配件仓位仓名改用 wms_co_id 关联,修正误显示「常州简尚家居有限公司」
根因
- rpt.jst_packInventoryInfo.wh_id **不是** rpt.jst_warehouseInfo.Id:
  全表只有 6 个取值(1/2/3/4/6/7),90% 的行都是 wh_id=1,
  按 `w.Id = p.wh_id` 关联后,727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。
- 该表的仓 id 列是自带的 `wms_co_id`:
  09-19 实测 30,236 行 **0 NULL**、14 个仓取值,按 `w.wms_co_id = p.wms_co_id`
  关联即得到正确仓名「简尚-崔桥工厂半成品包装项目仓」。
- 旧结论「该表 wms_co_id 全表 NULL,必须用 wh_id」只对 09-17 的表状态成立
  (当时 85,152 行、只有 3 个箱、bin 全空、与 MKH 配件零交集);
  该表由上游**小时级增量刷新**(业务确认 09-19),09-18 重建后 wms_co_id 已全部有值。

改动
- backend/src/services/mkhBomService.js
  · loadComponentLocations() 的 JOIN 与 GROUP BY:p.wh_id → p.wms_co_id
  · 文件头 ⑤ 与 formatPackLocation() 注释更新为正确关联列,并记录该表的小时级
    增量刷新特性(行级 updateTime 只反映「有变化的行」,不能当表的更新频率;
    行数/箱数/命中数会随上游变化,不要写死)。
- backend/src/services/modularStockMonitorService.js
  · 补注:该页只取 bin、不 join 仓名,不受此关联列问题影响;命中数会随上游变化。
- backend/test/mkhBomService.test.js
  · 仓位格式化用例的 fixture 改回真实仓名,避免继续固化错误仓名。

影响与验证
- 仅影响 MKH-BOM 页「配件仓位」列与导出配件明细的仓名文本;
  A4 打印预览本就会去掉仓名前缀(stripWarehousePrefix),不受影响。
- 配件监控页不 join 仓名,无影响。
- 实测:修正后 15 个真实配件编码输出「简尚-崔桥工厂半成品包装项目仓 · BCP01-01-1」。
- 后端:check-syntax 151 文件通过;node --test 337/338 通过
  (唯一失败为既有环境问题 resilientSecretStore 权限断言,与本改动无关)。
2026-09-19 17:08:00 +08:00
dff2422b34 fix: MKH-BOM 日销兜底排除 0.01 元特殊单,修复「没发过货却有日销」
背景
- 业务反馈 MKHDNZ0410260700005 从没发过货,页面却显示日销 0.6 并报缺货。
- 根因是上一版日销兜底(⑦-a)只看 send_date,把「0.01 元特殊单」当成了真实销售:
  · 拼多多-亿家达书房家具官方旗舰店在 2026-09-18 10:35–10:36 **一分钟内**批量生成
    18 张 MKH 订单,实付合计仅 0.18 元、免额合计 16,344 元(单张:实付 0.01 元、
    商品额 908.01、免额 908 全额免),备注统一为 SSDD,标签含「特殊单」「转x00005」;
    同批还有 18 张商品码为占位码 1.X00005 的 0.01 元订单。
  · 这批单 status=Sent、有物流单号,但 **rpt.jst_salesOutboundInfo 里查不到对应出库记录**
    (同店同时段其他订单有 6,467 行出库)—— 即系统标了「已发货」,货并没有真出。
  · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警。

改动
- loadOrderIndex 的**已发货分支**增加异常单过滤:命中任一即不计入日销
  · pay_amount <= 0.01(0.01 元全免单)
  · labels 含「特殊单」
- 查询补充取出 o.pay_amount / o.labels(两张订单表均有这两列,已确认)。
- 金额取不到(null / 空串 / 非数字)时**不排除** —— 宁可少排也别误杀,取不到就退化成旧口径。
- ⚠️ **不使用 free_amount > 0 作为判据**:正常订单普遍带折扣免额(窗口内实测 569/1086 行
  free_amount > 0),按它筛会把实付 356 / 458 / 1338 元的正常单一并误杀(第一版即踩此坑)。
- 本次只作用于「已发货」分支,待发货(⑩)口径未改动。

验证
- 语法检查:151 文件通过。
- 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 权限断言(期望 0o600、
  Windows 实际 0o666),平台固有,与本改动无关。mkhBomService 单测 44/44。
- 新增回归断言:实付 0.01 / 0 元及字符串金额判为异常单;标签含「特殊单」判为异常单;
  **有免额的正常单不得被排除**;金额缺省/为 null/非数字时不排除。
- 订单索引用例补充:0.01 元单与「特殊单」标签行不进入 shippedBySku,而带折扣免额的
  正常单照常计入(shippedBySku.size 由 1 调整为 2)。
- 真实数据端到端(业务库实时,窗口 15 天):
  · 应排除的 10 个异常单 SKU(MKHDNZ0410260700001/0002/0004/0005/0007/0008/0009/
    0010/0011/0012)**全部已排除**;
  · 应保留的 12 个真实单 SKU **全部保留**(含本问题之外的 MKHDNZ0409260601548 等);
  · 兜底生效 SKU 由 **22 → 12**;兜底日销最大值由 **1.8000 → 0.2000**
    (0.2 来自 MKHDNZ0409260700483 的 3 件真实发货,属正常)。

风险与回退
- 只影响「快照日销 ≤ 0 且近 15 天有发货」的商品;快照 > 0 的商品行为完全不变。
- 实付 ≤ 0.01 元的订单在任何业务口径下都不构成正常销售,排除不会误伤真实需求。
- 回退:本提交可整体 revert,无表结构变更、无数据迁移。
- 遗留(未在本次改动内):待发货(⑩)分支同样可能被 0.01 元单驱动,
  是否一并排除需业务确认;「售后 / 补发」类订单是否计入亦有待定夺。
2026-09-19 14:49:39 +08:00
4946018251 Merge pull request 'fix: MKH-BOM 新增日销订单表兜底,修复上游快照漏算导致「库存 0 却不报缺货」' (#18) 2026-09-19 14:20:52 +08:00
3e64702f12 fix: MKH-BOM 新增日销订单表兜底,修复上游快照漏算导致「库存 0 却不报缺货」
背景
- 业务反馈 MKHDNZ0409260601548 近 15 天实发 2 件、库存 0,页面既不报缺货也不进任何预警。
- 根因是双重盲区:
  ① 上游 rpt.pm_stockMonitor.fifteenAvgSale 漏算 —— 快照 09-18 下,窗口内有真实发货的
     178 个 MKH 成品编码里有 24 个被记成 0;其中 16 个订单全部来自样品专用店铺(上游同样
     不计样品单,属合理口径),剩 8 个是真实店铺(天猫/京东/拼多多)。该列是**保留两位
     小数的日均**(最小非零 0.07 = 1 件 ÷ 15 天),无法用四舍五入解释。
  ② 快照日销 = 0 且待发货订单 = 0 时,判定直接落到「无销量」分支、hasWarning=false,
     彻底排除在预警之外 —— 库存为 0 也救不回来。

改动
- 日销兜底(服务文件头新增 ⑦-a):**快照日销 ≤ 0 且近 15 天有真实发货**时,日销改用
  订单表实算值 = 已发货件数 ÷ 15;快照 > 0 一律以上游为准,不覆盖,避免两套口径打架。
  · 排除样品/测试专用店铺(shop_name 含「样品」「测试」),与上游口径对齐
  · 窗口 = 含今天在内的 15 个自然日,与分母同口径
  · 结果透出 dailySaleSource('snapshot'|'orders') / snapshotDailySale / recentShippedQty
- 取数:loadPendingOrderIndex 重构为 loadOrderIndex,一条查询同时覆盖「待发货
  (send_date IS NULL)」与「近 15 天已发货(send_date 在窗口内)」。实测合并后总耗时
  约 1.25s ≈ 单查待发货,而拆成两条独立查询要在现有开销之上再加一次全表扫描
  (首轮冷执行计划下 qmcust 单表可劣化到 20s+)。
- PENDING_ORDER_TABLES 更名 ORDER_TABLES:两张表的口径没变,只是不再仅服务待发货。
- 前端:日销来自兜底时加虚线下划 + 悬停说明,业务拿该数字与源系统 fifteenAvgSale
  对照时不会被误判为数据错误。

验证
- 语法检查:151 文件通过。
- 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 的权限断言(期望 0o600、
  Windows 实际 0o666),平台固有,与本改动无关。
- 新增回归测试:兜底生效 / 快照非零不覆盖 / 无发货不兜底 / 与订单驱动的优先级 /
  订单源降级时退化成旧口径;订单索引测试补充「已发货同编码跨表累加」与「样品、测试店排除」断言。
- 真实数据端到端(本地接口 /api/mkh-bom/list,4,223 个成品):
  · MKHDNZ0409260601548:日销 0 → 0.1333(source=orders),不再隐身
  · 日销 > 0 的商品 177 → 198;有效预警(缺货 + 预警)18 → 28
  · 21 个等级变化**全部是「无销量 → 缺货/正常/已排产」**,没有任何既有预警被降级
  · 其中 10 个新缺货均为「库存 0 + 近 15 天真实发货 9~27 件」的真盲区
  · 样品店排除按整行生效:实测某编码窗口内 5 件(含样品店 1 件)→ 索引记为 4 件
- 前端 eslint 通过、vite build 通过。

风险与回退
- 只影响「快照日销 = 0」的商品,快照 > 0 的商品行为完全不变。
- 订单源不可用时兜底自动失效,退化成旧口径(不会误报)。
- 回退:本提交可整体 revert,无表结构变更、无数据迁移。
2026-09-19 13:24:15 +08:00
d650fd02f2 Merge pull request 'fix: MKH-BOM 待发货订单合并两张渠道表,修复天猫/淘宝订单漏报' (#17) 2026-09-19 13:06:11 +08:00
f156b90dbb fix: MKH-BOM 待发货订单合并两张渠道表,修复天猫/淘宝订单漏报
问题
  MKHDNZ0409260700059、MKHDNZ0409260601228 等成品明明有待发货订单,
  补货预警却显示 0 单、判为「不预警」,业务反馈到系统。

根因
  业务库的订单按渠道拆成**互不重叠的两张表**,而 loadPendingOrderIndex()
  只读了 rpt.jst_Orders(京东/京喜/拼多多/抖音/得物/唯品会),
  天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全部被漏掉**。

实测(2026-09-19,近 30 天窗口)
  - 两表店铺名交集 0(jst_Orders 398 店 vs qmcust 78 店),so_id 交集 5 条,
    o_id 交集 **0 条** —— 合并无重复计件风险;
  - jst_Orders 里 `shop_name LIKE '%天猫%'` = **0 行**
    (不是命名差异:同一品牌在不同平台是不同店铺名,按品牌名反查会误判);
  - MKH 成品待发货:仅 jst_Orders = 36 编码 / 50 件  →
    合并后 = **40 编码 / 56 件**,补回 4 个编码:
    MKHDNZ0409260700059 / MKHDNZ0409260601228 /
    MKHDNZ0409260601530 / MKHDNZ0409260700498。

修复
  - PENDING_ORDER_TABLE → PENDING_ORDER_TABLES(两张表,新增 qmcust 源);
  - Promise.allSettled 并行读取,按 o_id 去重后合并(去重为防御性逻辑);
  - 部分源失败时**保留成功源的数据**并透出 degraded=true,用短 TTL 尽快重试,
    不再像原先那样单源失败就整表降级为空索引(那会静默漏报全部需求);
  - 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 /
    分销导单专用店铺。

测试
  - 新增 2 个回归测试:两张表都读到 + 跨表同 o_id 只计一次 + 组合装不计入;
    单源失败时 degraded=true 且成功源数据保留。
  - 后端 337 项:336 通过,1 项失败为 Windows 平台固有差异
    (resilientSecretStore 断言 0o600,Windows 实际 0o666),与本改动无关。
  - 语法检查 151 文件通过。
2026-09-19 12:25:43 +08:00
cc66e5100f Merge pull request 'feat: MKH-BOM 补货预警(订单驱动 + 配件不足)与日销口径改近 15 天' (#16) 2026-09-18 17:56:13 +08:00
b7aca2343b feat(配件库存监控/MKH-BOM): 新增「采购在途」数量列与在途明细弹窗,补货预警改按结余库存判定
配件库存监控:
- 列表「实时库存」后新增「采购在途数量」列,口径 SUM(MAX(0, jst_purchaseItem.qty - inQty))
- 顶部「实时库存合计」后新增可点击「采购在途」卡片,弹窗展示在途明细
  (商品编码/商品名称/采购数量/已入库/在途数量/计划到货日期/采购单号),支持关键词、排序、分页
- 新增后端接口 GET /api/modular-stock-monitor/purchase-in-transit
- 筛选器品类改直查 jst_sku 主表,避免新增 OUTER APPLY 拖慢筛选
- 采购在途列仅取数展示,不参与 ORDER BY / 聚合(规避 MSSQL 对 OUTER APPLY 列的灾难性执行计划)

MKH-BOM 补货预警:
- 日销驱动与已排产分支改用「结余库存 = 现有库存 - 待发货件数」判定可用天数与预警等级
- 建议补货量按 ceil(3 天日销 + 待发货件数 - 现有库存) 计算
- 新增 scope=work-order 范围筛选;列表新增「结余库存」列
- 打印模板剥离公司名前缀,打印字号调大

测试:mkhBomService 单测新增结余库存缓冲/紧张用例与回归用例
2026-09-18 17:44:01 +08:00
be52bdd0b5 feat(配件库存监控/MKH-BOM): 两页面新增「同步最新数据」按钮
需求:配件库存监控、MKH-BOM 资料两个页面各加一个同步按钮,任何时候点击都能
立即拉取最新数据,不必等凌晨 00:00 的定时刷新。

后端:
- mkhBomService 新增 clearMkhBomCache():清空本页全部服务端缓存
  (businessBom / officialBomIndex / officialBomDetail / productBom /
  mkhProduct / replenishIndex / workOrderIndex / pendingOrderIndex /
  componentStock / componentLocation),返回 syncedAt + 各缓存清除条目数。
- modularStockMonitorService 新增 syncModularStock():本页无进程内缓存
  (每次请求直连副本库),同步动作 = 重新解析并返回最新监控日,前端据此重拉列表。
- 新增接口 POST /api/mkh-bom/sync、POST /api/modular-stock-monitor/sync。

前端:
- api/mkhBom.js 新增 syncMkhBom;api/modularStockMonitor.js 新增 syncModularStock。
- 两个页面工具栏各加「同步最新数据」主按钮(SyncOutlined,带 loading 态),
  点击 → 调后端 sync → 成功后重新拉列表(MKH-BOM 页同时清空展开明细缓存
  detailCache,避免看到旧的配件明细)→ 提示「已同步最新数据」;
  失败时展示后端返回的错误信息。

口径说明(重要):
- 两页数据来自副本库 JSReportReplica,由上游聚水潭同步;应用层无法触发上游拉取。
  本按钮的作用是「清服务端缓存 + 直连副本库重取最新」,保证看到的不是
  2~15 分钟前的缓存结果,而不是重新跑一遍上游同步。
- 后端已内置的 dailyRefresh(cron 0 0 * * *,Asia/Shanghai)刷的是
  summary_daily_* / summary_monthly_* 汇总表,不覆盖这两页数据源,
  与本按钮既不重复也不冲突,可并存。

验证:
- 后端 4 个改动文件 node --check 全部通过。
- 前端 4 个改动文件(含 2 个 jsx)用 @babel/parser 解析全部通过。
- 实测:syncModularStock 返回 monitorDate=2026-09-16;
  clearMkhBomCache 返回 syncedAt 与各缓存清除条目数。
- 单测 mkhBomService.test.js 40/40 通过,无回归。
- 前端 lint/build 未运行:frontend/node_modules 仍是之前被中断 npm ci 破坏的
  坏目录(frontend/node_modules_broken_0917/,未纳入提交)。
  合并前需在本机 npm ci 恢复后,打开两个页面确认按钮渲染与点击效果。
- 后端改动需重启服务后才生效。
2026-09-17 18:14:57 +08:00
bc8c46d5be 配件库存监控:未配置MKH 不进任何缺货/预警
业务确认(2026-09-17):未配置MKH 的配件不属于模块化体系,预警列一律显示「正常」,
既不报缺货(实时库存<=0)也不报预警(可用天数<=7)。

实现:
- 新增 buildWarningOrderSql() 统一表达式(0缺货/1预警/2正常),未配置MKH(cfg.configured=0) 直接判 2。
- 列表映射 mapRow 改用 SQL 算好的 warningOrder 推导 warningLevel(删除重复 normalizeWarningLevel,
  避免前后端各算一份导致排序与显示不一致)。
- 汇总计数 buildSummary.outOfStockCount / lowStockDaysCount 改用统一表达式,不再计入未配置MKH。
- 筛选 buildFilters:按预警等级筛选、onlyWarning=1(仅看缺货/预警)均排除未配置MKH。

实测(接口直跑,目标仓 794 SKU):
- 未配置MKH 95 个全部判「正常」(其中 55 个库存=0、58 个可用天数<=7,原会预警)。
- summary.outOfStockCount=13 / lowStockDaysCount=3,与已配置MKH 页面分布一致。
- onlyWarning=1 返回 16 条,0 条未配置MKH。
2026-09-17 16:41:25 +08:00
0bb7c5905d fix(模块化库存监控): 已配置MKH 改从聚水潭 SKU 级 BOM 维护(jst_skuBomItem)判定
业务反馈上一版「已配置MKH」数量不对(仅 29 个),原因是用了
jst_comboProductItem 组合装引用口径,漏掉了真正以 MKH 成品为父项的 BOM 配件。

修正:
- 数据源由 jst_comboProductItem 改为 rpt.jst_skuBomItem(聚水潭 SKU 级 BOM 明细,
  含配件商品编码 map_outer_sku_id)。
- 判定口径:目标仓商品编码出现在 jst_skuBomItem.map_outer_sku_id,且父项
  sku_id 以 'MKH' 开头(即被某 MKH 模块化成品 BOM 引用为配件) → 已配置MKH。
- 实测目标仓 794 个 SKU 中 699 个命中(map_outer_sku_id 匹配、父项 MKH 过滤),
  95 个未配置,合计 794,与接口返回 summary 一致。
- 同步前端状态列 tooltip 与页脚口径说明。
- 移除对「产品资料系统数据库商品档案」「jst_comboProductItem 组合装」两套旧口径的依赖。

注:pm_bomBaseInfo(聚水潭 BOM 维护) 的配件行无 SKU 编码列(仅中文品名/位号),
其组件 SKU 编码实际落在 jst_skuBomItem.map_outer_sku_id,故采用后者。
2026-09-17 16:20:17 +08:00
e6ea1246f9 配件库存监控:已配置MKH改为"被MKH模块化成品引用"判定
- backend/src/services/modularStockMonitorService.js
  - 「已配置MKH」判定口径切回 jst_comboProductItem 组合装引用:
    该 SKU 出现在 jst_comboProductItem.src_sku_id 且 combine_sku_id 以 'MKH' 开头
    即视为被 MKH 模块化成品(组合装/成品档案)引用为配件。
  - 移除产品资料系统数据库覆盖清单逻辑(resolveProductCoverage / productCoveredCsv),
    产品资料库仅保留作图片来源。
  - 实测目标仓 794 SKU 中 29 个命中(原产品资料库口径 661 已废弃)。
- frontend/src/pages/ModularStockMonitor/index.jsx
  - 状态列 tooltip 文案同步为「是否被 MKH 模块化成品作为配件引用」。

注:本改动会令已配置MKH数量从 661 降至 29(数据使然,非 bug)。
2026-09-17 16:01:20 +08:00
75a8795723 配件库存监控:表头"SKU编码"改为"商品编码"
- frontend/src/pages/ModularStockMonitor/index.jsx
  - 表格列头 "SKU编码" → "商品编码"
  - MKH 配置状态 tooltip 文案同步调整
  - 搜索框 placeholder "SKU 编码 / 名称" → "商品编码 / 名称"
- backend/src/services/modularStockMonitorService.js
  - 文件头注释同步更新(仅文案,无逻辑改动)

关于"从 BOM 维护匹配已配置 MKH 成品编码":
已探测 rpt.pm_bomBaseInfo 与目标仓 SKU 的编码体系完全脱钩
(sku_id/i_id/name 均 0 命中),若直接切换数据源会导致已配置数
从 661 归零。经用户确认,本次保留现有产品资料库判定口径,
仅做表头文案变更。
2026-09-17 15:44:30 +08:00
8ba052649d feat: 补货预警-日销为0有代发订单时按库存÷代发件数算可用天数预警
- ⑩订单驱动分支改为:可用天数 = 现有库存 ÷ 代发(待发货)订单件数
  · 可用天数 = 0(无库存)→ 缺货
  · 0 < 可用天数 < 3 天 → 预警(建议补到 3 天缓冲:3×代发件数−现有库存)
  · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致)
- buildReplenishMetrics 订单驱动分支不再把 stockDays 记 null,统一如实记录
- 同步前端「可用天数」列/导出/数据口径说明文案(不再显示「按订单」)
- 更新 mkhBomService.test.js 对应用例,单测 40/40 通过
2026-09-17 15:04:50 +08:00
3e02c3e393 feat: 日销口径由近 7 天改为近 15 天(MKH 补货预警 + 模块化库存监控)
业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销,
已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。

为什么没有 30 天(2026-09-17 实测,避免重复排查):
- rpt.pm_stockMonitor 只有 threeAvgSale(3天)/sevenAvgSale(7天)/fifteenAvgSale(15天),
  且整表只保留 1 天快照(2026-09-16),攒不出 30 天历史
- rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,新鲜到 09-15)
  里 MKH 商品 0 个;目标仓 794 个 SKU 命中也是 0
- rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
- rpt.jst_salesOutboundInfo 只有出库单头,无 SKU 明细;业务库无出库明细表
- rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice 没有销量

改动:
- backend/src/services/mkhBomService.js
  · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale',
    监控快照 SQL 按该常量插值取数(以后改口径只改常量)
  · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦)
  · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新
- backend/src/services/modularStockMonitorService.js
  · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d
    (字段名同步改,避免名不副实)
- frontend/src/pages/MkhBomData/index.jsx
  · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15
- frontend/src/pages/ModularStockMonitor/index.jsx
  · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改
  · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗

影响(同一份 09-16 快照,7 天 vs 15 天):
  有销商品  114 → 170   周期拉长,更多商品进得了「有销」集合
  补货预警   25 → 19    反而变少:15 天日销普遍低于 7 天(近期销量走低),
                         可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
  缺货        9 → 10
  已排产     11 → 13
  订单驱动   14 → 8     更多商品自己有日销,不再需要靠订单兜底
  配件不足   10 → 12
「预警变少」是口径变化的正常结果,不是 bug。

验证:
- 后端语法检查 151 个文件通过
- mkhBomService + mkhReplenishAlertJob 单测 52/52 通过
- 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的
  Windows 文件权限断言,与本改动无关的环境固有问题)
- 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 /
  saleWindowDays 15 / componentShortageCount 12
2026-09-17 14:06:50 +08:00
06952d8a04 Merge remote-tracking branch 'origin/main' into codex/mkh-alert-export-0917
origin/main 已前进 11 个提交(PR #11~#15),其中 #11「配件仓位只取箱及仓位库存表」
与 #12「从模块化成品列表排除配件档案」改动了与本分支相同的三个文件。

冲突处理(3 个文件,逐处保留双方意图,未使用 ours/theirs 批量取舍):
- backend/src/services/mkhBomService.js(4 处):取本分支侧。本分支的 ⑤~⑪ 注释与
  loadComponentLocations 是 origin/main 那段的超集,且双方都已删除
  buildInventoryWarehouseMap 回退链,语义一致;同时并入 origin/main 收紧后的
  MKH_WHERE(必须是 jst_skuBomItem 正式父项)与文件头 ① 的新口径说明。
- backend/test/mkhBomService.test.js(1 处):hunk 边界冲突,双方各缺一个 `});`。
  origin/main 新增的「无箱记录不回退仓库」测试按语义插在仓位测试之后,
  再接本分支新增的测试,两处闭合各自补回。
- frontend/src/pages/MkhBomData/index.jsx(2 处):合并双方文案。数据口径采用
  origin/main 的表述(正式 BOM 父项、仓位只取 rpt.jst_packInventoryInfo),
  并保留本分支的补货预警 / 待发货订单 / 在途加工单 / 配件不足四段说明。

顺带校正 mkhBomService.js 注释中因成品口径收紧而过期的数字
(成品总数 4408 → 见 ①,预警数 26 → 25),仅改注释,不动逻辑。

合并后重新验证:后端语法检查 151 个文件通过;mkhBomService + mkhReplenishAlertJob
单测 52/52 通过;接口实测 total 4223 / warningCount 25 / componentShortageCount 10。
2026-09-17 13:52:40 +08:00
f69d26796e feat: MKH-BOM 补货预警支持待发订单驱动与配件不足标记
补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:

1. 待发订单驱动
   - MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
   - 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
   - 列表新增待发货订单列,展示件数、单数与最早下单时间。

2. 配件不足
   - 对判定为缺货或预警的商品,按 BOM 计算可组装数:
     可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
   - 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
     建议补货量显示「—」,同时给出短板配件及其库存。
   - 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
     按业务确认不计采购在途。
   - 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
     不写入缓存,避免把瞬时故障固化为错误数据。

导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。

验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
2026-09-17 12:18:32 +08:00
a928135a63 Merge pull request 'fix: 兼容生产服务器旧版 Git' (#15)
Avoid unsupported git -C and branch --show-current commands in the production build script.
2026-09-17 11:56:10 +08:00
lunanting
5302b22fc1 fix: 兼容生产服务器旧版Git 2026-09-17 11:55:37 +08:00
fd89eb64d2 Merge pull request 'build: 使用兼容生产环境的前端构建流程' (#14)
Use standard Vite and an isolated Node.js 22 production build script verified against the deployed SHA.
2026-09-17 11:54:50 +08:00
lunanting
31a4b0aebb build: 使用兼容生产环境的前端构建流程 2026-09-17 11:54:03 +08:00
611165dded Merge pull request 'docs: 修正生产部署运行时命令' (#13)
Use commands compatible with the production Git and build the frontend under Node.js 22.
2026-09-17 11:46:14 +08:00
lunanting
e1c2ad1ea6 docs: 修正生产部署运行时命令 2026-09-17 11:45:37 +08:00
2b096ed2e2 Merge pull request 'fix: 从模块化成品列表排除配件档案' (#12)
Only designated warehouse SKU records that are official BOM parents appear as modular finished products.
2026-09-17 11:43:38 +08:00
lunanting
986174076f fix: 仅展示正式BOM父项商品 2026-09-17 11:42:50 +08:00
ecfd97787d Merge pull request 'fix: 配件仓位仅使用箱及仓位库存表' (#11)
Use rpt.jst_packInventoryInfo as the sole BOM location source and document explicit owner merge authorization.
2026-09-17 11:15:47 +08:00
lunanting
80028665c5 docs: allow explicit owner merge authorization 2026-09-17 11:14:46 +08:00
lunanting
3a87d8ecba fix: use pack inventory for BOM locations 2026-09-17 10:26:54 +08:00
897bbd2afd Merge remote-tracking branch 'origin/main' into codex/mkh-alert-export-0917
远端 6c00977(PR #10 "show MKH component warehouse locations")与本地的
通知推送 / 导出 / 在途加工单改动改到了同一批文件,手工合并如下:

配件的仓位与库存统一到 loadComponentLocationData(),列表展开、单品明细、
导出配件明细三处共用(新增 loadComponentLocations 作为兼容入口):

- 仓位四级兜底链:真货位 bin_group/bin → 箱号 pack_id → 库存正数量仓库
  (未分箱)→ jst_sku.other_3 商品档案仓(暂无箱号)
- 库存与仓位一次查询同时产出,两条查询都成功才写缓存(TTL 10 分钟)
- 修正远端版本的仓库关联列:仓名必须用 jst_warehouseInfo.Id = p.wh_id;
  jst_packInventoryInfo.wms_co_id 全表 NULL,用它关联 85,152 行全部取不到仓名。
  (库存表相反,必须用 w.wms_co_id = i.wms_co_id)
- 前端口径说明同时保留补货预警/在途加工单与新仓位口径
2026-09-17 10:06:26 +08:00
fe831c32fd feat: MKH-BOM 补货预警排除在途加工单、缺货通知推送、导出功能
- 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列
- 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键)
- 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量
- 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用)
2026-09-17 10:01:13 +08:00
6c0097753f Merge pull request 'fix: show MKH component warehouse locations' (#10) from codex/fix-bom-location-0917 into main 2026-09-17 09:52:44 +08:00
lunanting
006737e4ae fix: show MKH component warehouse locations 2026-09-17 09:39:25 +08:00
50202557d9 Merge pull request 'fix: restrict MKH products and use official BOM tables' (#9) from codex/bom-warehouse-filter-0916 into main 2026-09-16 21:03:57 +08:00
lunanting
1c72df7f36 Merge remote-tracking branch 'origin/main' into codex/bom-warehouse-filter-0916
# Conflicts:
#	backend/src/services/mkhBomService.js
2026-09-16 21:02:07 +08:00
4aed4d543d Merge pull request 'fix: 从包装库存读取 BOM 配件仓位' (#8) from codex/bom-pack-location-0916 into main 2026-09-16 21:00:01 +08:00
lunanting
8e5d427b3a fix: read MKH details from official BOM tables 2026-09-16 20:56:26 +08:00
lunanting
3bf7b9f208 fix: restrict MKH BOM list to designated warehouse 2026-09-16 18:02:28 +08:00
lunanting
65d009dcdc fix: read BOM locations from pack inventory 2026-09-16 17:52:49 +08:00
3fe0e55ad6 Merge pull request 'fix: show MKH BOM details in drill-down' (#7) from codex/fix-mkh-bom-drilldown into main 2026-09-16 16:58:54 +08:00
lunanting
3ccba5f460 fix: show MKH BOM details in drill-down 2026-09-16 14:57:05 +08:00
4c2ebef988 Merge pull request 'feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料' (#6) from yanhui/codex/yanhui-operation-modules-001 into main
经项目负责人 yanhui 授权合并(2026-09-16)。

基线 main: 8448f7a0e849ac04c7f572bb3fb1fa89765c0945
待合并提交: 445347e664a555cdc8bc2a0a7c488f111db2039e

验证:前端 lint 通过 / test 16-16 通过 / vite build 通过;
后端语法检查 149 文件通过、node --test 38-39 通过
(唯一失败 resilientSecretStore.test.js 为 Windows 文件权限语义差异,不涉及本次改动)。
2026-09-16 13:49:35 +08:00
445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00
8448f7a0e8 Merge pull request 'feat: support product information PostgreSQL config' (#5) from codex/add-product-information-postgres into main 2026-09-12 21:34:32 +08:00
lunanting
4b1e004b3c feat: support product information PostgreSQL config 2026-09-12 21:34:07 +08:00
d726dfd4cb Merge pull request 'docs: require database load and index validation' (#4) from codex/database-index-guidelines into main 2026-09-12 19:49:57 +08:00
lunanting
3c1895a9e2 docs: require database load and index validation 2026-09-12 19:48:57 +08:00
490c68f7d7 Merge pull request #3: remove commerce monitoring feature suites
Validated frontend, backend, remote-access and dashboard route checks.
2026-09-12 18:51:35 +08:00
lunanting
d9ebbba61e refactor: remove commerce monitoring feature suites 2026-09-12 18:49:37 +08:00
lunanting
36948c98ca refactor: remove store and operations feature suites 2026-09-12 17:50:26 +08:00
lunanting
1d87c3f94c docs: define safe production deployment workflow 2026-09-12 17:38:11 +08:00
lunanting
4f78a5b512 security: run managed Redis unprivileged 2026-09-12 17:33:26 +08:00
lunanting
e1772917ea fix: persist provisioned Redis ACL users 2026-09-12 17:29:24 +08:00
lunanting
4f48a20670 fix: avoid Redis INFO in restricted clients 2026-09-12 17:26:23 +08:00
lunanting
c4f14b0cdf fix: label Redis certificate mount for SELinux 2026-09-12 17:21:05 +08:00
lunanting
8c448bcd74 feat: provision managed Redis during enrollment 2026-09-12 17:13:59 +08:00
lunanting
86ab958911 security: restrict bootstrap state permissions 2026-09-12 16:58:00 +08:00
lunanting
3229c2a1c3 fix: use backend liveness health check 2026-09-12 16:55:48 +08:00
lunanting
a85acabde5 ops: add isolated production deployment 2026-09-12 16:53:18 +08:00
lunanting
088a24c3a9 fix: route bootstrap HTTPS through port 8443 2026-09-12 16:11:05 +08:00
lunanting
5689671312 fix: use DNS validation for managed TLS certificate 2026-09-12 16:08:57 +08:00
lunanting
584e0b4612 docs: define safe multi-user and AI contribution workflow 2026-09-12 16:06:04 +08:00
lunanting
3239d6a558 feat: bootstrap local instances from central system database 2026-09-12 16:06:01 +08:00
222 changed files with 39690 additions and 19624 deletions

11
.gitignore vendored
View File

@ -54,5 +54,16 @@ tmall-board-speed-check.json
**/.env.*
!**/.env.example
**/uploads/
.remote-access/
.remote-access.backup-*/
.workbuddy/
**/node_modules
# Local verification artifacts
/outputs/
.git-status.txt
# 本地一次性探查/调试产物(不进仓库)
_probe_*.json
_probe_*.js
_work-scratch-*/

15
AGENTS.md Normal file
View File

@ -0,0 +1,15 @@
# AI 开发协作入口
开始修改、提交、推送或合并前,必须阅读并遵守 [仓库提交与合并规范](./仓库提交与合并规范.md)。准备更新生产服务器时,还必须阅读并遵守 [服务器部署规范](./服务器部署规范.md)。这些文件是本仓库多人协作的具体流程,不可仅凭本摘要跳过全文。
- 每个任务使用独立目录和唯一任务分支,默认前缀 `codex/`;不得共享目录并行编辑。
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
- 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。
- 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。
这些文档不代表服务端保护已启用。任何 AI 工具无法自动读取规则时,由使用它的开发者主动提供全文。

View File

@ -2,33 +2,23 @@
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
## 本地启动
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
需要 Node.js 22。团队开发机器无需安装或初始化 MySQL、Redis 及第三方数据库;使用管理员发放的部署 Key,一次注册后自动连接中央系统数据库、公网 TLS Redis 和第三方数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。
```bash
npm --prefix backend ci
npm --prefix frontend ci
cp backend/.env.example backend/.env
npm run remote:dev
```
填写 `backend/.env` 中的连接信息与密钥,分别在两个终端启动:
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
```bash
cd backend
npm run dev
```
```bash
cd frontend
npm run dev
```
前端默认访问 http://localhost:5173,API 默认代理到 http://localhost:4001,可用 `VITE_API_PROXY_TARGET` 覆盖。首次启动按照安装与设置向导初始化系统。
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
## 验证与构建
@ -39,8 +29,10 @@ npm --prefix frontend run check
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
## 参与修改
开始开发前请阅读 [仓库提交与合并规范(多用户与 AI 开发必读)](./仓库提交与合并规范.md)。准备更新生产服务器时必须继续阅读 [服务器部署规范(开发者与 AI 必读)](./服务器部署规范.md)。AI 工具的规则入口为 [AGENTS.md](./AGENTS.md)。每个任务使用独立目录和分支,通过 PR、同步验证和独立审核合并,禁止强推或覆盖其他开发者的工作。
所有用户均可 Fork 仓库、创建分支并提交 Pull Request。直接推送本仓库需要仓库所有者授予协作者写权限。提交时请说明修改和验证结果,勿提交 `.env`、密码、令牌、数据库备份或真实业务数据。

5
backend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.env
npm-debug.log*
coverage
runtime

View File

@ -116,8 +116,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
REDIS_HOST=
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TLS=false
REDIS_TLS_SERVER_NAME=
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
REDIS_RUNTIME_PROFILE=stored
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234

View File

@ -0,0 +1,21 @@
FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
# 每日进销存定时截图需要 Chromium 与中文字体:
# · fonts-noto-cjk —— 否则截图里所有中文都会变成方块
# · playwright install --with-deps chromium —— 默认镜像不带浏览器
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& npx playwright install --with-deps chromium \
&& rm -rf /var/lib/apt/lists/* /root/.npm/_cacache
COPY . .
EXPOSE 4090
CMD ["node", "src/index.js"]

View File

@ -28,6 +28,7 @@
"multer": "^2.1.1",
"mysql2": "^3.6.5",
"node-cron": "^3.0.3",
"pg": "8.17.2",
"playwright": "^1.60.0",
"sharp": "^0.33.5",
"uuid": "^9.0.1",

View File

@ -10,6 +10,7 @@
"test": "node --test test/*.test.js",
"check": "npm run syntax && npm test",
"alert:check": "node src/services/alertChecker.js",
"inventory:import-official": "node scripts/importDailyInventoryOfficial.js",
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
},
"dependencies": {
@ -32,6 +33,7 @@
"multer": "^2.1.1",
"mysql2": "^3.6.5",
"node-cron": "^3.0.3",
"pg": "8.17.2",
"playwright": "^1.60.0",
"sharp": "^0.33.5",
"uuid": "^9.0.1",

View File

@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* 导入聚水潭「进销存按仓导出」到每日进销存校正表
*
* 用法:
* node scripts/importDailyInventoryOfficial.js <业务日期> <xlsx 路径>
* node scripts/importDailyInventoryOfficial.js 2026-09-26 ./进销存_按仓_20260926.xlsx
*
* 作用:
* 1) 解析导出文件,校验「期初 + Σ流水 = 期末」逐仓闭合
* 2) 写入本地表 daily_inventory_official(同一业务日整体覆盖)
* 3) 回写本地快照:官方期初 → 上一业务日期末、官方期末 → 该业务日期末
*
* 页面右上角「导入聚水潭进销存」走的是同一个后端接口(POST /api/daily-inventory/official-import)。
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { parseWorkbook, importOfficial, checkBalance } = require('../src/services/dailyInventoryOfficial');
const snapshotDb = require('../src/config/dailyInventorySnapshotDb');
const { DAILY_INVENTORY_BASELINE } = require('../src/config/dailyInventoryBaseline');
function prevDay(bizDate) {
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
async function main() {
const [bizDate, file] = process.argv.slice(2);
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) {
console.error('用法: node scripts/importDailyInventoryOfficial.js <业务日期 YYYY-MM-DD> <xlsx 路径>');
process.exit(1);
}
if (!file || !fs.existsSync(file)) {
console.error(`文件不存在: ${file}`);
process.exit(1);
}
const { rows, warnings, sheetName } = parseWorkbook(fs.readFileSync(file));
console.log(`解析 ${path.basename(file)} [${sheetName}]:${rows.length} 行,提示:${warnings.join(';') || '无'}`);
if (!rows.length) {
console.error('未解析到仓库行,已中止');
process.exit(1);
}
const mismatches = checkBalance(rows);
if (mismatches.length) {
console.warn(`⚠ 以下 ${mismatches.length} 个仓「期初 + 流水 ≠ 期末」:`);
mismatches.forEach((m) => console.warn(` ${m.warehouse}: 差 ${m.diff}`));
} else {
console.log('恒等式校验:期初 + 流水 = 期末,逐仓闭合 ✓');
}
const result = await importOfficial(bizDate, rows);
console.log(`写入 official:${result.imported} 行(${bizDate})`);
if (result.unknownWarehouses.length) {
console.warn(`⚠ 未匹配到仓库 ID(仅按仓库名存档):${result.unknownWarehouses.join('、')}`);
}
if (result.missingWarehouses.length) {
console.warn(`⚠ 清单中未出现在导出里的仓库:${result.missingWarehouses.join('、')}`);
}
const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b]));
const known = rows.filter((r) => r.wmsCoId != null);
const prevDate = prevDay(bizDate);
const prev = await snapshotDb.fetchSnapshot(prevDate);
const prevCount = await snapshotDb.upsertSnapshot(prevDate, known
.filter((r) => r.openingQty != null)
.map((r) => ({
wmsCoId: r.wmsCoId,
warehouse: r.warehouse,
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
closingQty: Number(r.openingQty),
skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0),
takenAt: null,
})));
const current = await snapshotDb.fetchSnapshot(bizDate);
const currentCount = await snapshotDb.upsertSnapshot(bizDate, known
.filter((r) => r.closingQty != null)
.map((r) => ({
wmsCoId: r.wmsCoId,
warehouse: r.warehouse,
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
closingQty: Number(r.closingQty),
skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0),
takenAt: null,
})));
console.log(`快照回写:${prevDate}(官方期初 → 期末)${prevCount} 行,${bizDate}(官方期末)${currentCount} 行`);
process.exit(0);
}
main().catch((e) => {
console.error('导入失败:', e.message);
process.exit(1);
});

View File

@ -0,0 +1,25 @@
/**
* 手动同步一次每日库存快照
*
* 用途:补跑 / 排障 / 挂到系统计划任务(Windows 任务计划程序、Linux crontab)。
* 业务库没有按天的历史库存结存表,本脚本把 26 仓实时库存落一份到本地快照表,
* 使「今日期初 = 上一业务日期末」能够自动滚动。
*
* 运行:node scripts/sync-daily-inventory-snapshot.cjs
* 业务日自动取库存快照库 rpt.pm_stockMonitor 的最新日期(T-1)。
*
* 生产环境下后端每日 01:30 的定时任务(src/jobs/dailyInventorySnapshotJob.js)会自动执行,
* 本脚本仅作为兜底与手动补跑手段。
*/
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const { syncOnce } = require('../src/jobs/dailyInventorySnapshotJob');
syncOnce('manual')
.then((result) => {
console.log('[快照同步] 结果:', JSON.stringify(result));
})
.catch((error) => {
console.error('[快照同步] 失败:', error.message);
})
.finally(() => process.exit(0));

View File

@ -0,0 +1,153 @@
'use strict';
/**
* 用业务库(JSReportReplica)反算流水,校验已导入的聚水潭官方进销存数据。
* 用法(先起隧道): cd backend && node scripts/validateDailyInventoryFromBiz.js 2026-10-06
*
* 注意:业务库 jst_inventoryInfo 是 T-1 同步快照,只能精确校验「昨天」的期末;
* 更早的历史日会带自然变动(实测 10-05 差 0.1%~1.2%),属正常。
*/
const mysql = require('mysql2/promise');
const sql = require('mssql');
const BIZ_DATE = process.argv[2] || '2026-10-06';
const SYS = {
host: '127.0.0.1',
port: 13307,
user: 'cangku_67c48c6660be787f',
password: 'wIJugzi26RQHF4J4BgnwxCxxecp157goR93M_IXEhqc',
database: 'shujuku2_system',
};
// 业务库能还原的 4 个流水列(与 dailyInventoryFlows.js 口径一致,这里加单日上界)
const FLOWS = [
{
field: 'purchase_in',
label: '采购进仓',
sql: `SELECT p.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(pi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_purchase p JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
WHERE p.wms_co_id IN (%W%) AND p.po_date >= @from AND p.po_date < @to
AND ISNULL(p.status,'') IN ('Confirmed','Finished') GROUP BY p.wms_co_id`,
},
{
field: 'purchase_return',
label: '采购退货',
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_purchaseout o JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
WHERE o.wms_co_id IN (%W%) AND o.io_date >= @from AND o.io_date < @to
GROUP BY o.wms_co_id`,
},
{
field: 'sales_out',
label: '销售出仓',
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_Orders o JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id
WHERE o.wms_co_id IN (%W%) AND o.send_date >= @from AND o.send_date < @to
AND ISNULL(o.status,'') NOT IN ('Delete','Cancelled') GROUP BY o.wms_co_id`,
},
{
field: 'sales_return',
label: '销售退货',
sql: `SELECT r.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(ri.r_qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_returnAndRefundInfo r JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
WHERE r.wms_co_id IN (%W%) AND r.as_date >= @from AND r.as_date < @to
AND ISNULL(r.type,'') IN (N'普通退货',N'拒收退货',N'换货',N'维修') GROUP BY r.wms_co_id`,
},
];
(async () => {
const sys = await mysql.createConnection(SYS);
const [official] = await sys.query(
'SELECT wms_co_id, warehouse, opening_qty, closing_qty, purchase_in, purchase_return, sales_out, sales_return FROM daily_inventory_official WHERE DATE(biz_date)=? ORDER BY closing_qty DESC',
[BIZ_DATE]
);
if (!official.length) {
console.log('官方表无 ' + BIZ_DATE + ' 数据');
await sys.end();
return;
}
const ids = official.map((r) => String(r.wms_co_id)).filter(Boolean);
const clause = ids.map((_, i) => '@w' + i).join(',');
const wparams = {};
ids.forEach((v, i) => (wparams['w' + i] = v));
// 业务库凭据从系统库 database_configs 现取(不落盘)
const [cfgRows] = await sys.query(
"SELECT host, port, database_name, username, password FROM database_configs WHERE name='cloud_database_public' LIMIT 1"
);
const biz = cfgRows[0];
await sql.connect({
server: biz.host,
port: Number(biz.port),
database: biz.database_name,
user: biz.username,
password: biz.password,
options: { encrypt: false, trustServerCertificate: true },
connectionTimeout: 20000,
requestTimeout: 180000,
});
const from = BIZ_DATE + ' 00:00:00';
const d = new Date(BIZ_DATE + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 1);
const to = d.toISOString().slice(0, 10) + ' 00:00:00';
const computed = {};
for (const f of FLOWS) {
const req = new sql.Request();
req.input('from', sql.DateTime, new Date(from));
req.input('to', sql.DateTime, new Date(to));
ids.forEach((v, i) => req.input('w' + i, sql.NVarChar, v));
const r = await req.query(f.sql.replace('%W%', clause));
r.recordset.forEach((row) => {
computed[String(row.wmsCoId)] = computed[String(row.wmsCoId)] || {};
computed[String(row.wmsCoId)][f.field] = Number(row.qty) || 0;
});
}
console.log('业务库口径反算 vs 聚水潭官方导出 —— ' + BIZ_DATE);
console.log('(业务库仅能还原 4/12 列;采购退货/销售出仓为流出,官方值取绝对值比较)\n');
const pad = (s, n) => String(s).padEnd(n);
console.log(
pad('仓库', 26) + pad('采购进仓 官方/业务库', 26) + pad('采购退货 官方/业务库', 26) + pad('销售出仓 官方/业务库', 26) + '销售退货 官方/业务库'
);
let diffs = { purchase_in: 0, sales_out: 0, purchase_return: 0, sales_return: 0 };
for (const o of official) {
const c = computed[String(o.wms_co_id)] || {};
const cells = ['purchase_in', 'purchase_return', 'sales_out', 'sales_return'].map((k) => {
const ov = Math.abs(Number(o[k]) || 0);
const cv = Math.abs(Number(c[k]) || 0);
if (Math.abs(ov - cv) > 0.005) diffs[k]++;
return pad(ov + ' / ' + cv, 26);
});
console.log(pad(o.warehouse, 26) + cells.join(''));
}
console.log('\n不一致仓数:' + JSON.stringify(diffs) + ' / 共 ' + official.length + ' 仓');
// 期末 vs 业务库实时库存(仅作量级参考,业务库只有实时库存)
const req2 = new sql.Request();
ids.forEach((v, i) => req2.input('w' + i, sql.NVarChar, v));
const inv = await req2.query(
`SELECT wms_co_id AS wmsCoId, SUM(CAST(ISNULL(qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_inventoryInfo WHERE wms_co_id IN (${clause}) GROUP BY wms_co_id`
);
const invMap = {};
inv.recordset.forEach((r) => (invMap[String(r.wmsCoId)] = Number(r.qty) || 0));
console.log('\n【参考】官方' + BIZ_DATE + '期末 vs 业务库实时库存(业务库无历史快照,仅看量级)');
official
.slice()
.sort((a, b) => Number(b.closing_qty) - Number(a.closing_qty))
.slice(0, 8)
.forEach((o) => {
const live = invMap[String(o.wms_co_id)] || 0;
const off = Number(o.closing_qty) || 0;
const pct = off ? (((live - off) / off) * 100).toFixed(1) : '-';
console.log(pad(o.warehouse, 26) + '期末 ' + off + ' | 实时 ' + live + ' | 差 ' + pct + '%');
});
await sql.close();
await sys.end();
})().catch((e) => {
console.log('FAIL', e.message);
process.exit(1);
});

View File

@ -1,14 +1,14 @@
const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures');
const {
isRetiredDatabaseConfigName,
isRetiredSystemConfigKey,
pruneRetiredSidebarConfig,
} = require('../services/retiredFeatures');
// 管理员 API
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const dayjs = require('dayjs');
const fs = require('fs');
const multer = require('multer');
const os = require('os');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { requireRole } = require('../middleware/auth');
const database = require('../config/database');
@ -25,17 +25,7 @@ const {
normalizeStorageClass,
testObjectStorageConnection,
uploadBase64ImageToDefaultS3,
uploadFileToDefaultS3,
} = require('../services/objectStorage');
const {
compareVersions,
getLatestDesktopRelease,
listDesktopReleases,
normalizeArchitecture,
normalizePlatform,
publishDesktopRelease,
validateVersion,
} = require('../services/desktopReleaseService');
const { listOnlineDesktopUsers } = require('../services/desktopPresenceService');
const {
getSystemDbConfig,
@ -108,10 +98,6 @@ const {
testSelectionFissionAiConfig,
} = require('../services/selectionFissionAiSettings');
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
const {
getProductTagConfig,
saveProductTagConfig,
} = require('../services/productTagConfigService');
const backfillJobs = new Map();
const productCodeImportUpload = multer({
@ -129,44 +115,6 @@ const productCodeImportUpload = multer({
},
});
const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases');
fs.mkdirSync(desktopReleaseTempDir, { recursive: true });
const desktopReleaseUpload = multer({
dest: desktopReleaseTempDir,
limits: {
fileSize: 300 * 1024 * 1024,
files: 1,
},
fileFilter: (_req, file, callback) => {
if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) {
callback(null, true);
return;
}
callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包'));
},
});
const uploadDesktopReleaseInstaller = (req, res, next) => {
desktopReleaseUpload.single('installer')(req, res, (error) => {
if (!error) return next();
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' });
}
return res.status(400).json({ success: false, error: error.message || '安装包上传失败' });
});
};
async function sha256File(filePath) {
const hash = crypto.createHash('sha256');
await new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex');
}
const runBackfill = async (job) => {
try {
const totalStepsPerDay = 21;
@ -208,93 +156,6 @@ router.get('/desktop-presence', async (_req, res) => {
}
});
router.get('/desktop-releases', async (req, res) => {
try {
const releases = await listDesktopReleases(req.query.limit);
res.json({
success: true,
data: releases.map((release) => ({
...release,
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
})),
});
} catch (error) {
console.error('获取桌面客户端发布记录失败:', error);
res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' });
}
});
router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => {
const tempFilePath = req.file?.path;
try {
if (!req.file) {
return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' });
}
const version = validateVersion(req.body?.version);
const originalName = String(req.file.originalname || '');
const platform = normalizePlatform(req.body?.platform, originalName);
const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform);
const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe';
if (!originalName.toLowerCase().endsWith(expectedExtension)) {
return res.status(400).json({
success: false,
error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式',
});
}
const latest = await getLatestDesktopRelease(platform, architecture);
if (latest && compareVersions(version, latest.version) < 0) {
return res.status(409).json({
success: false,
error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`,
});
}
const fileName = platform === 'darwin'
? `pdd-auto-sale-${version}-${architecture}.dmg`
: `pdd-auto-sale-${version}-setup.exe`;
const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`;
const mimeType = platform === 'darwin'
? 'application/x-apple-diskimage'
: 'application/vnd.microsoft.portable-executable';
const [sha256, uploadResult] = await Promise.all([
sha256File(tempFilePath),
uploadFileToDefaultS3(tempFilePath, {
key: objectKey,
mimeType,
}),
]);
const release = await publishDesktopRelease({
version,
platform,
architecture,
releaseNotes: String(req.body?.releaseNotes || '').trim(),
objectKey: uploadResult.key,
fileName,
fileSize: req.file.size,
sha256,
publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null,
publishedByName: req.user?.name || req.user?.username || req.user?.email || '',
});
res.status(201).json({
success: true,
message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`,
data: {
...release,
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
},
});
} catch (error) {
console.error('发布桌面客户端失败:', error);
const status = /版本号格式/.test(error.message) ? 400 : 500;
res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' });
} finally {
if (tempFilePath) {
await fs.promises.unlink(tempFilePath).catch(() => {});
}
}
});
router.get('/cache-performance', async (_req, res) => {
const dataPayloadStats = getStatsSnapshot();
res.json({
@ -1350,40 +1211,6 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req,
// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ====================
router.get('/product-tag-config', async (_req, res) => {
try {
res.json({ success: true, data: await getProductTagConfig() });
} catch (error) {
console.error('获取商品标签配置失败:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '获取商品标签配置失败',
});
}
});
router.put('/product-tag-config', async (req, res) => {
try {
const result = await saveProductTagConfig(req.body || {});
const config = await getProductTagConfig();
res.json({
success: true,
message: '商品标签配置已保存到系统数据库',
data: { ...config, saved: result },
});
} catch (error) {
console.error('保存商品标签配置失败:', error);
// 校验类错误回 400,方便前端直接展示原因
const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500);
res.status(status).json({
success: false,
error: error.message || '保存商品标签配置失败',
});
}
});
// ==================== 1688 专用以图搜图 ====================
@ -1622,12 +1449,10 @@ router.post('/work-order-config/test', async (req, res) => {
const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database';
const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public';
const PLANNING_DATABASE_CONFIG_NAME = 'planning_database';
const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database';
const PROTECTED_DATABASE_CONFIG_NAMES = new Set([
DEFAULT_DATABASE_CONFIG_NAME,
CLOUD_DATABASE_PUBLIC_CONFIG_NAME,
PLANNING_DATABASE_CONFIG_NAME,
AI_BUYER_SHOW_DATABASE_CONFIG_NAME,
]);
function normalizeDatabaseConfigName(rawName) {
@ -1640,6 +1465,11 @@ function normalizeDatabaseConfigName(rawName) {
error.statusCode = 400;
throw error;
}
if (isRetiredDatabaseConfigName(configName)) {
const error = new Error('该功能已下线,不能创建对应数据库配置');
error.statusCode = 410;
throw error;
}
return configName;
}
@ -1649,6 +1479,13 @@ function getDefaultPortByDriver(driver) {
return 3306;
}
function normalizeDatabaseDriver(driver) {
const normalized = String(driver || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'postgres') return 'postgres';
if (normalized === 'mssql') return 'mssql';
return 'mysql';
}
async function getStoredDatabaseConfig(configName) {
const normalizedName = normalizeDatabaseConfigName(configName);
let configs = await database.local.query(
@ -1787,7 +1624,9 @@ router.put('/database', async (req, res) => {
}
// 根据数据库类型设置默认端口
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
const resolvedDriver = normalizeDatabaseDriver(
driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql')
);
const defaultPort = getDefaultPortByDriver(resolvedDriver);
const configData = {
@ -1881,6 +1720,8 @@ router.post('/database/test', async (req, res) => {
return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' });
}
driver = normalizeDatabaseDriver(driver);
if (driver === 'mssql') {
// SQL Server 连接测试
const sql = require('mssql');
@ -1900,6 +1741,26 @@ router.post('/database/test', async (req, res) => {
const pool = await sql.connect(config);
await pool.request().query('SELECT 1 as test');
await pool.close();
} else if (driver === 'postgres') {
const { Client } = require('pg');
const client = new Client({
host,
port: port || 5432,
database: dbName,
user: username,
password,
ssl: ssl ? { rejectUnauthorized: false } : false,
connectionTimeoutMillis: 10000,
query_timeout: 10000,
statement_timeout: 10000,
});
try {
await client.connect();
await client.query('SELECT 1 AS test');
} finally {
await client.end().catch(() => {});
}
} else {
// MySQL 连接测试
const mysql = require('mysql2/promise');

View File

@ -1,397 +0,0 @@
const express = require('express');
const multer = require('multer');
const axios = require('axios');
const {
buildDefaultPrompt,
createAiBuyerShowJob,
generateAiBuyerShowReviews,
getAiBuyerShowHistoryDetail,
getAiBuyerShowJob,
getQueueStats,
getWorkerRuntimeStatus,
listAiBuyerShowHistory,
listAiBuyerShowJobs,
testAiBuyerShowDatabase,
} = require('../services/aiBuyerShowService');
const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService');
const {
createSeedPack,
deleteSeedPack,
getSeedPackDownload,
listSeedPacks,
MAX_SEED_PACK_FILE_BYTES,
} = require('../services/aiBuyerShowSeedPackService');
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 20 * 1024 * 1024,
files: 10,
},
fileFilter: (_req, file, callback) => {
if (String(file.mimetype || '').startsWith('image/')) {
callback(null, true);
return;
}
callback(new Error('仅支持上传图片文件'));
},
});
const uploadFields = upload.fields([
{ name: 'productImages', maxCount: 5 },
{ name: 'sceneReferenceImages', maxCount: 4 },
]);
function runUpload(req, res, next) {
uploadFields(req, res, (error) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).json({ success: false, error: '图片不能超过 20MB' });
return;
}
res.status(400).json({ success: false, error: error.message || '图片上传失败' });
});
}
const seedPackUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_SEED_PACK_FILE_BYTES,
files: 1,
},
fileFilter: (_req, file, callback) => {
const isZip = String(file.mimetype || '') === 'application/zip'
|| String(file.mimetype || '') === 'application/x-zip-compressed'
|| /\.zip$/i.test(String(file.originalname || ''));
if (isZip) {
callback(null, true);
return;
}
callback(new Error('种菜资源包仅支持上传 ZIP 文件'));
},
}).single('file');
function runSeedPackUpload(req, res, next) {
seedPackUpload(req, res, (error) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' });
return;
}
res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' });
});
}
function normalizeList(value) {
const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/);
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
}
function isAllowedImageUrl(value) {
try {
const parsed = new URL(String(value || ''));
return ['http:', 'https:'].includes(parsed.protocol) && (
parsed.hostname.endsWith('aliyuncs.com')
|| parsed.hostname.endsWith('volces.com')
|| parsed.hostname.endsWith('jiansuntuzhi.cn')
|| parsed.hostname.endsWith('yydd.ai')
|| parsed.hostname.endsWith('jiansun.vip')
);
} catch (_) {
return false;
}
}
router.get('/runtime', async (_req, res) => {
try {
const databaseStatus = await testAiBuyerShowDatabase();
const queue = databaseStatus.ok ? await getQueueStats() : null;
const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : {
enabled: false,
hasApiKey: false,
status: 'database_unavailable',
source: 'job-table-observation',
lastActivityAt: null,
lastError: '',
};
res.json({
success: true,
data: {
database: databaseStatus,
queue,
worker,
},
});
} catch (error) {
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀运行状态查询失败',
data: {
database: {
ok: false,
missingTables: [],
},
},
});
}
});
router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => {
try {
const body = req.body || {};
res.json({
success: true,
data: {
prompt: body.review
? buildBuyerShowPrompt({
review: body.review,
analysis: body.analysis || {},
style: body.buyerShowStyle,
})
: buildDefaultPrompt(body),
},
});
} catch (error) {
res.status(500).json({ success: false, error: error.message || '提示词预览失败' });
}
});
router.post('/reviews', runUpload, async (req, res) => {
try {
const body = req.body || {};
const result = await generateAiBuyerShowReviews({
productName: body.productName || body.spu,
spu: body.spu || body.productName,
scene: body.scene,
userType: body.userType,
sellingPoints: body.sellingPoints,
useEmoji: body.useEmoji,
reviewTotal: body.reviewTotal,
productImageFiles: req.files?.productImages || [],
}, req.user || {});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] generate reviews failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '攻心评价生成失败',
});
}
});
router.post('/jobs', runUpload, async (req, res) => {
try {
const body = req.body || {};
const result = await createAiBuyerShowJob({
productName: body.productName || body.spu,
spu: body.spu || body.productName,
scene: body.scene,
userType: body.userType,
sellingPoints: body.sellingPoints,
reviewText: body.reviewText,
generationId: body.generationId,
reviewRecordId: body.reviewRecordId,
prompt: body.prompt,
negativePrompt: body.negativePrompt,
buyerShowStyle: body.buyerShowStyle,
imageCount: 5,
aspectRatio: body.aspectRatio,
imageSize: body.imageSize,
productImageUrls: normalizeList(body.productImageUrls),
sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls),
productImageFiles: req.files?.productImages || [],
sceneReferenceImageFiles: req.files?.sceneReferenceImages || [],
}, req.user || {});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] create job failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务创建失败',
});
}
});
router.get('/jobs', async (req, res) => {
try {
const result = await listAiBuyerShowJobs({
page: req.query.page,
pageSize: req.query.pageSize,
spu: req.query.spu,
includeTotal: req.query.includeTotal !== '0',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] list jobs failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务列表查询失败',
});
}
});
router.get('/history', async (req, res) => {
try {
const data = await listAiBuyerShowHistory({
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
});
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] list history failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '历史生成记录加载失败',
});
}
});
router.get('/history/:id', async (req, res) => {
try {
const data = await getAiBuyerShowHistoryDetail(req.params.id);
if (!data) {
res.status(404).json({ success: false, error: '历史生成记录不存在' });
return;
}
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] get history failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '历史生成详情加载失败',
});
}
});
router.get('/image-proxy', async (req, res) => {
const imageUrl = String(req.query.url || '').trim();
if (!isAllowedImageUrl(imageUrl)) {
res.status(400).json({ success: false, error: '不支持的图片地址' });
return;
}
try {
const response = await axios.get(imageUrl, {
responseType: 'stream',
timeout: 120000,
maxContentLength: 30 * 1024 * 1024,
headers: { 'User-Agent': 'Mozilla/5.0' },
});
res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream');
res.set('Cache-Control', 'private, max-age=300');
response.data.pipe(res);
} catch (error) {
res.status(error.response?.status || 502).json({
success: false,
error: error.message || '图片下载失败',
});
}
});
router.get('/jobs/:jobId', async (req, res) => {
try {
const job = await getAiBuyerShowJob(req.params.jobId);
if (!job) {
res.status(404).json({ success: false, error: '任务不存在' });
return;
}
res.json({ success: true, data: job });
} catch (error) {
console.error('[ai-buyer-show] get job failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务查询失败',
});
}
});
router.get('/queue-stats', async (req, res) => {
try {
const data = await getQueueStats(req.query.jobId || '');
res.json({ success: true, data });
} catch (error) {
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀队列统计查询失败',
});
}
});
router.post('/seed-packs', runSeedPackUpload, async (req, res) => {
try {
const body = req.body || {};
const data = await createSeedPack({
shopName: body.shopName,
productId: body.productId,
skuName: body.skuName,
fileName: body.fileName || req.file?.originalname,
reviewCount: body.reviewCount,
file: req.file,
}, req.user || {});
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] create seed pack failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包上传失败',
});
}
});
router.get('/seed-packs', async (req, res) => {
try {
const data = await listSeedPacks({
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
});
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] list seed packs failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包列表查询失败',
});
}
});
router.get('/seed-packs/:id/download', async (req, res) => {
try {
const data = await getSeedPackDownload(req.params.id);
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] get seed pack download failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包下载信息获取失败',
});
}
});
router.delete('/seed-packs/:id', async (req, res) => {
try {
const data = await deleteSeedPack(req.params.id);
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] delete seed pack failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包删除失败',
});
}
});
module.exports = router;

View File

@ -0,0 +1,73 @@
'use strict';
const express = require('express');
const {
getReplenishExportRows,
getReplenishList,
} = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' },
{ key: 'productName', title: '品名' },
{ key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' },
{ key: 'orderQty', title: '待发订单' },
{ key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' },
{ key: 'shortfall', title: '存储缺口' },
];
/** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
router.get('/list', async (req, res, next) => {
try {
const payload = await getReplenishList({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
page: req.query.page,
pageSize: req.query.pageSize,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/export —— 服务端全量导出 */
router.get('/export', async (req, res, next) => {
try {
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const payload = await getReplenishExportRows({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
});
const fileName = `${payload.warehouse.shortName}待补明细`;
sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title),
rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
});
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } });
});
module.exports = router;

View File

@ -1,93 +0,0 @@
'use strict';
const express = require('express');
const {
getGoods,
getChatDataMeta,
getCustomers,
getMessages,
getProductProfile,
startAnalysisJob,
getAnalysisJob,
runAnalysis,
listHistory,
getHistoryDetail,
} = require('../services/csQualityService');
const router = express.Router();
function sendError(res, error, fallback = '客服质检请求失败') {
const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500);
console.error('[CsQuality]', {
message: error?.message || fallback,
code: error?.code || null,
status,
stack: error?.stack || null,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
router.get('/goods', async (req, res) => {
try {
res.json({ success: true, data: await getGoods(req.query) });
} catch (error) { sendError(res, error, '加载商品聊天列表失败'); }
});
router.get('/data-meta', async (_req, res) => {
try {
res.json({ success: true, data: await getChatDataMeta() });
} catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); }
});
router.get('/goods/:goodsId/profile', async (req, res) => {
try {
res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) });
} catch (error) { sendError(res, error, '加载商品表现失败'); }
});
router.get('/customers', async (req, res) => {
try {
res.json({ success: true, data: await getCustomers(req.query) });
} catch (error) { sendError(res, error, '加载客户列表失败'); }
});
router.get('/messages', async (req, res) => {
try {
res.json({ success: true, data: await getMessages(req.query) });
} catch (error) { sendError(res, error, '加载聊天内容失败'); }
});
router.post('/analysis', async (req, res) => {
try {
res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) });
} catch (error) { sendError(res, error, 'AI质检失败'); }
});
router.post('/analysis-jobs', async (req, res) => {
try {
const data = await startAnalysisJob(req.body || {}, req.user || {});
res.status(202).json({ success: true, data });
} catch (error) { sendError(res, error, '创建AI质检任务失败'); }
});
router.get('/analysis-jobs/:analysisId', async (req, res) => {
try {
res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) });
} catch (error) { sendError(res, error, '查询AI质检进度失败'); }
});
router.get('/analysis-history', async (req, res) => {
try {
res.json({ success: true, data: await listHistory(req.query) });
} catch (error) { sendError(res, error, '加载AI质检历史失败'); }
});
router.get('/analysis-history/:id', async (req, res) => {
try {
const data = await getHistoryDetail(req.params.id);
if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' });
return res.json({ success: true, data });
} catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); }
});
module.exports = router;

View File

@ -0,0 +1,563 @@
const express = require('express');
const multer = require('multer');
const cloudDb = require('../config/cloudDatabase');
const snapshotDb = require('../config/dailyInventorySnapshotDb');
const officialDb = require('../config/dailyInventoryOfficialDb');
const {
DAILY_INVENTORY_BASELINE,
FLOW_COLUMNS,
} = require('../config/dailyInventoryBaseline');
const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows');
const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening');
const {
parseWorkbook,
normalizeRecords,
checkBalance,
importOfficial,
} = require('../services/dailyInventoryOfficial');
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/**
* 「月度」发货窗口天数:滚动 30 天。
* 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。
*/
const SHIP_WINDOW_DAYS = 30;
const AGE_3M_DAYS = 90;
const AGE_12M_DAYS = 365;
/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
let cachedResult = null;
let cachedResultAt = 0;
const toNumber = (v) => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */
function nextDayAsOf(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
}
/** 发货窗口:[asOf - 30 天, asOf),滚动一个月 */
function shipWindow(asOf) {
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
const from = new Date(to.getTime() - SHIP_WINDOW_DAYS * 86400000);
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
return { shipFrom: fmt(from), shipTo: fmt(to) };
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */
function prevDay(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
const key = `${prefix}${i}`;
params[key] = v;
return `@${key}`;
})
.join(',');
return { clause, params };
}
/**
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
*/
function buildLevelExpr() {
const t = 'b.turnoverPct';
return `
CASE
WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn'
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow'
WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow'
WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale'
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale'
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie'
ELSE NULL
END`;
}
/**
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
*
* 口径:
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100%
* · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled)
* · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均)
* 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created)
*
* SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中)
*/
async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) {
if (!wmsCoIds.length || !bizDate || !asOf) return new Map();
const invIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const { shipFrom, shipTo } = shipWindow(asOf);
const level = buildLevelExpr();
const sqlText = `
WITH inv AS (
SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${invIn.clause})
GROUP BY i.wms_co_id, i.sku_id
),
created AS (
SELECT m.sku_id AS skuId, MIN(m.created) AS created
FROM rpt.pm_stockMonitor m
WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate
GROUP BY m.sku_id
),
ship AS (
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS shipQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
GROUP BY o.wms_co_id, oi.sku_id
),
base AS (
SELECT v.wmsCoId,
v.skuId,
v.stockQty,
ISNULL(s.shipQty, 0) AS shipQty,
DATEDIFF(DAY, c.created, @asOf) AS ageDays,
CASE WHEN v.stockQty <= 0 THEN NULL
ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct
FROM inv v
LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId
LEFT JOIN created c ON c.skuId = v.skuId
)
SELECT b.wmsCoId,
SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
SUM(b.stockQty) AS totalStock,
SUM(b.shipQty) AS totalShip,
SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku,
CASE WHEN SUM(b.stockQty) > 0
THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2))
ELSE NULL END AS turnoverPct,
SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel
FROM base b
GROUP BY b.wmsCoId`;
const rows = await cloudDb.query(sqlText, {
...invIn.params,
...shipIn.params,
bizDate,
asOf,
shipFrom,
shipTo,
}, { requestTimeout: 120000 });
const map = new Map();
rows.forEach((r) => {
map.set(String(r.wmsCoId), {
skuCount: toNumber(r.skuCount) || 0,
gradeStock: toNumber(r.totalStock) || 0,
shipQty: toNumber(r.totalShip) || 0,
turnoverDays: toNumber(r.turnoverPct),
warnLevel: toNumber(r.warnLevel) || 0,
slowLevel: toNumber(r.slowLevel) || 0,
staleLevel: toNumber(r.staleLevel) || 0,
zombieLevel: toNumber(r.zombieLevel) || 0,
ungradedSku: toNumber(r.ungradedSku) || 0,
});
});
return map;
}
/** 实时库存表的数据时效(一次取全局即可) */
async function fetchInventoryTakenAt() {
const rows = await cloudDb.query(
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
);
return rows[0]?.takenAt || null;
}
/**
* GET /api/daily-inventory
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
*
* 数据源分工(全部来自数据库,无硬编码业务数值):
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存;
* 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
* 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」)
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const requestedDate = typeof req.query?.date === 'string'
&& /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : null;
const systemBizDate = await fetchLatestBizDate().catch(() => null);
const latestBizDate = requestedDate || systemBizDate;
// 查看历史业务日时,业务库只有「当前」的实时库存与流水,不能当成该日的期末/发生额
const staleMetrics = Boolean(requestedDate && systemBizDate && requestedDate !== systemBizDate);
const cacheKey = `${latestBizDate}|${staleMetrics ? 'hist' : 'live'}`;
if (!force && cachedResult && cachedResult.latestBizDate === cacheKey
&& Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) {
return res.json({ success: true, data: cachedResult, cached: true });
}
const asOf = nextDayAsOf(latestBizDate);
const openingDate = prevDay(latestBizDate);
const wmsCoIds = DAILY_INVENTORY_BASELINE
.map((b) => b.wmsCoId)
.filter((v) => v != null);
const [gradeMap, takenAt, officialMap, officialDates] = await Promise.all([
fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => {
console.error('[daily-inventory] 分级计算失败:', e.message);
return new Map();
}),
fetchInventoryTakenAt().catch(() => null),
officialDb.fetchOfficial(latestBizDate).catch(() => new Map()),
officialDb.listOfficialDates(30).catch(() => []),
]);
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
let openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map());
const missing = DAILY_INVENTORY_BASELINE
.filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId)))
.map((b) => {
const g = gradeMap.get(String(b.wmsCoId));
return {
wmsCoId: b.wmsCoId,
warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse,
closingQty: Number(g?.gradeStock ?? 0),
skuCount: Number(g?.skuCount ?? 0),
takenAt: takenAt ? new Date(takenAt) : null,
};
});
if (missing.length) {
await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => {
console.error('[daily-inventory] 快照补写失败:', e.message);
});
}
// 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
if (openingDate && !openingMap.size) {
await deriveAndStoreOpeningSnapshot({
cloudDb,
snapshotDb,
baseline: DAILY_INVENTORY_BASELINE,
openingDate,
latestBizDate,
takenAt,
}).catch((e) => {
console.error('[daily-inventory] 期初快照推导失败:', e.message);
});
openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
}
// 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负)
let flowMap = new Map();
if (openingDate) {
flowMap = await fetchFlows(cloudDb, wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => {
console.error('[daily-inventory] 流水取数失败:', e.message);
return new Map();
});
}
const items = DAILY_INVENTORY_BASELINE.map((base) => {
const official = officialMap.get(base.warehouse) || null;
const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null;
const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null;
const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null;
// 12 个流水列:聚水潭官方导出优先,缺失时回退业务库推算(历史业务日不做回退)
const flows = {};
const flowSources = {};
for (const col of FLOW_COLUMNS) {
const fromOfficial = official?.flows?.[col.field];
if (fromOfficial != null) {
flows[col.field] = Number(fromOfficial);
flowSources[col.field] = 'official';
continue;
}
const derived = itemFlows ? itemFlows[col.field] : null;
if (derived != null && !staleMetrics) {
flows[col.field] = Number(derived.toFixed(2));
flowSources[col.field] = 'derived';
continue;
}
flows[col.field] = null;
flowSources[col.field] = 'none';
}
// 期末:官方期末优先;无官方时,历史业务日不给实时库存(口径不对)
const gradeStock = grade?.gradeStock ?? null;
let closingQty = gradeStock;
let closingSource = 'derived';
if (official?.closingQty != null) {
closingQty = Number(official.closingQty);
closingSource = 'official';
} else if (staleMetrics) {
closingQty = null;
closingSource = 'none';
}
// 期初:官方期初优先,其次本地快照
let openingQty = opening ? opening.closingQty : null;
let openingSource = opening ? 'snapshot' : 'none';
if (official?.openingQty != null) {
openingQty = Number(official.openingQty);
openingSource = 'official';
} else if (staleMetrics) {
openingQty = null;
openingSource = 'none';
}
return {
warehouse: base.warehouse,
dbWarehouse: base.dbWarehouse,
openingQty,
openingQtyExtra: null,
...flows,
closingQty,
closingSkuCount: grade?.skuCount ?? null,
closingTakenAt: takenAt,
shipQty30d: grade?.shipQty ?? null,
turnoverDays: grade?.turnoverDays ?? null,
gradeStock,
warnLevel: grade?.warnLevel ?? null,
slowLevel: grade?.slowLevel ?? null,
staleLevel: grade?.staleLevel ?? null,
zombieLevel: grade?.zombieLevel ?? null,
ungradedSku: grade?.ungradedSku ?? null,
hasGrade: Boolean(grade),
source: {
opening: openingSource,
closing: closingSource,
flows: flowSources,
hasOfficial: Boolean(official),
},
};
});
// 合计行:便于与表格总额核对
const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true };
for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
// 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0
totals[k] = items.every((it) => it[k] == null) ? null : Number(sum(k).toFixed(2));
}
// 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真
totals.shipQty30d = Number(sum('shipQty30d').toFixed(2));
const totalStock = sum('closingQty');
totals.turnoverDays = totalStock > 0
? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2))
: null;
const noOpeningCount = items.filter((it) => it.openingQty == null).length;
const balanceMismatch = checkBalance([...officialMap.values()]);
const officialFlowFields = FLOW_COLUMNS
.filter((c) => items.some((it) => it.source.flows[c.field] === 'official'))
.map((c) => c.field);
const derivedFlowFields = FLOW_COLUMNS
.filter((c) => items.some((it) => it.source.flows[c.field] === 'derived'))
.map((c) => c.field);
const payload = {
openingDate,
latestBizDate,
systemBizDate,
staleMetrics,
asOf,
shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null,
flowColumns: FLOW_COLUMNS,
flowColumnsAvailable: AVAILABLE_FLOW_FIELDS,
flowColumnsOfficial: officialFlowFields,
flowColumnsDerived: derivedFlowFields,
noOpeningCount,
official: {
hasData: officialMap.size > 0,
warehouseCount: officialMap.size,
availableDates: officialDates,
balanceMismatch,
},
items,
totals,
};
cachedResult = payload;
cachedResultAt = Date.now();
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[daily-inventory] 查询失败:', error.message);
return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
}
});
/**
* 官方期初 = 上一业务日期末、官方期末 = 当日期末。
* 导入后回写本地快照,让「今日期初 = 昨日结存」的自动推算也对齐聚水潭口径。
*/
async function syncSnapshotFromOfficial(bizDate, rows) {
const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b]));
const known = rows.filter((r) => r.wmsCoId != null);
if (!known.length) return { prev: 0, current: 0 };
const prevDate = prevDay(bizDate);
let prevCount = 0;
if (prevDate) {
const prev = await snapshotDb.fetchSnapshot(prevDate).catch(() => new Map());
prevCount = await snapshotDb.upsertSnapshot(prevDate, known
.filter((r) => r.openingQty != null)
.map((r) => ({
wmsCoId: r.wmsCoId,
warehouse: r.warehouse,
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
closingQty: Number(r.openingQty),
skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0),
takenAt: null,
}))).catch((e) => {
console.error('[daily-inventory] 期初快照回写失败:', e.message);
return 0;
});
}
const current = await snapshotDb.fetchSnapshot(bizDate).catch(() => new Map());
const currentCount = await snapshotDb.upsertSnapshot(bizDate, known
.filter((r) => r.closingQty != null)
.map((r) => ({
wmsCoId: r.wmsCoId,
warehouse: r.warehouse,
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
closingQty: Number(r.closingQty),
skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0),
takenAt: null,
}))).catch((e) => {
console.error('[daily-inventory] 期末快照回写失败:', e.message);
return 0;
});
return { prev: prevCount, current: currentCount };
}
/**
* GET /api/daily-inventory/official
* 查询某业务日已导入的聚水潭官方进销存数据
*/
router.get('/official', async (req, res) => {
try {
const date = String(req.query?.date || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ success: false, error: '请提供业务日期 date=YYYY-MM-DD' });
}
const map = await officialDb.fetchOfficial(date);
const dates = await officialDb.listOfficialDates(30);
return res.json({
success: true,
data: { date, rows: [...map.values()], availableDates: dates },
});
} catch (error) {
console.error('[daily-inventory] 官方数据查询失败:', error.message);
return res.status(500).json({ success: false, error: '官方数据查询失败', detail: error.message });
}
});
/**
* POST /api/daily-inventory/official-import
* 导入聚水潭进销存按仓导出(multipart: file=xlsx, date;或 JSON: {date, rows})
*/
router.post('/official-import', upload.single('file'), async (req, res) => {
try {
const date = String((req.body && req.body.date) || req.query?.date || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ success: false, error: '请提供业务日期 date(YYYY-MM-DD)' });
}
let rows = [];
let warnings = [];
let sheetName = null;
if (req.file && req.file.buffer) {
const parsed = parseWorkbook(req.file.buffer);
rows = parsed.rows;
warnings = parsed.warnings;
sheetName = parsed.sheetName;
if (!rows.length) {
return res.status(400).json({
success: false,
error: '未能从文件解析出仓库行',
detail: warnings.join(';') || '未找到含「仓库」列的工作表',
});
}
} else if (req.body && Array.isArray(req.body.rows)) {
const parsed = normalizeRecords(req.body.rows);
rows = parsed.rows;
warnings = parsed.warnings;
if (!rows.length) {
return res.status(400).json({ success: false, error: 'rows 中未解析到仓库行', detail: warnings.join(';') });
}
} else {
return res.status(400).json({ success: false, error: '请上传 xlsx 文件(字段名 file)或提供 rows 数组' });
}
const result = await importOfficial(date, rows, {
source: String((req.body && req.body.source) || 'jst-export'),
});
const synced = await syncSnapshotFromOfficial(date, rows);
cachedResult = null; // 导入后立即使查询缓存失效
return res.json({
success: true,
data: {
...result,
sheetName,
warnings,
snapshotSynced: synced,
},
});
} catch (error) {
console.error('[daily-inventory] 官方数据导入失败:', error.message);
return res.status(500).json({ success: false, error: '官方数据导入失败', detail: error.message });
}
});
module.exports = router;

View File

@ -0,0 +1,81 @@
'use strict';
// 每日进销存 · 定时截图通知(管理员专用)
//
// 安全约定:
// · 全部接口要求登录且 role === 'admin'
// · 响应里只有「是否已配置」与脱敏预览,绝不返回通知中心密钥 / 对象存储密钥
// · 收件人名单由后端校验与规范化,前端只能提交姓名文本
const express = require('express');
const {
getDailyInventoryNotificationAdminConfig,
listRunRecords,
saveDailyInventoryNotificationConfig,
} = require('../services/dailyInventoryNotificationSettings');
const {
runDailyInventoryNotificationJob,
} = require('../jobs/dailyInventoryNotificationJob');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[DailyInventoryNotification]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
/** 仅管理员可查看与修改通知设置、执行立即测试 */
function requireAdmin(req, res, next) {
if (req?.user?.role !== 'admin') {
return res.status(403).json({ success: false, error: '仅管理员可操作通知设置' });
}
return next();
}
router.use(requireAdmin);
// 读取通知设置(含通知中心 / 对象存储的配置状态与最近执行记录)
router.get('/config', async (_req, res) => {
try {
res.json({ success: true, data: await getDailyInventoryNotificationAdminConfig() });
} catch (error) {
sendError(res, error, '加载每日进销存通知设置失败');
}
});
// 保存通知设置
router.put('/config', async (req, res) => {
try {
const saved = await saveDailyInventoryNotificationConfig(req.body || {});
res.json({ success: true, data: { ...saved, admin: await getDailyInventoryNotificationAdminConfig() } });
} catch (error) {
sendError(res, error, '保存每日进销存通知设置失败');
}
});
// 立即测试:真实走一次截图 + 上传 + 通知,但不写入正式幂等记录
router.post('/test', async (_req, res) => {
try {
const result = await runDailyInventoryNotificationJob({ mode: 'test' });
res.json({ success: result?.success === true, data: result });
} catch (error) {
sendError(res, error, '执行每日进销存通知测试失败');
}
});
// 最近执行记录(管理员排障用)
router.get('/runs', async (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 50);
res.json({ success: true, data: await listRunRecords(limit) });
} catch (error) {
sendError(res, error, '加载每日进销存通知执行记录失败');
}
});
module.exports = router;

View File

@ -1,18 +1,6 @@
const express = require('express');
const crypto = require('crypto');
const authMiddleware = require('../middleware/auth');
const {
buildPublicUrl,
getDefaultS3Config,
getObjectFromDefaultS3,
} = require('../services/objectStorage');
const {
compareVersions,
getDesktopReleaseById,
getLatestDesktopRelease,
mapRelease,
normalizeVersion,
} = require('../services/desktopReleaseService');
const {
DesktopSessionError,
createDesktopSession,
@ -24,23 +12,6 @@ const {
const router = express.Router();
function publicRelease(release) {
const mapped = mapRelease(release);
if (!mapped) return null;
return {
id: mapped.id,
version: mapped.version,
releaseNotes: mapped.releaseNotes,
platform: mapped.platform,
architecture: mapped.architecture,
fileName: mapped.fileName,
fileSize: mapped.fileSize,
sha256: mapped.sha256,
publishedAt: mapped.publishedAt,
downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`,
};
}
function requestUserId(req) {
return req.user?.sub ?? req.user?.id;
}
@ -164,68 +135,4 @@ router.post('/session/revoke', authMiddleware, async (req, res) => {
}
});
router.get('/latest', async (req, res) => {
try {
const platform = req.query.platform || 'win32';
const architecture = req.query.architecture || '';
const release = await getLatestDesktopRelease(platform, architecture);
const currentVersion = normalizeVersion(req.query.currentVersion);
const data = publicRelease(release);
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.json({
success: true,
data: data ? {
...data,
currentVersion: currentVersion || null,
updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null,
} : null,
});
} catch (error) {
console.error('获取桌面客户端最新版本失败:', error);
res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' });
}
});
router.get('/releases/:id/download', async (req, res) => {
try {
const release = await getDesktopReleaseById(Number(req.params.id));
if (!release) {
return res.status(404).json({ success: false, error: '安装包不存在或已下线' });
}
const storageConfig = await getDefaultS3Config();
if (storageConfig?.customDomain) {
return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey));
}
const object = await getObjectFromDefaultS3(release.objectKey, {
range: req.headers.range,
});
const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+');
res.status(object.ContentRange ? 206 : 200);
const fallbackContentType = release.platform === 'darwin'
? 'application/x-apple-diskimage'
: 'application/vnd.microsoft.portable-executable';
res.setHeader('Content-Type', object.ContentType || fallbackContentType);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes');
res.setHeader('Cache-Control', 'private, max-age=3600');
if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength));
if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange);
if (object.ETag) res.setHeader('ETag', object.ETag);
object.Body.on('error', (error) => {
console.error('桌面客户端安装包下载流中断:', error);
if (!res.headersSent) res.status(502).end();
else res.destroy(error);
});
object.Body.pipe(res);
} catch (error) {
console.error('下载桌面客户端安装包失败:', error);
if (!res.headersSent) {
res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' });
}
}
});
module.exports = router;

View File

@ -5,6 +5,11 @@ const {
shouldAutoRestartAfterInstall,
testSystemDatabaseBootstrapConnection,
} = require('../services/installService');
const {
DEFAULT_BOOTSTRAP_ENDPOINT,
enrollRemoteInstallation,
scheduleRemoteRestart,
} = require('../services/remoteEnrollmentService');
const router = express.Router();
@ -18,6 +23,47 @@ router.get('/status', async (_req, res) => {
}
});
router.get('/remote-bootstrap', (_req, res) => {
res.json({
enabled: true,
defaultEndpoint: DEFAULT_BOOTSTRAP_ENDPOINT,
supervised: process.env.CANGKU_REMOTE_SUPERVISOR === 'true',
});
});
router.post('/remote-bootstrap', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
const status = await getInstallStatus();
if (!status.required) {
return res.status(409).json({ error: '系统数据库已经配置,不能重复注册远程部署' });
}
const result = await enrollRemoteInstallation(req.body || {});
const restartScheduled = process.env.CANGKU_REMOTE_SUPERVISOR === 'true';
res.once('finish', () => {
setTimeout(() => {
try {
scheduleRemoteRestart();
} catch (error) {
console.error('写入远程启动标记失败:', error.message);
}
}, 800);
});
return res.json({
success: true,
message: restartScheduled
? '远程系统数据库配置完成,服务正在自动重启'
: '远程系统数据库配置完成,请使用 npm run remote:dev 重新启动',
restartScheduled,
...result,
});
} catch (error) {
console.error('远程部署注册失败:', error.message);
return res.status(400).json({ error: error.message || '远程部署注册失败' });
}
});
router.post('/system-database/test', async (req, res) => {
try {
const startTime = Date.now();

127
backend/src/api/mkhBom.js Normal file
View File

@ -0,0 +1,127 @@
'use strict';
const express = require('express');
const {
getMkhBomList,
getMkhBomDetail,
listMkhBomExportRows,
clearMkhBomCache,
} = require('../services/mkhBomService');
const {
getMkhReplenishAlertAdminConfig,
resetMkhReplenishAlertState,
saveMkhReplenishAlertConfig,
} = require('../services/mkhReplenishAlertSettings');
const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob');
const {
saveYydNotificationCenterConfig,
} = require('../services/yydNotificationCenterSettings');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[MkhBom]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
function buildSyncQuery(query = {}) {
return { ...query, live: '1', fresh: '1' };
}
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 资料列表失败');
}
});
// 单个 MKH 组合商品的 BOM 配件明细(打印用)
router.get('/detail', async (req, res) => {
try {
const skuId = req.query?.skuId || req.query?.comboSkuId;
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomDetail(skuId) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 明细失败');
}
});
// 导出:当前筛选条件下的全部数据(不分页)
// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平
router.get('/export', async (req, res) => {
try {
res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) });
} catch (error) {
sendError(res, error, '导出 MKH-BOM 资料失败');
}
});
// 补货预警推送设置(接收人 / 开关 / 推送范围)
router.get('/alert-config', async (req, res) => {
try {
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
} catch (error) {
sendError(res, error, '加载补货预警推送设置失败');
}
});
// 保存补货预警推送设置
router.put('/alert-config', async (req, res) => {
try {
const data = await saveMkhReplenishAlertConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '保存补货预警推送设置失败');
}
});
// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录)
router.post('/alert-check', async (req, res) => {
try {
const body = req.body || {};
if (body.reset === true) await resetMkhReplenishAlertState();
const result = await checkMkhReplenishAlerts({
dryRun: body.dryRun === true,
force: body.force === true,
});
res.json({ success: true, data: result });
} catch (error) {
sendError(res, error, '执行补货预警推送失败');
}
});
// 保存统一通知中心凭据(补货预警的推送通道)
router.put('/notification-center', async (req, res) => {
try {
await saveYydNotificationCenterConfig(req.body || {});
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
} catch (error) {
sendError(res, error, '保存统一通知中心配置失败');
}
});
// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。
router.post('/sync', async (req, res) => {
try {
const cache = clearMkhBomCache();
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
sendError(res, error, '同步 MKH-BOM 数据失败');
}
});
module.exports = router;
module.exports._private = { buildSyncQuery };

View File

@ -0,0 +1,59 @@
'use strict';
const express = require('express');
const {
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
} = require('../services/modularStockMonitorService');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[ModularStockMonitor]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 配件库存监控明细列表(默认仅「简尚-崔桥工厂半成品包装项目仓」)
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getModularStockList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载配件库存监控列表失败');
}
});
// 筛选下拉选项(品类 / 预警等级)
router.get('/filters', async (_req, res) => {
try {
res.json({ success: true, data: await getModularStockFilters() });
} catch (error) {
sendError(res, error, '加载配件库存监控筛选选项失败');
}
});
// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细
router.get('/purchase-in-transit', async (req, res) => {
try {
res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载采购在途明细失败');
}
});
// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照
router.post('/sync', async (_req, res) => {
try {
res.json({ success: true, data: await syncModularStock() });
} catch (error) {
sendError(res, error, '同步配件库存监控数据失败');
}
});
module.exports = router;

View File

@ -0,0 +1,282 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TOTAL_LABEL,
PICKING_BIN_BIN_TYPES,
} = require('../config/pickingBinSalesAnalysis');
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/** 结果缓存:一次要扫订单明细 + 仓位明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheByKey = new Map();
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000;
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
function nextDayAsOf(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
}
/** 发货窗口:[asOf - N 天, asOf) */
function buildShipWindow(asOf, days) {
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
const from = new Date(to.getTime() - days * 86400000);
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
return { shipFrom: fmt(from), shipTo: fmt(to) };
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
async function fetchBinTakenAt() {
const rows = await cloudDb.query(
`SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo`
);
return rows[0]?.takenAt || null;
}
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
function resolveDays(raw) {
const n = Number.parseInt(String(raw ?? ''), 10);
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
}
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
function resolveWarehouses(raw) {
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const requested = String(raw || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean);
const picked = requested.length > 0
? known.filter((b) => requested.includes(String(b.wmsCoId)))
: known;
return picked;
}
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
/** 明细行按「仓库 × 商品编码」去重后的商品个数 */
function dedupeCount(list) {
return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size;
}
/**
* GET /api/picking-bin-sales-analysis
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const days = resolveDays(req.query?.days);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const cacheKey = `${days}::${wmsCoIds.join('|')}`;
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByKey.get(cacheKey);
if (!force && cached && cached.latestBizDate === latestBizDate
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
return res.json({ success: true, data: cached.payload, cached: true });
}
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const [rows, takenAt] = await Promise.all([
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
fetchBinTakenAt().catch(() => null),
]);
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const items = rows.map((r) => ({
wmsCoId: r.wmsCoId,
binName: r.binName,
binType: r.binType,
skuId: r.skuId,
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
stockQty: r.stockQty,
skuCount: r.skuCount,
salesQty: round2(r.salesQty),
dailySales: round4(r.dailySales),
gradeKey: r.gradeKey,
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
}));
// 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加
const skuSalesMap = new Map();
rows.forEach((r) => {
const key = `${r.wmsCoId}::${r.skuId}`;
if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty);
});
const dedupeSales = (list) => {
const seen = new Set();
return list.reduce((acc, it) => {
const key = `${it.wmsCoId}::${it.skuId}`;
if (seen.has(key)) return acc;
seen.add(key);
return acc + (skuSalesMap.get(key) || 0);
}, 0);
};
// 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复)
const summary = PICKING_BIN_GRADE_RULES.map((grade) => {
const hit = items.filter((it) => it.gradeKey === grade.key);
const salesQty = round2(dedupeSales(hit));
return {
key: grade.key,
label: grade.label,
tip: grade.tip,
rowCount: hit.length,
skuCount: dedupeCount(hit),
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
salesQty,
dailySales: round4(salesQty / days),
};
});
const dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`));
const totalSales = round2(dedupeSales(items));
const total = {
key: '__total__',
label: PICKING_BIN_TOTAL_LABEL,
rowCount: items.length,
skuCount: dedupeAll.size,
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
salesQty: totalSales,
dailySales: round4(totalSales / days),
};
const payload = {
days,
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
binTypes: [...PICKING_BIN_BIN_TYPES],
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
takenAt,
grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
totalLabel: PICKING_BIN_TOTAL_LABEL,
warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })),
items,
summary,
total,
};
cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload });
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[picking-bin-sales-analysis] 查询失败:', error.message);
return res.status(500).json({
success: false,
error: '拣选库位动销分析数据查询失败',
detail: error.message,
});
}
});
/**
* GET /api/picking-bin-sales-analysis/export
* 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
*/
router.get('/export', async (req, res) => {
try {
const days = resolveDays(req.query?.days);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const grades = String(req.query?.grade || '')
.split(',')
.map((v) => v.trim())
.filter((v) => GRADE_MAP.has(v));
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const filtered = grades.length > 0
? rows.filter((r) => grades.includes(r.gradeKey))
: rows;
const sheetRows = filtered.map((r) => [
whMap.get(r.wmsCoId) || r.wmsCoId,
r.binName,
r.binType,
r.skuId,
r.stockQty,
r.skuCount,
round2(r.salesQty),
round4(r.allocatedSalesQty),
round4(r.dailySales),
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
days,
latestBizDate || '',
]);
const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
const { totalRows } = sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '库位动销明细',
header: [
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
'动销等级', '统计天数', '统计基准日',
],
rows: sheetRows,
}],
});
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);
if (!res.headersSent) {
return res.status(500).json({
success: false,
error: '拣选库位动销分析导出失败',
detail: error.message,
});
}
return null;
}
});
module.exports = router;

View File

@ -1,833 +0,0 @@
const express = require('express');
const XLSX = require('xlsx');
const database = require('../config/database');
const {
getCompanySkusBySpu,
fetchFreightProfile,
getSelectionFissionProfitPricingContext,
getSelectionFissionProductCodeMeta,
saveSelectionFissionProfitPricingTable,
} = require('../services/planningSelectionFissionService');
const router = express.Router();
const HISTORY_TABLE = 'pricing_calculator_history';
// 运费/售后画像查询设定超时,避免整页等待。
const FREIGHT_PROFILE_TIMEOUT_MS = Math.max(
Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000,
5000
);
const MAX_HISTORY_ROWS = 500;
const MAX_PAYLOAD_BYTES = 1024 * 1024;
const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']);
let historyTableReady = null;
async function ensureHistoryTable() {
if (!historyTableReady) {
historyTableReady = (async () => {
// 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE,
// MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。
const existing = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[HISTORY_TABLE]
);
if (existing.length > 0) {
return;
}
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
brand VARCHAR(100) NULL COMMENT '品牌名称',
note VARCHAR(500) NULL COMMENT '备注',
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'`
);
})();
}
try {
await historyTableReady;
} catch (error) {
// 建表失败后允许下次请求重试
historyTableReady = null;
throw error;
}
}
function sendError(res, error, fallbackMessage) {
const status = error.status || error.statusCode || 500;
res.status(status).json({
success: false,
error: error.message || fallbackMessage,
});
}
function getRequestUser(req) {
const user = req.user || {};
return {
id: Number(user.sub || user.id || 0) || null,
name: user.name || user.username || '',
isAdmin: user.role === 'admin',
};
}
function requireUserId(req, res) {
const { id } = getRequestUser(req);
if (!id) {
res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
return null;
}
return id;
}
const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength);
const normalizeCalculatorType = (value) => (
CALCULATOR_TYPES.has(value) ? value : 'company'
);
function sanitizeHistoryProduct(input) {
const product = input && typeof input === 'object' ? input : {};
const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : [])
.slice(0, 4)
.map((dimension) => ({
name: normalizeText(dimension?.name, 120),
values: (Array.isArray(dimension?.values) ? dimension.values : [])
.slice(0, 500)
.map((value) => normalizeText(value, 500))
.filter(Boolean),
}))
.filter((dimension) => dimension.name);
return {
productId: normalizeText(product.productId, 80),
title: normalizeText(product.title, 500),
mainImage: normalizeText(product.mainImage, 2000),
sourceUrl: normalizeText(product.sourceUrl, 2000),
storeName: normalizeText(product.storeName, 300),
category: normalizeText(product.category, 500),
skuDimensions: dimensions,
};
}
function mapSkuRow(row) {
const qgyf = Number(row?.raw?.qgyf);
return {
skuCode: row.skuCode || '',
status: row.saleStatus || '',
stock: row.stock ?? null,
stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [],
name: row.skuName || row.productName || '',
image: row.skuImageUrl || '',
size: row.size || '',
color: row.colorName || '',
cost: row.cost ?? null,
minimumPrice: row.minimumPrice ?? null,
salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null,
// qgyf = 聚水潭接口返回的全国平均运费(按条码)
freight: Number.isFinite(qgyf) ? qgyf : null,
supplier: row.supplierName || '',
category: row.categoryName || '',
lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null,
widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null,
heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null,
weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null,
};
}
async function fetchFreightProfileBestEffort(spuCode, skuRows, user) {
const emptyProfile = {
nationalFreight: null,
zone1Freight: null,
zone2Freight: null,
zone3Freight: null,
zone4Freight: null,
aftersaleRatio: null,
minimumPricesBySku: {},
raw: null,
};
try {
const result = await Promise.race([
fetchFreightProfile({ spuCode, skuRows }, user),
new Promise((resolve) => {
setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS);
}),
]);
return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile;
} catch (error) {
return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' };
}
}
// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为)
// 运费由聚水潭接口 qgyf 字段按条码直出,
// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。
const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on';
function getSelectionSkuCodeSet(selectionContext) {
if (!selectionContext) return null;
const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes)
? selectionContext.skuSceneImages.selectedSkuCodes
: [];
const fallbackSkuCodes = Array.isArray(selectionContext.rows)
? selectionContext.rows.map((row) => row.skuCode)
: [];
return new Set(
(selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes)
.map((skuCode) => normalizeText(skuCode, 160).toUpperCase())
.filter(Boolean)
);
}
function filterSkuRowsForSelectionContext(skuRows, selectionContext) {
const rows = Array.isArray(skuRows) ? skuRows : [];
const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext);
if (!selectedSkuCodes) return rows;
return rows.filter((row) => selectedSkuCodes.has(
normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase()
));
}
router.get('/product-code/meta', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
try {
res.json({ success: true, data: await getSelectionFissionProductCodeMeta() });
} catch (error) {
sendError(res, error, '公司SKU编码配置加载失败');
}
});
router.get('/spu/:spuCode', async (req, res) => {
const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase();
if (!spuCode) {
return res.status(400).json({ success: false, error: '请输入公司 SPU' });
}
try {
const planId = Number.parseInt(req.query.planId, 10) || 0;
const itemId = Number.parseInt(req.query.itemId, 10) || 0;
if (Boolean(planId) !== Boolean(itemId)) {
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
}
const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true });
const allSkuRows = Array.from(skuResult.rows || []);
if (allSkuRows.length === 0) {
return res.status(404).json({
success: false,
error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`,
});
}
let selectionContext = null;
if (planId && itemId) {
selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId);
if (selectionContext.spuCode.toUpperCase() !== spuCode) {
return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` });
}
}
const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext);
if (selectionContext && skuRows.length === 0) {
return res.status(409).json({
success: false,
error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认',
});
}
const freightProfile = FREIGHT_PROFILE_ENABLED
? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user)
: { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null };
const warnings = [...(skuResult.warnings || [])];
if (freightProfile.warning) {
warnings.push(freightProfile.warning);
}
const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [
String(row.skuCode || '').toUpperCase(),
row,
]));
res.json({
success: true,
data: {
spu: spuCode,
source: skuResult.source,
warnings,
freightProfile: {
nationalFreight: freightProfile.nationalFreight ?? null,
zone1Freight: freightProfile.zone1Freight ?? null,
zone2Freight: freightProfile.zone2Freight ?? null,
zone3Freight: freightProfile.zone3Freight ?? null,
zone4Freight: freightProfile.zone4Freight ?? null,
aftersaleRatio: freightProfile.aftersaleRatio ?? null,
minimumPricesBySku: freightProfile.minimumPricesBySku || {},
},
selectionContext: selectionContext ? {
planId: selectionContext.planId,
itemId: selectionContext.itemId,
taskCode: selectionContext.taskCode,
schemeCode: selectionContext.schemeCode,
listedItemId: selectionContext.listedItemId,
pricingTableSavedAt: selectionContext.pricingTableSavedAt,
pricingTableSavedBy: selectionContext.pricingTableSavedBy,
pricingTableVersion: selectionContext.pricingTableVersion,
workflowProfile: selectionContext.workflowProfile,
productCodeStatus: selectionContext.productCodeStatus,
productCodeStyleCode: selectionContext.productCodeStyleCode,
productCodeConfig: selectionContext.productCodeConfig,
productCodeErrorMessage: selectionContext.productCodeErrorMessage,
productCodeRequestedAt: selectionContext.productCodeRequestedAt,
productCodeCompletedAt: selectionContext.productCodeCompletedAt,
} : null,
rows: skuRows.map((skuRow) => {
const mapped = mapSkuRow(skuRow);
const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase());
return {
...mapped,
savedPricing: saved ? {
price: saved.finalPrice,
groupPrice: saved.groupPrice,
installFee: saved.installationFee,
coupon: saved.discountAmount,
activityDiscount: saved.activityDiscount,
shipping: saved.freightNational,
commission: saved.platformCommissionRatio,
promoRate: saved.promotionRatio,
adFee: saved.marketingAdRatio,
seeding: saved.farmingRatio,
afterSale: saved.aftersaleRatio,
insurance: saved.insuranceRatio,
brandFee: saved.brandUsageRatio,
targetMargin: saved.operatingProfitRatio,
originalSkuName: saved.originalSkuName,
selectedSkuName: saved.selectedSkuName,
internalProductCode: saved.internalProductCode,
productCodeProductName: saved.productCodeProductName,
productCodeColorSpec: saved.productCodeColorSpec,
} : null,
};
}),
},
});
} catch (error) {
sendError(res, error, 'SPU 条码查询失败,请稍后重试');
}
});
// 保存一条测算历史(完整行快照,含输入与公式结果)
router.post('/history', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const body = req.body || {};
const spuCode = normalizeText(body.spuCode, 160).toUpperCase();
const calculatorType = normalizeCalculatorType(body.calculatorType);
const rows = Array.isArray(body.rows) ? body.rows : [];
if (!spuCode) {
return res.status(400).json({ success: false, error: '缺少 SPU 编码' });
}
if (rows.length === 0) {
return res.status(400).json({ success: false, error: '测算行为空,无法保存' });
}
if (rows.length > MAX_HISTORY_ROWS) {
return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` });
}
const payload = {
version: 4,
calculatorType,
spuCode,
isBlackLabelShop: body.isBlackLabelShop === true,
brand: normalizeText(body.brand, 100),
note: normalizeText(body.note, 500),
savedAt: new Date().toISOString(),
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null,
product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null,
rows,
};
const payloadJson = JSON.stringify(payload);
if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) {
return res.status(400).json({ success: false, error: '测算快照过大,无法保存' });
}
try {
let selectionPricing = null;
const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0;
const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0;
if (Boolean(planId) !== Boolean(itemId)) {
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
}
if (planId && itemId) {
// 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。
const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId);
const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => (
normalizeText(row.skuCode, 160).toUpperCase()
)));
const listingRows = rows.filter((row) => listingSkuCodes.has(
normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase()
));
selectionPricing = await saveSelectionFissionProfitPricingTable(
planId,
itemId,
{
spuCode,
rows: listingRows,
note: payload.note,
productCodeConfig: body.productCodeConfig,
},
req.user,
{ pricingContext }
);
}
let insertId = null;
if (!selectionPricing) {
await ensureHistoryTable();
const inserted = await database.local.insert(HISTORY_TABLE, {
user_id: userId,
user_name: getRequestUser(req).name || null,
spu_code: spuCode,
brand: payload.brand || null,
note: payload.note || null,
row_count: rows.length,
payload_json: payloadJson,
});
insertId = inserted.insertId;
}
res.json({
success: true,
data: {
id: insertId,
spuCode,
rowCount: rows.length,
selectionPricing: selectionPricing ? {
planId: selectionPricing.planId,
itemId: selectionPricing.itemId,
taskCode: selectionPricing.taskCode,
pricingTableSavedAt: selectionPricing.pricingTableSavedAt,
pricingTableVersion: selectionPricing.pricingTableVersion,
productCodeStatus: selectionPricing.productCodeStatus,
productCodeStyleCode: selectionPricing.productCodeStyleCode,
productCodeConfig: selectionPricing.productCodeConfig,
productCodeCompletedAt: selectionPricing.productCodeCompletedAt,
productCodes: (selectionPricing.rows || []).map((row) => ({
skuCode: row.skuCode,
originalSkuName: row.originalSkuName,
selectedSkuName: row.selectedSkuName,
internalProductCode: row.internalProductCode,
productCodeProductName: row.productCodeProductName,
})),
} : null,
},
});
} catch (error) {
sendError(res, error, '保存测算记录失败');
}
});
// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文)
router.get('/history', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const { isAdmin } = getRequestUser(req);
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100);
const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase();
const calculatorType = normalizeText(req.query.calculatorType, 30);
const offset = (page - 1) * pageSize;
try {
await ensureHistoryTable();
const where = [];
const params = [];
if (!isAdmin) {
where.push('user_id = ?');
params.push(userId);
}
if (spuCode) {
where.push('spu_code = ?');
params.push(spuCode);
}
if (calculatorType === 'alibaba1688') {
where.push('payload_json LIKE ?');
params.push('%"calculatorType":"alibaba1688"%');
} else if (calculatorType === 'company') {
// 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。
where.push('payload_json NOT LIKE ?');
params.push('%"calculatorType":"alibaba1688"%');
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const [countRows, list] = await Promise.all([
database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params),
database.local.query(
`SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at
FROM ${HISTORY_TABLE}
${whereSql}
ORDER BY id DESC
LIMIT ${pageSize} OFFSET ${offset}`,
params
),
]);
res.json({
success: true,
data: {
list: list.map((row) => ({
id: row.id,
userName: row.user_name || '',
spuCode: row.spu_code,
brand: row.brand || '',
note: row.note || '',
rowCount: row.row_count,
calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company',
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
total: Number(countRows[0]?.total || 0),
page,
pageSize,
},
});
} catch (error) {
sendError(res, error, '查询测算历史失败');
}
});
async function loadOwnHistoryRow(req, res) {
const userId = requireUserId(req, res);
if (!userId) return null;
const { isAdmin } = getRequestUser(req);
const id = Number.parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) {
res.status(400).json({ success: false, error: '无效的历史记录 ID' });
return null;
}
await ensureHistoryTable();
// 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在
const rows = isAdmin
? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id])
: await database.local.query(
`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`,
[id, userId]
);
if (rows.length === 0) {
res.status(404).json({ success: false, error: '测算记录不存在或已被删除' });
return null;
}
return rows[0];
}
// 历史详情(仅本人)
router.get('/history/:id', async (req, res) => {
try {
const row = await loadOwnHistoryRow(req, res);
if (!row) return;
let payload = null;
try {
payload = JSON.parse(row.payload_json);
} catch {
payload = null;
}
res.json({
success: true,
data: {
id: row.id,
userName: row.user_name || '',
spuCode: row.spu_code,
brand: row.brand || '',
note: row.note || '',
rowCount: row.row_count,
createdAt: row.created_at,
updatedAt: row.updated_at,
payload,
},
});
} catch (error) {
sendError(res, error, '查询测算记录失败');
}
});
// 删除历史(本人,或管理员可管理全部)
router.delete('/history/:id', async (req, res) => {
try {
const row = await loadOwnHistoryRow(req, res);
if (!row) return;
await database.local.query(
`DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`,
[row.id, row.user_id]
);
res.json({ success: true, data: { id: row.id } });
} catch (error) {
sendError(res, error, '删除测算记录失败');
}
});
// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载
const MAX_EXPORT_ROWS = 2000;
const MAX_EXPORT_COLUMNS = 40;
router.post('/export', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const body = req.body || {};
const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU';
const columns = Array.isArray(body.columns) ? body.columns : [];
const rows = Array.isArray(body.rows) ? body.rows : [];
const validColumns = columns
.filter((col) => col && typeof col === 'object')
.slice(0, MAX_EXPORT_COLUMNS)
.map((col) => ({
key: normalizeText(col.key, 60),
label: normalizeText(col.label, 60) || normalizeText(col.key, 60),
percent: col.percent === true,
}))
.filter((col) => col.key);
if (validColumns.length === 0) {
return res.status(400).json({ success: false, error: '缺少导出列配置' });
}
if (rows.length === 0) {
return res.status(400).json({ success: false, error: '导出数据为空' });
}
if (rows.length > MAX_EXPORT_ROWS) {
return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` });
}
try {
const aoa = [validColumns.map((col) => col.label)];
rows.forEach((row) => {
aoa.push(validColumns.map((col) => {
const value = row && typeof row === 'object' ? row[col.key] : null;
return value === null || value === undefined ? '' : value;
}));
});
const sheet = XLSX.utils.aoa_to_sheet(aoa);
// 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%)
validColumns.forEach((col, colIndex) => {
if (!col.percent) return;
for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) {
const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex });
const cell = sheet[addr];
if (cell && typeof cell.v === 'number') {
cell.z = '0.00%';
}
}
});
sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, '上架测算');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const stamp = new Date();
const pad = (n) => String(n).padStart(2, '0');
const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
res.send(buffer);
} catch (error) {
res.status(500).json({ success: false, error: `导出失败:${error.message}` });
}
});
// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) =====
const PREFS_TABLE = 'pricing_calculator_user_prefs';
let prefsTableReady = null;
async function ensurePrefsTable() {
if (!prefsTableReady) {
prefsTableReady = (async () => {
const existing = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[PREFS_TABLE]
);
if (existing.length > 0) {
return;
}
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'`
);
})();
}
try {
await prefsTableReady;
} catch (error) {
prefsTableReady = null;
throw error;
}
}
// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%)
const PREF_FIELD_RANGES = {
coupon: { min: 0, max: 100000 },
activityDiscount: { min: 0.01, max: 1 },
installFee: { min: 0, max: 100000 },
targetMargin: { min: -1, max: 1 },
};
function sanitizePrefs(input) {
const src = input && typeof input === 'object' ? input : {};
const prefs = {};
Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => {
const num = Number(src[key]);
if (Number.isFinite(num)) {
prefs[key] = Math.min(range.max, Math.max(range.min, num));
}
});
return prefs;
}
const PREFS_PROFILE_KEY = '_profiles';
const PREFS_PROFILES = new Set(['company', 'alibaba1688']);
function normalizePrefsProfile(value) {
return PREFS_PROFILES.has(value) ? value : 'company';
}
function parsePrefsStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function readPrefsProfile(storageValue, profile = 'company') {
const storage = parsePrefsStorage(storageValue);
const normalizedProfile = normalizePrefsProfile(profile);
const source = normalizedProfile === 'company'
? storage
: storage[PREFS_PROFILE_KEY]?.[normalizedProfile];
const prefs = sanitizePrefs(source);
return Object.keys(prefs).length > 0 ? prefs : null;
}
function writePrefsProfile(storageValue, profile, prefs) {
const storage = parsePrefsStorage(storageValue);
const normalizedProfile = normalizePrefsProfile(profile);
if (normalizedProfile === 'company') {
return { ...storage, ...prefs };
}
return {
...storage,
[PREFS_PROFILE_KEY]: {
...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object'
? storage[PREFS_PROFILE_KEY]
: {}),
[normalizedProfile]: prefs,
},
};
}
function createPrefsPatch(profile, prefs) {
const normalizedProfile = normalizePrefsProfile(profile);
return normalizedProfile === 'company'
? prefs
: { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } };
}
async function persistPrefsProfile(userId, profile, prefs) {
const patch = JSON.stringify(createPrefsPatch(profile, prefs));
try {
// JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。
await database.local.query(
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
VALUES(prefs_json)
)`,
[userId, patch]
);
} catch (error) {
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
.includes(error.code);
if (!canFallback) throw error;
// 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。
const existingRows = await database.local.query(
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
[userId]
);
const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs);
await database.local.query(
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
[userId, JSON.stringify(storage)]
);
}
}
router.get('/prefs', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
try {
await ensurePrefsTable();
const rows = await database.local.query(
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
[userId]
);
const profile = normalizePrefsProfile(req.query.profile);
const prefs = rows.length > 0
? readPrefsProfile(rows[0].prefs_json, profile)
: null;
res.json({ success: true, data: { prefs } });
} catch (error) {
sendError(res, error, '读取个性化配置失败');
}
});
router.put('/prefs', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const prefs = sanitizePrefs(req.body?.prefs ?? req.body);
const profile = normalizePrefsProfile(req.body?.profile);
if (Object.keys(prefs).length === 0) {
return res.status(400).json({ success: false, error: '缺少有效的配置项' });
}
try {
await ensurePrefsTable();
await persistPrefsProfile(userId, profile, prefs);
res.json({ success: true, data: { prefs } });
} catch (error) {
sendError(res, error, '保存个性化配置失败');
}
});
module.exports = router;
module.exports._test = {
filterSkuRowsForSelectionContext,
createPrefsPatch,
getSelectionSkuCodeSet,
normalizeCalculatorType,
readPrefsProfile,
sanitizeHistoryProduct,
sanitizePrefs,
writePrefsProfile,
};

View File

@ -1,73 +0,0 @@
'use strict';
const crypto = require('crypto');
const express = require('express');
const pricingCalculatorRoutes = require('./pricingCalculator');
const {
requireDesktopPageAccess,
requireExternalAutomationApiKey,
} = require('../services/externalAutomationAuth');
const router = express.Router();
const MAX_OPERATOR_NAME_LENGTH = 100;
const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER);
function normalizeOperatorName(value) {
return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH);
}
function decodeOperatorNameHeader(value) {
const encoded = String(value || '').trim();
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
try {
return Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return '';
}
}
function desktopPricingUserId(operatorName) {
const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN');
if (!normalized) return null;
const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest();
const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n;
return -Number(value);
}
function requireDesktopPricingOperator(req, res, next) {
if (req.externalApiKey?.authType === 'jwt' && req.user?.name) {
return next();
}
const operatorName = normalizeOperatorName(
decodeOperatorNameHeader(req.get('x-operator-name-encoded'))
|| req.get('x-operator-name')
|| req.query?.operatorName
|| req.body?.operatorName
);
if (!operatorName) {
return res.status(400).json({ success: false, error: '缺少运营花名' });
}
const userId = desktopPricingUserId(operatorName);
req.user = {
id: userId,
sub: userId,
name: operatorName,
username: operatorName,
role: 'operator',
authSource: 'desktop-api-key',
};
return next();
}
router.use(
requireExternalAutomationApiKey,
requireDesktopPageAccess('planning_upload'),
requireDesktopPricingOperator,
pricingCalculatorRoutes
);
module.exports = router;
module.exports.desktopPricingUserId = desktopPricingUserId;
module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator;

View File

@ -9,7 +9,7 @@ const router = express.Router();
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread';
const USER_PREFS_TABLE = 'pricing_calculator_user_prefs';
const USER_PREFS_TABLE = 'product_analysis_user_prefs';
const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds';
const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000;
const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000);

View File

@ -0,0 +1,58 @@
'use strict';
const express = require('express');
const {
getReturnInventoryWarningList,
buildReturnInventoryWarningExport,
} = require('../services/returnInventoryWarningService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
function sendError(res, error, fallback = '退货库存预警请求失败') {
const status = Number(error?.statusCode) || 500;
console.error('[ReturnInventoryWarning]', {
message: error?.message || fallback,
code: error?.code || null,
status,
stack: error?.stack || null,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 退货库存预警明细列表(固定过滤仓库:销售退仓 横林二仓)
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getReturnInventoryWarningList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载退货库存预警列表失败');
}
});
/**
* 导出退货入库明细(固定过滤仓库:销售退仓 横林二仓)
* query: warehouse, date, keyword, format=xlsx|csv
* 注意:导出走服务端全量拉取,不受列表分页限制。
*/
router.get('/export', async (req, res) => {
try {
const query = req.query || {};
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const result = await buildReturnInventoryWarningExport(query);
console.log('[ReturnInventoryWarning][export]', {
format,
warehouse: result.warehouse,
date: result.date,
rows: result.totalRows,
matchedTotal: result.matchedTotal,
truncated: result.truncated,
});
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
} catch (error) {
sendError(res, error, '导出退货入库明细失败');
}
});
module.exports = router;

View File

@ -0,0 +1,161 @@
'use strict';
const express = require('express');
const {
getDashboard,
runDailyCompare,
backfillHistory,
getInStockLinesForCode,
resolveDiscrepancy,
updateInboundStatus,
hasComparedToday,
todayInShanghai,
buildReturnMonitorExport,
} = require('../services/returnMonitorService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
function sendError(res, error, fallback = '退货入库核销请求失败') {
const status = Number(error?.statusCode) || 500;
console.error('[ReturnMonitor]', {
message: error?.message || fallback,
status,
stack: error?.stack || null,
});
res.status(status).json({ success: false, error: error?.message || fallback });
}
/**
* 看板数据(KPI / 库龄分布 / 超期清单 / 按编码汇总 / 差异清单 / 入库明细)
* query: date, overdueDays, status, keyword, page, pageSize, refresh=1
*/
router.get('/dashboard', async (req, res) => {
try {
const query = req.query || {};
const refresh = String(query.refresh || '') === '1';
const bizDate = String(query.date || '').trim() || todayInShanghai();
let compare = null;
// 首次打开或显式刷新时,先跑一轮「同步 + 比对」
if (refresh || !(await hasComparedToday(bizDate))) {
compare = await runDailyCompare({ date: bizDate, trigger: refresh ? 'manual' : 'api' });
}
const data = await getDashboard(query);
res.json({ success: true, data: { ...data, compare } });
} catch (error) {
sendError(res, error, '加载退货入库核销看板失败');
}
});
/** 手动触发一轮「同步 + 每日比对」;backfillDays 用于补历史(库龄分布/超期清单) */
router.post('/sync', async (req, res) => {
try {
const body = req.body || {};
const date = String(body.date || '').trim() || todayInShanghai();
const lookbackDays = body.lookbackDays === undefined ? 3 : Number(body.lookbackDays) || 0;
let backfill = null;
if (body.backfillDays) {
backfill = await backfillHistory({
days: Number(body.backfillDays) || 180,
toDate: date,
});
}
const result = await runDailyCompare({ date, trigger: 'manual', lookbackDays, bootstrapDays: 0 });
res.json({ success: true, data: { ...result, backfill } });
} catch (error) {
sendError(res, error, '同步退货入库数据失败');
}
});
/**
* 导出看板数据
* query: scope=all|detail|overdue|code|discrepancy|age, format=xlsx|csv,
* date, overdueDays, keyword, status
* xlsx 支持多工作表;csv 只能承载一张表(scope=all 时自动降级为 detail)。
*/
router.get('/export', async (req, res) => {
try {
const query = req.query || {};
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const requestedScope = String(query.scope || 'all').trim() || 'all';
const scope = format === 'csv' && requestedScope === 'all' ? 'detail' : requestedScope;
const result = await buildReturnMonitorExport({
date: query.date,
overdueDays: query.overdueDays,
keyword: query.keyword,
status: query.status,
scope,
});
console.log('[ReturnMonitor][export]', {
format,
scope: result.scope,
date: result.date,
counts: result.counts,
});
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
} catch (error) {
sendError(res, error, '导出退货入库核销数据失败');
}
});
/** 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用) */
router.get('/code-lines', async (req, res) => {
try {
const query = req.query || {};
res.json({
success: true,
data: await getInStockLinesForCode({
productCode: query.productCode,
page: query.page,
pageSize: query.pageSize,
}),
});
} catch (error) {
sendError(res, error, '加载该商品编码的在库入库行失败');
}
});
/** 处理差异清单:mode=fifo 采纳 FIFO 建议;mode=manual 手动指定行 */
router.post('/discrepancy/resolve', async (req, res) => {
try {
const body = req.body || {};
const result = await resolveDiscrepancy({
productCode: body.productCode,
mode: body.mode === 'manual' ? 'manual' : 'fifo',
ids: body.ids,
outboundDate: body.outboundDate,
operator: req.user?.username || req.user?.name || body.operator,
remark: body.remark,
});
const data = await getDashboard({});
res.json({ success: true, data: { result, dashboard: data } });
} catch (error) {
sendError(res, error, '差异核销失败');
}
});
/** 人工标记单行状态(在库 / 已出库 / 已报废) */
router.post('/inbound/mark', async (req, res) => {
try {
const body = req.body || {};
const result = await updateInboundStatus({
id: body.id,
status: body.status,
outboundDate: body.outboundDate,
operator: req.user?.username || req.user?.name || body.operator,
remark: body.remark,
});
res.json({ success: true, data: result });
} catch (error) {
sendError(res, error, '更新退货入库状态失败');
}
});
module.exports = router;

View File

@ -0,0 +1,132 @@
'use strict';
const express = require('express');
const multer = require('multer');
const {
buildExportColumns,
getReturnStockWarningExportRows,
getReturnStockWarningList,
resolveWarehouse,
resolveWarehouseKey,
} = require('../services/returnStockWarningService');
const {
clearStockWarningImport,
importStockWarning,
} = require('../services/returnStockWarningImport');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
});
const resolveScope = (value) => (['imported', 'replica'].includes(String(value)) ? String(value) : 'all');
/**
* GET /api/return-stock-warning/list —— 退货库存预警列表(客退数据监控,按仓库出数)
* warehouse: 仓库标识 cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓)
*/
router.get('/list', async (req, res, next) => {
try {
const payload = await getReturnStockWarningList({
warehouse: req.query.warehouse,
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
supplier: req.query.supplier,
sortBy: req.query.sortBy,
sortOrder: req.query.sortOrder,
qtyBasis: req.query.qtyBasis,
createdAtBasis: req.query.createdAtBasis,
scope: resolveScope(req.query.scope),
focusDays: req.query.focusDays,
alertDays: req.query.alertDays,
warningLevel: req.query.warningLevel,
priceFocusMin: req.query.priceFocusMin,
priceHighMin: req.query.priceHighMin,
priceLevel: req.query.priceLevel,
returnLimitDays: req.query.returnLimitDays,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
} catch (error) {
next(error);
}
});
/** GET /api/return-stock-warning/export —— 服务端全量导出(xlsx / csv) */
router.get('/export', async (req, res, next) => {
try {
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const keyword = String(req.query.keyword || '').trim();
const warehouse = resolveWarehouse(req.query.warehouse);
const rows = await getReturnStockWarningExportRows({
warehouse: req.query.warehouse,
keyword,
supplier: req.query.supplier,
qtyBasis: req.query.qtyBasis,
createdAtBasis: req.query.createdAtBasis,
scope: resolveScope(req.query.scope),
focusDays: req.query.focusDays,
alertDays: req.query.alertDays,
warningLevel: req.query.warningLevel,
priceFocusMin: req.query.priceFocusMin,
priceHighMin: req.query.priceHighMin,
priceLevel: req.query.priceLevel,
returnLimitDays: req.query.returnLimitDays,
refresh: req.query.refresh,
});
const exportColumns = buildExportColumns(warehouse);
sendWorkbookDownload(res, {
fileName: `${warehouse.shortName}客退数据监控_${keyword || '全部'}`,
format,
sheets: [{
name: `${warehouse.shortName}客退数据监控`,
header: exportColumns.map((column) => column.title),
rows: rows.map((row) => exportColumns.map((column) => row[column.key])),
}],
});
} catch (error) {
next(error);
}
});
/** POST /api/return-stock-warning/import —— 导入聚水潭「箱及仓位库存」导出文件 */
router.post('/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
res.status(400).json({ success: false, message: '请选择要导入的文件(xlsx / xls / csv)' });
return;
}
// multer 按 latin1 解析 multipart 文件名,中文会变成乱码,这里转回 UTF-8
const rawName = req.file.originalname || '';
const fileName = /[\u00c0-\u00ff]/.test(rawName)
? Buffer.from(rawName, 'latin1').toString('utf8')
: rawName;
const result = await importStockWarning({
buffer: req.file.buffer,
fileName,
importedBy: req.user?.username || req.user?.name || null,
// 导入落在哪个仓库下:优先取表单字段,其次 query,最后回落默认仓
warehouseKey: resolveWarehouseKey(req.body?.warehouse || req.query.warehouse),
});
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** DELETE /api/return-stock-warning/import —— 清除当前仓库的导入,回到系统推算口径 */
router.delete('/import', async (req, res, next) => {
try {
const result = await clearStockWarningImport(resolveWarehouseKey(req.query.warehouse));
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@ -0,0 +1,281 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
const {
SALES_GRADE_RULES,
SALES_TOTAL_LABEL,
STOCK_SALES_WINDOW_OPTIONS,
STOCK_SALES_DEFAULT_DAYS,
} = require('../config/stockSalesAnalysis');
const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheByDays = new Map();
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
function nextDayAsOf(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
}
/** 发货窗口:[asOf - N 天, asOf) */
function buildShipWindow(asOf, days) {
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
const from = new Date(to.getTime() - days * 86400000);
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
return { shipFrom: fmt(from), shipTo: fmt(to) };
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
async function fetchInventoryTakenAt() {
const rows = await cloudDb.query(
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
);
return rows[0]?.takenAt || null;
}
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
function resolveDays(raw) {
const n = Number.parseInt(String(raw ?? ''), 10);
return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS;
}
/**
* GET /api/stock-sales-analysis
* 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const days = resolveDays(req.query?.days);
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByDays.get(days);
if (!force && cached && cached.latestBizDate === latestBizDate
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
return res.json({ success: true, data: cached.payload, cached: true });
}
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const wmsCoIds = warehouses.map((b) => b.wmsCoId);
const [rows, takenAt] = await Promise.all([
fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
fetchInventoryTakenAt().catch(() => null),
]);
// (仓库, 等级) → 指标
const matrix = new Map();
rows.forEach((r) => {
matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r);
});
const buildBucket = (grade, cell) => ({
key: grade.key,
label: grade.label,
tip: grade.tip,
skuCount: cell?.skuCount ?? 0,
stockQty: round2(cell?.stockQty ?? 0),
salesQty: round2(cell?.salesQty ?? 0),
dailySales: Number((cell?.dailySales ?? 0).toFixed(2)),
});
const items = warehouses.map((base) => {
const id = String(base.wmsCoId);
const buckets = SALES_GRADE_RULES.map((grade) => (
buildBucket(grade, matrix.get(`${id}::${grade.key}`))
));
const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0);
const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0);
const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0));
return {
warehouse: base.warehouse,
dbWarehouse: base.dbWarehouse,
wmsCoId: base.wmsCoId,
hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0,
buckets: buckets.map((b) => ({
...b,
skuRatio: totalSku > 0 ? b.skuCount / totalSku : null,
stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null,
})),
total: {
key: '__total__',
label: SALES_TOTAL_LABEL,
skuCount: totalSku,
skuRatio: totalSku > 0 ? 1 : null,
stockQty: totalStock,
stockRatio: totalStock !== 0 ? 1 : null,
salesQty: totalSales,
dailySales: Number((totalSales / days).toFixed(2)),
},
};
});
// 全仓合计:各等级跨仓累加 + 汇总
const grandBuckets = SALES_GRADE_RULES.map((grade) => {
const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0);
const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0));
const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0));
return {
key: grade.key,
label: grade.label,
tip: grade.tip,
skuCount,
stockQty,
salesQty,
dailySales: Number((salesQty / days).toFixed(2)),
};
});
const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0);
const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0));
const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0));
const grandTotal = {
key: '__total__',
label: SALES_TOTAL_LABEL,
skuCount: grandSku,
skuRatio: grandSku > 0 ? 1 : null,
stockQty: grandStock,
stockRatio: grandStock !== 0 ? 1 : null,
salesQty: grandSales,
dailySales: Number((grandSales / days).toFixed(2)),
};
const payload = {
days,
windowOptions: STOCK_SALES_WINDOW_OPTIONS,
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
takenAt,
grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
totalLabel: SALES_TOTAL_LABEL,
items,
grand: {
warehouse: '全部仓库合计',
hasData: grandSku > 0,
buckets: grandBuckets.map((b) => ({
...b,
skuRatio: grandSku > 0 ? b.skuCount / grandSku : null,
stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null,
})),
total: grandTotal,
},
emptyWarehouseCount: items.filter((it) => !it.hasData).length,
};
cacheByDays.set(days, { latestBizDate, at: Date.now(), payload });
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[stock-sales-analysis] 查询失败:', error.message);
return res.status(500).json({
success: false,
error: '仓库库存动销分析数据查询失败',
detail: error.message,
});
}
});
/**
* GET /api/stock-sales-analysis/zero-sales-export
* 导出「销售=0」档的 SKU 级明细。
* query: days(统计窗口)、wmsCoId(可选,逗号分隔,不传=全部仓库)、format(xlsx|csv,默认 xlsx)
*/
router.get('/zero-sales-export', async (req, res) => {
try {
const days = resolveDays(req.query?.days);
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
// 仓库过滤:只允许出现在仓库清单里的 wmsCoId,防止任意拼参
const known = new Map(
DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null)
.map((b) => [String(b.wmsCoId), b])
);
const requested = String(req.query?.wmsCoId || '')
.split(',')
.map((v) => v.trim())
.filter((v) => known.has(v));
const wmsCoIds = requested.length > 0 ? requested : [...known.keys()];
const rows = await fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
const sheetRows = rows.map((r) => {
const base = known.get(r.wmsCoId);
return [
base?.warehouse || r.wmsCoId,
r.skuId,
r.skuName,
r.binName,
r.stockQty,
r.salesQty,
days,
latestBizDate || '',
];
});
const scopeLabel = requested.length === 1 ? `-${known.get(requested[0]).warehouse}` : '';
const fileName = `库存动销分析-销售0明细${scopeLabel}-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
const { totalRows } = sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '销售=0明细',
header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
rows: sheetRows,
}],
});
console.log(`[stock-sales-analysis] 销售=0明细导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[stock-sales-analysis] 销售=0明细导出失败:', error.message);
// 响应可能已部分写出(sendWorkbookDownload 之后一般不会抛),仅在未发送时回 JSON
if (!res.headersSent) {
return res.status(500).json({
success: false,
error: '销售=0明细导出失败',
detail: error.message,
});
}
return null;
}
});
module.exports = router;

View File

@ -1,999 +0,0 @@
const express = require('express');
const jianSunDb = require('../config/jianSunDatabase');
const router = express.Router();
const META_CACHE_TTL_MS = 60 * 1000;
const OVERVIEW_CACHE_TTL_MS = 60 * 1000;
const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000;
const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000;
const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000;
const HOME_COMPENSATION_SAMPLE_SIZE = 10000;
const metaCache = { value: null, expiresAt: 0, promise: null };
const overviewCache = new Map();
const overviewInFlight = new Map();
const homeSummaryCache = new Map();
const homeSummaryInFlight = new Map();
const homeReviewCache = new Map();
const homeReviewInFlight = new Map();
const homeCompensationCache = new Map();
const homeCompensationSummaryCache = new Map();
const homeCompensationSummaryInFlight = new Map();
const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']);
function normalizeDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
function addDays(value, offset) {
const date = new Date(`${value}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
function inclusiveDayCount(startDate, endDate) {
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
}
function toNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function normalizeCompensationDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
function compensationDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
}
async function loadRecentCompensationRows(shopName = '') {
const cacheKey = shopName || '*';
const cached = homeCompensationCache.get(cacheKey);
if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows;
if (cached?.promise) return cached.promise;
const promise = jianSunDb.query(
`SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE})
id, chargeAmount, chargeReason, chargeTime, couponDetailId,
goodsId, goodsName, orderSn, shopName
FROM dbo.pdd_abnormal_compensation WITH (NOLOCK)
WHERE (@shopName = '' OR shopName = @shopName)
ORDER BY id DESC`,
{ shopName }
).then((rows) => {
const normalizedRows = rows
.map((row) => ({
id: row.id,
date: compensationDate(row.chargeTime),
amount: toNumber(row.chargeAmount),
reason: String(row.chargeReason || '').trim() || '未分类',
goodsId: String(row.goodsId || '').trim(),
goodsName: String(row.goodsName || '').trim(),
orderSn: String(row.orderSn || '').trim(),
detailId: String(row.couponDetailId || '').trim(),
shopName: String(row.shopName || '').trim(),
}))
.filter((row) => row.date);
homeCompensationCache.set(cacheKey, {
rows: normalizedRows,
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
promise: null,
});
if (homeCompensationCache.size > 12) {
const firstKey = homeCompensationCache.keys().next().value;
homeCompensationCache.delete(firstKey);
}
return normalizedRows;
}).finally(() => {
const entry = homeCompensationCache.get(cacheKey);
if (entry?.promise) homeCompensationCache.delete(cacheKey);
});
homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise });
return promise;
}
async function resolveCompensationDateRange(req, rows) {
const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), '');
const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate;
const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
if (!startDate || !endDate) {
const error = new Error('赔付数据没有可用日期');
error.statusCode = 404;
throw error;
}
const dayCount = inclusiveDayCount(startDate, endDate);
if (dayCount < 1) {
const error = new Error('赔付开始日期不能晚于结束日期');
error.statusCode = 400;
throw error;
}
if (dayCount > 90) {
const error = new Error('赔付查询日期范围不能超过90天');
error.statusCode = 400;
throw error;
}
return { startDate, endDate, dayCount, latestAvailableDate };
}
function createCompensationGroup(row) {
return {
amount: 0,
recordCount: 0,
orderKeys: new Set(),
...row,
};
}
function addCompensationRecord(group, row) {
group.amount += row.amount;
group.recordCount += 1;
group.orderKeys.add(row.orderSn || row.detailId || String(row.id));
}
function queryHomeCompensationSummary(rows, range) {
const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate);
const orderKeys = new Set();
const goodsKeys = new Set();
const reasonGroups = new Map();
const goodsGroups = new Map();
let totalAmount = 0;
selectedRows.forEach((row) => {
const orderKey = row.orderSn || row.detailId || String(row.id);
orderKeys.add(orderKey);
if (row.goodsId) goodsKeys.add(row.goodsId);
totalAmount += row.amount;
const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason });
addCompensationRecord(reasonGroup, row);
reasonGroups.set(row.reason, reasonGroup);
const goodsKey = row.goodsId || `unknown-${row.id}`;
const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({
goodsId: row.goodsId || '未识别商品ID',
goodsName: row.goodsName,
shopName: row.shopName,
});
addCompensationRecord(goodsGroup, row);
if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName;
if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName;
goodsGroups.set(goodsKey, goodsGroup);
});
const toGroupResult = (group) => ({
...group,
orderCount: group.orderKeys.size,
amount: Number(group.amount.toFixed(2)),
share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0,
orderKeys: undefined,
});
const reasonDistribution = [...reasonGroups.values()]
.map(toGroupResult)
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
.slice(0, 6);
const highCompensationGoods = [...goodsGroups.values()]
.map(toGroupResult)
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
.slice(0, 6);
return {
...range,
sampledRecordCount: rows.length,
selectedRecordCount: selectedRows.length,
compensationCount: orderKeys.size,
recordCount: selectedRows.length,
totalAmount: Number(totalAmount.toFixed(2)),
averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0,
affectedGoodsCount: goodsKeys.size,
reasonDistribution,
highCompensationGoods,
};
}
function toNullableNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function averageNullable(rows, field) {
const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null);
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
}
function cleanShopName(value) {
const text = String(value || '').trim();
return text || '';
}
function shopFilterSql() {
return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)';
}
async function loadMeta() {
if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value;
if (metaCache.promise) return metaCache.promise;
metaCache.promise = Promise.all([
jianSunDb.query(
`SELECT
shopName,
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
FROM dbo.pdd_DataNormal
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
GROUP BY shopName
ORDER BY shopName`
),
jianSunDb.query(
`SELECT
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
FROM dbo.pdd_DataNormal
WHERE statDate IS NOT NULL`
),
]).then(([shopRows, dateRows]) => {
const shops = shopRows
.map((row) => ({
name: cleanShopName(row.shopName),
minDate: normalizeDate(row.minDate),
latestDate: normalizeDate(row.latestDate),
}))
.filter((row) => row.name);
const globalDate = dateRows[0] || {};
const data = {
shops,
minDate: normalizeDate(globalDate.minDate),
latestDate: normalizeDate(globalDate.latestDate),
};
metaCache.value = data;
metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS;
return data;
}).finally(() => {
metaCache.promise = null;
});
return metaCache.promise;
}
async function resolveContext(req) {
const meta = await loadMeta();
const shopName = cleanShopName(req.query.shopName);
const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null;
const requestedDate = normalizeDate(req.query.date);
const date = requestedDate || shop?.latestDate || meta.latestDate;
return { meta, shopName, date };
}
async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) {
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT ${fields},
ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber
FROM dbo.${table}
WHERE ${shopFilterSql()}
AND [${dateColumn}] = @targetDate
)
SELECT * FROM ranked WHERE rowNumber = 1`,
{ targetDate, shopName: shopName || null }
);
return rows;
}
async function queryBusinessSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_DataNormal',
dateColumn: 'statDate',
targetDate,
shopName,
fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto',
});
const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0);
const averageConversion = averageNullable(rows, 'payUvRto');
const count = rows.length;
return {
shopCount: count,
sales,
averageAov: averageNullable(rows, 'payOrdrAup'),
averageConversion: averageConversion === null ? null : averageConversion * 100,
rows,
};
}
async function queryAfterSalesSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_ShopNormalOverflow',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
});
const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt'];
const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {});
return { ...data, shopCount: rows.length, rows };
}
async function queryCustomerServiceSnapshot(targetDate, shopName) {
const [refundRows, receptionRows] = await Promise.all([
queryLatestRows({
table: 'pdd_DataCustomerService',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr',
}),
jianSunDb.query(
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_customer_overflow
WHERE ${shopFilterSql()}
AND [createTime] = @targetDate
)
SELECT
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount,
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount,
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount,
AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime,
SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount,
COUNT(DISTINCT cs_name) AS customerServiceCount
FROM ranked
WHERE rowNumber = 1 AND cs_name = N'店铺总计'`,
{ targetDate, shopName: shopName || null }
),
]);
const refundFields = [
'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d',
];
const refundTotals = refundFields.reduce((result, field) => ({
...result,
[field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0),
}), {});
const averageFields = [
'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m',
'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr',
];
const averages = averageFields.reduce((result, field) => ({
...result,
[field]: averageNullable(refundRows, field),
}), {});
return {
...refundTotals,
...averages,
shopCount: refundRows.length,
reception: receptionRows[0] || {},
rows: refundRows,
};
}
async function queryServiceScoreSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_MallServiceScore',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore',
});
const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore'];
const values = fields.reduce((result, field) => ({
...result,
[field]: averageNullable(rows, field),
}), {});
return { ...values, shopCount: rows.length, rows };
}
async function queryStarsSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_ShopStars',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk',
});
const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk'];
const values = averageFields.reduce((result, field) => ({
...result,
[field]: averageNullable(rows, field),
}), {});
const first = rows[0] || {};
const targetFields = [
['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'],
['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'],
['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'],
['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'],
['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'],
['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'],
['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'],
['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'],
['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'],
['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'],
];
const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({
field,
label,
value: toNullableNumber(first[field]),
status: first[statusField] || null,
threshold: toNullableNumber(first[thresholdField]),
})) : [];
const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0);
return { ...values, shopCount: rows.length, targets, targetFailureCount, rows };
}
function includesFailure(value) {
const text = String(value || '').trim();
return text.includes('不达标') || text.includes('未达标') || text.includes('下降');
}
function buildIssues({ afterSales, customerService, stars }) {
const issues = [];
const addIssue = (key, tone, title, value, description, source) => {
if (toNumber(value) > 0 || includesFailure(value)) {
issues.push({ key, tone, title, value, description, source });
}
};
addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后');
addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后');
addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待');
addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待');
addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级');
return issues;
}
async function queryOverview(context) {
const { shopName, date } = context;
const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([
queryBusinessSnapshot(date, shopName),
queryAfterSalesSnapshot(date, shopName),
queryCustomerServiceSnapshot(date, shopName),
queryServiceScoreSnapshot(date, shopName),
queryStarsSnapshot(date, shopName),
]);
const reception = customerService.reception || {};
return {
date,
shopName: shopName || '全部店铺',
shopCount: business.shopCount,
business: {
sales: business.sales,
averageAov: business.averageAov,
averageConversion: business.averageConversion,
},
afterSales: {
afterSalesCount: afterSales.afterSalesCount,
executedCount: afterSales.executedCount,
waitSellerHandle: afterSales.waitSellerHandle,
platformHandling: afterSales.platformHandling,
waitBuyerHandle: afterSales.waitBuyerHandle,
todosCount: afterSales.todosCount,
todosEndedCount: afterSales.todosEndedCount,
cfmOrdrCnt: afterSales.cfmOrdrCnt,
},
customerService: {
...customerService,
reception: {
consultUserCount: toNumber(reception.consultUserCount),
inquiryUserCount: toNumber(reception.inquiryUserCount),
finalGroupUserCount: toNumber(reception.finalGroupUserCount),
csSalesAmount: toNumber(reception.csSalesAmount),
needManualReplyCount: toNumber(reception.needManualReplyCount),
receiveUserCount: toNumber(reception.receiveUserCount),
delayReplyCount: toNumber(reception.delayReplyCount),
replyRate3Min: toNullableNumber(reception.replyRate3Min),
replyRate30s: toNullableNumber(reception.replyRate30s),
unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount),
complainUserCount: toNumber(reception.complainUserCount),
averageReplyTime: toNullableNumber(reception.averageReplyTime),
serviceRefundCount: toNumber(reception.serviceRefundCount),
customerServiceCount: toNumber(reception.customerServiceCount),
},
},
serviceScore: {
cstmrServScore: serviceScore.cstmrServScore,
attiLmScore: serviceScore.attiLmScore,
jcfwLmScore: serviceScore.jcfwLmScore,
spLmScore: serviceScore.spLmScore,
fhLmScore: serviceScore.fhLmScore,
wlLmScore: serviceScore.wlLmScore,
},
stars: {
mallStarTomms: stars.mallStarTomms,
mallStarTommsChange: stars.mallStarTommsChange,
dsr3m: stars.dsr3m,
ptHelpRate1m: stars.ptHelpRate1m,
aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m,
targets: stars.targets,
},
issues: buildIssues({ afterSales, customerService, stars }),
coverage: {
business: business.shopCount > 0,
afterSales: afterSales.shopCount > 0,
customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
serviceScore: serviceScore.shopCount > 0,
stars: stars.shopCount > 0,
},
};
}
async function queryHomeSummary(context) {
const { shopName, date } = context;
const [afterSales, customerService, serviceScore, stars] = await Promise.all([
queryAfterSalesSnapshot(date, shopName),
queryCustomerServiceSnapshot(date, shopName),
queryServiceScoreSnapshot(date, shopName),
queryStarsSnapshot(date, shopName),
]);
const reception = customerService.reception || {};
const riskItems = [
{
key: 'waitSellerHandle',
label: '待商家处理',
value: toNumber(afterSales.waitSellerHandle),
tone: 'error',
},
{
key: 'delayReplyCount',
label: '三分钟未回复',
value: toNumber(reception.delayReplyCount),
tone: 'warning',
},
{
key: 'complainUserCount',
label: '客服投诉',
value: toNumber(reception.complainUserCount),
tone: 'error',
},
{
key: 'starTargetFailure',
label: '星级未达标项',
value: toNumber(stars.targetFailureCount),
tone: 'warning',
},
];
const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0);
const riskTypeCount = riskItems.filter((item) => item.value > 0).length;
const coverageModules = [
afterSales.shopCount > 0,
customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
serviceScore.shopCount > 0,
stars.shopCount > 0,
].filter(Boolean).length;
return {
date,
shopName: shopName || '全部店铺',
shopCount: Math.max(
toNumber(afterSales.shopCount),
toNumber(customerService.shopCount),
toNumber(serviceScore.shopCount),
toNumber(stars.shopCount)
),
totalShopCount: shopName ? 1 : context.meta.shops.length,
coverageModules,
totalModules: 4,
status: riskSignalCount > 0 ? 'attention' : 'healthy',
riskSignalCount,
riskTypeCount,
averageStar: toNullableNumber(stars.mallStarTomms),
starChange: toNullableNumber(stars.mallStarTommsChange),
evaluationScore: toNullableNumber(stars.dsr3m),
riskItems,
serviceScores: [
{ key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) },
{ key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) },
{ key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) },
{ key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) },
],
};
}
async function resolveReviewDateRange(req) {
const shopName = cleanShopName(req.query.shopName);
let endDate = normalizeDate(req.query.endDate);
if (!endDate) {
const rows = await jianSunDb.query(
`SELECT TOP (1) LEFT(createTime, 10) AS latestDate
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= '2000-01-01'
AND createTime < '2100-01-01'
AND descScore BETWEEN 1 AND 5
AND (@shopName = '' OR shopName = @shopName)
ORDER BY createTime DESC`,
{ shopName }
);
endDate = normalizeDate(rows[0]?.latestDate);
}
const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
if (!startDate || !endDate) {
const error = new Error('评价数据没有可用日期');
error.statusCode = 404;
throw error;
}
const dayCount = inclusiveDayCount(startDate, endDate);
if (dayCount < 1) {
const error = new Error('评价开始日期不能晚于结束日期');
error.statusCode = 400;
throw error;
}
if (dayCount > 90) {
const error = new Error('评价查询日期范围不能超过90天');
error.statusCode = 400;
throw error;
}
return { startDate, endDate, dayCount, shopName };
}
async function queryHomeReviewSummary(range) {
const params = {
startDate: range.startDate,
endDateExclusive: addDays(range.endDate, 1),
shopName: range.shopName || '',
};
const [summaryRows, keywordRows] = await Promise.all([
jianSunDb.query(
`SELECT
COUNT_BIG(1) AS totalReviewCount,
SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount,
SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount,
SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count,
SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count,
SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count,
SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count,
SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count,
AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore,
COUNT(DISTINCT goodsId) AS reviewedGoodsCount,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= @startDate
AND createTime < @endDateExclusive
AND descScore BETWEEN 1 AND 5
AND (@shopName = '' OR shopName = @shopName)`,
params
),
jianSunDb.query(
`SELECT keywords, COUNT_BIG(1) AS reviewCount
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= @startDate
AND createTime < @endDateExclusive
AND descScore BETWEEN 1 AND 3
AND (@shopName = '' OR shopName = @shopName)
AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL
GROUP BY keywords`,
params
),
]);
const row = summaryRows[0] || {};
const totalReviewCount = toNumber(row.totalReviewCount);
const goodReviewCount = toNumber(row.goodReviewCount);
const badReviewCount = toNumber(row.badReviewCount);
const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({
score,
count: toNumber(row[`score${score}Count`]),
}));
const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/;
const badKeywordCounts = new Map();
keywordRows.forEach((item) => {
const weight = toNumber(item.reviewCount);
String(item.keywords || '').split(/[,,]/).forEach((value) => {
const keyword = value.trim();
if (!keyword || !negativeKeywordPattern.test(keyword)) return;
badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight);
});
});
const badKeywords = [...badKeywordCounts.entries()]
.map(([keyword, count]) => ({ keyword, count }))
.sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN'))
.slice(0, 5);
return {
...range,
totalReviewCount,
goodReviewCount,
goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0,
badReviewCount,
badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0,
averageScore: toNullableNumber(row.averageScore),
reviewedGoodsCount: toNumber(row.reviewedGoodsCount),
reviewedShopCount: toNumber(row.reviewedShopCount),
distribution: scoreCounts.map((item) => ({
...item,
rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0,
})),
badKeywords,
};
}
async function queryBusinessTab(context) {
const startDate = addDays(context.date, -29);
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT
statDate,
shopName, cfmOrdrAmt, payOrdrAup, payUvRto,
ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_DataNormal
WHERE ${shopFilterSql()}
AND statDate BETWEEN @startDate AND @targetDate
)
SELECT statDate,
SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales,
AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov,
AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion,
COUNT(*) AS shopCount
FROM ranked
WHERE rowNumber = 1
GROUP BY statDate
ORDER BY statDate`,
{ startDate, targetDate: context.date, shopName: context.shopName || '' }
);
return {
status: rows.length ? 'ready' : 'empty',
metrics: [
{ key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' },
{ key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' },
{ key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' },
],
trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })),
};
}
async function queryAfterSalesTab(context) {
const rows = await queryLatestRows({
table: 'pdd_ShopNormalOverflow',
dateColumn: 'createTime',
targetDate: context.date,
shopName: context.shopName,
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
});
return {
status: rows.length ? 'ready' : 'empty',
rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' },
{ key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' },
{ key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' },
],
};
}
async function queryCustomerServiceTab(context) {
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_customer_overflow
WHERE ${shopFilterSql()}
AND [createTime] = @targetDate
)
SELECT
cs_name AS csName,
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount
FROM ranked
WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计'
GROUP BY cs_name
ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`,
{ targetDate: context.date, shopName: context.shopName || '' }
);
return {
status: rows.length ? 'ready' : 'empty',
rows,
columns: [
{ key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' },
{ key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' },
{ key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' },
],
};
}
async function queryServiceScoreTab(context) {
const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName);
return {
status: snapshot.shopCount ? 'ready' : 'empty',
metrics: [
{ key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' },
{ key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' },
{ key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' },
{ key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' },
{ key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' },
{ key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' },
],
rows: snapshot.rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' },
{ key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' },
],
};
}
async function queryStarsTab(context) {
const snapshot = await queryStarsSnapshot(context.date, context.shopName);
return {
status: snapshot.shopCount ? 'ready' : 'empty',
metrics: [
{ key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' },
{ key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' },
{ key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' },
{ key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' },
{ key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' },
],
targets: snapshot.targets,
rows: snapshot.rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' },
{ key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' },
],
};
}
router.get('/meta', async (_req, res) => {
try {
res.json({ success: true, data: await loadMeta() });
} catch (error) {
console.error('[StoreCheckup] 查询元数据失败:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/overview', async (req, res) => {
try {
const context = await resolveContext(req);
const cacheKey = `${context.shopName || '*'}:${context.date}`;
const cached = overviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = overviewInFlight.get(cacheKey);
if (!pending) {
pending = queryOverview(context)
.then((data) => {
overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS });
return data;
})
.finally(() => overviewInFlight.delete(cacheKey));
overviewInFlight.set(cacheKey, pending);
}
const data = await pending;
res.json({ success: true, data });
} catch (error) {
console.error('[StoreCheckup] 查询体检概览失败:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/home-summary', async (req, res) => {
try {
const context = await resolveContext(req);
const cacheKey = `${context.shopName || '*'}:${context.date}`;
const cached = homeSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
const refreshSummary = () => {
let pending = homeSummaryInFlight.get(cacheKey);
if (pending) return pending;
pending = queryHomeSummary(context)
.then((data) => {
const now = Date.now();
homeSummaryCache.set(cacheKey, {
data,
expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS,
staleUntil: now + HOME_SUMMARY_STALE_TTL_MS,
});
return data;
})
.finally(() => homeSummaryInFlight.delete(cacheKey));
homeSummaryInFlight.set(cacheKey, pending);
return pending;
};
if (cached && cached.staleUntil > Date.now()) {
refreshSummary().catch((error) => {
console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message);
});
return res.json({ success: true, data: { ...cached.data, refreshing: true } });
}
return res.json({ success: true, data: await refreshSummary() });
} catch (error) {
console.error('[StoreCheckup] 查询首页摘要失败:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
router.get('/home-reviews', async (req, res) => {
try {
const range = await resolveReviewDateRange(req);
const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`;
const cached = homeReviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = homeReviewInFlight.get(cacheKey);
if (!pending) {
pending = queryHomeReviewSummary(range)
.then((data) => {
homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS });
return data;
})
.finally(() => homeReviewInFlight.delete(cacheKey));
homeReviewInFlight.set(cacheKey, pending);
}
return res.json({ success: true, data: await pending });
} catch (error) {
console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message);
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/home-compensation', async (req, res) => {
try {
const shopName = cleanShopName(req.query.shopName);
const rows = await loadRecentCompensationRows(shopName);
const range = { ...(await resolveCompensationDateRange(req, rows)), shopName };
const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`;
const cached = homeCompensationSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = homeCompensationSummaryInFlight.get(cacheKey);
if (!pending) {
pending = Promise.resolve(queryHomeCompensationSummary(rows, range))
.then((data) => {
homeCompensationSummaryCache.set(cacheKey, {
data,
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
});
return data;
})
.finally(() => homeCompensationSummaryInFlight.delete(cacheKey));
homeCompensationSummaryInFlight.set(cacheKey, pending);
}
return res.json({ success: true, data: await pending });
} catch (error) {
console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message);
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/tabs/:tabKey', async (req, res) => {
try {
const tabKey = String(req.params.tabKey || '').trim();
if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') {
return res.status(400).json({ success: false, error: '不支持的店铺体检模块' });
}
const context = await resolveContext(req);
const handlers = {
business: queryBusinessTab,
'after-sales': queryAfterSalesTab,
'customer-service': queryCustomerServiceTab,
'service-score': queryServiceScoreTab,
stars: queryStarsTab,
};
const data = await handlers[tabKey](context);
return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } });
} catch (error) {
console.error('[StoreCheckup] 查询体检模块失败:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

View File

@ -0,0 +1,173 @@
'use strict';
/**
* 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-logistics-exception/list
* query:
* date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
* limit 明细条数上限(默认 500,最大 2000)
* force 1 = 绕过 5 分钟结果缓存
* GET /api/warehouse-logistics-exception/export
* 同 /list 口径的不分页明细(导出用,上限 5000)
*
* 每天 09:10 推 Excel 到钉钉群(2026-10-22 新增):
* GET /api/warehouse-logistics-exception/notify/settings 读配置(AppSecret 只回掩码)
* PUT /api/warehouse-logistics-exception/notify/settings 保存配置(AppSecret 留空=不变)
* POST /api/warehouse-logistics-exception/notify/run 立即推送一次(force / dryRun)
* GET /api/warehouse-logistics-exception/notify/state 上次推送结果
*
* 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware
*/
const express = require('express');
const {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
} = require('../services/warehouseLogisticsExceptionService');
const {
CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR,
} = require('../services/warehouseLogisticsExceptionRegistry');
const {
getWarehouseLogisticsExceptionNotifyAdminConfig,
loadWarehouseLogisticsExceptionNotifyState,
missingFields,
saveWarehouseLogisticsExceptionNotifyConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
} = require('../services/warehouseLogisticsExceptionNotifySettings');
const {
pushWarehouseLogisticsExceptionNotify,
} = require('../jobs/warehouseLogisticsExceptionNotifyJob');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[WarehouseLogisticsException]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 转运异常总览:12 个发货仓异常单量 + 明细
router.get('/list', async (req, res) => {
try {
const data = await getLogisticsExceptions(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '加载物流转运异常失败');
}
});
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
router.get('/export', async (req, res) => {
try {
const data = await getLogisticsExceptionExportRows(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '导出物流转运异常失败');
}
});
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
router.get('/meta', (_req, res) => {
res.json({
success: true,
data: {
verifyHour: VERIFY_HOUR,
lookbackDays: LOOKBACK_DAYS,
centers: CENTERS.map((c) => ({ key: c.key, name: c.name })),
exceptionTypes: EXCEPTION_TYPES,
},
});
});
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
// 读配置:AppSecret 只回掩码 + 是否已配置,明文永不出后端
router.get('/notify/settings', async (_req, res) => {
try {
const [config, state] = await Promise.all([
getWarehouseLogisticsExceptionNotifyAdminConfig(),
loadWarehouseLogisticsExceptionNotifyState(),
]);
res.json({ success: true, data: { ...config, lastState: state || {} } });
} catch (error) {
sendError(res, error, '读取异常通知配置失败');
}
});
// 保存配置;AppSecret 留空表示「沿用已存的」,不必每次重填
router.put('/notify/settings', async (req, res) => {
try {
const data = await saveWarehouseLogisticsExceptionNotifyConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
const status = Number(error?.status) || 400;
console.error('[WarehouseLogisticsException] 保存通知配置失败:', error?.message);
res.status(status).json({ success: false, error: error.message || '保存异常通知配置失败' });
}
});
router.get('/notify/state', async (_req, res) => {
try {
const state = await loadWarehouseLogisticsExceptionNotifyState();
res.json({ success: true, data: state || {} });
} catch (error) {
sendError(res, error, '读取推送状态失败');
}
});
/**
* 立即推送一次(页面「立即推送」/「预览」/「测试通道」)。
* body:
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
* force true = 忽略「今天已经推过」(默认手动触发就是 true,除非显式给 false)
* date 指定核验日
*/
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 getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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 pushWarehouseLogisticsExceptionNotify({
dryRun,
probe,
force,
date: body.date || undefined,
});
// 查完这一轮记得让页面上的状态同步(lastState 由前端重新拉 settings 获取)
res.json({ success: true, data: result });
} catch (error) {
const status = Number(error?.status) || 500;
console.error('[WarehouseLogisticsException] 推送失败:', {
message: error?.message,
code: error?.code,
dingtalk: error?.dingtalk,
});
res.status(status).json({
success: false,
error: error.message || '推送到钉钉群失败',
data: { code: error?.code ?? null },
});
}
});
module.exports = router;

View File

@ -0,0 +1,174 @@
'use strict';
/**
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-ship-timeliness/list
* query:
* round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定
* limit 明细条数上限(默认 500,最大 2000)
*
* 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
*/
const express = require('express');
const {
getShipTimeliness,
listShipTimelinessExportRows,
} = require('../services/warehouseShipTimelinessService');
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
const {
getShipTimelinessNotifyAdminConfig,
getShipTimelinessNotifyRuntimeConfig,
loadShipTimelinessNotifyState,
missingFields,
saveShipTimelinessNotifyConfig,
} = require('../services/warehouseShipTimelinessNotifySettings');
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[WarehouseShipTimeliness]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 时效总览:每个主仓「超时未发」数量 + 超时订单明细
router.get('/list', async (req, res) => {
try {
const data = await getShipTimeliness(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '加载仓储中心发货时效失败');
}
});
// 仅轮次信息(不查订单)
router.get('/rounds', async (_req, res) => {
try {
res.json({
success: true,
data: {
rounds: describeRounds(new Date()),
centers: CENTERS.map((c) => ({
key: c.key,
name: c.name,
placeholder: Boolean(c.placeholder),
})),
},
});
} catch (error) {
sendError(res, error, '加载核验轮次失败');
}
});
/**
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
*
* 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;

View File

@ -19,6 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
let pool = null;
let activeCloudConfig = null;
let cachedCloudConfigCandidates = null;
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
let cachedCloudConfigExpiresAt = 0;
function isPoolUsable(activePool) {
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
@ -30,7 +32,11 @@ const parsePositiveInt = (value, fallback) => {
};
const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000);
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000);
// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。
// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000);
// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。
const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000);
const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10);
const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000);
const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
@ -536,6 +542,10 @@ function normalizeMssqlConfig(rawConfig, meta = {}) {
trustServerCertificate: true,
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
// 业务库的 DATETIME 存的是北京时间(无时区标记)。mssql 驱动默认 useUTC=true 会把它
// 当成 UTC 解析,而本项目所有时间格式化都是用本地 getter 还原墙上时间,两者叠加会让
// 显示值整体 +8 小时(例如库里 16:56:07 被显示成次日 00:56:07)。按本地时间解析才对得上。
useUTC: false,
},
pool: {
max: CLOUD_DB_POOL_MAX,
@ -673,7 +683,15 @@ async function loadCandidateConfig(localPool, profile) {
}
async function resolveCloudConfigCandidates() {
if (cachedCloudConfigCandidates) {
// 缓存有效判据:**非 null** 且在有效期内。
//
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
//
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
return cachedCloudConfigCandidates;
}
@ -705,6 +723,7 @@ async function resolveCloudConfigCandidates() {
}
cachedCloudConfigCandidates = candidates;
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
return cachedCloudConfigCandidates;
} catch (error) {
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
@ -716,15 +735,23 @@ async function resolveCloudConfigCandidates() {
}
}
cachedCloudConfigCandidates = [];
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
const fallbacks = [];
if (hasCompleteEnvConfig(process.env, 'default')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
}
if (hasCompleteEnvConfig(process.env, 'public')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
}
cachedCloudConfigCandidates = fallbacks;
cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS;
console.warn(
`[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),`
+ `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试`
);
return cachedCloudConfigCandidates;
}
@ -737,7 +764,20 @@ async function connectWithCandidate(candidate) {
}
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
// request.timeout(请求级)在 mssql 12.x 上实测不生效(始终按池配置的默认值掐断),
// 慢查询必须通过「池配置级 requestTimeout」放宽,这里允许调用方覆盖候选连接的池级超时。
let mssqlConfig = candidate.mssqlConfig;
const poolRequestTimeout = parsePositiveInt(options.connectRequestTimeoutMs, 0);
if (poolRequestTimeout > 0) {
mssqlConfig = {
...candidate.mssqlConfig,
options: {
...candidate.mssqlConfig.options,
requestTimeout: poolRequestTimeout,
},
};
}
const nextPool = new sql.ConnectionPool(mssqlConfig);
try {
await nextPool.connect();
@ -961,10 +1001,21 @@ const getPool = async () => {
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
};
/**
* 统一失效「云配置候选」缓存。
*
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
*/
function invalidateCloudConfigCache() {
cachedCloudConfigCandidates = null;
cachedCloudConfigExpiresAt = 0;
}
const resetPool = async () => {
if (!pool) {
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
return;
}
@ -975,7 +1026,7 @@ const resetPool = async () => {
} finally {
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
}
};
@ -1026,6 +1077,14 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
//
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
//
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
throw new Error(NOT_CONFIGURED_MESSAGE);
}
@ -1047,11 +1106,36 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
return [];
};
/**
* 慢查询专用入口:走独立临时连接,池级 requestTimeout 放宽到 SLOW_QUERY_POOL_TIMEOUT_MS。
* 背景:mssql 12.x 的 request.timeout(请求级)不生效,超过池默认 60s 的查询必须用
* 池配置级 requestTimeout 才能放宽。适用场景:动销分析这类多表大窗口聚合。
*/
const SLOW_QUERY_POOL_TIMEOUT_MS = parsePositiveInt(
process.env.CLOUD_DB_SLOW_QUERY_TIMEOUT_MS,
300000
);
const querySlow = async (sqlText, params = {}, options = {}) => {
const poolRequestTimeout = parsePositiveInt(
options.connectRequestTimeoutMs,
SLOW_QUERY_POOL_TIMEOUT_MS
);
return queryWithFreshConnection(sqlText, params, {
...options,
connectRequestTimeoutMs: poolRequestTimeout,
requestTimeout: poolRequestTimeout,
});
};
/**
* 测试连接
*/
const test = async () => {
try {
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
invalidateCloudConfigCache();
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
return { success: false, message: NOT_CONFIGURED_MESSAGE };
@ -1080,7 +1164,7 @@ const close = async () => {
}
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
};
/**
@ -1094,6 +1178,7 @@ const reload = async () => {
module.exports = {
getPool,
query,
querySlow,
queryWithFreshConnection,
createPinnedQueryClient,
test,

View File

@ -0,0 +1,216 @@
/**
* 每日进销存 —— 仓库清单(纯映射,不含业务数值)
*
* 重要:本文件只描述「要统计哪些仓库」以及它们与聚水潭 wms_co_id 的对应关系,
* 不再硬编码任何业务数值。所有数字都从数据库取:
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存
* - 期末数量:业务库实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓)
* - 周转率 / 四级库存量:近 30 天发货 × 实时库存 × SKU 库龄,见 src/api/dailyInventory.js
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
* 见 src/services/dailyInventoryFlows.js);其余 8 列缺调拨/盘点/加工/其它出入库单据表,
* 暂无法显示(前端渲染为「—」)
*
* 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。
*/
/** 一级/二级菜单名 */
const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
const DAILY_INVENTORY_LEVEL2 = '每日进销存';
/** 12 个流水列的中文表头与字段映射(顺序与聚水潭进销存导出一致) */
const FLOW_COLUMNS = [
{
"label": "加工进仓",
"field": "processIn"
},
{
"label": "采购退货",
"field": "purchaseReturn"
},
{
"label": "采购进仓",
"field": "purchaseIn"
},
{
"label": "其它进仓",
"field": "otherIn"
},
{
"label": "其它退货",
"field": "otherReturn"
},
{
"label": "销售出仓",
"field": "salesOut"
},
{
"label": "盘点",
"field": "stocktaking"
},
{
"label": "加工出仓",
"field": "processOut"
},
{
"label": "调拨出",
"field": "transferOut"
},
{
"label": "销售退货",
"field": "salesReturn"
},
{
"label": "调拨入",
"field": "transferIn"
},
{
"label": "其它出库",
"field": "otherOut"
}
];
/** 纳入口径的仓库(warehouse = 聚水潭主仓名,dbWarehouse = 库存监控表仓库名) */
const DAILY_INVENTORY_BASELINE = [
{
"warehouse": "简尚-崔桥工厂半成品包装项目仓主仓",
"dbWarehouse": "简尚-崔桥工厂半成品包装项目仓",
"wmsCoId": 15718196
},
{
"warehouse": "简尚-崔桥工厂仓主仓",
"dbWarehouse": "简尚-崔桥工厂仓",
"wmsCoId": 15038426
},
{
"warehouse": "简尚-山东青岗集仓主仓",
"dbWarehouse": "简尚-山东青岗集仓",
"wmsCoId": 15137523
},
{
"warehouse": "简尚-河南洛宁仓主仓",
"dbWarehouse": "简尚-河南洛宁仓",
"wmsCoId": 15539084
},
{
"warehouse": "简尚-新苏北仓主仓",
"dbWarehouse": "简尚-新苏北仓",
"wmsCoId": 15038347
},
{
"warehouse": "简尚-横林二仓主仓",
"dbWarehouse": "简尚-横林二仓",
"wmsCoId": 15033302
},
{
"warehouse": "简尚-横林三仓主仓",
"dbWarehouse": "简尚-横林三仓",
"wmsCoId": 15043561
},
{
"warehouse": "简尚-山东品牌仓主仓",
"dbWarehouse": "简尚-山东品牌仓",
"wmsCoId": 15182705
},
{
"warehouse": "简尚-睢宁仓主仓",
"dbWarehouse": "简尚-睢宁仓",
"wmsCoId": 15032603
},
{
"warehouse": "简尚-分销部专用仓主仓",
"dbWarehouse": "简尚-分销部专用仓",
"wmsCoId": 15045426
},
{
"warehouse": "简尚-新外贸仓主仓",
"dbWarehouse": "简尚-新外贸仓",
"wmsCoId": 15045308
},
{
"warehouse": "简尚-分销部睢宁专用仓主仓",
"dbWarehouse": "简尚-分销部睢宁专用仓",
"wmsCoId": 15051960
},
{
"warehouse": "京仓-山东青岗集京东自营仓主仓",
"dbWarehouse": "京仓-山东青岗集京东自营仓",
"wmsCoId": 15835935
},
{
"warehouse": "简尚-崔桥工厂品牌仓主仓",
"dbWarehouse": "简尚-崔桥工厂品牌仓",
"wmsCoId": 15870495
},
{
"warehouse": "简尚-分销部苏北专用仓主仓",
"dbWarehouse": "简尚-分销部苏北专用仓",
"wmsCoId": 15045325
},
{
"warehouse": "简尚-运营C横林二仓专用仓主仓",
"dbWarehouse": "简尚-运营C横林二仓专用仓",
"wmsCoId": 15301814
},
{
"warehouse": "简尚-济南外贸仓主仓",
"dbWarehouse": "简尚-济南外贸仓",
"wmsCoId": 15051888
},
{
"warehouse": "简尚-分销部崔桥专用仓主仓",
"dbWarehouse": "简尚-分销部崔桥专用仓",
"wmsCoId": 15404402
},
{
"warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
"dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
"wmsCoId": 15301815
},
{
"warehouse": "简尚-运营D苏北专用仓主仓",
"dbWarehouse": "简尚-运营D苏北专用仓",
"wmsCoId": 15406876
},
{
"warehouse": "简尚-运营C睢宁专用仓主仓",
"dbWarehouse": "简尚-运营C睢宁专用仓",
"wmsCoId": 15301813
},
{
"warehouse": "简尚-运营C新苏北专用仓主仓",
"dbWarehouse": "简尚-运营C新苏北专用仓",
"wmsCoId": 15301812
},
{
"warehouse": "简尚-运营D睢宁专用仓主仓",
"dbWarehouse": "简尚-运营D睢宁专用仓",
"wmsCoId": 15051907
},
{
"warehouse": "简尚-山东青岗集京东仓主仓",
"dbWarehouse": "简尚-山东青岗集京东仓",
"wmsCoId": 15406664
},
{
"warehouse": "简尚-运营D专用仓库主仓",
"dbWarehouse": "简尚-运营D专用仓库",
"wmsCoId": 15051915
},
{
"warehouse": "简尚-崔桥工厂京东仓主仓",
"dbWarehouse": "简尚-崔桥工厂京东仓",
"wmsCoId": 15045345
},
{
"warehouse": "京仓-横林二仓京东自营仓主仓",
"dbWarehouse": "京仓-横林二仓京东自营仓",
"wmsCoId": 15835893
}
];
module.exports = {
DAILY_INVENTORY_LEVEL1,
DAILY_INVENTORY_LEVEL2,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};

View File

@ -0,0 +1,184 @@
/**
* 每日进销存 —— 聚水潭「官方进销存导出」校正数据
*
* 背景:业务库(MSSQL / JSReportReplica)只能还原 4/12 个流水列,且口径与
* 聚水潭进销存报表不一致(采购按单据日期 vs 实际入库、销售出仓按订单发货 vs 出库单等),
* 其余 8 个流水列(调拨/盘点/加工/其它出入库)业务库根本没有对应单据表。
* 因此「每日进销存」以聚水潭官方导出的进销存报表为准:
* 官方有值 → 直接采用(source = official)
* 官方无值 → 回退业务库自动推算(source = derived)
*
* 存储:本地 MySQL 表 daily_inventory_official,主键 (biz_date, warehouse)。
*/
const { getPool } = require('./dailyInventorySnapshotDb');
const TABLE = 'daily_inventory_official';
/** 流水列 field(与 dailyInventoryBaseline.FLOW_COLUMNS 一致)→ 数据库列名 */
const FLOW_DB_COLUMNS = {
processIn: 'process_in',
purchaseReturn: 'purchase_return',
purchaseIn: 'purchase_in',
otherIn: 'other_in',
otherReturn: 'other_return',
salesOut: 'sales_out',
stocktaking: 'stocktaking',
processOut: 'process_out',
transferOut: 'transfer_out',
salesReturn: 'sales_return',
transferIn: 'transfer_in',
otherOut: 'other_out',
};
const CREATE_SQL = `
CREATE TABLE IF NOT EXISTS ${TABLE} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_date DATE NOT NULL COMMENT '业务日期',
wms_co_id BIGINT NULL COMMENT '聚水潭仓库 ID(未知仓为空)',
warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(聚水潭主仓)',
opening_qty DECIMAL(18,2) NULL COMMENT '期初数量',
closing_qty DECIMAL(18,2) NULL COMMENT '期末数量',
process_in DECIMAL(18,2) NULL COMMENT '加工进仓',
purchase_return DECIMAL(18,2) NULL COMMENT '采购退货',
purchase_in DECIMAL(18,2) NULL COMMENT '采购进仓',
other_in DECIMAL(18,2) NULL COMMENT '其它进仓',
other_return DECIMAL(18,2) NULL COMMENT '其它退货',
sales_out DECIMAL(18,2) NULL COMMENT '销售出仓',
stocktaking DECIMAL(18,2) NULL COMMENT '盘点',
process_out DECIMAL(18,2) NULL COMMENT '加工出仓',
transfer_out DECIMAL(18,2) NULL COMMENT '调拨出',
sales_return DECIMAL(18,2) NULL COMMENT '销售退货',
transfer_in DECIMAL(18,2) NULL COMMENT '调拨入',
other_out DECIMAL(18,2) NULL COMMENT '其它出库',
source VARCHAR(32) NOT NULL DEFAULT 'jst-export' COMMENT '数据来源',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_wh (biz_date, warehouse),
KEY idx_date (biz_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-聚水潭官方进销存校正数据';`;
async function ensureTable() {
await getPool().query(CREATE_SQL);
}
const num = (v) => {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/**
* 写入某业务日的官方进销存(幂等,重复导入覆盖)
* @param {string} bizDate
* @param {Array<{warehouse:string, wmsCoId?:number|null, openingQty?:number|null, closingQty?:number|null, flows?:Object}>} rows
* @param {string} source
*/
async function upsertOfficial(bizDate, rows, source = 'jst-export') {
if (!bizDate || !rows.length) return 0;
await ensureTable();
const conn = await getPool().getConnection();
try {
for (const r of rows) {
const flows = r.flows || {};
await conn.query(
`INSERT INTO ${TABLE}
(biz_date, wms_co_id, warehouse, opening_qty, closing_qty,
process_in, purchase_return, purchase_in, other_in, other_return,
sales_out, stocktaking, process_out, transfer_out, sales_return, transfer_in, other_out, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
wms_co_id = VALUES(wms_co_id),
opening_qty = VALUES(opening_qty), closing_qty = VALUES(closing_qty),
process_in = VALUES(process_in), purchase_return = VALUES(purchase_return),
purchase_in = VALUES(purchase_in), other_in = VALUES(other_in),
other_return = VALUES(other_return), sales_out = VALUES(sales_out),
stocktaking = VALUES(stocktaking), process_out = VALUES(process_out),
transfer_out = VALUES(transfer_out), sales_return = VALUES(sales_return),
transfer_in = VALUES(transfer_in), other_out = VALUES(other_out),
source = VALUES(source)`,
[
bizDate,
r.wmsCoId ?? null,
r.warehouse,
num(r.openingQty),
num(r.closingQty),
num(flows.processIn), num(flows.purchaseReturn), num(flows.purchaseIn),
num(flows.otherIn), num(flows.otherReturn), num(flows.salesOut),
num(flows.stocktaking), num(flows.processOut), num(flows.transferOut),
num(flows.salesReturn), num(flows.transferIn), num(flows.otherOut),
source,
]
);
}
return rows.length;
} finally {
conn.release();
}
}
/** 删除某业务日的官方数据(重新导入前清理) */
async function deleteOfficial(bizDate) {
if (!bizDate) return 0;
await ensureTable();
const [res] = await getPool().query(`DELETE FROM ${TABLE} WHERE biz_date = ?`, [bizDate]);
return res.affectedRows || 0;
}
/** 读取某业务日的官方进销存:key = 仓库名 */
async function fetchOfficial(bizDate) {
const map = new Map();
if (!bizDate) return map;
try {
await ensureTable();
const cols = Object.values(FLOW_DB_COLUMNS).map((c) => `o.${c} AS ${c}`).join(', ');
const [rows] = await getPool().query(
`SELECT o.wms_co_id AS wmsCoId, o.warehouse AS warehouse,
o.opening_qty AS openingQty, o.closing_qty AS closingQty, o.source AS source,
${cols}
FROM ${TABLE} o WHERE o.biz_date = ?`,
[bizDate]
);
const dbToField = Object.fromEntries(Object.entries(FLOW_DB_COLUMNS).map(([f, c]) => [c, f]));
rows.forEach((r) => {
const flows = {};
for (const [col, field] of Object.entries(dbToField)) flows[field] = num(r[col]);
map.set(String(r.warehouse), {
warehouse: r.warehouse,
wmsCoId: r.wmsCoId == null ? null : Number(r.wmsCoId),
openingQty: num(r.openingQty),
closingQty: num(r.closingQty),
source: r.source,
flows,
});
});
} catch (error) {
console.error('[daily-inventory-official] 读取官方数据失败:', error.message);
}
return map;
}
/** 已有官方数据的业务日(倒序) */
async function listOfficialDates(limit = 30) {
try {
await ensureTable();
const [rows] = await getPool().query(
`SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`,
[Number(limit) || 30]
);
return rows.map((r) => (typeof r.d === 'string' ? r.d.slice(0, 10) : String(r.d).slice(0, 10)));
} catch (error) {
console.error('[daily-inventory-official] 读取官方日期失败:', error.message);
return [];
}
}
module.exports = {
TABLE,
CREATE_SQL,
FLOW_DB_COLUMNS,
ensureTable,
upsertOfficial,
deleteOfficial,
fetchOfficial,
listOfficialDates,
};

View File

@ -0,0 +1,137 @@
/**
* 每日进销存 —— 本地库存快照库访问
*
* 背景:业务库(MSSQL / JSReportReplica)只有「当前」实时库存 jst_inventoryInfo,
* 没有按天的历史库存结存表,因此「期初数量」无法回溯。
* 解决办法:项目本地 MySQL 每天落一份按仓库存的快照,
* 今日期初 = 上一业务日快照,期末 = 实时库存。
*
* 连接复用 LOCAL_DB_* 配置(与项目其它本地表同库)。
*/
const path = require('path');
const dotenv = require('dotenv');
const mysql = require('mysql2/promise');
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const TABLE = 'daily_inventory_snapshot';
const CREATE_SQL = `
CREATE TABLE IF NOT EXISTS ${TABLE} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_date DATE NOT NULL COMMENT '业务日期(T-1)',
wms_co_id BIGINT NOT NULL COMMENT '聚水潭仓库 ID',
warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(主仓)',
db_warehouse VARCHAR(128) NULL COMMENT '监控表仓库名',
closing_qty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '该业务日期末数量',
sku_count INT NOT NULL DEFAULT 0 COMMENT '有库存 SKU 数',
taken_at DATETIME NULL COMMENT '源库库存数据更新时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_wh (biz_date, wms_co_id),
KEY idx_date (biz_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-按仓库存快照';`;
let pool = null;
function getPool() {
if (pool) return pool;
pool = mysql.createPool({
host: process.env.LOCAL_DB_HOST,
port: Number(process.env.LOCAL_DB_PORT || 3306),
user: process.env.LOCAL_DB_USER,
password: process.env.LOCAL_DB_PASSWORD,
database: process.env.LOCAL_DB_NAME,
waitForConnections: true,
connectionLimit: 5,
connectTimeout: 10000,
// DATE/DATETIME 直接返回字符串,避免 JS Date 按本地时区回读时整体偏移一天
dateStrings: true,
});
return pool;
}
async function ensureTable() {
await getPool().query(CREATE_SQL);
}
/**
* 写入某业务日的期末快照(幂等,重复执行覆盖)
* @param {string} bizDate YYYY-MM-DD
* @param {Array<{wmsCoId:number, warehouse:string, dbWarehouse?:string, closingQty:number, skuCount:number, takenAt?:Date|null}>} rows
*/
async function upsertSnapshot(bizDate, rows) {
if (!bizDate || !rows.length) return 0;
await ensureTable();
const conn = await getPool().getConnection();
try {
for (const r of rows) {
await conn.query(
`INSERT INTO ${TABLE} (biz_date, wms_co_id, warehouse, db_warehouse, closing_qty, sku_count, taken_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE warehouse = VALUES(warehouse), db_warehouse = VALUES(db_warehouse),
closing_qty = VALUES(closing_qty), sku_count = VALUES(sku_count), taken_at = VALUES(taken_at)`,
[bizDate, r.wmsCoId, r.warehouse, r.dbWarehouse || null, r.closingQty, r.skuCount, r.takenAt || null]
);
}
return rows.length;
} finally {
conn.release();
}
}
/**
* 读取某业务日的期末快照
* @returns {Promise<Map<string, {closingQty:number, skuCount:number, takenAt:Date|null}>>} key = wmsCoId 字符串
*/
async function fetchSnapshot(bizDate) {
const map = new Map();
if (!bizDate) return map;
try {
await ensureTable();
const [rows] = await getPool().query(
`SELECT wms_co_id AS wmsCoId, closing_qty AS closingQty, sku_count AS skuCount, taken_at AS takenAt
FROM ${TABLE} WHERE biz_date = ?`,
[bizDate]
);
rows.forEach((r) => map.set(String(r.wmsCoId), {
closingQty: Number(r.closingQty),
skuCount: Number(r.skuCount),
takenAt: r.takenAt || null,
}));
} catch (error) {
console.error('[daily-inventory-snapshot] 读取快照失败:', error.message);
}
return map;
}
/** 已有快照的业务日(倒序,最多 n 条) */
async function listSnapshotDates(limit = 10) {
try {
await ensureTable();
const [rows] = await getPool().query(
`SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`,
[Number(limit) || 10]
);
return rows.map((r) => {
if (typeof r.d === 'string') return r.d.slice(0, 10);
const v = new Date(r.d);
const pad = (n) => String(n).padStart(2, '0');
return `${v.getFullYear()}-${pad(v.getMonth() + 1)}-${pad(v.getDate())}`;
});
} catch (error) {
console.error('[daily-inventory-snapshot] 读取快照日期失败:', error.message);
return [];
}
}
module.exports = {
TABLE,
CREATE_SQL,
getPool,
ensureTable,
upsertSnapshot,
fetchSnapshot,
listSnapshotDates,
};

View File

@ -0,0 +1,78 @@
/**
* 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值)
*
* 一级菜单:大货库存监控
* 二级菜单:拣选库位动销分析
*
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近N天销量 | 日销 | 动销等级
*
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用。
*
* 口径:
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty
* 统计范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
* 销量: 4 个来源分支,均按 send_date 窗口(详见 services/pickingBinSalesAnalysis.js):
* ① jst_Orders 普通行;② jst_Orders 组合装行 → jst_comboProductItem 展开为子件;
* ③ jst_qmcust_orderlist(全民客户 B2B 分销)普通行(排除拆单父单 Split);
* ④ qmcust 组合装行 → 同样展开。
* 日销: 商品窗口销量 / 窗口天数(整档,不按仓位分摊)
* 等级: 严格按商品日销判定,同一商品占多少个仓位都是同一等级
*
* 【为什么不分摊来定等级】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个
* 拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。若把销量摊到仓位再定等级,
* 一件爆款商品放在 10 个仓位就会被算成日销 1/10 的滞销品 —— 判断的是「摆法」而不是「商品」。
* 因此等级一律以商品自身的销售速度为准。
*
* 【分摊销量列】导出仍附带「分摊销量」= 商品销量 ÷ 其占用的同类型仓位数,
* 供需要按行求和的场景使用:恒等式(已实测校验,差异 0)
* 全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const PICKING_BIN_LEVEL1 = '大货库存监控';
const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
/** 可选的统计窗口天数;默认 30 天(需求指定按近 30 天销量划分动销等级) */
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30, 60];
const PICKING_BIN_DEFAULT_DAYS = 30;
/**
* 仓位类型:本功能只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空)。
* 箱记录(pack_type=Pack)和存货位(bin_group=存货)不纳入动销等级统计,
* 取数在仓位库存表上直接过滤,
* 页面也不再提供类型切换。
*/
const BIN_TYPE_PICKING = '拣选';
/** 固定统计范围:仅拣选位(保持数组形式,便于接口字段与后续扩展) */
const PICKING_BIN_BIN_TYPES = [BIN_TYPE_PICKING];
/**
* 动销等级(顺序即展示顺序)。判定依据为「商品日销」= 商品窗口整档销量 ÷ 天数,
* 不做仓位分摊,同一商品在所有仓位显示同一等级。
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
* zero 档为「窗口内无销量」,由 salesQty <= 0 兜底。
*/
const PICKING_BIN_GRADE_RULES = [
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '商品日均销量 ≥ 20 件' },
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '商品日均销量 10 ~ 20 件(含 10,不含 20)' },
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '商品日均销量 3 ~ 10 件(含 3,不含 10)' },
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '商品日均销量 1 ~ 3 件(含 1,不含 3)' },
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '商品日均销量大于 0 且小于 1 件' },
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内该商品无销量(仍占用库存)' },
];
const PICKING_BIN_TOTAL_LABEL = '汇总';
module.exports = {
PICKING_BIN_LEVEL1,
PICKING_BIN_LEVEL2,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
BIN_TYPE_PICKING,
PICKING_BIN_BIN_TYPES,
PICKING_BIN_GRADE_RULES,
PICKING_BIN_TOTAL_LABEL,
};

View File

@ -0,0 +1,169 @@
/**
* 产品资料系统数据库连接模块(PostgreSQL)
*
* 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。
* 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1
*
* 该库用于 MKH-BOM 资料页面的商品资料匹配:
* external.intake_sku_items_ready_v1
* —— 真实可用的 SKU 明细视图(约 4.6 万行)
* —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码)
* —— sku_code 为商品编码,barcode 为条码
* external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区)
*
* 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。
*/
const { Pool } = require('pg');
const database = require('./database');
const CONFIG_NAME = 'product_information_database';
const READY_VIEW = 'external.intake_sku_items_ready_v1';
const LOCATION_VIEW = 'external.warehouse_locations_v1';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4);
let pool = null;
let cachedSignature = '';
const truthy = (value) => (
value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'
);
async function loadStoredConfig() {
const rows = await database.local.query(
`SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status
FROM database_configs WHERE name = ? LIMIT 1`,
[CONFIG_NAME]
);
const config = rows[0];
if (!config) {
const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置');
error.statusCode = 503;
error.code = 'PRODUCT_DB_NOT_CONFIGURED';
throw error;
}
if (String(config.driver || '').toLowerCase() !== 'postgres') {
const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型');
error.statusCode = 503;
error.code = 'PRODUCT_DB_DRIVER_MISMATCH';
throw error;
}
return {
id: config.id,
host: String(config.host || '').trim(),
port: Number(config.port) || 5432,
database: String(config.database_name || '').trim(),
user: String(config.username || '').trim(),
password: config.password || '',
ssl: truthy(config.ssl),
};
}
const buildSignature = (config) => [
config.id,
config.host,
config.port,
config.database,
config.user,
config.ssl ? 'ssl' : 'nossl',
].join('::');
async function getPool() {
const config = await loadStoredConfig();
const signature = buildSignature(config);
if (pool && signature === cachedSignature) {
return pool;
}
if (pool) {
await pool.end().catch(() => {});
pool = null;
}
pool = new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
max: POOL_MAX,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 20000,
query_timeout: 180000,
statement_timeout: 180000,
});
pool.on('error', (error) => {
console.warn('[ProductDB] 连接池异常:', error.message);
});
cachedSignature = signature;
return pool;
}
/**
* 执行查询,返回行数组。
* @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位)
* @param {Array} params 参数数组
*/
async function query(sqlText, params = []) {
const maxAttempts = 2;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await getPool();
const result = await activePool.query(sqlText, params);
return result.rows;
} catch (error) {
lastError = error;
if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') {
throw error;
}
if (attempt < maxAttempts) {
console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
await reset();
}
}
}
throw lastError;
}
async function reset() {
if (pool) {
await pool.end().catch(() => {});
}
pool = null;
cachedSignature = '';
}
async function test() {
try {
const rows = await query('SELECT 1 AS test');
return { success: true, message: '产品资料系统数据库连接成功', data: rows };
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
}
module.exports = {
query,
test,
reset,
getPool,
CONFIG_NAME,
READY_VIEW,
LOCATION_VIEW,
};

View File

@ -41,13 +41,15 @@ function buildRedisClientConfig(config) {
return {
host: config.host,
port: parseInt(config.port || '6379', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
password: config.password || undefined,
db: parseInt(config.db || '0', 10),
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
retryStrategy: (times) => (times > 1 ? null : 200),
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
enableReadyCheck: false,
lazyConnect: true,
};
}
@ -61,8 +63,10 @@ function getConfigSignature(config) {
host: config.host,
port: parseInt(config.port || '6379', 10),
db: parseInt(config.db || '0', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
password: config.password || '',
tls: Boolean(config.tls),
tlsServerName: config.tlsServerName || '',
});
}

View File

@ -0,0 +1,47 @@
/**
* 仓库库存动销分析 —— 口径配置(纯规则,不含业务数值)
*
* 一级菜单:大货库存监控
* 二级菜单:库存动销分析
*
* 表头(与需求表一致):仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量
*
* 口径:
* 日销(日均销量)= 统计窗口内已发货件数 / 窗口天数
* 动销等级按「单个 SKU 的日销」分档,落在哪一档就计入哪一档
* 仓库内汇总:SKU占比 = 该等级 SKU 数 / 该仓库 SKU 总数
* 库存占比 = 该等级库存件数 / 该仓库库存总件数
* 仓库清单与「每日进销存」保持同一份(DAILY_INVENTORY_BASELINE)
*/
const STOCK_SALES_LEVEL1 = '大货库存监控';
const STOCK_SALES_LEVEL2 = '库存动销分析';
/** 可选的统计窗口天数;默认 30 天 */
const STOCK_SALES_WINDOW_OPTIONS = [7, 14, 30];
const STOCK_SALES_DEFAULT_DAYS = 30;
/**
* 动销等级(顺序即表格行顺序,汇总行由 total 单独给出)。
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
*/
const SALES_GRADE_RULES = [
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '单个 SKU 日均销量 ≥ 20 件' },
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '单个 SKU 日均销量 10 ~ 20 件(含 10,不含 20)' },
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '单个 SKU 日均销量 3 ~ 10 件(含 3,不含 10)' },
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '单个 SKU 日均销量 1 ~ 3 件(含 1,不含 3)' },
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '单个 SKU 日均销量大于 0 且小于 1 件' },
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内无销量的 SKU(仍占用库存)' },
];
/** 汇总行的等级名(表内展示用) */
const SALES_TOTAL_LABEL = '汇总';
module.exports = {
STOCK_SALES_LEVEL1,
STOCK_SALES_LEVEL2,
STOCK_SALES_WINDOW_OPTIONS,
STOCK_SALES_DEFAULT_DAYS,
SALES_GRADE_RULES,
SALES_TOTAL_LABEL,
};

View File

@ -29,12 +29,27 @@ const SIDEBAR_PAGES = [
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }),
buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }),
buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }),
buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }),
buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }),
buildPlanningPage({
pageKey: 'modular_stock_monitor',
pageName: '模块化库存监控 · 配件库存监控',
path: '/planning/operation/modular-stock-monitor',
}),
buildPlanningPage({
pageKey: 'mkh_bom',
pageName: '模块化库存监控 · MKH-BOM资料',
path: '/planning/operation/mkh-bom-data',
}),
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
buildPlanningPage({
pageKey: 'stock_sales_analysis',
pageName: '大货库存监控 · 库存动销分析',
path: '/planning/operation/stock-sales-analysis',
}),
buildPlanningPage({
pageKey: 'picking_bin_sales_analysis',
pageName: '大货库存监控 · 拣选库位动销分析',
path: '/planning/operation/picking-bin-sales-analysis',
}),
{
pageKey: 'settings',
pageName: '系统设置',

View File

@ -19,7 +19,6 @@ const planningSelectionFissionExternalRoutes = require('./api/planningSelectionF
const presaleExternalRoutes = require('./api/presaleExternal');
const delistExternalRoutes = require('./api/delistExternal');
const campaignCloudRoutes = require('./api/campaignCloud');
const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal');
const desktopClientRoutes = require('./api/desktopClient');
const desktopPresenceRoutes = require('./api/desktopPresence');
const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
@ -33,11 +32,19 @@ const productCodeBoardRoutes = require('./api/productCodeBoard');
const productAnalysisRoutes = require('./api/productAnalysis');
const marketingEventsRoutes = require('./api/marketingEvents');
const promotionModuleRoutes = require('./api/promotionModule');
const csQualityRoutes = require('./api/csQuality');
const aiBuyerShowRoutes = require('./api/aiBuyerShow');
const storeCheckupRoutes = require('./api/storeCheckup');
const pricingCalculatorRoutes = require('./api/pricingCalculator');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException');
const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
const binReplenishRoutes = require('./api/binReplenish');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning');
const database = require('./config/database');
const cloudDb = require('./config/cloudDatabase');
const jianSunDb = require('./config/jianSunDatabase');
@ -50,9 +57,24 @@ const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMa
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
const {
startDailyInventorySnapshotScheduler,
} = require('./jobs/dailyInventorySnapshotJob');
const {
startDailyInventoryNotificationScheduler,
} = require('./jobs/dailyInventoryNotificationJob');
const {
startAlibaba1688ImageSearchExpiryScheduler,
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
const {
startMkhReplenishAlertScheduler,
} = require('./jobs/mkhReplenishAlertJob');
const {
startWarehouseLogisticsExceptionNotifyScheduler,
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
const {
startShipTimelinessNotifyScheduler,
} = require('./jobs/warehouseShipTimelinessNotifyJob');
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
const { ensureLocalSchema } = require('./services/localSchema');
const {
@ -636,7 +658,6 @@ async function startServer(options = {}) {
cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
cubejsApp.use('/api/campaign', campaignCloudRoutes);
cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes);
cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
cubejsApp.use('/api/external/presale', presaleExternalRoutes);
@ -655,13 +676,26 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes);
cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes);
cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes);
cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes);
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
// 缺货补货监控(拣货位缺货 → 从存货位补货)
cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes);
// =====================
// 数据分析 API(公开访问,可根据需要添加认证)
@ -697,11 +731,41 @@ async function startServer(options = {}) {
}
});
// =====================
// 统一 API 兜底(必须放在所有路由之后)
// =====================
// 背景:此前没有任何 4 参错误处理中间件,路由里抛出的异常会落到 Express
// 默认处理器,返回一张 HTML 的 "Internal Server Error" 页面;前端 axios
// 读不到 error / message 字段,只能显示 "Request failed with status code 500",
// 出问题时完全看不到真实原因。这里统一返回 JSON 并打印堆栈。
cubejsApp.use('/api', (req, res) => {
res.status(404).json({
success: false,
error: `接口不存在: ${req.method} ${req.originalUrl}`,
});
});
cubejsApp.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
const rawStatus = Number(err?.status || err?.statusCode);
const status = Number.isFinite(rawStatus) && rawStatus >= 400 && rawStatus <= 599
? rawStatus
: 500;
const messageText = err?.message || '服务器内部错误';
console.error(`❌ [API ${status}] ${req.method} ${req.originalUrl} -> ${messageText}`);
if (err?.stack) {
console.error(err.stack);
}
return res.status(status).json({ success: false, error: messageText });
});
console.log('='.repeat(50));
console.log('🚀 后端服务已启动');
console.log(`📡 服务地址: http://localhost:4000`);
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
console.log(cubeMode === 'cube'
? '📊 Cube.js API: http://localhost:4000/cubejs-api'
? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api`
: '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
// 测试数据库连接
@ -716,6 +780,11 @@ async function startServer(options = {}) {
console.log(` [缓存] ${redisTest.message}`);
console.log('='.repeat(50));
// 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭,
// 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。
// 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。
startDailyInventoryNotificationScheduler();
if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
return;
@ -730,6 +799,10 @@ async function startServer(options = {}) {
startOpsBIncentiveSnapshotSchedulers();
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler();
startWarehouseLogisticsExceptionNotifyScheduler();
startShipTimelinessNotifyScheduler();
startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema
// inspection round-trip over the planning database tunnel.

View File

@ -0,0 +1,351 @@
'use strict';
// 每日进销存 · 定时截图通知
//
// 执行流程:
// ① 单实例锁(MySQL GET_LOCK),避免多实例重复截图推送
// ② 校验:功能开启 / 有收件人 / 通知中心已配置 / 对象存储已配置,任一不满足就记录原因并退出
// ③ 按配置时间(默认 01:00 Asia/Shanghai)触发;业务日期 = 北京时间昨日(与页面 T-1 口径一致)
// ④ 截图 → 上传对象存储 → 拿到公开 HTTPS 地址后才发通知
// ⑤ 逐用户发送,用「业务日期 + 收件人」幂等键去重;单人失败不影响其他人
// ⑥ 截图/上传/通知失败均做有限次数重试与指数退避
// ⑦ 每次执行都落一条记录(时间 / 截图地址 / 通知对象 / 成功状态 / 失败原因)
//
// 立即测试(mode='test'):走同一条链路,但幂等键带随机数,**不写入正式幂等记录**,
// 因此不会影响下一次正式定时推送。
const crypto = require('crypto');
const cron = require('node-cron');
const database = require('../config/database');
const {
appendRunRecord,
buildIdempotencyKey,
getDailyInventoryNotificationRuntimeConfig,
getObjectStorageStatus,
hasNotified,
loadDailyInventoryNotificationState,
markNotified,
normalizeRecipients,
} = require('../services/dailyInventoryNotificationSettings');
const {
captureAndUploadDailyInventory,
chinaTimeParts,
resolvePageUrl,
} = require('../services/dailyInventoryScreenshot');
const {
getYydNotificationCenterRuntimeConfig,
isYydNotificationCenterConfigured,
sendYydNotification,
} = require('../services/yydNotificationCenterClient');
const TIMEZONE = 'Asia/Shanghai';
const LOCK_KEY = 'daily_inventory_notification_job';
const LOCK_WAIT_SECONDS = 2;
const NOTIFICATION_TITLE = '每日进销存数据';
const ACTION_TITLE = '查看每日进销存';
// 重试策略:最多 3 次,退避 2s / 4s
const MAX_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 2000;
// 命中时间后允许补跑的窗口与次数上限(防止全天每分钟重试刷屏)
const RETRY_WINDOW_MINUTES = 30;
const MAX_SCHEDULED_ATTEMPTS_PER_DAY = 3;
let running = false;
let timer = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** 有限次数重试 + 指数退避 */
async function withRetry(task, { label, attempts = MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS } = {}) {
let lastError = null;
for (let index = 0; index < attempts; index += 1) {
try {
return await task();
} catch (error) {
lastError = error;
const delay = baseDelayMs * (2 ** index);
console.error(
`[DailyInventoryNotify] ${label} 第 ${index + 1}/${attempts} 次失败:${error.message}`
+ `${index < attempts - 1 ? `,${delay}ms 后重试` : ''}`
);
if (index < attempts - 1) await sleep(delay);
}
}
throw lastError;
}
/** 业务日期 = 北京时间昨日(页面标注 T-1,凌晨跑批产出昨日快照) */
function resolveBizDate(now = new Date()) {
const target = now instanceof Date ? now : new Date(now);
const shifted = new Date(target.getTime() - 24 * 60 * 60 * 1000);
return chinaTimeParts(shifted).bizDate;
}
function beijingTimeText(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat('zh-CN', {
timeZone: TIMEZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).format(target);
}
function currentBeijingHhMm(now = new Date()) {
const text = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false,
}).format(now instanceof Date ? now : new Date(now));
return text;
}
/** 全实例锁:拿到锁才继续执行,拿不到说明其它实例正在跑 */
async function withJobLock(runner) {
const pool = database.local.pool;
let connection = null;
try {
connection = await pool.getConnection();
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [LOCK_KEY, LOCK_WAIT_SECONDS]);
const acquired = Number(rows?.[0]?.lockStatus || 0) === 1;
if (!acquired) {
return { success: false, skipped: true, reason: 'lock_not_acquired' };
}
return await runner();
} catch (error) {
return { success: false, skipped: true, reason: 'lock_error', error: error.message };
} finally {
if (connection) {
try {
await connection.query('DO RELEASE_LOCK(?)', [LOCK_KEY]);
} catch (_error) {
// ignore
}
connection.release();
}
}
}
function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) {
return [
`**数据日期:** ${bizDate}`,
`**截图生成时间:** ${takenAt}(${TIMEZONE})`,
`**图片地址:** ${imageUrl}`,
`**页面地址:** ${pageUrl}`,
'',
`点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`,
].join('\n');
}
/**
* 执行一次(正式 / 测试)
* @param {Object} options
* mode 'scheduled' | 'manual' | 'test'
* force true 时忽略「今天已调度过」的节流判断(手动触发用)
* now 指定执行时刻,便于测试
*/
async function runDailyInventoryNotificationJob(options = {}) {
const mode = options.mode || 'scheduled';
const now = options.now ? new Date(options.now) : new Date();
const startedAt = now.toISOString();
if (running) {
return { success: false, skipped: true, reason: 'already_running' };
}
running = true;
try {
return await withJobLock(async () => {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
const recipients = normalizeRecipients(config.recipients);
const bizDate = resolveBizDate(now);
const finish = async (payload) => {
await appendRunRecord({ ...payload, runAt: startedAt, bizDate, mode, notifyTime: config.notifyTime });
return payload;
};
if (!config.enabled) {
return finish({ success: false, reason: 'disabled', error: '功能未启用' });
}
if (!recipients.length) {
return finish({ success: false, reason: 'no_recipient', error: '未配置通知用户' });
}
let centerConfig = null;
try {
centerConfig = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
centerConfig = null;
}
if (!isYydNotificationCenterConfigured(centerConfig)) {
return finish({ success: false, reason: 'notification_center_not_configured', error: '统一通知中心未配置' });
}
const storage = await getObjectStorageStatus();
if (!storage.configured) {
return finish({ success: false, reason: 'object_storage_not_configured', error: '对象存储未配置' });
}
// 截图 → 上传(失败直接结束,绝不发通知)
let shot;
try {
shot = await withRetry(
// 对象键按业务日期归档:daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
() => captureAndUploadDailyInventory({ now, bizDate }),
{ label: '截图并上传' }
);
} catch (error) {
return finish({ success: false, reason: 'screenshot_failed', error: error.message });
}
const pageUrl = resolvePageUrl();
const takenAt = beijingTimeText(now);
const nonce = crypto.randomBytes(8).toString('hex');
const deliveries = [];
for (const recipient of recipients) {
const idempotencyKey = buildIdempotencyKey({ bizDate, recipient, mode, nonce });
// 正式推送先查幂等:同一业务日期 + 同一用户只成功通知一次
if (mode !== 'test' && await hasNotified(bizDate, recipient)) {
deliveries.push({ recipient, status: 'skipped', reason: 'already_notified' });
continue;
}
try {
const response = await withRetry(
() => sendYydNotification({
idempotencyKey,
recipientAlias: recipient,
title: NOTIFICATION_TITLE,
content: buildNotificationContent({
bizDate, takenAt, imageUrl: shot.url, pageUrl,
}),
actionTitle: ACTION_TITLE,
actionUrl: pageUrl,
}),
{ label: `通知 ${recipient}` }
);
const deliveryId = response?.id || response?.data?.id || null;
if (mode !== 'test') await markNotified(bizDate, recipient, { deliveryId, idempotencyKey });
deliveries.push({ recipient, status: 'sent', deliveryId });
} catch (error) {
// 单人失败不阻断其他人
console.error(`[DailyInventoryNotify] 通知 ${recipient} 失败:${error.message}`);
deliveries.push({ recipient, status: 'failed', error: error.message });
}
}
const sentCount = deliveries.filter((d) => d.status === 'sent').length;
const failedCount = deliveries.filter((d) => d.status === 'failed').length;
const success = sentCount > 0;
console.log(
`[DailyInventoryNotify] ${mode} 完成:业务日 ${bizDate},截图 ${shot.url},`
+ `通知成功 ${sentCount} / 失败 ${failedCount} / 跳过 ${deliveries.length - sentCount - failedCount}`
);
return finish({
success,
reason: success ? null : 'notify_failed',
error: success ? null : '所有收件人均通知失败',
screenshotUrl: shot.url,
screenshotKey: shot.key,
rowCount: shot.rowCount,
recipients: deliveries,
});
});
} finally {
running = false;
}
}
/** 同一业务日期已经发起过的正式调度次数与最后一次结果(用于失败重试节流) */
async function scheduledAttemptStatus(bizDate) {
const state = await loadDailyInventoryNotificationState();
const runs = (Array.isArray(state.runs) ? state.runs : [])
.filter((r) => r.bizDate === bizDate && r.mode === 'scheduled');
return {
count: runs.length,
lastSuccess: runs.length ? runs[runs.length - 1].success === true : null,
};
}
function minutesOfDay(hhmm) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || ''));
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return hour * 60 + minute;
}
/**
* 是否应当发起一次正式调度:
* · 恰好命中配置时间 → 执行
* · 命中时间之后的 RETRY_WINDOW_MINUTES 分钟内 → 仅在上一次失败且未超出重试上限时补跑
* · 其余时间 → 不执行
*/
async function shouldRunScheduled(config, now) {
const target = minutesOfDay(config.notifyTime);
const current = minutesOfDay(currentBeijingHhMm(now));
if (target === null || current === null) return false;
const diff = current - target;
if (diff < 0 || diff > RETRY_WINDOW_MINUTES) return false;
if (diff === 0) return true;
const status = await scheduledAttemptStatus(resolveBizDate(now));
return status.lastSuccess === false && status.count < MAX_SCHEDULED_ATTEMPTS_PER_DAY;
}
function startDailyInventoryNotificationScheduler() {
const disabled = String(process.env.DAILY_INVENTORY_NOTIFICATION_ENABLED || 'true').trim().toLowerCase() === 'false';
if (disabled) {
console.log('[DailyInventoryNotify] 已通过环境变量关闭');
return null;
}
if (timer) return timer;
// 每分钟检查一次:命中配置的通知时间才真正执行,配置改动无需重启
timer = cron.schedule('* * * * *', async () => {
try {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
if (!config.enabled) return;
const now = new Date();
if (!(await shouldRunScheduled(config, now))) return;
await runDailyInventoryNotificationJob({ mode: 'scheduled', now });
} catch (error) {
console.error('[DailyInventoryNotify] 定时检查失败:', error.message);
}
}, { timezone: TIMEZONE });
console.log(
`[Scheduler] 每日进销存截图通知已注册(按配置时间触发,${TIMEZONE},`
+ `失败后 ${RETRY_WINDOW_MINUTES} 分钟内最多补跑 ${MAX_SCHEDULED_ATTEMPTS_PER_DAY} 次)`
);
return timer;
}
function stopDailyInventoryNotificationScheduler() {
if (timer) {
timer.stop();
timer = null;
}
}
module.exports = {
ACTION_TITLE,
LOCK_KEY,
MAX_ATTEMPTS,
MAX_SCHEDULED_ATTEMPTS_PER_DAY,
NOTIFICATION_TITLE,
RETRY_BASE_DELAY_MS,
TIMEZONE,
RETRY_WINDOW_MINUTES,
beijingTimeText,
buildNotificationContent,
currentBeijingHhMm,
minutesOfDay,
resolveBizDate,
runDailyInventoryNotificationJob,
startDailyInventoryNotificationScheduler,
stopDailyInventoryNotificationScheduler,
withRetry,
};

View File

@ -0,0 +1,122 @@
'use strict';
/**
* 每日库存快照任务
*
* 业务库没有按天的历史库存结存表,「期初数量」无法回溯。
* 本任务每天定时把 26 个仓库的实时库存落一份到本地快照表 daily_inventory_snapshot,
* 使「今日期初 = 上一业务日期末」能够自动滚动。
*
* 默认 01:30 执行(报表库 01:00 跑批之后)。可用环境变量覆盖:
* DAILY_INVENTORY_SNAPSHOT_ENABLED=false 关闭
* DAILY_INVENTORY_SNAPSHOT_CRON='30 1 * * *'
*/
const cron = require('node-cron');
const cloudDb = require('../config/cloudDatabase');
const snapshotDb = require('../config/dailyInventorySnapshotDb');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
let running = false;
let timer = null;
const JOB_ENABLED = String(process.env.DAILY_INVENTORY_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false';
const JOB_CRON = process.env.DAILY_INVENTORY_SNAPSHOT_CRON || '30 1 * * *';
/** 业务日 = 库存快照库最新日期(报表库每日凌晨跑批,标注 T-1) */
async function resolveBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
params[`${prefix}${i}`] = v;
return `@${prefix}${i}`;
})
.join(',');
return { clause, params };
}
async function syncOnce(trigger = 'cron') {
if (running) {
console.log(`[DailyInventorySnapshot] 上一次任务未结束,跳过 trigger=${trigger}`);
return { success: false, skipped: true, reason: 'already_running' };
}
running = true;
const startedAt = Date.now();
try {
const bizDate = await resolveBizDate();
if (!bizDate) {
console.log('[DailyInventorySnapshot] 未取到业务日,跳过');
return { success: false, skipped: true, reason: 'no_biz_date' };
}
const whs = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const { clause, params } = buildInClause(whs.map((b) => b.wmsCoId), 'w');
const rows = await cloudDb.query(
`SELECT i.wms_co_id AS wmsCoId,
ISNULL(SUM(CAST(i.qty AS DECIMAL(18,2))), 0) AS closingQty,
SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
MAX(i.modified) AS takenAt
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${clause})
GROUP BY i.wms_co_id`,
params,
{ requestTimeout: 120000 }
);
const m = new Map(rows.map((r) => [String(r.wmsCoId), r]));
const payload = whs.map((b) => {
const r = m.get(String(b.wmsCoId));
return {
wmsCoId: b.wmsCoId,
warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse,
closingQty: Number(r?.closingQty ?? 0),
skuCount: Number(r?.skuCount ?? 0),
takenAt: r?.takenAt ? new Date(r.takenAt) : null,
};
});
const n = await snapshotDb.upsertSnapshot(bizDate, payload);
console.log(`[DailyInventorySnapshot] 业务日 ${bizDate} 写入 ${n}/${whs.length} 仓,耗时 ${Date.now() - startedAt}ms`);
return { success: true, bizDate, count: n };
} catch (error) {
console.error('[DailyInventorySnapshot] 执行失败:', error.message);
return { success: false, error: error.message };
} finally {
running = false;
}
}
function startDailyInventorySnapshotScheduler() {
if (!JOB_ENABLED) {
console.log('[DailyInventorySnapshot] 已通过环境变量关闭');
return null;
}
if (timer) return timer;
if (!cron.validate(JOB_CRON)) {
console.error(`[DailyInventorySnapshot] cron 表达式非法: ${JOB_CRON}`);
return null;
}
timer = cron.schedule(JOB_CRON, () => { syncOnce('cron'); }, { timezone: 'Asia/Shanghai' });
console.log(`[DailyInventorySnapshot] 已启动,cron=${JOB_CRON} (Asia/Shanghai)`);
return timer;
}
function stopDailyInventorySnapshotScheduler() {
if (timer) {
timer.stop();
timer = null;
}
}
module.exports = {
syncOnce,
startDailyInventorySnapshotScheduler,
stopDailyInventorySnapshotScheduler,
};

View File

@ -0,0 +1,396 @@
'use strict';
// MKH 成品补货预警 · 定时推送
//
// 业务口径(2026-09-17 业务确认):
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品
// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示)
// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪)
// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件,
// 不给出建议补货量(否则业务会照着补不到的目标去采购)
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
// ④ 时间:每天早上 09:00(Asia/Shanghai)
//
// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。
const crypto = require('crypto');
const cron = require('node-cron');
const {
listReplenishAlertRows,
} = require('../services/mkhBomService');
const {
getMkhReplenishAlertRuntimeConfig,
loadMkhReplenishAlertState,
resolveMkhReplenishAlertRecipient,
saveMkhReplenishAlertState,
} = require('../services/mkhReplenishAlertSettings');
const {
isYydNotificationCenterConfigured,
sendYydNotification,
} = require('../services/yydNotificationCenterClient');
const {
getYydNotificationCenterRuntimeConfig,
} = require('../services/yydNotificationCenterSettings');
const DEFAULT_CRON = '0 9 * * *';
const TIMEZONE = 'Asia/Shanghai';
const OUT_OF_STOCK = '缺货';
const WARNING = '预警';
// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限)
const MAX_LISTED_ITEMS = 20;
const STARTUP_DELAY_MS = 45 * 1000;
function formatQty(value) {
if (value === null || value === undefined || value === '') return '—';
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2)));
}
function formatChinaTime(value) {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */
function chinaDateKey(date = new Date()) {
const text = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date instanceof Date ? date : new Date(date));
return text.replace(/\D/g, '');
}
/**
* 对比「当前预警清单」与「已推送记录」:
* fresh 本次新出现的(需要推送)
* persisted 之前推过、目前仍在预警的(不重复推)
* restored 之前推过、现在已经不预警的(补货完成,从记录里剔除)
*/
function diffAlertState(alerts = [], state = {}) {
const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const fresh = [];
const persisted = [];
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
if (notified[skuId]) persisted.push(row);
else fresh.push(row);
});
const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean));
const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId));
return { fresh, persisted, restored };
}
/**
* 生成下一版「已推送」记录:只保留当前仍在预警的商品。
* touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。
*/
function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) {
const touch = options.touch !== false;
const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const next = {};
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
const old = prev[skuId];
const firstNotifiedAt = old?.firstNotifiedAt || nowIso;
next[skuId] = {
level: row.warningLevel || null,
firstNotifiedAt,
lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt),
dailySale: Number(row.dailySale) || 0,
stock: Number(row.stock) || 0,
};
});
return next;
}
/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */
function buildAlertSignature(alerts = []) {
const parts = alerts
.map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`)
.sort();
return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16);
}
/**
* 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件,
* 即真正卡住组装的那些,最多 2 个,避免正文过长。
*/
function formatShortageNote(row) {
const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : [];
const detail = bottlenecks
.filter((item) => Number(item?.available) <= 0)
.slice(0, 2)
.map((item) => {
const occupied = Number(item.occupied) || 0;
return `${item.code}(库存 ${formatQty(item.stock)}`
+ `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`;
})
.join('、');
return detail ? `,短板配件:${detail}` : '';
}
function describeAlert(row) {
// 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来,
// 建议补货量不再给数字(显示「—」),与页面口径保持一致。
const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`;
// 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩):
// 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量
const metric = row.orderDriven
? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)`
: `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`;
const lines = [
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
String(row.productName || '').trim() || '(无名称)',
metric,
];
if (row.componentShortage) {
lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`);
}
return lines.join('\n');
}
function buildMkhReplenishNotification({
alerts = [],
allAlerts = alerts,
snapshot = {},
recipientAlias,
dateKey,
}) {
const listed = alerts.slice(0, MAX_LISTED_ITEMS);
const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalCount = allAlerts.length || alerts.length;
const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount))
? Number(snapshot.outOfStockCount)
: allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount))
? Number(snapshot.warningOnlyCount)
: Math.max(0, totalCount - totalOutOfStock);
const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`];
if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`);
const blocks = [
`**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`,
`**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`,
`**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`,
];
listed.forEach((row, index) => {
blocks.push(`**${index + 1}.** ${describeAlert(row)}`);
});
blocks.push(
[
`---`,
`当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`,
`建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`,
Number(snapshot.orderDrivenCount) > 0
? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。`
: '',
Number(snapshot.componentShortageCount) > 0
? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。`
: '',
snapshot.workOrderDegraded === true
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
: '',
snapshot.pendingOrderDegraded === true
? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。`
: '',
snapshot.componentStockDegraded === true
? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。`
: '',
].filter(Boolean).join('\n\n')
);
const actionUrl = String(
process.env.MKH_REPLENISH_ALERT_ACTION_URL
|| 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning'
).trim();
return {
idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`,
recipientAlias,
title: titleParts.join(' · ').slice(0, 100),
content: blocks.join('\n\n').slice(0, 4800),
actionTitle: '查看补货清单',
actionUrl,
};
}
/**
* 执行一次检查。可选参数:
* force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用)
* dryRun 只计算不发送,用于预览将推送的内容
*/
async function checkMkhReplenishAlerts(options = {}) {
const now = options.now === undefined ? new Date() : new Date(options.now);
const nowIso = now.toISOString();
const dryRun = options.dryRun === true;
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const snapshot = await listReplenishAlertRows({ scope: config.scope });
const alerts = snapshot.rows || [];
const state = await loadMkhReplenishAlertState();
const { fresh, persisted, restored } = diffAlertState(alerts, state);
const summary = {
scope: snapshot.scope,
totalCount: snapshot.totalCount,
outOfStockCount: snapshot.outOfStockCount,
warningOnlyCount: snapshot.warningOnlyCount,
replenishQtyTotal: snapshot.replenishQtyTotal,
// 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩
orderDrivenCount: snapshot.orderDrivenCount,
pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal,
// 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪
componentShortageCount: snapshot.componentShortageCount,
freshCount: fresh.length,
persistedCount: persisted.length,
restoredCount: restored.length,
monitorDate: snapshot.monitorDate,
workOrderDegraded: snapshot.workOrderDegraded,
pendingOrderDegraded: snapshot.pendingOrderDegraded,
componentStockDegraded: snapshot.componentStockDegraded,
};
if (!config.enabled) return { sent: false, reason: 'disabled', summary };
// 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒
if (!alerts.length) {
if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso });
return { sent: false, reason: 'no_alert', summary };
}
const targets = options.force === true ? alerts : fresh;
if (!targets.length) {
if (!dryRun) {
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }),
lastRunAt: nowIso,
});
}
return { sent: false, reason: 'no_new_alert', summary };
}
const recipient = await resolveMkhReplenishAlertRecipient({ config });
if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary };
const payload = buildMkhReplenishNotification({
alerts: targets,
allAlerts: alerts,
snapshot,
recipientAlias: recipient.alias,
dateKey: chinaDateKey(now),
});
// 预览不受「通知中心是否配置」影响,方便先核对口径
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
summary,
recipient: recipient.alias,
recipientSource: recipient.source,
payload,
};
}
let centerConfig = null;
try {
centerConfig = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
centerConfig = null;
}
if (!isYydNotificationCenterConfigured(centerConfig)) {
return {
sent: false,
reason: 'notification_center_not_configured',
summary,
recipient: recipient.alias,
payload,
};
}
const response = await sendYydNotification(payload);
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso),
lastRunAt: nowIso,
});
return {
sent: true,
recipient: recipient.alias,
recipientSource: recipient.source,
newCount: targets.length,
deliveryId: response?.id || response?.data?.id || null,
summary,
};
}
async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) {
try {
const result = await checkMkhReplenishAlerts(options);
if (result.sent) {
console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`);
} else {
console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error);
return { sent: false, reason: 'error', error: error.message || String(error) };
}
}
function startMkhReplenishAlertScheduler() {
const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] MKH 补货预警推送已禁用');
return;
}
const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim();
cron.schedule(expression, () => {
runMkhReplenishAlertJob('daily_schedule').catch(() => {});
}, { timezone: TIMEZONE });
const startupTimer = setTimeout(() => {
runMkhReplenishAlertJob('startup').catch(() => {});
}, STARTUP_DELAY_MS);
if (typeof startupTimer.unref === 'function') startupTimer.unref();
console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`);
}
module.exports = {
DEFAULT_CRON,
MAX_LISTED_ITEMS,
TIMEZONE,
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
checkMkhReplenishAlerts,
chinaDateKey,
diffAlertState,
formatQty,
runMkhReplenishAlertJob,
startMkhReplenishAlertScheduler,
};

View File

@ -0,0 +1,34 @@
'use strict';
const cron = require('node-cron');
const { runDailyCompare, todayInShanghai } = require('../services/returnMonitorService');
// 每天 07:00(东八区)跑一轮:同步当日退货入库 + 现有库存 + 每日比对核销
const JOB_CRON = String(process.env.RETURN_MONITOR_CRON || '0 7 * * *').trim();
const TIMEZONE = 'Asia/Shanghai';
const LOOKBACK_DAYS = Number(process.env.RETURN_MONITOR_LOOKBACK_DAYS || 3);
async function runReturnMonitorCompare(trigger = 'cron') {
const bizDate = todayInShanghai();
try {
const result = await runDailyCompare({ date: bizDate, trigger, lookbackDays: LOOKBACK_DAYS });
console.log(`[ReturnMonitor] 每日比对完成: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error('[ReturnMonitor] 每日比对失败:', error.message);
throw error;
}
}
function startReturnMonitorScheduler() {
if (String(process.env.RETURN_MONITOR_JOB_DISABLED || '').trim().toLowerCase() === 'true') {
console.log('[ReturnMonitor] 退货入库核销定时任务已禁用');
return;
}
cron.schedule(JOB_CRON, () => {
runReturnMonitorCompare('cron').catch(() => {});
}, { timezone: TIMEZONE });
console.log(`[Scheduler] 崔桥工厂仓客退库龄核销任务已注册: ${JOB_CRON} (${TIMEZONE})`);
}
module.exports = { startReturnMonitorScheduler, runReturnMonitorCompare };

View File

@ -0,0 +1,529 @@
'use strict';
/**
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-22 定稿):
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
*
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
*/
const cron = require('node-cron');
const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService');
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
const {
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
saveWarehouseLogisticsExceptionNotifyState,
} = require('../services/warehouseLogisticsExceptionNotifySettings');
const TIMEZONE = 'Asia/Shanghai';
// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端
const DEFAULT_TICK_CRON = '* * * * *';
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
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 —— 用来跟配置里的 sendTime 比对 */
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);
}
/**
* 字段清洗:换行/连续空白压成单空格。
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
*/
function cleanCell(value, fallback = '') {
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
return text || fallback;
}
/**
* 明细一行:仓|快递单号|快递|异常类型
* ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
* 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
* ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
* 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
* ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
*/
function buildDetailLine(row, index) {
const parts = [
cleanCell(row?.warehouseName, '未知仓'),
cleanCell(row?.trackingNo || row?.oId, '无单号'),
cleanCell(row?.carrier, '未知快递'),
cleanCell(row?.exceptionLabel, '异常'),
];
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 个发货仓够用)*/
function buildCenterLines(data = {}) {
const rows = Array.isArray(data.rows) ? data.rows : [];
const packagesByCenter = rows.reduce((acc, row) => {
const key = String(row?.centerKey || '');
if (!key) return acc;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
return (data.centers || [])
.map((center) => ({
name: cleanCell(center.name || center.key, '未知仓'),
exceptionPackages: packagesByCenter[center.key] || 0,
shippedOrders: Number(center.shippedOrders) || 0,
}))
.filter((item) => item.exceptionPackages > 0)
.sort((a, b) => b.exceptionPackages - a.exceptionPackages)
.slice(0, 12)
.map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`);
}
function buildTypeLine(data = {}) {
const summary = data.summary || {};
const entries = Object.entries(summary.byType || {});
if (!entries.length) return '';
return entries
.map(([type, count]) => {
const meta = (data.types || []).find((item) => item.key === type);
return { label: meta?.label || type, count: Number(count) || 0 };
})
.sort((a, b) => b.count - a.count)
.map((item) => `${item.label} ${item.count}`)
.join(' · ');
}
function buildPageLink(pageUrl, verifyDate) {
const base = String(pageUrl || DEFAULT_PAGE_URL);
if (!verifyDate) return base;
const glue = base.includes('?') ? '&' : '?';
return `${base}${glue}date=${encodeURIComponent(verifyDate)}`;
}
/**
* 组装群消息正文(纯函数,便于单测)。
* 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接
* 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。
*
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
*/
function buildNotifyText(data = {}, options = {}) {
const batch = data.batch || {};
const summary = data.summary || {};
const rows = Array.isArray(data.rows) ? data.rows : [];
const exclusions = data.exclusions || {};
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
: DEFAULT_MAX_DETAIL_ROWS;
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate);
const totalPackages = Number(summary.exceptionPackages) || rows.length;
const shipped = Number(summary.shippedOrders) || 0;
const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—';
const head = [
`【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`,
`已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`,
];
const typeLine = buildTypeLine(data);
if (typeLine) head.push(`类型:${typeLine}`);
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
const freshness = data.freshness || {};
if (totalPackages === 0) {
if (freshness.emptyWindow && freshness.sourceStale) {
const hours = Number(freshness.sourceAgeMinutes) > 0
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
: '';
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
} else if (freshness.emptyWindow) {
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
} else if (shipped === 0) {
head.push('本批发货 0 单,无异常可核。');
} else {
head.push('本批无转运异常,全部正常。');
}
}
const excludedCount = (Number(exclusions.carrierPackages) || 0)
+ (Number(exclusions.voidedPackages) || 0)
+ (Number(exclusions.nonRealPackages) || 0)
+ (Number(exclusions.transferPackages) || 0);
if (excludedCount > 0) {
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
}
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
const interceptCount = Number(exclusions.interceptPackages) || 0;
if (interceptCount > 0) {
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
}
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
const wantCount = detailAll.length;
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
const buildTail = (listed) => {
const lines = [];
if (totalPackages > 0) {
if (listed === 0) {
lines.push('');
lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`);
} else if (totalPackages > listed) {
lines.push(`…其余 ${totalPackages - listed} 条见页面。`);
}
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
}
return lines;
};
const assemble = (listed, withCenters, extraNote = '') => {
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);
if (extraNote) blocks.push(extraNote);
const text = blocks.join('\n');
return { text, bytes: Buffer.byteLength(text, 'utf8') };
};
// 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块)
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」——
// 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。
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: totalPackages,
listedCount: listed,
bytes: result.bytes,
truncated,
};
}
/**
* 执行一次推送。参数:
* now 指定时间(测试用)
* date 指定核验日 'YYYY-MM-DD'
* force 忽略「今天已经推过」的限制(页面「立即推送」用)
* dryRun 只组装消息不发送(预览 / 单测用)
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
*/
async function pushWarehouseLogisticsExceptionNotify(options = {}) {
const now = options.now === undefined ? new Date() : new Date(options.now);
const pushDate = chinaDateKey(now);
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
if (options.probe === true) {
if (!isWarehouseLogisticsExceptionNotifyReady(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 };
}
const state = await loadWarehouseLogisticsExceptionNotifyState();
if (state?.lastSentDate === pushDate && options.force !== true) {
return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null };
}
if (!config.enabled && options.force !== true) {
return { sent: false, reason: 'disabled' };
}
const dryRun = options.dryRun === true;
if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) {
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
}
const data = await getLogisticsExceptionExportRows({ date: options.date });
if (data?.notStarted) {
// 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工
return { sent: false, reason: 'not_started', batch: data.batch || null };
}
const built = buildNotifyText(data, {
maxDetailRows: config.maxDetailRows,
pageUrl: config.pageUrl,
});
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
text: built.text,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
batch: data.batch || null,
summary: data.summary || null,
};
}
const delivery = await sendTextToWebhook({
webhookUrl: config.webhookUrl,
secret: config.webhookSecret,
content: built.text,
atAll: config.atAll === true,
});
await saveWarehouseLogisticsExceptionNotifyState({
lastSentDate: pushDate,
lastSentAt: new Date().toISOString(),
lastStatus: 'sent',
lastError: '',
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
verifyDate: data.batch?.verifyDate || '',
});
return {
sent: true,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
text: built.text,
batch: data.batch || null,
summary: data.summary || null,
delivery,
};
}
async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) {
try {
const result = await pushWarehouseLogisticsExceptionNotify(options);
if (result.sent) {
console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`);
} else {
console.log(`[WleNotify] ${source} 跳过: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[WleNotify] ${source} 执行失败:`, error.message || error);
try {
await saveWarehouseLogisticsExceptionNotifyState({
...(await loadWarehouseLogisticsExceptionNotifyState()),
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 == 配置的 sendTime」。
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
*/
function startWarehouseLogisticsExceptionNotifyScheduler() {
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] 物流转运异常钉钉推送已禁用');
return;
}
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
cron.schedule(expression, () => {
if (tickRunning) return;
tickRunning = true;
(async () => {
try {
const now = new Date();
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
if (!config.enabled) return;
if (chinaHourMinute(now) !== config.sendTime) return;
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
} catch (error) {
console.error('[WleNotify] 定时检查失败:', error.message || error);
} finally {
tickRunning = false;
}
})();
}, { timezone: TIMEZONE });
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
}
module.exports = {
DEFAULT_TICK_CRON,
SAFE_CONTENT_BYTES,
TIMEZONE,
buildCenterLines,
buildDetailLine,
buildNotifyText,
buildTypeLine,
chinaDateKey,
chinaHourMinute,
groupRowsByWarehouse,
pickDetailRows,
cleanCell,
pushWarehouseLogisticsExceptionNotify,
runWarehouseLogisticsExceptionNotifyJob,
startWarehouseLogisticsExceptionNotifyScheduler,
};

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

@ -1,375 +0,0 @@
const axios = require('axios');
const database = require('../config/database');
const DEFAULT_REVIEW_TOTAL = 10;
const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100];
const REVIEW_BATCH_SIZE = 5;
const REVIEW_MAX_CONCURRENCY = 4;
const REVIEW_MAX_REFILL_ATTEMPTS = 3;
const SUPPORTED_SCENES = [
'卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关',
'办公室', '户外', '厨房', '电竞房',
];
const SUPPORTED_USER_TYPES = [
'自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友',
'女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生',
'上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导',
];
function normalizeMultiInput(value) {
const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/);
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
}
function normalizeBoolean(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
async function getSystemConfigMap(keys) {
try {
const placeholders = keys.map(() => '?').join(', ');
const rows = await database.local.query(
`SELECT config_key AS configKey, config_value AS configValue
FROM system_config
WHERE config_key IN (${placeholders})`,
keys
);
return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()]));
} catch (_) {
return new Map();
}
}
async function getRuntimeConfig() {
const keys = [
'ai_buyer_show_ark_api_key',
'ai_buyer_show_ark_base_url',
'ai_buyer_show_vision_model',
'ai_buyer_show_review_model',
];
const stored = await getSystemConfigMap(keys);
const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim();
if (!apiKey) {
const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价');
error.statusCode = 503;
throw error;
}
const baseUrl = process.env.ARK_BASE_URL
|| stored.get(keys[1])
|| 'https://ark.cn-beijing.volces.com/api/v3';
const visionModel = process.env.VISION_MODEL
|| stored.get(keys[2])
|| 'doubao-1-5-vision-pro-32k-250115';
const reviewModel = process.env.PDD_HEART_REVIEW_MODEL
|| process.env.DOUBAO_TEXT_MODEL
|| stored.get(keys[3])
|| visionModel;
return { apiKey, baseUrl, visionModel, reviewModel };
}
function buildImageDataUrl(buffer, mimeType) {
const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg';
return `data:${type};base64,${buffer.toString('base64')}`;
}
function parseJsonContent(content, fallback) {
const trimmed = String(content || '')
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```$/i, '')
.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed);
} catch (_) {
const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0]
|| trimmed.match(/\{[\s\S]*\}/)?.[0];
if (!candidate) return fallback;
try {
return JSON.parse(candidate);
} catch (_error) {
return fallback;
}
}
}
async function callChatCompletion(messages, modelType, options = {}) {
const config = await getRuntimeConfig();
const model = modelType === 'vision' ? config.visionModel : config.reviewModel;
try {
const response = await axios.post(
`${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`,
{
model,
messages,
temperature: options.temperature ?? 0.68,
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 180000,
}
);
return {
content: response.data?.choices?.[0]?.message?.content || '',
model: response.data?.model || model,
};
} catch (error) {
const detail = error.response?.data?.error?.message
|| error.response?.data?.message
|| error.response?.data?.error
|| error.message
|| 'AI接口调用失败';
const wrapped = new Error(`AI接口调用失败:${detail}`);
wrapped.statusCode = error.response?.status || 502;
throw wrapped;
}
}
async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) {
const prompt = [
'你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。',
'只返回 JSON,不要 markdown,不要解释。',
'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。',
`运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`,
`运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
`运营填写卖点:${sellingPoints}`,
].join('\n');
const result = await callChatCompletion([{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } },
],
}], 'vision');
return { analysis: parseJsonContent(result.content, {}), model: result.model };
}
function normalizeReviewText(value) {
return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim();
}
function stripEmoji(value) {
return String(value || '')
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
.replace(/\p{Extended_Pictographic}/gu, '')
.replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function normalizeReviewItem(value, useEmoji) {
const source = typeof value === 'string' ? { reviewContent: value } : (value || {});
const reviewContent = normalizeReviewText(
source.reviewContent || source.content || source.text || source.review || source['评价内容']
);
if (!reviewContent) return null;
return {
reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent),
userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']),
sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']),
scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']),
};
}
function parseReviews(content, useEmoji) {
const parsed = parseJsonContent(content, null);
const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []);
if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean);
return String(content || '')
.split(/\n+/)
.map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, ''))
.filter((line) => line.trim().length >= 30)
.map((line) => normalizeReviewItem(line, useEmoji))
.filter(Boolean);
}
function dedupeReviews(reviews) {
const seen = new Set();
return reviews.filter((review) => {
const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function diversifyOpenings(reviews) {
const counts = new Map();
const replacements = {
'说实话': ['其实,', '老实讲,', '用下来,'],
'本来': ['一开始', '之前', '原先'],
'我是': ['家里', '最近', '买回来后'],
};
return reviews.map((review) => {
const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item));
if (!opening) return review;
const used = counts.get(opening) || 0;
counts.set(opening, used + 1);
if (used === 0 && opening !== '我是') return review;
return {
...review,
reviewContent: review.reviewContent.replace(
new RegExp(`^${opening}[,,、\s]*`),
replacements[opening][used % replacements[opening].length]
),
};
});
}
async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) {
const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors);
const prompt = [
'生成拼多多家具真实买家评价。',
`1. 本批生成 ${count} 条。`,
'2. 每条 50-200 个中文字符。',
'3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。',
'4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。',
'5. 不要夸张承诺,不要医疗功效,不要绝对化表达。',
'6. 场景和使用人自然交叉组合。',
`7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`,
'8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。',
useEmoji
? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。'
: '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。',
'10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。',
'11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。',
`批次:${batchIndex}`,
`可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`,
`可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
`运营填写卖点:${sellingPoints}`,
`图片识别信息:${JSON.stringify(analysis || {})}`,
].join('\n');
const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', {
maxTokens: Math.min(4096, 240 * count),
});
return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model };
}
async function runWithLimit(tasks, limit, onResult) {
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const index = cursor;
cursor += 1;
const result = await tasks[index]();
onResult(result, index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker()));
}
async function generateHeartReviews({
imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent,
}) {
const startedAt = Date.now();
const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item));
const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item));
const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用'];
const normalizedSellingPoints = String(sellingPoints || '').trim();
const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal))
? Number(reviewTotal)
: DEFAULT_REVIEW_TOTAL;
const normalizedUseEmoji = normalizeBoolean(useEmoji, true);
if (!normalizedSellingPoints) {
const error = new Error('请填写商品卖点');
error.statusCode = 400;
throw error;
}
const vision = await analyzeImage({
imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints,
});
onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis });
let reviews = [];
let reviewPrompt = '';
let reviewModel = '';
const append = (batch) => {
if (!reviewPrompt) reviewPrompt = batch.prompt;
if (!reviewModel) reviewModel = batch.model;
reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal);
onEvent?.({ type: 'batch', total: reviews.length, targetTotal });
};
const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => {
const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE);
return () => generateReviewBatch({
count: batchCount,
batchIndex: `${prefix}${index + 1}`,
scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean),
userTypes: effectiveUserTypes,
sellingPoints: normalizedSellingPoints,
analysis: vision.analysis,
useEmoji: normalizedUseEmoji,
});
});
await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append);
for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) {
await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append);
}
if (reviews.length < targetTotal) {
const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`);
error.statusCode = 502;
throw error;
}
return {
scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'),
scenes,
userTypes: effectiveUserTypes,
sellingPoints: normalizedSellingPoints,
useEmoji: normalizedUseEmoji,
imageAnalysis: vision.analysis,
reviewPrompt,
reviews: reviews.slice(0, targetTotal),
total: targetTotal,
targetTotal,
responseMs: Date.now() - startedAt,
models: { vision: vision.model, review: reviewModel },
};
}
function buildBuyerShowPrompt({ review, analysis, style = '真实' }) {
const normalizedStyle = style === '饱和' ? '饱和' : '真实';
const productHints = normalizeMultiInput([
analysis?.productCategory,
analysis?.productNameGuess,
...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []),
]).join('、');
const styleRule = normalizedStyle === '饱和'
? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。'
: '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。';
return [
`根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`,
`当前风格:${normalizedStyle}`,
'硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。',
'家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。',
'组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。',
`买家评价:${review.reviewContent || ''}`,
`使用人:${review.userType || '按评价自然判断'}`,
`使用场景:${review.scene || '按评价自然判断'}`,
`突出卖点:${review.sellingPoint || '按评价自然提炼'}`,
productHints ? `图片识别到的商品信息:${productHints}` : '',
`${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`,
].filter(Boolean).join('\n');
}
module.exports = {
DEFAULT_REVIEW_TOTAL,
REVIEW_TOTAL_OPTIONS,
SUPPORTED_SCENES,
SUPPORTED_USER_TYPES,
buildBuyerShowPrompt,
generateHeartReviews,
};

View File

@ -1,237 +0,0 @@
const crypto = require('crypto');
const {
getPool,
query,
resolveUser,
} = require('./aiBuyerShowService');
const { uploadBufferToDefaultS3 } = require('./objectStorage');
const SEED_PACK_TABLE = 'pdd_seed_resource_packs';
const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024;
let tableReady = false;
function normalizeString(value) {
return String(value || '').trim();
}
function normalizeNullableString(value) {
const text = normalizeString(value);
return text || null;
}
// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII
// names; re-encode to recover the original UTF-8 text.
function normalizeUploadFileName(value) {
const text = normalizeString(value);
if (!text) return '';
try {
const decoded = Buffer.from(text, 'latin1').toString('utf8');
return /[\uFFFD]/.test(decoded) ? text : decoded;
} catch (_) {
return text;
}
}
async function ensureSeedPackTable() {
if (tableReady) return;
await query(
`CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID',
pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称',
product_id VARCHAR(64) NOT NULL COMMENT '商品ID',
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key',
file_url VARCHAR(1024) NULL COMMENT '公开下载地址',
review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数',
operator_id VARCHAR(64) NULL COMMENT '上传人ID',
operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人',
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_pack_uid (pack_uid),
KEY idx_seed_pack_shop (shop_name),
KEY idx_seed_pack_product (product_id),
KEY idx_seed_pack_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'`
);
tableReady = true;
}
function normalizePackRow(row = {}) {
return {
id: Number(row.id),
packUid: row.pack_uid ?? row.packUid ?? '',
shopName: row.shop_name ?? row.shopName ?? '',
productId: row.product_id ?? row.productId ?? '',
skuName: row.sku_name ?? row.skuName ?? '',
fileName: row.file_name ?? row.fileName ?? '',
fileSize: Number(row.file_size ?? row.fileSize ?? 0),
fileUrl: row.file_url ?? row.fileUrl ?? '',
reviewCount: Number(row.review_count ?? row.reviewCount ?? 0),
operatorName: row.operator_name ?? row.operatorName ?? '',
downloadCount: Number(row.download_count ?? row.downloadCount ?? 0),
createdAt: row.created_at || null,
};
}
function validateSeedPackInput({ shopName, productId, file }) {
if (!normalizeString(shopName)) {
const error = new Error('店铺名称不能为空');
error.statusCode = 400;
return error;
}
if (!normalizeString(productId)) {
const error = new Error('商品ID不能为空');
error.statusCode = 400;
return error;
}
if (!file?.buffer || !file.buffer.length) {
const error = new Error('ZIP 文件不能为空');
error.statusCode = 400;
return error;
}
if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) {
const error = new Error('ZIP 文件不能超过 200MB');
error.statusCode = 413;
return error;
}
return null;
}
async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) {
await ensureSeedPackTable();
const operator = resolveUser(user);
const validationError = validateSeedPackInput({ shopName, productId, file });
if (validationError) throw validationError;
const uploaded = await uploadBufferToDefaultS3(
file.buffer,
'application/zip',
'seed-packs',
'zip'
);
const resolvedFileName = normalizeString(fileName)
|| normalizeUploadFileName(file?.originalname)
|| `种菜资源包_${Date.now()}.zip`;
const packUid = crypto.randomUUID();
const insertRows = await query(
`INSERT INTO ${SEED_PACK_TABLE} (
pack_uid, shop_name, product_id, sku_name, file_name, file_size,
object_key, file_url, review_count, operator_id, operator_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
packUid,
normalizeString(shopName).slice(0, 128),
normalizeString(productId).slice(0, 64),
normalizeNullableString(String(skuName || '').slice(0, 255)),
resolvedFileName.slice(0, 255),
file.buffer.length,
uploaded.key,
uploaded.url || '',
Math.max(Number(reviewCount) || 0, 0),
operator.id || null,
operator.name,
]
);
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[insertRows.insertId]
);
return normalizePackRow(rows[0]);
}
async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) {
await ensureSeedPackTable();
const normalizedPage = Math.max(Number(page) || 1, 1);
const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100);
const offset = (normalizedPage - 1) * normalizedPageSize;
const normalizedKeyword = normalizeString(keyword);
const likeKeyword = `%${normalizedKeyword}%`;
const whereSql = normalizedKeyword
? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?'
: '';
const params = normalizedKeyword ? Array(5).fill(likeKeyword) : [];
const countRows = await query(
`SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`,
params
);
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE}
${whereSql}
ORDER BY id DESC
LIMIT ? OFFSET ?`,
[...params, normalizedPageSize, offset]
);
return {
rows: rows.map(normalizePackRow),
total: Number(countRows[0]?.total || 0),
page: normalizedPage,
pageSize: normalizedPageSize,
};
}
async function getSeedPackDownload(id) {
await ensureSeedPackTable();
const packId = Number(id);
if (!Number.isFinite(packId) || packId <= 0) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[packId]
);
const pack = rows[0];
if (!pack) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
await query(
`UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`,
[packId]
);
return {
...normalizePackRow(pack),
downloadCount: Number(pack.downloadCount || 0) + 1,
};
}
async function deleteSeedPack(id) {
await ensureSeedPackTable();
const packId = Number(id);
if (!Number.isFinite(packId) || packId <= 0) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
const rows = await query(
`SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[packId]
);
if (!rows.length) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]);
return { id: packId };
}
module.exports = {
createSeedPack,
deleteSeedPack,
getSeedPackDownload,
listSeedPacks,
MAX_SEED_PACK_FILE_BYTES,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
'use strict';
/**
* 缺货补货监控(拣货位 → 存货位)本地表
* --------------------------------------------------------------------------
* 为什么需要本地表:
* 聚水潭「箱及仓位库存」在副本里是**视图** rpt.jst_packInventoryInfo,
* 只有货位号(bin)与数量,**没有货位类型**(拣货位 / 存货位)这层主数据
* (全库 5,496 个字段里没有 bin_type / location_type 一类的列,见 2026-10-22 复核)。
* 而业务判断「拣货位缺货、要从存货位补」必须先知道哪些 bin 是拣货位,
* 所以把业务给的货位清单导入本地库,作为判定基准。
*
* 两张表:
* bin_replenish_pick_bin —— 拣货位清单(按仓库隔离,可重复导入覆盖)
* bin_replenish_import_batch —— 导入批次日志(谁、什么时候、导入多少个)
*
* 未导入清单时,服务层回退到「pack_type = 'Bin' 的记录」作为拣货位口径,
* 页面会明确提示当前用的是回退口径。
*/
const database = require('../config/database');
const SCHEMA_SQL = [
`CREATE TABLE IF NOT EXISTS bin_replenish_pick_bin (
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识(changzhou/henglin3/subei/suining/henglin2/wh7)',
bin_code VARCHAR(64) NOT NULL COMMENT '货位号(大写去空格)',
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (warehouse_key, bin_code),
KEY idx_brpb_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位清单'`,
`CREATE TABLE IF NOT EXISTS bin_replenish_import_batch (
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识',
file_name VARCHAR(255) NULL COMMENT '文件名',
total_cells INT NOT NULL DEFAULT 0 COMMENT '扫描到的候选单元格数',
imported_rows INT NOT NULL DEFAULT 0 COMMENT '写入的货位数',
imported_by VARCHAR(100) NULL COMMENT '导入人',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (batch_id),
KEY idx_brpib_wh (warehouse_key, imported_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位导入批次'`,
];
async function ensureBinReplenishSchema() {
for (const sql of SCHEMA_SQL) {
// eslint-disable-next-line no-await-in-loop
await database.local.query(sql);
}
}
module.exports = { ensureBinReplenishSchema };

View File

@ -0,0 +1,477 @@
'use strict';
/**
* 缺货补货监控(订单驱动版,2026-10-22 重写)
* ==========================================================================
* 业务需求(用户确认):
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
*
* ── 数据源 ───────────────────────────────────────────────────────────
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
* —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 按 wh_id 会把多仓混成一个桶。
*
* ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
* 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
* 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
* 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
* 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
* **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
*
* ── 判断逻辑(核心)──────────────────────────────────────────────────
* 对每一个 SKU(在拣货/存储位上有库存):
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
* 超出订单数 excess = max(0, orderQty − pickQty)
* —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
*
* 状态:
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* enough 拣货位充足(0 < orderQty ≤ pickQty)
* no_order 无待发单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
*
* ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/
const cloudDb = require('../config/cloudDatabase');
/**
* 仓库清单:**按 wms_co_id 取数**。
* 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
* 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
*/
const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 },
henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 },
subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 },
henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 },
shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 },
cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 },
suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 },
cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 },
henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 },
});
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000;
// ── 货位前缀规则(用户指定)────────────────────────────────────────
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
// ── 待发口径:聚水潭「打单拣货/发货中」快照(rpt.op_OrderNotDispatched)────────
// 该表是每日快照;取该仓「最新快照日」且 status='发货中' 的行。
// 历史标签在 2026-03 之前叫「发货中(打单拣货)」,两个都认,避免换名漏数。
// 不含:已发/取消/删除/拆单/合单、异常、已付款待审核、待付款。
const PENDING_STATUS_LIST = Object.freeze(['发货中', '发货中(打单拣货)']);
const PENDING_STATUS_IN = PENDING_STATUS_LIST.map((s) => `'${s}'`).join(',');
const PENDING_ORDER_SCOPE = '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)';
const STATUS = Object.freeze({
SHORT: 'short',
NEED: 'need',
ENOUGH: 'enough',
NO_ORDER: 'no_order',
});
const STATUS_LABELS = Object.freeze({
short: '存储不足',
need: '需补货',
enough: '拣货位充足',
no_order: '无待发单',
});
// 排序权重:最紧急的排最前
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
const cacheStore = new Map();
let cacheEpoch = 0;
function toText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function toNumber(value, fallback = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
}
function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
}
function resolveWarehouse(value) {
return WAREHOUSES[resolveWarehouseKey(value)];
}
function resolveCacheTtlMs() {
const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS;
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS;
const num = Number(raw);
if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS;
return num;
}
// ── 副本取数 ────────────────────────────────────────────────────────
/**
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
* 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
*/
async function loadReplicaInventory(warehouse) {
const sql = `
SELECT p.sku_id AS skuId,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId
AND p.wh_id = 1
AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`;
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
}
/**
* 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
* 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
*/
async function loadSkuNames(skuIds) {
const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
if (!uniq.length) return new Map();
const map = new Map();
const CHUNK = 400;
for (let i = 0; i < uniq.length; i += CHUNK) {
const chunk = uniq.slice(i, i + CHUNK);
const params = {};
const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
chunk.forEach((id, idx) => { params[`s${idx}`] = id; });
const rows = await cloudDb.querySlow(
`SELECT sku_id AS skuId, name AS productName, supplier_name AS supplier
FROM rpt.jst_sku WHERE sku_id IN (${ph})`,
params,
{ requestTimeout: 120000 }
);
(rows || []).forEach((r) => {
const id = toText(r.skuId, 120);
if (id) map.set(id, { productName: toText(r.productName, 300), supplier: toText(r.supplier, 120) });
});
}
return map;
}
/**
* 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
* 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
* 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
*
* ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
* 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
* 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
*/
async function loadPendingOrderMap(warehouse) {
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const slowOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 300000 };
// step1:该仓最新快照日(local_updated≈每日 07:00 一批,MAX 落在最新那批)
const snapRows = await runQuery(
`SELECT CAST(MAX(local_updated) AS date) AS snapDate
FROM rpt.op_OrderNotDispatched
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId: warehouse.wmsCoId },
slowOpts
);
const snapDate = snapRows && snapRows[0] && snapRows[0].snapDate;
if (!snapDate) return new Map();
// step2:最新快照日当天的「发货中」待发量,按 sku_id 汇总(组合装已展开)
const rows = await runQuery(
`SELECT o.sku_id AS skuId,
SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.op_OrderNotDispatched o
WHERE o.wms_co_id = @wmsCoId
AND o.status IN (${PENDING_STATUS_IN})
AND o.local_updated >= @snapDate
AND o.local_updated < DATEADD(day, 1, @snapDate)
AND ISNULL(o.sku_id, '') <> ''
GROUP BY o.sku_id`,
{ wmsCoId: warehouse.wmsCoId, snapDate },
slowOpts
);
const map = new Map();
(rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.orderQty));
});
return map;
}
// ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ invRows, orderMap }) {
const rows = [];
(invRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (!skuId) return;
const pickQty = round2(row.pickQty);
const storageQty = round2(row.storageQty);
const orderQty = round2(toNumber(orderMap.get(skuId), 0));
const excess = Math.max(0, round2(orderQty - pickQty));
let status = STATUS.NO_ORDER;
let suggestQty = 0;
let shortfall = 0;
let pickEmpty = false;
if (orderQty > 0) {
if (pickQty <= 0) pickEmpty = true;
if (excess > 0) {
const can = Math.min(excess, storageQty);
suggestQty = round2(can);
if (storageQty >= excess) {
status = STATUS.NEED;
} else {
status = STATUS.SHORT;
shortfall = round2(excess - storageQty);
}
} else {
status = STATUS.ENOUGH;
}
}
rows.push({
skuId,
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '',
status,
statusLabel: STATUS_LABELS[status],
pickQty,
storageQty,
totalQty: round2(pickQty + storageQty),
orderQty,
excess,
suggestQty,
shortfall,
pickEmpty,
});
});
return rows;
}
/**
* 汇总只统计「待补」SKU(need / short)——
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
* 实时代发订单数 = 待补 SKU 待发件数合计
* 拣货库存数 = 待补 SKU 拣货位库存合计
* 存储库存数 = 待补 SKU 存储位库存合计
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
*/
function buildSummary(rows) {
const summary = {
skuTotal: 0,
need: 0,
short: 0,
orderQtyTotal: 0,
pickTotal: 0,
storageTotal: 0,
suggestQtyTotal: 0,
exceedTotal: 0,
shortfallTotal: 0,
};
rows.forEach((row) => {
summary.skuTotal += 1;
if (row.status === STATUS.NEED) summary.need += 1;
else if (row.status === STATUS.SHORT) summary.short += 1;
summary.orderQtyTotal += row.orderQty || 0;
summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0;
summary.suggestQtyTotal += row.suggestQty || 0;
summary.exceedTotal += row.excess || 0;
summary.shortfallTotal += row.shortfall || 0;
});
summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal);
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
return summary;
}
async function loadDataset(warehouse, { refresh = false } = {}) {
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
const cacheKey = warehouse.key;
const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs();
const now = Date.now();
if (!refresh && cached && ttl > 0 && now - cached.at < ttl) {
return { ...cached, cacheHit: true };
}
const epoch = cacheEpoch;
const [invRows, orderMap] = await Promise.all([
loadReplicaInventory(warehouse),
loadPendingOrderMap(warehouse).catch(() => new Map()),
]);
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 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 = {
at: now,
rows,
warehouse,
orderScope: PENDING_ORDER_SCOPE,
meta: {
rowCount: rows.length,
orderSkuCount: orderMap.size,
},
};
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset);
return { ...dataset, cacheHit: false };
}
function invalidateCache(warehouseKey) {
cacheEpoch += 1;
if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey));
else cacheStore.clear();
}
function sortRows(rows) {
return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0)
|| (b.excess || 0) - (a.excess || 0)
|| String(a.skuId).localeCompare(String(b.skuId))
));
}
/**
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 SKU(need / short)。
* status='short' 时仅保留存储不足(缺货)的 SKU。
*/
function filterReplenish(rows, { keyword, status }) {
const kw = toText(keyword, 120).toLowerCase();
const onlyShort = toText(status, 12).toLowerCase() === 'short';
return rows.filter((row) => {
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;
return row.skuId.toLowerCase().includes(kw)
|| String(row.productName || '').toLowerCase().includes(kw)
|| String(row.supplier || '').toLowerCase().includes(kw);
});
}
async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const replenished = filterReplenish(dataset.rows, options);
const sorted = sortRows(replenished);
const summary = buildSummary(replenished);
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 start = (page - 1) * pageSize;
return {
rows: sorted.slice(start, start + pageSize),
total: sorted.length,
page,
pageSize,
summary,
warehouse,
warehouses: Object.values(WAREHOUSES),
orderScope: PENDING_ORDER_SCOPE,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
};
}
async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const replenished = filterReplenish(dataset.rows, options);
return {
warehouse,
orderScope: PENDING_ORDER_SCOPE,
rows: sortRows(replenished),
summary: buildSummary(replenished),
};
}
/** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() {
try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
const t0 = Date.now();
await loadDataset(warehouse, { refresh: true });
console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`);
} catch (error) {
console.warn('[BinReplenish] 预热失败:', error.message);
}
}
module.exports = {
DEFAULT_WAREHOUSE_KEY,
PICK_PREFIXES,
STORE_PREFIXES,
STATUS,
STATUS_LABELS,
WAREHOUSES,
clearPickBins: async () => ({ removed: 0 }),
getReplenishExportRows,
getReplenishList,
loadDataset,
resolveWarehouse,
resolveWarehouseKey,
warmBinReplenishCache,
};

View File

@ -1,111 +0,0 @@
'use strict';
const PRE_SALE_LABELS = [
'库存备货:是否有货',
'发货问题:发货地、发货速度、缺货问题、催发货、延期发货',
'批量外贸单:贴唛头',
'快递问题:是否包邮、指定快递、送货上门、物流速度、配送速度、到货时间',
'产品问题:是否防潮/防火、是否有背板/轨道、材质的区别、产地问题、质保时效、尺寸问题、板材厚度、是否支持定制、承重性',
'产品环保问题:检测报告、气味问题',
'价格问题:优惠券问题、买后比价、商品降价',
'下单错误:多拍错拍、地址/电话信息错误',
'决策信息不充分:尺码/尺寸/规格不合适',
'安装问题:安装说明书/安装视频、是否包安装、是否整装发货',
'发票问题:是否有发票、多开/少开发票、催发票',
];
const AFTER_SALE_LABELS = [
'发货履约问题:发货异常、虚假发货',
'物流配送问题:更新超时、丢件、派送超时、送货异常、退货到仓',
'商品交付问题:少发漏发、错发、运输产品破损',
'商品质量问题:产品质量、描述不符',
'安装服务问题:安装咨询、上门安装',
'客服服务问题:回复超时、服务态度极差、承诺未履行、操作错误',
'退换货问题:上门取件',
'平台业务问题:铺货错误、种菜异常',
'其他售后:其他',
];
function buildSystemPrompt() {
return [
'你是拼多多电商客服聊天质检与商品问题分析专家。',
'你的任务是分析一个商品ID在指定日期范围内的全部客服聊天,识别售前和售后诉求,并把结果聚合成可用于产品改进、详情页优化和售后管理的结构化数据。',
'',
'【发言方识别】',
'1. 每条消息已经提供“买家”或“客服”角色,优先使用该角色。',
'2. 客服寒暄、安抚、礼貌语和标准话术不能单独作为证据;客服明确确认已超时、已登记、已拒绝、承诺未履行时可辅助举证。',
'3. 归因以买家诉求为主,不能把客服建议或猜测当成买家问题。',
'',
'【售前/售后判定】',
'1. 出现“订单编号:xxx”“订单号:xxx”或订单卡片后,从该位置开始的后续买家内容按售后语境处理;之前内容独立判断。',
'2. 未出现订单号时,根据语义判断:下单前的购买决策咨询属于售前;已付款、已收货或明确反馈已发生问题属于售后。',
'3. “多久发货”在下单前是售前;“为什么还不发货”或“催发货”通常是售后。',
'4. 同一诉求无法区分时售后优先。否定、假设、举例、转述他人或已经解决的问题不能作为当前问题。',
'',
'【售前标签白名单】',
...PRE_SALE_LABELS,
'售前无法匹配时使用 primary_reason=“未知”、secondary_reason=“其他问题”。',
'',
'【售后标签白名单】',
...AFTER_SALE_LABELS,
'售后无法匹配时使用 primary_reason=“其他售后”、secondary_reason=“其他”。',
'',
'【标签边界】',
'商品与页面描述、宣传或明确承诺不一致,使用“商品质量问题/描述不符”;商品本身缺陷、故障、开裂、变形,使用“商品质量问题/产品质量”;发错型号、款式、颜色或规格,使用“商品交付问题/错发”;缺少商品或配件,使用“商品交付问题/少发漏发”;运输造成磕碰、断裂或包装损坏,使用“商品交付问题/运输产品破损”。',
'',
'【聚合规则】',
'1. 相同一级原因+二级原因的诉求合并为一条结果,统计 occurrence_count 和 affected_customer_count。',
'2. 不同语义的独立问题分别输出,例如少发漏发和运输破损不能合并。',
'3. 每条结果最多保留3条最有代表性的买家原话证据,证据不得改写或拼接。',
'4. affected_conversation_ids 必须列出当前问题涉及的全部会话ID(uid),不得只列关键证据中的客户,也不得填入无关客户。',
'5. 买家消息中的图片URL只提取与当前问题直接相关的图片,每条结果最多5张,必须原样保留。',
'6. product_improvement_areas只能从:产品本体、规格配置、宣传主图、详情页、客服话术、价格策略、库存备货、履约物流、安装服务、售后服务、无需改进 中选择。',
'',
'【严格输出】',
'只能输出合法JSON,不要输出Markdown代码块、解释或额外文字。JSON结构必须是:',
JSON.stringify({
schema_version: '1.0',
conversation_type: '售前|售后|混合|无有效咨询',
summary: {
conversation_count: 0,
pre_sale_conversation_count: 0,
after_sale_conversation_count: 0,
mixed_conversation_count: 0,
main_findings: [],
},
results: [{
consultation_type: '售前|售后',
primary_reason: '一级原因',
secondary_reason: '二级原因',
occurrence_count: 1,
affected_customer_count: 1,
affected_conversation_ids: ['uid'],
buyer_intent: '买家诉求概括',
evidence: [{ conversation_id: 'uid', nickname: '买家昵称', message_time: '消息时间', text: '买家原文' }],
image_urls: [],
product_improvement_areas: [],
improvement_suggestion: '',
confidence: 0.95,
}],
}, null, 2),
].join('\n');
}
function buildUserPrompt({ product = {}, metrics = {}, chatText = '', startDate, endDate }) {
return [
`请分析商品ID:${product.goodsId || ''}`,
`商品名称:${product.goodsName || ''}`,
`所属店铺:${product.shopName || ''}`,
`图片地址:${product.hdThumbUrl || ''}`,
`分析日期:${startDate} 至 ${endDate}`,
`商品表现指标:${JSON.stringify(metrics)}`,
'',
'以下是该商品在指定日期范围内的聊天记录。每条记录已经标注角色,请结合上下文完成售前/售后识别、标签映射和问题聚合。',
chatText,
].join('\n');
}
module.exports = {
buildSystemPrompt,
buildUserPrompt,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
'use strict';
/**
* 每日进销存 —— 流水列取数(业务库原生口径)
*
* 业务库 JSReportReplica 能还原的 4 个流水列(其余 8 列缺单据表,保持「—」):
* 采购进仓(+): rpt.jst_purchase × jst_purchaseItem,按 po_date,状态 Confirmed/Finished,取明细 qty
* (明细 inQty 全为 0 未同步,故用 qty)
* 采购退货(−): rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date
* 销售出仓(−): rpt.jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled
* (与「库存周转天数」的发货口径一致;与聚水潭进销存导出的出库单口径有偏差,
* 因为销售出库单明细数量在业务库未同步,无法精确还原)
* 销售退货(+): rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date,
* 类型限定 普通退货/拒收退货/换货/维修,取明细 r_qty(实收数量;
* qty 字段大多未同步,不能用)
*
* 符号约定与页面一致:流入为正、流出为负(前端负数标红)。
* 因为还有 8 类单据(调拨/盘点/加工/其它出入库)在业务库无表,
* 「期末 ≠ 期初 + 流水之和」,差值即缺失单据的影响。
*/
const FLOW_QUERIES = [
{
field: 'purchaseIn',
sign: 1,
sql: `
SELECT p.wms_co_id AS wmsCoId,
SUM(CAST(ISNULL(pi.qty, 0) AS DECIMAL(18, 2))) AS qty
FROM rpt.jst_purchase p
JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
WHERE p.wms_co_id IN (%WAREHOUSES%)
AND p.po_date >= @from
AND ISNULL(p.status, '') IN ('Confirmed', 'Finished')
GROUP BY p.wms_co_id`,
},
{
field: 'purchaseReturn',
sign: -1,
sql: `
SELECT o.wms_co_id AS wmsCoId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty
FROM rpt.jst_purchaseout o
JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
WHERE o.wms_co_id IN (%WAREHOUSES%)
AND o.io_date >= @from
GROUP BY o.wms_co_id`,
},
{
field: 'salesOut',
sign: -1,
sql: `
SELECT o.wms_co_id AS wmsCoId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty
FROM rpt.jst_Orders o
JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id
WHERE o.wms_co_id IN (%WAREHOUSES%)
AND o.send_date >= @from
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
GROUP BY o.wms_co_id`,
},
{
field: 'salesReturn',
sign: 1,
sql: `
SELECT r.wms_co_id AS wmsCoId,
SUM(CAST(ISNULL(ri.r_qty, 0) AS DECIMAL(18, 2))) AS qty
FROM rpt.jst_returnAndRefundInfo r
JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
WHERE r.wms_co_id IN (%WAREHOUSES%)
AND r.as_date >= @from
AND ISNULL(r.type, '') IN (N'普通退货', N'拒收退货', N'换货', N'维修')
GROUP BY r.wms_co_id`,
},
];
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
params[`${prefix}${i}`] = v;
return `@${prefix}${i}`;
})
.join(',');
return { clause, params };
}
/** 业务库可还原的流水字段(带符号:流入 +1 / 流出 −1) */
const AVAILABLE_FLOW_FIELDS = FLOW_QUERIES.map((q) => q.field);
/**
* 拉取自 fromText(含)以来各仓的流水合计。
* @returns {Promise<Map<string, Object>>} key = wmsCoId 字符串,value = { purchaseIn, purchaseReturn, salesOut, salesReturn }(带符号,无记录的字段不出现)
*/
async function fetchFlows(cloudDb, wmsCoIds, fromText) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchFlows 第一个参数必须是数据库实例(防呆:调用漏传 cloudDb 时立即报错而非静默返回空)');
}
const map = new Map();
if (!wmsCoIds.length || !fromText) return map;
for (const q of FLOW_QUERIES) {
const { clause, params } = buildInClause(wmsCoIds, 'w');
const sqlText = q.sql.replace('%WAREHOUSES%', clause);
const rows = await cloudDb.query(sqlText, { ...params, from: fromText }, { requestTimeout: 120000 });
for (const r of rows) {
const key = String(r.wmsCoId);
const entry = map.get(key) || {};
const qty = Number(r.qty) || 0;
entry[q.field] = q.sign * qty;
map.set(key, entry);
}
}
return map;
}
module.exports = {
AVAILABLE_FLOW_FIELDS,
FLOW_QUERIES,
fetchFlows,
buildInClause,
};

View File

@ -0,0 +1,330 @@
'use strict';
// 每日进销存 · 定时截图通知 —— 配置 + 执行记录 + 幂等记录(本地库 system_config)
//
// ── 配置键 daily_inventory_notification_config ────────────────────
// { enabled, notifyTime: 'HH:mm', recipients: ['张三', '李四'] }
//
// ── 状态键 daily_inventory_notification_state ──────────────────────
// { version, notified: { 'YYYY-MM-DD': { '别名': { notifiedAt, deliveryId } } }, runs: [...] }
// · notified 用于「同一业务日期 + 同一用户只成功通知一次」的幂等判断
// · runs 保留最近 N 次执行明细,供管理员在页面查询
//
// 安全约定:本文件对外只返回「是否已配置」这类布尔值与脱敏预览,
// 绝不返回通知中心 projectSecret、对象存储 secretAccessKey 等密钥。
const database = require('../config/database');
const {
getYydNotificationCenterRuntimeConfig,
toAdminConfig: toNotificationCenterAdminConfig,
} = require('./yydNotificationCenterSettings');
const { getDefaultS3Config } = require('./objectStorage');
const CONFIG_KEY = 'daily_inventory_notification_config';
const STATE_KEY = 'daily_inventory_notification_state';
const DEFAULT_NOTIFY_TIME = '01:00';
const TIMEZONE = 'Asia/Shanghai';
const MAX_RECIPIENTS = 20;
const MAX_RECIPIENT_LENGTH = 50;
const MAX_RUN_RECORDS = 30;
const CONFIG_CACHE_TTL_MS = 30 * 1000;
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try {
const parsed = JSON.parse(String(value || ''));
return parsed && typeof parsed === 'object' ? parsed : fallback;
} catch (_error) {
return fallback;
}
}
function normalizeFlag(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;
}
/**
* 通知时间规范化:只接受 HH:mm(24 小时制),非法值回退默认 01:00。
* 允许传 'HH:mm:ss',截断到分钟。
*/
function normalizeNotifyTime(value) {
const text = normalizeText(value, 10);
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text);
if (!match) return DEFAULT_NOTIFY_TIME;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || hour < 0 || hour > 23) return DEFAULT_NOTIFY_TIME;
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return DEFAULT_NOTIFY_TIME;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/**
* 收件人名单规范化:trim → 去空 → 去重(保持输入顺序)→ 限长限量。
* 支持数组、逗号/换行/分号分隔的字符串。
*/
function normalizeRecipients(input) {
let list = [];
if (Array.isArray(input)) {
list = input;
} else if (typeof input === 'string') {
list = input.split(/[,,;;\n\r\t]+/);
} else if (input && typeof input === 'object') {
list = Object.values(input);
}
const seen = new Set();
const result = [];
for (const raw of list) {
const name = normalizeText(raw, MAX_RECIPIENT_LENGTH);
if (!name) continue;
if (seen.has(name)) continue;
seen.add(name);
result.push(name);
if (result.length >= MAX_RECIPIENTS) break;
}
return result;
}
/** 幂等键:正式推送按「业务日期 + 收件人」去重;测试推送带时间戳,不污染正式记录 */
function buildIdempotencyKey({ bizDate, recipient, mode = 'scheduled', nonce = '' }) {
const scope = mode === 'test' ? `test:${normalizeText(nonce, 64)}` : 'daily';
return `daily_inventory_notification:${scope}:${bizDate}:${recipient}:v1`.slice(0, 160);
}
async function readSystemConfigValue(key) {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
return rows?.[0]?.configValue;
}
async function upsertSystemConfigValue(key, value, configType = 'json') {
const payload = typeof value === 'string' ? value : JSON.stringify(value);
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
if (rows?.[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[payload, configType, key]
);
} else {
await database.local.insert('system_config', {
config_key: key,
config_value: payload,
config_type: configType,
});
}
return payload;
}
// ── 配置 ────────────────────────────────────────────────────────
async function getDailyInventoryNotificationRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
return { ...cachedConfig };
}
let stored = {};
try {
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
} catch (error) {
console.warn('读取每日进销存通知配置失败,使用默认值:', error.message);
}
cachedConfig = {
enabled: normalizeFlag(stored.enabled, false),
notifyTime: normalizeNotifyTime(stored.notifyTime ?? stored.notify_time),
recipients: normalizeRecipients(stored.recipients),
};
cachedAt = Date.now();
return { ...cachedConfig };
}
async function saveDailyInventoryNotificationConfig(input = {}) {
const current = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
const next = {
enabled: normalizeFlag(input.enabled, current.enabled),
notifyTime: normalizeNotifyTime(
input.notifyTime ?? input.notify_time ?? current.notifyTime
),
recipients: normalizeRecipients(
input.recipients === undefined ? current.recipients : input.recipients
),
};
await upsertSystemConfigValue(CONFIG_KEY, next);
cachedConfig = { ...next };
cachedAt = Date.now();
return { ...next };
}
// ── 执行记录 / 幂等记录 ─────────────────────────────────────────
async function loadDailyInventoryNotificationState() {
let parsed = {};
try {
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
} catch (error) {
console.warn('读取每日进销存通知状态失败,按空状态处理:', error.message);
}
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
? parsed.notified
: {};
const runs = Array.isArray(parsed.runs) ? parsed.runs : [];
return { version: 1, notified, runs };
}
async function saveDailyInventoryNotificationState(state = {}) {
const value = {
version: 1,
notified: state.notified && typeof state.notified === 'object' && !Array.isArray(state.notified)
? state.notified
: {},
runs: Array.isArray(state.runs) ? state.runs.slice(-MAX_RUN_RECORDS) : [],
};
await upsertSystemConfigValue(STATE_KEY, value);
return value;
}
/** 判断某业务日期是否已成功通知过某人(只认正式推送) */
async function hasNotified(bizDate, recipient) {
const state = await loadDailyInventoryNotificationState();
const day = state.notified?.[bizDate];
return Boolean(day && day[recipient]);
}
/** 记录一次成功通知(正式推送才写;测试推送不写) */
async function markNotified(bizDate, recipient, extra = {}) {
const state = await loadDailyInventoryNotificationState();
const day = { ...(state.notified?.[bizDate] || {}) };
day[recipient] = {
notifiedAt: new Date().toISOString(),
deliveryId: extra.deliveryId || null,
idempotencyKey: extra.idempotencyKey || null,
};
const notified = { ...(state.notified || {}), [bizDate]: day };
// 只保留最近 14 个业务日,避免状态无限膨胀
const keys = Object.keys(notified).sort();
const trimmed = {};
keys.slice(-14).forEach((k) => { trimmed[k] = notified[k]; });
return saveDailyInventoryNotificationState({ ...state, notified: trimmed });
}
/** 追加一条执行记录 */
async function appendRunRecord(record = {}) {
const state = await loadDailyInventoryNotificationState();
const runs = [...(state.runs || []), {
runAt: record.runAt || new Date().toISOString(),
bizDate: record.bizDate || null,
mode: record.mode || 'scheduled',
success: record.success === true,
reason: record.reason || null,
error: record.error || null,
screenshotUrl: record.screenshotUrl || null,
screenshotKey: record.screenshotKey || null,
notifyTime: record.notifyTime || null,
recipients: Array.isArray(record.recipients) ? record.recipients : [],
}];
return saveDailyInventoryNotificationState({ ...state, runs });
}
function listRunRecords(limit = 10) {
return loadDailyInventoryNotificationState().then((state) => {
const runs = Array.isArray(state.runs) ? state.runs : [];
return runs.slice(-Math.max(Number(limit) || 10, 1)).reverse();
});
}
// ── 管理员视图(不含任何密钥) ───────────────────────────────────
async function getObjectStorageStatus() {
try {
const config = await getDefaultS3Config(false);
return {
configured: Boolean(config && config.bucket && config.endpoint && config.accessKeyId),
bucket: config?.bucket || '',
endpoint: config?.endpoint || '',
// 只给脱敏信息,secretAccessKey 从不读取、从不返回
accessKeyIdPreview: config?.accessKeyId
? `${String(config.accessKeyId).slice(0, 4)}***`
: '',
};
} catch (error) {
return { configured: false, bucket: '', endpoint: '', accessKeyIdPreview: '', error: error.message };
}
}
async function getDailyInventoryNotificationAdminConfig() {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
center = {};
}
const centerAdmin = toNotificationCenterAdminConfig(center);
const storage = await getObjectStorageStatus();
const state = await loadDailyInventoryNotificationState();
const runs = Array.isArray(state.runs) ? state.runs : [];
const blockingReasons = [];
if (!config.enabled) blockingReasons.push('功能未启用');
if (!config.recipients.length) blockingReasons.push('未配置通知用户');
if (!centerAdmin.configured) blockingReasons.push('统一通知中心未配置');
if (!storage.configured) blockingReasons.push('对象存储未配置');
return {
enabled: config.enabled,
notifyTime: config.notifyTime,
recipients: config.recipients,
timezone: TIMEZONE,
maxRecipients: MAX_RECIPIENTS,
notificationCenter: {
configured: centerAdmin.configured,
baseUrl: centerAdmin.baseUrl,
projectCode: centerAdmin.projectCode,
projectSecretConfigured: centerAdmin.projectSecretConfigured,
projectSecretPreview: centerAdmin.projectSecretPreview,
},
objectStorage: storage,
blockingReasons,
lastRunAt: runs.length ? runs[runs.length - 1].runAt : null,
lastRunSuccess: runs.length ? runs[runs.length - 1].success === true : null,
runs: runs.slice(-10).reverse(),
};
}
module.exports = {
CONFIG_KEY,
STATE_KEY,
DEFAULT_NOTIFY_TIME,
MAX_RECIPIENTS,
MAX_RECIPIENT_LENGTH,
MAX_RUN_RECORDS,
TIMEZONE,
appendRunRecord,
buildIdempotencyKey,
getDailyInventoryNotificationAdminConfig,
getDailyInventoryNotificationRuntimeConfig,
getObjectStorageStatus,
hasNotified,
listRunRecords,
loadDailyInventoryNotificationState,
markNotified,
normalizeFlag,
normalizeNotifyTime,
normalizeRecipients,
normalizeText,
saveDailyInventoryNotificationConfig,
saveDailyInventoryNotificationState,
};

View File

@ -0,0 +1,137 @@
/**
* 每日进销存 —— 聚水潭官方进销存导出解析 / 导入
*
* 输入为聚水潭进销存报表按仓导出(xlsx),列包含:
* 仓库、期初数量、[12 个流水列]、期末数量
* 流水列符号沿用导出原值(出仓为负、入仓为正),前端不做二次转换。
*/
const XLSX = require('xlsx');
const {
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
} = require('../config/dailyInventoryBaseline');
const officialDb = require('../config/dailyInventoryOfficialDb');
const FLOW_FIELDS = FLOW_COLUMNS.map((c) => c.field);
/** 流水列中文表头 → field */
const LABEL_TO_FIELD = new Map(FLOW_COLUMNS.map((c) => [c.label, c.field]));
/** 仓库名 → wms_co_id(来自项目仓库清单) */
const WAREHOUSE_TO_ID = new Map(
DAILY_INVENTORY_BASELINE.map((b) => [b.warehouse, b.wmsCoId])
);
const num = (v) => {
if (v == null || v === '' || v === '-') return null;
const n = Number(String(v).replace(/,/g, '').trim());
return Number.isFinite(n) ? n : null;
};
/**
* 把「表头 → 值」的记录数组归一化为官方行
* @param {Array<Object>} records
* @returns {{rows: Array, warnings: string[]}}
*/
function normalizeRecords(records) {
const rows = [];
const warnings = [];
if (!Array.isArray(records)) return { rows, warnings: ['导入内容不是数组'] };
records.forEach((rec, idx) => {
const keys = Object.keys(rec);
const warehouseKey = keys.find((k) => String(k).trim() === '仓库');
if (!warehouseKey) return;
const warehouse = String(rec[warehouseKey] ?? '').trim();
if (!warehouse) return;
const openingKey = keys.find((k) => String(k).trim() === '期初数量');
const closingKey = keys.find((k) => String(k).trim() === '期末数量');
if (!openingKey && !closingKey) {
warnings.push(`第 ${idx + 2} 行缺少「期初数量 / 期末数量」列,已跳过`);
return;
}
// 12 个流水列先置 null,导出行里没出现的列保持 null(区别于显式为 0)
const flows = {};
for (const field of FLOW_FIELDS) flows[field] = null;
for (const k of keys) {
const label = String(k).trim();
const field = LABEL_TO_FIELD.get(label);
if (!field) continue;
flows[field] = num(rec[k]);
}
rows.push({
warehouse,
wmsCoId: WAREHOUSE_TO_ID.get(warehouse) ?? null,
openingQty: openingKey ? num(rec[openingKey]) : null,
closingQty: closingKey ? num(rec[closingKey]) : null,
flows,
});
});
if (!rows.length) warnings.push('未解析到任何仓库行,请确认包含「仓库」列');
return { rows, warnings };
}
/**
* 解析 xlsx 缓冲区(自动定位含「仓库」表头的工作表与表头行)
* @param {Buffer} buffer
*/
function parseWorkbook(buffer) {
const wb = XLSX.read(buffer, { type: 'buffer', cellDates: true });
const warnings = [];
for (const sheetName of wb.SheetNames) {
const ws = wb.Sheets[sheetName];
const grid = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', blankrows: false });
const headerIdx = grid.findIndex((r) => r.some((c) => String(c).trim() === '仓库'));
if (headerIdx < 0) continue;
const header = grid[headerIdx].map((c) => String(c).trim());
const body = grid.slice(headerIdx + 1)
.filter((r) => r.some((c) => String(c).trim() !== ''))
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
const { rows, warnings: w } = normalizeRecords(body);
if (rows.length) return { sheetName, rows, warnings: warnings.concat(w) };
warnings.push(...w);
}
return { sheetName: null, rows: [], warnings: warnings.length ? warnings : ['未找到含「仓库」列的工作表'] };
}
/** 恒等式校验:期初 + Σ流水 = 期末 */
function checkBalance(rows, tolerance = 1) {
return rows
.map((r) => {
const flowSum = FLOW_FIELDS.reduce((acc, f) => acc + (Number(r.flows?.[f]) || 0), 0);
const opening = Number(r.openingQty) || 0;
const closing = Number(r.closingQty) || 0;
const diff = opening + flowSum - closing;
return { warehouse: r.warehouse, opening, flowSum, closing, diff: Number(diff.toFixed(2)) };
})
.filter((r) => Math.abs(r.diff) > tolerance);
}
/**
* 导入某业务日的官方进销存(先清后写)
* @returns {Promise<{bizDate:string, imported:number, warnings:string[], mismatches:Array, unknownWarehouses:string[], missingWarehouses:string[]}>}
*/
async function importOfficial(bizDate, rows, { source = 'jst-export' } = {}) {
await officialDb.deleteOfficial(bizDate);
const imported = await officialDb.upsertOfficial(bizDate, rows, source);
const mismatches = checkBalance(rows);
const unknownWarehouses = rows.filter((r) => r.wmsCoId == null).map((r) => r.warehouse);
const importedNames = new Set(rows.map((r) => r.warehouse));
const missingWarehouses = DAILY_INVENTORY_BASELINE
.filter((b) => !importedNames.has(b.warehouse))
.map((b) => b.warehouse);
return { bizDate, imported, warnings: [], mismatches, unknownWarehouses, missingWarehouses };
}
module.exports = {
FLOW_FIELDS,
LABEL_TO_FIELD,
normalizeRecords,
parseWorkbook,
checkBalance,
importOfficial,
};

View File

@ -0,0 +1,91 @@
'use strict';
/**
* 每日进销存 —— 期初快照缺失时的兜底推导
*
* 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。
* 若快照缺失(服务停机、新纳入仓库等),用「实时库存扣流水回推」推导
* 「上一业务日期末结存」并补写快照:
*
* closing(D-1) = 当前实时库存 − 自 D 01:00 以来的净流入
* 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和,见 dailyInventoryFlows)
*
* 口径校准(2026-09-27,对照聚水潭进销存导出 09-23 期初基准):
* 崔桥工厂仓 基准 92388 / 回推 ≈89650;横林二仓 基准 63780 / 回推 ≈64029;
* 山东青岗集 基准 88377 / 回推 ≈88210;河南洛宁 基准 83305 / 回推 ≈82473。
* 偏差 0.3%~2.7%,来源为业务库缺失的 8 类单据(调拨/盘点/加工/其它出入库)。
*
* 注意:不要用 rpt.pm_stockMonitor 聚合 jsStock 充当全仓结存 ——
* 该表只覆盖纳入监控范围的 SKU 子集(横林二仓实测 1197 vs 全仓 63780),
* 系统性偏低,已在校准后弃用。
*/
const { fetchFlows } = require('./dailyInventoryFlows');
/**
* 推导 openingDate 的期末快照并补写。
* @param {Object} opts
* @param {Object} opts.cloudDb 业务库(mssql)
* @param {Object} opts.snapshotDb 本地快照表访问模块
* @param {Array} opts.baseline 仓库基线(warehouse/dbWarehouse/wmsCoId)
* @param {string} opts.openingDate 期初业务日(YYYY-MM-DD,即要补快照的日期)
* @param {string} opts.latestBizDate 最新业务日(YYYY-MM-DD,流水起点 = 其当日 01:00)
* @param {Date|null} opts.takenAt 实时库存数据时间
* @returns {Promise<{count:number, derivedCount:number}>}
*/
async function deriveAndStoreOpeningSnapshot(opts) {
const { cloudDb, snapshotDb, baseline, openingDate, latestBizDate, takenAt } = opts;
if (!openingDate || !latestBizDate) return { count: 0, derivedCount: 0 };
const whs = baseline.filter((b) => b.wmsCoId != null);
const wmsCoIds = whs.map((b) => b.wmsCoId);
const flowFrom = `${latestBizDate} 01:00:00`;
// 实时库存(与每日快照任务同口径:jst_inventoryInfo 按仓聚合)
let realtimeMap = new Map();
try {
const rows = await cloudDb.query(
`SELECT i.wms_co_id AS wmsCoId,
ISNULL(SUM(CAST(i.qty AS DECIMAL(18, 2))), 0) AS qty,
SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${wmsCoIds.map((_, i) => `@w${i}`).join(',')})
GROUP BY i.wms_co_id`,
Object.fromEntries(wmsCoIds.map((id, i) => [`w${i}`, id])),
{ requestTimeout: 120000 }
);
realtimeMap = new Map(rows.map((r) => [String(r.wmsCoId), r]));
} catch (error) {
console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message);
}
// 自期初时刻以来的带符号流水(流出为负)
let flowMap = new Map();
try {
flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom);
} catch (error) {
console.error('[daily-inventory-opening] 流水取数失败:', error.message);
}
let derivedCount = 0;
const payload = whs.map((b) => {
const rt = realtimeMap.get(String(b.wmsCoId));
const f = flowMap.get(String(b.wmsCoId)) || {};
const netInflow = (f.purchaseIn || 0) + (f.salesReturn || 0) + (f.salesOut || 0) + (f.purchaseReturn || 0);
const closingQty = Number(rt?.qty ?? 0) - netInflow;
derivedCount += 1;
return {
wmsCoId: b.wmsCoId,
warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse,
closingQty: Number(closingQty.toFixed(2)),
skuCount: Number(rt?.skuCount ?? 0),
takenAt: takenAt ? new Date(takenAt) : null,
};
});
const count = await snapshotDb.upsertSnapshot(openingDate, payload);
console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(实时库存扣流水回推)`);
return { count, derivedCount };
}
module.exports = { deriveAndStoreOpeningSnapshot };

View File

@ -0,0 +1,307 @@
'use strict';
// 每日进销存 · 页面截图与上传
//
// 要点:
// 1. 使用服务端签发的**短时效** JWT 作为专用身份访问页面(15 分钟过期),
// 令牌只写入浏览器 localStorage,不会出现在截图地址、日志或接口响应里。
// 2. 截图前必须等到数据真正加载完成:表格有数据行、没有 Spin 加载中、没有错误提示。
// 3. 表格带内部滚动,直接截屏只能看到一屏;截图前注入 CSS 展开滚动容器,
// 再整页截图,保证数据是完整的。
// 4. 截图落在系统临时目录,上传成功后立即删除。
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const database = require('../config/database');
const { uploadFileToDefaultS3 } = require('./objectStorage');
const TIMEZONE = 'Asia/Shanghai';
const PAGE_PATH = '/planning/operation/daily-inventory';
// 生产主站(见《服务器部署规范》):可用 DAILY_INVENTORY_SCREENSHOT_BASE_URL / PUBLIC_SITE_URL 覆盖
const DEFAULT_BASE_URL = 'https://ck.yydd.ai';
// 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟
const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000;
/**
* 截图专用服务账号
*
* 页面启动时会调用 /api/auth/verify,该接口要求令牌的 sub 必须是系统库 users 表中
* 真实且 active 的用户;仅签名合法但用户不存在的令牌会被判定为未登录并跳转登录页,
* 截图也就拿不到任何数据。因此这里不使用凭空捏造的身份,而是在系统库维护一个
* 仅供服务端使用的专用账号,令牌以该账号的 id 作为 sub。
*
* 该账号只以服务端签发的短时效令牌方式使用:密码哈希是随机值且永不对外,
* 任何人无法通过密码登录;删除该行即可彻底回收。
*/
const SERVICE_USERNAME = String(
process.env.DAILY_INVENTORY_SCREENSHOT_USERNAME || 'daily_inventory_screenshot_bot'
).trim().slice(0, 50) || 'daily_inventory_screenshot_bot';
const SERVICE_NAME = '每日进销存截图服务';
const SERVICE_EMAIL = `${SERVICE_USERNAME}@service.local`;
let cachedServiceUserId = null;
const EXPAND_CSS = `
.ant-table-body, .ant-table-content, .ant-table-container { max-height: none !important; overflow: visible !important; }
.daily-inventory-page { overflow: visible !important; }
.ant-table-cell-fix-left, .ant-table-cell-fix-right { position: static !important; }
`;
function normalizeText(value, maxLength = 500) {
return String(value ?? '').trim().slice(0, maxLength);
}
/** 北京时间日期/时间片段:YYYY-MM-DD、YYYYMMDD、HHmmss */
function chinaTimeParts(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
const dateText = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(target);
const timeText = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(target);
return {
bizDate: dateText,
dateKey: dateText.replace(/\D/g, ''),
timeKey: timeText.replace(/\D/g, ''),
};
}
function resolveBaseUrl() {
const raw = normalizeText(
process.env.DAILY_INVENTORY_SCREENSHOT_BASE_URL
|| process.env.PUBLIC_SITE_URL
|| DEFAULT_BASE_URL,
500
).replace(/\/+$/, '');
return raw || DEFAULT_BASE_URL;
}
function resolvePageUrl() {
return `${resolveBaseUrl()}${PAGE_PATH}`;
}
/** 清空服务账号 id 缓存(账号被重建或测试时刷新用) */
function resetScreenshotServiceUserCache() {
cachedServiceUserId = null;
}
/** 生成一个无法用于登录的密码哈希(随机串无人知晓,仅满足字段非空约束) */
function buildUnusablePasswordHash() {
return bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
}
/**
* 确保系统库存在截图专用服务账号,并返回其 id。
* 只维护 auth_source='service' 的本服务账号,不触碰任何人工账号。
*/
async function ensureScreenshotServiceUser() {
if (cachedServiceUserId) return cachedServiceUserId;
const rows = await database.local.query(
'SELECT id, role, status FROM users WHERE username = ? LIMIT 1',
[SERVICE_USERNAME]
);
let user = rows[0];
if (!user) {
const created = await database.local.insert('users', {
username: SERVICE_USERNAME,
password_hash: await buildUnusablePasswordHash(),
name: SERVICE_NAME,
email: SERVICE_EMAIL,
role: 'admin',
status: 'active',
auth_source: 'service',
});
user = { id: created.insertId, role: 'admin', status: 'active' };
} else if (user.status !== 'active' || user.role !== 'admin') {
await database.local.query(
"UPDATE users SET role = 'admin', status = 'active', auth_source = 'service' WHERE id = ? AND auth_source = 'service'",
[user.id]
);
}
cachedServiceUserId = Number(user.id);
return cachedServiceUserId;
}
/**
* 签发截图专用短时效令牌。
* sub 为系统库中的专用服务账号 id,令牌只写入浏览器 localStorage,
* 不会出现在截图地址、日志或接口响应里,也不含任何密钥。
*/
async function signScreenshotToken() {
const secret = process.env.CUBEJS_SECRET;
if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图身份令牌');
const userId = await ensureScreenshotServiceUser();
return jwt.sign(
{
sub: String(userId),
username: SERVICE_USERNAME,
name: SERVICE_NAME,
role: 'admin',
authSource: 'service',
allowedPageKeys: [],
},
secret,
{ expiresIn: '15m' }
);
}
/**
* 截图文件名与对象键。
* 目录与文件名里的日期都用**业务日期**,时间用执行时刻,便于按数据日期归档:
* daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
*/
function buildScreenshotNames({ bizDate, timeKey }) {
const safeDate = normalizeText(bizDate, 10);
const dateKey = safeDate.replace(/\D/g, '');
const fileName = `daily-inventory-${dateKey}-${timeKey}.png`;
return {
fileName,
objectKey: `daily-inventory/${safeDate}/${fileName}`,
};
}
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
function buildTempFilePath({ bizDate, timeKey }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
const { fileName, objectKey } = buildScreenshotNames({ bizDate, timeKey });
return { dir, filePath: path.join(dir, fileName), objectKey };
}
async function launchBrowser() {
let playwright;
try {
playwright = require('playwright');
} catch (_error) {
throw new Error('未安装 playwright,无法执行页面截图');
}
try {
return await playwright.chromium.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
} catch (error) {
const detail = normalizeText(error?.message, 300);
throw new Error(`浏览器启动失败(服务器需先执行 npx playwright install chromium):${detail}`);
}
}
/** 等到数据真正渲染完成:有数据行、无加载中、无错误提示 */
async function waitForPageReady(page) {
await page.waitForSelector('.daily-inventory-table .ant-table-tbody tr', {
timeout: PAGE_READY_TIMEOUT_MS,
state: 'attached',
});
await page.waitForFunction(
() => {
const spinning = document.querySelector('.daily-inventory-page .ant-spin-spinning');
const skeleton = document.querySelector('.ant-skeleton');
const rows = document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr');
return !spinning && !skeleton && rows.length > 0;
},
{ timeout: PAGE_READY_TIMEOUT_MS, polling: 500 }
);
const errorText = await page.evaluate(() => {
const node = document.querySelector('.daily-inventory-page .ant-alert-error');
return node ? node.textContent || '' : '';
});
if (errorText) {
throw new Error(`页面返回错误提示,放弃截图:${normalizeText(errorText, 200)}`);
}
const rowCount = await page.evaluate(
() => document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr').length
);
if (!rowCount) throw new Error('页面未渲染出任何数据行,放弃截图');
return rowCount;
}
/**
* 执行一次截图并上传到默认对象存储桶
* @returns {Promise<{url:string, key:string, bizDate:string, size:number, rowCount:number, takenAt:string}>}
*/
async function captureAndUploadDailyInventory(options = {}) {
const now = options.now ? new Date(options.now) : new Date();
const { timeKey } = chinaTimeParts(now);
// 业务日期由调用方(定时任务)给出;未指定时按北京时间当天兜底
const bizDate = normalizeText(options.bizDate, 10) || chinaTimeParts(now).bizDate;
const token = await signScreenshotToken();
const pageUrl = resolvePageUrl();
const browser = await launchBrowser();
const { dir, filePath, objectKey } = buildTempFilePath({ bizDate, timeKey });
let rowCount = 0;
try {
const context = await browser.newContext({
viewport: { width: 1680, height: 1050 },
deviceScaleFactor: 1,
timezoneId: TIMEZONE,
locale: 'zh-CN',
});
// 令牌只注入浏览器 localStorage,URL 与日志里都不出现
await context.addInitScript((value) => {
try {
window.localStorage.setItem('token', value);
} catch (_error) {
// 忽略:首个 document 可能还不允许访问 localStorage
}
}, token);
const page = await context.newPage();
page.on('pageerror', () => {});
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_READY_TIMEOUT_MS });
rowCount = await waitForPageReady(page);
// 展开内部滚动容器,保证整页截图包含完整数据区
await page.addStyleTag({ content: EXPAND_CSS });
await page.waitForTimeout(500);
await page.screenshot({ path: filePath, fullPage: true });
const stats = await fs.promises.stat(filePath);
if (!stats.size) throw new Error('截图文件为空');
const uploaded = await uploadFileToDefaultS3(filePath, {
key: objectKey,
mimeType: 'image/png',
folder: 'daily-inventory',
extension: 'png',
});
return {
url: uploaded.url,
key: uploaded.key,
bizDate,
size: stats.size,
rowCount,
takenAt: now.toISOString(),
};
} finally {
await browser.close().catch(() => {});
fs.rm(dir, { recursive: true, force: true }, () => {});
}
}
module.exports = {
DEFAULT_BASE_URL,
PAGE_PATH,
PAGE_READY_TIMEOUT_MS,
SERVICE_NAME,
SERVICE_USERNAME,
TIMEZONE,
buildScreenshotNames,
captureAndUploadDailyInventory,
chinaTimeParts,
ensureScreenshotServiceUser,
resetScreenshotServiceUserCache,
resolveBaseUrl,
resolvePageUrl,
signScreenshotToken,
};

View File

@ -1,254 +0,0 @@
const database = require('../config/database');
const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases';
const MAX_RELEASE_HISTORY = 50;
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']);
const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']);
function normalizeVersion(value) {
return String(value || '').trim().replace(/^v/i, '');
}
function versionParts(value) {
const normalized = normalizeVersion(value);
if (!VERSION_PATTERN.test(normalized)) return null;
return normalized.split('.').map((part) => Number(part));
}
function compareVersions(left, right) {
const leftParts = versionParts(left);
const rightParts = versionParts(right);
if (!leftParts || !rightParts) return 0;
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}
function validateVersion(value) {
const version = normalizeVersion(value);
if (!VERSION_PATTERN.test(version)) {
throw new Error('版本号格式必须为 x.y.z,例如 0.0.9');
}
return version;
}
function normalizePlatform(value, fileName = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin';
if (['win32', 'win', 'windows'].includes(normalized)) return 'win32';
if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin';
return 'win32';
}
function normalizeArchitecture(value, fileName = '', platform = '') {
const normalized = String(value || '').trim().toLowerCase();
if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized;
const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/);
if (fileMatch) return fileMatch[1];
return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64';
}
function normalizeStoredRelease(value) {
if (!value || typeof value !== 'object') return null;
const version = normalizeVersion(value.version);
if (!VERSION_PATTERN.test(version) || !value.objectKey) return null;
const platform = normalizePlatform(value.platform, value.fileName || value.objectKey);
const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform);
const defaultFileName = platform === 'darwin'
? `pdd-auto-sale-${version}-${architecture}.dmg`
: `pdd-auto-sale-${version}-setup.exe`;
return {
id: Number(value.id) || Date.now(),
version,
releaseNotes: String(value.releaseNotes || ''),
objectKey: String(value.objectKey),
platform,
architecture,
fileName: String(value.fileName || defaultFileName),
fileSize: Number(value.fileSize || 0),
sha256: String(value.sha256 || ''),
publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId),
publishedByName: String(value.publishedByName || ''),
publishedAt: value.publishedAt || value.createdAt || null,
createdAt: value.createdAt || value.publishedAt || null,
updatedAt: value.updatedAt || value.publishedAt || null,
};
}
function parseReleaseStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const releases = Array.isArray(parsed?.releases)
? parsed.releases.map(normalizeStoredRelease).filter(Boolean)
: [];
const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object'
? parsed.latestVersions
: {};
const latestVersions = {};
for (const platform of SUPPORTED_PLATFORMS) {
const version = normalizeVersion(storedLatestVersions[platform]);
if (VERSION_PATTERN.test(version)) latestVersions[platform] = version;
}
const legacyLatestVersion = normalizeVersion(parsed?.latestVersion);
if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) {
latestVersions.win32 = legacyLatestVersion;
}
for (const release of releases) {
if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version;
}
return {
latestVersion: latestVersions.win32 || legacyLatestVersion,
latestVersions,
releases,
};
} catch {
return { latestVersion: '', latestVersions: {}, releases: [] };
}
}
async function loadReleaseStorage() {
const rows = await database.local.query(
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
return parseReleaseStorage(rows[0]?.config_value);
}
async function saveReleaseStorage(storage) {
const value = JSON.stringify(storage);
const existing = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
if (existing.length) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', DESKTOP_RELEASE_CONFIG_KEY]
);
return;
}
await database.local.insert('system_config', {
config_key: DESKTOP_RELEASE_CONFIG_KEY,
config_value: value,
config_type: 'json',
});
}
async function ensureDesktopReleaseSchema() {
// Release metadata uses system_config because the runtime account cannot create tables.
}
function mapRelease(release, latestVersion = '') {
const normalized = normalizeStoredRelease(release);
if (!normalized) return null;
return {
...normalized,
isLatest: normalized.version === normalizeVersion(latestVersion),
};
}
async function getLatestDesktopRelease(platform = 'win32', architecture = '') {
const storage = await loadReleaseStorage();
const targetPlatform = normalizePlatform(platform);
const targetArchitecture = architecture
? normalizeArchitecture(architecture, '', targetPlatform)
: '';
const platformReleases = storage.releases
.filter((release) => release.platform === targetPlatform)
.sort((left, right) => (
compareVersions(right.version, left.version)
|| String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''))
));
const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || '';
const versionReleases = platformReleases.filter((release) => release.version === latestVersion);
const latest = (targetArchitecture
? versionReleases.find((release) => release.architecture === targetArchitecture)
|| versionReleases.find((release) => release.architecture === 'universal')
: null)
|| versionReleases[0]
|| platformReleases[0]
|| null;
return latest ? mapRelease(latest, latestVersion || latest.version) : null;
}
async function getDesktopReleaseById(id) {
const storage = await loadReleaseStorage();
const release = storage.releases.find((item) => String(item.id) === String(id));
return release ? mapRelease(release, storage.latestVersions[release.platform]) : null;
}
async function listDesktopReleases(limit = 50) {
const storage = await loadReleaseStorage();
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
return storage.releases
.map((release) => mapRelease(release, storage.latestVersions[release.platform]))
.filter(Boolean)
.sort((left, right) => {
if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1;
return String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''));
})
.slice(0, safeLimit);
}
async function publishDesktopRelease(input) {
const version = validateVersion(input.version);
const storage = await loadReleaseStorage();
const platform = normalizePlatform(input.platform, input.fileName || input.objectKey);
const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform);
const existing = storage.releases.find((release) => (
release.version === version
&& release.platform === platform
&& release.architecture === architecture
));
const now = new Date().toISOString();
const release = {
id: existing?.id || Date.now(),
version,
platform,
architecture,
releaseNotes: input.releaseNotes || '',
objectKey: input.objectKey,
fileName: input.fileName,
fileSize: Number(input.fileSize || 0),
sha256: input.sha256,
publishedByUserId: input.publishedByUserId || null,
publishedByName: input.publishedByName || '',
publishedAt: now,
createdAt: existing?.createdAt || now,
updatedAt: now,
};
const releases = [
release,
...storage.releases.filter((item) => !(
item.version === version
&& item.platform === platform
&& item.architecture === architecture
)),
].slice(0, MAX_RELEASE_HISTORY);
const latestVersions = { ...storage.latestVersions, [platform]: version };
await saveReleaseStorage({
latestVersion: latestVersions.win32 || version,
latestVersions,
releases,
});
return mapRelease(release, version);
}
module.exports = {
compareVersions,
ensureDesktopReleaseSchema,
getDesktopReleaseById,
getLatestDesktopRelease,
listDesktopReleases,
mapRelease,
normalizeVersion,
normalizeArchitecture,
normalizePlatform,
parseReleaseStorage,
publishDesktopRelease,
validateVersion,
};

View File

@ -66,7 +66,7 @@ function buildDesktopCapabilities(user) {
const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []);
const isAdmin = user?.role === 'admin';
const canPlan = isAdmin || allowed.has('planning_upload');
const canUseMarket = isAdmin || allowed.has('market_insight') || canPlan;
const canUseMarket = isAdmin || canPlan;
return {
controlCenter: true,
@ -80,7 +80,6 @@ function buildDesktopCapabilities(user) {
marketingEvents: isAdmin || allowed.has('marketing_events'),
promotion: isAdmin || allowed.has('promotion_module'),
productTest: isAdmin || allowed.has('new_product_test'),
reviewAnalysis: isAdmin || allowed.has('review_analysis'),
};
}

View File

@ -0,0 +1,210 @@
'use strict';
/**
* 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增)
* ────────────────────────────────────────────────────────────────────────────
* 为什么不用企业内部应用发文件?
* 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard,
* 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。
* 业务最终选择「群里直接看文本」,于是回到最简单的一条路:
* 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。
*
* 请求形状(钉钉官方「自定义机器人接入」):
* POST https://oapi.dingtalk.com/robot/send?access_token=xxx&timestamp=xxx&sign=xxx
* { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } }
* → { "errcode": 0, "errmsg": "ok" }
*
* 三个必须知道的限制(踩过就知道疼):
* ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400,
* 所以这里按**字节**截断(不是按字符),并返回是否被截断。
* ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」),
* 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。
* ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。
* 顺序错了就是 310000 sign not match,很难看出是编码问题。
*
* 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。
*/
const crypto = require('crypto');
const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com';
const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000);
// 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴
const MAX_CONTENT_BYTES = 20000;
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
/** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */
function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) {
const source = String(text ?? '');
if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false };
const chars = Array.from(source);
let bytes = 0;
let out = '';
for (const char of chars) {
const size = Buffer.byteLength(char, 'utf8');
if (bytes + size > maxBytes) break;
bytes += size;
out += char;
}
return { text: out, truncated: true };
}
function buildDingTalkError(label, payload, httpStatus) {
const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null;
const message = payload?.errmsg || payload?.message || `${label}失败`;
const error = new Error(`${label}失败(${code}):${message}`);
error.code = code;
error.httpStatus = httpStatus || null;
error.dingtalk = payload || null;
return error;
}
/**
* 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。
* 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。
*/
function normalizeWebhookUrl(value) {
const text = normalizeText(value, 1024);
if (!text) {
throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' });
}
let url;
try {
url = new URL(text);
} catch (_error) {
throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' });
}
if (!/^https?:$/.test(url.protocol)) {
throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' });
}
// 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样
// `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404)
if (!/^\/robot\/send\/?$/.test(url.pathname)) {
throw Object.assign(
new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
if (!url.searchParams.get('access_token')) {
throw Object.assign(
new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
return url.toString();
}
/**
* 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`)))
* ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000)
*/
function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) {
const timestamp = String(Number(timestampMs) || Date.now());
const timestamped = new URL(webhookUrl);
timestamped.searchParams.set('timestamp', timestamp);
const text = normalizeText(secret, 256);
if (!text) return timestamped.toString();
const sign = crypto
.createHmac('sha256', text)
.update(`${timestamp}\n${text}`, 'utf8')
.digest('base64');
timestamped.searchParams.set('sign', sign);
return timestamped.toString();
}
async function requestJson(label, url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const raw = await response.text();
let payload = null;
try {
payload = raw ? JSON.parse(raw) : {};
} catch (_error) {
payload = { errmsg: raw.slice(0, 500) };
}
if (!response.ok) throw buildDingTalkError(label, payload, response.status);
if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) {
throw buildDingTalkError(label, payload, response.status);
}
return payload || {};
} catch (error) {
if (error.name === 'AbortError') {
throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' });
}
throw error;
} finally {
clearTimeout(timer);
}
}
/**
* 发一条文本消息到群。
* @param {object} params
* @param {string} params.webhookUrl 群机器人 Webhook(含 access_token)
* @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空)
* @param {string} params.content 纯文本(支持 \n 换行)
* @param {boolean} [params.atAll] 是否 @所有人
* @param {string[]} [params.atMobiles]
* @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>}
*/
async function sendTextToWebhook({
webhookUrl, secret, content, atAll = false, atMobiles = [],
}) {
const base = normalizeWebhookUrl(webhookUrl);
const raw = String(content ?? '');
if (!raw.trim()) {
throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' });
}
const limited = truncateBytes(raw);
const url = buildSignedUrl(base, secret);
const mobiles = (Array.isArray(atMobiles) ? atMobiles : [])
.map((item) => normalizeText(item, 32))
.filter((item) => /^\d{6,20}$/.test(item));
const payload = await requestJson('发送钉钉群消息', url, {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=utf-8' },
body: JSON.stringify({
msgtype: 'text',
text: { content: limited.text },
at: { isAtAll: atAll === true, atMobiles: mobiles },
}),
});
return {
delivered: true,
truncated: limited.truncated,
bytes: Buffer.byteLength(limited.text, 'utf8'),
content: limited.text,
errmsg: normalizeText(payload.errmsg, 200) || 'ok',
};
}
/**
* 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。
* 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。
*/
async function sendProbeText({ webhookUrl, secret, text }) {
const content = normalizeText(text, 500)
|| '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。';
return sendTextToWebhook({ webhookUrl, secret, content });
}
module.exports = {
MAX_CONTENT_BYTES,
OAPI_BASE,
buildSignedUrl,
normalizeWebhookUrl,
sendProbeText,
sendTextToWebhook,
truncateBytes,
};

View File

@ -0,0 +1,100 @@
'use strict';
/**
* 通用表格导出
* --------------------------------------------------------------------------
* 把 [{ name, header: string[], rows: any[][] }] 渲染成:
* - xlsx:多工作表工作簿(基于 backend 已依赖的 xlsx 包)
* - csv :单工作表、带 UTF-8 BOM(Excel 直接打开中文不乱码)
* 语义与 frontend/src/utils/tableExport.js 的「表头 + 行」保持一致。
*/
const xlsx = require('xlsx');
const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
// Excel 工作表名限制:<=31 字符,且不能包含 : \ / ? * [ ]
const sanitizeSheetName = (name, fallback) => {
const cleaned = String(name || '').replace(/[:\\/?*[\]]/g, ' ').replace(/\s+/g, ' ').trim();
return (cleaned || fallback).slice(0, 31);
};
const toCellValue = (value) => (
value === null || value === undefined || value === '' ? '' : value
);
const sheetToAoa = (sheet) => ([
(sheet?.header || []).map(toCellValue),
...((sheet?.rows || []).map((row) => (row || []).map(toCellValue))),
]);
/** 多工作表 xlsx buffer;工作表名重复/非法时自动去重与截断 */
const buildXlsxBuffer = (sheets = []) => {
const workbook = xlsx.utils.book_new();
const usedNames = new Set();
sheets.forEach((sheet, index) => {
const base = sanitizeSheetName(sheet?.name, `工作表${index + 1}`);
let name = base;
let suffix = 2;
while (usedNames.has(name)) {
name = `${base.slice(0, 27)}(${suffix})`;
suffix += 1;
}
usedNames.add(name);
const worksheet = xlsx.utils.aoa_to_sheet(sheetToAoa(sheet));
worksheet['!cols'] = (sheet?.header || []).map((title) => ({
wch: Math.max(10, Math.min(32, String(title || '').length * 2 + 6)),
}));
xlsx.utils.book_append_sheet(workbook, worksheet, name);
});
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
};
const quoteCsvCell = (value) => {
const text = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
/** 单工作表 csv buffer(只取第一个工作表),前置 BOM */
const buildCsvBuffer = (sheets = []) => {
const sheet = sheets[0] || { header: [], rows: [] };
const lines = [(sheet.header || []).map(quoteCsvCell).join(',')];
(sheet.rows || []).forEach((row) => {
lines.push((row || []).map(quoteCsvCell).join(','));
});
return Buffer.from(`\ufeff${lines.join('\r\n')}`, 'utf8');
};
const sanitizeFileName = (fileName) => String(fileName || '导出数据').replace(/[\\/:*?"<>|]/g, '_').trim() || '导出数据';
/**
* 直接把导出结果写回响应。
* @param {import('express').Response} res
* @param {{ fileName?: string, format?: 'xlsx'|'csv', sheets?: Array }} payload
*/
const sendWorkbookDownload = (res, { fileName, format = 'xlsx', sheets = [] } = {}) => {
const isCsv = String(format).toLowerCase() === 'csv';
const exportSheets = isCsv ? sheets.slice(0, 1) : sheets;
const buffer = isCsv ? buildCsvBuffer(exportSheets) : buildXlsxBuffer(exportSheets);
const totalRows = exportSheets.reduce((sum, sheet) => sum + ((sheet?.rows || []).length), 0);
const fullName = `${sanitizeFileName(fileName)}${isCsv ? '.csv' : '.xlsx'}`;
res.setHeader('Content-Type', isCsv ? CSV_CONTENT_TYPE : XLSX_CONTENT_TYPE);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fullName)}`);
res.setHeader('X-Export-Count', String(totalRows));
res.send(buffer);
return { totalRows, fullName };
};
module.exports = {
XLSX_CONTENT_TYPE,
CSV_CONTENT_TYPE,
sanitizeSheetName,
buildXlsxBuffer,
buildCsvBuffer,
sendWorkbookDownload,
};

View File

@ -3,7 +3,6 @@ const { cleanupRetiredFeatureSettings } = require('./retiredFeatures');
const { ensureDepartmentPagePermissions, ensureSidebarRolePermissions } = require('./pageAccess');
const { repairDuplicateExternalDepartments } = require('./socialAuth');
const { ensureSetupConfigDefaults } = require('./setupWizard');
const { ensureDesktopReleaseSchema } = require('./desktopReleaseService');
const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
@ -13,6 +12,13 @@ const {
ensureProductMonitoringSchema,
warmProductMonitoringDataset,
} = require('./productMonitoringService');
const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
const {
ensureReturnStockWarningSchema,
} = require('./returnStockWarningSchema');
const { ensureBinReplenishSchema } = require('./binReplenishSchema');
const { warmBinReplenishCache } = require('./binReplenishService');
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
const USER_COLUMNS = [
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
@ -119,6 +125,18 @@ async function ensureTable(tableName, createSql) {
}
}
async function tableExists(tableName) {
const rows = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[tableName]
);
return rows.length > 0;
}
async function indexExists(tableName, indexName) {
const rows = await database.local.query(
`SELECT 1
@ -279,7 +297,27 @@ async function ensureLocalSchema() {
UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
);
await ensureDesktopReleaseSchema();
await ensureTable(
'product_analysis_user_prefs',
`CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'`
);
if (await tableExists('pricing_calculator_user_prefs')) {
await database.local.query(
`INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at)
SELECT user_id, prefs_json, created_at, updated_at
FROM pricing_calculator_user_prefs
WHERE JSON_VALID(prefs_json)
AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL
ON DUPLICATE KEY UPDATE
prefs_json = VALUES(prefs_json),
updated_at = VALUES(updated_at)`
);
}
await ensureDesktopPresenceSchema();
await ensureDesktopExecutionTaskSchema();
await ensureThirdPartySsoSchema();
@ -921,6 +959,13 @@ async function ensureLocalSchema() {
);
await ensureProductMonitoringSchema();
await ensureReturnMonitorSchema();
await ensureReturnStockWarningSchema();
await ensureBinReplenishSchema();
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
void warmReturnStockWarningCache();
// 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动
void warmBinReplenishCache();
await ensureSidebarRolePermissions();
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,227 @@
'use strict';
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
//
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
// { enabled, recipientAlias, scope }
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
//
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
const database = require('../config/database');
const {
getYydNotificationCenterRuntimeConfig,
toAdminConfig: toNotificationCenterAdminConfig,
} = require('./yydNotificationCenterSettings');
const CONFIG_KEY = 'mkh_replenish_alert_config';
const STATE_KEY = 'mkh_replenish_alert_state';
const CONFIG_CACHE_TTL_MS = 30 * 1000;
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
const SCOPE_OPTIONS = Object.freeze([
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
]);
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try {
const parsed = JSON.parse(String(value || ''));
return parsed && typeof parsed === 'object' ? parsed : fallback;
} catch (_error) {
return fallback;
}
}
function normalizeScope(value) {
const text = normalizeText(value, 40).toLowerCase();
if (!text) return SCOPES.WARNING;
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
return SCOPES.WARNING;
}
function normalizeFlag(value, fallback = true) {
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 readSystemConfigValue(key) {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
return rows?.[0]?.configValue;
}
async function upsertSystemConfigValue(key, value, configType = 'json') {
const payload = typeof value === 'string' ? value : JSON.stringify(value);
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
if (rows?.[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[payload, configType, key]
);
} else {
await database.local.insert('system_config', {
config_key: key,
config_value: payload,
config_type: configType,
});
}
return payload;
}
// ── 配置 ────────────────────────────────────────────────────────
async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
return { ...cachedConfig };
}
let stored = {};
try {
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
} catch (error) {
console.warn('读取补货预警推送配置失败,使用默认值:', error.message);
}
cachedConfig = {
enabled: normalizeFlag(stored.enabled, true),
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
scope: normalizeScope(stored.scope),
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
};
cachedAt = Date.now();
return { ...cachedConfig };
}
/**
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
*/
async function resolveMkhReplenishAlertRecipient(options = {}) {
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
} catch (_error) {
center = {};
}
const alias = normalizeText(center?.recipientAlias, 50);
return { alias, source: alias ? 'notification_center' : 'none' };
}
async function getMkhReplenishAlertAdminConfig() {
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const recipient = await resolveMkhReplenishAlertRecipient({ config });
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
center = {};
}
const centerAdmin = toNotificationCenterAdminConfig(center);
const state = await loadMkhReplenishAlertState();
return {
enabled: config.enabled,
scope: config.scope,
recipientAlias: config.recipientAlias,
effectiveRecipientAlias: recipient.alias || '',
recipientSource: recipient.source,
notificationCenterConfigured: centerAdmin.configured,
notificationCenterBaseUrl: centerAdmin.baseUrl,
notificationCenter: {
configured: centerAdmin.configured,
baseUrl: centerAdmin.baseUrl,
projectCode: centerAdmin.projectCode,
projectSecretConfigured: centerAdmin.projectSecretConfigured,
projectSecretPreview: centerAdmin.projectSecretPreview,
recipientAlias: centerAdmin.recipientAlias,
},
notifiedCount: Object.keys(state.notified || {}).length,
lastRunAt: state.lastRunAt || null,
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
};
}
async function saveMkhReplenishAlertConfig(input = {}) {
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const rawAlias = input.recipientAlias ?? input.recipient_alias;
const next = {
enabled: normalizeFlag(input.enabled, current.enabled),
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
scope: normalizeScope(input.scope || current.scope),
};
await upsertSystemConfigValue(CONFIG_KEY, next);
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
cachedAt = Date.now();
return getMkhReplenishAlertAdminConfig();
}
// ── 推送状态 ────────────────────────────────────────────────────
async function loadMkhReplenishAlertState() {
let parsed = {};
try {
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
} catch (error) {
console.warn('读取补货预警推送状态失败,按空状态处理:', error.message);
}
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
? parsed.notified
: {};
return {
version: 1,
notified,
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
};
}
async function saveMkhReplenishAlertState(state = {}) {
const value = {
version: 1,
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
};
await upsertSystemConfigValue(STATE_KEY, value);
return value;
}
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
async function resetMkhReplenishAlertState() {
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
}
module.exports = {
CONFIG_KEY,
STATE_KEY,
SCOPES,
SCOPE_OPTIONS,
getMkhReplenishAlertAdminConfig,
getMkhReplenishAlertRuntimeConfig,
loadMkhReplenishAlertState,
normalizeFlag,
normalizeScope,
resetMkhReplenishAlertState,
resolveMkhReplenishAlertRecipient,
saveMkhReplenishAlertConfig,
saveMkhReplenishAlertState,
};

View File

@ -0,0 +1,635 @@
'use strict';
// 模块化库存监控 —— 配件库存监控
//
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
// 目标仓库共 794 个 SKU
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐;
// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
// nearSecondSale 近一日发货量 → 当日出库
//
// ⚠️ 关联键坑(2026-09-12 修正):
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
//
// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废):
// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」
// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后
// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1)
// - 详见 buildLocationSql() 注释
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
const WAREHOUSE_WMS_CO_ID = 15718196;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEFAULT_SAFETY_DAYS = 15;
// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0)
// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql)
const LOW_STOCK_DAYS_THRESHOLD = 7;
const WARNING_LEVEL = Object.freeze({
OUT_OF_STOCK: '缺货',
WARNING: '预警',
NORMAL: '正常',
});
// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正):
// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中,
// 被某个 MKH 模块化成品引用:
// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU
// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。
// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。
// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。
// ⚠️ 历史口径均已废弃:
// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关;
// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装,
// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。
// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。
const MKH_STATUS = Object.freeze({
CONFIGURED: '已配置MKH',
UNCONFIGURED: '未配置MKH',
});
const SORTABLE_FIELDS = Object.freeze({
skuId: 's.sku_id',
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'stockDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
location: 'location',
warehouse: 's.other_3',
});
// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。
// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。
// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。
const buildRealtimeStockSql = () => (
`CASE
WHEN inv.qty > 0 THEN inv.qty
WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty
WHEN inv.qty IS NOT NULL THEN inv.qty
ELSE ISNULL(m.jsStock, 0)
END`
);
// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。
// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。
const buildStockDaysSql = () => (
`CASE
WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN
CASE
WHEN ISNULL(m.fifteenAvgSale, 0) > 0
THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2))
ELSE NULL
END
ELSE m.jsUseDays
END`
);
/**
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。
*/
const buildConfiguredExistsSql = () => (
`EXISTS (
SELECT 1 FROM rpt.jst_skuBomItem x
WHERE x.map_outer_sku_id = s.sku_id
AND x.sku_id LIKE N'MKH%'
)`
);
/**
* 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。
* 0 = 缺货 / 1 = 预警 / 2 = 正常
* 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。
* 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。
* 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。
*/
const buildWarningOrderSql = () => (
`CASE
WHEN cfg.configured = 0 THEN 2
WHEN ${buildRealtimeStockSql()} <= 0 THEN 0
WHEN ${buildStockDaysSql()} IS NOT NULL
AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END`
);
/**
* 统一的「仓位」表达式(2026-09-18 改源)。
* 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」);
* 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后,
* 目标仓 794 个 SKU **命中 727 个**(09-19 实测;该表小时级增量刷新,命中数会变)。
* ⚠️ 只取 bin,**不 join 仓名**,所以不受该表 wh_id / wms_co_id 关联列问题影响(见
* mkhBomService 文件头 ⑤;那个坑只影响 MKH-BOM 页配件仓位列的仓名文本)。
* · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位)
* · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果)
* 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。
* ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。
*/
const buildLocationSql = () => (
`COALESCE(
ploc.bin,
NULLIF(LTRIM(RTRIM(s.bin)), N''),
NULLIF(LTRIM(RTRIM(s.other_bin)), N'')
)`
);
const buildBaseSql = () => `
FROM dbo.jst_sku s
OUTER APPLY (
SELECT TOP 1 i.qty
FROM dbo.jst_inventoryInfo i
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
ORDER BY m2.date DESC
) m
OUTER APPLY (
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
AND p.delivery_date >= @monitorDate
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
) pin
-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计
-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、
-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。
-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明,
-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。
OUTER APPLY (
SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
) ptr
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
FROM dbo.fys_skuCostPriceUpdate c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU
-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。
LEFT JOIN (
SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty
FROM (
SELECT
pk.sku_id,
pk.wms_co_id,
NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin,
SUM(ISNULL(pk.qty, 0)) AS binQty,
SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty,
ROW_NUMBER() OVER (
PARTITION BY pk.sku_id, pk.wms_co_id
ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC
) AS rn
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.wms_co_id = @wmsCoId
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'')
HAVING SUM(ISNULL(pk.qty, 0)) > 0
) ranked
WHERE ranked.rn = 1
) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
) cfg
WHERE s.other_3 = @warehouseName`;
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const WARNING_ORDER = {
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
[WARNING_LEVEL.WARNING]: 1,
[WARNING_LEVEL.NORMAL]: 2,
};
/**
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
*/
async function resolveMonitorDate() {
const rows = await cloudDatabase.query(
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
FROM dbo.pm_stockMonitor
WHERE warehouse = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 120000 }
);
const raw = rows?.[0]?.monitorDate;
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
}
const buildFilters = (query = {}, monitorDate = null) => {
const conditions = [];
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
}
const category = toText(query.category);
if (category) {
params.category = category;
conditions.push('s.category = @category');
}
const location = toText(query.location);
if (location) {
params.location = location;
conditions.push(`${buildLocationSql()} = @location`);
}
const mkhStatus = toText(query.mkhStatus);
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
params.mkhStatus = mkhStatus;
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
}
const warningLevel = toText(query.warningLevel);
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
params.warningOrder = WARNING_ORDER[warningLevel];
// 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH)
conditions.push(`${buildWarningOrderSql()} = @warningOrder`);
}
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
if (['1', 'true', 'yes'].includes(onlyWarning)) {
// 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除)
conditions.push(`${buildWarningOrderSql()} <= 1`);
}
return { conditions, params };
};
/**
* 用「产品资料系统数据库」补齐商品图片。
*
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
* external.product_catalog_wide_base_v1
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
* 实测目标仓 661 条可匹配,其中 481 条带图。
* 查询失败时静默降级,不影响列表主流程。
*/
async function attachProductImages(rows = []) {
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
if (codes.length === 0) {
return rows;
}
const imageMap = new Map();
try {
const imageRows = await productDb.query(
`SELECT barcode, image_url, product_thumbnail_url
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
imageRows.forEach((row) => {
const code = toText(row.barcode);
if (!code || imageMap.has(code)) return;
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
if (url) imageMap.set(code, url);
});
} catch (error) {
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
}
return rows.map((row) => ({
...row,
productImageUrl: imageMap.get(toText(row.skuId)) || null,
}));
}
/**
* 「已配置MKH」判定说明(2026-09-17 晚修正):
* 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头),
* 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。
*/
const mapRow = (row = {}) => {
const realtimeStock = Number(row.realtimeStock || 0);
const stockDays = toNumber(row.stockDays);
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
// 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则),
// 前端显示与排序口径保持一致,避免前后端各算一份。
const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]);
const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK]
? WARNING_LEVEL.OUT_OF_STOCK
: warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING]
? WARNING_LEVEL.WARNING
: WARNING_LEVEL.NORMAL;
return {
skuId: toText(row.skuId),
productName: toText(row.productName),
category: toText(row.category),
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
warningLevel,
warningOrder,
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
inQtyToday: toNumber(row.inQtyToday) ?? 0,
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
purchaseTransit: toNumber(row.purchaseTransit) ?? 0,
location: toText(row.location),
avgShip15d: toNumber(row.avgShip15d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
};
};
const buildProjection = () => `
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
${buildRealtimeStockSql()} AS realtimeStock,
ISNULL(ptr.transit, 0) AS purchaseTransit,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
${buildLocationSql()} AS location,
m.fifteenAvgSale AS avgShip15d,
${buildStockDaysSql()} AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
s.other_3 AS warehouse,
COALESCE(
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
NULLIF(LTRIM(RTRIM(s.pic)), N''),
cost.pictureSkucode
) AS imageUrl,
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
${buildWarningOrderSql()} AS warningOrder,
m.date AS monitorDate`;
async function getModularStockList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const monitorDate = await resolveMonitorDate();
const { conditions, params } = buildFilters(query, monitorDate);
const baseSql = buildBaseSql();
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
const offset = (page - 1) * pageSize;
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
? orderSql
: `${orderSql}, s.sku_id ASC`;
const rows = await cloudDatabase.query(
`SELECT ${buildProjection()}
${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
);
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const [summaryRow] = await buildSummary(whereSql, params);
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
const enrichedRows = await attachProductImages(rows);
return {
rows: enrichedRows.map(mapRow),
total: Number(totalRow?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
monitorDate: summaryRow?.monitorDate || monitorDate,
summary: {
skuCount: summaryRow?.skuCount || 0,
outOfStockCount: summaryRow?.outOfStockCount || 0,
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
configuredCount: summaryRow?.configuredCount || 0,
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
totalStock: summaryRow?.totalStock || 0,
totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0,
},
};
}
async function buildSummary(whereSql, params) {
const rows = await cloudDatabase.query(
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock,
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
MAX(m.date) AS monitorDate
${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const row = rows[0] || {};
return [{
skuCount: Number(row.skuCount || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
totalStock: Number(row.totalStock || 0),
totalPurchaseTransit: Number(row.totalPurchaseTransit || 0),
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
configuredCount: Number(row.configuredCount || 0),
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
}];
}
async function getModularStockFilters() {
// 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可,
// 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。
const rows = await cloudDatabase.query(
`SELECT DISTINCT s.category
FROM dbo.jst_sku s
WHERE s.other_3 = @warehouseName
AND s.category IS NOT NULL
AND LTRIM(RTRIM(s.category)) <> N''
ORDER BY s.category`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 60000 }
);
return {
categories: rows.map((row) => toText(row.category)).filter(Boolean),
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
warehouse: WAREHOUSE_NAME,
};
}
/**
* 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。
* 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。
* 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。
*/
async function getPurchaseInTransitList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const keyword = toText(query.keyword);
const params = { warehouseName: WAREHOUSE_NAME };
let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`;
if (keyword) {
params.keyword = `%${keyword}%`;
whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`;
}
const sortField = String(query.sortField || '').trim();
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderMap = {
skuId: 'p.sku_id',
deliveryDate: 'p.delivery_date',
transitQty: 'transitQty',
purchaseQty: 'p.qty',
};
const orderCol = orderMap[sortField] || 'transitQty';
const orderClause = sortField === 'skuId'
? `${orderCol} ${sortOrder}`
: `${orderCol} ${sortOrder}, p.sku_id ASC`;
const offset = (page - 1) * pageSize;
const [rows, totalRow] = await Promise.all([
cloudDatabase.query(
`SELECT
p.sku_id AS skuId,
p.name AS productName,
CAST(p.qty AS BIGINT) AS purchaseQty,
CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty,
CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty,
p.delivery_date AS deliveryDate,
CAST(p.po_id AS NVARCHAR(50)) AS poId,
CAST(p.poi_id AS NVARCHAR(50)) AS poiId
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
),
cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}`,
params,
{ requestTimeout: 180000 }
),
]);
return {
rows: rows.map((row) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
purchaseQty: Number(row.purchaseQty || 0),
inQty: Number(row.inQty || 0),
transitQty: Number(row.transitQty || 0),
deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null,
poId: toText(row.poId),
poiId: toText(row.poiId),
})),
total: Number(totalRow?.[0]?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
};
}
// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回,
// 前端据此刷新列表到最新快照。
// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。
async function syncModularStock() {
const monitorDate = await resolveMonitorDate();
return {
syncedAt: new Date().toISOString(),
monitorDate,
};
}
module.exports = {
WAREHOUSE_NAME,
WAREHOUSE_WMS_CO_ID,
WARNING_LEVEL,
DEFAULT_SAFETY_DAYS,
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
};

View File

@ -0,0 +1,243 @@
'use strict';
/**
* 拣选库位动销分析 —— 业务库取数
*
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
* 范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
* 销量(4 个来源分支,均按 send_date 窗口):
* 1) rpt.jst_Orders × jst_OrderItem 普通行(sku_type<>combine),排除 Delete/Cancelled
* 2) rpt.jst_Orders 组合装行(sku_id 为 ZH-... 拼接串)→ JOIN jst_comboProductItem 展开为子件×数量
* 3) rpt.jst_qmcust_orderlist(全民客户 B2B 分销)普通行,排除 Delete/Cancelled/Split/Question
* (Split 是拆单父单,与拆出的子单重复,必须排除)
* 4) rpt.jst_qmcust_orderlist 组合装行 → 同样按映射表展开
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
*
* 【日销与动销等级口径】按「商品日销」判定,不做仓位分摊:
* 商品日销 = 该商品在窗口内的整档销量 ÷ 窗口天数
* 同一件商品无论占用几个仓位,各行显示的销量 / 日销 / 动销等级完全一致 ——
* 不会因为货放得散就被算成滞销。
*
* 附带输出「分摊销量」= 商品销量 ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景:
* 恒等式(已实测校验,差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = require('../config/pickingBinSalesAnalysis');
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
const key = `${prefix}${i}`;
params[key] = v;
return `@${key}`;
})
.join(',');
return { clause, params };
}
/**
* 日销分档表达式:从高档往低档依次命中,最后一档「销售=0」由 salesQty <= 0 兜底。
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
*/
function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days') {
const branches = PICKING_BIN_GRADE_RULES
.filter((rule) => rule.lower > 0)
.map((rule) => {
const daily = `(${salesExpr} / ${daysParam})`;
const lower = `${daily} >= ${rule.lower}`;
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
const cond = upper ? `${lower} AND ${upper}` : lower;
return `WHEN ${cond} THEN '${rule.key}'`;
});
return `
CASE
${branches.join('\n ')}
WHEN ${salesExpr} > 0 THEN 'slow2'
ELSE 'zero'
END`;
}
/**
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
*
* - stockQty:该商品在此仓位的实际库存
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
* - dailySales:salesQty / 窗口天数 = 商品日销
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number}} options
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
* dailySales:number, gradeKey:string}>>}
*/
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
}
const { shipFrom, shipTo, days } = options;
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const binIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空。
// 仅过滤 bin_group 会把同样为空的 Pack 箱记录误标为拣选位。
const sqlText = `
WITH bin AS (
SELECT p.wms_co_id AS wmsCoId,
LTRIM(RTRIM(p.bin)) AS binName,
N'${BIN_TYPE_PICKING}' AS binType,
p.sku_id AS skuId,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
FROM rpt.jst_packInventoryInfo p
WHERE p.wms_co_id IN (${binIn.clause})
AND ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
AND p.pack_type = N'Bin'
AND ISNULL(p.bin_group, '') = ''
GROUP BY p.wms_co_id,
LTRIM(RTRIM(p.bin)),
p.sku_id
),
binSpan AS (
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
FROM bin
GROUP BY wmsCoId, skuId, binType
),
ship AS (
-- 1) 线上店铺订单:普通商品行(sku_type<>combine,组合装行在下一分支展开)
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
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 o.wms_co_id, oi.sku_id
UNION ALL
-- 2) 线上店铺订单:组合装行(sku_id 为 ZH-子件A-N-子件B-M 拼接串)→ 按映射表展开为子件
SELECT o.wms_co_id AS wmsCoId,
ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
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 IN (${shipIn.clause})
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 o.wms_co_id, ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
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 IN (${shipIn.clause})
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 o.wms_co_id, oi.sku_id
UNION ALL
-- 4) 全民客户订单:组合装行展开为子件
SELECT o.wms_co_id AS wmsCoId,
ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
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 IN (${shipIn.clause})
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 o.wms_co_id, ci.src_sku_id
),
shipAgg AS (
-- 同一个 仓库+商品 可能同时命中多个来源分支(线上普通/线上组合装/全民客户普通/全民客户组合装),
-- 必须先按 仓库+商品 汇总成一行再 join,否则一个仓位行会被拆成多行、每行只带某分支的销量。
SELECT wmsCoId,
skuId,
SUM(CAST(salesQty AS DECIMAL(18, 2))) AS salesQty
FROM ship
GROUP BY wmsCoId, skuId
),
binAgg AS (
SELECT wmsCoId,
binName,
binType,
COUNT(DISTINCT skuId) AS skuCount
FROM bin
GROUP BY wmsCoId, binName, binType
)
SELECT b.wmsCoId,
b.binName,
b.binType,
b.skuId,
CAST(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS DECIMAL(18, 2)) AS stockQty,
CAST(a.skuCount AS INT) AS skuCount,
CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
CAST(ISNULL(s.salesQty, 0) / NULLIF(sp.binCount, 0) AS DECIMAL(18, 4)) AS allocatedSalesQty,
CAST(ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
${buildGradeCase('ISNULL(s.salesQty, 0)')} AS gradeKey
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
JOIN binAgg a
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
LEFT JOIN shipAgg s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
ORDER BY b.wmsCoId, b.binType, b.binName, b.skuId`;
// 该查询 union 了 4 个销量来源(含 qmcust 全渠道扫描),全仓窗口可能超过默认 60s;
// mssql 12.x 请求级 timeout 不生效,必须走池级放宽的慢查询入口。
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const rows = await runQuery(
sqlText,
{
...binIn.params,
...shipIn.params,
shipFrom,
shipTo,
days,
},
{ requestTimeout: 120000 }
);
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
binName: r.binName || '',
binType: r.binType || '',
skuId: r.skuId == null ? '' : String(r.skuId).trim(),
stockQty: Number(r.stockQty) || 0,
skuCount: Number(r.skuCount) || 0,
salesQty: Number(r.salesQty) || 0,
allocatedSalesQty: Number(r.allocatedSalesQty) || 0,
dailySales: Number(r.dailySales) || 0,
gradeKey: r.gradeKey || 'zero',
}));
}
module.exports = {
buildGradeCase,
buildInClause,
fetchPickingBinSkuRows,
};

View File

@ -54,8 +54,11 @@ const STORED_PROFILE_LABELS = {
const ENV_MAPPING = {
host: 'REDIS_HOST',
port: 'REDIS_PORT',
username: 'REDIS_USERNAME',
password: 'REDIS_PASSWORD',
db: 'REDIS_DB',
tls: 'REDIS_TLS',
tlsServerName: 'REDIS_TLS_SERVER_NAME',
};
const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234';
@ -86,6 +89,12 @@ function normalizeDb(value) {
return Number.isFinite(db) && db >= 0 ? db : DEFAULT_REDIS_CONFIG.db;
}
function normalizeBoolean(value) {
return [true, 1, '1', 'true', 'yes', 'on'].includes(
typeof value === 'string' ? value.toLowerCase() : value
);
}
function normalizeRuntimeProfile(value) {
const requestedProfile = String(value || '').trim();
if (!requestedProfile) {
@ -189,7 +198,10 @@ function mapConfig(config = {}, { includePassword = false, source = 'database' }
profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey,
host,
port: normalizePort(config.port),
username: String(config.username || '').trim(),
db: normalizeDb(config.db),
tls: normalizeBoolean(config.tls),
tlsServerName: String(config.tlsServerName || config.tls_server_name || '').trim(),
hasPassword: Boolean(password),
enabled,
source,
@ -203,8 +215,11 @@ function getEnvRedisConfig(options = {}) {
profileName: '.env Redis',
host: process.env[ENV_MAPPING.host] || '',
port: process.env[ENV_MAPPING.port],
username: process.env[ENV_MAPPING.username] || '',
db: process.env[ENV_MAPPING.db],
password: process.env[ENV_MAPPING.password] || '',
tls: process.env[ENV_MAPPING.tls],
tlsServerName: process.env[ENV_MAPPING.tlsServerName] || '',
enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()),
}, {
includePassword: Boolean(options.includePassword),
@ -354,12 +369,15 @@ async function probeRedisConnection(config) {
const client = new Redis({
host: config.host,
port: normalizePort(config.port),
username: config.username || undefined,
password: config.password || undefined,
db: normalizeDb(config.db),
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
lazyConnect: true,
connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
enableReadyCheck: false,
retryStrategy: () => null,
});
@ -390,8 +408,11 @@ async function getRuntimeRedisConfig() {
return {
host: candidate.host,
port: candidate.port,
username: candidate.username || undefined,
password: candidate.password || undefined,
db: candidate.db,
tls: candidate.tls,
tlsServerName: candidate.tlsServerName,
source: candidate.source,
};
}
@ -400,8 +421,11 @@ async function getRuntimeRedisConfig() {
return {
host: candidate.host,
port: candidate.port,
username: candidate.username || undefined,
password: candidate.password || undefined,
db: candidate.db,
tls: candidate.tls,
tlsServerName: candidate.tlsServerName,
source: candidate.profileKey || candidate.source,
};
}
@ -411,8 +435,11 @@ async function getRuntimeRedisConfig() {
return {
host: envCandidate.host,
port: envCandidate.port,
username: envCandidate.username || undefined,
password: envCandidate.password || undefined,
db: envCandidate.db,
tls: envCandidate.tls,
tlsServerName: envCandidate.tlsServerName,
source: envCandidate.source,
};
}

View File

@ -0,0 +1,67 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pathToFileURL } = require('url');
const REPOSITORY_ROOT = path.resolve(__dirname, '../../..');
const SETUP_MODULE_PATH = path.join(REPOSITORY_ROOT, 'scripts', 'setup-remote.mjs');
const RESTART_MARKER_PATH = path.join(REPOSITORY_ROOT, '.remote-access', 'restart-requested.json');
const DEFAULT_BOOTSTRAP_ENDPOINT = 'https://ck.yydd.ai:8443/v1/enroll';
function normalizeEndpoint(value) {
const endpoint = new URL(String(value || DEFAULT_BOOTSTRAP_ENDPOINT).trim());
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) {
throw new Error('配置请求地址必须是没有内嵌账号密码的 HTTPS 地址');
}
return endpoint.toString();
}
async function enrollRemoteInstallation(input = {}) {
const endpoint = normalizeEndpoint(input.endpoint);
const apiKey = String(input.apiKey || '').trim();
const label = String(input.label || os.hostname()).trim();
if (!apiKey) throw new Error('请输入部署 Key');
if (!label || label.length > 80) throw new Error('设备名称必须为 1 到 80 个字符');
const setupModule = await import(pathToFileURL(SETUP_MODULE_PATH).href);
const { enrollment, connection } = await setupModule.configureRemoteAccess({
endpoint,
key: apiKey,
label,
replace: false,
});
return {
installationId: enrollment.installation_id,
certificateValidUntil: enrollment.ssh.valid_until,
systemDatabase: {
host: '127.0.0.1',
port: connection.localDatabasePort,
database: enrollment.database.name,
},
redis: {
host: enrollment.redis.host,
port: Number(enrollment.redis.port),
tls: true,
},
adminUsername: enrollment.application_admin.username,
adminPassword: enrollment.application_admin.password,
};
}
function scheduleRemoteRestart() {
fs.mkdirSync(path.dirname(RESTART_MARKER_PATH), { recursive: true, mode: 0o700 });
fs.writeFileSync(
RESTART_MARKER_PATH,
`${JSON.stringify({ requestedAt: new Date().toISOString() })}\n`,
{ mode: 0o600 }
);
fs.chmodSync(RESTART_MARKER_PATH, 0o600);
}
module.exports = {
DEFAULT_BOOTSTRAP_ENDPOINT,
enrollRemoteInstallation,
normalizeEndpoint,
scheduleRemoteRestart,
};

View File

@ -1,14 +1,31 @@
const RETIRED_PAGE_KEYS = ['planning_upload', 'market_insight', 'agent', 'ai_skill_tags'];
const RETIRED_ENTRY_KEYS = new Set(['market-insight', 'agent', 'ai-skill-tags', 'platform-switch']);
const RETIRED_PAGE_KEYS = [
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
'store_checkup', 'review_analysis', 'customer_service_checkup',
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
];
const RETIRED_ENTRY_KEYS = new Set([
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
'desktop-client-releases',
]);
const RETIRED_CONFIG_KEYS = [
'openclaw_chat_config', 'openclaw_chat_skill_tags',
'selection_similar_products_config', 'alibaba_1688_image_search_config',
'cs_quality_ai_config', 'desktop_client_releases',
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
];
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
function isRetiredSystemConfigKey(key) {
return RETIRED_CONFIG_KEYS.includes(String(key || '').trim());
}
function isRetiredDatabaseConfigName(name) {
return RETIRED_DATABASE_CONFIG_NAMES.includes(String(name || '').trim());
}
function pruneRetiredSidebarConfig(value) {
try {
const config = typeof value === 'string' ? JSON.parse(value) : value;
@ -28,6 +45,9 @@ async function cleanupRetiredFeatureSettings(db) {
await db.query(`DELETE FROM ${table} WHERE page_key IN (${RETIRED_PAGE_KEYS.map(() => '?').join(',')})`, RETIRED_PAGE_KEYS);
}
await db.query(`DELETE FROM system_config WHERE config_key IN (${RETIRED_CONFIG_KEYS.map(() => '?').join(',')})`, RETIRED_CONFIG_KEYS);
await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES);
await db.query('DROP TABLE IF EXISTS pricing_calculator_history');
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
if (rows.length) {
const current = rows[0].config_value;
@ -36,4 +56,12 @@ async function cleanupRetiredFeatureSettings(db) {
}
}
module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings };
module.exports = {
RETIRED_PAGE_KEYS,
RETIRED_CONFIG_KEYS,
RETIRED_DATABASE_CONFIG_NAMES,
isRetiredSystemConfigKey,
isRetiredDatabaseConfigName,
pruneRetiredSidebarConfig,
cleanupRetiredFeatureSettings,
};

View File

@ -0,0 +1,412 @@
'use strict';
const cloudDb = require('../config/cloudDatabase');
/**
* 退货库存预警服务
* --------------------------------------------------------------------------
* 数据来源:业务数据库(MSSQL 报表副本 / JSReportReplica, schema rpt)。
* 固定过滤仓库:「销售退仓 横林二仓」对应业务库仓库名「简尚-横林二仓销退仓」。
*
* 日期口径(重要):
* 列表按「退货入库日期」筛选,取 jst_returnAndRefundItem.receive_date(明细级收货日期),
* 而不是 jst_returnAndRefundInfo.as_date(售后申请日期)。原因:
* - as_date 是售后单申请日,很多退货是几天后才实际收货入库的;
* - 只按 as_date 过滤,会漏掉"前几天申请、今天才收货入库"的明细。
* 实测(2026-09-12):按 as_date 过滤只有 366 条明细;按 receive_date 过滤有 745 条明细,
* 更接近聚水潭「销售退仓」当天口径。
*
* 数据来源口径(重要,2026-09-15 修正):
* 副本里有两条互不重叠的退货入库同步管线,必须取并集才对得上聚水潭「销售退仓」:
* ① ERP 售后单 jst_returnAndRefundInfo × jst_returnAndRefundItem
* ② 奇门退款单 jst_qmcust_refundlist × jst_qmcust_refundlist_item
* 去重键 = (退货单号 as_id, 商品编码 sku_id),同键优先取 ERP 行。
*
* 字段映射(业务库 -> 逻辑字段,与前端页面表头一一对应):
* 退货入库时间 returnInboundTime <- jst_returnAndRefundItem.receive_date(回退 info.as_date)
* 快递单号 expressNo <- jst_returnAndRefundInfo.l_id
* 供应商 supplier <- jst_sku.supplier_name
* 预警 warning <- ope_clear_task.alert(按 sku 取最新月)
* 商品编码 productCode <- jst_returnAndRefundItem.sku_id
* 入库件数 inboundQty <- jst_returnAndRefundItem.r_qty(实收件数;
* 当 r_qty 为 0 时回退到 qty)
* 在库天数 stockDays <- DATEDIFF(DAY, 入库日期, 出库日期);出库未发生 → null
* 退货总库存 returnTotalStock <- jst_inventoryInfo.return_qty(横林二仓 wms_co_id=15033302)
* 出库日期 outboundDate <- 业务库暂无 采购退货 表 → null
* 出库件数 outboundQty <- 业务库暂无对应来源 → null
*
* 说明:dbo.* 引用会被 cloudDatabase.rewriteReportReplicaSql 改写为 rpt.*。
* 仓库过滤条件以字面量 N'...' 写入 SQL,规避中文参数化时的 Unicode 问题。
* 若业务库不可用或查询失败,接口兜底返回演示数据并附带 notice。
*/
// 前端展示名(入参)与业务库真实仓库名映射
const WAREHOUSE_DISPLAY = '销售退仓 横林二仓';
const WAREHOUSE_DB = '简尚-横林二仓销退仓';
// 横林二仓在 jst_inventoryInfo 中的仓编码(wh_id=2)
const HENLIN2_WMS_CO_ID = 15033302;
// 单页最大条数(一天 1000+ 条,留足余量)
const MAX_PAGE_SIZE = 3000;
// 已知的副本缺口(前端 / 业务侧可见)
const KNOWN_GAPS = [
'退货总库存:期望"箱及仓位库存-横林二仓-客退临时上架仓"(仓位级),但 rpt 副本仅有 jst_inventoryInfo(仓+SKU 级),无仓位维度。当前按 wms_co_id=15033302 取 return_qty 作为仓级近似值。',
'出库日期/出库件数:期望"聚水潭采购退货-横林二仓",但 rpt 副本中没有采购退货表(jst_purchase/jst_purchaseItem 是采购入库单据)。当前为 null。',
'条数口径(2026-09-15 已修正):本表取「ERP 售后单 + 奇门退款单」两条同步管线的并集,去重键为(退货单号 + 商品编码)。实测 09-14 并集 1179 条 vs 聚水潭「销售退仓」1194 条(吻合度 98.7%),较旧的单表口径(797 条 / 66.8%)大幅修正。剩余 1~2% 差额来自副本未覆盖的 WMS 退仓单据(退仓单号 / 上架状态 / 箱号维度在 rpt 副本中不存在)。',
];
function resolveWarehouseDbName(incoming) {
const v = String(incoming || '').trim();
if (!v || v === WAREHOUSE_DISPLAY) return WAREHOUSE_DB;
return v; // 允许直接传入真实仓库名
}
// 取"今天"(东八区)的 YYYY-MM-DD
function todayInShanghai() {
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
const shMs = utcMs + 8 * 3600000;
return new Date(shMs).toISOString().slice(0, 10);
}
function normalizeDateInput(value) {
const v = String(value || '').trim();
if (!v) return '';
// 接受 YYYY-MM-DD 或 YYYY/MM/DD
const m = v.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!m) return '';
const mm = String(m[2]).padStart(2, '0');
const dd = String(m[3]).padStart(2, '0');
return `${m[1]}-${mm}-${dd}`;
}
// 演示数据(仅当业务库查询失败时兜底,便于核对页面布局与字段)
const DEMO_ROWS = [
{ returnInboundTime: '2026-09-12 15:38:03', expressNo: '76024510836500010001', supplier: '越子涵', warning: '正常', productCode: 'SZ001541N961V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
{ returnInboundTime: '2026-09-12 15:37:49', expressNo: '730006113799', supplier: '艾米莉', warning: '正常', productCode: 'SJ004831N1172V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
{ returnInboundTime: '2026-09-11 10:20:00', expressNo: 'SF2025514008294', supplier: '常州苗苗', warning: '黄色预警', productCode: 'SJZ000316N1253V01', inboundQty: 12, stockDays: null, returnTotalStock: 86, outboundDate: null, outboundQty: null },
{ returnInboundTime: '2026-09-10 09:12:00', expressNo: 'SF1234567890123', supplier: '横林家居厂', warning: '红色预警', productCode: 'HL20240001', inboundQty: 120, stockDays: null, returnTotalStock: 320, outboundDate: null, outboundQty: null },
];
// ---------------------------------------------------------------------------
// 数据来源(2026-09-15 修正):两条同步管线取并集
// ---------------------------------------------------------------------------
// 副本里「退货入库」其实有两条互相独立的同步管线,且数据几乎不重叠:
// ① ERP 售后单:dbo.jst_returnAndRefundInfo × dbo.jst_returnAndRefundItem(状态以 WaitConfirm 为主)
// ② 奇门退款单:dbo.jst_qmcust_refundlist × dbo.jst_qmcust_refundlist_item(状态以 Confirmed 为主)
// 实测 2026-09-14:① 797 条、② 497 条,仅 115 条重叠(as_id + SKU 完全一致),并集 1179 条;
// 聚水潭「销售退仓」界面当天 1194 条 → 吻合度 98.7%(只用 ① 仅 66.8%)。
// 因此统一取并集,去重键 = (退货单号, 商品编码),同键优先取 ERP 售后单(字段更全)。
// ---------------------------------------------------------------------------
function buildSourceCte(date) {
const mainDateClause = date ? `AND CAST(it.receive_date AS DATE) = @date` : '';
const qmDateClause = date ? `AND CAST(qit.receive_date AS DATE) = @date` : '';
return `
WITH src AS (
SELECT
CONVERT(NVARCHAR(50), i.as_id) AS as_id,
LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) AS product_code,
ISNULL(NULLIF(it.r_qty, 0), it.qty) AS inbound_qty,
COALESCE(it.receive_date, i.as_date) AS inbound_time,
i.l_id AS express_no,
1 AS src_rank,
N'ERP售后单' AS src_label
FROM dbo.jst_returnAndRefundInfo i
JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id
WHERE i.warehouse = N'${WAREHOUSE_DB}'
AND it.sku_id IS NOT NULL
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) <> N''
${mainDateClause}
UNION ALL
SELECT
CONVERT(NVARCHAR(50), q.as_id),
LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))),
ISNULL(NULLIF(qit.r_qty, 0), qit.qty),
COALESCE(qit.receive_date, q.as_date),
q.l_id,
2,
N'奇门退款单'
FROM dbo.jst_qmcust_refundlist q
JOIN dbo.jst_qmcust_refundlist_item qit
ON CONVERT(NVARCHAR(50), q.as_id) = CONVERT(NVARCHAR(50), qit.as_id)
WHERE q.warehouse = N'${WAREHOUSE_DB}'
AND qit.sku_id IS NOT NULL
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))) <> N''
${qmDateClause}
),
dedup AS (
SELECT as_id, product_code, inbound_qty, inbound_time, express_no, src_label
FROM (
SELECT src.*,
ROW_NUMBER() OVER (
PARTITION BY src.as_id, UPPER(src.product_code)
ORDER BY src.src_rank
) AS rn
FROM src
) ranked
WHERE ranked.rn = 1
)
`;
}
const FROM_JOINS = `
FROM dedup u
LEFT JOIN dbo.jst_sku sk ON sk.sku_id = u.product_code
`;
const SELECT_COLUMNS = `
CONVERT(NVARCHAR(19), u.inbound_time, 120) AS [returnInboundTime],
u.express_no AS [expressNo],
sk.supplier_name AS [supplier],
NULLIF(LTRIM(RTRIM(COALESCE(ct.alert, N''))), N'') AS [warning],
u.product_code AS [productCode],
u.inbound_qty AS [inboundQty],
ISNULL(inv.return_qty, 0) AS [returnTotalStock],
NULL AS [outboundDate],
NULL AS [outboundQty],
u.src_label AS [dataSource]
`;
const OUTER_APPLIES = `
OUTER APPLY (
SELECT TOP 1 alert
FROM dbo.ope_clear_task ct
WHERE ct.clear_skuoid = u.product_code
ORDER BY ct.stat_month DESC
) ct
OUTER APPLY (
SELECT TOP 1 return_qty
FROM dbo.jst_inventoryInfo inv
WHERE inv.sku_id = u.product_code AND inv.wms_co_id = ${HENLIN2_WMS_CO_ID}
) inv
`;
// 关键字过滤:与页面上的「商品编码 / 快递单号」搜索保持一致
const buildKeywordClause = (keyword) => (
keyword ? `AND (u.product_code LIKE @keyword OR u.express_no LIKE @keyword)` : ''
);
function buildListSql({ offset, limit, date, keyword }) {
return `
${buildSourceCte(date)}
SELECT ${SELECT_COLUMNS}
${FROM_JOINS}
${OUTER_APPLIES}
WHERE 1 = 1
${buildKeywordClause(keyword)}
ORDER BY u.inbound_time DESC, u.as_id DESC
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY
`;
}
function buildCountSql({ date, keyword }) {
return `
${buildSourceCte(date)}
SELECT COUNT(*) AS [total]
FROM dedup u
WHERE 1 = 1
${buildKeywordClause(keyword)}
`;
}
// 该仓「最新一条明细收货日」——用于前端在"今天还没同步到"时回退到最新有数据的日期
function buildLatestDateSql() {
return `
${buildSourceCte('')}
SELECT CONVERT(varchar(10), MAX(u.inbound_time), 120) AS [latestDate]
FROM dedup u
`;
}
async function fetchLatestDate() {
try {
const rows = await cloudDb.query(buildLatestDateSql(), {}, { requestTimeout: 180000 });
return rows?.[0]?.latestDate || null;
} catch (error) {
return null; // 单独失败不影响主列表
}
}
function normalizeRow(row = {}) {
return {
returnInboundTime: row.returnInboundTime ?? null,
expressNo: row.expressNo ?? null,
supplier: row.supplier ?? null,
warning: row.warning ?? null,
productCode: row.productCode ?? null,
inboundQty: row.inboundQty === null || row.inboundQty === undefined ? 0 : Number(row.inboundQty),
// 在库天数:当前副本无 采购退货 来源,固定为 null(有出库日期后再启用 DATEDIFF)
stockDays: null,
returnTotalStock: (row.returnTotalStock === null || row.returnTotalStock === undefined) ? null : Number(row.returnTotalStock),
outboundDate: null,
outboundQty: null,
// 并集口径下的数据来源:ERP售后单 / 奇门退款单(便于与聚水潭逐条核对)
dataSource: row.dataSource || null,
};
}
// 统一的查询口径解析:仓库展示名、业务库仓库名、日期(默认今天,'all'/'' = 不限)、关键字
function resolveListQuery(params = {}) {
const displayWarehouse = params.warehouse || WAREHOUSE_DISPLAY;
const warehouse = resolveWarehouseDbName(displayWarehouse);
// 日期口径:默认今天(东八区);传 date='' 或 'all' 表示不限日期
const rawDate = params.date === undefined ? todayInShanghai() : String(params.date || '').trim();
const date = (rawDate === '' || rawDate.toLowerCase() === 'all') ? '' : normalizeDateInput(rawDate);
const keyword = String(params.keyword || '').trim();
return { displayWarehouse, warehouse, date, keyword };
}
function buildSqlParams({ date, keyword }, extra = {}) {
const sqlParams = { ...extra };
if (date) sqlParams.date = date;
if (keyword) sqlParams.keyword = `%${keyword}%`;
return sqlParams;
}
async function getReturnInventoryWarningList(params = {}) {
const { displayWarehouse, date, keyword } = resolveListQuery(params);
const page = Math.max(1, parseInt(params.page, 10) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || 500));
const offset = (page - 1) * pageSize;
const dateBasis = 'receive_date';
try {
const [rows, countRows, latestDate] = await Promise.all([
cloudDb.query(
buildListSql({ offset, limit: pageSize, date, keyword }),
buildSqlParams({ date, keyword }, { offset, limit: pageSize }),
{ requestTimeout: 180000 }
),
cloudDb.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 }),
fetchLatestDate(),
]);
const normalized = (rows || []).map(normalizeRow).filter((row) => row.productCode);
const total = Number(countRows?.[0]?.total ?? normalized.length);
return {
rows: normalized,
total,
page,
pageSize,
warehouse: displayWarehouse,
dateBasis,
date: date || null,
latestDate,
dateIsEmpty: total === 0,
isDemo: false,
gaps: KNOWN_GAPS,
};
} catch (error) {
const message = error?.message || '未知错误';
return {
rows: DEMO_ROWS.map((row) => ({ ...row })),
total: DEMO_ROWS.length,
page,
pageSize,
warehouse: displayWarehouse,
dateBasis,
date: date || null,
latestDate: null,
dateIsEmpty: true,
isDemo: true,
notice: `业务库查询失败,当前展示演示数据。原始错误:${message}`,
gaps: KNOWN_GAPS,
};
}
}
// ---------------------------------------------------------------------------
// 导出
// ---------------------------------------------------------------------------
// 导出时按批拉取,避免一次 OFFSET/FETCH 过大导致副本查询超时
const EXPORT_PAGE_SIZE = 2000;
const EXPORT_MAX_ROWS = 50000;
// 与页面表头一一对应
const EXPORT_COLUMNS = [
{ title: '退货入库时间', key: 'returnInboundTime' },
{ title: '快递单号', key: 'expressNo' },
{ title: '供应商', key: 'supplier' },
{ title: '预警', key: 'warning' },
{ title: '商品编码', key: 'productCode' },
{ title: '入库件数', key: 'inboundQty' },
{ title: '在库天数', key: 'stockDays' },
{ title: '退货总库存', key: 'returnTotalStock' },
{ title: '出库日期', key: 'outboundDate' },
{ title: '出库件数', key: 'outboundQty' },
{ title: '数据来源', key: 'dataSource' },
];
/** 拉取当前筛选条件下的全部明细(分页拉取,最多 EXPORT_MAX_ROWS 行) */
async function getReturnInventoryWarningExportRows(params = {}) {
const { displayWarehouse, date, keyword } = resolveListQuery(params);
const maxRows = Math.max(1, parseInt(params.limit, 10) || EXPORT_MAX_ROWS);
const countPromise = cloudDb
.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 })
.catch(() => null);
const rows = [];
let page = 1;
while (rows.length < maxRows) {
const offset = (page - 1) * EXPORT_PAGE_SIZE;
const limit = Math.min(EXPORT_PAGE_SIZE, maxRows - rows.length);
const batch = await cloudDb.query(
buildListSql({ offset, limit, date, keyword }),
buildSqlParams({ date, keyword }, { offset, limit }),
{ requestTimeout: 300000 }
);
const normalized = (batch || []).map(normalizeRow).filter((row) => row.productCode);
rows.push(...normalized);
if (!batch || batch.length < limit) break;
page += 1;
}
const countRows = await countPromise;
const total = Number(countRows?.[0]?.total ?? rows.length);
return {
rows,
total,
truncated: total > rows.length,
warehouse: displayWarehouse,
date: date || null,
keyword,
};
}
/** 组装导出数据集(供 api 层渲染成 xlsx / csv) */
async function buildReturnInventoryWarningExport(params = {}) {
const { rows, total, warehouse, date, keyword, truncated } = await getReturnInventoryWarningExportRows(params);
return {
fileName: `横林二仓当日退货入库明细_${date || '全部日期'}${keyword ? `_${keyword}` : ''}`,
sheets: [{
name: '退货入库明细',
header: EXPORT_COLUMNS.map((column) => column.title),
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
totalRows: rows.length,
matchedTotal: total,
truncated,
warehouse,
date,
};
}
module.exports = {
getReturnInventoryWarningList,
getReturnInventoryWarningExportRows,
buildReturnInventoryWarningExport,
DEFAULT_WAREHOUSE: WAREHOUSE_DISPLAY,
todayInShanghai,
};

View File

@ -0,0 +1,128 @@
'use strict';
/**
* 横林二仓客退「退货入库核销 + 每日库存监控」本地表结构
* --------------------------------------------------------------------------
* 为什么需要本地表:
* 业务库(JSReportReplica)只有退货单据的"事实",没有"核销状态"。
* 而本需求的「状态(在库/已出库/已报废)」「出库日期」「差异人工确认结果」
* 是每天比对后累积出来的状态,必须落库持久化,否则每天重算会丢历史。
*
* 存放位置:系统库(LOCAL_DB_*,默认 shujuku2_system)
*/
const database = require('../config/database');
const SCHEMA_SQL = [
// 1. 核心:退货入库主数据(一单一行 = 一个退货单的一个商品行)
`CREATE TABLE IF NOT EXISTS return_monitor_inbound (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
return_no VARCHAR(120) NOT NULL COMMENT '退货单号(聚水潭售后单号 as_id)',
product_code VARCHAR(120) NOT NULL COMMENT '商品编码(sku_id)',
sku_name VARCHAR(255) NULL COMMENT '商品名称',
supplier VARCHAR(255) NULL COMMENT '供应商',
qty INT NOT NULL DEFAULT 0 COMMENT '数量(入库件数)',
inbound_date DATE NOT NULL COMMENT '入库日期(取单据/收货日期)',
express_no VARCHAR(120) NULL COMMENT '快递单号',
order_no VARCHAR(120) NULL COMMENT '原订单号',
defective_qty INT NULL COMMENT '来源单据的不良品数量(如有)',
status VARCHAR(16) NOT NULL DEFAULT '在库' COMMENT '状态:在库/已出库/已报废',
outbound_date DATE NULL COMMENT '出库日期',
writeoff_mode VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT '核销方式:none/auto_clear/fifo/manual/mark',
remark VARCHAR(500) NULL COMMENT '备注',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次同步时间',
last_synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近同步时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_rm_inbound_line (warehouse_key, return_no, product_code, inbound_date),
KEY idx_rm_inbound_status (warehouse_key, status, inbound_date),
KEY idx_rm_inbound_code (warehouse_key, product_code),
KEY idx_rm_inbound_date (warehouse_key, inbound_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-退货入库主数据(核销状态)'`,
// 2. 现有库存每日快照(按商品编码)
`CREATE TABLE IF NOT EXISTS return_monitor_stock (
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
stat_date DATE NOT NULL COMMENT '快照日期',
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
PRIMARY KEY (warehouse_key, stat_date, product_code),
KEY idx_rm_stock_code (warehouse_key, product_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-现有库存每日快照'`,
// 3. 差异清单(部分出库,需人工确认)
`CREATE TABLE IF NOT EXISTS return_monitor_discrepancy (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
supplier VARCHAR(255) NULL COMMENT '供应商',
inbound_total INT NOT NULL DEFAULT 0 COMMENT '该编码在库入库总件数',
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
diff_qty INT NOT NULL DEFAULT 0 COMMENT '推定已出库件数 = 在库入库 - 现有库存',
fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)',
status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '状态:待确认/已确认/已忽略',
resolution VARCHAR(20) NULL COMMENT '处理方式:fifo/manual',
first_seen_date DATE NOT NULL COMMENT '首次进入差异清单日期',
last_seen_date DATE NOT NULL COMMENT '最近一次仍有差异的日期',
resolved_by VARCHAR(100) NULL COMMENT '处理人',
resolved_at DATETIME NULL COMMENT '处理时间',
remark VARCHAR(500) NULL COMMENT '备注',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_rm_discrepancy_code (warehouse_key, product_code),
KEY idx_rm_discrepancy_status (warehouse_key, status, last_seen_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-部分出库差异清单'`,
// 4. 同步/比对日志
`CREATE TABLE IF NOT EXISTS return_monitor_job_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
biz_date DATE NOT NULL COMMENT '业务日期',
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '触发方式:manual/cron/api',
inbound_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的入库明细行数',
stock_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的库存行数',
cleared_rows INT NOT NULL DEFAULT 0 COMMENT '整批核销(已出库)行数',
discrepancy_rows INT NOT NULL DEFAULT 0 COMMENT '差异编码数',
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态:success/failed',
error_message VARCHAR(1000) NULL COMMENT '错误信息',
elapsed_ms INT NOT NULL DEFAULT 0 COMMENT '耗时(ms)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_rm_job_log_date (warehouse_key, biz_date, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-同步与比对日志'`,
];
// 兼容早期建表:把 TEXT 的 fifo_plan_json 放宽为 MEDIUMTEXT
const COLUMN_MIGRATIONS = [
[
'return_monitor_discrepancy',
'fifo_plan_json',
"ALTER TABLE return_monitor_discrepancy MODIFY COLUMN fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)'",
'mediumtext',
],
];
async function applyColumnMigrations() {
for (const [table, column, alterSql, expectedType] of COLUMN_MIGRATIONS) {
const rows = await database.local.query(
`SELECT DATA_TYPE AS d FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
[table, column]
);
const current = String(rows?.[0]?.d || '').toLowerCase();
if (current === expectedType) continue;
await database.local.query(alterSql);
}
}
async function ensureReturnMonitorSchema() {
for (const sql of SCHEMA_SQL) {
await database.local.query(sql);
}
await applyColumnMigrations();
}
module.exports = { ensureReturnMonitorSchema };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
'use strict';
/**
* 客退数据监控 —— 仓库注册表
* --------------------------------------------------------------------------
* 每个仓库一张「客退数据监控」看板,页面结构完全一致,差异只有三件事:
* 1. 库存取自哪个仓(jst_inventoryInfo.wms_co_id);
* 2. 「客退临时上架仓」的库存在哪个自定义库存位上;
* 3. 退货单表(售后单 / 奇门退款单)里的销退仓名称(仅「最早入库时间」口径用)。
*
* 关于第 2 点——2026-09-17 用户给出了每个仓在聚水潭里的**准确仓位名**,并明确
* 「看板数据取自该仓位」。聚水潭给每个仓库单独开「自定义库存位」来做客退上架仓,
* **各仓用的位不一样**(2026-09-16 全库核对结论),所以两个字段要分开写:
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
*
* 仓位名 → 库存位 对照(2026-09-27 复测,条件 ISNULL(col,0) > 0):
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 349 编码 / 507 件
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
*
* ── 2026-09-27 重要更新:崔桥工厂仓的客退库存已经同步进副本了 ──
* 09-25 首次切换时该仓客退位只有 2 编码 / 2 件(当时判定「未同步进副本」),
* 09-26 起 IT 侧补上了同步:09-26 当天写入 317 编码 / 460 件,09-27 又补 31 编码 / 46 件,
* 09-27 10:11 实测 349 编码 / 507 件(= 副本 c1 合计,页面与副本完全对得上)。
* ⚠ 所以下面「崔桥必然近乎空白 / 只能靠导入」的旧结论已经作废,不要再拿它当依据。
* 现在这张看板能正常出数,**不需要**用导入功能兜底。
*
* ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论:
* 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 +
* 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存
* 没同步进副本」。全库 218 张表里没有任何「销退仓 / 客退仓 / 上架仓」命名的独立仓
* (jst_warehouseInfo 里 name 含「退」的为 0),销退仓只是退货单据里的 warehouse 文本值。
* 所以崔桥这张看板在当前副本下**必然近乎空白**,要出数只能:① 让 IT 把该仓客退上架仓
* 的库存同步进 jst_inventoryInfo 的自定义库存位;② 用页面「导入」上传聚水潭导出文件。
*
* ⚠ 副本里**没有任何仓位维度**(2026-09-17 复核:218 张表 / 5,496 个字段中,列名含
* bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、
* jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 四个疑似字段 100% 为空)。
* 所以「按仓位名取数」在副本上做不到,只能用该仓位绑定的自定义库存位近似;
* 要逐箱还原(含真实的箱上架时间),必须导入聚水潭导出的文件。
*
* 河南洛宁仓(15539084)当天客退库存为 0:仓库存在、也有退货单,但三个自定义库存位
* 全为 0(只有 qty 总库存 83,273 件)。看板照样建,只是空表——页面会提示
* 「该仓当前没有客退在库数据」,等聚水潭侧同步进来后自动有内容。
*/
// 客退临时上架仓在 jst_inventoryInfo 里的候选列
const RETURN_SLOT_COLUMNS = ['customize_qty_1', 'customize_qty_2', 'customize_qty_3'];
const WAREHOUSE_LIST = [
{
key: 'cuiqiao',
// 页面标题 / 侧边栏名称(2026-09-25 用户指定:原「横林二仓客退数据监控」改为崔桥工厂仓)
title: '崔桥工厂仓客退数据监控',
shortName: '崔桥工厂仓',
// 仓库主表 jst_warehouseInfo.wms_co_id
wmsCoId: 15038426,
warehouseName: '简尚-崔桥工厂仓',
// 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到
returnWarehouseDb: '简尚-崔桥工厂仓销退仓',
// 聚水潭「箱及仓位库存」里对应的仓位名(2026-09-25 用户指定,页面/导出直接显示它)
returnBinName: '崔桥工厂客退临时上架仓',
// 客退临时上架仓占用的自定义库存位
returnSlot: 'customize_qty_1',
// 2026-09-27 复测:客退位已同步进副本 —— 349 编码 / 507 件(c1 合计),
// 退货单口径:ERP 售后单 3,115 条 + 奇门退款单 1,812 条(warehouse='简尚-崔桥工厂仓销退仓')。
hasReturnStock: true,
},
{
key: 'subei',
title: '苏北客退数据监控',
shortName: '苏北',
wmsCoId: 15038347,
warehouseName: '简尚-新苏北仓',
returnWarehouseDb: '简尚-新苏北仓销退仓',
// 2026-09-17 用户指定:苏北客退数据取自「新苏北客退上架仓」
returnBinName: '新苏北客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'suining',
title: '睢宁客退数据监控',
shortName: '睢宁',
wmsCoId: 15032603,
warehouseName: '简尚-睢宁仓',
returnWarehouseDb: '简尚-睢宁仓销退仓',
// 2026-09-17 用户指定:睢宁客退数据取自「睢宁客退上架仓」
returnBinName: '睢宁客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'shandong',
title: '山东客退数据监控',
shortName: '山东',
wmsCoId: 15137523,
warehouseName: '简尚-山东青岗集仓',
returnWarehouseDb: '简尚-山东青岗集仓销退仓',
// 2026-09-17 用户指定:山东客退数据取自「青岗集客退上架仓」
returnBinName: '青岗集客退上架仓',
// ★ 山东用的是第 2 个自定义库存位,与横林/苏北/睢宁不同
returnSlot: 'customize_qty_2',
hasReturnStock: true,
},
{
key: 'henan',
title: '河南客退数据监控',
shortName: '河南',
wmsCoId: 15539084,
warehouseName: '简尚-河南洛宁仓',
returnWarehouseDb: '简尚-河南洛宁仓销退仓',
// 2026-09-17 用户指定:河南客退数据取自「河南洛宁客退上架仓」
returnBinName: '河南洛宁客退上架仓',
// 该仓三个自定义库存位全为 0,河南洛宁客退上架仓在副本里取不到数据
returnSlot: 'customize_qty_1',
// 当前该仓客退库存为 0(仓库与退货单都在,只是当天没有客退在库)
hasReturnStock: false,
},
];
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
const WAREHOUSE_MAP = new Map(WAREHOUSE_LIST.map((item) => [item.key, item]));
/** 归一化仓库标识:认 key(henlin2 / subei / ...),也容忍直接传 wms_co_id */
function resolveWarehouseKey(value) {
const raw = String(value === null || value === undefined ? '' : value).trim();
if (!raw) return DEFAULT_WAREHOUSE_KEY;
const byKey = WAREHOUSE_MAP.get(raw.toLowerCase());
if (byKey) return byKey.key;
const byId = WAREHOUSE_LIST.find((item) => String(item.wmsCoId) === raw);
return byId ? byId.key : DEFAULT_WAREHOUSE_KEY;
}
/** 取仓库配置(永远返回一个有效配置,非法值回落到默认仓) */
function resolveWarehouse(value) {
return WAREHOUSE_MAP.get(resolveWarehouseKey(value));
}
/**
* 页面/导出文案里的「数据来源」,例如
* 「箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓」
* 仓位名逐仓不同(用户 2026-09-17 口径),必须读注册表,不能写死。
*/
function getWarehouseStockLabel(warehouse) {
return `箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}`;
}
module.exports = {
WAREHOUSE_LIST,
RETURN_SLOT_COLUMNS,
DEFAULT_WAREHOUSE_KEY,
resolveWarehouse,
resolveWarehouseKey,
getWarehouseStockLabel,
};

View File

@ -0,0 +1,275 @@
'use strict';
/**
* 聚水潭「箱及仓位库存 - {仓库} - 客退临时上架仓」导出文件导入
* --------------------------------------------------------------------------
* 用途:副本里没有箱/仓位维度,取不到聚水潭的「创建时间」,件数口径也可能不同。
* 导入后按商品编码覆盖「创建时间 / 件数 / 供应商」,页面与导出即与聚水潭一致。
*
* 按仓库隔离:每个看板(横林二仓 / 苏北 / 睢宁 / 山东 / 河南)各自上传自己仓库的导出文件,
* 落在 warehouse_key 下,互不覆盖。调用方通过 warehouseKey 传入。
*
* 文件要求:xlsx / xls / csv,表头需含「商品编码」(或 sku / 商品编号 / 货号)。
* 「创建时间」「数量/件数」「供应商」列名会自动模糊识别,不强制要求。
* 同一商品编码出现多行(多个箱/仓位)时:件数求和,创建时间取最早。
*/
const crypto = require('crypto');
const XLSX = require('xlsx');
const database = require('../config/database');
// 与 returnStockWarehouseRegistry 的默认仓保持一致(不直接 require 以免循环依赖)
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
const MAX_ROWS = 200000;
const CODE_PATTERN = /(商品编码|商品编号|sku|货号|^编码$)/i;
const TIME_PATTERN = /(创建时间|创建日期|上架时间|入库时间|入库日期|首次上架)/i;
const QTY_PATTERN = /(件数|数量|库存数量|可用库存|库存)/i;
const SUPPLIER_PATTERN = /(供应商|供货商)/i;
const EXCLUDE_CODE_PATTERN = /(仓位|库位|货位|箱号|仓库)/i;
function parseDateTimeValue(value) {
if (value === null || value === undefined || value === '') return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return null;
// Excel 序列号:按本地时钟解释,避免出现 +8 小时偏移
const days = Math.floor(value);
const frac = value - days;
const base = Date.UTC(1899, 11, 30);
const utc = new Date(base + days * 86400000 + Math.round(frac * 86400000));
return new Date(
utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate(),
utc.getUTCHours(), utc.getUTCMinutes(), utc.getUTCSeconds()
);
}
const text = String(value).trim();
if (!text) return null;
const direct = new Date(text.replace(/\//g, '-').replace(/^(\d{4})-(\d{1,2})-(\d{1,2})/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`));
if (!Number.isNaN(direct.getTime())) return direct;
const nums = text.match(/\d+/g);
if (!nums || nums.length < 3) return null;
const [y, m, d, hh = '0', mm = '0', ss = '0'] = nums;
const built = new Date(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss));
return Number.isNaN(built.getTime()) ? null : built;
}
function parseNumberValue(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const text = String(value).replace(/[,,\s]/g, '');
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
function findHeaderRow(matrix) {
for (let i = 0; i < Math.min(matrix.length, 20); i += 1) {
const cells = (matrix[i] || []).map((cell) => String(cell === null || cell === undefined ? '' : cell).trim());
const hit = cells.some((cell) => CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell));
if (hit) return i;
}
return -1;
}
function resolveColumns(headerCells) {
const index = {
code: -1, time: -1, qty: -1, supplier: -1,
};
headerCells.forEach((raw, i) => {
const cell = String(raw === null || raw === undefined ? '' : raw).trim();
if (!cell) return;
if (index.code < 0 && CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell)) index.code = i;
if (index.time < 0 && TIME_PATTERN.test(cell)) index.time = i;
if (index.qty < 0 && QTY_PATTERN.test(cell)) index.qty = i;
if (index.supplier < 0 && SUPPLIER_PATTERN.test(cell)) index.supplier = i;
});
return index;
}
/**
* 解析文件为「按商品编码聚合」的记录
*/
function parseImportBuffer(buffer) {
const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error('文件里没有可读取的工作表');
}
const matrix = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, defval: null, blankrows: false });
if (!matrix.length) {
throw new Error('文件是空的');
}
const headerIndex = findHeaderRow(matrix);
if (headerIndex < 0) {
throw new Error('找不到表头,请确认文件里有一列叫「商品编码」(或 sku / 商品编号 / 货号)');
}
const columns = resolveColumns(matrix[headerIndex] || []);
if (columns.code < 0) {
throw new Error('找不到「商品编码」列');
}
const aggregated = new Map();
let skipped = 0;
let withCreatedAt = 0;
let withQty = 0;
for (let i = headerIndex + 1; i < matrix.length && aggregated.size < MAX_ROWS; i += 1) {
const row = matrix[i] || [];
const code = String(row[columns.code] === null || row[columns.code] === undefined
? '' : row[columns.code]).trim();
if (!code) {
skipped += 1;
continue;
}
const time = columns.time >= 0 ? parseDateTimeValue(row[columns.time]) : null;
const qty = columns.qty >= 0 ? parseNumberValue(row[columns.qty]) : null;
const supplier = columns.supplier >= 0
? String(row[columns.supplier] === null || row[columns.supplier] === undefined ? '' : row[columns.supplier]).trim()
: '';
const existing = aggregated.get(code);
if (!existing) {
aggregated.set(code, {
productCode: code,
createdAt: time,
inboundQty: qty === null ? null : qty,
supplier,
});
} else {
if (time && (!existing.createdAt || time < existing.createdAt)) existing.createdAt = time;
if (qty !== null) {
existing.inboundQty = (existing.inboundQty === null ? 0 : existing.inboundQty) + qty;
}
if (!existing.supplier && supplier) existing.supplier = supplier;
}
}
const records = Array.from(aggregated.values());
records.forEach((record) => {
if (record.createdAt) withCreatedAt += 1;
if (record.inboundQty !== null) withQty += 1;
});
return {
records,
sheetName,
headerIndex,
columns,
totalRows: matrix.length - headerIndex - 1,
skipped,
withCreatedAt,
withQty,
};
}
function toMysqlDateTime(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/**
* 写入导入数据(按仓库整批替换:以该仓最新一次导出为准)
* 只删/写当前仓库的行,不会动其它看板的导入数据。
*/
async function importStockWarning({ buffer, fileName, importedBy, warehouseKey }) {
const parsed = parseImportBuffer(buffer);
if (!parsed.records.length) {
throw new Error('没有解析到任何有效的商品编码行');
}
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
const batchId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
await database.local.query(
'DELETE FROM return_stock_warning_import WHERE warehouse_key = ?',
[key]
);
const chunkSize = 500;
for (let i = 0; i < parsed.records.length; i += chunkSize) {
const chunk = parsed.records.slice(i, i + chunkSize);
const values = chunk.map((record) => ([
key,
record.productCode,
toMysqlDateTime(record.createdAt),
record.inboundQty,
record.supplier || null,
batchId,
]));
await database.local.query(
`INSERT INTO return_stock_warning_import
(warehouse_key, product_code, created_at, inbound_qty, supplier, batch_id)
VALUES ?
ON DUPLICATE KEY UPDATE
created_at = VALUES(created_at),
inbound_qty = VALUES(inbound_qty),
supplier = VALUES(supplier),
batch_id = VALUES(batch_id)`,
[values]
);
}
await database.local.query(
`INSERT INTO return_stock_warning_import_batch
(batch_id, warehouse_key, file_name, total_rows, matched_rows, with_created_at, with_qty, skipped_rows, imported_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
batchId,
key,
String(fileName || '').slice(0, 255),
parsed.totalRows,
parsed.records.length,
parsed.withCreatedAt,
parsed.withQty,
parsed.skipped,
importedBy || null,
]
);
return {
batchId,
warehouseKey: key,
fileName: String(fileName || ''),
sheetName: parsed.sheetName,
totalRows: parsed.totalRows,
matchedRows: parsed.records.length,
withCreatedAt: parsed.withCreatedAt,
withQty: parsed.withQty,
skippedRows: parsed.skipped,
detectedColumns: {
code: parsed.columns.code,
time: parsed.columns.time,
qty: parsed.columns.qty,
supplier: parsed.columns.supplier,
},
};
}
/** 清除当前仓库的导入,回到「系统推算」口径(不影响其它看板) */
async function clearStockWarningImport(warehouseKey) {
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
await database.local.query('DELETE FROM return_stock_warning_import WHERE warehouse_key = ?', [key]);
await database.local.query('DELETE FROM return_stock_warning_import_batch WHERE warehouse_key = ?', [key]);
return { cleared: true, warehouseKey: key };
}
module.exports = {
importStockWarning,
clearStockWarningImport,
parseImportBuffer,
};

View File

@ -0,0 +1,130 @@
'use strict';
/**
* 退货库存预警(客退数据监控)本地表
* --------------------------------------------------------------------------
* 为什么需要本地表:
* 报表副本(JSReportReplica)只有仓库级库存表 jst_inventoryInfo,
* 没有聚水潭「箱及仓位库存」的箱/仓位维度,因此:
* - 「创建时间」= 聚水潭该箱/仓位记录的上架时间,副本里根本没有这个字段;
* - 「件数」也可能与聚水潭口径不同(副本只能按 return_qty + 客退自定义库存位近似)。
* 要跟聚水潭页面 100% 对齐,唯一办法是把聚水潭导出的「箱及仓位库存」导入进来,
* 按商品编码覆盖创建时间(可选覆盖件数),并持久化。
*
* 按仓库隔离(2026-09-16 新增):
* 现在有横林二仓 / 苏北 / 睢宁 / 山东 / 河南 五张看板,每张上传的是各自仓库的聚水潭导出文件,
* 同一份文件里同一个商品编码可能同时出现在多个仓;如果不按仓隔离,后导入的会把先导入的覆盖掉。
* 因此两张表都加了 warehouse_key,主键从 (product_code) 改成 (warehouse_key, product_code)。
*/
const database = require('../config/database');
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
const SCHEMA_SQL = [
// 1. 按「仓库 + 商品编码」的导入覆盖值(以聚水潭导出为准)
`CREATE TABLE IF NOT EXISTS return_stock_warning_import (
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识(henlin2/subei/suining/shandong/henan)',
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
created_at DATETIME NULL COMMENT '聚水潭创建时间(上架时间)',
inbound_qty INT NULL COMMENT '聚水潭件数(导入,可为空=不覆盖)',
supplier VARCHAR(255) NULL COMMENT '聚水潭供应商(导入,可为空)',
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (warehouse_key, product_code),
KEY idx_rswi_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入覆盖值(按仓库+商品编码)'`,
// 2. 导入批次日志
`CREATE TABLE IF NOT EXISTS return_stock_warning_import_batch (
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识',
file_name VARCHAR(255) NULL COMMENT '文件名',
total_rows INT NOT NULL DEFAULT 0 COMMENT '解析出的行数',
matched_rows INT NOT NULL DEFAULT 0 COMMENT '成功写入的编码数',
with_created_at INT NOT NULL DEFAULT 0 COMMENT '含创建时间的行数',
with_qty INT NOT NULL DEFAULT 0 COMMENT '含件数的行数',
skipped_rows INT NOT NULL DEFAULT 0 COMMENT '无商品编码被跳过的行数',
imported_by VARCHAR(100) NULL COMMENT '导入人',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (batch_id),
KEY idx_rswibatch_time (imported_at),
KEY idx_rswibatch_wh (warehouse_key, imported_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入批次日志'`,
];
/** 某张表上是否存在某列 */
async function hasColumn(table, column) {
const rows = await database.local.query(
`SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
[table, column]
);
return Number(rows?.[0]?.cnt || 0) > 0;
}
/** 某张表的主键列(按顺序) */
async function getPrimaryKeyColumns(table) {
const rows = await database.local.query(
`SELECT COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION`,
[table]
);
return (rows || []).map((row) => String(row.COLUMN_NAME || '').toUpperCase());
}
/**
* 老库升级:给已存在的两张表补 warehouse_key,并把导入表主键改成 (warehouse_key, product_code)。
* 幂等,每次启动都跑一遍,已经是新结构时不做任何事。
*/
async function migrateReturnStockWarningSchema() {
// 逐列补:两张表都要有 warehouse_key
const targets = ['return_stock_warning_import', 'return_stock_warning_import_batch'];
for (const table of targets) {
// eslint-disable-next-line no-await-in-loop
if (!(await hasColumn(table, 'warehouse_key'))) {
// 老数据全部归到默认仓(横林二仓)——它本来就是为横林二仓建的
// eslint-disable-next-line no-await-in-loop
await database.local.query(
`ALTER TABLE ${table}
ADD COLUMN warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}'
COMMENT '仓库标识' AFTER ${table === 'return_stock_warning_import' ? 'product_code' : 'batch_id'}`
);
}
}
// 主键改造:老结构是 (product_code),需要换成 (warehouse_key, product_code)
const pk = await getPrimaryKeyColumns('return_stock_warning_import');
if (pk.length === 1 && pk[0] === 'PRODUCT_CODE') {
await database.local.query(
'ALTER TABLE return_stock_warning_import DROP PRIMARY KEY, ADD PRIMARY KEY (warehouse_key, product_code)'
);
}
// 批次表的仓库索引
const idxRows = await database.local.query(
`SELECT COUNT(*) AS cnt
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'return_stock_warning_import_batch'
AND INDEX_NAME = 'idx_rswibatch_wh'`
);
if (Number(idxRows?.[0]?.cnt || 0) === 0) {
await database.local.query(
'ALTER TABLE return_stock_warning_import_batch ADD KEY idx_rswibatch_wh (warehouse_key, imported_at)'
);
}
}
async function ensureReturnStockWarningSchema() {
for (const sql of SCHEMA_SQL) {
// eslint-disable-next-line no-await-in-loop
await database.local.query(sql);
}
await migrateReturnStockWarningSchema();
}
module.exports = { ensureReturnStockWarningSchema, migrateReturnStockWarningSchema };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
'use strict';
/**
* 仓库库存动销分析 —— 业务库取数
*
* 一次查询覆盖全部仓库 × 全部动销等级:
* 库存件数:rpt.jst_inventoryInfo 实时库存(按 wms_co_id + sku_id 汇总)
* 销量: rpt.jst_OrderItem × rpt.jst_Orders,按 send_date,排除 Delete/Cancelled
* (与「每日进销存」的发货口径一致)
* 日销: 销量 / 统计窗口天数 → 分档得到动销等级
*
* 只统计「有库存或有销量」的 SKU:库存为 0 且窗口内无销量的 SKU 已下架,
* 计入「销售=0」会虚增分母,故整体剔除。
*/
const { SALES_GRADE_RULES } = require('../config/stockSalesAnalysis');
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
const key = `${prefix}${i}`;
params[key] = v;
return `@${key}`;
})
.join(',');
return { clause, params };
}
/**
* 日销分档表达式:从高档往低档依次命中。
* 最后一档「销售=0」由 `salesQty <= 0` 兜底(销量为 NULL 视同 0)。
*/
function buildGradeCase(salesExpr = 'ISNULL(b.salesQty, 0)', daysParam = '@days') {
const branches = SALES_GRADE_RULES
.filter((rule) => rule.lower > 0)
.map((rule) => {
const daily = `(${salesExpr} / ${daysParam})`;
const lower = `${daily} >= ${rule.lower}`;
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
const cond = upper ? `${lower} AND ${upper}` : lower;
return `WHEN ${cond} THEN '${rule.key}'`;
});
return `
CASE
${branches.join('\n ')}
WHEN ${salesExpr} > 0 THEN 'slow2'
ELSE 'zero'
END`;
}
/**
* 每个仓库 × 每个动销等级的 SKU 数、库存件数、销量。
* @returns {Promise<Array<{wmsCoId:string, gradeKey:string, skuCount:number, stockQty:number, salesQty:number, dailySales:number}>>}
*/
async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchGradeRows 的第一个参数必须是业务库实例');
}
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const invIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const gradeCase = buildGradeCase();
const sqlText = `
WITH inv AS (
SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${invIn.clause})
GROUP BY i.wms_co_id, i.sku_id
),
ship AS (
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
GROUP BY o.wms_co_id, oi.sku_id
),
base AS (
SELECT u.wmsCoId,
u.skuId,
SUM(u.stockQty) AS stockQty,
SUM(u.salesQty) AS salesQty
FROM (
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
UNION ALL
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
) u
GROUP BY u.wmsCoId, u.skuId
),
graded AS (
SELECT b.wmsCoId,
b.stockQty,
b.salesQty,
${gradeCase} AS gradeKey
FROM base b
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
)
SELECT g.wmsCoId,
g.gradeKey,
COUNT(*) AS skuCount,
CAST(SUM(ISNULL(g.stockQty, 0)) AS DECIMAL(18, 2)) AS stockQty,
CAST(SUM(ISNULL(g.salesQty, 0)) AS DECIMAL(18, 2)) AS salesQty,
CAST(SUM(ISNULL(g.salesQty, 0) / @days) AS DECIMAL(18, 4)) AS dailySales
FROM graded g
GROUP BY g.wmsCoId, g.gradeKey`;
const rows = await cloudDb.query(sqlText, {
...invIn.params,
...shipIn.params,
shipFrom,
shipTo,
days,
}, { requestTimeout: 180000 });
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
gradeKey: String(r.gradeKey),
skuCount: Number(r.skuCount) || 0,
stockQty: Number(r.stockQty) || 0,
salesQty: Number(r.salesQty) || 0,
dailySales: Number(r.dailySales) || 0,
}));
}
/**
* 「销售=0」档的 SKU 级明细(导出用)。
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
*
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全为空、库存表无仓位字段)。
* 每个仓位独立一行,库存数量按该仓位的实际 qty 统计(各仓位行之和 = SKU 总库存):
* - 有仓位数据的 SKU:按 (wms_co_id, sku_id, bin) 展开,qty=0 的仓位行剔除;
* - 无仓位数据的 SKU(仓位表未覆盖的仓库):保留一行,仓位为空、库存取 SKU 总库存,避免丢数。
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName:string, stockQty:number, salesQty:number}>>}
*/
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchZeroSaleSkuRows 的第一个参数必须是业务库实例');
}
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const invIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const gradeCase = buildGradeCase();
const sqlText = `
WITH bin AS (
SELECT p.wms_co_id AS wmsCoId,
p.sku_id AS skuId,
LTRIM(RTRIM(p.bin)) AS b,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binQty
FROM rpt.jst_packInventoryInfo p
WHERE ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
GROUP BY p.wms_co_id, p.sku_id, LTRIM(RTRIM(p.bin))
),
inv AS (
SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${invIn.clause})
GROUP BY i.wms_co_id, i.sku_id
),
ship AS (
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
GROUP BY o.wms_co_id, oi.sku_id
),
base AS (
SELECT u.wmsCoId,
u.skuId,
SUM(u.stockQty) AS stockQty,
SUM(u.salesQty) AS salesQty
FROM (
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
UNION ALL
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
) u
GROUP BY u.wmsCoId, u.skuId
),
graded AS (
SELECT b.wmsCoId,
b.skuId,
b.stockQty,
b.salesQty,
${gradeCase} AS gradeKey
FROM base b
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
)
SELECT g.wmsCoId,
g.skuId,
ISNULL(s.name, '') AS skuName,
ISNULL(bn.b, '') AS binName,
CAST(COALESCE(bn.binQty, g.stockQty) AS DECIMAL(18, 2)) AS stockQty,
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
FROM graded g
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId
LEFT JOIN bin bn ON bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId
WHERE g.gradeKey = 'zero'
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId, bn.b`;
const rows = await cloudDb.query(sqlText, {
...invIn.params,
...shipIn.params,
shipFrom,
shipTo,
days,
}, { requestTimeout: 180000 });
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
skuId: String(r.skuId ?? ''),
skuName: r.skuName == null ? '' : String(r.skuName),
binName: r.binName == null ? '' : String(r.binName),
stockQty: Number(r.stockQty) || 0,
salesQty: Number(r.salesQty) || 0,
}));
}
module.exports = {
buildInClause,
buildGradeCase,
fetchGradeRows,
fetchZeroSaleSkuRows,
};

View File

@ -0,0 +1,140 @@
'use strict';
/**
* 「最晚退厂时间」取数(2026-09-28 新增)
*
* 业务口径:供应商不再合作时,工作平台会给该供应商一个「最晚退厂时间」,
* 即它放在仓里的货最后必须拉走的日期,看板上跟「供应商是否合作」列并排展示。
*
* ── 数据在哪 ──────────────────────────────────────────────────────────────
* 工作平台库。副本实例(218.93.9.21:1433)上共有 9 个库:
* master / model / msdb / tempdb / SSISDB / JSReportReplica(副本,我们只读它)/
* DataPlatformControl / WorkbenchDataShared / WorkbenchIdentity。
* 「工作平台」= WorkbenchDataShared(配套身份库 WorkbenchIdentity,控制库 DataPlatformControl)。
*
* ── 当前为什么是空的(2026-09-28 已逐条取证,别再重复排查)───────────────
* ① 副本账号 cangku 访问 WorkbenchDataShared / WorkbenchIdentity / DataPlatformControl
* **一律被拒**:The server principal "cangku" is not able to access the database
* "WorkbenchDataShared" under the current security context.
* ② 系统设置(database_configs)里只有 cloud_database_public(JSReportReplica)与
* product_information_database(PostgreSQL),没有工作平台库。
* ③ 本机 .env 与生产 /opt/cangku/shared/backend.env 里的 JIAN_SUN_DB_HOST/USER/PASSWORD、
* WORK_ORDER_* 全部为空(只留了端口/库名占位)。
* ④ 副本库内部没有任何「退厂」字段:5,496 个字段全量搜过,COLUMN_NAME 含「退厂」的为 0;
* 最接近的 fys_supplier.NoCoopTime(不合作时间)**不能拿来顶替** —— 它是停更快照
* (DownloadTime 多在 2023~2024),崔桥仓 8 个不合作供应商里只有 1 个有值,
* 而且那 1 条自身还标着 IsCoop='合作',自相矛盾。
*
* ── IT 开通后怎么接(只需改本文件)────────────────────────────────────────
* 二选一,改完即生效,页面与导出都会自动出数:
* A. 给副本账号授只读:让 IT 执行 `USE WorkbenchDataShared; GRANT SELECT TO [cangku];`
* (或建同义词/视图),然后把下面 EXIT_DATE_QUERY 填成跨库查询,例如:
* SELECT SupplierName AS supplierName, CONVERT(varchar(10), LatestExitDate, 120) AS exitDate
* FROM WorkbenchDataShared.dbo.<供应商不合作表>
* B. 让 IT 把「供应商不合作」表同步进 JSReportReplica(像 js_supplier / fys_supplier 那样),
* 再把 EXIT_DATE_QUERY 指向副本里的表名。
* 查询只需要返回两列:supplierName(供应商名,口径同 js_supplier.scmname / jst_sku.supplier_name,
* 即简称)与 exitDate(最晚退厂日期,字符串 'YYYY-MM-DD' 或 datetime 均可)。
*
* 未接入时本模块恒返回 available=false,页面该列显示「待接入」,不会报错、不影响其他列。
*/
const cloudDb = require('../config/cloudDatabase');
const SOURCE_LABEL = '工作平台(WorkbenchDataShared)· 供应商不合作';
const UNAVAILABLE_MESSAGE = '工作平台库尚未授权,待 IT 开通后自动出数';
// ---- 接入点(IT 开通后配这两个环境变量即可,不必改代码)--------------------
// SUPPLIER_EXIT_DATE_ENABLED=true
// SUPPLIER_EXIT_DATE_SQL=<查询语句,需返回 supplierName / exitDate 两列>
// 也可以在下面 CODE_* 常量里直接写死(env 优先)。
const CODE_EXIT_DATE_ENABLED = false;
const CODE_EXIT_DATE_QUERY = '';
const ENABLED = process.env.SUPPLIER_EXIT_DATE_ENABLED === 'true' || CODE_EXIT_DATE_ENABLED;
const EXIT_DATE_QUERY = process.env.SUPPLIER_EXIT_DATE_SQL || CODE_EXIT_DATE_QUERY;
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10) >= 0
? Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10)
: 30 * 60 * 1000;
let cache = null; // { at, payload }
const formatExitDate = (value) => {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
const text = String(value).trim();
if (!text) return '';
const match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (!match) return text;
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
};
/**
* 取「供应商 → 最晚退厂时间」映射。
* 返回 { available, sourceLabel, message, bySupplier: Map<string, string> }
*/
async function loadSupplierExitDates() {
const describe = () => ({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
if (!ENABLED || !EXIT_DATE_QUERY) return describe();
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
try {
const rows = await cloudDb.query(EXIT_DATE_QUERY, {}, { requestTimeout: 120000 });
const bySupplier = new Map();
(rows || []).forEach((row) => {
const name = String(row.supplierName || '').trim();
const date = formatExitDate(row.exitDate);
if (!name || !date) return;
const prev = bySupplier.get(name);
// 同一供应商多条时取最晚的日期
if (!prev || date > prev) bySupplier.set(name, date);
});
const payload = {
available: true,
sourceLabel: SOURCE_LABEL,
message: '',
bySupplier,
};
cache = { at: Date.now(), payload };
return payload;
} catch (error) {
// 取不到不算致命:该列留空、页面提示待接入,其余列照常
console.warn('[SupplierExitDate] 最晚退厂时间取数失败:', error.message);
const payload = {
available: false,
sourceLabel: SOURCE_LABEL,
message: `工作平台库读取失败(${error.message}),该列暂空`,
bySupplier: new Map(),
};
cache = { at: Date.now(), payload };
return payload;
}
}
const EMPTY_EXIT_DATES = Object.freeze({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
module.exports = {
loadSupplierExitDates,
formatExitDate,
EMPTY_EXIT_DATES,
EXIT_DATE_SOURCE_LABEL: SOURCE_LABEL,
EXIT_DATE_UNAVAILABLE_MESSAGE: UNAVAILABLE_MESSAGE,
};

View File

@ -0,0 +1,318 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
*
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
* 群里直接看文本),所以只有两个凭据字段:
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
*
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
* 生产容器是 UTC,绝不能拿容器本地时间比。
*/
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
const DEFAULT_SEND_TIME = '09:10';
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
const DEFAULT_MAX_DETAIL_ROWS = 30;
const MAX_DETAIL_ROWS_LIMIT = 50;
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
const CACHE_TTL_MS = 30 * 1000;
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);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
function normalizeSendTime(value) {
const text = normalizeText(value, 5);
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
return DEFAULT_SEND_TIME;
}
/** 明细条数: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;
}
function splitSendTime(sendTime) {
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
return { hour, minute };
}
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('[WleNotify] 读取推送配置失败:', error.message);
return {};
}
}
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
});
cachedConfig = {
enabled: normalizeBoolean(stored.enabled, false),
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
webhookSecret: secret.value,
webhookSecretSource: secret.source,
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
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 isWarehouseLogisticsExceptionNotifyReady(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',
sendTime: normalizeSendTime(config.sendTime),
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
atAll: config.atAll === true,
pageUrl: normalizePageUrl(config.pageUrl),
ready: isWarehouseLogisticsExceptionNotifyReady(config),
missingFields: missingFields(config),
source: config.source || 'default',
};
}
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
return toAdminConfig(config);
}
/**
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
*/
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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),
sendTime: input.sendTime === undefined && input.send_time === undefined
? normalizeSendTime(current.sendTime)
: normalizeSendTime(input.sendTime ?? input.send_time),
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,
sendTime: config.sendTime,
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);
}
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
async function loadWarehouseLogisticsExceptionNotifyState() {
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, {});
return state && typeof state === 'object' ? state : {};
} catch (error) {
console.warn('[WleNotify] 读取推送状态失败:', error.message);
return {};
}
}
async function saveWarehouseLogisticsExceptionNotifyState(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('[WleNotify] 保存推送状态失败:', error.message);
}
}
function clearNotifyConfigCache() {
cachedConfig = null;
cachedAt = 0;
}
module.exports = {
CONFIG_KEY,
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME,
MAX_DETAIL_ROWS_LIMIT,
SECRET_KEY,
STATE_KEY,
clearNotifyConfigCache,
getWarehouseLogisticsExceptionNotifyAdminConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
maskSecret,
missingFields,
normalizeMaxDetailRows,
normalizePageUrl,
normalizeSendTime,
saveWarehouseLogisticsExceptionNotifyConfig,
saveWarehouseLogisticsExceptionNotifyState,
splitSendTime,
toAdminConfig,
};

View File

@ -0,0 +1,457 @@
'use strict';
/**
* 仓储中心物流转运异常(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-21):
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
* 下属 12 个发货仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
* 山东青岗集唯品仓 / 河南洛宁仓
* 核验规则:**当日 09:00 核验前一日已发货订单的物流记录** ——
* 昨天发了货,到今天早上 9 点物流还没有正常轨迹(没揽收 / 没物流单 / 快递报异常 /
* 被退回 / 揽收后长时间无新轨迹),就是「物流转运异常」,按仓归口。
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* `rpt.jst_order_express_details`(聚水潭订单状态镜像,402 万行,视图)
* 这是一张「订单 + 物流」合并的宽表,一单多包裹会有多行(按 logistic_id 区分)。
* 本页用到的列:
* send_time 发货时间(聚水潭回传)
* logistic_status_name 物流状态:已签收 / 运输中 / 派送中 / 已揽收 /
* 未揽收 / 无物流单 / 异常 / 拒收 / 其他
* logistic_time 最后一条物流轨迹时间
* last_logistic_info 最后一条轨迹描述(nvarchar 6000,SQL 里截 300 字)
* query_time ETL 快照时间(每行最后一次被刷新的时刻)
* ⚠️ 别用 `logistic_time_to_now_h` 判「停滞」:那是相对**查询时刻**算的,
* 而本页的核验基准是**核验日的 09:00**(回看历史批次时会飘),统一在 Node 侧按
* `logistic_time → deadline` 重算。
*
* ── 异常判定(2026-10-21 定版 · 二次收敛)────────────────────────────────
* 业务口径:「统计的是**未揽收**和**已揽收、但没有下一步中转记录**的」,
* 「有『运输』字样的,往后中转记录不考核」。
* `NO_TRACKING` 无物流单 —— 发了货但没生成运单号(打单环节漏了)
* `NOT_PICKED` 未揽收 —— 到核验时刻快递还没揽收
* `PICKED_NO_TRANSIT` 已揽收未中转 —— 揽收后没有下一步中转/运输记录,卡在网点
* `CARRIER_EXCEPTION` 快递报异常 —— 状态=异常(寄件人取消寄件、异常签收等)
* `RETURNED` 退回 —— 状态=拒收,或「其他」(实测基本是「换单退回」)
* 已签收 / 运输中 / 派送中 → 正常,不进列表。
* 🔥 原来那套「揽收后 24h 无新轨迹 = 运输停滞」已**废弃**:
* 干线与中转轨迹本来就不回传聚水潭,按 24h 判全是假异常。
*
* ── 仓库归属 ──────────────────────────────────────────────────────────────
* 与「24 小时发货时效」同一套:按仓库名关键词**最长匹配**归主仓,子仓自动并入;
* 「京仓-横林二仓京东云仓」业务指定排除(与发货时效页保持一致)。
* 山东青岗集唯品仓的关键词必须比「山东青岗集」更长,否则会被青岗集仓抢走。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 🔥 生产容器是 `Etc/UTC`,DB 的 `GETDATE()` 是北京时间 —— 详见
* warehouseShipTimelinessRegistry.beijingNow() 的说明。
* 本模块一律用 `beijingNow()` 取「今天」,**不要**直接 `new Date()`。
*
* ── 批次 ──────────────────────────────────────────────────────────────────
* 「核验批次」= 核验日 + 发货日(核验日的前一天)。
* 默认:现在已过 09:00 → 今天这批(核验昨天发货);未到 09:00 → 昨天那批。
* 可回看近 30 天。
* ⚠️ 历史批次是**按当前物流快照重算**的 —— 昨天是异常、今天已经签收的单,
* 回看时会显示成正常。页面会明确标注,别拿它当「当时的留档」。
*/
const BEIJING_OFFSET_MINUTES = 480;
// ── 12 个发货仓(业务给定,顺序即展示顺序)────────────────────────────────
const CENTERS = Object.freeze([
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌'] },
// 🔥 关键词要比「山东青岗集」长,否则「简尚-山东青岗集仓唯品仓」会被青岗集仓抢走
{ key: 'SD_QGJ_VIP', name: '山东青岗集唯品仓', keywords: ['山东青岗集仓唯品', '山东青岗集唯品'] },
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
]);
// 业务指定排除:京仓(平台仓)不算自营发货仓的考核范围(与发货时效页一致)
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云仓']);
// ── 不参与考核的承运商(业务 2026-10-21 指定)──────────────────────────────
// 判据 = 状态表 `logistic_company_name` **包含**关键词(不区分大小写)。
// 🔥 「武进安能」**不能**泛化成「安能」:12 仓另有「常州安能新」3383 单/30 天,
// 那是要正常考核的承运商,泛化会把整片正常单误剔。
// 🔥 「中铁」「壹米滴答」这类专线物流**轨迹不回传聚水潭**,实测近 30 天
// 中铁物流 104 单 100% 报「异常」、壹米滴答 33 单 100% 报「异常」、
// 唯品顺丰 2510 单 2500 单报「异常」(99.6%)—— 全是假异常,必须剔。
// 「自提」「线下」当前 12 仓实测 0 单(历史/其它仓才有),保留判定防止以后出现。
// 🔥 「京喜京东」(含 `京喜京东快递` / `京喜京东快运`)是京喜自营那条线,
// 实测山东青岗集仓的京喜单揽收后轨迹全停在站点、不回传聚水潭,
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
// 是另外的账号,正常回传轨迹,不能连带剔掉。
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
const EXCLUDED_CARRIERS = Object.freeze([
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
// `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭
// (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。
// 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。
// 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。
'唯品顺丰',
'顺丰',
'武进安能',
'中铁',
'壹米滴答',
'自提',
'线下',
'商家配送',
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
// (2026-10-22 曾短暂要求全剔,随即撤回)。
'京喜京东',
]);
// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 2026-10-21 追加)────────
// 「睢宁中通」是全公司唯一的中通面单账号,开在睢宁网点。
// · 睢宁仓自己用 → 正常揽收、轨迹完整,**照常考核**;
// · 山东品牌仓 / 河南洛宁仓 / 崔桥工厂仓 异地用它打单 → 揽收不畅,
// 近 30 天非睢宁仓 490 单里 163 单「未揽收」(33%),全是账号异地揽收造成的假异常。
// 所以:`keepCenterKeys` 里的仓照常考核,其它仓命中即剔除。
const CONDITIONAL_EXCLUDED_CARRIERS = Object.freeze([
{
keyword: '睢宁中通',
keepCenterKeys: ['SN'],
reason: '睢宁中通(非睢宁仓异地揽收)',
},
]);
// ── 已终止的订单不算转运异常(业务 2026-10-21 指定)──────────────────────
// ① 订单状态:状态表 `order_status_name` 实测只有「已发货 / 已取消」两个值,
// 「已取消」在近 30 天 12 仓里 8 单,其中 6 单会误报未揽收/无物流单。
const EXCLUDED_ORDER_STATUS_KEYWORDS = Object.freeze(['取消', '作废']);
// ② 订单标签:`order_labels`(JSON 数组字符串)
// · 「发货后取消」= 客户已申请退款取消,货在路上但不用再跟物流
// · 「作废」= 单据作废
// · 「拦截」= 买家/客服要求拦截(实测标签为「留言备注拦截」)
// ⚠️ 别把「取消」直接当关键词匹配标签:会连带「取消预售」等无关标签。
const EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze([
'发货后取消',
'作废',
'拦截',
]);
// ── 刷单 / 信誉单不算转运异常(业务 2026-10-21 追加)──────────────────────
// 判据 = 状态表 `order_labels` 里的**订单类型标签**(不是状态、不是备注):
// · 含「刷单」 = 刷单待处理 → 假发货,本来就不会真揽收
// · 含「转空包」= 信誉单(信誉订单仓那批单都带这个标签,发空包不走真货)
// 实测(近 30 天 12 仓):带「刷单」411 单,其中 148 单报未揽收 —— 全是假异常;
// 带「转空包」0 单(都在「简尚-信誉订单仓」,那本来不在 12 个考核仓里)。
// ⚠️ 别用「特殊单」当判据:它和补差价/配件单混在一起,剔了会漏掉真异常。
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
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
.sort((a, b) => b.kw.length - a.kw.length);
// ── 核验时刻 ──────────────────────────────────────────────────────────────
const VERIFY_HOUR = 9;
const LOOKBACK_DAYS = 30;
// ── 异常类型(2026-10-21 二次定版:只看「揽收前」的两个卡点)───────────────
// 业务原话:「这里的物流异常统计的是未揽收和已揽收、但是没有下一步中转记录的」,
// 以及「有『运输』字样的,往后中转记录不考核,不属于物流异常」。
// 所以:运输中 / 派送中 / 已签收 **一律正常**,不再用「24h 无新轨迹」判停滞
// (干线/中转轨迹本来就不回传,按 24h 判只会刷屏)。
// label 必须**自带结论**(业务要求「写上是什么异常」),不要只写「物流异常」这种笼统词。
const EXCEPTION_TYPES = Object.freeze([
{
key: 'NO_TRACKING',
label: '无物流单',
tone: 'red',
desc: '已发货但连运单号都没有 —— 打单/推单环节就没生成物流单',
},
{
key: 'NOT_PICKED',
label: '未揽收',
tone: 'orange',
desc: '有运单号,到核验时刻快递还没上门揽收',
},
{
key: 'PICKED_NO_TRANSIT',
label: '已揽收未中转',
tone: 'gold',
desc: '快递已揽收,但之后没有下一步中转/运输记录 —— 卡在网点没发出去',
},
{
key: 'CARRIER_EXCEPTION',
label: '快递报异常',
tone: 'red',
desc: '快递回传「异常」(寄件人取消寄件、异常签收等),需要人工介入',
},
{
key: 'RETURNED',
label: '退回',
tone: 'volcano',
desc: '拒收,或包裹已「换单退回」',
},
]);
// 「已经动起来了」的轨迹关键词:状态停在「已揽收」但最后一条轨迹里已经出现
// 运输/中转/发往等字样 —— 说明有下一步中转记录,按业务口径**不算异常**。
const TRANSIT_INFO_KEYWORDS = Object.freeze(['运输', '中转', '发往', '已发出', '分拨', '干线']);
const TYPE_MAP = new Map(EXCEPTION_TYPES.map((t) => [t.key, t]));
function pad2(n) {
return String(n).padStart(2, '0');
}
/**
* 取「按北京时间读出来」的当前时刻(容器可能跑在 UTC)。
* @param {Date} [base]
* @returns {Date} 本地 getter 读出来正好是北京时间的 Date
*/
function beijingNow(base = new Date()) {
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
}
function formatDate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
/** 'YYYY-MM-DD' → 当日 00:00 的 Date(本地,不做时区换算) */
function parseDate(text) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(text || '').trim());
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0);
return Number.isNaN(d.getTime()) ? null : d;
}
function addDays(date, days) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0);
}
/** 仓库名 → 主仓;被排除或无法归属返回 null */
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
const name = String(warehouseName);
for (const kw of EXCLUDED_WAREHOUSE_KEYWORDS) {
if (name.includes(kw)) return null;
}
for (const item of SORTED_KEYWORDS) {
if (name.includes(item.kw)) return item.center;
}
return null;
}
/**
* 承运商是否不参与考核。
* @param {string} companyName 状态表 `logistic_company_name`
* @param {string} [centerKey] 该行所属仓(CONDITIONAL_EXCLUDED_CARRIERS 用);
* 不传时**保守不剔** —— 宁可少剔,不能把本仓正常单误剔
* @returns {string|null} 命中原因/关键词;不剔除返回 null
*/
function matchExcludedCarrier(companyName, centerKey) {
const name = String(companyName || '');
if (!name) return null;
// ① 无条件剔除
for (const kw of EXCLUDED_CARRIERS) {
if (name.includes(kw)) return kw;
}
// ② 有条件剔除:只在该承运商「本仓」正常时才留(如睢宁中通 → 睢宁仓)
for (const rule of CONDITIONAL_EXCLUDED_CARRIERS) {
if (!name.includes(rule.keyword)) continue;
if (!centerKey) return null;
if (rule.keepCenterKeys.includes(centerKey)) return null;
return rule.reason;
}
return null;
}
/**
* 订单是否已终止(已取消 / 作废 / 拦截),终止的不用跟物流。
* @param {string} orderStatus 状态表 `order_status_name`
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中原因(人话),不剔除返回 null
*/
function matchVoidedOrder(orderStatus, orderLabels) {
const status = String(orderStatus || '');
for (const kw of EXCLUDED_ORDER_STATUS_KEYWORDS) {
if (status.includes(kw)) return `订单${status}`;
}
const labels = String(orderLabels || '');
if (!labels) return null;
for (const kw of EXCLUDED_ORDER_LABEL_KEYWORDS) {
if (labels.includes(kw)) return `标签含「${kw}」`;
}
return null;
}
/**
* 刷单 / 信誉单 → 不是真实发货,不用跟物流。
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中的人话原因;不剔除返回 null
*/
function matchNonRealOrder(orderLabels) {
const labels = String(orderLabels || '');
if (!labels) return null;
for (const item of EXCLUDED_ORDER_TYPE_LABELS) {
if (labels.includes(item.keyword)) {
return item.label === '刷单' ? '刷单' : '信誉单(转空包)';
}
}
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, '');
}
/**
* 解析「核验批次」。
*
* @param {string} [dateInput] 核验日 'YYYY-MM-DD';不传按当前时刻自动判定
* @param {Date} [nowBase] 当前时刻(真实时刻,内部会转北京时间)
* @returns {object} 批次描述
*/
function resolveBatch(dateInput, nowBase = new Date()) {
const now = beijingNow(nowBase);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
let verifyDay = null;
if (dateInput) {
const parsed = parseDate(dateInput);
if (parsed) verifyDay = parsed;
}
if (!verifyDay) {
// 未到 09:00 时,今天的批次还没跑 → 默认看昨天那批(核验前日发货)
const beforeCheckpoint = now.getHours() < VERIFY_HOUR;
verifyDay = beforeCheckpoint ? addDays(today, -1) : today;
}
// 护栏:不能晚于今天,也不能早于回看下限
if (verifyDay > today) verifyDay = today;
const earliest = addDays(today, -LOOKBACK_DAYS + 1);
if (verifyDay < earliest) verifyDay = earliest;
const shipDay = addDays(verifyDay, -1);
const verifyDate = formatDate(verifyDay);
const shipDate = formatDate(shipDay);
const deadline = `${verifyDate} ${pad2(VERIFY_HOUR)}:00:00`;
const nowText = `${formatDate(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
return {
verifyDate,
shipDate,
deadline,
windowStart: `${shipDate} 00:00:00`,
windowEnd: `${verifyDate} 00:00:00`,
label: `${verifyDate.slice(5)} 09:00 核验 · ${shipDate.slice(5)} 发货`,
started: nowText >= deadline,
isToday: verifyDate === formatDate(today),
lookback: verifyDate !== formatDate(today),
lookbackDays: Math.round((today - verifyDay) / 86400000),
};
}
module.exports = {
CENTERS,
EXCLUDED_WAREHOUSE_KEYWORDS,
EXCLUDED_CARRIERS,
CONDITIONAL_EXCLUDED_CARRIERS,
EXCLUDED_ORDER_STATUS_KEYWORDS,
EXCLUDED_ORDER_LABEL_KEYWORDS,
EXCLUDED_ORDER_TYPE_LABELS,
INTERCEPT_AFTERSALE_SOURCES,
EXCEPTION_TYPES,
TYPE_MAP,
TRANSIT_INFO_KEYWORDS,
VERIFY_HOUR,
LOOKBACK_DAYS,
resolveCenter,
matchExcludedCarrier,
matchVoidedOrder,
matchNonRealOrder,
matchTransferWarehouse,
TRANSFER_WAREHOUSE_LABEL_PATTERN,
resolveBatch,
beijingNow,
formatDate,
parseDate,
addDays,
pad2,
};

View File

@ -0,0 +1,697 @@
'use strict';
/**
* 仓储中心物流转运异常 —— 服务层
* ────────────────────────────────────────────────────────────────────────────
* 口径见 warehouseLogisticsExceptionRegistry.js 文件头。这里只讲实现要点:
*
* ① 一趟查询把「核验日前一天发货」的单全部取回来(窄列 + 轨迹截 300 字),
* Node 侧按仓库名归主仓、按物流状态判异常。实测:5400 行冷启动 ~20s、
* 热缓存 ~4s(视图谓词下推一般,走 querySlow 给足超时)。
* ② 一单多包裹会有多行(按 logistic_id 区分)——
* · 明细按 (内部单号 + 运单号) 去重,仓库能看到是哪个包裹出的问题;
* · 汇总按**内部单号**去重,否则 3 个包裹的单会被算成 3 单,异常率虚高。
* ③ 只判「揽收前」的两个卡点(未揽收 / 已揽收未中转),见 classify()。
* 运输中、派送中一律不考核,不再算 24h 停滞。
* ④ 结果缓存 5 分钟:页面 10 分钟自动刷一次,点「刷新」带 force=1 绕过。
*
* ── 剔除(业务 2026-10-21 定版)──────────────────────────────────────────────
* 剔掉的单**既不算异常、也不算发货分母**(否则异常率会被稀释/虚高):
* a. 承运商 ∈ EXCLUDED_CARRIERS(顺丰系全部 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 /
* 商家配送 / 京喜京东)—— 轨迹不回传,留着全是假异常(顺丰系各账号 52%~99.6% 假异常,
* 中铁、壹米滴答近 30 天 100%)。「武进安能」必须精确,别泛化成「安能」。
* a2. 有条件剔除 CONDITIONAL_EXCLUDED_CARRIERS:**「睢宁中通」只在睢宁仓考核**,
* 其它仓异地用它打单的剔(近 30 天非睢宁仓 490 单里 163 单未揽收,全是账号异地揽收)。
* b. 订单已终止:order_status_name 含「取消/作废」,或 order_labels 含
* 「发货后取消 / 作废 / 拦截」—— 单据都终止了,不用再跟物流。
* c. 刷单(标签含「刷单」)/ 信誉单(标签含「转空包」)—— 单据本身不是真实发货。
* 各类剔除量通过 `exclusions.*` 原样返回,页面页脚展示,方便业务核对口径。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseLogisticsExceptionRegistry');
const DEFAULT_DETAIL_LIMIT = 500;
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
const MAX_DETAIL_LIMIT = 2000;
const EXPORT_DETAIL_LIMIT = 5000;
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
const SNAPSHOT_STALE_MINUTES = 120;
// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同:
// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」,
// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120;
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
const CACHE_MAX_ENTRIES = 8;
const resultCache = new Map();
function clearLogisticsExceptionCache() {
resultCache.clear();
}
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
function formatDateTime(value) {
if (!value) return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const p = (n) => String(n).padStart(2, '0');
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
}
const s = String(value);
if (!s) return '';
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
}
/** 两个时间文本相差多少分钟(to - from) */
function diffMinutes(fromText, toText) {
const a = formatDateTime(fromText);
const b = formatDateTime(toText);
if (!a || !b) return null;
const pa = new Date(a.replace(' ', 'T')).getTime();
const pb = new Date(b.replace(' ', 'T')).getTime();
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return null;
return Math.round((pb - pa) / 60000);
}
/**
* 按物流状态判异常类型(口径 2026-10-21 二次定版)。
*
* 只有两种「真异常」,其余状态一律不考核:
* · 没揽收 —— 状态停在「未揽收」(或压根没物流单)
* · 揽收了但没下一步中转 —— 状态停在「已揽收」,最后轨迹里也没有运输/中转字样
*
* 🔥 业务原话:「有『运输』字样的,往后中转记录不考核」——
* 所以 `运输中` / `派送中` **直接算正常**;不再用「距核验时刻 ≥24h 无新轨迹」
* 判停滞(干线、中转轨迹本就不回传聚水潭,按 24h 判全是假异常)。
*
* @returns {string|null} EXCEPTION_TYPES[].key;正常返回 null
*/
function classify(row) {
const status = String(row.logistic_status_name || '').trim();
const trackingNo = String(row.logistic_id || '').trim();
// 状态为空:有运单号但没轨迹 = 未揽收;连运单号都没有 = 无物流单
if (!status) return trackingNo ? 'NOT_PICKED' : 'NO_TRACKING';
if (status === '已签收' || status === '签收') return null;
if (status.includes('无物流')) return 'NO_TRACKING';
if (status.includes('未揽收')) return 'NOT_PICKED';
if (status.includes('拒收')) return 'RETURNED';
// 「其他」实测全是「包裹已换单退回」,和拒收同性质
if (status === '其他') return 'RETURNED';
if (status.includes('异常')) return 'CARRIER_EXCEPTION';
if (status.includes('已揽收')) {
// 揽收之后如果最后一条轨迹已经出现运输/中转/发往字样,就算「有下一步中转」
const info = String(row.last_info || row.last_logistic_info || '');
const moved = registry.TRANSIT_INFO_KEYWORDS.some((kw) => info.includes(kw));
return moved ? null : 'PICKED_NO_TRANSIT';
}
// 运输中 / 派送中(以及任何带「运输」字样的状态)= 已经动起来了,不考核
return null;
}
/** 拼 SQL 的仓库名 LIKE 条件(关键词取自 CENTERS,避免手写两套名单) */
function buildWarehouseFilters() {
const keywords = [];
for (const center of registry.CENTERS) {
for (const kw of center.keywords) {
if (!keywords.includes(kw)) keywords.push(kw);
}
}
const params = {};
const clauses = keywords.map((kw, i) => {
params[`w${i}`] = `%${kw}%`;
return `e.warehouse_name LIKE @w${i}`;
});
return { clauses, params, keywords };
}
async function loadShippedPackages(batch) {
const { clauses, params } = buildWarehouseFilters();
const rows = await cloudDatabase.querySlow(
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.logistic_id, e.logistic_company_name, e.logistic_status_name,
e.order_status_name, e.order_labels,
e.send_time, e.logistic_time, e.query_time,
LEFT(ISNULL(e.last_logistic_info, ''), 300) AS last_info
FROM dbo.jst_order_express_details e
WHERE e.send_time >= @shipStart AND e.send_time < @shipEnd
AND (${clauses.join(' OR ')})`,
{ ...params, shipStart: batch.windowStart, shipEnd: batch.windowEnd },
// 视图谓词下推一般,冷启动 20s;普通池 60s 池级超时 + 重试会把耗时翻倍
{ connectRequestTimeoutMs: 300000 }
);
return rows || [];
}
/**
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
*
* 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
* 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**,
* 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时
* `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成
* 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。
*
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
*
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
*/
async function loadSourceLastUpdate() {
try {
const rows = await cloudDatabase.query(
`SELECT MAX(e.query_time) AS max_q
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
{},
{ timeoutMs: 240000 }
);
return formatDateTime(rows?.[0]?.max_q);
} catch (err) {
// 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间
console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`);
return '';
}
}
// 售后拦截判定:一次 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() {
return {
shippedOrders: 0,
packageCount: 0,
exceptionOrders: 0,
exceptionPackages: 0,
exceptionRate: 0,
centerCount: 0,
byType: {},
};
}
/**
* 剔除口径(前端页脚直接展示,别在页面里再写一份)。
* 两处返回(正常 / 未到核验时刻)共用,避免口径漂移。
*/
function exclusionMeta() {
return {
carriers: registry.EXCLUDED_CARRIERS,
// 有条件剔除:「睢宁中通」只在睢宁仓考核,其它仓异地打单的剔
conditionalCarriers: registry.CONDITIONAL_EXCLUDED_CARRIERS.map((r) => ({
keyword: r.keyword,
keepCenters: r.keepCenterKeys.map((key) => {
const center = registry.CENTERS.find((c) => c.key === key);
return center ? center.name : key;
}),
reason: r.reason,
})),
orderStatusKeywords: registry.EXCLUDED_ORDER_STATUS_KEYWORDS,
orderLabelKeywords: registry.EXCLUDED_ORDER_LABEL_KEYWORDS,
// 刷单 / 信誉单的标签判据
orderTypeLabels: registry.EXCLUDED_ORDER_TYPE_LABELS.map((t) => t.label),
};
}
/**
* 缓存里存的是**全量明细**(不截断),limit 只在出口生效。
*
* 🔥 2026-10-23 修:缓存键以前是 `${核验日}|${limit}`,页面传 1000、导出传 5000
* → 两份缓存互不命中,点一次导出就要把整轮重算一遍(生产实测 317s,而实际
* 异常只有 33 行,limit 根本不影响扫库成本)。现在键只按核验日,出口再 slice。
*/
function finalizeDetail(data, limit, cached, cacheAgeSec) {
const all = Array.isArray(data.orders) ? data.orders : [];
return {
...data,
orders: all.slice(0, limit),
detailLimit: limit,
truncated: all.length > limit,
cached,
cacheAgeSec,
};
}
async function getLogisticsExceptions(options = {}) {
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
const limitCap = Math.min(
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
EXPORT_DETAIL_LIMIT
);
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
limitCap
);
const force = options.force === true || options.force === '1' || options.force === 1;
const generatedAt = formatDateTime(registry.beijingNow());
// 未到 09:00:本批次还没跑,不拿当前快照冒充「09:00 的结论」
if (!batch.started) {
return {
batch,
notStarted: true,
resolution: '09:00',
summary: emptySummary(),
centers: registry.CENTERS.map((c) => ({
key: c.key, name: c.name, shippedOrders: 0, exceptionOrders: 0, exceptionRate: 0, byType: {},
})),
types: registry.EXCEPTION_TYPES,
exclusions: {
...exclusionMeta(),
carrierPackages: 0,
carrierOrders: 0,
carrierReasons: {},
voidedPackages: 0,
voidedOrders: 0,
voidedReasons: {},
nonRealPackages: 0,
nonRealOrders: 0,
nonRealReasons: {},
interceptPackages: 0,
interceptOrders: 0,
interceptReasons: {},
transferPackages: 0,
transferOrders: 0,
transferReasons: {},
},
orders: [],
detailLimit: limit,
truncated: false,
freshness: {
snapshotAt: '',
ageMinutes: null,
stale: false,
coversDeadline: null,
emptyWindow: false,
sourceLastUpdate: '',
sourceAgeMinutes: null,
sourceStale: false,
hint: '',
},
generatedAt,
cached: false,
cacheAgeSec: 0,
};
}
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
const cacheKey = batch.verifyDate;
const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return finalizeDetail(
cachedEntry.data,
limit,
true,
Math.round((Date.now() - cachedEntry.at) / 1000)
);
}
const rows = await loadShippedPackages(batch);
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
const interceptedOrders = await loadInterceptedOrders(rows);
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
const centerStat = new Map();
for (const center of registry.CENTERS) {
centerStat.set(center.key, {
key: center.key,
name: center.name,
shippedOrders: new Set(),
exceptionOrders: new Set(),
byType: {},
packages: 0,
});
}
const seenPackage = new Set();
const exceptions = [];
let packageCount = 0;
const shippedOrderIds = new Set();
let snapshotAt = '';
// 剔除统计(业务 2026-10-21:不考核的承运商 + 已取消/作废/拦截 + 刷单/信誉单)
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} };
const skippedByTransfer = { packages: 0, orders: new Set(), reasons: {} };
for (const row of rows) {
const queryTime = formatDateTime(row.query_time);
if (queryTime && queryTime > snapshotAt) snapshotAt = queryTime;
const center = registry.resolveCenter(row.warehouse_name);
if (!center) continue;
const oId = String(row.inner_order_id || '');
const trackingNo = String(row.logistic_id || '');
const packageKey = `${oId}|${trackingNo || row.send_time || ''}`;
if (seenPackage.has(packageKey)) continue;
seenPackage.add(packageKey);
// ① 不参与考核的承运商(唯品顺丰 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 / 京喜京东)
// 这些要么轨迹不回传聚水潭、要么根本没有快递轨迹,留着只会刷假异常。
// 「睢宁中通」是有条件的:睢宁仓照常考核,其它仓异地打单的剔(传 center.key 区分)。
const carrierHit = registry.matchExcludedCarrier(row.logistic_company_name, center.key);
if (carrierHit) {
skippedByCarrier.packages += 1;
skippedByCarrier.orders.add(oId);
skippedByCarrier.reasons[carrierHit] = (skippedByCarrier.reasons[carrierHit] || 0) + 1;
continue;
}
// ② 已取消 / 作废 / 拦截的订单 —— 单据已终止,不用再跟物流
const voidReason = registry.matchVoidedOrder(row.order_status_name, row.order_labels);
if (voidReason) {
skippedByVoid.packages += 1;
skippedByVoid.orders.add(oId);
skippedByVoid.reasons[voidReason] = (skippedByVoid.reasons[voidReason] || 0) + 1;
continue;
}
// ③ 刷单 / 信誉单 —— 订单本身不是真实发货,揽不揽收不代表仓库的物流能力
const nonRealReason = registry.matchNonRealOrder(row.order_labels);
if (nonRealReason) {
skippedByType.packages += 1;
skippedByType.orders.add(oId);
skippedByType.reasons[nonRealReason] = (skippedByType.reasons[nonRealReason] || 0) + 1;
continue;
}
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。
// 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的,
// 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理
// (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。
const transferReason = registry.matchTransferWarehouse(row.order_labels);
if (transferReason) {
skippedByTransfer.packages += 1;
skippedByTransfer.orders.add(oId);
skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1;
continue;
}
const stat = centerStat.get(center.key);
stat.shippedOrders.add(oId);
stat.packages += 1;
packageCount += 1;
shippedOrderIds.add(oId);
const type = classify(row);
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 logisticAt = formatDateTime(row.logistic_time);
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
stat.exceptionOrders.add(oId);
stat.byType[type] = (stat.byType[type] || 0) + 1;
exceptions.push({
oId,
soId: String(row.so_id || ''),
shopName: String(row.shop_name || ''),
warehouseName: String(row.warehouse_name || ''),
centerKey: center.key,
centerName: center.name,
carrier: String(row.logistic_company_name || ''),
trackingNo,
logisticStatus: String(row.logistic_status_name || ''),
exceptionType: type,
exceptionLabel: typeMeta.label,
exceptionDesc: typeMeta.desc || '',
tone: typeMeta.tone,
sendTime: sendAt,
lastLogisticTime: logisticAt,
lastLogisticInfo: String(row.last_info || '').trim(),
// 发货 → 核验时刻(小时,1 位小数):仓库看到「发了多久还没动静」
pendingHours: pendingMinutes === null ? null : Math.round((pendingMinutes / 60) * 10) / 10,
// 最后一条轨迹 → 核验时刻(小时):负数表示这条轨迹是核验之后才出现的
staleHours: staleMinutes === null ? null : Math.round((staleMinutes / 60) * 10) / 10,
});
}
// 异常单最久未处理的排前面
exceptions.sort((a, b) => (b.pendingHours || 0) - (a.pendingHours || 0));
// ── 汇总 ────────────────────────────────────────────────────────────
const summary = emptySummary();
const allExceptionOrders = new Set();
summary.shippedOrders = shippedOrderIds.size;
summary.packageCount = packageCount;
const centers = registry.CENTERS.map((center) => {
const stat = centerStat.get(center.key);
const byType = { ...stat.byType };
for (const oid of stat.exceptionOrders) allExceptionOrders.add(oid);
return {
key: center.key,
name: center.name,
shippedOrders: stat.shippedOrders.size,
exceptionOrders: stat.exceptionOrders.size,
exceptionRate: stat.shippedOrders.size
? Math.round((stat.exceptionOrders.size / stat.shippedOrders.size) * 1000) / 10
: 0,
byType,
};
});
summary.exceptionOrders = allExceptionOrders.size;
summary.exceptionPackages = exceptions.length;
summary.exceptionRate = summary.shippedOrders
? Math.round((summary.exceptionOrders / summary.shippedOrders) * 1000) / 10
: 0;
summary.centerCount = centers.filter((c) => c.exceptionOrders > 0).length;
for (const item of exceptions) {
summary.byType[item.exceptionType] = (summary.byType[item.exceptionType] || 0) + 1;
}
// ── 新鲜度 ──────────────────────────────────────────────────────────
// 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。
// 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='')
// 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。
// 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。
const emptyWindow = rows.length === 0;
const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : '';
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null;
const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES;
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
let hint = '';
if (emptyWindow) {
const lastText = sourceLastUpdate || '';
const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10;
if (sourceStale) {
hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。`
+ ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。'
+ ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。';
} else if (lastText) {
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。`
+ ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。';
} else {
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——`
+ ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。';
}
} else if (coversDeadline === false) {
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
} else if (stale) {
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)}(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新),`
+ '状态可能滞后,请以聚水潭实时物流为准。';
}
const payload = {
batch,
notStarted: false,
resolution: '09:00',
summary,
centers,
// 异常类型字典(前端图例/筛选器直接用,避免两边各写一套文案)
types: registry.EXCEPTION_TYPES,
// 业务指定的剔除口径(前端原样展示,别在页面里硬编码一份)
exclusions: {
...exclusionMeta(),
// 本批实际剔掉了多少(包裹 / 订单 / 原因明细)
carrierPackages: skippedByCarrier.packages,
carrierOrders: skippedByCarrier.orders.size,
carrierReasons: skippedByCarrier.reasons,
voidedPackages: skippedByVoid.packages,
voidedOrders: skippedByVoid.orders.size,
voidedReasons: skippedByVoid.reasons,
nonRealPackages: skippedByType.packages,
nonRealOrders: skippedByType.orders.size,
nonRealReasons: skippedByType.reasons,
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
interceptPackages: skippedByIntercept.packages,
interceptOrders: skippedByIntercept.orders.size,
interceptReasons: skippedByIntercept.reasons,
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
transferPackages: skippedByTransfer.packages,
transferOrders: skippedByTransfer.orders.size,
transferReasons: skippedByTransfer.reasons,
},
// 缓存里存全量,limit 由 finalizeDetail 在出口截断
orders: exceptions,
detailLimit: limit,
truncated: false,
freshness: {
snapshotAt,
ageMinutes,
stale,
coversDeadline,
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
emptyWindow,
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
sourceLastUpdate,
sourceAgeMinutes,
sourceStale,
hint,
},
generatedAt,
cached: false,
cacheAgeSec: 0,
};
resultCache.set(cacheKey, { at: Date.now(), data: payload });
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
if (resultCache.size > CACHE_MAX_ENTRIES) {
let oldestKey = '';
let oldestAt = Infinity;
for (const [key, entry] of resultCache.entries()) {
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
}
if (oldestKey) resultCache.delete(oldestKey);
}
return finalizeDetail(payload, limit, false, 0);
}
/**
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
*
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
* 不另写一套查询,避免「导出和页面两套口径」。
*
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
*/
async function getLogisticsExceptionExportRows(options = {}) {
const data = await getLogisticsExceptions({
...options,
limit: EXPORT_DETAIL_LIMIT,
maxLimit: EXPORT_DETAIL_LIMIT,
});
const rows = Array.isArray(data.orders) ? data.orders : [];
const total = Number(data.summary?.exceptionPackages) || rows.length;
return {
rows,
total,
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
truncated: rows.length < total,
detailLimit: EXPORT_DETAIL_LIMIT,
batch: data.batch,
notStarted: data.notStarted,
summary: data.summary,
centers: data.centers,
types: data.types,
exclusions: data.exclusions,
freshness: data.freshness,
generatedAt: data.generatedAt,
};
}
module.exports = {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
clearLogisticsExceptionCache,
classify,
formatDateTime,
diffMinutes,
};

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

@ -0,0 +1,718 @@
'use strict';
/**
* 仓储中心发货时效(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
* 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验;
* 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。
* (2026-10-20 三次调整:
* 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00;
* 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476
* (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮;
* ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时;
* ③ **历史未发单一律算**,不卡「今天 00:00 起」。
* 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到
* 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。)
* 展示口径:**只显示超时未发**(业务确认)。
*
* 业务确认的口径(2026-10-20):
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
* ③ 展示结构 —— **只显示超时未发**
* ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id)
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
* 是老口径,仅作历史记录,不要再照着实现**。
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
* ⚠️ 不是 `order_date`(平台下单时间);
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
* 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平;
* ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。
* 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。
* · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date`
* 非空,**或**订单状态已是 `Sent` / `Delivering`。
* · 待出库:以上三条都不满足。
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
*
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
* 横林三仓 15043561
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
* 崔桥工厂仓 15038426 + 15301815/16040410
* 崔桥工厂品牌仓 15870495
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
* 新苏北仓 15038347 + 15315031/15301812
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
* 山东品牌仓 15182705
* 河南洛宁仓 15539084
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
*
* ── 时效判定 ──────────────────────────────────────────────────────────────
* 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方):
* `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。
* `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。
* 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次:
* 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发
* 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发)
* ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内;
* 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。
* 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**):
* · 超时未出库 = 到核验时刻仍未发货
* (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering)
* 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远
* 看不到当天 7 点那批(才等了 6 小时),业务直接打回。
* · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering
* · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** ——
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
* 均为北京时间)。因此 SQL 里统一用 `CAST(GETDATE() AS date)` 取「今天」,
* 服务器与数据库都在东八区;**不要**在 Node 侧用 UTC 日期做判断。
*/
const cloudDatabase = require('../config/cloudDatabase');
// ── 主仓定义(业务给定的 11 个)──────────────────────────────────────────
// keywords:该主仓在 rpt.jst_warehouseInfo.name 里应包含的关键词(任一命中即归属)
// 顺序即展示顺序;`match` 用最长关键词优先,所以「横林二仓品牌仓」不会被「横林二仓」抢走。
const CENTERS = Object.freeze([
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
// 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位;
// 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。
// ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度,
// 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
// 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)——
// 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前,
// 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } },
// 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令)
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } },
]);
// 各主仓在某一轮的窗口上界小时。
// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。
// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。
function centerWindowEndHour(centerKey, roundKey) {
const center = CENTERS.find((c) => c.key === centerKey);
const override = center && center.windowEndHourByRound
? center.windowEndHourByRound[roundKey]
: undefined;
return Number.isFinite(override) ? override : null;
}
/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */
function centerWindowEnd(centerKey, round) {
const hour = centerWindowEndHour(centerKey, round.key);
if (hour === null) return round.windowEnd;
return `${round.date} ${String(hour).padStart(2, '0')}:00:00`;
}
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
// 靠关键词长度倒序匹配即可(见 resolveCenter)。
const SORTED_KEYWORDS = CENTERS
.filter((c) => c.keywords.length)
.flatMap((c) => c.keywords.map((kw) => ({ kw, center: c })))
.sort((a, b) => b.kw.length - a.kw.length);
// ── 排除仓(业务指定,2026-10-20)──────────────────────────────────────────
// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」,
// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。
// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」:
// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、
// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉;
// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([
'横林二仓京东云仓',
]);
// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先
const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length);
// ── 时效轮次(业务给定,2026-10-20 二次调整)──────────────────────────────
// verifyHour :当天几点开始核验(13:00 / 18:00)
// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00)
// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当:
// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把
// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。
// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单
// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
const ROUNDS = Object.freeze([
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
]);
// ── 订单窗口下界(含历史的天数,**业务可调**)────────────────────────────
// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切,
// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。
//
// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测
// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量):
// 近 7 天 → 约 5s 近 30 天 → 约 45s
// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死)
// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。
//
// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、
// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。
// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。
const HISTORY_LOOKBACK_DAYS = 30;
// 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致)
//
// 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
// 不会发货(`send_date` 永远为空),子单才是真正进仓发货的那一单。
// 2026-10-20 实测:不加这个条件,一年前的 Split 父单全被算成「超时未发」,
// 全库未发 Split 单占比极高 —— 会让页面变成上万条的垃圾列表。
// ⚠️ 与 mkhBomService 的差异:那边是「预警今天要发的货」,Split 父单不带真实
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`;
// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)────
// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」,
// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个:
// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering,
// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空);
// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走),
// `is_split=1` 才是真标记(样本 9838537 就是这样);
// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。
// 订单状态实测分布(2026-10-20,近 7 天,两表合计):
// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 /
// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12
// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空
// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES)
const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']);
// 待出库候选:把「已发货」状态一并排除。判定顺序是
// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并)
// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库)
// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时**
// —— 注意 Question 必须留在候选里,否则统计不出挂起单数;
// 它只是不进 orders(超时明细),走 heldCount / heldOrders。
const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`;
// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。
// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。
// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底,
// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。
const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`;
// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)──────────────────────
// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭
// **销售出库单**页面为准,不再用订单表的 send_date 猜。
//
// 两张表与订单表同构、店铺互斥:
// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行
// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行
// 字段口径(2026-10-20 实测确认):
// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个),
// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用;
// · `io_date` —— **出库时间**(真正发货那一刻);
// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x),
// 与订单表的 modified 一样**不可当作创建时间**;
// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。
// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。
const OUTBOUND_TABLES = Object.freeze({
jst_Orders: 'jst_salesOutboundInfo',
jst_qmcust_orderlist: 'jst_qmcust_salesoutbound',
});
// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)──────────
// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status`
// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。
// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里
// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后);
// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。
//
// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55)
// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率):
// · `order_status_name` —— 聚水潭真实订单状态
// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669
// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833
// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点
// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新)
// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空)
// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配)
// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …)
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
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 UNAUDITED_STATUS = '已支付待审核';
// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时
const ABNORMAL_STATUS = '异常';
// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、
// 等供销商|外仓发货(不是本仓发)
// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」):
// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。
// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单,
// 漏掉这个排除,整个页面会被作废单淹没。
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
// 🔥 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)。
//
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
//
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
const CREATED_BACKFILL_BATCH = 200;
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单,
// 却出现在超时未发货里。四张表对照后的真相:
// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空
// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的;
// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49;
// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。
// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**,
// 单子早随主单发走了,状态表还停在「发货中」。
// ⟹ 所以除了状态表的 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']);
// 订单表 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 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
const SHIP_TIMELINESS_HOURS = 24;
const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000;
let warehouseCache = { at: 0, rows: [] };
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function pad2(n) {
return String(n).padStart(2, '0');
}
// 🔥 北京时间(UTC+8)偏移,分钟
const BEIJING_OFFSET_MINUTES = 480;
/**
* 取「按北京时间读出来」的当前时刻。
*
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
*
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
*
* @param {Date} [base] 真实时刻,默认 `new Date()`
* @returns {Date} 本地 getter = 北京时间的 Date
*/
function beijingNow(base = new Date()) {
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
}
/**
* 用「关键词最长匹配」把仓库名归到主仓。
*
* ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」)
* 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。
*
* @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null
*/
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
const name = String(warehouseName);
for (const kw of SORTED_EXCLUSIONS) {
if (name.includes(kw)) return null;
}
for (const item of SORTED_KEYWORDS) {
if (name.includes(item.kw)) return item.center;
}
return null;
}
/**
* 拉取仓库表并建立「主仓 → wms_co_id 列表」映射。
* 缓存 1 小时;仓库表变更不频繁,且新增子仓会自动被关键词捞到。
*/
async function loadCenterWarehouseMap(force = false) {
const fresh = Date.now() - warehouseCache.at < WAREHOUSE_CACHE_TTL_MS;
if (!force && fresh && warehouseCache.rows.length) {
return warehouseCache.rows;
}
// ⚠️ cloudDatabase.query() 直接返回 recordset 数组,不是 {recordset} 对象
const rows = (await cloudDatabase.query(
`SELECT Id, name, wms_co_id
FROM dbo.jst_warehouseInfo`,
{},
{ requestTimeout: 60000 }
)) || [];
const byCenter = new Map(CENTERS.map((c) => [c.key, []]));
const unmatched = [];
const excluded = [];
for (const row of rows) {
const name = String(row.name || '');
// 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」
if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) {
excluded.push({ wmsCoId: Number(row.wms_co_id), name });
continue;
}
const center = resolveCenter(name);
if (center && byCenter.has(center.key)) {
byCenter.get(center.key).push({
wmsCoId: Number(row.wms_co_id),
warehouseId: Number(row.Id),
name,
});
} else {
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
}
}
const list = CENTERS.map((c) => ({
key: c.key,
name: c.name,
placeholder: Boolean(c.placeholder),
warehouses: byCenter.get(c.key) || [],
}));
warehouseCache = {
at: Date.now(),
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,
excluded,
};
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() {
warehouseCache = { at: 0, rows: [] };
}
/**
* 计算「当前已生效」的核验轮次。
*
* 13:00 之前 → **未到核验时间,返回 null**。
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
*
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
* 这是一个滚动累积的待发池,不按自然日切。
*
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
* @returns {object|null} null 表示当前不在任何核验时段内
*/
function resolveActiveRound(now = beijingNow()) {
const hour = now.getHours();
const minute = now.getMinutes();
const minutes = hour * 60 + minute;
let active = null;
for (const round of ROUNDS) {
if (minutes >= round.verifyHour * 60) active = round;
}
// 13:00 前:首轮核验还没开始,不做判定
if (!active) return null;
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
// 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS)
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
return {
...active,
// deadline = 核验时刻:判「发了没发 / 超时多久」都用它
deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`,
windowStart: floorStr,
// windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
// 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」
includeHistory: true,
date: `${y}-${m}-${d}`,
settled: false,
isPreviousDay: false,
};
}
/**
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
*/
function describeRounds(now = beijingNow()) {
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
const date = `${y}-${m}-${d}`;
const minutes = now.getHours() * 60 + now.getMinutes();
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
return ROUNDS.map((r) => ({
key: r.key,
label: r.label,
date,
verifyHour: r.verifyHour,
deadline: `${date} ${pad2(r.verifyHour)}:00:00`,
windowStart: floorStr,
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
includeHistory: true,
active: minutes >= r.verifyHour * 60,
}));
}
/**
* 下一轮的核验时刻。
*
* ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的,
* 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮,
* 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述
* 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。
*
* @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key)
* @returns {string} 'YYYY-MM-DD HH:mm:00'
*/
function nextRoundDeadline(round) {
const idx = ROUNDS.findIndex((r) => r.key === round.key);
if (idx >= 0 && idx + 1 < ROUNDS.length) {
return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`;
}
// 当天最后一轮 → 次日首轮
const [y, m, d] = String(round.date).split('-').map(Number);
const next = new Date(y, (m || 1) - 1, (d || 1) + 1);
return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}`
+ ` ${pad2(ROUNDS[0].verifyHour)}:00:00`;
}
module.exports = {
CENTERS,
ROUNDS,
HISTORY_LOOKBACK_DAYS,
EXCLUDED_WAREHOUSE_KEYWORDS,
resolveCenter,
centerWindowEndHour,
centerWindowEnd,
nextRoundDeadline,
loadCenterWarehouseMap,
loadWarehouseNameMap,
clearWarehouseCache,
getWarehouseCache: () => warehouseCache,
resolveActiveRound,
describeRounds,
beijingNow,
BEIJING_OFFSET_MINUTES,
EXCLUDED_STATUS_SQL,
SHIPPED_STATUS,
PENDING_STATUS_SQL,
NOT_SPLIT_MERGED_SQL,
OUTBOUND_TABLES,
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,
UNAUDITED_STATUS,
ABNORMAL_STATUS,
IGNORED_STATES,
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
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,
toNumber,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,126 @@
/**
* 配置负缓存 TTL 回归测试。
*
* 背景(线上真实故障):
* 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来
* (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy),
* 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。
*
* 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。
* 本文件锁定三条语义:
* ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时)
* ② TTL 到期后必须重试
* ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰
*/
const test = require('node:test');
const assert = require('node:assert/strict');
// ── 必须在加载被测模块之前完成环境与替身准备 ─────────────────────────────
// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置)
for (const key of Object.keys(process.env)) {
if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) {
delete process.env[key];
}
}
// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉
process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150';
// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool
const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise');
const realMysqlPromise = require(MYSQL_PROMISE_PATH);
let systemDbDialAttempts = 0;
let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复
const systemConfigRow = {
name: 'cloud_database',
host: '127.0.0.1',
port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库
database_name: 'JSReportReplica',
username: 'probe',
password: 'probe',
ssl: 0,
driver: 'mssql',
use_env_config: 0,
is_default: 1,
};
require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, {
createPool: () => {
systemDbDialAttempts += 1;
return {
query: async () => {
if (systemDbMode === 'down') {
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), {
code: 'ECONNREFUSED',
});
}
return [[systemConfigRow]];
},
end: async () => {},
};
},
});
const cloudDatabase = require('../src/config/cloudDatabase');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const NOT_CONFIGURED = /业务数据库尚未配置/u;
test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => {
await cloudDatabase.close();
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置');
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析');
await sleep(220);
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(
systemDbDialAttempts,
base + 2,
'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键'
);
});
test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => {
await sleep(220); // 越过上一轮的负缓存 TTL
systemDbMode = 'recovered';
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
(error) => {
// 关键断言:不应再是「尚未配置」——说明配置已成功读回,
// 失败只是发生在后面连业务库那一层(这里故意指向空端口)。
assert.doesNotMatch(
error.message,
NOT_CONFIGURED,
'配置读取应已恢复,不该再报「业务数据库尚未配置」'
);
return true;
}
);
assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置');
// 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库
const afterRecovery = systemDbDialAttempts;
await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'));
assert.equal(
systemDbDialAttempts,
afterRecovery,
'成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)'
);
});

View File

@ -1,255 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
QWEN_PROVIDER,
AGNES_PROVIDER,
DEFAULT_QWEN_BASE_URL,
DEFAULT_QWEN_MODEL,
DEFAULT_AGNES_BASE_URL,
DEFAULT_AGNES_MODEL,
_test: {
resolveAiConfig,
buildAiRequestPayload,
parseModelJson,
mapChatDataMeta,
aggregateChatDimensionRows,
createChatBatches,
normalizeAnalysis,
mergeBatchAnalysesLocally,
reconcileMergedAnalysis,
sumUsages,
getAiErrorDetail,
isRetryableAiError,
getAiRetryDelayMs,
sanitizePublicError,
},
} = require('../src/services/csQualityService');
test('chat data metadata exposes the actual available date range', () => {
assert.deepEqual(mapChatDataMeta({
minDate: '2026-06-22',
latestDate: '2026-07-31 23:59:59',
messageCount: '792771',
}), {
tableName: 'rpt.pdd_GoodsChatMessages',
minDate: '2026-06-22',
latestDate: '2026-07-31',
messageCount: 792771,
});
});
function message(uid, content, index = 1) {
return {
uid,
nickname: `买家-${uid}`,
msgId: `${uid}-${index}`,
msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`,
directionLabel: '买家',
csname: '客服A',
content,
};
}
function result(overrides = {}) {
return {
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 1,
affected_customer_count: 1,
affected_conversation_ids: ['u1'],
buyer_intent: '咨询尺寸',
evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }],
image_urls: [],
product_improvement_areas: ['详情页'],
improvement_suggestion: '补充尺寸图',
confidence: 0.9,
...overrides,
};
}
test('customer-service quality defaults to the verified Qwen model and endpoint', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
assert.equal(QWEN_PROVIDER, 'aliyun-model-studio');
assert.equal(config.provider, QWEN_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL);
assert.equal(config.model, DEFAULT_QWEN_MODEL);
assert.equal(config.batchConcurrency, 2);
assert.equal(config.batchMaxChars, 45000);
assert.equal(config.mergeMaxBatches, 8);
assert.equal(config.requestRetryCount, 2);
assert.equal(config.retryBaseDelayMs, 1500);
});
test('customer-service quality resolves the Agnes China endpoint and request format', () => {
const config = resolveAiConfig({
provider: AGNES_PROVIDER,
apiKey: 'test-key',
});
assert.equal(config.provider, AGNES_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL);
assert.equal(config.model, DEFAULT_AGNES_MODEL);
assert.equal(config.timeoutMs, 600000);
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.model, DEFAULT_AGNES_MODEL);
assert.equal(payload.max_tokens, 2048);
assert.equal(payload.temperature, 0);
assert.equal(payload.stream, false);
assert.equal('max_completion_tokens' in payload, false);
assert.equal('response_format' in payload, false);
});
test('Qwen requests retain the provider-specific JSON parameters', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.max_completion_tokens, 2048);
assert.deepEqual(payload.response_format, { type: 'json_object' });
assert.equal(payload.enable_thinking, false);
assert.equal('max_tokens' in payload, false);
});
test('chat batching keeps every customer conversation intact', () => {
const rows = [
message('u1', '甲'.repeat(700), 1),
message('u1', '乙'.repeat(700), 2),
message('u2', '丙'.repeat(700), 1),
message('u3', '丁'.repeat(700), 1),
];
const batches = createChatBatches(rows, 1300);
assert.equal(batches.length, 3);
assert.deepEqual(batches[0].conversationIds, ['u1']);
assert.equal(batches[0].messageCount, 2);
assert.ok(batches.every((batch) => !(
batch.conversationIds.includes('u1') && batch.conversationIds.length > 1
)));
assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']);
assert.equal(batches[0].startDate, '2026-07-30');
assert.equal(batches[0].endDate, '2026-07-30');
});
test('chat dimension snapshot derives goods and customers in one aggregation', () => {
const snapshot = aggregateChatDimensionRows([
{
goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z',
},
{
goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z',
},
{
goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z',
},
{
goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2',
nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z',
},
]);
assert.equal(snapshot.goodsRows.length, 2);
assert.equal(snapshot.goodsRows[0].goodsId, 'g1');
assert.equal(snapshot.goodsRows[0].messageCount, 6);
assert.equal(snapshot.goodsRows[0].userCount, 2);
assert.equal(snapshot.goodsRows[0].csCount, 2);
assert.equal(snapshot.customersByGoods.g1.length, 2);
assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1');
assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5);
assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B');
assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z');
assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z');
});
test('local merge sums disjoint batch counts and deduplicates evidence', () => {
const analyses = [
{
summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result()],
},
{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result({
occurrence_count: 3,
affected_customer_count: 2,
affected_conversation_ids: ['u1', 'u2'],
evidence: [
{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' },
{ conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' },
],
})],
},
];
const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 });
assert.equal(merged.results.length, 1);
assert.equal(merged.results[0].occurrence_count, 4);
assert.equal(merged.results[0].affected_customer_count, 3);
assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']);
assert.equal(merged.results[0].evidence.length, 2);
assert.equal(merged.summary.conversation_count, 3);
assert.equal(merged.summary.pre_sale_conversation_count, 3);
assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']);
});
test('model merge cannot change deterministic counts or drop a label group', () => {
const local = mergeBatchAnalysesLocally([{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] },
results: [result({ occurrence_count: 4, affected_customer_count: 2 })],
}], { customerCount: 2, messageCount: 8 });
const model = {
schema_version: '1.0',
conversation_type: '售前',
summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] },
results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })],
};
const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 });
assert.equal(reconciled.results[0].occurrence_count, 4);
assert.equal(reconciled.results[0].affected_customer_count, 2);
assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求');
assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']);
assert.equal(reconciled.summary.conversation_count, 2);
assert.equal(reconciled.summary.pre_sale_conversation_count, 2);
});
test('analysis normalization keeps every valid affected conversation id', () => {
const normalized = normalizeAnalysis({
summary: {},
results: [{
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 3,
affected_customer_count: 3,
affected_conversation_ids: ['u1', 'u2', 'not-in-batch'],
evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }],
}],
}, { customerCount: 3, messageCount: 10 }, {
allowedConversationIds: ['u1', 'u2', 'u3'],
});
assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']);
assert.equal(normalized.results[0].affected_customer_count, 3);
});
test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => {
assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true });
assert.deepEqual(sumUsages([
{ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
{ input_tokens: 7, output_tokens: 2 },
]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 });
assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited');
assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed');
assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true);
assert.equal(isRetryableAiError({ response: { status: 429 } }), true);
assert.equal(isRetryableAiError({ response: { status: 401 } }), false);
assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500);
assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000);
assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000);
assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务');
assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed');
});

View File

@ -0,0 +1,110 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
AVAILABLE_FLOW_FIELDS,
buildInClause,
fetchFlows,
} = require('../src/services/dailyInventoryFlows');
const { deriveAndStoreOpeningSnapshot } = require('../src/services/dailyInventoryOpening');
const BASELINE = [
{ warehouse: '仓A主仓', dbWarehouse: '仓A', wmsCoId: 101 },
{ warehouse: '仓B主仓', dbWarehouse: '仓B', wmsCoId: 102 },
{ warehouse: '仓C主仓', dbWarehouse: null, wmsCoId: 103 },
];
/** 构造一个按 SQL 关键字路由的假业务库 */
function fakeCloudDb(handler) {
return { query: async (sql, params) => handler(String(sql), params || {}) };
}
test('buildInClause 展开参数且不为 async', () => {
const { clause, params } = buildInClause([1, 2], 'w');
assert.equal(clause, '@w0,@w1');
assert.deepEqual(params, { w0: 1, w1: 2 });
});
test('fetchFlows 查询四类单据并按符号约定返回(流入正、流出负)', async () => {
const seen = [];
const db = fakeCloudDb((sql, params) => {
seen.push(sql);
if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 101, qty: '100' }];
if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 101, qty: '30' }];
if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 101, qty: '50' }];
if (sql.includes('jst_returnAndRefundItem')) return [{ wmsCoId: 101, qty: '8' }];
return [];
});
const map = await fetchFlows(db, [101, 102], '2026-09-26 01:00:00');
const f = map.get('101');
assert.equal(f.purchaseIn, 100, '采购进仓为流入正数');
assert.equal(f.purchaseReturn, -30, '采购退货为流出负数');
assert.equal(f.salesOut, -50, '销售出仓为流出负数');
assert.equal(f.salesReturn, 8, '销售退货为流入正数');
assert.equal(seen.length, 4, '应发出 4 条单据查询');
assert.ok(seen.every((s) => s.includes('@from')), '每条查询都带 @from 参数');
assert.deepEqual(AVAILABLE_FLOW_FIELDS.sort(), ['purchaseIn', 'purchaseReturn', 'salesOut', 'salesReturn']);
});
test('fetchFlows 空仓库列表直接返回空 Map', async () => {
const map = await fetchFlows(fakeCloudDb(() => []), [], '2026-09-26 01:00:00');
assert.equal(map.size, 0);
});
test('deriveAndStoreOpeningSnapshot:全部仓库用实时库存扣流水回推,并保留基线仓库名', async () => {
const upserts = [];
const cloudDb = fakeCloudDb((sql, params) => {
if (sql.includes('jst_inventoryInfo')) {
return [
{ wmsCoId: 101, qty: '520', skuCount: '13' },
{ wmsCoId: 102, qty: '300', skuCount: '9' },
{ wmsCoId: 103, qty: '10', skuCount: '2' },
];
}
if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 102, qty: '60' }];
if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 102, qty: '10' }];
if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 102, qty: '40' }];
if (sql.includes('jst_returnAndRefundItem')) return [];
return [];
});
const snapshotDb = {
async upsertSnapshot(bizDate, rows) {
upserts.push({ bizDate, rows });
return rows.length;
},
};
const result = await deriveAndStoreOpeningSnapshot({
cloudDb,
snapshotDb,
baseline: BASELINE,
openingDate: '2026-09-25',
latestBizDate: '2026-09-26',
takenAt: null,
});
assert.equal(result.count, 3);
assert.equal(result.derivedCount, 3);
assert.equal(upserts.length, 1);
assert.equal(upserts[0].bizDate, '2026-09-25');
const byWh = new Map(upserts[0].rows.map((r) => [r.wmsCoId, r]));
// 仓A:无流水 → 等于实时库存 520
assert.equal(byWh.get(101).closingQty, 520);
// 仓B:300 − 净流入(60 − 10 − 40) = 300 − 10 = 290
assert.equal(byWh.get(102).closingQty, 290);
// 仓C:无流水 → 等于实时库存 10
assert.equal(byWh.get(103).closingQty, 10);
// 带回基线仓库名,保证后续按 wmsCoId 读取正确
assert.equal(byWh.get(102).warehouse, '仓B主仓');
assert.equal(byWh.get(102).dbWarehouse, '仓B');
});
test('fetchFlows 缺少数据库实例参数时立即抛错(防静默空结果)', async () => {
await assert.rejects(
() => fetchFlows([101, 102], '2026-09-26 01:00:00'),
/第一个参数必须是数据库实例/
);
});

View File

@ -0,0 +1,333 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
DEFAULT_NOTIFY_TIME,
MAX_RECIPIENTS,
buildIdempotencyKey,
normalizeFlag,
normalizeNotifyTime,
normalizeRecipients,
} = require('../src/services/dailyInventoryNotificationSettings');
const {
ACTION_TITLE,
NOTIFICATION_TITLE,
buildNotificationContent,
minutesOfDay,
resolveBizDate,
withRetry,
} = require('../src/jobs/dailyInventoryNotificationJob');
const {
buildScreenshotNames,
chinaTimeParts,
} = require('../src/services/dailyInventoryScreenshot');
// ── 收件人名单规范化 ────────────────────────────────────────────
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']);
});
test('normalizeRecipients 去重且忽略首尾空格', () => {
assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']);
});
test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => {
assert.deepEqual(
normalizeRecipients('张三,李四,王五;赵六\n孙七'),
['张三', '李四', '王五', '赵六', '孙七']
);
});
test('normalizeRecipients 对非法输入返回空数组', () => {
assert.deepEqual(normalizeRecipients(null), []);
assert.deepEqual(normalizeRecipients(undefined), []);
assert.deepEqual(normalizeRecipients(12345), []);
});
test('normalizeRecipients 超量时截断到上限', () => {
const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`);
const result = normalizeRecipients(list);
assert.equal(result.length, MAX_RECIPIENTS);
assert.equal(result[0], '用户0');
});
// ── 通知时间校验 ────────────────────────────────────────────────
test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => {
assert.equal(normalizeNotifyTime('1:05'), '01:05');
assert.equal(normalizeNotifyTime('23:59'), '23:59');
assert.equal(normalizeNotifyTime('00:00'), '00:00');
});
test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => {
assert.equal(normalizeNotifyTime('01:00:30'), '01:00');
});
test('normalizeNotifyTime 非法值回退默认 01:00', () => {
assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME);
});
test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => {
assert.equal(normalizeFlag('true', false), true);
assert.equal(normalizeFlag('off', true), false);
assert.equal(normalizeFlag('maybe', true), true);
assert.equal(normalizeFlag(null, false), false);
});
// ── 幂等键 ──────────────────────────────────────────────────────
test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => {
const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' });
assert.equal(a, b);
assert.notEqual(a, c);
});
test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => {
const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' });
const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' });
assert.notEqual(t1, t2);
assert.notEqual(t1, formal);
});
// ── 通知正文 ────────────────────────────────────────────────────
test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => {
const content = buildNotificationContent({
bizDate: '2026-09-25',
takenAt: '2026/09/25 01:00:00',
imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png',
pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory',
});
assert.match(content, /2026-09-25/);
assert.match(content, /2026\/09\/25 01:00:00/);
assert.match(content, /https:\/\/cdn\.example\.com\//);
assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/);
assert.match(content, new RegExp(ACTION_TITLE));
assert.equal(NOTIFICATION_TITLE, '每日进销存数据');
});
// ── 业务日期与调度时刻 ──────────────────────────────────────────
test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => {
assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24');
assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24');
});
test('minutesOfDay 正确换算,非法值返回 null', () => {
assert.equal(minutesOfDay('00:00'), 0);
assert.equal(minutesOfDay('01:00'), 60);
assert.equal(minutesOfDay('23:59'), 1439);
assert.equal(minutesOfDay('24:00'), null);
assert.equal(minutesOfDay('abc'), null);
assert.equal(minutesOfDay(null), null);
});
test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00'));
assert.equal(parts.bizDate, '2026-09-25');
assert.equal(parts.dateKey, '20260925');
assert.equal(parts.timeKey, '010000');
assert.equal(
`daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`,
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
);
});
test('buildScreenshotNames 用业务日期归档目录与文件名,时间取执行时刻', () => {
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
assert.equal(names.fileName, 'daily-inventory-20260925-010000.png');
assert.equal(
names.objectKey,
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
);
});
test('buildScreenshotNames 业务日期与执行日期不同时以业务日期为准', () => {
// 凌晨 01:00 推送的是「昨日」数据,归档必须落在业务日而不是执行日
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
assert.ok(!names.objectKey.includes('2026-09-26'));
assert.ok(names.objectKey.startsWith('daily-inventory/2026-09-25/'));
});
test('buildScreenshotNames 业务日期缺失时回退空目录占位而不抛错', () => {
const names = buildScreenshotNames({ bizDate: '', timeKey: '010000' });
assert.equal(names.fileName, 'daily-inventory--010000.png');
});
// ── 幂等记录:同一业务日期 + 同一用户只成功通知一次 ────────────
async function withStubbedState(runner) {
const database = require('../src/config/database');
const settings = require('../src/services/dailyInventoryNotificationSettings');
const original = database.local;
const store = new Map();
database.local = {
async query(sql, params) {
const text = String(sql);
if (text.startsWith('SELECT config_value')) {
const value = store.get(params[0]);
return value === undefined ? [] : [{ configValue: value }];
}
if (text.startsWith('SELECT id FROM system_config')) {
const value = store.get(params[0]);
return value === undefined ? [] : [{ id: 1 }];
}
// UPDATE system_config SET config_value = ? ...
store.set(params[params.length - 1], params[0]);
return { affectedRows: 1 };
},
async insert(_table, data) {
store.set(data.config_key, data.config_value);
return { insertId: 1 };
},
};
try {
return await runner(settings);
} finally {
database.local = original;
}
}
test('markNotified 后同一业务日期同一用户命中幂等,跨日期互不影响', async () => {
await withStubbedState(async (settings) => {
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), false);
await settings.markNotified('2026-09-25', '苏适', { deliveryId: 'd-1' });
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), true);
// 同一天其他用户不受影响
assert.equal(await settings.hasNotified('2026-09-25', '李四'), false);
// 同一用户换一天应当重新通知
assert.equal(await settings.hasNotified('2026-09-26', '苏适'), false);
});
});
test('appendRunRecord 保留执行时间、截图地址、通知对象与失败原因', async () => {
await withStubbedState(async (settings) => {
await settings.appendRunRecord({
runAt: '2026-09-25T01:00:00.000Z',
bizDate: '2026-09-25',
mode: 'scheduled',
success: false,
error: '所有收件人均通知失败',
screenshotUrl: 'https://scqny.jiansun.vip/daily-inventory/2026-09-25/x.png',
recipients: [{ recipient: '苏适', status: 'failed', error: '通知中心 502' }],
});
const runs = await settings.listRunRecords(5);
assert.equal(runs.length, 1);
assert.equal(runs[0].bizDate, '2026-09-25');
assert.equal(runs[0].success, false);
assert.equal(runs[0].error, '所有收件人均通知失败');
assert.equal(runs[0].recipients[0].recipient, '苏适');
assert.ok(runs[0].screenshotUrl.startsWith('https://'));
});
});
// ── 重试与退避 ──────────────────────────────────────────────────
test('withRetry 第二次成功后返回结果', async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls += 1;
if (calls < 2) throw new Error('临时失败');
return 'ok';
},
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
);
assert.equal(result, 'ok');
assert.equal(calls, 2);
});
test('withRetry 超过次数后抛出最后一次错误', async () => {
let calls = 0;
await assert.rejects(
withRetry(
async () => {
calls += 1;
throw new Error(`失败${calls}`);
},
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
),
/失败3/
);
assert.equal(calls, 3);
});
// ── 截图身份:必须使用系统库中真实存在的服务账号 ──────────────
test('ensureScreenshotServiceUser 账号不存在时创建,存在时复用 id', async () => {
const database = require('../src/config/database');
const screenshot = require('../src/services/dailyInventoryScreenshot');
const original = database.local;
const calls = { select: 0, insert: 0, update: 0 };
let existing = null;
database.local = {
async query(sql, params) {
if (String(sql).startsWith('SELECT id, role, status FROM users')) {
calls.select += 1;
return existing ? [existing] : [];
}
calls.update += 1;
return { affectedRows: 1 };
},
async insert(_table, data) {
calls.insert += 1;
assert.equal(data.role, 'admin');
assert.equal(data.status, 'active');
assert.equal(data.auth_source, 'service');
assert.ok(String(data.password_hash || '').length > 20, '密码哈希不能为空');
existing = { id: 4242, role: data.role, status: data.status };
return { insertId: 4242 };
},
};
try {
screenshot.resetScreenshotServiceUserCache();
const created = await screenshot.ensureScreenshotServiceUser();
assert.equal(created, 4242);
assert.equal(calls.insert, 1);
assert.equal(calls.select, 1);
// 第二次调用命中缓存,不再查库
assert.equal(await screenshot.ensureScreenshotServiceUser(), 4242);
assert.equal(calls.select, 1);
} finally {
database.local = original;
screenshot.resetScreenshotServiceUserCache();
}
});
test('signScreenshotToken 的 sub 指向服务账号 id 且通过签名校验', async () => {
const database = require('../src/config/database');
const screenshot = require('../src/services/dailyInventoryScreenshot');
const jwt = require('jsonwebtoken');
const original = database.local;
const originalSecret = process.env.CUBEJS_SECRET;
process.env.CUBEJS_SECRET = originalSecret || 'test-secret-for-unit';
database.local = {
async query() {
return [{ id: 99, role: 'admin', status: 'active' }];
},
async insert() {
throw new Error('不应在账号已存在时插入');
},
};
try {
screenshot.resetScreenshotServiceUserCache();
const token = await screenshot.signScreenshotToken();
const payload = jwt.verify(token, process.env.CUBEJS_SECRET || 'test-secret');
assert.equal(payload.sub, '99');
assert.equal(payload.role, 'admin');
assert.equal(payload.authSource, 'service');
} finally {
database.local = original;
process.env.CUBEJS_SECRET = originalSecret;
screenshot.resetScreenshotServiceUserCache();
}
});

View File

@ -0,0 +1,101 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const XLSX = require('xlsx');
const {
normalizeRecords,
parseWorkbook,
checkBalance,
} = require('../src/services/dailyInventoryOfficial');
const RECORDS = [
{
一级功能按键: '大货库存监控',
二级功能按键: '每日进销存',
仓库: '简尚-崔桥工厂仓主仓',
期初数量: 90811,
加工进仓: 0,
采购退货: 0,
采购进仓: 0,
其它进仓: 0,
其它退货: 0,
销售出仓: -3252,
盘点: 0,
加工出仓: 0,
调拨出: -881,
销售退货: 0,
调拨入: 4144,
期初: 0,
其它出库: 0,
期末数量: 90822,
},
{
仓库: '简尚-新外贸仓主仓',
期初数量: 5165,
销售出仓: -1743,
期末数量: 3422,
},
];
test('normalizeRecords 解析官方导出并映射仓库 ID', () => {
const { rows, warnings } = normalizeRecords(RECORDS);
assert.equal(rows.length, 2);
assert.deepEqual(warnings, []);
const [cuiqiao] = rows;
assert.equal(cuiqiao.warehouse, '简尚-崔桥工厂仓主仓');
assert.equal(cuiqiao.wmsCoId, 15038426); // 来自项目仓库清单
assert.equal(cuiqiao.openingQty, 90811);
assert.equal(cuiqiao.closingQty, 90822);
assert.equal(cuiqiao.flows.salesOut, -3252);
assert.equal(cuiqiao.flows.transferIn, 4144);
assert.equal(cuiqiao.flows.otherIn, 0); // 导出里显式为 0 的列保留 0
// 导出里没有出现的流水列保持 null,不能被当成 0
assert.equal(rows[1].flows.transferOut, null);
assert.equal(rows[1].flows.stocktaking, null);
// 导出里的第二个「期初」列不参与映射
assert.equal(Object.prototype.hasOwnProperty.call(cuiqiao.flows, 'openingQty'), false);
});
test('normalizeRecords 跳过无仓库名的行,未匹配仓库的 wmsCoId 为 null', () => {
const { rows } = normalizeRecords([
{ 仓库: '', 期初数量: 1, 期末数量: 1 },
{ 仓库: '某某新仓主仓', 期初数量: 10, 期末数量: 10 },
]);
assert.equal(rows.length, 1);
assert.equal(rows[0].warehouse, '某某新仓主仓');
assert.equal(rows[0].wmsCoId, null);
});
test('checkBalance 校验「期初 + 流水 = 期末」,不闭合的仓库会被列出', () => {
const { rows } = normalizeRecords(RECORDS);
assert.deepEqual(checkBalance(rows), []);
const broken = checkBalance([{
warehouse: '差异仓', openingQty: 100, closingQty: 90, flows: { salesOut: -5 },
}]);
assert.equal(broken.length, 1);
assert.equal(broken[0].diff, 5);
});
test('parseWorkbook 能从 xlsx 缓冲区还原仓库行(表头不在首行也能定位)', () => {
const grid = [
['说明', '这一行是导出时附加的标题'],
['一级功能按键', '二级功能按键', '仓库', '期初数量', '销售出仓', '调拨入', '期末数量'],
['大货库存监控', '每日进销存', '简尚-崔桥工厂仓主仓', 90811, -3252, 4144, 90822],
['', '', '简尚-新外贸仓主仓', 5165, -1743, 0, 3422],
];
const ws = XLSX.utils.aoa_to_sheet(grid);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Sheet2');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
const { rows, sheetName } = parseWorkbook(buffer);
assert.equal(sheetName, 'Sheet2');
assert.equal(rows.length, 2);
assert.equal(rows[0].warehouse, '简尚-崔桥工厂仓主仓');
assert.equal(rows[0].flows.transferIn, 4144);
assert.equal(rows[1].closingQty, 3422);
});

View File

@ -1,60 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
compareVersions,
normalizeArchitecture,
normalizePlatform,
parseReleaseStorage,
validateVersion,
} = require('../src/services/desktopReleaseService');
test('desktop release versions are compared numerically', () => {
assert.equal(compareVersions('0.0.10', '0.0.9'), 1);
assert.equal(compareVersions('v1.2.3', '1.2.3'), 0);
assert.equal(compareVersions('2.0.0', '10.0.0'), -1);
});
test('desktop release versions require an x.y.z value', () => {
assert.equal(validateVersion('v1.2.3'), '1.2.3');
assert.throws(() => validateVersion('1.2'), /版本号格式/);
});
test('desktop release storage drops invalid records without losing the latest version', () => {
const storage = parseReleaseStorage(JSON.stringify({
latestVersion: '0.0.9',
releases: [
{ id: 9, version: '0.0.9', objectKey: 'desktop-client/0.0.9/setup.exe' },
{ id: 8, version: 'invalid', objectKey: 'desktop-client/invalid/setup.exe' },
],
}));
assert.equal(storage.latestVersion, '0.0.9');
assert.equal(storage.releases.length, 1);
assert.equal(storage.releases[0].version, '0.0.9');
assert.equal(storage.releases[0].platform, 'win32');
assert.equal(storage.releases[0].architecture, 'x64');
assert.equal(storage.latestVersions.win32, '0.0.9');
});
test('desktop release platform and architecture are inferred from installers', () => {
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-arm64.dmg'), 'darwin');
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-setup.exe'), 'win32');
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-arm64.dmg', 'darwin'), 'arm64');
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-setup.exe', 'win32'), 'x64');
});
test('desktop release storage keeps Windows and macOS installers for the same version', () => {
const storage = parseReleaseStorage({
latestVersions: { win32: '0.0.10', darwin: '0.0.10' },
releases: [
{ id: 10, version: '0.0.10', objectKey: 'desktop-client/win32/0.0.10/setup.exe', fileName: 'pdd-auto-sale-0.0.10-setup.exe' },
{ id: 11, version: '0.0.10', objectKey: 'desktop-client/darwin/0.0.10/app.dmg', fileName: 'pdd-auto-sale-0.0.10-arm64.dmg' },
],
});
assert.equal(storage.releases.length, 2);
assert.deepEqual(storage.releases.map((release) => release.platform), ['win32', 'darwin']);
assert.equal(storage.latestVersions.win32, '0.0.10');
assert.equal(storage.latestVersions.darwin, '0.0.10');
});

View File

@ -95,7 +95,6 @@ test('desktop capabilities follow resolved page permissions', () => {
marketingEvents: false,
promotion: false,
productTest: false,
reviewAnalysis: false,
});
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
});

View File

@ -5,7 +5,6 @@ const assert = require('node:assert/strict');
const jwt = require('jsonwebtoken');
const presaleRouter = require('../src/api/presaleExternal');
const autoListingRouter = require('../src/api/planningSelectionFissionExternal');
const pricingRouter = require('../src/api/pricingCalculatorExternal');
const alibaba1688Router = require('../src/api/alibaba1688ProductExternal');
const {
requireDesktopPageAccess,
@ -38,40 +37,6 @@ test('presale and auto-listing external APIs use the same request key middleware
assert.equal(getRouteMiddleware(alibaba1688Router, '/:productId'), requireExternalAutomationApiKey);
});
test('desktop pricing uses the shared request key middleware', () => {
const middlewareLayer = pricingRouter.stack.find((item) => item.name === 'requireExternalAutomationApiKey');
assert.equal(middlewareLayer?.handle, requireExternalAutomationApiKey);
});
test('desktop pricing identity is stable, isolated and uses negative safe integers', () => {
const first = pricingRouter.desktopPricingUserId('香茗');
assert.equal(first, pricingRouter.desktopPricingUserId(' 香茗 '));
assert.notEqual(first, pricingRouter.desktopPricingUserId('江陵'));
assert.equal(Number.isSafeInteger(first), true);
assert.equal(first < 0, true);
});
test('desktop pricing accepts a UTF-8 base64url nickname header', () => {
const nickname = '胖大海';
const req = {
get(name) {
return name.toLowerCase() === 'x-operator-name-encoded'
? Buffer.from(nickname, 'utf8').toString('base64url')
: '';
},
query: {},
body: {},
};
let nextCalled = false;
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.user.name, nickname);
assert.equal(req.user.id, req.user.sub);
assert.ok(req.user.id < 0);
});
test('shared external auth accepts a valid desktop JWT without an API key', async () => {
const previousSecret = process.env.CUBEJS_SECRET;
process.env.CUBEJS_SECRET = 'desktop-session-test-secret';
@ -178,20 +143,3 @@ test('desktop page access enforces JWT permissions and preserves API key compati
assert.equal(nextCalled, true);
}
});
test('desktop pricing keeps the verified JWT user identity', () => {
const req = {
externalApiKey: { authType: 'jwt' },
user: { sub: '42', id: 42, name: '陆南亭' },
get() { return ''; },
query: {},
body: {},
};
let nextCalled = false;
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.user.id, 42);
assert.equal(req.user.name, '陆南亭');
});

View File

@ -0,0 +1,13 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { _private } = require('../src/api/mkhBom');
test('manual MKH sync always forces a live operational refresh', () => {
assert.deepEqual(
_private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }),
{ page: 2, fresh: '1', live: '1' }
);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,194 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
chinaDateKey,
diffAlertState,
formatQty,
} = require('../src/jobs/mkhReplenishAlertJob');
const alert = (skuId, level = '缺货', extra = {}) => ({
skuId,
warningLevel: level,
productName: `商品 ${skuId}`,
dailySale: 1,
stock: 0,
replenishQty: 3,
...extra,
});
test('diffAlertState splits fresh / persisted / restored', () => {
const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } };
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state);
assert.deepEqual(fresh.map((r) => r.skuId), ['C']);
assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']);
// Z 已补货恢复,应从已推送记录里剔除
assert.deepEqual(restored, ['Z']);
});
test('diffAlertState treats empty state as all fresh', () => {
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {});
assert.equal(fresh.length, 2);
assert.equal(persisted.length, 0);
assert.deepEqual(restored, []);
});
test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z');
assert.deepEqual(Object.keys(next).sort(), ['A', 'B']);
assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z');
assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z');
assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z');
});
test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false });
assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z');
});
test('buildAlertSignature is order independent but content sensitive', () => {
const a = alert('A');
const b = alert('B', '预警');
assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a]));
assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a]));
// 等级变化也算变化(缺货 → 预警 应产生新指纹)
assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')]));
});
test('chinaDateKey renders Beijing date regardless of UTC offset', () => {
// 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17
assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917');
assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916');
});
test('formatQty trims trailing zeros and tolerates bad input', () => {
assert.equal(formatQty(3), '3');
assert.equal(formatQty(2.9700000001), '2.97');
assert.equal(formatQty(0.28), '0.28');
assert.equal(formatQty(null), '—');
assert.equal(formatQty('abc'), '—');
});
test('notification carries recipient, idempotency key and shortfall details', () => {
const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })];
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
const snapshot = {
monitorDate: '2026-09-16',
saleWindowDays: 15,
replenishDaysThreshold: 3,
outOfStockCount: 1,
warningOnlyCount: 1,
replenishQtyTotal: 14,
};
const payload = buildMkhReplenishNotification({
alerts: fresh,
allAlerts: all,
snapshot,
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.equal(payload.recipientAlias, '苏适');
assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`);
assert.ok(payload.title.includes('新增 1 个'));
assert.ok(payload.title.includes('含 1 个已缺货'));
assert.ok(payload.content.includes('MKHDNZ0409260700428'));
assert.ok(payload.content.includes('现有库存 0'));
assert.ok(payload.content.includes('建议补货 9'));
// 汇总用全量口径,不是本批新增
assert.ok(payload.content.includes('当前共 **2** 个'));
assert.equal(payload.actionTitle, '查看补货清单');
assert.ok(payload.content.length <= 4800);
});
test('notification truncates long lists but keeps the totals', () => {
const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`));
const payload = buildMkhReplenishNotification({
alerts: many,
allAlerts: many,
snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('仅列出前 20 个'));
assert.ok(payload.content.includes('当前共 **40** 个'));
assert.equal(payload.content.includes('MKH0039'), false);
assert.ok(payload.content.length <= 4800);
});
test('notification flags degraded work-order source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('加工单数据源暂不可用'));
});
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
test('notification spells out 配件不足 and drops the replenish target', () => {
const shortage = alert('MKHDNZ0409260600843', '缺货', {
dailySale: 0,
stock: 0,
replenishQty: 1,
componentShortage: true,
assemblableQty: 0,
bottleneckComponents: [
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
],
});
const payload = buildMkhReplenishNotification({
alerts: [shortage],
allAlerts: [shortage],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 1,
componentShortageCount: 1,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件不足,无法补货'));
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
assert.ok(payload.content.includes('建议补货 —'));
assert.equal(payload.content.includes('建议补货 1'), false);
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
assert.ok(payload.content.includes('DNZ040958N1440V01'));
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
// 汇总段说明本批里有几个配件不足
assert.ok(payload.content.includes('因**配件库存不足**'));
});
test('notification flags degraded component stock source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 3,
componentStockDegraded: true,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
assert.ok(payload.content.includes('未做'));
});

View File

@ -0,0 +1,171 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchPickingBinSkuRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
test('buildInClause 按位展开参数', () => {
const { clause, params } = buildInClause([101, 102], 'w');
assert.equal(clause, '@w0,@w1');
assert.deepEqual(params, { w0: 101, w1: 102 });
});
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
const expr = buildGradeCase();
PICKING_BIN_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
});
assert.ok(expr.includes("THEN 'slow2'"));
assert.ok(expr.includes("ELSE 'zero'"));
});
test('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位汇总列)', () => {
const expr = buildGradeCase();
assert.ok(expr.includes('ISNULL(s.salesQty, 0) / @days'));
assert.ok(!expr.includes('x.salesQty'));
});
test('默认窗口为 30 天,统计范围固定为拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return [{ skuId: 'S1' }]; } };
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
assert.equal(called, 0);
});
test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商品整档销量', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
stockQty: 12, skuCount: 2, salesQty: 600, allocatedSalesQty: 300,
dailySales: 20, gradeKey: 'hot',
},
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
dailySales: 1.5, gradeKey: 'slow1',
},
];
},
};
const rows = await fetchPickingBinSkuRows(db, [101, 102], {
shipFrom: '2026-09-08 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 30,
});
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
// 等级按商品自身日销,同一仓位的多行可以不同
assert.deepEqual(rows.map((r) => r.gradeKey), ['hot', 'slow1']);
// 分摊销量 = 商品销量 ÷ 占用的同类型仓位数(用于按行求和)
assert.deepEqual(rows.map((r) => r.allocatedSalesQty), [300, 22.5]);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-01-01',
binType: '拣选',
skuId: 'SKU1',
stockQty: 12,
skuCount: 2,
salesQty: 600,
allocatedSalesQty: 300,
dailySales: 20,
gradeKey: 'hot',
});
// 参数必须带上两个仓库、发货窗口与天数
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.shipFrom, '2026-09-08 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 30);
// 商品销量按占用仓位数分摊(供求和),但等级依据的是未分摊的商品销量
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
assert.ok(captured.sqlText.includes('ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)'));
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空;只统计有库存的行
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
assert.ok(captured.sqlText.includes('HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001'));
});
test('fetchPickingBinSkuRows 只统计真实拣选仓位,排除 Pack 箱记录且不带类型参数', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
// Pack 箱记录和存货位被排除,且不再有 binType IN (...) 的可变过滤
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(!captured.sqlText.includes('b.binType IN'));
assert.equal(captured.params.t0, undefined);
assert.equal(captured.params.binTypes, undefined);
// 多来源 union 后必须再按 仓库+商品 汇总,否则同一仓位行会被拆成多行
assert.ok(captured.sqlText.includes('shipAgg AS ('));
assert.ok(captured.sqlText.includes('LEFT JOIN shipAgg s'));
assert.ok(!captured.sqlText.includes('LEFT JOIN ship s'));
});
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
// ① 线上店铺订单普通行
assert.ok(captured.sqlText.includes('FROM rpt.jst_OrderItem'), '缺少 jst_OrderItem 普通行分支');
// ② 线上订单组合装行按映射表展开为子件
assert.ok(captured.sqlText.includes('JOIN rpt.jst_comboProductItem'), '缺少组合装展开分支');
assert.ok(captured.sqlText.includes("oi.sku_type = 'combine'"), '缺少组合装行识别条件');
// ③ 全民客户(B2B 分销)渠道普通行,排除拆单父单 Split 与问题单
assert.ok(captured.sqlText.includes('FROM rpt.jst_qmcust_orderlist_item'), '缺少 qmcust 分支');
assert.ok(captured.sqlText.includes("'Split', 'Question'"), 'qmcust 未排除拆单父单/问题单');
// 四个分支共用同一发货窗口
assert.equal(captured.sqlText.split('o.send_date >= @shipFrom').length, 5);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', skuId: 'S1', stockQty: 5 }] };
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].allocatedSalesQty, 0);
assert.equal(rows[0].dailySales, 0);
assert.equal(rows[0].skuId, 'S1');
});

View File

@ -1,110 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
_test: {
filterSkuRowsForSelectionContext,
createPrefsPatch,
getSelectionSkuCodeSet,
normalizeCalculatorType,
readPrefsProfile,
sanitizeHistoryProduct,
writePrefsProfile,
},
} = require('../src/api/pricingCalculator');
const skuRows = [
{ skuCode: 'SKU-A' },
{ skuCode: 'SKU-B' },
{ skuCode: 'SKU-C' },
];
test('selection pricing only returns explicitly selected SKU rows', () => {
const selectionContext = {
skuSceneImages: { selectedSkuCodes: ['sku-a', 'SKU-C'] },
rows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }],
};
assert.deepEqual(
filterSkuRowsForSelectionContext(skuRows, selectionContext),
[{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }]
);
});
test('selection pricing falls back to stored pricing rows for legacy tasks', () => {
const selectionContext = {
skuSceneImages: {},
rows: [{ skuCode: 'SKU-B' }],
};
assert.deepEqual([...getSelectionSkuCodeSet(selectionContext)], ['SKU-B']);
assert.deepEqual(
filterSkuRowsForSelectionContext(skuRows, selectionContext),
[{ skuCode: 'SKU-B' }]
);
});
test('standalone pricing continues to return all SPU SKU rows', () => {
assert.deepEqual(filterSkuRowsForSelectionContext(skuRows, null), skuRows);
});
test('pricing preferences keep legacy company values and isolate 1688 defaults', () => {
const legacy = JSON.stringify({
coupon: 30,
activityDiscount: 0.7,
installFee: 5,
targetMargin: 0.15,
});
const withDropship = writePrefsProfile(legacy, 'alibaba1688', {
coupon: 20,
activityDiscount: 0.6,
installFee: 0,
targetMargin: 0.12,
});
assert.deepEqual(readPrefsProfile(withDropship, 'company'), {
coupon: 30,
activityDiscount: 0.7,
installFee: 5,
targetMargin: 0.15,
});
assert.deepEqual(readPrefsProfile(withDropship, 'alibaba1688'), {
coupon: 20,
activityDiscount: 0.6,
installFee: 0,
targetMargin: 0.12,
});
const updatedCompany = writePrefsProfile(withDropship, 'company', { targetMargin: 0.18 });
assert.equal(readPrefsProfile(updatedCompany, 'company').targetMargin, 0.18);
assert.equal(readPrefsProfile(updatedCompany, 'alibaba1688').targetMargin, 0.12);
assert.deepEqual(createPrefsPatch('company', { targetMargin: 0.15 }), { targetMargin: 0.15 });
assert.deepEqual(createPrefsPatch('alibaba1688', { targetMargin: 0.12 }), {
_profiles: { alibaba1688: { targetMargin: 0.12 } },
});
});
test('1688 history product keeps bounded identity and specification dimensions', () => {
assert.equal(normalizeCalculatorType('alibaba1688'), 'alibaba1688');
assert.equal(normalizeCalculatorType('unknown'), 'company');
assert.deepEqual(sanitizeHistoryProduct({
productId: ' 1073252781818 ',
title: ' 测试商品 ',
skuDimensions: [
{ name: '颜色', values: ['原木色', '胡桃色'] },
{ name: '尺寸', values: ['60cm'] },
],
}), {
productId: '1073252781818',
title: '测试商品',
mainImage: '',
sourceUrl: '',
storeName: '',
category: '',
skuDimensions: [
{ name: '颜色', values: ['原木色', '胡桃色'] },
{ name: '尺寸', values: ['60cm'] },
],
});
});

Some files were not shown because too many files have changed in this diff Show More