Compare commits

..

23 Commits

Author SHA1 Message Date
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
22 changed files with 3435 additions and 451 deletions

View File

@ -6,10 +6,9 @@ const {
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TOTAL_LABEL,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_BIN_TYPES,
} = require('../config/pickingBinSalesAnalysis');
const { fetchPickingBinRows, fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
@ -60,18 +59,6 @@ function resolveDays(raw) {
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
}
/** 解析仓位类型,只保留已知类型;空 → 默认(拣选位) */
function resolveBinTypes(raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return [...PICKING_BIN_DEFAULT_TYPES];
}
const list = String(raw)
.split(',')
.map((v) => v.trim())
.filter((v) => BIN_TYPES.includes(v));
return list.length > 0 ? list : [...PICKING_BIN_DEFAULT_TYPES];
}
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
function resolveWarehouses(raw) {
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
@ -87,19 +74,24 @@ function resolveWarehouses(raw) {
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
* 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const days = resolveDays(req.query?.days);
const binTypes = resolveBinTypes(req.query?.binType);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const cacheKey = `${days}::${binTypes.join('|')}::${wmsCoIds.join('|')}`;
const cacheKey = `${days}::${wmsCoIds.join('|')}`;
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByKey.get(cacheKey);
if (!force && cached && cached.latestBizDate === latestBizDate
@ -117,41 +109,65 @@ router.get('/', async (req, res) => {
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const [rows, takenAt] = await Promise.all([
fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }),
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) => ({
...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(hit.reduce((acc, it) => acc + it.salesQty, 0));
const salesQty = round2(dedupeSales(hit));
return {
key: grade.key,
label: grade.label,
tip: grade.tip,
binCount: hit.length,
rowCount: hit.length,
skuCount: dedupeCount(hit),
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
skuCount: hit.reduce((acc, it) => acc + it.skuCount, 0),
salesQty,
dailySales: round4(salesQty / days),
};
});
const totalSales = round2(summary.reduce((acc, s) => acc + s.salesQty, 0));
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,
binCount: summary.reduce((acc, s) => acc + s.binCount, 0),
rowCount: items.length,
skuCount: dedupeAll.size,
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
skuCount: summary.reduce((acc, s) => acc + s.skuCount, 0),
salesQty: totalSales,
dailySales: round4(totalSales / days),
};
@ -159,8 +175,8 @@ router.get('/', async (req, res) => {
const payload = {
days,
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
binTypes,
binTypeOptions: BIN_TYPES,
binTypes: [...PICKING_BIN_BIN_TYPES],
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
@ -187,12 +203,11 @@ router.get('/', async (req, res) => {
/**
* GET /api/picking-bin-sales-analysis/export
* 导出库位明细。query: days、binType、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
* 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
*/
router.get('/export', async (req, res) => {
try {
const days = resolveDays(req.query?.days);
const binTypes = resolveBinTypes(req.query?.binType);
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';
@ -213,7 +228,7 @@ router.get('/export', async (req, res) => {
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes });
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
@ -228,6 +243,7 @@ router.get('/export', async (req, res) => {
r.stockQty,
r.skuCount,
round2(r.salesQty),
round4(r.allocatedSalesQty),
round4(r.dailySales),
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
days,
@ -241,13 +257,14 @@ router.get('/export', async (req, res) => {
sheets: [{
name: '库位动销明细',
header: [
'仓库', '仓位', '类型', '商品编码', '库存数量', 'SKU数',
`销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日',
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
'动销等级', '统计天数', '统计基准日',
],
rows: sheetRows,
}],
});
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 类型 ${binTypes.join('/')}, 窗口 ${days} 天`);
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);

View File

@ -0,0 +1,62 @@
'use strict';
/**
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-ship-timeliness/list
* query:
* round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定
* limit 明细条数上限(默认 500,最大 2000)
*
* GET /api/warehouse-ship-timeliness/rounds
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
*
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
*/
const express = require('express');
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
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, '加载核验轮次失败');
}
});
module.exports = router;

View File

@ -764,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();
@ -1093,6 +1106,28 @@ 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,
});
};
/**
* 测试连接
*/
@ -1143,6 +1178,7 @@ const reload = async () => {
module.exports = {
getPool,
query,
querySlow,
queryWithFreshConnection,
createPinnedQueryClient,
test,

View File

@ -5,19 +5,29 @@
* 二级菜单:拣选库位动销分析
*
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
* 仓库 | 仓位 | 类型 | 库存数量 | SKU数 | 近N天销量 | 日销 | 动销等级
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近N天销量 | 日销 | 动销等级
*
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用。
*
* 口径:
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty
* 仓位类型:bin_group 为空 → 拣选位;bin_group = '存货' → 存货位
* 销量: rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 日销: 库位销量 / 窗口天数
* 库存数量: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 个)。若直接把 SKU 销量累加到每个仓位,
* 销量会被放大数倍。因此这里按「SKU 销量在其占用的同类型仓位数之间平均分摊」:
* 库位销量 = Σ(该库位内各 SKU 的窗口销量 ÷ 该 SKU 占用的同类型仓位数)
* 恒等式(已实测校验,差异 0):全部库位销量之和 = 有库位记录的那些 SKU 的销量之和。
* 【为什么不分摊来定等级】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个
* 拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。若把销量摊到仓位再定等级,
* 一件爆款商品放在 10 个仓位就会被算成日销 1/10 的滞销品 —— 判断的是「摆法」而不是「商品」。
* 因此等级一律以商品自身的销售速度为准。
*
* 【分摊销量列】导出仍附带「分摊销量」= 商品销量 ÷ 其占用的同类型仓位数,
* 供需要按行求和的场景使用:恒等式(已实测校验,差异 0)
* 全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
@ -29,25 +39,29 @@ const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30, 60];
const PICKING_BIN_DEFAULT_DAYS = 30;
/** 仓位类型:业务库 bin_group 为空视为拣选位 */
/**
* 仓位类型:本功能只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空)。
* 箱记录(pack_type=Pack)和存货位(bin_group=存货)不纳入动销等级统计,
* 取数在仓位库存表上直接过滤,
* 页面也不再提供类型切换。
*/
const BIN_TYPE_PICKING = '拣选';
const BIN_TYPE_STORAGE = '存货';
const BIN_TYPES = [BIN_TYPE_PICKING, BIN_TYPE_STORAGE];
/** 默认只看拣选位(本功能的核心是拣货位动销) */
const PICKING_BIN_DEFAULT_TYPES = [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: '统计窗口内该库位所有 SKU 均无销量(仍占用库存)' },
{ 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 = '汇总';
@ -58,9 +72,7 @@ module.exports = {
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
BIN_TYPE_PICKING,
BIN_TYPE_STORAGE,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_BIN_TYPES,
PICKING_BIN_GRADE_RULES,
PICKING_BIN_TOTAL_LABEL,
};

View File

@ -35,6 +35,7 @@ const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
@ -672,6 +673,8 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);

View File

@ -70,8 +70,11 @@
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id **仓白名单**)实时库存;
// 🔥 2026-10-17 修正为**多仓求和**(原来是单仓 15870495):商品档案 other_3 只记
// 「归属仓」,实物会整批搬到同体系其它发货仓 —— 只查归属仓会把有货的算成 0。
// 实测新仓「简尚-横林二仓品牌仓」(16132681,10-13 建仓) 58 个 MKH 成品 / 525 件
// 全在它名下、归属仓为 0 → 5 个误报缺货 + 2 个误报预警。白名单见 BRAND_WMS_CO_IDS。
// 缺失时回退监控快照 jsStock
// · 结余库存 = max(0, 现有库存 − 待发货订单)
// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
@ -215,6 +218,28 @@
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
//
// ── ⑦-e 成品库存多仓合并(2026-10-17 新增)────────────────────────
// 业务原话:「MKHDNZ0409260600788 库存在横林二仓品牌仓,**重新加了一个品牌仓库存**,
// 需要同步这个仓库库存」。
// 🔥 根因:**商品档案的 `other_3` 记录的是「归属仓」,不是「实物所在仓」**。
// 归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物被整批搬到 10-13 新建的
// 「简尚-横林二仓品牌仓」(16132681) —— 归属仓库存为 0,新仓有货。
// 而 ⑦-d 的取数只看 `wms_co_id = 15870495` 单仓 → 该批商品库存全被算成 0。
// 实测:新仓 58 个 SKU 全是 MKH 成品(other_3 全为崔桥品牌仓),合计 525 件;
// 线上 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」**。
// 规则:`jst_inventoryInfo` / `jst_packInventoryInfo` 都按 **仓白名单 `IN (...)`**
// 取数,并 **按 SKU 多仓求和**(同一批货可能同时分散在多仓)。
// · 白名单常量:BRAND_WMS_CO_IDS(主仓 + 横林二仓品牌仓)
// · ⚠️ 原来的「按 updateTime DESC 取首条」在单仓时是去重,多仓时会**只留一个仓**,
// 必须换成 SUM + GROUP BY(本次改动同时修掉这个隐患)。
// · ⚠️ 「求和」而非「取最大」:两仓是独立仓位,同一 SKU 同批货不会重复记账
// (实测该 SKU 两仓 bin 分别为 `PP01-15-22` 与空,无同箱重复)。
// 影响面(2026-10-17 实测):58 个 SKU 受影响,其中 49 个库存值变化;
// 预警 30 → 25、缺货 21 → 16(消除 5 个误报缺货、2 个误报预警)。
// ⚠️ 新增同类「成品发货仓-真实库存」时,往 BRAND_WMS_CO_IDS 追加 wms_co_id 即可。
// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有,
// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -410,8 +435,17 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%'
)`;
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
const BRAND_WMS_CO_ID = 15870495;
// 成品库存仓白名单(rpt.jst_warehouseInfo 查得)。**多仓求和**:
// 商品档案的 other_3 只记录「归属仓」(仍是崔桥工厂品牌仓),但实物会成批搬到
// 同体系的其它发货仓,此时库存表里只有新仓有数、归属仓为 0 —— 只查归属仓会把
// 有货的商品算成 0(实测 2026-10-17:新仓「简尚-横林二仓品牌仓」10-13 建仓,
// 58 个 MKH 成品 / 525 件全部只在它名下,导致 5 个误报缺货 + 2 个误报预警)。
// · 15870495 简尚-崔桥工厂品牌仓(归属主仓)
// · 16132681 简尚-横林二仓品牌仓(2026-10-13 新增,成品发货仓-真实库存)
// ⚠️ 新增同类「成品发货仓-真实库存」时把 wms_co_id 追加进来即可,别再写死单仓。
const BRAND_WMS_CO_IDS = Object.freeze([15870495, 16132681]);
// 兼容旧引用:主仓仍作为「仓库名」文案与监控快照的 warehouse 过滤值
const BRAND_WMS_CO_ID = BRAND_WMS_CO_IDS[0];
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
@ -537,8 +571,10 @@ const normalizeCode = (value) => {
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
const REPLENISH_PARAMS = Object.freeze({
brandWarehouse: DESIGNATED_WAREHOUSE,
brandWmsCoId: BRAND_WMS_CO_ID,
});
// 成品库存仓白名单的 SQL 片段(纯代码常量插值,不含外部输入)。
// 用 `IN (...)` 一条查完多仓,再由应用层按 SKU **求和**分仓结果。
const BRAND_WMS_CO_ID_LIST_SQL = BRAND_WMS_CO_IDS.join(',');
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够
const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
let mkhProductCache = null;
@ -1757,21 +1793,23 @@ async function loadReplenishIndex() {
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
// 实时库存(见文件头 ⑦-d):仓白名单多仓,**按 SKU 求和**(同一批货可能分散在多仓)。
// ⚠️ 不能再用「按 updateTime DESC 取首条」——那会只留一个仓的数,正好丢掉新仓。
cloudDatabase.query(
`SELECT i.sku_id AS skuId, i.qty AS qty
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
FROM dbo.jst_inventoryInfo i
WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%'
ORDER BY i.sku_id ASC, i.updateTime DESC`,
WHERE i.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND i.sku_id LIKE N'MKH%'
GROUP BY i.sku_id`,
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
// 同样按仓白名单多仓求和(见 BRAND_WMS_CO_IDS 注释)。
cloudDatabase.query(
`SELECT p.sku_id AS skuId, SUM(p.qty) AS qty
FROM dbo.jst_packInventoryInfo p
WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%'
WHERE p.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND p.sku_id LIKE N'MKH%'
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`,
REPLENISH_PARAMS,
@ -2682,6 +2720,9 @@ module.exports = {
COMPONENT_STOCK_WAREHOUSE,
COMPONENT_STOCK_CHUNK_SIZE,
COMPONENT_SHORTAGE_MAX_LIST,
// ⑦-d 成品库存仓白名单(多仓求和口径)
BRAND_WMS_CO_IDS,
BRAND_WMS_CO_ID,
parseFlag,
resolveScope,
filterRows,

View File

@ -3,15 +3,29 @@
/**
* 拣选库位动销分析 —— 业务库取数
*
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty(仓位为空的行不参与)
* 类型:bin_group 为空 → 拣选位;否则取 bin_group(业务库目前只有「存货」)
* 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 分摊:SKU 销量 ÷ 该 SKU 占用的同类型仓位数,再按库位累加
* 保证「全部库位销量之和 = 全仓销量」
* 日销:库位销量 / 窗口天数 → 分档得到动销等级
* 库存: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 } = require('../config/pickingBinSalesAnalysis');
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = require('../config/pickingBinSalesAnalysis');
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
@ -28,8 +42,9 @@ function buildInClause(values, prefix) {
/**
* 日销分档表达式:从高档往低档依次命中,最后一档「销售=0」由 salesQty <= 0 兜底。
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
*/
function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days') {
function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days') {
const branches = PICKING_BIN_GRADE_RULES
.filter((rule) => rule.lower > 0)
.map((rule) => {
@ -49,182 +64,49 @@ function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days'
}
/**
* 拉取各仓库每个仓位的库存、销量、日销与动销等级。
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
*
* - stockQty:该商品在此仓位的实际库存
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
* - dailySales:salesQty / 窗口天数 = 商品日销
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string,
* stockQty:number, skuCount:number, salesQty:number, dailySales:number, gradeKey:string}>>}
*/
async function fetchPickingBinRows(cloudDb, wmsCoIds, options = {}) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchPickingBinRows 的第一个参数必须是业务库实例');
}
const { shipFrom, shipTo, days, binTypes } = options;
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const binIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const gradeCase = buildGradeCase();
// 仓位类型过滤(不传 = 全部类型)
let typeFilter = '';
const typeParams = {};
if (Array.isArray(binTypes) && binTypes.length > 0) {
const t = buildInClause(binTypes, 't');
Object.assign(typeParams, t.params);
typeFilter = `AND a.binType IN (${t.clause})`;
}
const sqlText = `
WITH bin AS (
SELECT p.wms_co_id AS wmsCoId,
LTRIM(RTRIM(p.bin)) AS binName,
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END 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
GROUP BY p.wms_co_id,
LTRIM(RTRIM(p.bin)),
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END,
p.sku_id
),
binSpan AS (
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
FROM bin
GROUP BY wmsCoId, skuId, binType
),
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
),
binSales AS (
SELECT b.wmsCoId,
b.binName,
b.binType,
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
LEFT JOIN ship s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType
),
binAgg AS (
SELECT wmsCoId,
binName,
binType,
SUM(binSkuQty) AS stockQty,
COUNT(DISTINCT skuId) AS skuCount
FROM bin
GROUP BY wmsCoId, binName, binType
)
SELECT a.wmsCoId,
a.binName,
a.binType,
CAST(a.stockQty AS DECIMAL(18, 2)) AS stockQty,
CAST(a.skuCount AS INT) AS skuCount,
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
CAST(ISNULL(x.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
${gradeCase} AS gradeKey
FROM binAgg a
LEFT JOIN binSales x
ON x.wmsCoId = a.wmsCoId AND x.binName = a.binName AND x.binType = a.binType
WHERE ABS(ISNULL(a.stockQty, 0)) > 0.0001
${typeFilter}
ORDER BY a.wmsCoId, a.binType, a.binName`;
const rows = await cloudDb.query(
sqlText,
{
...binIn.params,
...shipIn.params,
...typeParams,
shipFrom,
shipTo,
days,
},
{ requestTimeout: 120000 }
);
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
binName: r.binName || '',
binType: r.binType || '',
stockQty: Number(r.stockQty) || 0,
skuCount: Number(r.skuCount) || 0,
salesQty: Number(r.salesQty) || 0,
dailySales: Number(r.dailySales) || 0,
gradeKey: r.gradeKey || 'zero',
}));
}
/**
* 拉取「仓位 × 商品编码」粒度明细(导出用)。
*
* 与 fetchPickingBinRows 的差别:每个仓位有 N 个 SKU 就输出 N 行。
* - 库存数量:该 SKU 在这个仓位的实际库存
* - 销量:该 SKU 的销量 ÷ 它占用的同类型仓位数(即这个组合对仓位的贡献),
* 同一 SKU 出现在多个仓位时各行数值相同;全部行求和仍等于「有库位 SKU 销量之和」
* - skuCount / binSalesQty / gradeKey:仍是仓位粒度,同一仓位的多行重复显示
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
* @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, dailySales:number,
* binSalesQty:number, gradeKey: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, binTypes } = options;
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');
let typeFilter = '';
const typeParams = {};
if (Array.isArray(binTypes) && binTypes.length > 0) {
const t = buildInClause(binTypes, 't');
Object.assign(typeParams, t.params);
typeFilter = `AND a.binType IN (${t.clause})`;
}
// 统计范围固定为真实拣选仓位: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,
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END AS binType,
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)),
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END,
p.sku_id
),
binSpan AS (
@ -233,6 +115,7 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
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
@ -242,68 +125,96 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
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
),
binSales AS (
SELECT b.wmsCoId,
b.binName,
b.binType,
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
LEFT JOIN ship s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType
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,
SUM(binSkuQty) AS stockQty,
COUNT(DISTINCT skuId) AS skuCount
FROM bin
GROUP BY wmsCoId, binName, binType
),
skuRows AS (
SELECT b.wmsCoId,
b.binName,
b.binType,
b.skuId,
SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS stockQty,
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
LEFT JOIN ship s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId
)
SELECT sk.wmsCoId,
sk.binName,
sk.binType,
sk.skuId,
CAST(sk.stockQty AS DECIMAL(18, 2)) AS stockQty,
CAST(a.skuCount AS INT) AS skuCount,
CAST(ISNULL(sk.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
CAST(ISNULL(sk.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS binSalesQty,
${buildGradeCase('ISNULL(x.salesQty, 0)')} AS gradeKey
FROM skuRows sk
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 = sk.wmsCoId AND a.binName = sk.binName AND a.binType = sk.binType
LEFT JOIN binSales x
ON x.wmsCoId = sk.wmsCoId AND x.binName = sk.binName AND x.binType = sk.binType
WHERE ABS(ISNULL(sk.stockQty, 0)) > 0.0001
${typeFilter}
ORDER BY sk.wmsCoId, sk.binType, sk.binName, sk.skuId`;
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`;
const rows = await cloudDb.query(
// 该查询 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,
...typeParams,
shipFrom,
shipTo,
days,
@ -319,8 +230,8 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
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,
binSalesQty: Number(r.binSalesQty) || 0,
gradeKey: r.gradeKey || 'zero',
}));
}
@ -328,6 +239,5 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
module.exports = {
buildGradeCase,
buildInClause,
fetchPickingBinRows,
fetchPickingBinSkuRows,
};

View File

@ -0,0 +1,559 @@
'use strict';
/**
* 仓储中心 24 小时发货时效(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 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 四次修正,全部以聚水潭「销售出库单」页面为准):
* · 创建时间:**销售出库单的 `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';
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
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(['已发货', '已取消', '等供销商|外仓发货']);
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
//
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
// 是**同步落库时间**,用了会把起算点整体推后几小时。
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
// 要换掉。
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
// 这才是聚水潭订单列表里「创建时间」那一列。
//
// 性能(2026-10-20 实测 9,676 个候选):
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
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 与合并/拆单标记位,
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
// 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,
unmatchedCount: unmatched.length,
excluded,
};
return list;
}
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,
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,
SHIPPING_STATUS,
UNAUDITED_STATUS,
ABNORMAL_STATUS,
IGNORED_STATES,
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
SHIP_TIMELINESS_HOURS,
toNumber,
};

View File

@ -0,0 +1,775 @@
'use strict';
/**
* 仓储中心 24 小时发货时效 —— 服务层
*
* ═══════════════════════════════════════════════════════════════════════════
* 口径(2026-10-20 第九次修正,业务最终定版)
* ═══════════════════════════════════════════════════════════════════════════
* 业务原话(按时间顺序,最后一条为准):
* 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」
* 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验;
* 然后再看历史未发的订单」
*
* ⟹ 三条铁律:
* ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值):
* 今天 00:00~07:00 审核 → 13:00 轮
* **截止今天 16:00 前审核**的单 → 18:00 轮
* (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单,
* 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、
* 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的;
* 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry)
* ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06)
* 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的,
* 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。
* ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到
* (它们早就该发了,不该等今天的分段窗口)。
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
*
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
* 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。
* 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致:
* 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。
* 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮,
* 已等自然用 18:00 算,不会出现「0 分」。
* 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**,
* 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。
*
* 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。
* **不要**改用运单号(logistic_id)判已发:实测同一店铺里,
* 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。
* 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。
*
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
*
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
*
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
* 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补:
* · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用**
* · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒)
* · 已发货 = `send_time` 非空(已发货单 100% 有值)
* · 待发货 = `send_time` 为空 且 `order_status_name = 发货中`
* · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓)
*
* ── 为什么弃用 jst_Orders.status(前三版全栽在这)──────────────────────────
* 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、
* 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单:
* 订单表 status 全是 `WaitConfirm`(看起来像待发货),
* 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。
* ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。
*
* 时间列语义(已逐一实测,别再猜):
* · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间)
* · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒)
* · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟)
* · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用
* · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退)
* · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)**
* · `send_time` —— **发货时间**
* · `query_time` —— 本表快照时间(判数据新鲜度)
*
* ── 三态分流(互斥)─────────────────────────────────────────────────────
* 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00)
* → orders(考核仓库)
* 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders
* 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃
* 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍,
* 不排除的话整页都是客户取消掉的单。
*
* ── 核验轮次(业务给定)──────────────────────────────────────────────────
* `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它
* `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry)
* ⚠️ 两者不是同一个东西,别合成一个字段
* ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回)
* ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」
* 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 →
* 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就
* 只该有早上那批,不是数据不对。
*
* ── 性能(2026-10-20 实测,四个坑都踩过)──────────────────────────────────
* ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含
* `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做
* 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。
* ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测
* **38.7s**。发货时效不需要金额,直接不做。
* (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。)
* ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。
* 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。
* ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。
* ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
*
* ── 时区 ─────────────────────────────────────────────────────────────────
* 所有时间列都是**东八区本地时间**;服务与库都在东八区。
* **不要**在 Node 侧用 UTC 日期判断。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseShipTimelinessRegistry');
const {
ORDER_STATE_TABLE,
SHIPPING_STATUS,
UNAUDITED_STATUS,
ABNORMAL_STATUS,
IGNORED_STATES,
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
resolveCenter,
} = registry;
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000;
// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。
// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。
const STATE_STALE_MINUTES = 90;
/**
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
* 自提 —— 客户自己来拉,仓库不负责派送
* 中铁 —— 中铁物流(大件专线)
* 壹米滴答 —— 专线物流(大件)
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
* 只影响「超时未发」判定,不影响「已发」判定。
*/
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
/**
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
*
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
* 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写
* (实测 103 单里 103 单都是空),所以必须把 status 也当依据。
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
* 正是本页要考核的对象;算进来整页就空了。
*/
const ORDER_SHIPPED_STATUS = ['Sent'];
/**
* 结果缓存。
*
* 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是
* `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库,
* 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」,
* 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。
*/
const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000;
let resultCache = { key: '', at: 0, data: null };
/** 清缓存(切轮次 / 手工排查时用) */
function clearShipTimelinessCache() {
resultCache = { key: '', at: 0, data: null };
}
/** 把 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);任一为空返回 0 */
function diffMinutes(fromText, toText) {
const a = formatDateTime(fromText);
const b = formatDateTime(toText);
if (!a || !b) return 0;
const pa = new Date(a.replace(' ', 'T')).getTime();
const pb = new Date(b.replace(' ', 'T')).getTime();
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0;
return Math.floor((pb - pa) / 60000);
}
/**
* 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。
*
* 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。
* 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。
*
* ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本
* 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。
* ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。
*
* 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。
* 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选;
* 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来
* 判断窗口归属,不再参与「等了多少小时」的门槛。
*/
async function loadPendingStates(round) {
const rows = await cloudDatabase.querySlow(
// 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串:
// CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+;
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
// 就是库里存的北京时间,不会偏移 8 小时。
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.order_status_name, e.logistic_status_name,
e.logistic_id, e.logistic_company_name,
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
e.send_time, e.query_time
FROM dbo.${ORDER_STATE_TABLE} e
WHERE e.send_time IS NULL
AND e.pay_time >= @windowStart
AND e.order_status_name IN (@shipStatus, @st1, @st2)`,
{
shipStatus: SHIPPING_STATUS,
st1: UNAUDITED_STATUS,
st2: ABNORMAL_STATUS,
windowStart: round.windowStart,
},
// 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s)
{ connectRequestTimeoutMs: 300000 }
);
return rows || [];
}
/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */
async function mapLimit(items, limit, worker) {
const out = [];
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const i = cursor;
cursor += 1;
if (i >= items.length) return;
out[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return out;
}
/**
* 回订单表补「起算点 + 作废事实」。
*
* 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库
* 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取
* `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。
*
* 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时):
* 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了,
* 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的
* `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。
*
* ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s,
* 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。
*
* @param {number[]} oids 内部单号
* @returns {Promise<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
*/
async function backfillOrderFacts(oids) {
const map = new Map();
if (!oids.length) return map;
let remaining = oids;
for (const table of CREATED_SOURCE_TABLES) {
if (!remaining.length) break;
const batches = [];
for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) {
batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH));
}
const results = await mapLimit(batches, 4, async (batch) => {
try {
return await cloudDatabase.querySlow(
`SELECT o_id, created, status, send_date, is_merge, is_split
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
{},
{ connectRequestTimeoutMs: 300000 }
);
} catch (e) {
// 补不到不致命:调用方会回退到付款时间,不整页失败
return [];
}
});
for (const rows of results) {
for (const r of rows || []) {
const id = Number(r.o_id);
if (!Number.isFinite(id)) continue;
const status = String(r.status || '');
const sendDate = formatDateTime(r.send_date);
map.set(id, {
createdAt: formatDateTime(r.created),
status,
sendDate,
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
void: VOID_ORDER_STATUS.includes(status)
|| Number(r.is_merge) === 1
|| Number(r.is_split) === 1,
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
});
}
}
remaining = remaining.filter((n) => !map.has(n));
}
return map;
}
function emptySummary() {
return {
// 超时未发货(考核仓库的部分)
overdueUnshipped: 0,
overdueCenterCount: 0,
oldestDays: 0,
// 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任
heldCount: 0,
// 已剔除的作废 / 已发单(页面只作说明,不进考核)
voidCount: 0,
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答)
carrierSkipped: 0,
};
}
function buildEmptyCenters(centerList) {
return centerList.map((c) => ({
key: c.key,
name: c.name,
placeholder: c.placeholder,
warehouseCount: c.warehouses.length,
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
overdueUnshipped: 0,
oldestDays: 0,
heldCount: 0,
}));
}
async function getShipTimeliness(options = {}) {
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
const now = options.now instanceof Date ? options.now : registry.beijingNow();
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
MAX_DETAIL_LIMIT
);
// ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词)
const centerList = await registry.loadCenterWarehouseMap();
const pad2 = (n) => String(n).padStart(2, '0');
// ② 决定本轮截止时刻
const rounds = registry.describeRounds(now);
let round = null;
if (options.round) {
const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase());
if (!target) {
const err = new Error(`未知的核验轮次: ${options.round}`);
err.statusCode = 400;
throw err;
}
round = rounds.find((r) => r.key === target.key);
if (!round) {
const err = new Error(`无法构造核验窗口: ${options.round}`);
err.statusCode = 500;
throw err;
}
} else {
round = registry.resolveActiveRound(now);
}
// 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库
if (!round) {
const nextRound = rounds.find((r) => !r.active) || rounds[0];
return {
generatedAt: formatDateTime(now),
date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
round: null,
notStarted: true,
isPreviousDay: false,
rounds,
nextRoundAt: nextRound ? nextRound.deadline : '',
nextWindowEnd: nextRound ? nextRound.windowEnd : '',
deadline: '',
window: null,
summary: emptySummary(),
centers: buildEmptyCenters(centerList),
orders: [],
heldOrders: [],
truncated: false,
detailLimit: limit,
detailDegraded: false,
freshness: null,
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
};
}
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
const force = options.force === true
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
const cacheKey = `${round.key}|${round.date}|${limit}`;
if (!force && resultCache.data && resultCache.key === cacheKey
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
return {
...resultCache.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
};
}
let stateRows = [];
try {
stateRows = await loadPendingStates(round);
} catch (e) {
const err = new Error(`聚水潭订单状态表查询失败:${e.message}`);
err.statusCode = 502;
throw err;
}
// ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取)
const oids = [...new Set(
stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n))
)];
const orderFacts = await backfillOrderFacts(oids);
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
let voidCount = 0;
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
let carrierSkipped = 0;
// ④ 分桶
const acc = new Map(centerList.map((c) => [c.key, {
pendingCount: 0,
oldestDays: 0,
heldCount: 0,
}]));
const pendingRows = [];
const heldRows = [];
const seenSo = new Set();
let snapshotAt = '';
for (const row of stateRows) {
// 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表)
const qt = formatDateTime(row.query_time);
if (qt > snapshotAt) snapshotAt = qt;
const center = resolveCenter(row.warehouse_name);
if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…)
const bucket = acc.get(center.key);
if (!bucket) continue;
const state = String(row.order_status_name || '');
// 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。
// 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023:
// 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、
// 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。
const fact = orderFacts.get(Number(row.inner_order_id));
if (fact && (fact.void || fact.shipped)) {
voidCount += 1;
continue;
}
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
if (IGNORED_STATES.includes(state)) continue;
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
const carrier = String(row.logistic_company_name || '').trim();
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
if (hitCarrier) {
carrierSkipped += 1;
continue;
}
// ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)────
// 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」
// 实测案例(业务亲自指出的两单):
// · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现
// · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮
// ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮,
// 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。
// ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。
// ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。
const createdAt = fact ? fact.createdAt : '';
const payAt = formatDateTime(row.pay_time);
const auditFirst = formatDateTime(row.first_oa_confirm_time);
const auditLast = formatDateTime(row.last_oa_confirm_time);
// 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。
// 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」,
// 根因就是我们之前取错了列(first_oa = 首次审核):
// 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38
// 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31
// 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11
// ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。
const auditAt = auditLast || auditFirst;
const windowAt = auditAt || createdAt || payAt;
const benchmarkAt = windowAt;
const benchmarkSource = auditAt
? (auditLast ? 'last_audit' : 'first_audit')
: (createdAt ? 'created' : 'pay');
// 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重
const dedupeKey = `${row.so_id}|${windowAt}`;
if (seenSo.has(dedupeKey)) continue;
seenSo.add(dedupeKey);
// ── 本轮核验范围(业务 2026-10-20 最终定版)────────────────────────────
// ① **今天的单按审核时间分段**:
// 今天 00:00 ~ 07:00 审核 → 13:00 轮核验
// **截止今天 16:00 前审核**的单 → 18:00 轮核验
// (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。
// 山东两仓 15:00、河南洛宁 13:00 为仓级例外)
// ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476)
// **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单)
// ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时,
// 两个轮次都该看到,不受「今天 00:00 起」的限制。
// ③ 30 天护栏只为查询性能,不是业务边界。
const windowEndAt = registry.centerWindowEnd(center.key, round);
const todayStart = `${round.date} 00:00:00`;
if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏
if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue;
const base = {
row,
center,
state,
createdAt,
payAt,
auditAt,
windowAt,
benchmarkAt,
benchmarkSource,
warehouseName: String(row.warehouse_name || ''),
};
// ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ──────
if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) {
bucket.heldCount += 1;
heldRows.push({
...base,
holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常',
});
continue;
}
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
if (!benchmarkAt) {
bucket.heldCount += 1;
heldRows.push({ ...base, holdReason: '缺审核时间' });
continue;
}
// waited = 审核时间 → 本轮核验时刻(13:00 / 18:00)
// ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline,
// 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮:
// 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
bucket.pendingCount += 1;
const days = Math.floor(waited / 1440);
if (days > bucket.oldestDays) bucket.oldestDays = days;
pendingRows.push({ ...base, waited });
}
// ⑤ 排序:最久的排前面(业务一眼看最急的)
pendingRows.sort((a, b) => {
if (a.windowAt < b.windowAt) return -1;
if (a.windowAt > b.windowAt) return 1;
return 0;
});
heldRows.sort((a, b) => {
const x = a.windowAt || '';
const y = b.windowAt || '';
if (x < y) return -1;
if (x > y) return 1;
return 0;
});
const detailRows = pendingRows.slice(0, limit);
const heldDetail = heldRows.slice(0, limit);
const orders = detailRows.map((x) => {
const r = x.row;
return {
oId: String(r.inner_order_id || ''),
soId: String(r.so_id || ''),
shopName: String(r.shop_name || ''),
// 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准)
createdAt: x.createdAt,
// 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」
sendTime: formatDateTime(r.send_time),
// 付款时间(并列展示,方便对账)
payTime: x.payAt,
// 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算
auditTime: x.auditAt,
// 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记
benchmarkTime: x.benchmarkAt,
benchmarkSource: x.benchmarkSource,
state: x.state,
logisticStatus: String(r.logistic_status_name || ''),
// 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。
// 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。
logisticId: String(r.logistic_id || '').trim(),
logisticCompany: String(r.logistic_company_name || '').trim(),
centerKey: x.center.key,
centerName: x.center.name,
warehouseName: x.warehouseName,
// 已等时长(小时,保留 1 位)与分钟(排序都用分钟)
waitedHours: Math.round((x.waited / 60) * 10) / 10,
overdueMinutes: x.waited,
};
});
const heldOrders = heldDetail.map((x) => {
const r = x.row;
const from = x.benchmarkAt || '';
return {
oId: String(r.inner_order_id || ''),
soId: String(r.so_id || ''),
shopName: String(r.shop_name || ''),
createdAt: x.createdAt,
sendTime: formatDateTime(r.send_time),
payTime: x.payAt,
auditTime: x.auditAt,
benchmarkTime: x.benchmarkAt,
benchmarkSource: x.benchmarkSource,
state: x.state,
holdReason: x.holdReason,
logisticStatus: String(r.logistic_status_name || ''),
logisticId: String(r.logistic_id || '').trim(),
logisticCompany: String(r.logistic_company_name || '').trim(),
centerKey: x.center.key,
centerName: x.center.name,
warehouseName: x.warehouseName,
heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0,
};
});
const centers = centerList.map((c) => {
const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 };
return {
key: c.key,
name: c.name,
placeholder: c.placeholder,
warehouseCount: c.warehouses.length,
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
overdueUnshipped: b.pendingCount,
oldestDays: b.oldestDays,
heldCount: b.heldCount,
// 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00)
windowEnd: registry.centerWindowEnd(c.key, round),
};
});
// 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组)
const windowOverrides = centerList
.filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null)
.map((c) => ({
key: c.key,
name: c.name,
hour: registry.centerWindowEndHour(c.key, round.key),
windowEnd: registry.centerWindowEnd(c.key, round),
}));
const summary = centers.reduce((a, c) => {
a.overdueUnshipped += c.overdueUnshipped;
if (c.overdueUnshipped > 0) a.overdueCenterCount += 1;
if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays;
a.heldCount += c.heldCount || 0;
return a;
}, emptySummary());
summary.voidCount = voidCount;
summary.carrierSkipped = carrierSkipped;
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
// 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false ——
// 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新),
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
// 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
// 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」,
// 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
const freshness = {
stateMaxQuery: snapshotAt,
ageMinutes: snapshotAgeMin,
stale,
coversDeadline,
hint: [
stale
? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
: '',
!coversDeadline
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
: '',
].filter(Boolean).join(' '),
};
const payload = {
generatedAt: formatDateTime(now),
date: round.date,
round: {
key: round.key,
label: round.label,
verifyHour: round.verifyHour,
windowEnd: round.windowEnd,
},
notStarted: false,
isPreviousDay: false,
rounds,
deadline: round.deadline,
// boundary='audit':轮次归属、已等时长都按**首次审核时间**算
window: {
start: round.windowStart,
end: round.windowEnd,
includeHistory: true,
boundary: 'audit',
measuredBy: 'audit',
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
overrides: windowOverrides,
},
summary,
centers,
orders,
// 不考核的快递(前端文案直接用,避免两边各写一份)
excludedCarriers: EXCLUDED_CARRIERS,
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
heldOrders,
truncated: pendingRows.length > orders.length,
detailLimit: limit,
detailDegraded: false,
freshness,
cached: false,
cacheAgeSec: 0,
note: '',
};
resultCache = { key: cacheKey, at: Date.now(), data: payload };
return payload;
}
module.exports = {
getShipTimeliness,
clearShipTimelinessCache,
DEFAULT_DETAIL_LIMIT,
MAX_DETAIL_LIMIT,
};

View File

@ -525,6 +525,74 @@ test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)
}
});
// ⑦-e 成品库存多仓合并(2026-10-17):
// 实物搬到「简尚-横林二仓品牌仓」(16132681) 时,归属仓 (15870495) 为 0,
// 只查归属仓会把有货的成品算成 0(本次 5 个误报缺货 + 2 个误报预警)。
test('replenish index: sums stock across brand warehouse whitelist (⑦-e)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
const captured = [];
cloudDatabase.query = async (sql) => {
captured.push(sql);
if (sql.includes('jst_packInventoryInfo')) {
// 箱表:同样的「老仓 0 / 新仓 10」格局
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
}
if (sql.includes('jst_inventoryInfo')) {
// 关键:SUM 之后返回的是**多仓合计**。旧口径只查单仓时这里拿不到这笔货。
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
}
if (sql.includes('pm_stockMonitor')) {
return [{
skuId: 'MKHDNZ0409260600788',
fifteenAvgSale: 1,
monitorStock: 0,
safetyDays: 3,
suggestStock: 0,
monitorDate: new Date('2026-10-17T00:00:00Z'),
}];
}
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) return [];
if (sql.includes('jst_qmcust_orderlist')) return [];
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
});
try {
const index = await svc._private.loadReplenishIndex();
const row = index.bySku.get('MKHDNZ0409260600788');
assert.equal(row.stock, 10, '多仓求和后必须看到新仓的 10 件,不能是 0');
// SQL 层必须真的按白名单多仓取数,而不是退回单仓
const invSql = captured.find((s) => s.includes('jst_inventoryInfo'));
assert.ok(invSql.includes('IN (15870495,16132681)'),
`实时库存必须按仓白名单取数,实际: ${invSql}`);
assert.ok(invSql.includes('GROUP BY') && invSql.includes('SUM('),
'实时库存必须多仓 SUM + GROUP BY(旧版是 ORDER BY updateTime DESC 取首条)');
const packSql = captured.find((s) => s.includes('jst_packInventoryInfo'));
assert.ok(packSql.includes('IN (15870495,16132681)'),
`箱表必须按同一份仓白名单取数,实际: ${packSql}`);
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});
// 白名单常量本身要被导出(新增同类仓时改这一处即可)
test('replenish index: brand warehouse whitelist contains main + Henglin No.2 brand warehouse', () => {
const svc = require('../src/services/mkhBomService');
const ids = svc._private.BRAND_WMS_CO_IDS;
assert.ok(Array.isArray(ids), 'BRAND_WMS_CO_IDS 必须是数组');
assert.ok(ids.includes(15870495), '必须含崔桥工厂品牌仓(归属主仓)');
assert.ok(ids.includes(16132681), '必须含横林二仓品牌仓(2026-10-13 新增)');
});
test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');

View File

@ -6,15 +6,13 @@ const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchPickingBinRows,
fetchPickingBinSkuRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_TYPES,
BIN_TYPES,
PICKING_BIN_BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
test('buildInClause 按位展开参数', () => {
@ -32,93 +30,16 @@ test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
assert.ok(expr.includes("ELSE 'zero'"));
});
test('默认窗口为 30 天,默认只看拣选位', () => {
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_DEFAULT_TYPES, ['拣选']);
assert.deepEqual(BIN_TYPES, ['拣选', '存货']);
});
test('fetchPickingBinRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 15 }),
TypeError
);
});
test('fetchPickingBinRows 无仓库或缺失窗口时直接返回空结果,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchPickingBinRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 15 }), []);
assert.deepEqual(await fetchPickingBinRows(db, [101], { shipFrom: null, shipTo: 'b', days: 15 }), []);
assert.equal(called, 0);
});
test('fetchPickingBinRows 把仓库/类型/窗口参数一并传给业务库,并做分摊与分档', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, binName: 'XA01-17-12', binType: '拣选', stockQty: 21, skuCount: 1, salesQty: 9, dailySales: 0.6, gradeKey: 'slow2' },
];
},
};
const rows = await fetchPickingBinRows(db, [101, 102], {
shipFrom: '2026-09-23 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 15,
binTypes: ['拣选'],
});
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-17-12',
binType: '拣选',
stockQty: 21,
skuCount: 1,
salesQty: 9,
dailySales: 0.6,
gradeKey: 'slow2',
});
// 参数必须带上两个仓库、发货窗口与天数,以及仓位类型过滤
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-23 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 15);
assert.equal(captured.params.t0, '拣选');
// 分摊:SKU 销量除以其占用的同类型仓位数
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
// 仓位类型:bin_group 为空视为拣选位
assert.ok(captured.sqlText.includes("THEN N'拣选'"));
// 只统计有库存的仓位
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
});
test('fetchPickingBinRows 不传类型时不过滤 binType', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinRows(db, [101], {
shipFrom: 'a', shipTo: 'b', days: 15, binTypes: [],
});
assert.ok(!captured.sqlText.includes('a.binType IN'));
assert.equal(captured.params.t0, undefined);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', stockQty: 5 }] };
const rows = await fetchPickingBinRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].dailySales, 0);
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
@ -128,14 +49,16 @@ test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () =>
);
});
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组', async () => {
const db = { query: async () => [{ skuId: 'S1' }] };
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 () => {
test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商品整档销量', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
@ -143,29 +66,106 @@ test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径
return [
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
stockQty: 12, skuCount: 2, salesQty: 30, dailySales: 1,
binSalesQty: 45, gradeKey: 'slow1',
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: 15, dailySales: 0.5,
binSalesQty: 45, gradeKey: 'slow1',
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
dailySales: 1.5, gradeKey: 'slow1',
},
];
},
};
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
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']);
// 一个仓位两个 SKU 拆成两行,库存为各商品在该仓位的实际库存
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
// 动销等级仍属仓位,同一仓位多行相同
assert.equal(rows[0].gradeKey, 'slow1');
assert.equal(rows[1].gradeKey, 'slow1');
assert.equal(rows[0].binSalesQty, 45);
assert.equal(rows[0].skuCount, 2);
// SQL 含商品编码列与倍数分摊
assert.ok(captured.sqlText.includes('b.skuId'));
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
// 等级按商品自身日销,同一仓位的多行可以不同
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

@ -0,0 +1,667 @@
/**
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const registry = require('../src/services/warehouseShipTimelinessRegistry');
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
});
test('resolveCenter 能捞到各种命名的子仓', () => {
const cases = [
['简尚-运营C横林二仓专用仓', 'HL2'],
['简尚-横林二仓京东仓', 'HL2'],
['京仓-横林二仓京东自营仓', 'HL2'],
['简尚-崔桥工厂仓唯品仓', 'CQ'],
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
['简尚-新苏北临时仓', 'XSB'],
['简尚-分销部睢宁专用仓', 'SN'],
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
['简尚-河南洛宁仓', 'HN_LN'],
];
for (const [name, expected] of cases) {
assert.equal(registry.resolveCenter(name)?.key, expected, name);
}
});
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
});
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
const shouldBeNull = [
'工厂-山东菏泽森美代发仓',
'工厂-山东恒思代发仓',
'工厂-河南千优代发仓',
'工厂-河北霸州派雅世佳代发仓',
'简尚-苏州福利品仓',
];
for (const name of shouldBeNull) {
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
}
});
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
assert.ok(c, 'CQ_WIP 必须存在');
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
});
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
});
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
const r = registry.resolveActiveRound(at(h, m));
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
assert.equal(r.key, 'NOON');
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
assert.equal(r.deadline, '2026-10-20 13:00:00');
assert.equal(r.verifyHour, 13);
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
assert.equal(r.includeHistory, true);
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
}
});
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
const r = registry.resolveActiveRound(at(18, 0));
assert.equal(r.key, 'AFTERNOON');
assert.equal(r.deadline, '2026-10-20 18:00:00');
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
});
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
const rounds = registry.describeRounds(at(16, 5));
assert.deepEqual(
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
);
assert.equal(rounds[0].active, true);
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
});
test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
const [noon, afternoon] = registry.describeRounds(at(16, 5));
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
'18:00 是当天最后一轮 → 次日 13:00');
// 跨月边界也要对
assert.equal(
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
'2026-11-01 13:00:00'
);
});
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
test('数据源必须是聚水潭实时状态表,不是订单表', () => {
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
});
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
assert.equal(registry.SHIPPING_STATUS, '发货中');
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
assert.equal(registry.ABNORMAL_STATUS, '异常');
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
});
test('作废单(已取消)明确不考核', () => {
assert.ok(
registry.IGNORED_STATES.includes('已取消'),
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
);
assert.ok(registry.IGNORED_STATES.includes('已发货'));
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
});
test('创建时间要从订单表补,且只补窄列', () => {
assert.deepEqual(
[...registry.CREATED_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表都要查(店铺互斥,交集为 0)'
);
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个');
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, scenario = 'default') {
const wmsId = 15038426;
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
const defaultFacts = {
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
// 12:拆单父单,status 不是 Split 但 is_split=1
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
// 只能进 18:00 轮,不能进 13:00 轮
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
};
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
const noonFacts = {
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
};
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
const mk = (o) => ({
shop_name: '测试店',
logistic_status_name: '',
query_time: '2026-10-20 11:18:27',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
...o,
});
const defaultRows = [
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
last_oa_confirm_time: '2026-10-20 16:30:00' }),
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
last_oa_confirm_time: '2026-09-26 00:15:00' }),
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: null }),
// 5:异常 → 不计考核
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: '2026-09-23 01:05:00' }),
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
last_oa_confirm_time: '2026-10-20 17:05:00' }),
// 7:被业务排除的京东云仓 → 不归属任何主仓
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
last_oa_confirm_time: '2026-09-25 00:10:00' }),
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 9:作废单(已取消)—— 业务定性不考核
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
last_oa_confirm_time: '2026-09-21 08:10:00' }),
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
last_oa_confirm_time: '2026-09-21 10:05:00' }),
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
last_oa_confirm_time: '2026-09-21 11:05:00' }),
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
last_oa_confirm_time: '2026-09-21 12:05:00' }),
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
// 本版必须算超时(业务口径:到点未发就算超时)
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
last_oa_confirm_time: '2026-10-20 15:30:00' }),
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
last_oa_confirm_time: '2026-10-20 15:35:00' }),
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
last_oa_confirm_time: '2026-10-20 14:35:00' }),
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
last_oa_confirm_time: '2026-10-20 13:35:00' }),
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
last_oa_confirm_time: '2026-10-20 12:35:00' }),
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
last_oa_confirm_time: '2026-09-22 09:05:00' }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
const noonRows = [
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
last_oa_confirm_time: '2026-10-20 07:20:00' }),
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
last_oa_confirm_time: '2026-10-20 06:40:00' }),
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
last_oa_confirm_time: '2026-10-19 20:10:00' }),
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
last_oa_confirm_time: '2026-10-20 07:40:00' }),
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
return [
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
];
}
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
calls.backfill += 1;
calls.backfillSql.push(text);
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
const m = text.match(/IN \(([0-9,\s]+)\)/u);
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
return ids
.filter((id) => factsMap[id] && factsMap[id].table === table)
.map((id) => {
const x = factsMap[id];
return {
o_id: id,
created: x.created,
status: x.status,
send_date: x.send_date,
is_merge: x.is_merge,
is_split: x.is_split,
};
});
}
calls.state += 1;
calls.sql.push(text);
calls.params.push(params || {});
return rows;
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return { calls, wmsId };
}
function withStubbedService(fn, scenario = 'default') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
await fn(svc, ctx);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
}
};
}
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
// 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
const c = data.centers.find((x) => x.key === 'CQ');
assert.ok(c, '应能归到崔桥工厂仓');
// 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
// = 6 单
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
assert.equal(data.summary.overdueUnshipped, 7);
assert.equal(data.summary.overdueCenterCount, 3);
assert.equal(data.summary.oldestDays, 25);
assert.equal(data.summary.heldCount, 2);
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
assert.equal(data.orders.length, 7);
assert.equal(String(data.orders[0].oId), '1');
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间');
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示');
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
assert.equal(data.orders[0].benchmarkSource, 'last_audit');
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
assert.equal(data.orders[0].overdueMinutes, 37020);
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
const fresh = data.orders.find((o) => String(o.oId) === '13');
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
assert.equal(fresh.overdueMinutes, 150);
assert.equal(fresh.waitedHours, 2.5);
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
assert.equal(lateAudit.overdueMinutes, 290);
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
const reAudited = data.orders.find((o) => String(o.oId) === '25');
assert.ok(reAudited, '重审单也要算');
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
const third = data.orders.find((o) => String(o.oId) === '3');
assert.ok(third, '补不到创建时间的单也要算超时');
assert.equal(third.createdAt, '', '确实没补到');
assert.equal(third.benchmarkSource, 'last_audit');
assert.equal(third.payTime, '2026-09-26 00:10:00');
// 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00)
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
assert.deepEqual(
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
);
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
// 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
for (const id of ['9', '10', '11', '12']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
}
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
assert.ok(
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
'不考核快递的单不能出现在超时明细里'
);
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
// 不计考核清单
assert.equal(data.heldOrders.length, 2);
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
// 新鲜度直接取自主查询的 query_time,不再额外扫表
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
assert.equal(
data.freshness.stale,
data.freshness.ageMinutes > 90,
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
);
if (data.freshness.stale) {
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
}
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
// ── 性能护栏 ────────────────────────────────────────────────────────
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
const sql = calls.sql[0];
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
// 本版基准是审核时间 → 两列审核时间都必须在投影里
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
for (const bs of calls.backfillSql) {
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s');
}
// ⚠️ 窗口参数走 params,不在 SQL 里手写
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
assert.equal(calls.params[0].shipStatus, '发货中');
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
}));
test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
assert.equal(data.round.key, 'NOON');
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
// 24 号:今天 07:40 审核 → 同理不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
'今天 07:40 审核 → 不进 13:00 轮');
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
'下午才审核的老单不能进 13:00 轮');
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
const b = data.orders.find((o) => String(o.oId) === '22');
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
assert.equal(b.benchmarkSource, 'last_audit');
assert.equal(b.overdueMinutes, 380);
assert.equal(b.waitedHours, 6.3);
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
const d = data.orders.find((o) => String(o.oId) === '23');
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
assert.equal(d.overdueMinutes, 1010);
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
assert.equal(data.summary.overdueUnshipped, 2);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
}, 'noon'));
test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => {
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
assert.equal(data.notStarted, true);
assert.equal(data.round, null);
assert.equal(data.orders.length, 0);
assert.equal(data.summary.overdueUnshipped, 0);
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
assert.equal(data.centers.length, registry.CENTERS.length);
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
assert.equal(calls.state, 0, '未到核验时间不应查库');
assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表');
assert.match(data.note, /13:00/u);
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
}));
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
const base = new Date('2026-10-21T00:04:00Z');
const bj = registry.beijingNow(base);
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+ `-${String(d.getDate()).padStart(2, '0')} `
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
'北京时间 00:00~08:00 不能取成前一天');
});
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
assert.equal(noon.key, 'NOON');
assert.equal(noon.date, '2026-10-21');
assert.equal(noon.deadline, '2026-10-21 13:00:00');
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
});

View File

@ -0,0 +1,23 @@
import request from './request';
// 发货时效要扫两张订单表 + 出库表(rpt 下全是视图),冷启动实测 40~50 秒,
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
// params.limit 明细条数上限
export const getWarehouseShipTimeliness = (params = {}) => request.get(
'/api/warehouse-ship-timeliness/list',
{
params,
timeout: TIMELINESS_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 仅取今日两轮核验的描述(不打订单表,秒回),用于顶部提示
export const getWarehouseShipTimelinessRounds = () => request.get(
'/api/warehouse-ship-timeliness/rounds',
{ timeout: 30000 }
);

View File

@ -36,6 +36,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
{
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
children: [

View File

@ -1,4 +1,4 @@
/** 拣选库位动销分析 —— 表头与口径说明 */
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
export const PICKING_BIN_COLUMNS = [
{
@ -9,37 +9,37 @@ export const PICKING_BIN_COLUMNS = [
{
key: 'binName',
label: '仓位',
tip: '系统仓位编码(jst_packInventoryInfo.bin)',
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
},
{
key: 'binType',
label: '类型',
tip: '拣选 = 拣货位(bin_group 为空);存货 = 存货位(bin_group=存货)',
tip: '统计范围固定为真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录和存货位不纳入动销分析',
},
{
key: 'skuId',
label: '商品编码',
tip: '商品编码(jst_packInventoryInfo.sku_id),一行就是一个「仓位 × 商品」组合',
},
{
key: 'stockQty',
label: '库存数量',
tip: '该仓位当前库存件数(jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty)',
},
{
key: 'skuCount',
label: 'SKU数',
tip: '该仓位内存放且有库存的 SKU 个数',
tip: '该商品在这个仓位上的当前库存件数(同一仓位多行的库存相加 = 仓位库存)',
},
{
key: 'salesQty',
label: '销量',
tip: '近 N 天该仓位的分摊销量:Σ(仓位内各 SKU 销量 ÷ 该 SKU 占用的同类型仓位数)',
label: '商品销量',
tip: '近 N 天该商品的销量(整档,不按仓位分摊):含线上订单、组合装子件拆分、全民客户(B2B分销)渠道;同一商品占几个仓位,这几行显示的销量都一样,所以这一列不能直接按行求和',
},
{
key: 'dailySales',
label: '日销',
tip: '分摊销量 ÷ 统计天数,动销等级按此值划分',
label: '商品日销',
tip: '商品销量 ÷ 统计天数,动销等级严格按此值划分',
},
{
key: 'gradeKey',
label: '动销等级',
tip: '爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
tip: '按商品日销划分:爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
},
];
@ -52,13 +52,6 @@ export const PICKING_BIN_WINDOW_OPTIONS = [
export const PICKING_BIN_DEFAULT_DAYS = 30;
export const PICKING_BIN_TYPE_OPTIONS = [
{ label: '拣选位', value: '拣选' },
{ label: '存货位', value: '存货' },
];
export const PICKING_BIN_DEFAULT_TYPES = ['拣选'];
/** 动销等级展示色(与后端 key 对应) */
export const PICKING_BIN_GRADE_COLORS = {
hot: 'red',
@ -70,19 +63,29 @@ export const PICKING_BIN_GRADE_COLORS = {
};
export const PICKING_BIN_DESCRIPTION = [
'动销等级口径:按「单个仓位的日均销量(日销)」分档,日销 = 该仓位近 N 天销量 ÷ N。',
'【统计范围】只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录(pack_type=Pack)和存货位不纳入动销分析。',
'',
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
'',
'【动销等级口径】按「商品日销」分档:商品日销 = 该商品近 N 天销量 ÷ N。',
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该仓位所有 SKU 均无销量。',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该商品无销量。',
' 等级判断的是商品本身的销售速度,与其摆放位置无关 —— 同一件商品占几个仓位,等级都相同。',
'默认统计窗口为近 30 天,可切换 7 / 15 / 30 / 60 天。',
'',
'【销量为什么要分摊】',
'【为什么不用分摊销量来定等级】',
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
' 若直接把 SKU 销量累加到每个仓位,销量会被放大数倍。',
' 因此按「SKU 销量在其占用的同类型仓位数之间平均分摊」计算库位销量,',
' 恒等式已实测校验:全部库位销量之和 = 有库位记录的那些 SKU 的销量之和(差异 0)。',
' 注意该合计小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
' 若把销量先摊到每个仓位再定等级,一件日销 100 的爆款放在 10 个仓位就会被算成日销 10 —— 判断的是「摆法」而不是商品。',
'',
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位汇总);',
'销量取 rpt.jst_Orders × rpt.jst_OrderItem(按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
'仓位类型:bin_group 为空视为拣选位,bin_group=存货 为存货位。',
'【商品销量为什么不能直接按行求和】',
' 商品销量取的是整档销量(不除以占用仓位数),同一件商品跨仓位会重复出现,所以该列相加无意义。',
' 需要按行求和请用导出文件里的「分摊销量」列:商品销量 ÷ 其占用的同类型仓位数。',
' 恒等式已实测校验(差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。',
' 注意它小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
'',
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位 + 商品汇总);',
'销量按 send_date 窗口统计 4 个来源:① jst_Orders 普通商品行;② jst_Orders 组合装行(ZH-拼接编码)按组合装映射展开为子件;',
'③ jst_qmcust_orderlist 全民客户(B2B分销)普通行(排除拆单父单 Split);④ qmcust 组合装行同样展开。',
'统计范围:只取 pack_type=Bin 且 bin_group 为空的真实拣选仓位,箱记录(pack_type=Pack)和存货位(bin_group=存货)不参与统计。',
];

View File

@ -119,6 +119,11 @@ const SIDEBAR_ROUTE_MAP = {
activePath: '/planning/operation/mkh-bom-data',
matchMode: 'prefix',
},
'warehouse-ship-timeliness': {
path: '/planning/operation/warehouse-ship-timeliness',
activePath: '/planning/operation/warehouse-ship-timeliness',
matchMode: 'prefix',
},
'daily-inventory': { path: '/planning/operation/daily-inventory' },
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },

View File

@ -953,6 +953,7 @@ const MkhBomData = () => {
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
</Space>
</Modal>
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>

View File

@ -13,8 +13,6 @@ import {
PICKING_BIN_COLUMNS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TYPE_OPTIONS,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_GRADE_COLORS,
PICKING_BIN_DESCRIPTION,
} from '../../constants/pickingBinSalesAnalysis';
@ -47,7 +45,6 @@ const PickingBinSalesAnalysis = () => {
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
const [binTypes, setBinTypes] = useState(PICKING_BIN_DEFAULT_TYPES);
const [warehouse, setWarehouse] = useState('');
const [grades, setGrades] = useState([]);
const [keyword, setKeyword] = useState('');
@ -55,9 +52,8 @@ const PickingBinSalesAnalysis = () => {
const params = useMemo(() => ({
days,
binType: binTypes.join(','),
...(warehouse ? { wmsCoId: warehouse } : {}),
}), [days, binTypes, warehouse]);
}), [days, warehouse]);
const load = useCallback(async (options = {}) => {
setLoading(true);
@ -85,7 +81,9 @@ const PickingBinSalesAnalysis = () => {
const kw = keyword.trim().toLowerCase();
return items.filter((it) => {
if (grades.length > 0 && !grades.includes(it.gradeKey)) return false;
if (kw && !String(it.binName).toLowerCase().includes(kw)) return false;
if (kw
&& !String(it.binName).toLowerCase().includes(kw)
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
return true;
});
}, [items, grades, keyword]);
@ -94,9 +92,10 @@ const PickingBinSalesAnalysis = () => {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: col.key,
align: ['warehouse', 'binName', 'binType', 'gradeKey'].includes(col.key) ? 'left' : 'right',
width: col.key === 'warehouse' ? 240 : (col.key === 'gradeKey' ? 170 : 110),
sorter: ['stockQty', 'skuCount', 'salesQty', 'dailySales'].includes(col.key)
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
width: col.key === 'warehouse' ? 240
: (col.key === 'gradeKey' ? 170 : (col.key === 'skuId' ? 150 : 110)),
sorter: ['stockQty', 'salesQty', 'dailySales'].includes(col.key)
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
: undefined,
render: (value) => {
@ -104,10 +103,10 @@ const PickingBinSalesAnalysis = () => {
const g = gradeMap.get(value);
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
}
if (col.key === 'binType') return <Text>{value || '—'}</Text>;
if (col.key === 'binType' || col.key === 'skuId') return <Text>{value || '—'}</Text>;
if (col.key === 'dailySales') return num(value, 2);
if (col.key === 'salesQty') return num(value, 2);
if (col.key === 'stockQty' || col.key === 'skuCount') return int(value);
if (col.key === 'stockQty') return int(value);
return value || '—';
},
})), [gradeMap]);
@ -171,16 +170,6 @@ const PickingBinSalesAnalysis = () => {
style={{ width: 110 }}
/>
</Space>
<Select
mode="multiple"
allowClear
value={binTypes}
options={PICKING_BIN_TYPE_OPTIONS}
onChange={(v) => setBinTypes(v && v.length > 0 ? v : PICKING_BIN_DEFAULT_TYPES)}
style={{ minWidth: 170 }}
placeholder="仓位类型"
maxTagCount={2}
/>
<Select
allowClear
value={warehouse || undefined}
@ -195,14 +184,14 @@ const PickingBinSalesAnalysis = () => {
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索仓位"
style={{ width: 160 }}
placeholder="搜索仓位 / 商品编码"
style={{ width: 180 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => load()} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
</Tooltip>
<Tooltip title="导出当前筛选下的库位明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级)。按「仓位 × 商品编码」拆行:同一仓位有多个商品时拆成多行,库存数量为该商品在此仓位的实际库存;销量为该商品按其占用仓位数分摊后的贡献,动销等级仍属仓位(同一仓位多行相同)">
<Tooltip title="导出当前筛选下的明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级),仅含拣选位。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
</Tooltip>
</Space>
@ -211,8 +200,8 @@ const PickingBinSalesAnalysis = () => {
<Alert
type="info"
showIcon
message={`按「单个仓位的日均销量」把库位分成 ${summary.length} 个动销等级,全部数值来自业务库实时取数`}
description="库存取仓位库存表,销量取销售出库单并在同类仓位间平均分摊(避免同一 SKU 占用多个仓位导致销量被放大)。点击下方等级卡片可筛选。"
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
style={{ marginBottom: 12 }}
/>
@ -228,14 +217,16 @@ const PickingBinSalesAnalysis = () => {
onClick={() => toggleGrade(s.key)}
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
>
<Statistic
title={s.label}
value={s.binCount}
suffix="位"
valueStyle={{ fontSize: 18 }}
/>
<Tooltip title={`${s.tip};明细 ${int(s.rowCount)} 行,库存合计 ${int(s.stockQty)} 件,日销合计 ${num(s.dailySales, 1)}(销量按去重商品计)`}>
<Statistic
title={s.label}
value={s.skuCount}
suffix="品"
valueStyle={{ fontSize: 18 }}
/>
</Tooltip>
<div className="picking-bin-card__meta">
库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
</div>
</Card>
</Col>
@ -243,9 +234,11 @@ const PickingBinSalesAnalysis = () => {
{total ? (
<Col flex="1 1 150px">
<Card size="small" className="picking-bin-card is-total">
<Statistic title={total.label} value={total.binCount} suffix="位" valueStyle={{ fontSize: 18 }} />
<Tooltip title={`去重商品 ${int(total.skuCount)} 个,涉及「仓位 × 商品」组合 ${int(total.rowCount)} 行`}>
<Statistic title={total.label} value={total.skuCount} suffix="品" valueStyle={{ fontSize: 18 }} />
</Tooltip>
<div className="picking-bin-card__meta">
库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
</div>
</Card>
</Col>
@ -256,23 +249,23 @@ const PickingBinSalesAnalysis = () => {
className="picking-bin-table"
columns={columns}
dataSource={filteredRows}
rowKey={(r) => `${r.wmsCoId}::${r.binType}::${r.binName}`}
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
size="small"
bordered
pagination={{
pageSize: 50,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (t) => `共 ${t} 个库位`,
showTotal: (t) => `共 ${t} 条明细`,
}}
scroll={{ x: 'max-content', y: 'calc(100vh - 420px)' }}
/>
</Spin>
<Text type="secondary" className="picking-bin-footer">
当前筛选出 {filteredRows.length} 个库位
{items.length !== filteredRows.length ? `(共 ${items.length} 个)` : ''};
已选类型:{(data?.binTypes ?? []).join('、') || '—'}。
当前筛选出 {filteredRows.length} 条明细
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
</Text>
<Modal

View File

@ -62,6 +62,8 @@
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picking-bin-table .ant-table-thead > tr > th {

View File

@ -0,0 +1,546 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
import './style.css';
const { Title, Text } = Typography;
// 核验点只有 13:00 / 18:00 两个,10 分钟刷新一次足够。
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
const AUTO_REFRESH_MS = 10 * 60 * 1000;
const number = (value, digits = 0) => (
value === null || value === undefined || value === ''
? '0'
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
);
// 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」。
// ⚠️ 口径放开后(含历史未发单)会出现跨天的超时,必须给「天」,否则显示成「8640 小时」。
const 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} 天`;
};
/**
* 「不计考核」清单的列(待审核 / 订单异常)。
* 业务反馈(2026-10-20):贴进来的一批单号在聚水潭查不到,实测真实状态是
* 「已取消(发货前售后)」或「异常」—— 这类单不该算仓库超时。
* 这里把它们单列出来,回答「为什么这些单没进超时列表」。
*/
const HELD_COLUMNS = [
{ title: '内部单号', dataIndex: 'oId', width: 100 },
{ title: '线上订单号', dataIndex: 'soId', width: 170, ellipsis: true },
{ title: '店铺', dataIndex: 'shopName', width: 170, ellipsis: true },
{ title: '发货仓', dataIndex: 'warehouseName', width: 160, ellipsis: true },
{
title: '挂起原因',
dataIndex: 'holdReason',
width: 110,
render: (v) => (
<Tag bordered={false} color={v === '订单异常' ? 'red' : 'default'}>{v || '—'}</Tag>
),
},
{
title: '发货时间',
dataIndex: 'sendTime',
width: 178,
ellipsis: true,
render: (v, row) => {
if (v) return v;
const no = row?.logisticId;
if (no) {
return (
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})`}>
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
</Tooltip>
);
}
return <Tag bordered={false} color="default">未发货</Tag>;
},
},
{
title: '审核时间',
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
},
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
];
const DETAIL_COLUMNS = [
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
{ title: '内部单号', dataIndex: 'oId', width: 110 },
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
{
title: '线上订单号',
dataIndex: 'soId',
width: 178,
ellipsis: true,
render: (v) => (v ? <span className="wst-detail__soid">{v}</span> : '—'),
},
{
title: '店铺',
dataIndex: 'shopName',
width: 180,
ellipsis: true,
render: (v) => (v || '—'),
},
{
title: '发货仓',
dataIndex: 'warehouseName',
width: 170,
ellipsis: true,
render: (v) => (v || '—'),
},
{
// 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间)
title: (
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
<span>发货时间</span>
</Tooltip>
),
dataIndex: 'sendTime',
width: 178,
ellipsis: true,
render: (v, row) => {
if (v) return v;
const no = row?.logisticId;
if (no) {
return (
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})。聚水潭还没回传发货时间,所以还没从超时列表里消失`}>
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
</Tooltip>
);
}
return <Tag bordered={false} color="default">未发货</Tag>;
},
},
{
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
title: (
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
<span>审核时间</span>
</Tooltip>
),
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v, row) => (v
? v
: (
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
</Tooltip>
)),
},
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
{
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
title: (
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
<span>发货状态</span>
</Tooltip>
),
dataIndex: 'state',
width: 104,
render: (v) => (
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
),
},
{
title: '物流状态',
dataIndex: 'logisticStatus',
width: 100,
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
},
{
title: (
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
<span>已等时长</span>
</Tooltip>
),
dataIndex: 'overdueMinutes',
width: 112,
render: (v) => <span className="wst-detail__overdue">{formatOverdue(v)}</span>,
},
];
/**
* 仓储中心 24 小时发货时效(独立页面)
* ────────────────────────────────────────────────────────────────
* 业务口径(yanhui 2026-10-20):
* · 11 个主仓,子仓并入主仓一起算;
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
* (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
* 今天之前审核仍未发的(历史未发)两个轮次都算;
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
* 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核);
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
* · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发);
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
*
* 数据来自 /api/warehouse-ship-timeliness/list(service 层见
* backend/src/services/warehouseShipTimelinessService.js)。
*/
function WarehouseShipTimeliness() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 选中的主仓:为空时右侧展示全部仓的超时明细
const [activeCenter, setActiveCenter] = useState('');
// 「业务挂起(疑问单)」明细弹窗
const [heldOpen, setHeldOpen] = useState(false);
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const result = await getWarehouseShipTimeliness({ limit: 1000, force: force ? 1 : 0 });
if (!result || result.success === false) {
setError(result?.error || '加载发货时效失败');
return;
}
setData(result.data || null);
} catch (loadError) {
setError(loadError?.response?.data?.error || loadError?.message || '加载发货时效失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const timer = window.setInterval(() => load(), AUTO_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load]);
const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]);
const orders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
// 业务挂起(疑问单)—— 与 orders 互斥,两者相加才是待出库池
const heldOrders = useMemo(
() => (Array.isArray(data?.heldOrders) ? data.heldOrders : []),
[data]
);
const summary = data?.summary || {};
const round = data?.round || null;
const freshness = data?.freshness || null;
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
const notStarted = Boolean(data?.notStarted);
const hasRound = Boolean(round);
const cutoffText = String(round?.windowEnd || '').slice(11, 16);
const windowEndText = String(data?.window?.end || '').slice(11, 16);
// 仓级例外窗口:18:00 轮里 山东两仓 15:00、河南洛宁 13:00(业务 2026-10-20 指定)
const windowOverrides = Array.isArray(data?.window?.overrides) ? data.window.overrides : [];
const overrideText = windowOverrides
.map((o) => `${o.name} ${String(o.hour).padStart(2, '0')}:00`)
.join('、');
// 未到核验时间时的提示文案,全部由后端给,前端不写死时刻
const nextRoundText = String(data?.nextRoundAt || '').slice(11, 16) || '13:00';
const nextWindowEndText = String(data?.nextWindowEnd || '').slice(11, 16) || '07:00';
const activeCenterName = useMemo(() => {
if (!activeCenter) return '';
const hit = centers.find((c) => c.key === activeCenter);
return hit ? hit.name : activeCenter;
}, [activeCenter, centers]);
const detailOrders = useMemo(() => (
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
), [activeCenter, orders]);
return (
<SycmPage className="wst-page">
<header className="wst-header">
<Title level={3}>仓储中心 24 小时发货时效</Title>
<Text type="secondary">
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
</Text>
<Text type="secondary" className="wst-note">
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
13:00 之前不核验,显示「未到核验时间」。
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
—— 该字段经常不回写,会把早就发货的单误判成超时。
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
已按物流公司自动剔除。
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
</Text>
</header>
<Card className="wst-status" bordered={false}>
<div className="wst-status__row">
<div className="wst-status__round">
{hasRound ? (
<>
<span className="wst-status__badge">{round.label}</span>
<span className="wst-status__label">
核验今天 {cutoffText} 前审核的订单
</span>
<span className="wst-status__window">
含历史未发 · 到点未发即超时
{overrideText ? `(${overrideText} 为仓级例外)` : ''}
</span>
</>
) : (
<>
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
<span className="wst-status__label">
{nextRoundText} 开始首轮核验
(核验今天 {nextWindowEndText} 前审核的订单)
</span>
</>
)}
</div>
<Space size={8}>
{data?.generatedAt ? (
<Text type="secondary" className="wst-status__time">
数据更新于 {String(data.generatedAt).slice(11, 19)}
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
</Text>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
</Space>
</div>
</Card>
{error ? (
<Alert
type="error"
showIcon
className="wst-alert"
message={error}
action={<Button size="small" onClick={load}>重试</Button>}
/>
) : null}
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
<Alert
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
type={freshness.coversDeadline === false ? 'error' : 'warning'}
showIcon
className="wst-alert"
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
description={freshness.hint}
/>
) : null}
<div className="wst-stats">
<Card bordered={false} className="wst-stat">
<Statistic
title={(
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
<span>超时未发货</span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.overdueUnshipped ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
/>
</Card>
<Card
bordered={false}
className="wst-stat wst-stat--held"
onClick={() => setHeldOpen(true)}
>
<Statistic
title={(
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
<span>不计考核 <span className="wst-stat__more">明细</span></span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.heldCount ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.heldCount || 0) > 0 ? '#8c8c8c' : undefined }}
/>
</Card>
<Card bordered={false} className="wst-stat">
<Statistic
title="涉及仓库"
value={notStarted ? 0 : (summary.overdueCenterCount ?? 0)}
suffix="个"
formatter={(v) => (notStarted ? '—' : number(v))}
/>
</Card>
<Card bordered={false} className="wst-stat">
<Statistic
title="最久未发货"
value={notStarted ? 0 : (summary.oldestDays ?? 0)}
suffix="天"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.oldestDays || 0) >= 7 ? '#d46b08' : undefined }}
/>
</Card>
</div>
<div className="wst-body">
<Card
bordered={false}
className="wst-centers"
title="仓储中心"
extra={<Text type="secondary" className="wst-centers__hint">点仓库看明细</Text>}
>
<Spin spinning={loading && !data} size="small">
<ul className="wst-centers__list">
{centers.map((c) => {
const isActive = activeCenter === c.key;
const disabled = c.placeholder || notStarted;
return (
<li key={c.key}>
<Tooltip
placement="right"
title={c.placeholder
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
: notStarted
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
+ `超时未发货 ${c.overdueUnshipped} 单`
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
>
<button
type="button"
className={[
'wst-center',
isActive ? 'is-active' : '',
!c.overdueUnshipped ? 'is-empty' : '',
disabled ? 'is-disabled' : '',
].filter(Boolean).join(' ')}
onClick={() => {
if (disabled) return;
setActiveCenter(isActive ? '' : c.key);
}}
>
<span className="wst-center__name">{c.name}</span>
{c.placeholder ? (
<span className="wst-center__badge wst-center__badge--none">未接入</span>
) : (
<span className={`wst-center__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
{notStarted ? '—' : (c.overdueUnshipped > 0 ? c.overdueUnshipped : '—')}
</span>
)}
</button>
</Tooltip>
</li>
);
})}
</ul>
</Spin>
</Card>
<Card
bordered={false}
className="wst-detail"
title={(
<span>
超时未发货订单
<span className="wst-detail__scope">
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
? `(已截断,仅显示前 ${data.detailLimit} 条)`
: ''}
</span>
</span>
)}
extra={activeCenter ? (
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
) : null}
>
<Spin spinning={loading && !data}>
{notStarted ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={`未到核验时间,今日 ${nextRoundText} 开始首轮核验`}
/>
) : detailOrders.length ? (
<Table
rowKey={(r) => `${r.source}-${r.oId}`}
size="small"
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
dataSource={detailOrders}
columns={DETAIL_COLUMNS}
scroll={{ x: 1642 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有超时未发货的订单" />
)}
</Spin>
</Card>
</div>
<Modal
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
open={heldOpen}
onCancel={() => setHeldOpen(false)}
footer={null}
width={1080}
destroyOnClose
>
<Alert
type="info"
showIcon
className="wst-alert"
message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
/>
{heldOrders.length ? (
<Table
rowKey={(r) => `${r.oId}`}
size="small"
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
dataSource={heldOrders}
columns={HELD_COLUMNS}
scroll={{ x: 1280 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有待审核 / 异常的单" />
)}
</Modal>
<Text type="secondary" className="wst-foot">
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
发货 = 聚水潭回传的发货时间。
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
<b>自提 / 中铁 / 壹米滴答不考核</b>
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
</Text>
</SycmPage>
);
}
export default WarehouseShipTimeliness;

View File

@ -0,0 +1,255 @@
.wst-page {
padding: 16px 20px 24px;
}
.wst-header {
margin-bottom: 14px;
}
.wst-header .ant-typography {
margin-bottom: 4px;
}
.wst-note {
display: block;
max-width: 1180px;
margin-top: 8px;
padding: 10px 14px;
/* 页面顶部是深色渐变,直接放浅色小字会看不清,给口径说明一块浅底 */
background: var(--sycm-bg-container);
border: 1px solid var(--sycm-border-color);
border-radius: 8px;
font-size: 12px;
line-height: 1.75;
}
/* ============ 轮次状态条 ============ */
.wst-status {
margin-bottom: 12px;
}
.wst-status__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wst-status__round {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.wst-status__badge {
padding: 1px 8px;
border-radius: 4px;
background: rgba(22, 119, 255, 0.12);
color: #0958d9;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.wst-status__badge--idle {
background: rgba(136, 135, 128, 0.14);
color: var(--sycm-text-tertiary);
}
.wst-status__label {
font-size: 13px;
color: var(--sycm-text-primary);
}
.wst-status__window,
.wst-status__time {
font-size: 12px;
color: var(--sycm-text-tertiary);
}
.wst-alert {
margin-bottom: 12px;
}
/* ============ 统计卡 ============ */
/* 5 张卡(超时未出库 / 业务挂起 / 涉及仓库 / 最久未出库 / 合计金额)
用 grid 而不是 Row/Col:24 栅格除不尽 5,Row/Col 会把第 5 张挤到下一行。 */
.wst-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
@media (max-width: 1500px) {
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.wst-stat {
height: 100%;
}
/* 「业务挂起」卡可点开明细 */
.wst-stat--held {
cursor: pointer;
transition: box-shadow 0.2s;
}
.wst-stat--held:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.wst-stat__more {
font-size: 12px;
font-weight: 400;
color: #1677ff;
}
/* ============ 主从两栏 ============ */
.wst-body {
display: flex;
align-items: flex-start;
gap: 14px;
}
.wst-centers {
flex: none;
width: 264px;
}
.wst-centers__hint {
font-size: 11px;
}
.wst-centers__list {
margin: 0;
padding: 0;
list-style: none;
}
.wst-center {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 8px;
margin-bottom: 2px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 13px;
text-align: left;
color: var(--sycm-text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.wst-center:hover {
background: var(--sycm-bg-subtle);
}
.wst-center.is-active {
border-color: rgba(22, 119, 255, 0.45);
background: rgba(22, 119, 255, 0.08);
}
.wst-center.is-empty .wst-center__name {
color: var(--sycm-text-tertiary);
}
.wst-center.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wst-center__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.wst-center__badge {
flex: none;
min-width: 24px;
padding: 0 6px;
border-radius: 9px;
background: rgba(136, 135, 128, 0.12);
font-size: 11px;
font-weight: 600;
line-height: 17px;
text-align: center;
color: var(--sycm-text-tertiary);
font-variant-numeric: tabular-nums;
}
.wst-center__badge.is-warn {
background: rgba(255, 77, 79, 0.16);
color: #cf1322;
}
.wst-center__badge--none {
font-weight: 400;
}
.wst-detail {
flex: 1 1 auto;
min-width: 0;
}
.wst-detail__scope {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--sycm-text-tertiary);
}
.wst-detail__clear {
padding: 0;
border: none;
background: transparent;
font-size: 12px;
color: #1677ff;
cursor: pointer;
}
.wst-detail__overdue {
color: #cf1322;
font-variant-numeric: tabular-nums;
}
/* 创建时间没补到、回退成付款时间时的提示色(带 * 号,tooltip 有说明) */
.wst-detail__fallback {
color: var(--sycm-text-tertiary);
font-style: italic;
}
.wst-foot {
display: block;
margin-top: 12px;
font-size: 12px;
color: var(--sycm-text-tertiary);
}
/* 窄屏:仓库列表改为两列网格,明细整行下移 */
@media (max-width: 1200px) {
.wst-body {
flex-direction: column;
}
.wst-centers {
width: 100%;
}
.wst-centers__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 10px;
}
}

View File

@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness'));
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
@ -115,6 +116,10 @@ const router = createBrowserRouter([
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{
path: 'planning/operation/warehouse-ship-timeliness',
element: renderLazyComponent(WarehouseShipTimeliness),
},
{
path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory),