Compare commits

...

109 Commits

Author SHA1 Message Date
6833b7bc79 Merge pull request 'feat: 新增「仓储中心24小时发货时效」侧栏(11 个主仓 · 12:00/16:00 双轮核验)' (#68) from codex/warehouse-ship-timeliness-1020 into main 2026-10-20 11:28:11 +08:00
893f4d6aca feat: 新增「仓储中心24小时发货时效」侧栏(11 个主仓 · 12:00/16:00 双轮核验)
需求(yanhui 2026-10-20):在 MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,
下列 11 个主仓(横林二仓、横林三仓、横林二仓品牌仓、崔桥工厂仓、崔桥工厂品牌仓、
崔桥工厂半成品仓、新苏北仓、睢宁仓、山东青岗集仓、山东品牌仓、河南洛宁仓),
从系统库存抓取已发货/未发货订单,按订单创建时间判定发货是否及时:
· 每天 12:00 核验「当天创建时间 7:00 之前」的订单是否已完成发货
· 每天 16:00 核验「当天创建时间 16:00 之前」的订单是否已完成发货

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

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

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

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

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

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

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

## 改动

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

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

## 验证

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

## 风险与回退

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:
- 全仓无 largeStockMonitor 残留引用;后端 346 用例 345 通过
  (1 项失败为 resilientSecretStore.test.js,Windows 权限语义基线既有失败)
- 前端 lint + 测试 + 构建通过,产物中 LargeStockMonitor chunk 已消失
- 运行时验证:/api/large-stock-monitor/* 返回 404,页面与登录不受影响
2026-09-23 16:07:04 +08:00
d4e6c6e4a2 Merge pull request 'fix: 配件库存用最新箱仓数据补齐' (#28) from codex/component-pack-stock-fallback-0923 into main 2026-09-23 15:43:53 +08:00
dcf3375dbe Merge branch 'main' into codex/cangku-returns-monitor-001 (sync #25 #26) 2026-09-23 13:50:26 +08:00
acc088dd1a Merge branch 'main' into codex/cangku-returns-monitor-001 2026-09-23 11:39:39 +08:00
b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

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

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00
97 changed files with 16676 additions and 1871 deletions

5
.gitignore vendored
View File

@ -62,3 +62,8 @@ tmall-board-speed-check.json
# Local verification artifacts
/outputs/
.git-status.txt
# 本地一次性探查/调试产物(不进仓库)
_probe_*.json
_probe_*.js
_work-scratch-*/

View File

@ -18,6 +18,8 @@ npm run remote:dev
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
## 验证与构建
```bash

View File

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

View File

@ -10,6 +10,7 @@
"test": "node --test test/*.test.js",
"check": "npm run syntax && npm test",
"alert:check": "node src/services/alertChecker.js",
"inventory:import-official": "node scripts/importDailyInventoryOfficial.js",
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
},
"dependencies": {

View File

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

View File

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

View File

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

View File

@ -1,113 +0,0 @@
// 端到端校验:大货库存监控 26 仓在「数据截止时刻(快照日次日 01:00)」口径下的取数
// 用法:node scripts/verify-large-stock-monitor.js
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const http = require('http');
const BASE = 'http://127.0.0.1:4001';
const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../src/config/largeStockMonitorConfig');
function request(method, path, token, body) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
`${BASE}${path}`,
{
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => {
try {
resolve({ status: res.statusCode, json: JSON.parse(data) });
} catch (e) {
reject(new Error(`响应解析失败 ${res.statusCode}: ${data.slice(0, 200)}`));
}
});
}
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
const login = await request('POST', '/api/auth/local-preview', null, {});
const token = login.json?.token || login.json?.data?.token || login.json?.access_token;
if (!token) {
console.error('登录失败:', JSON.stringify(login.json).slice(0, 300));
process.exit(1);
}
console.log('登录成功\n');
let ok = 0;
const problems = [];
let sampleAsOf = null;
for (const w of LARGE_STOCK_MONITOR_WAREHOUSES) {
const url = `/api/large-stock-monitor/overview?warehouse=${encodeURIComponent(w.warehouse)}`;
try {
const res = await request('GET', url, token);
const d = res.json?.data;
if (!res.json?.success || !d) {
problems.push(`${w.warehouse}: 接口失败 ${res.status} ${res.json?.error || ''}`);
continue;
}
sampleAsOf = sampleAsOf || d.asOf;
const o = d.overview || {};
const filled = Object.values(o).filter((v) => v !== '—' && v !== null && v !== undefined).length;
if (!d.hasData) {
problems.push(`${w.warehouse}: 无数据源 (source=${d.source})`);
continue;
}
ok += 1;
console.log(
`[${String(w.idx).padStart(2)}] ${w.warehouse}\n` +
` 快照=${d.latestBizDate} 截止=${d.asOf} 区间=${d.period?.start} ~ ${d.period?.end}\n` +
` SKU=${d.skuCount} 期末=${o.closingQty} 期初(估)=${o.periodStart}\n` +
` 采购进=${o.purchaseIn} 采购退=${o.purchaseReturn} 销售出=${o.salesOut} 销售退=${o.salesReturn}\n` +
` 周转=${o.turnoverDays} 预警=${o.warnLevel} 慢流=${o.slowLevel} 呆滞=${o.staleLevel} 僵尸=${o.zombieLevel}\n` +
` 有值列 ${filled}/18 source=${d.source}`
);
} catch (e) {
problems.push(`${w.warehouse}: ${e.message}`);
}
}
// 明细接口校验:带 @asOf 的分级筛选 SQL(level 过滤会引用 @asOf)
console.log('\n===== 明细接口校验 =====');
for (const w of LARGE_STOCK_MONITOR_WAREHOUSES.slice(0, 4)) {
for (const lv of ['', 'warn', 'slow', 'stale', 'zombie']) {
const qs = `warehouse=${encodeURIComponent(w.warehouse)}&page=1&pageSize=5${lv ? `&level=${lv}` : ''}`;
try {
const res = await request('GET', `/api/large-stock-monitor/sku-list?${qs}`, token);
const d = res.json?.data;
if (!res.json?.success) {
problems.push(`${w.warehouse} level=${lv || '全部'}: ${res.json?.error}`);
} else {
console.log(` ${w.warehouse} level=${lv || '全部'} -> total=${d?.total} 返回=${d?.items?.length} 截止=${d?.asOf}`);
}
} catch (e) {
problems.push(`${w.warehouse} level=${lv}: ${e.message}`);
}
}
}
console.log(`\n===== 汇总 =====`);
console.log(`数据截止时刻(asOf): ${sampleAsOf}`);
console.log(`有数据仓库: ${ok}/${LARGE_STOCK_MONITOR_WAREHOUSES.length}`);
if (problems.length) {
console.log('异常:');
problems.forEach((p) => console.log(' - ' + p));
} else {
console.log('全部仓库取数正常,无异常');
}
process.exit(0);
})();

View File

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

View File

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

View File

@ -1,514 +0,0 @@
// 大货库存监控 - 后端路由
// 业务数据源:rpt.pm_stockMonitor(MSSQL,业务库 JSReportReplica)
// rpt.jst_*(聚水潭单据/库存表,用于出入库流水)
// 配置源:C:/Users/.../Desktop/库存监控.xlsx 中的 26 行仓库配置
//
// 计算口径(用户给定):
// 月度发货总件数 ≈ threeAvgSale × 30 (业务库无直接月度发货字段,按 3 天日均销量 × 30 估算)
// 月度日均库存件数 ≈ jsStock (业务库只有当前日快照,作为日均近似)
// 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
// 注:按公式实际为周转率(%),列名沿用 Excel 表头"库存周转天数"
// 库龄(月) = DATEDIFF(MONTH, created, GETDATE())
//
// 分级口径(用户给定,按"库龄 + 周转天数(%)"两个维度):
// 滞销预警 warn 库龄 < 3 个月 且 30 ≤ 周转天数 ≤ 199
// 商品慢流 slow 库龄 < 3 个月 且 周转天数 > 200
// 或 库龄 > 3 个月 且 30 ≤ 周转天数 ≤ 99
// 商品呆滞 stale 库龄 3~12 个月 且 周转天数 ≥ 100
// 或 库龄 > 12 个月 且 100 ≤ 周转天数 ≤ 199
// 商品僵尸 zombie 库龄 > 12 个月 且 周转天数 ≥ 200
const express = require('express');
const authMiddleware = require('../middleware/auth');
const cloudDb = require('../config/cloudDatabase');
const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../config/largeStockMonitorConfig');
const { fetchFlowSummary, fetchInventoryQty } = require('../services/largeStockMonitorFlow');
const router = express.Router();
const LEVEL_LABELS = {
warn: '滞销预警',
slow: '商品慢流',
stale: '商品呆滞',
zombie: '商品僵尸',
};
// 18 列表头(与库存监控.xlsx 第 1 行的 D..U 列对应)
// available=false 表示业务库无对应数据源(加工/盘点/调拨/其它出库),前端显示「—」
const COLUMN_HEADERS = [
{ key: 'periodStart', label: '期初数据', available: true, estimated: true },
{ key: 'processIn', label: '加工进仓', available: false },
{ key: 'purchaseReturn', label: '采购退货', available: true },
{ key: 'purchaseIn', label: '采购进仓', available: true },
{ key: 'salesOut', label: '销售出仓', available: true },
{ key: 'stocktake', label: '盘点', available: false },
{ key: 'processOut', label: '加工出仓', available: false },
{ key: 'transferOut', label: '调拨出', available: false },
{ key: 'salesReturn', label: '销售退货', available: true },
{ key: 'transferIn', label: '调拨入', available: false },
{ key: 'openingBalance', label: '期初', available: true, estimated: true },
{ key: 'otherOut', label: '其它出库', available: false },
{ key: 'closingQty', label: '期末数量', available: true },
{ key: 'turnoverDays', label: '库存周转天数', available: true },
{ key: 'warnLevel', label: '滞销预警级库存量', available: true },
{ key: 'slowLevel', label: '商品慢流级库存量', available: true },
{ key: 'staleLevel', label: '商品呆滞级库存量', available: true },
{ key: 'zombieLevel', label: '商品僵尸级库存量', available: true },
];
// 在 SELECT/WHERE 中复用:库龄月数、月度发货、月度日均库存、周转天数(%)、分级
// 关键:所有算式都做 NULL/0 兜底,避免分母为 0 导致 NULL 污染整列
// 时间基准统一使用 @asOf(数据截止时刻,默认快照日次日 01:00),不再用 GETDATE(),
// 保证同一批报表内库龄/分级与流水口径完全一致。
function buildDerivedColumns(alias = 'm') {
const a = alias ? `${alias}.` : '';
const T = 'CONVERT(DATETIME, @asOf, 120)';
return {
ageMonths: `CASE WHEN ${a}created IS NULL THEN NULL ELSE CAST(DATEDIFF(DAY, ${a}created, ${T}) AS DECIMAL(18, 4)) / 30.0 END`,
monthlyShipment: `(ISNULL(${a}threeAvgSale, 0) * 30.0)`,
avgDailyStock: `CAST(ISNULL(${a}jsStock, 0) AS DECIMAL(18, 4))`,
turnoverDays: `CASE WHEN ISNULL(${a}jsStock, 0) = 0 THEN NULL ELSE CAST(ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock AS DECIMAL(18, 4)) END`,
levelExpr: `
CASE
WHEN ${a}created IS NULL OR ISNULL(${a}jsStock, 0) = 0 OR ISNULL(${a}threeAvgSale, 0) * 30.0 / NULLIF(${a}jsStock, 0) IS NULL THEN NULL
WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 199 THEN 'warn'
WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'slow'
WHEN DATEDIFF(DAY, ${a}created, ${T}) >= 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 99 THEN 'slow'
WHEN DATEDIFF(DAY, ${a}created, ${T}) BETWEEN 90 AND 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 100 THEN 'stale'
WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 100 AND 199 THEN 'stale'
WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'zombie'
ELSE NULL
END
`,
};
}
function resolveWarehouse(warehouse) {
const target = String(warehouse || '').trim();
if (!target) return null;
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === target) || null;
}
function buildPlaceholderRow() {
return COLUMN_HEADERS.reduce((acc, column) => {
acc[column.key] = '—';
return acc;
}, {});
}
// 默认跟随业务库最新快照日期;可通过 ?date=YYYY-MM-DD 指定某一天
const DEFAULT_TARGET_DATE = null;
// 数据截止时刻:快照日次日的凌晨 01:00。
// 业务库每天凌晨 1 点跑批生成快照,标注为前一日(T-1)的库存,
// 因此「9 月 23 日凌晨 1 点的数据」= 快照日 2026-09-22 + 截止时刻 2026-09-23 01:00。
const AS_OF_TIME = '01:00';
// 流水统计区间:快照所在自然月的 1 日 00:00 ~ 快照日次日 01:00(左闭右开)
// 即完整覆盖快照日全天,并包含跑批当日凌晨 0 点~1 点的单据
function buildPeriodRange(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const [y, mo] = bizDate.split('-');
const start = `${y}-${mo}-01 00:00`;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
const endDay = d.toISOString().slice(0, 10);
const end = `${endDay} ${AS_OF_TIME}`;
return { start, end, endDay, asOf: end };
}
// 允许 ?asOf=YYYY-MM-DD HH:mm 手工覆盖截止时刻(排查/补数用)
function normalizeAsOf(value) {
const raw = String(value || '').trim();
if (!raw) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw} ${AS_OF_TIME}`;
const m = raw.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
return null;
}
const pad2 = (n) => String(n).padStart(2, '0');
function localDateString(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function localDateTimeString(d) {
return `${localDateString(d)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
// 无库存快照的仓库(走实时库存兜底)没有「快照日次日」可用,截止时刻取今天凌晨 1 点;
// 若当前还没到凌晨 1 点(跑批尚未完成),则退化为当前时刻。
function fallbackAsOf() {
const now = new Date();
const hourFloor = Number(AS_OF_TIME.split(':')[0]);
if (now.getHours() >= hourFloor) return `${localDateString(now)} ${AS_OF_TIME}`;
return localDateTimeString(now);
}
async function fetchTargetBizDate(dbWarehouse, preferredDate) {
if (!dbWarehouse) return null;
const rows = await cloudDb.query(
`SELECT TOP 1 CONVERT(VARCHAR(10), date, 23) AS d
FROM rpt.pm_stockMonitor
WHERE warehouse = @warehouse AND date IS NOT NULL
AND (@preferredDate IS NULL OR CONVERT(VARCHAR(10), date, 23) <= @preferredDate)
ORDER BY date DESC`,
{ warehouse: dbWarehouse, preferredDate: preferredDate || null }
);
return rows[0]?.d || null;
}
// 期初倒推:期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货
// 业务库 pm_stockMonitor 仅保留最新单日快照(无上月末历史),且加工/调拨/盘点/其它出库无数据源,
// 因此期初只能由已知流水倒推,属估算值(前端标注「估」)。
// 倒推出现负数说明该仓库本期存在未采集到的出库(调拨/加工/其它出库),此时不再展示估算值,
// 避免出现「负库存」这类明显失真的数字。
function estimateOpeningBalance(closingQty, flow) {
if (closingQty == null) return null;
const purchaseIn = flow?.purchaseIn ?? 0;
const purchaseReturn = flow?.purchaseReturn ?? 0;
const salesOut = flow?.salesOut ?? 0;
const salesReturn = flow?.salesReturn ?? 0;
const value = closingQty - purchaseIn + purchaseReturn + salesOut - salesReturn;
return value >= 0 ? value : null;
}
router.get('/config', authMiddleware, async (_req, res) => {
try {
res.json({
success: true,
data: {
columns: COLUMN_HEADERS,
levelLabels: LEVEL_LABELS,
warehouses: LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
warehouse: entry.warehouse,
group: entry.group,
routeKey: entry.routeKey,
hasData: Boolean(entry.dbWarehouse),
hasWms: Boolean(entry.wmsCoId),
})),
},
});
} catch (error) {
console.error('[LargeStockMonitor] 获取配置失败:', error);
res.status(500).json({ success: false, error: error.message || '获取配置失败' });
}
});
router.get('/overview', authMiddleware, async (req, res) => {
try {
const config = resolveWarehouse(req.query.warehouse);
if (!config) {
return res.status(404).json({ success: false, error: '未知的仓库配置' });
}
const requestedDate = String(req.query.date || '').trim() || null;
if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) {
return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' });
}
const dbWarehouse = config.dbWarehouse;
const wmsCoId = config.wmsCoId;
const overview = buildPlaceholderRow();
const baseResponse = {
warehouse: config.warehouse,
group: config.group,
routeKey: config.routeKey,
requestedDate,
latestBizDate: null,
fallbackDate: null,
asOf: null,
period: null,
source: null,
hasData: false,
skuCount: 0,
overview,
};
// 1) 快照日期:优先 pm_stockMonitor;无该仓库时退回聚水潭实时库存(无日期概念)
let latestBizDate = dbWarehouse
? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null)
: null;
const fallbackDate = requestedDate && latestBizDate && latestBizDate !== requestedDate ? latestBizDate : null;
const period = buildPeriodRange(latestBizDate);
// 数据截止时刻:默认快照日次日 01:00(如 2026-09-23 01:00),可用 ?asOf= 覆盖
const asOf = normalizeAsOf(req.query.asOf) || period?.asOf || fallbackAsOf();
// 2) 出入库流水(本月 1 日 ~ 截止时刻);pm_stockMonitor 无该仓库时用当天所在月
const today = localDateString(new Date());
const periodForFlow = period
? { ...period, end: asOf || period.end }
: { start: buildPeriodRange(today)?.start, end: asOf };
const flow = await fetchFlowSummary({
wmsCoId,
dbWarehouse: dbWarehouse || config.warehouse.replace(/主仓$/, ''),
periodStart: periodForFlow?.start,
periodEnd: periodForFlow?.end,
}).catch(() => ({ purchaseIn: null, purchaseReturn: null, salesOut: null, salesReturn: null }));
if (flow.purchaseReturn != null) overview.purchaseReturn = flow.purchaseReturn;
if (flow.purchaseIn != null) overview.purchaseIn = flow.purchaseIn;
if (flow.salesOut != null) overview.salesOut = flow.salesOut;
if (flow.salesReturn != null) overview.salesReturn = flow.salesReturn;
// 3) 期末 / 分级
// 期末数量统一取「聚水潭实际库存」rpt.jst_inventoryInfo(按 wms_co_id 单仓统计):
// pm_stockMonitor 的 warehouse 是「归属仓」而非实物所在仓,其 jsStock 会把
// 已调拨到外仓(如横林二仓)的货仍计在本仓名下,与仓库实物库存对不上
// (例:崔桥工厂仓 快照 jsStock 115135 vs 实物 92439)。
// 库龄/周转率/四级库存量只能由 pm_stockMonitor 提供(实时库存表无 created、销量字段)。
let skuCount = 0;
let source = null;
let closingSource = null;
let hasGrade = false;
let summary = {};
if (dbWarehouse && latestBizDate) {
const D = buildDerivedColumns();
const summaryRows = await cloudDb.query(
`SELECT
COUNT(*) AS skuCount,
SUM(CAST(jsStock AS DECIMAL(18, 2))) AS totalStock,
AVG(${D.turnoverDays}) AS avgTurnover,
MAX(${D.turnoverDays}) AS maxTurnover,
SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel
FROM rpt.pm_stockMonitor m
WHERE m.warehouse = @warehouse
AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`,
{ warehouse: dbWarehouse, bizDate: latestBizDate, asOf }
);
summary = summaryRows[0] || {};
const snapSkuCount = Number(summary.skuCount || 0);
if (snapSkuCount > 0) {
hasGrade = true;
overview.turnoverDays = summary.avgTurnover == null ? null : Number(summary.avgTurnover);
overview.warnLevel = Number(summary.warnLevel || 0);
overview.slowLevel = Number(summary.slowLevel || 0);
overview.staleLevel = Number(summary.staleLevel || 0);
overview.zombieLevel = Number(summary.zombieLevel || 0);
}
}
// 3.2 期末数量:聚水潭实际库存(单仓 wms_co_id);SKU 数同步跟随期末口径
let closingTakenAt = null;
if (wmsCoId) {
const inv = await fetchInventoryQty(wmsCoId).catch(() => null);
if (inv && inv.qty != null) {
closingSource = 'jst_inventoryInfo';
overview.closingQty = inv.qty;
skuCount = inv.skuCount || 0;
closingTakenAt = inv.takenAt || null;
}
}
// 3.3 兜底:拿不到实时库存时退回快照归属库存(并在响应中标记来源)
if (!closingSource && summary.totalStock != null && Number(summary.skuCount || 0) > 0) {
closingSource = 'pm_stockMonitor';
overview.closingQty = Number(summary.totalStock || 0);
skuCount = Number(summary.skuCount || 0);
}
source = closingSource || (hasGrade ? 'pm_stockMonitor' : null);
// 四级库存量基于快照归属库存分级,与期末(实际库存)口径不同,供前端提示用
const gradeBasisStock = Number(summary.totalStock || 0);
// 4) 期初倒推(估算)
const opening = estimateOpeningBalance(
typeof overview.closingQty === 'number' ? overview.closingQty : null,
flow
);
if (opening != null) {
overview.periodStart = opening;
overview.openingBalance = opening;
}
res.json({
success: true,
data: {
...baseResponse,
latestBizDate: latestBizDate || null,
fallbackDate,
asOf,
period: periodForFlow,
source,
closingSource,
closingTakenAt,
hasGrade,
gradeBasisStock,
hasData: Boolean(source),
skuCount,
overview,
turnoverDaysAvg: summary.avgTurnover != null ? Number(summary.avgTurnover) : null,
turnoverDaysMax: summary.maxTurnover != null ? Number(summary.maxTurnover) : null,
},
});
} catch (error) {
console.error('[LargeStockMonitor] 获取概览失败:', error);
res.status(error.statusCode || 500).json({
success: false,
error: error.message || '获取概览失败',
});
}
});
router.get('/sku-list', authMiddleware, async (req, res) => {
try {
const config = resolveWarehouse(req.query.warehouse);
if (!config) {
return res.status(404).json({ success: false, error: '未知的仓库配置' });
}
const requestedDate = String(req.query.date || '').trim() || null;
if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) {
return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' });
}
const dbWarehouse = config.dbWarehouse;
const wmsCoId = config.wmsCoId;
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize, 10) || 20, 1), 200);
const offset = (page - 1) * pageSize;
const level = String(req.query.level || '').trim();
const keyword = String(req.query.keyword || '').trim();
const baseResponse = {
warehouse: config.warehouse,
group: config.group,
requestedDate,
latestBizDate: null,
fallbackDate: null,
asOf: null,
source: null,
page,
pageSize,
total: 0,
items: [],
};
const latestBizDate = dbWarehouse
? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null)
: null;
const asOf = normalizeAsOf(req.query.asOf) || buildPeriodRange(latestBizDate)?.asOf || fallbackAsOf();
// pm_stockMonitor 有数据 → 走原有分级明细
if (dbWarehouse && latestBizDate) {
const D = buildDerivedColumns();
const filters = ['m.warehouse = @warehouse', "CONVERT(VARCHAR(10), m.date, 23) = @bizDate"];
const params = { warehouse: dbWarehouse, bizDate: latestBizDate, asOf };
if (keyword) {
filters.push('(m.sku LIKE @kw OR m.skuName LIKE @kw)');
params.kw = `%${keyword}%`;
}
if (['warn', 'slow', 'stale', 'zombie'].includes(level)) {
filters.push(`${D.levelExpr} = @level`);
params.level = level;
}
const whereClause = filters.join(' AND ');
const totalRows = await cloudDb.query(
`SELECT COUNT(*) AS total FROM rpt.pm_stockMonitor m WHERE ${whereClause}`,
params
);
const total = Number(totalRows[0]?.total || 0);
params.pageSize = pageSize;
params.offset = offset;
const items = await cloudDb.query(
`SELECT
m.sku, m.spu, m.skuName, m.supplier, m.purchase, m.category,
m.jsStock, m.threeAvgSale, m.created,
${D.ageMonths} AS ageMonths,
${D.monthlyShipment} AS monthlyShipment,
${D.avgDailyStock} AS avgDailyStock,
${D.turnoverDays} AS turnoverDays,
${D.levelExpr} AS level,
m.warehouse
FROM rpt.pm_stockMonitor m
WHERE ${whereClause}
ORDER BY m.jsStock DESC, m.sku ASC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`,
params
);
return res.json({
success: true,
data: {
...baseResponse,
latestBizDate,
fallbackDate: requestedDate && latestBizDate !== requestedDate ? latestBizDate : null,
asOf,
source: 'pm_stockMonitor',
total,
items,
},
});
}
// 兜底:聚水潭实时库存明细(无库龄/销量字段,level 与周转率留空)
// 该数据源算不出库龄与周转率,任何分级筛选都不可能命中,直接返回空列表,
// 避免点选「商品僵尸」等时把整仓库存原样列出造成误导。
if (['warn', 'slow', 'stale', 'zombie'].includes(level)) {
return res.json({
success: true,
data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total: 0, items: [] },
});
}
if (!wmsCoId) {
return res.json({ success: true, data: baseResponse });
}
const invFilters = ['v.wms_co_id = @wmsCoId', 'ISNULL(v.qty, 0) <> 0'];
const invParams = { wmsCoId };
if (keyword) {
invFilters.push('(v.sku_id LIKE @kw OR v.name LIKE @kw)');
invParams.kw = `%${keyword}%`;
}
const invWhere = invFilters.join(' AND ');
const invTotal = await cloudDb.query(
`SELECT COUNT(*) AS total FROM rpt.jst_inventoryInfo v WHERE ${invWhere}`,
invParams
);
const total = Number(invTotal[0]?.total || 0);
invParams.pageSize = pageSize;
invParams.offset = offset;
const items = await cloudDb.query(
`SELECT
v.sku_id AS sku,
v.i_id AS spu,
v.name AS skuName,
v.qty AS jsStock,
CAST(NULL AS DECIMAL(18, 4)) AS ageMonths,
CAST(NULL AS DECIMAL(18, 4)) AS monthlyShipment,
CAST(v.qty AS DECIMAL(18, 4)) AS avgDailyStock,
CAST(NULL AS DECIMAL(18, 4)) AS turnoverDays,
CAST(NULL AS VARCHAR(10)) AS level,
w.name AS warehouse
FROM rpt.jst_inventoryInfo v
LEFT JOIN rpt.jst_warehouseInfo w ON w.wms_co_id = v.wms_co_id
WHERE ${invWhere}
ORDER BY v.qty DESC, v.sku_id ASC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`,
invParams
);
res.json({
success: true,
data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total, items },
});
} catch (error) {
console.error('[LargeStockMonitor] 获取 SKU 列表失败:', error);
res.status(error.statusCode || 500).json({
success: false,
error: error.message || '获取 SKU 列表失败',
});
}
});
module.exports = router;

View File

@ -29,6 +29,10 @@ function sendError(res, error, fallback) {
res.status(status).json({ success: false, error: error.message || fallback });
}
function buildSyncQuery(query = {}) {
return { ...query, live: '1', fresh: '1' };
}
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
@ -109,7 +113,9 @@ router.put('/notification-center', async (req, res) => {
router.post('/sync', async (req, res) => {
try {
const cache = clearMkhBomCache();
const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' });
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
@ -118,3 +124,4 @@ router.post('/sync', async (req, res) => {
});
module.exports = router;
module.exports._private = { buildSyncQuery };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

@ -542,6 +542,10 @@ function normalizeMssqlConfig(rawConfig, meta = {}) {
trustServerCertificate: true,
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
// 业务库的 DATETIME 存的是北京时间(无时区标记)。mssql 驱动默认 useUTC=true 会把它
// 当成 UTC 解析,而本项目所有时间格式化都是用本地 getter 还原墙上时间,两者叠加会让
// 显示值整体 +8 小时(例如库里 16:56:07 被显示成次日 00:56:07)。按本地时间解析才对得上。
useUTC: false,
},
pool: {
max: CLOUD_DB_POOL_MAX,
@ -760,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();
@ -1089,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,
});
};
/**
* 测试连接
*/
@ -1139,6 +1178,7 @@ const reload = async () => {
module.exports = {
getPool,
query,
querySlow,
queryWithFreshConnection,
createPinnedQueryClient,
test,

View File

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

View File

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

View File

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

View File

@ -1,76 +0,0 @@
// 大货库存监控配置 - 数据源:C:/Users/15018737611/Desktop/库存监控.xlsx
// 共 26 行,按 Excel 顺序保留:序号 / 二级功能按键 / 仓库全名
// 业务库 rpt.pm_stockMonitor.warehouse 的命名规则与 Excel 不一致:
// Excel 通常带「主仓」后缀,业务库只保留「主」前那一段。
// 已在 dbWarehouse 字段手工映射出业务库 warehouse 精确值;无法对应的留 null。
// 菜单 label 显示 Excel 仓库全名,routeKey 用 slug 化的二级功能按键+序号保证唯一。
// wmsCoId 来自 rpt.jst_warehouseInfo(聚水潭仓库主数据,155 行,26 个仓库全部命中),
// 用于从出入库单据表(jst_purchase / jst_purchaseout / jst_salesOutboundInfo 等)取流水数量。
// dbWarehouse 用于 rpt.pm_stockMonitor;该报表仅覆盖 16 个仓库,其余为 null。
const RAW_WAREHOUSES = [
{ idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓', dbWarehouse: '简尚-崔桥工厂半成品包装项目仓', wmsCoId: 15718196 },
{ idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301815 },
{ idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓', dbWarehouse: '简尚-崔桥工厂仓', wmsCoId: 15038426 },
{ idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓', dbWarehouse: null, wmsCoId: 15045345 },
{ idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓', dbWarehouse: '简尚-崔桥工厂品牌仓', wmsCoId: 15870495 },
{ idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓', dbWarehouse: '简尚-河南洛宁仓', wmsCoId: 15539084 },
{ idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓', dbWarehouse: null, wmsCoId: 15051915 },
{ idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301814 },
{ idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓', dbWarehouse: '简尚-横林二仓', wmsCoId: 15033302 },
{ idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓', dbWarehouse: '简尚-横林三仓', wmsCoId: 15043561 },
{ idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓', dbWarehouse: '简尚-山东品牌仓', wmsCoId: 15182705 },
{ idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓', dbWarehouse: null, wmsCoId: 15406664 },
{ idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓', dbWarehouse: '简尚-山东青岗集仓', wmsCoId: 15137523 },
{ idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15406876 },
{ idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15301812 },
{ idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓', dbWarehouse: '简尚-新苏北仓', wmsCoId: 15038347 },
{ idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓', dbWarehouse: '简尚-分销部苏北专用仓', wmsCoId: 15045325 },
{ idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15051907 },
{ idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15301813 },
{ idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓', dbWarehouse: '简尚-睢宁仓', wmsCoId: 15032603 },
{ idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓', dbWarehouse: '简尚-沭阳金源涌动项目仓', wmsCoId: 15043585 },
{ idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓', dbWarehouse: '简尚-分销部睢宁专用仓', wmsCoId: 15051960 },
{ idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓', dbWarehouse: '简尚-新外贸仓', wmsCoId: 15045308 },
{ idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓', dbWarehouse: null, wmsCoId: 15051888 },
{ idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓', dbWarehouse: '简尚-分销部专用仓', wmsCoId: 15045426 },
{ idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓', dbWarehouse: '简尚-分销部崔桥专用仓', wmsCoId: 15404402 },
];
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({
...entry,
routeKey: `stock-monitor-large-${entry.idx}`,
slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`,
hasData: Boolean(entry.dbWarehouse),
}));
// 无业务库数据源的列:业务库(JSReportReplica,220 张表)中不存在加工/盘点/调拨/其它出库
// 任何单据表,这 6 列无真实数据可回填,保留占位符「—」并在前端说明。
const UNAVAILABLE_COLUMN_KEYS = [
'processIn',
'stocktake',
'processOut',
'transferOut',
'transferIn',
'otherOut',
];
const LARGE_STOCK_MONITOR_PAGE = {
pageKey: 'large_stock_monitor',
path: '/planning/large-stock-monitor/:warehouseSlug',
name: '大货库存监控',
defaultRoles: { admin: true, analyst: true, user: true, guest: false },
};
module.exports = {
LARGE_STOCK_MONITOR_WAREHOUSES,
LARGE_STOCK_MONITOR_PAGE,
UNAVAILABLE_COLUMN_KEYS,
};

View File

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

View File

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

View File

@ -40,6 +40,16 @@ const SIDEBAR_PAGES = [
path: '/planning/operation/mkh-bom-data',
}),
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
buildPlanningPage({
pageKey: 'stock_sales_analysis',
pageName: '大货库存监控 · 库存动销分析',
path: '/planning/operation/stock-sales-analysis',
}),
buildPlanningPage({
pageKey: 'picking_bin_sales_analysis',
pageName: '大货库存监控 · 拣选库位动销分析',
path: '/planning/operation/picking-bin-sales-analysis',
}),
{
pageKey: 'settings',
pageName: '系统设置',

View File

@ -35,7 +35,14 @@ const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const largeStockMonitorRoutes = require('./api/largeStockMonitor');
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning');
const database = require('./config/database');
const cloudDb = require('./config/cloudDatabase');
const jianSunDb = require('./config/jianSunDatabase');
@ -48,6 +55,12 @@ const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMa
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
const {
startDailyInventorySnapshotScheduler,
} = require('./jobs/dailyInventorySnapshotJob');
const {
startDailyInventoryNotificationScheduler,
} = require('./jobs/dailyInventoryNotificationJob');
const {
startAlibaba1688ImageSearchExpiryScheduler,
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
@ -655,12 +668,22 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
cubejsApp.use('/api/large-stock-monitor', authMiddleware, largeStockMonitorRoutes);
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心 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);
cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
// =====================
// 数据分析 API(公开访问,可根据需要添加认证)
@ -696,6 +719,36 @@ async function startServer(options = {}) {
}
});
// =====================
// 统一 API 兜底(必须放在所有路由之后)
// =====================
// 背景:此前没有任何 4 参错误处理中间件,路由里抛出的异常会落到 Express
// 默认处理器,返回一张 HTML 的 "Internal Server Error" 页面;前端 axios
// 读不到 error / message 字段,只能显示 "Request failed with status code 500",
// 出问题时完全看不到真实原因。这里统一返回 JSON 并打印堆栈。
cubejsApp.use('/api', (req, res) => {
res.status(404).json({
success: false,
error: `接口不存在: ${req.method} ${req.originalUrl}`,
});
});
cubejsApp.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
const rawStatus = Number(err?.status || err?.statusCode);
const status = Number.isFinite(rawStatus) && rawStatus >= 400 && rawStatus <= 599
? rawStatus
: 500;
const messageText = err?.message || '服务器内部错误';
console.error(`❌ [API ${status}] ${req.method} ${req.originalUrl} -> ${messageText}`);
if (err?.stack) {
console.error(err.stack);
}
return res.status(status).json({ success: false, error: messageText });
});
console.log('='.repeat(50));
console.log('🚀 后端服务已启动');
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
@ -715,6 +768,11 @@ async function startServer(options = {}) {
console.log(` [缓存] ${redisTest.message}`);
console.log('='.repeat(50));
// 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭,
// 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。
// 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。
startDailyInventoryNotificationScheduler();
if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
return;
@ -730,6 +788,7 @@ async function startServer(options = {}) {
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler();
startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema
// inspection round-trip over the planning database tunnel.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,132 +0,0 @@
// 大货库存监控 - 出入库流水取数(本月 1 日 ~ 快照日)
//
// 业务库 JSReportReplica 里与「库存流水」相关的单据表:
// 采购进仓 rpt.jst_purchase (wms_co_id, po_date, status) + rpt.jst_purchaseItem (po_id, inQty)
// 采购退货 rpt.jst_purchaseout (wms_co_id, io_date, status) + rpt.jst_purchaseoutItem (io_id, qty)
// 销售出仓 rpt.jst_salesOutboundInfo(wms_co_id, io_date, status) + rpt.jst_OrderItem (o_id, qty)
// 销售退货 rpt.jst_returnAndRefundInfo(warehouse 名称) + rpt.jst_returnAndRefundItem(as_id, r_qty)
//
// 有效单据状态:只统计 Confirmed / Finished,排除 Cancelled / Delete / WaitConfirm。
// 业务库中不存在加工、盘点、调拨、其它出库任何单据表,这 6 列无法取数。
const cloudDb = require('../config/cloudDatabase');
// 单据有效状态(已确认/已完成),避免把取消单算进流水
const PURCHASE_STATUS = "'Confirmed','Finished'";
const PURCHASEOUT_STATUS = "'Confirmed'";
const SALESOUT_STATUS = "'Confirmed'";
const REFUND_STATUS = "'Confirmed'";
function toNumber(value) {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* 采购进仓:采购单已入库数量(inQty)
*/
async function fetchPurchaseIn(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(pi.inQty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_purchase p
JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
WHERE p.wms_co_id = @wmsCoId
AND p.po_date >= @periodStart AND p.po_date < @periodEnd
AND p.status IN (${PURCHASE_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 采购退货:采购退货出库单数量
*/
async function fetchPurchaseReturn(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_purchaseout o
JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
WHERE o.wms_co_id = @wmsCoId
AND o.io_date >= @periodStart AND o.io_date < @periodEnd
AND o.status IN (${PURCHASEOUT_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 销售出仓:销售出库单关联的订单明细数量
* 注意:jst_salesOutboundInfo.qty 是表头字段且恒为 0,真实件数在 jst_OrderItem.qty
*/
async function fetchSalesOut(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_salesOutboundInfo s
JOIN rpt.jst_OrderItem oi ON oi.o_id = s.o_id
WHERE s.wms_co_id = @wmsCoId
AND s.io_date >= @periodStart AND s.io_date < @periodEnd
AND s.status IN (${SALESOUT_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 销售退货:退货退款单明细的实退数量(r_qty)
* 该表 wms_co_id 大量为空(9 月 92821 单中 75094 单为空),改用 warehouse 名称匹配:
* 名称形如「简尚-崔桥工厂仓销退仓」/「简尚-分销部睢宁专用仓主仓」,统一去掉「销退仓」「主仓」后比对。
*/
async function fetchSalesReturn(dbWarehouse, periodStart, periodEnd) {
if (!dbWarehouse) return null;
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(ri.r_qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_returnAndRefundInfo r
JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
WHERE REPLACE(REPLACE(ISNULL(r.warehouse, ''), N'销退仓', N''), N'主仓', N'') = @whName
AND r.created >= @periodStart AND r.created < @periodEnd
AND r.status IN (${REFUND_STATUS})`,
{ whName: dbWarehouse, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 仓库实物库存:聚水潭实时库存表 rpt.jst_inventoryInfo(按 wms_co_id 单仓统计)
* 这是报表「期末数量」的取数口径 —— pm_stockMonitor 的 warehouse 仅是归属仓,
* 其 jsStock 含已调拨到外仓的货,不能代表本仓实物库存。
*/
async function fetchInventoryQty(wmsCoId) {
if (!wmsCoId) return null;
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS v,
COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount,
MAX(modified) AS takenAt
FROM rpt.jst_inventoryInfo
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId }
);
return {
qty: toNumber(rows[0]?.v),
skuCount: toNumber(rows[0]?.skuCount) || 0,
takenAt: rows[0]?.takenAt ? new Date(rows[0].takenAt).toISOString() : null,
};
}
/**
* 一次性取全部流水;任一子查询失败不影响其它字段(返回 null 由调用方降级为「—」)
*/
async function fetchFlowSummary({ wmsCoId, dbWarehouse, periodStart, periodEnd }) {
const [purchaseIn, purchaseReturn, salesOut, salesReturn] = await Promise.all([
wmsCoId ? fetchPurchaseIn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
wmsCoId ? fetchPurchaseReturn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
wmsCoId ? fetchSalesOut(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
fetchSalesReturn(dbWarehouse, periodStart, periodEnd).catch(() => null),
]);
return { purchaseIn, purchaseReturn, salesOut, salesReturn };
}
module.exports = {
fetchFlowSummary,
fetchInventoryQty,
};

View File

@ -12,6 +12,11 @@ const {
ensureProductMonitoringSchema,
warmProductMonitoringDataset,
} = require('./productMonitoringService');
const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
const {
ensureReturnStockWarningSchema,
} = require('./returnStockWarningSchema');
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
const USER_COLUMNS = [
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
@ -952,6 +957,10 @@ async function ensureLocalSchema() {
);
await ensureProductMonitoringSchema();
await ensureReturnMonitorSchema();
await ensureReturnStockWarningSchema();
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
void warmReturnStockWarningCache();
await ensureSidebarRolePermissions();
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动

View File

@ -51,15 +51,18 @@
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
// 只打一条查询,按编码缓存 10 分钟。
// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`;
// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。
// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到
// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。
// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题;
// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。
// ⚠️ 关联列(09-19 修正,PR #22):仓名用 `jst_warehouseInfo.wms_co_id = p.wms_co_id`
// —— 09-19 实测该表 30,236 行 `wms_co_id` **0 NULL、14 个仓取值**。
// ~~旧结论「该表 wms_co_id 全表 NULL,必须用 jst_warehouseInfo.Id = p.wh_id」~~
// 只对 09-17 的表状态成立;误用 `w.Id = p.wh_id`(wh_id 仅 6 个取值、90% 为 1)
// 会让 727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。
// 🔥 该表 **小时级增量刷新**,且 2026-09-18 被上游整表重建 ——
// 09-17 的「全表 85,152 行、仅 3 个箱、`bin` / `bin_group` 全表为空、与 MKH 配件
// **零交集**」这组结论**已全部失效**,别再照抄。
// 09-19 实测:目标仓 794 个 SKU 命中 **727** 个(真货位如 ZT-8 / BCP01-01-1);
// 2026-09-30 实测:本配件仓(15718196)箱表有库存(bin 非空)的 SKU **734** 个。
// → 配件货位 / 箱库存数据是真实存在并持续刷新的;
// 该结论同时修正 ⑪ 的配件库存来源(箱表可作兜底,见下)。
//
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。
//
@ -67,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 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
@ -191,13 +197,13 @@
// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**,
// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。
// 兜底规则(**只补 0,不覆盖有效值**):
// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致)
// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack')
// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime')
// ④ 否则回退监控快照 jsStock → (stockSource='monitor')
// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大**
// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。
// 兜底规则(2026-09-24 修正):
// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值
// ② 只有其中一个当前库存源有值 → 用该源
// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock
// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级
// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住;
// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。
// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件),
// 不过滤会虚增。
// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个**
@ -212,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 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -220,8 +248,9 @@
// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表)
// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓'
// AND 状态 <> '作废' ← 作废单据一律不核算
// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成)
// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集
// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量
// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。
// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。
// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query:
// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`,
// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成
@ -302,13 +331,22 @@
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓
// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
// 跨仓正库存为 0,所以不跨仓汇总。
// · 配件现有库存 **两级来源**(2026-09-30 修正,与配件监控页 `buildRealtimeStockSql` 同口径):
// ① rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓(wms_co_id = 15718196)
// —— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
// 跨仓正库存为 0,所以不跨仓汇总。
// ② 同仓 rpt.jst_packInventoryInfo 的 **bin 非空** 行合计 —— **仅当 ① 为 0 / 缺行时启用**。
// 🔥 **推翻旧结论**(旧版此处写「不能用 jst_packInventoryInfo 当配件库存 ——
// 与 MKH 配件零交集」,那是 09-17 的表状态,09-18 上游重建后已失效,见 ⑤)。
// 漏掉这一级会让「库存表滞后停在 0、箱表已有货」的配件被误判不足 ——
// 2026-09-30 案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01:
// inventoryInfo 停在 09-28 值 0,箱表当日 10:30 已记 bin=ZT-30 qty=200;
// 配件监控页显示 200(判「正常」),MKH-BOM 却因 0 判「配件不足」。
// 当日 30 行配件不足预警里 **7 行**属此类误判(可组装数 0 → 30~46)。
// ⚠️ 必须过滤 `bin` 为空的行 —— 那是装箱记录不是库存(见 ⑤);
// 且**只补 0、不覆盖正库存**(箱表可能只记部分装箱量,覆盖会压低有效库存)。
// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。
// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」,
// 与 MKH 配件零交集(见 ⑤),仓位口径专用。
// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。
// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0
// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。
@ -375,6 +413,14 @@ let productBomCache = null;
*/
let cacheEpoch = 0;
// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰
// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工,
// 又避免用户每刷新一次就把所有稳定表全量重扫。
let operationalCacheEpoch = 0;
let operationalRefreshPending = null;
let operationalRefreshedAt = 0;
const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000;
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
@ -389,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 决定实际取数列,
@ -516,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;
@ -1084,12 +1141,11 @@ function buildReplenishMetrics(row = {}) {
const hasRealtime = realtimeStock !== null;
let stock;
let stockSource;
if (hasRealtime && realtimeStock > 0) {
stock = realtimeStock;
stockSource = 'realtime';
} else if (packStock !== null && packStock > 0) {
stock = packStock;
stockSource = 'pack';
if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) {
const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0;
const packValue = packStock !== null && packStock > 0 ? packStock : 0;
stock = Math.max(realtimeValue, packValue);
stockSource = packValue > realtimeValue ? 'pack' : 'realtime';
} else if (hasRealtime) {
stock = realtimeStock;
stockSource = 'realtime';
@ -1105,7 +1161,7 @@ function buildReplenishMetrics(row = {}) {
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0);
const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0);
const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0;
const hasPendingWorkOrder = pendingWorkOrderQty > 0;
// 待发货订单(见文件头 ⑩):当前未发货订单里的件数
const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0);
@ -1353,19 +1409,34 @@ async function loadPendingWorkOrderIndex() {
const request = pool.request();
request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE);
request.input('voidStatus', WORK_ORDER_VOID_STATUS);
request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS);
const result = await request.query(`
SELECT [商品编码] AS skuId,
SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty,
COUNT(DISTINCT [加工单号]) AS orderCount,
MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta,
MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo
SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) AS pendingQty,
COUNT(DISTINCT CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN [加工单号]
END) AS orderCount,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111)
END) AS eta,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([加工单号] AS NVARCHAR(40))
END) AS firstOrderNo
FROM ${WORK_ORDER_TABLE} WITH (NOLOCK)
WHERE [加工仓储方] = @workOrderWarehouse
AND [状态] <> @voidStatus
AND [收货状态] <> @receivedStatus
GROUP BY [商品编码]`);
GROUP BY [商品编码]
HAVING SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) > 0`);
const bySku = new Map();
(result.recordset || []).forEach((row) => {
@ -1373,7 +1444,7 @@ async function loadPendingWorkOrderIndex() {
if (!key) return;
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0);
if (pendingQty <= 0 && orderCount <= 0) return;
if (pendingQty <= 0) return;
bySku.set(key, {
// 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留
skuId: toText(row.skuId),
@ -1390,15 +1461,16 @@ async function loadPendingWorkOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) workOrderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null;
throw error;
}
}
@ -1685,15 +1757,16 @@ async function loadOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) orderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null;
throw error;
}
}
@ -1720,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,
@ -1803,15 +1878,16 @@ async function loadReplenishIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
replenishIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) replenishIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null;
throw error;
}
}
@ -1819,6 +1895,10 @@ async function loadReplenishIndex() {
/**
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
* · 按基础配件编码精确匹配,SUM(qty) 汇总
* · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径):
* ① jst_inventoryInfo 有正库存 → 以它为准
* ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底
* (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足)
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
* 且失败的分片不写缓存,下次调用重试
@ -1846,25 +1926,42 @@ async function loadComponentStock(codes = []) {
params[`cs${index}`] = code;
return `@cs${index}`;
});
const codeFilter = `IN (${placeholders.join(', ')})`;
// 一条语句同时取两张表(UNION ALL + src 标记),避免多一次往返、也保证两个来源同一时点。
// 库存表不筛 bin;箱表**必须**筛 bin 非空 —— bin 为空的是装箱记录,不是库存(见文件头 ⑤)。
// eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池
const rows = await cloudDatabase.query(
`SELECT i.sku_id AS code, SUM(i.qty) AS qty
`SELECT N'inv' AS src, i.sku_id AS code, SUM(i.qty) AS qty
FROM dbo.jst_inventoryInfo i WITH (NOLOCK)
WHERE i.wms_co_id = @componentStockWmsCoId
AND i.sku_id IN (${placeholders.join(', ')})
GROUP BY i.sku_id`,
WHERE i.wms_co_id = @componentStockWmsCoId AND i.sku_id ${codeFilter}
GROUP BY i.sku_id
UNION ALL
SELECT N'pack' AS src, p.sku_id AS code, SUM(p.qty) AS qty
FROM dbo.jst_packInventoryInfo p WITH (NOLOCK)
WHERE p.wms_co_id = @componentStockWmsCoId AND p.sku_id ${codeFilter}
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`,
params,
{ requestTimeout: 120000 }
);
const found = new Map();
const inventoryQty = new Map();
const packQty = new Map();
rows.forEach((row) => {
const code = toText(row.code);
if (code) found.set(code, toNumber(row.qty) ?? 0);
if (!code) return;
const qty = toNumber(row.qty) ?? 0;
if (toText(row.src) === 'pack') packQty.set(code, qty);
else inventoryQty.set(code, qty);
});
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
chunk.forEach((code) => {
const qty = found.get(code) ?? 0;
// 只补 0、不覆盖正库存:库存表有正数就认它,否则看箱表(箱表可能只记部分装箱量)
let qty = inventoryQty.get(code) ?? 0;
if (qty <= 0) {
const packStock = packQty.get(code) ?? 0;
if (packStock > 0) qty = packStock;
}
// 本批结果仍要返回给调用方(stockMap),只是不再写缓存
if (writable) componentStockCache.set(code, { qty, expiresAt });
stockMap.set(code, qty);
@ -2163,7 +2260,12 @@ async function getMkhBomList(query = {}) {
// 默认按可用天数升序 —— 最紧急的排最前
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
const forceFresh = parseFlag(query.fresh);
const preferLiveData = parseFlag(query.live) || forceFresh;
// fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户
// 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。
if (forceFresh) await refreshMkhOperationalData();
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
buildMergedReplenishRows({ preferLiveData }),
@ -2240,6 +2342,13 @@ async function getMkhBomList(query = {}) {
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
// 前后端实时刷新协议。本地前端如果代理到未重启的旧后端,
// 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。
freshDataContractVersion: 1,
freshDataApplied: forceFresh && operationalRefreshedAt > 0,
operationalRefreshedAt: operationalRefreshedAt
? new Date(operationalRefreshedAt).toISOString()
: null,
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
workOrderDegraded: replenishIndex.workOrderDegraded === true,
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
@ -2481,11 +2590,60 @@ async function listMkhBomExportRows(query = {}) {
// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照,
// 而不是 2~15 分钟前的缓存结果。
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
function clearMkhOperationalCache() {
operationalCacheEpoch += 1;
operationalRefreshedAt = 0;
replenishIndexCache = null;
workOrderIndexCache = null;
orderIndexCache = null;
return {
operationalCacheEpoch,
clearedAt: new Date().toISOString(),
};
}
/**
* 强制重读会变的运营数据。
* - single-flight:并发用户共用同一次云库查询;
* - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库;
* - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。
*/
async function refreshMkhOperationalData() {
const now = Date.now();
if (operationalRefreshPending) {
const result = await operationalRefreshPending;
return { ...result, joined: true };
}
if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) {
return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() };
}
clearMkhOperationalCache();
const refreshEpoch = operationalCacheEpoch;
const pending = (async () => {
await loadReplenishIndex();
if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now();
return {
refreshed: true,
refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null,
};
})();
operationalRefreshPending = pending;
try {
return await pending;
} finally {
if (operationalRefreshPending === pending) operationalRefreshPending = null;
}
}
function clearMkhBomCache() {
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
cacheEpoch += 1;
operationalCacheEpoch += 1;
operationalRefreshPending = null;
operationalRefreshedAt = 0;
businessBomCache = null;
officialBomIndexCache = null;
@ -2507,6 +2665,7 @@ function clearMkhBomCache() {
return {
syncedAt: new Date().toISOString(),
cacheEpoch,
operationalCacheEpoch,
cleared: cleared.map(([name, entries]) => ({ name, entries })),
};
}
@ -2514,6 +2673,7 @@ function clearMkhBomCache() {
module.exports = {
getMkhBomList,
clearMkhBomCache,
refreshMkhOperationalData,
getMkhBomDetail,
listReplenishAlertRows,
listMkhBomExportRows,
@ -2560,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,
@ -2575,5 +2738,8 @@ module.exports = {
EXPORT_MODES,
buildProductExportRows,
buildComponentExportRows,
clearMkhOperationalCache,
refreshMkhOperationalData,
OPERATIONAL_REFRESH_DEDUPE_MS,
},
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,246 @@
'use strict';
/**
* 仓储中心 24 小时发货时效(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单创建时间**算是否及时。
* 每天 12:00 核验「当天创建、7:00 前」的订单是否已发货;
* 每天 16:00 核验「当天创建、16:00 前」的订单是否已发货。
* 展示口径:**只显示超时未发**(业务确认)。
*
* 业务确认的口径(2026-10-20 AskUserQuestion):
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东云仓/京东自营仓/运营C专用仓 等)
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**先留空占位**(不映射到 15718196)
* ③ 展示结构 —— **只显示超时未发**
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
* 字段口径:
* · 创建时间:`order_date`(订单创建时间,业务要求按此算时效)
* · 发货时间:`send_date`(为空 = 未发货)
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
*
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
* 横林二仓 15033302 + 15197138/15301814/15835893/16040225/16132681
* 横林三仓 15043561
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
* 崔桥工厂仓 15038426 + 15301815/16040410
* 崔桥工厂品牌仓 15870495
* 崔桥工厂半成品仓 —— 无同名仓,留空
* 新苏北仓 15038347 + 15315031/15301812
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
* 山东品牌仓 15182705
* 河南洛宁仓 15539084
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
*
* ── 时效判定 ──────────────────────────────────────────────────────────────
* 截止时刻(每日两轮,取**当前已生效且最晚**的那一轮;未到 12:00 时无截止):
* 12:00 → 核验「当天 00:00 ~ 07:00 创建」的订单
* 16:00 → 核验「当天 00:00 ~ 16:00 创建」的订单
* 判定:
* · 超时未发 = `send_date IS NULL` 且 `order_date <= 截止时刻`
* · 按时已发 = `send_date IS NOT NULL` 且 `send_date <= 截止时刻` → **不计入超时**
* · 已发但超时 = `send_date > 截止时刻` → 也算超时(已发,但没赶上截止)
* 业务只要「超时未发」,所以列表只返回 `send_date IS NULL` 的超时单;
* 但 summary 里仍给出 `overdueShipped`,避免「已发但超时」被当成没超时。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 订单时间列存的是**东八区本地时间**(实测 `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: ['崔桥工厂品牌仓'] },
// 系统内无同名仓(最接近的「简尚-崔桥工厂半成品包装项目仓」15718196 是配件仓,
// 业务确认先留空占位,不并进来)
{ key: 'CQ_WIP', name: '崔桥工厂半成品仓', keywords: [], placeholder: true },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'] },
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
]);
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
// 靠关键词长度倒序匹配即可(见 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);
// ── 时效轮次(业务给定)──────────────────────────────────────────────────
// cutoffHour:当天几点截止;windowEndHour:只核验「创建时间不晚于该时刻」的订单
const ROUNDS = Object.freeze([
{ key: 'NOON', label: '12:00 轮', cutoffHour: 12, windowEndHour: 7 },
{ key: 'AFTERNOON', label: '16:00 轮', cutoffHour: 16, windowEndHour: 16 },
]);
// 只排除取消/合并单;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')`;
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');
}
/**
* 用「关键词最长匹配」把仓库名归到主仓。
* @returns {object|null} CENTERS 里的元素;无法归属返回 null
*/
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
for (const item of SORTED_KEYWORDS) {
if (warehouseName.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 = [];
for (const row of rows) {
const center = resolveCenter(String(row.name || ''));
if (center && byCenter.has(center.key)) {
byCenter.get(center.key).push({
wmsCoId: Number(row.wms_co_id),
warehouseId: Number(row.Id),
name: String(row.name || ''),
});
} else {
unmatched.push({ wmsCoId: Number(row.wms_co_id), name: String(row.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 };
return list;
}
function clearWarehouseCache() {
warehouseCache = { at: 0, rows: [] };
}
/**
* 计算「当前已生效」的核验轮次。
*
* 12:00 之前 → **未到核验时间,返回 null**。
* 业务明确要求(2026-10-20 确认):12:00 之前不做核验,
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
* 12:00~15:59 → 用 12:00 轮(窗口 00:00~07:00)
* 16:00 之后 → 用 16:00 轮(窗口 00:00~16:00,覆盖范围更大)
*
* @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区)
* @returns {object|null} null 表示当前不在任何核验时段内
*/
function resolveActiveRound(now = new Date()) {
const hour = now.getHours();
const minute = now.getMinutes();
const minutes = hour * 60 + minute;
let active = null;
for (const round of ROUNDS) {
if (minutes >= round.cutoffHour * 60) active = round;
}
// 12:00 前:首轮核验还没开始,不做判定
if (!active) return null;
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
return {
...active,
deadline: `${y}-${m}-${d} ${pad2(active.cutoffHour)}:00:00`,
windowStart: `${y}-${m}-${d} 00:00:00`,
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
date: `${y}-${m}-${d}`,
settled: false,
isPreviousDay: false,
};
}
/**
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
*/
function describeRounds(now = new Date()) {
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
const date = `${y}-${m}-${d}`;
return ROUNDS.map((r) => ({
key: r.key,
label: r.label,
date,
deadline: `${date} ${pad2(r.cutoffHour)}:00:00`,
windowStart: `${date} 00:00:00`,
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
active: now.getHours() * 60 + now.getMinutes() >= r.cutoffHour * 60,
}));
}
module.exports = {
CENTERS,
ROUNDS,
resolveCenter,
loadCenterWarehouseMap,
clearWarehouseCache,
resolveActiveRound,
describeRounds,
EXCLUDED_STATUS_SQL,
toNumber,
};

View File

@ -0,0 +1,528 @@
'use strict';
/**
* 仓储中心 24 小时发货时效 —— 取数/聚合服务(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 配置与口径定义见 warehouseShipTimelinessRegistry.js(主仓关键词、轮次、时区说明)。
*
* ── 一次请求做几件事 ──────────────────────────────────────────────────────
* ① 读仓库表 → 主仓 → wms_co_id 集合(缓存 1h)
* ② 一次 SQL 同时取「超时未发」和「已超时已发」两类,按 wms_co_id 分组
* ③ 归并到主仓,返回每仓的 count / orderAmount,以及全量超时明细
*
* ── SQL 设计要点(踩过的坑)───────────────────────────────────────────────
* · 两张订单表**店铺互斥**,用 UNION ALL 合并后一次性分组,不要分别查再在
* Node 侧合并 —— 两趟扫描在 rpt 视图上代价翻倍。
* · `wms_co_id IN (...)` 直接内联仓库 ID(都是整数,无注入风险),因为仓库
* 集合动态生成,用参数化会引入可变数量的 @p0..@pN,反而难维护。
* · **不要在 ORDER BY 里对聚合列做算术**(见 MEMORY:会触发灾难计划)。
* 这里 SQL 只 GROUP BY 取原始计数/金额,排序与派生字段全在 Node 侧算。
* · 时间比较一律用**字面量字符串**(形如 '2026-10-20 16:00:00'),由
* resolveActiveRound 在东八区算出;不用 GETDATE() 做相对偏移,避免
* 数据库与应用的时区假设不一致。
* · 明细行带 `TOP (@limit)`,`@limit` 走参数化,避免界面一次拉几万行。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseShipTimelinessRegistry');
const { CENTERS, EXCLUDED_STATUS_SQL, toNumber } = registry;
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000;
/**
* 把 UTC Date / 字符串 统一格式化成 'YYYY-MM-DD HH:mm:ss'(按服务器本地时区)。
* mssql 驱动默认把 datetime 解析为 UTC 语义的 Date,这里只做展示,统一成
* 与库内一致的东八区墙钟时间。
*/
function formatDateTime(value) {
if (!value) return '';
if (typeof value === 'string') return value.replace('T', ' ').slice(0, 19);
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 两个 'YYYY-MM-DD HH:mm:ss' 相差多少分钟(四舍五入) */
function diffMinutes(fromText, toText) {
const a = new Date(String(fromText).replace(' ', 'T')).getTime();
const b = new Date(String(toText).replace(' ', 'T')).getTime();
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
return Math.round((b - a) / 60000);
}
/**
* 构造两表合并的子查询。
*
* 🔥 三条必须同时满足(2026-10-20 实测踩坑):
* ① `order_date >= @windowStart` —— **必须有下界**。只写 `<= @deadline` 会把
* 一年前创建、至今未发的历史单全捞进来(实测 13,553 条,最早 2025-09-01)。
* ② `order_date <= @deadline` —— 上界:截止时刻后创建的订单不参与本轮考核。
* ③ Split 已在 EXCLUDED_STATUS_SQL 里排掉(拆单父单永不发货)。
*
* @param {number[]} wmsIds 主仓下所有 wms_co_id
* @param {string} deadline 截止时刻字面量
* @param {'pending'|'shippedLate'} mode 取哪一类
*/
function buildUnionSql(wmsIds, deadline, mode) {
const idList = wmsIds.join(',');
// pending:未发货且窗口内已创建 → 超时未发
// shippedLate:已发货但发货时间晚于截止时刻 → 已超时已发
const cond = mode === 'pending'
? `o.send_date IS NULL`
: `o.send_date IS NOT NULL AND o.send_date > @deadline`;
const branch = (table) => `
SELECT N'${table}' AS src, o.o_id, o.so_id, o.shop_name, o.shop_id,
o.order_date, o.send_date, o.status, o.shop_status,
o.wms_co_id, o.pay_amount, o.labels
FROM dbo.${table} o
WHERE o.wms_co_id IN (${idList})
AND o.order_date >= @windowStart
AND o.order_date <= @deadline
AND ${cond}
AND ${EXCLUDED_STATUS_SQL}`;
return `${branch('jst_Orders')}
UNION ALL${branch('jst_qmcust_orderlist')}`;
}
/**
* 单个主仓的仓库 ID 集合 → 逗号串(用于按主仓分桶时拼 IN 条件)
*/
function joinIds(ids) {
return ids.join(',');
}
/**
* 主入口:计算某一天、某一轮次的仓储中心发货时效。
*
* @param {object} options
* @param {string} [options.round] 'NOON' | 'AFTERNOON',不传则按当前时刻自动判定
* @param {Date} [options.now] 仅测试用
* @param {number} [options.limit] 明细条数上限
* @returns {Promise<object>}
*/
async function getShipTimeliness(options = {}) {
const now = options.now instanceof Date ? options.now : new Date();
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
MAX_DETAIL_LIMIT
);
// ① 主仓 → 仓库映射
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;
}
const pad = (n) => String(n).padStart(2, '0');
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
round = {
...target,
date,
deadline: `${date} ${pad(target.cutoffHour)}:00:00`,
windowStart: `${date} 00:00:00`,
windowEnd: `${date} ${pad(target.windowEndHour)}:00:00`,
active: now.getHours() * 60 + now.getMinutes() >= target.cutoffHour * 60,
};
} else {
round = registry.resolveActiveRound(now);
}
// 🔥 12:00 前没有任何轮次生效(业务口径:12:00 才开始第一轮核验)。
// 这里**直接返回「未到核验时间」**,既不查库、也不回填昨天 ——
// 让业务清楚知道「现在还没到看数据的时候」,而不是拿旧数字去对仓库。
if (!round) {
const centerList = await registry.loadCenterWarehouseMap();
const nextRound = rounds.find((r) => r.cutoffHour === 12) || 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 : '',
deadline: '',
window: null,
summary: emptySummary(),
centers: buildEmptyCenters(centerList),
orders: [],
truncated: false,
detailLimit: limit,
detailDegraded: false,
freshness: null,
note: '今日 12:00 首轮核验开始后才展示超时数据',
};
}
// 没有归属任何主仓的仓库不用查
const allWmsIds = [...new Set(
centerList.flatMap((c) => c.warehouses.map((w) => w.wmsCoId)).filter((n) => Number.isFinite(n))
)];
if (!allWmsIds.length) {
return {
generatedAt: formatDateTime(now),
date: round.date,
round: { key: round.key, label: round.label },
isPreviousDay: Boolean(round.isPreviousDay),
rounds,
deadline: round.deadline,
window: { start: round.windowStart, end: round.windowEnd },
summary: emptySummary(),
centers: buildEmptyCenters(centerList),
orders: [],
truncated: false,
detailLimit: limit,
note: '仓库表中未匹配到任何目标主仓,请检查仓库命名',
};
}
// ③ 一趟 SQL 取全量分组结果。原实现拆成「超时未发 / 已超时已发 / 窗口总数」
// 三条 SQL,在 rpt 视图上三趟全表扫描实测 213 秒;合并成一趟后按
// `src + 是否超时` 打标记再 GROUP BY,只扫一次两表。
//
// 分组维度:主仓 不直接在 SQL 里 GROUP(仓库→主仓的映射在 Node 侧缓存里),
// 所以按 wms_co_id 分组,回 Node 侧累加到主仓。
//
// 🔥 「算不算已发货」的三态判定(2026-10-20 实测新增):
// 上游 `send_date` 回写**明显滞后** —— 实测 10-20 当天 700 单里只有 100 单
// 有 send_date,且都集中在 08:16~08:44(刚回写的那几单)。若只看 send_date,
// 中午 12:00 核验时当天早上的单会**全部被判超时**,页面毫无价值。
// 因此引入出库表 `rpt.jst_salesOutboundInfo` 作为佐证:
// · 判为「已发」= send_date 有值 **或** 出库表能查到该 o_id
// · 判为「按时」= send_date <= 截止 **或** 出库行 io_date <= 截止
// ⚠️ 出库表同样滞后(每天 04:00 起才有数据,当天往往 0 行,次日凌晨灌),
// 所以它只能**减少误判**(把「已发的判成没发」纠正过来),
// 不能凭空补出「没发的单已经发了」。最终仍可能有误报,故返回值里带
// `freshness` 让前端明示「数据可能滞后」。
// ⚠️ 出库表 `qty` 列全表恒 0(见 MEMORY),判断有无出库**只看行数 / io_date**。
//
// 每条订单归属哪一类(互斥):
// pending —— 两源都查不到发货痕迹 → 超时未发
// shippedLate —— 有发货痕迹但发货时间 > 截止 → 已发但超时
// onTime —— 发货时间 <= 截止 → 按时已发
// ⚠️ OUTER APPLY 里只取 `io_date`,用「io_date 是否为 NULL」判断出库表有无命中。
// 别在外面引用 `ob.o_id` —— APPLY 子查询只 SELECT 了 io_date,引用不存在的列
// 会报 `Invalid column name 'o_id'`(2026-10-20 已踩)。
//
// ⚡ 性能关键(2026-10-20 实测):
// · 原实现「汇总 SQL + 明细 SQL」各扫一遍窗口,合计 108 秒;
// · 窗口内订单只有几百条(实测 420 条),所以改成**一趟取回全部窗口订单明细**,
// 汇总与明细都在 Node 里算 —— 少一次大表扫描。
// · 返回列加上 pay_amount / status,供 Node 侧分桶。
//
// 🔥🔥 两表**不能 UNION ALL 成一条 SQL**(2026-10-20 实测,这是本文件最大的坑):
// · `jst_qmcust_orderlist` 是**宽视图**(上百列),拆开单独跑很快,但一旦参与
// UNION ALL + 派生表 + OUTER APPLY 的嵌套,优化器会放弃谓词下推,
// 变成先物化整表再过滤 —— 实测单条 SQL 跑 200~320 秒。
// · 两表**分别查、Node 侧合并**:jst_Orders 5.5 秒、jst_qmcust_orderlist 35 秒,
// 合计约 40 秒,比合并版快 5~8 倍。
// · 两表店铺完全互斥(京东/拼多多/抖音/得物/唯品会 vs 天猫/淘宝/1688),
// o_id 不会有交集,直接拼接即可,不需要再按 o_id 去重(仍保留一次去重兜底)。
//
// 🔥 `jst_qmcust_orderlist` 单表也有 35 秒,而同样条件的 `jst_Orders` 只要 2 秒。
// 曾尝试把它的窗口过滤换成 `created`(实测快 10 倍),但**已证伪,不能用**:
// · 2026-10-20 实测 2,581 行里,`order_date` 与 `created` 完全相等的只有 4 行,
// 相差 >1 小时的有 412 行,最大差 24.7 天,**198 行甚至跨日**;
// · 典型形态:`order_date=10-18 23:58` 而 `created=10-19 04:00:08` ——
// 上游每天凌晨 4 点批量补录,`created` 是**灌库时间**不是下单时间;
// · 同一窗口下 `created` 比 `order_date` 多命中 30 单(全是补录单)。
// 业务口径是「按**订单创建时间**算是否及时」,只有 `order_date` 是对的。
// 所以:**两表都用 `order_date`**,性能只能靠「两表分开查 + Node 侧合并」。
// ⚠️ 换回 `created` 之前必须先跑 `.workbuddy/tmp/probe-od-vs-created.cjs` 复测。
const windowBranch = (table) => `
SELECT N'${table}' AS src,
o.wms_co_id, o.o_id, o.so_id, o.shop_name, o.order_date,
o.send_date, o.status, o.shop_status, o.pay_amount, o.labels,
ob.io_date AS ob_io_date
FROM dbo.${table} o
OUTER APPLY (
SELECT TOP 1 s.io_date
FROM dbo.jst_salesOutboundInfo s
WHERE s.o_id = o.o_id
ORDER BY s.io_date ASC
) ob
WHERE o.wms_co_id IN (${joinIds(allWmsIds)})
AND o.order_date >= @windowStart
AND o.order_date <= @deadline
AND ${EXCLUDED_STATUS_SQL}`;
const params = { deadline: round.deadline, windowStart: round.windowStart };
// ⚠️ 超时给足:qmcust 单表实测 35~80 秒(视图谓词下推较差),
// 不能按 jst_Orders 的 5 秒去设,否则会被误杀成 500。
const queryOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 320000 };
// 两表并行查(互不依赖),Node 侧合并
const [ordersRows, qmcustRows] = await Promise.allSettled([
cloudDatabase.query(windowBranch('jst_Orders'), params, queryOpts),
cloudDatabase.query(windowBranch('jst_qmcust_orderlist'), params, queryOpts),
]);
// 🔥 用 allSettled 而不是 all:一张表超时/报错时,另一张表的数据仍然可用,
// 否则整页变空白。降级情况记录在 detailDegraded + note 里。
const mergeWarnings = [];
const windowRows = [];
if (ordersRows.status === 'fulfilled') {
windowRows.push(...(ordersRows.value || []));
} else {
mergeWarnings.push(`jst_Orders 查询失败:${ordersRows.reason?.message || '未知错误'}`);
}
if (qmcustRows.status === 'fulfilled') {
windowRows.push(...(qmcustRows.value || []));
} else {
mergeWarnings.push(`jst_qmcust_orderlist 查询失败:${qmcustRows.reason?.message || '未知错误'}`);
}
if (!windowRows.length && mergeWarnings.length) {
// 两表全挂 —— 这才是真正的失败,直接抛给上层
const err = new Error(mergeWarnings.join(';'));
err.statusCode = 502;
throw err;
}
// ④ 数据新鲜度:出库表最新 io_date。
// ⚠️ 只用于「提示用户数据可能滞后」,不参与超时判定。
// 两个源都滞后时(出库表当天 0 行 + send_date 昨天就停了),说明上游还没灌数,
// 此时「超时未发」的准确性无法保证 —— 前端必须显式告警,不能让业务拿着
// 一份假数据去考核仓库。
const freshness = await loadShipFreshness(round);
// ⑤ 三态归类(在 Node 侧做,SQL 只负责取数)
const wmsToCenter = new Map();
for (const c of centerList) {
for (const w of c.warehouses) wmsToCenter.set(w.wmsCoId, c.key);
}
const acc = new Map(centerList.map((c) => [c.key, {
pendingCount: 0,
pendingAmount: 0,
shippedLateCount: 0,
onTimeCount: 0,
}]));
const pendingRows = [];
// 两表店铺互斥、o_id 理论不重叠,但历史上出现过同一 o_id 被两边都收录的情况,
// 按 `${src}-${o_id}` 去重(跨表同 o_id 也算重复,取先到的 jst_Orders)。
const seenKey = new Set();
for (const row of windowRows || []) {
const bucket = acc.get(wmsToCenter.get(Number(row.wms_co_id)));
if (!bucket) continue;
const dedupeKey = String(row.o_id || '');
if (dedupeKey) {
if (seenKey.has(dedupeKey)) continue;
seenKey.add(dedupeKey);
}
const kind = bucketOf(row, round.deadline);
if (kind === 'pending') {
bucket.pendingCount += 1;
bucket.pendingAmount += toNumber(row.pay_amount);
pendingRows.push(row);
} else if (kind === 'shippedLate') {
bucket.shippedLateCount += 1;
} else {
bucket.onTimeCount += 1;
}
}
// 明细:只要「超时未发」,最早创建的最急,排前面
// 用 formatDateTime 转成 'YYYY-MM-DD HH:mm:ss' 再比字符串 —— 字典序与时间序一致,
// 且不受 mssql 驱动把 datetime 解析成 UTC Date 的影响(见文件头时区说明)。
pendingRows.sort((a, b) => {
const x = formatDateTime(a.order_date);
const y = formatDateTime(b.order_date);
if (x < y) return -1;
if (x > y) return 1;
return 0;
});
const detailRows = pendingRows.slice(0, limit);
const centers = centerList.map((c) => {
const b = acc.get(c.key) || { pendingCount: 0, pendingAmount: 0, shippedLateCount: 0, onTimeCount: 0 };
const totalCount = b.pendingCount + b.shippedLateCount + b.onTimeCount;
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,
overdueUnshippedAmount: Math.round(b.pendingAmount * 100) / 100,
// 辅助信息
overdueShipped: b.shippedLateCount,
windowOrderCount: totalCount,
onTimeRate: totalCount > 0
? Math.round((b.onTimeCount / totalCount) * 1000) / 10
: null,
};
});
const orders = (detailRows || []).map((r) => {
const orderDate = formatDateTime(r.order_date);
const center = centerList.find((c) => c.key === wmsToCenter.get(Number(r.wms_co_id)));
const warehouse = center && center.warehouses.find((w) => w.wmsCoId === Number(r.wms_co_id));
return {
oId: String(r.o_id || ''),
soId: String(r.so_id || ''),
source: r.src === 'jst_qmcust_orderlist' ? 'qmcust' : 'orders',
shopName: String(r.shop_name || ''),
orderDate,
sendDate: formatDateTime(r.send_date),
status: String(r.status || ''),
shopStatus: String(r.shop_status || ''),
wmsCoId: Number(r.wms_co_id),
warehouseName: warehouse ? warehouse.name : '',
centerKey: center ? center.key : '',
centerName: center ? center.name : '',
payAmount: toNumber(r.pay_amount),
labels: String(r.labels || ''),
// 相对截止时刻的超时时长(分钟)
overdueMinutes: diffMinutes(orderDate, round.deadline),
};
});
const summary = centers.reduce((acc2, c) => {
acc2.overdueUnshipped += c.overdueUnshipped;
acc2.overdueShipped += c.overdueShipped;
acc2.overdueUnshippedAmount += c.overdueUnshippedAmount;
acc2.windowOrderCount += c.windowOrderCount;
if (c.overdueUnshipped > 0) acc2.overdueCenterCount += 1;
return acc2;
}, emptySummary());
summary.overdueUnshippedAmount = Math.round(summary.overdueUnshippedAmount * 100) / 100;
// 按时率统一口径:按时 = 窗口内总数 - 超时未发 - 已发超时
// (与各仓算法完全一致,避免两处各算一遍算出不同数字)
summary.onTimeRate = summary.windowOrderCount > 0
? Math.round(((summary.windowOrderCount - summary.overdueUnshipped - summary.overdueShipped)
/ summary.windowOrderCount) * 1000) / 10
: null;
const totalPending = summary.overdueUnshipped;
return {
generatedAt: formatDateTime(now),
date: round.date,
// ⚠️ 前端要展示「核验 07:00 / 16:00 前」,必须把 windowEnd 一起带出去。
// 早先只回了 key/label,前端拿不到 cutoff,页面就渲染成「核验 前」。
round: {
key: round.key,
label: round.label,
cutoffHour: round.cutoffHour,
windowEnd: round.windowEnd,
},
// 12:00 前不会走到这里(见上方 notStarted 分支),留着兼容显式指定 round 的调用
notStarted: false,
isPreviousDay: false,
rounds,
deadline: round.deadline,
window: { start: round.windowStart, end: round.windowEnd },
summary,
centers,
orders,
truncated: totalPending > orders.length,
detailLimit: limit,
// 任一张订单表查询失败 → 数字不完整,前端要显式告警(不能拿残缺数据考核仓库)
detailDegraded: mergeWarnings.length > 0,
freshness,
note: mergeWarnings.length ? `${mergeWarnings.join(';')}(数据不完整)` : '',
};
}
/**
* 计算发货数据新鲜度。
*
* 🔥 业务上这是**最容易被误判成系统 bug** 的地方(见 MEMORY 入口 1):
* 上游 send_date / 出库表都滞后,「超时未发」数量会虚高。
* 这里只如实报告滞后程度,不做任何「猜测补数」。
*
* @returns {Promise<{outboundMaxIo: string, sendDateMax: string, stale: boolean, hint: string}>}
*/
async function loadShipFreshness(round) {
try {
const rows = await cloudDatabase.query(
`SELECT CONVERT(varchar(19), MAX(io_date), 120) AS outbound_max
FROM dbo.jst_salesOutboundInfo`,
{},
{ requestTimeout: 60000 }
);
const outboundMaxIo = rows && rows[0] ? String(rows[0].outbound_max || '') : '';
// 出库表最新一行早于今天 → 当天发货数据尚未灌入
const outboundStale = !outboundMaxIo || outboundMaxIo.slice(0, 10) < round.date;
return {
outboundMaxIo,
stale: outboundStale,
hint: outboundStale
? `出库表最新数据 ${outboundMaxIo || '暂无'},今天(${round.date})的发货记录尚未灌入,`
+ '「超时未发」可能偏多,请以仓库实际发货情况为准'
: '',
};
} catch (e) {
// 新鲜度查询失败绝不能影响主结果
console.warn('[ShipTimeliness] 取新鲜度失败:', e.message);
return { outboundMaxIo: '', stale: true, hint: '发货数据新鲜度未知' };
}
}
/**
* 单条订单的三态归类(与服务层口径一一对应):
* pending —— send_date 为空 **且** 出库表无命中 → 超时未发
* shippedLate —— 有发货痕迹但发货时间晚于截止 → 已发但超时
* onTime —— 发货时间不晚于截止 → 按时已发
*
* 「发货时间」取值优先级:send_date 有值就用 send_date,否则用出库表 io_date。
* (两个都滞后时以 send_date 为准,因为它是订单系统自己的回写,语义最直接。)
*
* @param {object} row 含 send_date / ob_io_date
* @param {string} deadline 'YYYY-MM-DD HH:mm:ss'
*/
function bucketOf(row, deadline) {
const sendDate = formatDateTime(row.send_date);
const ioDate = formatDateTime(row.ob_io_date);
if (!sendDate && !ioDate) return 'pending';
const shippedAt = sendDate || ioDate;
return shippedAt > deadline ? 'shippedLate' : 'onTime';
}
function emptySummary() {
return {
overdueUnshipped: 0,
overdueShipped: 0,
overdueUnshippedAmount: 0,
overdueCenterCount: 0,
windowOrderCount: 0,
onTimeRate: null,
};
}
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,
overdueUnshippedAmount: 0,
overdueShipped: 0,
windowOrderCount: 0,
onTimeRate: null,
}));
}
module.exports = {
getShipTimeliness,
DEFAULT_DETAIL_LIMIT,
MAX_DETAIL_LIMIT,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -250,7 +250,7 @@ test('replenish: live refresh ignores delayed sales and stock snapshots', () =>
assert.equal(live.stockSource, 'live-missing');
});
test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => {
test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => {
// 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货
const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 });
assert.equal(fillFromZero.stock, 4);
@ -261,9 +261,14 @@ test('replenish: box/pallet stock fills in when the daily inventory table is sta
assert.equal(fillFromMissing.stock, 5);
assert.equal(fillFromMissing.stockSource, 'pack');
// 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 });
assert.equal(realtimeWins.stock, 4);
// 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住
const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 });
assert.equal(packWins.stock, 47);
assert.equal(packWins.stockSource, 'pack');
// 散货只在 inventoryInfo 中:该表较大时仍保留完整库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 });
assert.equal(realtimeWins.stock, 100);
assert.equal(realtimeWins.stockSource, 'realtime');
// 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照
@ -363,16 +368,16 @@ test('replenish: pending work order fields default to empty without any order',
assert.equal(none.pendingWorkOrderEta, null);
assert.equal(none.pendingWorkOrderNo, null);
// 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理
// 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理
const zeroQty = metrics({
avgSale: 1,
realtimeStock: 0,
pendingWorkOrderQty: 0,
pendingWorkOrderCount: 2,
});
assert.equal(zeroQty.hasPendingWorkOrder, true);
assert.equal(zeroQty.warningLevel, '已排产');
assert.equal(zeroQty.hasWarning, false);
assert.equal(zeroQty.hasPendingWorkOrder, false);
assert.equal(zeroQty.warningLevel, '缺货');
assert.equal(zeroQty.hasWarning, true);
});
test('replenish: products without sales stay "no sale" even with a pending work order', () => {
@ -520,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');
@ -1280,3 +1353,164 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
svc.clearMkhBomCache();
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 配件库存的箱表兜底(2026-09-30 修正)
//
// 症状:配件监控页显示该配件有 200 件(货位 ZT-30、正常),MKH-BOM 却把成品
// MKHDNZ0409260600849 判成「配件不足」、可组装数 0。
// 成因:配件监控页是「库存表 → 同仓箱表 → 快照」三级兜底,而 MKH-BOM 的
// loadComponentStock **只查 jst_inventoryInfo**。上游库存表滞后停在 0 时,
// 箱表(小时级)已经有货 → 两个页面结论相反。
// 口径:与配件监控页 buildRealtimeStockSql 对齐 —— 库存表有正数就认它,
// 为 0 / 缺行才用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。
// ─────────────────────────────────────────────────────────────────────────────
test('配件库存兜底:库存表为 0 / 缺行时用同仓箱表补,但正库存不被覆盖', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const ONLY_PACK = 'DNZTESTPACK01'; // 库存表 0、箱表 200 → 必须取 200(误判就是这里)
const HAS_INV = 'DNZTESTPACK02'; // 库存表 5、箱表 300 → 必须取 5(箱表只记部分装箱量)
const PACK_ZERO = 'DNZTESTPACK03'; // 库存表 0、箱表 0 → 0
const NEITHER = 'DNZTESTPACK04'; // 两表都没有 → 0
const INV_ROWS = [ // 库存表:滞后停在旧值
{ code: ONLY_PACK, qty: 0 },
{ code: HAS_INV, qty: 5 },
{ code: PACK_ZERO, qty: 0 },
];
const PACK_ROWS = [ // 同仓箱表(bin 非空):小时级,已反映真实到货
{ code: ONLY_PACK, qty: 200 },
{ code: HAS_INV, qty: 300 },
{ code: PACK_ZERO, qty: 0 },
];
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
// 按 SQL 形状分流:旧实现只查库存表(SQL 里没有箱表名)→ 拿不到箱表数据,
// 兜底失效、ONLY_PACK 被判 0(对照组 = 修复前必然失败)。
if (!sql.includes('jst_packInventoryInfo')) return INV_ROWS;
return [
...INV_ROWS.map((row) => ({ src: 'inv', ...row })),
...PACK_ROWS.map((row) => ({ src: 'pack', ...row })),
];
};
try {
svc.clearMkhBomCache();
const { stockMap, degraded } = await svc._private.loadComponentStock([
ONLY_PACK, HAS_INV, PACK_ZERO, NEITHER,
]);
assert.equal(degraded, false);
assert.equal(stockMap.get(ONLY_PACK), 200,
'库存表为 0 时必须用箱表兜底,否则配件被误判不足(本问题的根因)');
assert.equal(stockMap.get(HAS_INV), 5,
'库存表有正库存时必须以它为准,不能被箱表覆盖');
assert.equal(stockMap.get(PACK_ZERO), 0, '箱表也是 0 时不能补出正数');
assert.equal(stockMap.get(NEITHER), 0, '两表都查不到 = 该仓无库存,仍按 0');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('配件库存兜底:单条查询取两张表,且箱表必须过滤 bin 为空的行(防腐化)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const seen = [];
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
seen.push(sql);
return [];
};
try {
svc.clearMkhBomCache();
await svc._private.loadComponentStock(['DNZTESTPACK05']);
assert.equal(seen.length, 1, '一个分片只应打一条查询(避免多一次往返与快照时点差异)');
const sql = seen[0];
assert.match(sql, /jst_packInventoryInfo/, '必须查箱表,否则兜底不存在');
assert.match(sql, /NULLIF\(LTRIM\(RTRIM\(p\.bin\)\), N''\)\s+IS NOT NULL/,
'箱表必须过滤 bin 为空的行 —— 那是装箱记录,不是库存');
assert.match(sql, /p\.wms_co_id = @componentStockWmsCoId/,
'箱表必须限定配件归属仓,不能跨仓汇总');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('配件库存兜底:查询失败仍降级为「不做判定」,不得当成 0', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const CODE = 'DNZTESTPACK06';
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
throw new Error('模拟连接抖动');
};
try {
svc.clearMkhBomCache();
const { stockMap, degraded } = await svc._private.loadComponentStock([CODE]);
assert.equal(degraded, true, '查询失败必须降级,让调用方放弃配件不足判定(宁可少标不误标)');
assert.equal(stockMap.get(CODE), undefined, '失败时不得把未知库存当作 0 返回');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
let queryCount = 0;
let releaseMonitor;
let markMonitorStarted;
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
cloudDatabase.query = async (sql) => {
queryCount += 1;
if (sql.includes('pm_stockMonitor')) {
markMonitorStarted();
await monitorGate;
}
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({
input() { return this; },
async query() { queryCount += 1; return { recordset: [] }; },
}),
});
try {
svc.clearMkhBomCache();
const first = svc.refreshMkhOperationalData();
await monitorStarted;
const second = svc.refreshMkhOperationalData();
releaseMonitor();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult.refreshed, true);
assert.equal(secondResult.joined, true);
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
const afterConcurrent = queryCount;
const reused = await svc.refreshMkhOperationalData();
assert.equal(reused.reused, true);
assert.equal(queryCount, afterConcurrent,
'15 秒保护窗内不应重复访问业务云库');
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});

View File

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

View File

@ -0,0 +1,149 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchGradeRows,
fetchZeroSaleSkuRows,
} = require('../src/services/stockSalesAnalysis');
const { SALES_GRADE_RULES } = require('../src/config/stockSalesAnalysis');
test('buildInClause 按位展开仓库参数', () => {
const { clause, params } = buildInClause([101, 102], 'w');
assert.equal(clause, '@w0,@w1');
assert.deepEqual(params, { w0: 101, w1: 102 });
});
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
const expr = buildGradeCase();
SALES_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
});
assert.ok(expr.includes("THEN 'slow2'"));
assert.ok(expr.includes("ELSE 'zero'"));
});
test('fetchGradeRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
await assert.rejects(
() => fetchGradeRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchGradeRows 无仓库或缺失窗口时直接返回空结果', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchGradeRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchGradeRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
assert.equal(called, 0);
});
test('fetchGradeRows 把仓库参数与窗口参数一并传给业务库', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, gradeKey: 'hot', skuCount: 2, stockQty: 438, salesQty: 1136, dailySales: 37.87 },
];
},
};
const rows = await fetchGradeRows(db, [101, 102], {
shipFrom: '2026-08-28 01:00:00',
shipTo: '2026-09-27 01:00:00',
days: 30,
});
assert.equal(rows.length, 1);
assert.equal(rows[0].wmsCoId, '101');
assert.equal(rows[0].stockQty, 438);
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.days, 30);
assert.equal(captured.params.shipFrom, '2026-08-28 01:00:00');
assert.equal(captured.params.shipTo, '2026-09-27 01:00:00');
assert.ok(captured.sqlText.includes('@days'));
assert.ok(captured.sqlText.includes('rpt.jst_inventoryInfo'));
assert.ok(captured.sqlText.includes('rpt.jst_OrderItem'));
});
test('fetchZeroSaleSkuRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
await assert.rejects(
() => fetchZeroSaleSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchZeroSaleSkuRows 无仓库或缺失窗口时直接返回空结果', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchZeroSaleSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchZeroSaleSkuRows(db, [101], { shipFrom: 'a', shipTo: null, days: 30 }), []);
assert.equal(called, 0);
});
test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1', stockQty: 7, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A02-03-1', stockQty: 5, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 },
];
},
};
const rows = await fetchZeroSaleSkuRows(db, [101, 102], {
shipFrom: '2026-08-28 01:00:00',
shipTo: '2026-09-27 01:00:00',
days: 30,
});
// 每个仓位独立一行;无仓位的 SKU 保留一行(仓位为空、库存取总库存)
assert.equal(rows.length, 3);
assert.equal(rows[0].wmsCoId, '101');
assert.equal(rows[0].skuName, '款式A');
assert.equal(rows[0].binName, 'A01-01-1');
assert.equal(rows[0].stockQty, 7);
assert.equal(rows[1].binName, 'A02-03-1');
assert.equal(rows[1].stockQty, 5);
assert.equal(rows[2].binName, '');
assert.equal(rows[2].stockQty, 3);
// 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
assert.ok(captured.sqlText.includes("ELSE 'zero'"));
assert.ok(captured.sqlText.includes("g.gradeKey = 'zero'"));
assert.ok(captured.sqlText.includes('rpt.jst_sku'));
assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')"));
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.days, 30);
// 系统仓位:来自 rpt.jst_packInventoryInfo,按「仓库+SKU+仓位」聚合出仓位级库存,
// 每仓位一行(COALESCE 兜底无仓位 SKU 取总库存),排除无仓位与零库存记录
assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo'));
assert.ok(captured.sqlText.includes('COALESCE(bn.binQty, g.stockQty)'));
assert.ok(captured.sqlText.includes('binName'));
});
test('fetchZeroSaleSkuRows 的仓位必须按同一仓库匹配,避免跨仓错配', async () => {
let captured = null;
const db = {
query: async (sqlText) => {
captured = sqlText;
return [];
},
};
await fetchZeroSaleSkuRows(db, [101], {
shipFrom: '2026-08-28 01:00:00', shipTo: '2026-09-27 01:00:00', days: 30,
});
assert.ok(captured.includes('bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId'));
assert.ok(captured.includes("ISNULL(p.qty, 0) <> 0"));
});

View File

@ -0,0 +1,233 @@
/**
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走,
* 否则品牌仓的订单会被重复计入两个仓;
* ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用
* 7:00 窗口、16:00 后用 16:00 窗口;
* ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单,
* 永不发货,不排除会把一年前的单全算成超时未发);
* ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
*/
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 = [
['京仓-横林二仓京东云仓', 'HL2'],
['简尚-运营C横林二仓专用仓', '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('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
// 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」,
// 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。
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, true);
assert.deepEqual(c.keywords, []);
});
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
test('12:00 前不做核验,返回 null(不回填昨日数据)', () => {
// 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验,
// 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
assert.equal(registry.resolveActiveRound(at(11, 59)), null);
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
});
test('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => {
const r = registry.resolveActiveRound(at(12, 0));
assert.ok(r);
assert.equal(r.key, 'NOON');
assert.equal(r.deadline, '2026-10-20 12:00:00');
assert.equal(r.windowStart, '2026-10-20 00:00:00');
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
});
test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => {
const r = registry.resolveActiveRound(at(16, 0));
assert.equal(r.key, 'AFTERNOON');
assert.equal(r.deadline, '2026-10-20 16:00:00');
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
// 深夜仍沿用当天 16:00 轮(午夜后又回到新的一天)
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
});
// ── ③ 状态排除 ──────────────────────────────────────────────────────────
test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => {
const sql = registry.EXCLUDED_STATUS_SQL;
assert.match(sql, /Cancelled/);
assert.match(sql, /Merged/);
assert.match(sql, /Split/);
assert.doesNotMatch(sql, /Question/);
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, realDb) {
const wmsId = 15038426;
// 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」,
// 所以 stub 要返回**明细行**而不是聚合行。三态各造几条:
// · 超时未发 3 单 / 合计 300
// · 已发但超时 1 单(send_date 晚于 16:00)
// · 按时 6 单(send_date 早于 16:00)
const mk = (sendDate, amount, oId) => ({
src: 'jst_Orders',
wms_co_id: wmsId,
o_id: oId,
so_id: String(oId),
shop_name: '测试店',
order_date: '2026-10-20 01:00:00',
send_date: sendDate,
status: 'Delivering',
shop_status: '',
pay_amount: amount,
labels: '',
ob_io_date: null,
});
const stubRows = [
mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3),
mk('2026-10-20 18:30:00', 50, 4),
mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6),
mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8),
mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10),
];
// 记录业务表被查了几次 —— 12:00 前应当为 0
// ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist),
// 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条,
// 这也顺带验证了「跨表同 o_id 去重」逻辑。
const calls = { order: 0 };
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: {
query: async (sql) => {
if (/jst_warehouseInfo/u.test(sql)) {
return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }];
}
if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) {
// 新鲜度查询:返回空,走 stale 分支也不影响主结果
return [{ outbound_max: '2026-10-19 23:00:00' }];
}
calls.order += 1;
return stubRows;
},
},
};
void realDb;
return { calls, wmsId };
}
test('服务层三态分桶互斥,按时率不为负', async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
stubDb(dbPath, realDb);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 });
const c = data.centers.find((x) => x.key === 'CQ');
assert.ok(c, '应能归到崔桥工厂仓');
assert.equal(c.overdueUnshipped, 3);
assert.equal(c.overdueShipped, 1);
assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime');
assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)');
// 汇总口径与各仓一致,且按时率不会是负数
assert.equal(data.summary.overdueUnshipped, 3);
assert.equal(data.summary.windowOrderCount, 10);
assert.equal(data.summary.onTimeRate, 60);
assert.ok(data.summary.onTimeRate >= 0);
// 前端要展示「核验 16:00 前」,round 必须带出 windowEnd ——
// 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到)
assert.ok(data.round, 'round 不能为空');
assert.equal(data.round.key, 'AFTERNOON');
assert.equal(data.round.cutoffHour, 16);
assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻');
} 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('服务层 12:00 前返回 notStarted,且不去查订单表', async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const { calls } = stubDb(dbPath, realDb);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
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((c) => c.overdueUnshipped === 0));
assert.equal(calls.order, 0, '未到核验时间不应扫订单表');
assert.match(data.note, /12:00/);
// 下一轮核验时刻要告诉前端,方便提示「12:00 开始」
assert.equal(data.nextRoundAt, '2026-10-20 12:00:00');
} 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')];
}
});

View File

@ -0,0 +1,101 @@
# 业务库补表需求:每日进销存流水
> 提给:DBA / 聚水潭数据同步负责人
> 提出方:仓库系统(cangku)· 大货库存监控 → 每日进销存
> 日期:2026-09-24
## 背景
「每日进销存」页面需要按仓库展示 26 个仓的日内进销存流水,表头 21 列:
| # | 列 | # | 列 |
|---|---|---|---|
| 1 | 仓库 | 12 | 销售退货 |
| 2 | 期初数量 | 13 | 调拨入 |
| 3 | 加工进仓 | 14 | 期初(2) |
| 4 | 采购退货 | 15 | 其它出库 |
| 5 | 采购进仓 | 16 | 期末数量 |
| 6 | 其它进仓 | 17 | 库存周转天数 |
| 7 | 其它退货 | 18 | 滞销预警级库存量 |
| 8 | 销售出仓 | 19 | 商品慢流级库存量 |
| 9 | 盘点 | 20 | 商品呆滞级库存量 |
| 10 | 加工出仓 | 21 | 商品僵尸级库存量 |
| 11 | 调拨出 | | |
其中 1、16~21 列已能从业务库算出(见下表「已具备」),**2~15 列目前无法从业务库还原**。
## 已具备(无需补表)
| 列 | 来源 | 说明 |
|---|---|---|
| 仓库 | `rpt.jst_warehouseInfo` | `wms_co_id` 与仓库名映射 |
| 期末数量 | `rpt.jst_inventoryInfo` | 按 `wms_co_id` 单仓 `SUM(qty)`,实时 |
| 库存周转天数 | 近 30 天发货 ÷ 实时库存 | `jst_OrderItem` × `jst_Orders`(`send_date`) |
| 四级库存量 | 实时库存 × 发货 × `pm_stockMonitor.created` | 库龄 = 基准时刻 − SKU 建档时间 |
## 缺口一:没有按天的历史库存结存表(阻塞「期初数量」)
`rpt.jst_inventoryInfo` 只有**当前**库存,没有历史版本;`rpt.pm_stockMonitor` 是「归属仓」口径的每日快照(`jsStock`),与实物库存口径不一致(崔桥 115135 vs 实物 92439),不能当结存用。
**现状的临时方案**:项目本地 MySQL 建了 `daily_inventory_snapshot` 表,每天 01:30 把 26 仓实时库存落一份快照,`今日期初 = 昨日快照`。这是自采数据,一旦任务漏跑就会断链。
**请求**:请提供按天、按仓、按 SKU 的库存结存表,形如:
```
jst_inventoryDaily(
biz_date DATE, -- 业务日
wms_co_id BIGINT, -- 仓库
sku_id VARCHAR, -- SKU
qty DECIMAL, -- 当日结存
...
)
```
有了它,「期初数量」即可直接取上一业务日结存,本地快照表可退役。
## 缺口二:缺少 4 类单据表(阻塞 12 个流水列)
业务库 `rpt` 下已排查过全部 220 张表,**不存在**以下单据:
| 缺失单据 | 影响的列 |
|---|---|
| 调拨单(调拨出 / 调拨入) | 调拨出、调拨入 |
| 盘点单 | 盘点 |
| 加工单(加工进仓 / 加工出仓) | 加工进仓、加工出仓 |
| 其它出入库单 | 其它进仓、其它退货、其它出库、期初(2) |
请补充按单据行(SKU 级)的明细表,至少包含:
```
业务日期 / 单据号 / 仓库 wms_co_id / sku_id / 数量 / 单据类型(出|入) / 状态
```
## 缺口三:已有单据的数值与进销存口径不一致(阻塞 销售出仓 / 采购进仓 / 采购退货 / 销售退货)
这 4 类单据表**存在**,但按天汇总后与聚水潭进销存导出的数值对不上,不能直接用:
| 列 | 现有来源 | 9-23 崔桥工厂仓实测 | 基准值 | 偏差 |
|---|---|---:|---:|---|
| 销售出仓 | `jst_OrderItem` × `jst_Orders` 按 `send_date` | 1,769 | 4,273 | **−59%** |
| 销售出仓 | 同上,按 `order_date` | 1,723 | 4,273 | −60% |
| 销售出仓 | 同上,按 `modified` | 5,471 | 4,273 | +28% |
| 采购进仓 | `jst_purchaseItem.inQty` × `jst_purchase` 按 `confirm_date` | 数百 | 1,295 | 严重偏低 |
其它已排除的候选:
- `rpt.jst_salesOutboundInfo`:`qty` 全为 0,且 `order_type` 只含销售类订单,不含调拨 / 其它出库。
- `rpt.jst_originalBillInfo`(1,500 万行):是支付宝 / 财务账单表(`channel=alipayCSV`、`fee_name=交易收款`、`sku_id` 为 null),与库存流水无关。
- `rpt.jst_qmcust_salesoutbound`(奇门出库单):非本业务口径。
**请求**:确认这 4 类表在报表库(JSReportReplica)的同步是否完整、以及「进销存导出」所用的日期字段是哪一个(发 / 审 / 确 / 记账)。给出字段口径后这 4 列即可接入。
## 验收标准
补表后,任一业务日应满足行内恒等式:
```
期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入
− 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库
```
页面会以该恒等式做逐仓自校验,全部通过后才会撤下「暂缺单据来源」的提示。

View File

@ -0,0 +1,29 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
const IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
// 每日进销存:进销存流水 + 实时期末 + 分级库存量
// refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s)
// date=YYYY-MM-DD 查看指定业务日(默认取业务库最新业务日)
export const getDailyInventory = (params = {}) => request.get(
'/api/daily-inventory',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 导入聚水潭「进销存按仓导出」xlsx:file 为 File 对象,date 为业务日 YYYY-MM-DD
export const importDailyInventoryOfficial = ({ file, date }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('date', date);
return request.post('/api/daily-inventory/official-import', formData, {
timeout: IMPORT_TIMEOUT_MS,
headers: { 'Content-Type': 'multipart/form-data' },
});
};
// 查询某业务日已导入的官方进销存数据
export const getDailyInventoryOfficial = (date) => request.get(
'/api/daily-inventory/official',
{ params: { date } }
);

View File

@ -0,0 +1,17 @@
import request from './request';
// 每日进销存 · 定时截图通知(管理员专用)
// 后端只返回「是否已配置」与脱敏预览,不返回任何密钥
export const getDailyInventoryNotificationConfig = () =>
request.get('/api/daily-inventory-notification/config');
export const saveDailyInventoryNotificationConfig = (payload) =>
request.put('/api/daily-inventory-notification/config', payload);
/** 立即测试:真实截图 + 上传 + 通知一次,不影响正式定时任务的幂等记录 */
export const runDailyInventoryNotificationTest = () =>
request.post('/api/daily-inventory-notification/test', {}, { timeout: 300000 });
export const getDailyInventoryNotificationRuns = (limit = 10) =>
request.get('/api/daily-inventory-notification/runs', { params: { limit } });

View File

@ -1,15 +0,0 @@
import request from './request';
const BASE = '/api/large-stock-monitor';
export function getLargeStockMonitorConfig() {
return request.get(`${BASE}/config`);
}
export function getLargeStockMonitorOverview(warehouse) {
return request.get(`${BASE}/overview`, { params: { warehouse } });
}
export function getLargeStockMonitorSkuList(params = {}) {
return request.get(`${BASE}/sku-list`, { params });
}

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
// refresh=1 跳过后端 5 分钟缓存,强制重算
export const getPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 导出库位明细(xlsx/csv,服务端直接返回文件流)
export const exportPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis/export',
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
);

View File

@ -14,6 +14,56 @@ const request = axios.create({
});
const AUTH_USER_STORAGE_KEY = 'auth_user';
// 后端重启窗口内的自动重连
// ------------------------------------------------------------------
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
// 重启窗口过去后请求会自己成功,页面不再报错。
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
const RETRYABLE_METHODS = ['get', 'head', 'options'];
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
function isRetryableRequest(config) {
if (!config || config.retry === false) {
return false;
}
const method = String(config.method || 'get').toLowerCase();
return RETRYABLE_METHODS.includes(method);
}
function isRetryableError(error) {
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
return false;
}
const status = error?.response?.status;
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
if (!status) {
return true;
}
// 5xx:后端内部错误 / 网关与代理错误
return status >= 500 && status <= 599;
}
function sleep(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
function notifyReconnecting() {
message.open({
key: RETRY_NOTICE_KEY,
type: 'loading',
content: '服务正在重启,正在自动重连…',
duration: 0,
});
}
function clearReconnectNotice() {
message.destroy(RETRY_NOTICE_KEY);
}
function clearAuthSession() {
localStorage.removeItem('token');
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
@ -94,11 +144,34 @@ request.interceptors.response.use(
}
return Promise.reject(new Error(res.message || 'Error'));
}
clearReconnectNotice();
return res;
},
(error) => {
async (error) => {
const config = error?.config;
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
const attempt = Number(config.__retryAttempt || 0);
if (attempt < RETRY_BACKOFF_MS.length) {
config.__retryAttempt = attempt + 1;
if (attempt === 0 && !config.silentError) {
notifyReconnecting();
}
await sleep(RETRY_BACKOFF_MS[attempt]);
return request(config);
}
// 次数用尽:标记后不再重试,并把连接提示收掉
config.__retryExhausted = true;
config.__retryAttempt = 0;
clearReconnectNotice();
}
const authSessionFailure = isAuthSessionFailure(error.response);
if (!authSessionFailure && !error.config?.silentError) {
if (!authSessionFailure && !error?.config?.silentError) {
message.error(getRequestErrorMessage(error));
}
if (authSessionFailure && !shouldBypass401Redirect(error)) {

View File

@ -0,0 +1,48 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控看板
export const getReturnMonitorDashboard = (params = {}) => request.get(
'/api/return-monitor/dashboard',
{ params, timeout: LONG_TIMEOUT_MS }
);
// 手动触发一轮「同步 + 每日比对」
export const syncReturnMonitor = (payload = {}) => request.post(
'/api/return-monitor/sync',
payload,
{ timeout: LONG_TIMEOUT_MS }
);
// 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用)
export const getReturnMonitorCodeLines = (params = {}) => request.get(
'/api/return-monitor/code-lines',
{ params, timeout: LONG_TIMEOUT_MS }
);
// 差异清单处理:mode = fifo | manual
export const resolveReturnMonitorDiscrepancy = (payload = {}) => request.post(
'/api/return-monitor/discrepancy/resolve',
payload,
{ timeout: LONG_TIMEOUT_MS }
);
// 人工标记单行状态:在库 / 已出库 / 已报废
export const markReturnMonitorInbound = (payload = {}) => request.post(
'/api/return-monitor/inbound/mark',
payload,
{ timeout: LONG_TIMEOUT_MS }
);
// 导出看板数据(scope=all|detail|overdue|code|discrepancy|age,返回 xlsx / csv 二进制)
// silentError:错误体是 Blob,由调用方用 readBlobErrorMessage 解出可读文案后再提示
export const exportReturnMonitor = (params = {}) => request.get(
'/api/return-monitor/export',
{
params,
responseType: 'blob',
timeout: LONG_TIMEOUT_MS,
silentError: true,
}
);

View File

@ -0,0 +1,39 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 客退数据监控(箱及仓位库存 · 客退临时上架仓)
// params.warehouse: cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓)
export const getReturnStockWarningList = (params = {}) => request.get(
'/api/return-stock-warning/list',
{ params, timeout: LONG_TIMEOUT_MS }
);
// 导出(xlsx / csv,服务端全量生成,返回二进制)
export const exportReturnStockWarning = (params = {}) => request.get(
'/api/return-stock-warning/export',
{
params,
responseType: 'blob',
timeout: LONG_TIMEOUT_MS,
silentError: true,
}
);
// 导入聚水潭「箱及仓位库存」导出文件(xlsx / xls / csv)
// warehouse: 导入落到哪个仓库下,各仓的导入互不覆盖
export const importReturnStockWarning = (file, warehouse) => {
const formData = new FormData();
formData.append('file', file);
if (warehouse) formData.append('warehouse', warehouse);
return request.post('/api/return-stock-warning/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: LONG_TIMEOUT_MS,
});
};
// 清除当前仓库的导入,回到「系统推算」口径(不影响其它仓)
export const clearReturnStockWarningImport = (warehouse) => request.delete(
'/api/return-stock-warning/import',
{ params: warehouse ? { warehouse } : {} }
);

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
// refresh=1 跳过后端 5 分钟缓存,强制重算
export const getStockSalesAnalysis = (params = {}) => request.get(
'/api/stock-sales-analysis',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 导出「销售=0」档的 SKU 级明细(xlsx/csv,服务端直接返回文件流)
export const exportStockSalesZeroDetail = (params = {}) => request.get(
'/api/stock-sales-analysis/zero-sales-export',
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
);

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

@ -1,14 +1,3 @@
import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js';
const buildLargeStockMonitorChildren = () => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
key: `stock-monitor-large-${entry.idx}`,
label: entry.warehouse,
icon: 'appstore',
routeKey: `stock-monitor-large-${entry.idx}`,
visible: true,
order: entry.idx * 10,
}));
export const DEFAULT_SIDEBAR_CONFIG = {
collapsedByDefault: false,
darkModeByDefault: false,
@ -29,6 +18,16 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
],
},
{
key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true,
children: [
{ key: 'returns-inventory-cuiqiao', label: '崔桥工厂仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-cuiqiao', visible: true, order: 10 },
{ key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 },
{ key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 },
{ key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 },
{ key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 },
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
@ -38,8 +37,12 @@ export const DEFAULT_SIDEBAR_CONFIG = {
],
},
{
key: 'stock-monitor-large', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: false,
children: buildLargeStockMonitorChildren(),
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
children: [
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
{ key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 },
{ key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
],
},
{
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,

View File

@ -0,0 +1,83 @@
/**
* 每日进销存 —— 列定义
*
* 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
* source 标记该列数据来源,便于在页面上如实标注口径:
* - snapshot:来自项目本地快照表 daily_inventory_snapshot(每日定时落库,自动滚动)
* - runtime:运行时从业务库实时取数
* - unavailable:业务库缺少对应单据表,暂无法计算(渲染为「—」)
*/
export const COLUMN_SOURCE = {
snapshot: 'snapshot',
runtime: 'runtime',
unavailable: 'unavailable',
};
const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表(调拨 / 盘点 / 加工 / 其它出入库),以聚水潭「进销存按仓导出」为准,导入后本列自动出数';
const FLOW_CALIBER_NOTE = '口径说明:业务库只能还原该列的近似值(采购按单据日期、销售按订单发货日期),与聚水潭进销存口径存在差异,已导入官方数据时以官方为准';
const OFFICIAL_TIP = '同一业务日导入过聚水潭进销存导出时,本列直接采用官方值(单元格为常规色);未导入时回退业务库推算(单元格标灰)';
/** 表格存在两个名为「期初」的列,第二个加后缀区分 */
export const DAILY_INVENTORY_COLUMNS = [
{ key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
{ key: 'openingQty', label: '期初数量', source: 'snapshot', tip: `优先取聚水潭「进销存按仓导出」的官方期初;未导入时取本地快照表 daily_inventory_snapshot 中上一业务日的期末结存(每日 01:30 自动落库),快照缺失则自动用「实时库存扣流水」回推补写(回推值受业务库缺失的 8 类单据影响,与官方存在偏差)` },
{ key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'purchaseReturn', label: '采购退货', source: 'runtime', tip: `采购退货(流出,负数)。rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date 统计。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
{ key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
{ key: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'salesOut', label: '销售出仓', source: 'runtime', tip: `销售出仓(流出,负数)。rpt.jst_Orders × jst_OrderItem,按 send_date 统计,排除 Delete/Cancelled,与「库存周转天数」的发货口径一致。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
{ key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'salesReturn', label: '销售退货', source: 'runtime', tip: `销售退货(流入)。rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date 统计,类型限定普通退货/拒收退货/换货/维修,取明细实收数量 r_qty。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
{ key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' },
{ key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
{ key: 'closingQty', label: '期末数量', source: 'runtime', tip: `优先取聚水潭「进销存按仓导出」的官方期末;未导入时取实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)` },
{ key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%。月度发货总件数=近 30 天已发货订单件数(jst_OrderItem×jst_Orders,按 send_date,排除 Delete/Cancelled);月度日均库存件数=该仓实时库存(无历史结存表,以当日库存近似)' },
{ key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月 且 周转率 30%~199% 的库存合计' },
{ key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月且周转率 ≥ 200%,或库龄 > 3 个月且周转率 30%~99% 的库存合计' },
{ key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: 'SKU 级:库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的库存合计' },
{ key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: 'SKU 级:库龄 > 12 个月 且 周转率 ≥ 200% 的库存合计' },
];
/** 口径说明弹框文案 */
export const FORMULA_DESCRIPTION = [
'数据来源说明(全部来自数据库或聚水潭官方导出,无人工填数):',
'· 聚水潭官方进销存(优先):把聚水潭「进销存按仓导出」的 xlsx 在页面右上角导入后,',
' 该业务日的期初、12 个流水列、期末直接采用官方值,恒等式「期初 + 流水 = 期末」逐仓闭合。',
' 业务库 JSReportReplica 只能还原 4/12 个流水列,且口径与聚水潭不一致(采购按单据日期 vs 实际入库、',
' 销售按订单发货日期 vs 出库单、jst_salesOutboundInfo.qty 全为 0),调拨 / 盘点 / 加工 / 其它出入库更没有对应单据表,',
' 因此未导入官方数据时这些列显示「—」或为业务库近似值(单元格标灰)。',
'· 期初数量:官方期初优先;未导入时取本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。',
' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把各仓实时库存落一份快照,',
' 今日期初 = 昨日快照,逐日自动滚动;导入官方数据时会同步回写快照,让后续推算也对齐聚水潭口径。',
'· 流水列(12 列):官方数据优先(导入后 12 列全部出数);未导入时业务库可还原 4 列 ——',
' 采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、采购退货(jst_purchaseout×Item,io_date)、',
' 销售出仓(jst_Orders×OrderItem,send_date,与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。',
' 其余 8 列(加工进仓 / 其它进仓 / 其它退货 / 盘点 / 加工出仓 / 调拨出 / 调拨入 / 其它出库)业务库无单据表,显示「—」。',
'· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。',
'· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。',
'',
'算式:',
'· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
'· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
' 月度发货总件数 = 该仓近 30 天已发货订单件数',
' (jst_OrderItem 关联 jst_Orders,按订单发货仓 wms_co_id 归属,send_date 落在窗口内,排除 Delete / Cancelled)',
' 月度日均库存件数 = 该仓聚水潭实时库存 jst_inventoryInfo 汇总',
' (业务库无按天的历史库存结存表,无法算真正的日均库存,以当日库存近似月度日均)',
' 库龄(天) = 基准时刻 − SKU 建档时间 created;3 个月按 90 天、12 个月按 365 天折算',
'· 库存分级(SKU 级,库龄 + 周转率双维度,低于 30% 的不计入任何级别):',
' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警',
' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流',
' 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流',
' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
'',
'注意:仓库整体周转率按「该仓发货总量 ÷ 该仓库存总量」计算,与四级分级的 SKU 级取舍不是同一个口径,',
' 两者不必相等(例如整体周转率 6% 的仓里,仍可以有部分 SKU 周转率超 30% 而被判为预警级)。',
];

View File

@ -1,165 +0,0 @@
// 大货库存监控配置(前端镜像)
// 与 backend/src/config/largeStockMonitorConfig.js 同步
// 修改需双端同步;运行期也可通过 /api/large-stock-monitor/config 获取
const RAW_WAREHOUSES = [
{ idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓' },
{ idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓' },
{ idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓' },
{ idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓' },
{ idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓' },
{ idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓' },
{ idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓' },
{ idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓' },
{ idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓' },
{ idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓' },
{ idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓' },
{ idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓' },
{ idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓' },
{ idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓' },
{ idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓' },
{ idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓' },
{ idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓' },
{ idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓' },
{ idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓' },
{ idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓' },
{ idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓' },
{ idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓' },
{ idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓' },
{ idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓' },
{ idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓' },
{ idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓' },
];
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({
...entry,
routeKey: `stock-monitor-large-${entry.idx}`,
slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`,
}));
// 18 列表头(按 Excel 第 1 行 D..U 列)
// available=false:业务库(220 张表)中不存在该单据类型,无数据源,显示「—」
// estimated=true:由已有字段推导(非原始字段),前端标「估」并在悬浮说明中写明推导式
export const OVERVIEW_COLUMNS = [
{
key: 'periodStart',
label: '期初数据',
available: true,
estimated: true,
tip: '业务库仅保留最新单日库存快照,无上月末历史。此值由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推,未包含加工/调拨/盘点/其它出库。倒推为负时显示「—」。',
},
{ key: 'processIn', label: '加工进仓', available: false, tip: '业务库无加工单据表,无数据源' },
{
key: 'purchaseReturn',
label: '采购退货',
available: true,
tip: '本月采购退货出库单数量:rpt.jst_purchaseout + jst_purchaseoutItem(状态 Confirmed)',
},
{
key: 'purchaseIn',
label: '采购进仓',
available: true,
tip: '本月采购单已入库数量:rpt.jst_purchase + jst_purchaseItem 的 inQty(状态 Confirmed/Finished)',
},
{
key: 'salesOut',
label: '销售出仓',
available: true,
tip: '本月销售出库单件数:rpt.jst_salesOutboundInfo(状态 Confirmed)关联 jst_OrderItem.qty',
},
{ key: 'stocktake', label: '盘点', available: false, tip: '业务库无盘点单据表,无数据源' },
{ key: 'processOut', label: '加工出仓', available: false, tip: '业务库无加工单据表,无数据源' },
{ key: 'transferOut', label: '调拨出', available: false, tip: '业务库无调拨单据表,无数据源' },
{
key: 'salesReturn',
label: '销售退货',
available: true,
tip: '本月退货退款单实退数量:rpt.jst_returnAndRefundInfo + jst_returnAndRefundItem 的 r_qty(状态 Confirmed,按仓库名称匹配)',
},
{ key: 'transferIn', label: '调拨入', available: false, tip: '业务库无调拨单据表,无数据源' },
{
key: 'openingBalance',
label: '期初',
available: true,
estimated: true,
tip: '同期初数据(倒推估算值)',
},
{ key: 'otherOut', label: '其它出库', available: false, tip: '业务库无其它出库单据表,无数据源' },
{
key: 'closingQty',
label: '期末数量',
available: true,
tip: '聚水潭实际库存 rpt.jst_inventoryInfo(按仓库 wms_co_id 单仓实物口径,实时值)。业务库无历史时点库存快照,此值随每次刷新滚动到最新;与某日「期末结存」的差额即该日之后的出入库与仓间调拨',
},
{ key: 'turnoverDays', label: '库存周转天数', available: true, tip: '月度发货总件数 / 月度日均库存件数 × 100%(按业务库口径,数值实为周转率 %)' },
{ key: 'warnLevel', label: '滞销预警级库存量', available: true, tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计(按库存快照分级,口径为归属库存,与期末实际库存不同)' },
{ key: 'slowLevel', label: '商品慢流级库存量', available: true, tip: '库龄 < 3 个月 且 周转率 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转率 ≤ 99%' },
{ key: 'staleLevel', label: '商品呆滞级库存量', available: true, tip: '库龄 3~12 个月 且 周转率 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转率 ≤ 199%' },
{ key: 'zombieLevel', label: '商品僵尸级库存量', available: true, tip: '库龄 > 12 个月 且 周转率 ≥ 200%' },
];
// 分级标签(与后端 levelExpr 返回值一致)
export const LEVEL_LABEL = {
warn: '滞销预警',
slow: '商品慢流',
stale: '商品呆滞',
zombie: '商品僵尸',
};
export const LEVEL_FILTER_OPTIONS = [
{ label: '全部级别', value: '' },
{ label: '滞销预警', value: 'warn' },
{ label: '商品慢流', value: 'slow' },
{ label: '商品呆滞', value: 'stale' },
{ label: '商品僵尸', value: 'zombie' },
];
export const LEVEL_COLOR = {
warn: 'gold',
slow: 'orange',
stale: 'volcano',
zombie: 'magenta',
};
export function findWarehouseBySlug(slug) {
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.slug === slug) || null;
}
export function findWarehouseByName(name) {
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === name) || null;
}
export const PAGE_KEY = 'large_stock_monitor';
export const PAGE_PATH = '/planning/large-stock-monitor/:warehouseSlug';
export const PAGE_NAME = '大货库存监控';
// 顶部"算式说明"提示
export const FORMULA_DESCRIPTION = [
'库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%(按业务库约定列名沿用「周转天数」,数值实际为周转率,单位 %)',
'月度发货总件数 ≈ threeAvgSale × 30 (业务库仅有最近 3 天日均销量)',
'月度日均库存件数 ≈ jsStock (业务库仅保留最近一天库存快照,作为日均近似)',
'数据截止时刻:库存快照日次日的凌晨 01:00(业务库每天凌晨 1 点跑批,快照标注为前一日 T-1)',
'库龄(月) = (数据截止时刻 - SKU 入库时间) / 30',
'分级口径:',
'· 滞销预警:库龄 < 3 个月 且 30% ≤ 周转天数 ≤ 199%',
'· 商品慢流:库龄 < 3 个月 且 周转天数 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转天数 ≤ 99%',
'· 商品呆滞:库龄 3~12 个月 且 周转天数 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转天数 ≤ 199%',
'· 商品僵尸:库龄 > 12 个月 且 周转天数 ≥ 200%',
'流水类字段(本期 = 快照所在自然月 1 日 00:00 ~ 数据截止时刻,左闭右开):',
'· 采购进仓:jst_purchase + jst_purchaseItem.inQty(Confirmed/Finished)',
'· 采购退货:jst_purchaseout + jst_purchaseoutItem.qty(Confirmed)',
'· 销售出仓:jst_salesOutboundInfo 关联 jst_OrderItem.qty(Confirmed)',
'· 销售退货:jst_returnAndRefundInfo + jst_returnAndRefundItem.r_qty(Confirmed,按仓库名匹配)',
'· 期初数据:业务库无上月末快照,由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推(标「估」,倒推为负时显示「—」)',
'· 加工进仓 / 加工出仓 / 盘点 / 调拨出 / 调拨入 / 其它出库:业务库无对应单据表,无数据源',
'期末数量:聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓实物口径)',
'注意:pm_stockMonitor 的 warehouse 是「归属仓」,jsStock 含已调拨到外仓的货,不能代表本仓实物库存',
'四级库存量:只能由库存快照分级得出(实时库存表无入库时间与销量字段),口径为归属库存,与期末实际库存不同',
];

View File

@ -0,0 +1,91 @@
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
export const PICKING_BIN_COLUMNS = [
{
key: 'warehouse',
label: '仓库',
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
},
{
key: 'binName',
label: '仓位',
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
},
{
key: 'binType',
label: '类型',
tip: '统计范围固定为真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录和存货位不纳入动销分析',
},
{
key: 'skuId',
label: '商品编码',
tip: '商品编码(jst_packInventoryInfo.sku_id),一行就是一个「仓位 × 商品」组合',
},
{
key: 'stockQty',
label: '库存数量',
tip: '该商品在这个仓位上的当前库存件数(同一仓位多行的库存相加 = 仓位库存)',
},
{
key: 'salesQty',
label: '商品销量',
tip: '近 N 天该商品的销量(整档,不按仓位分摊):含线上订单、组合装子件拆分、全民客户(B2B分销)渠道;同一商品占几个仓位,这几行显示的销量都一样,所以这一列不能直接按行求和',
},
{
key: 'dailySales',
label: '商品日销',
tip: '商品销量 ÷ 统计天数,动销等级严格按此值划分',
},
{
key: 'gradeKey',
label: '动销等级',
tip: '按商品日销划分:爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
},
];
export const PICKING_BIN_WINDOW_OPTIONS = [
{ label: '近 7 天', value: 7 },
{ label: '近 15 天', value: 15 },
{ label: '近 30 天', value: 30 },
{ label: '近 60 天', value: 60 },
];
export const PICKING_BIN_DEFAULT_DAYS = 30;
/** 动销等级展示色(与后端 key 对应) */
export const PICKING_BIN_GRADE_COLORS = {
hot: 'red',
best: 'volcano',
normal: 'blue',
slow1: 'gold',
slow2: 'default',
zero: 'default',
};
export const PICKING_BIN_DESCRIPTION = [
'【统计范围】只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录(pack_type=Pack)和存货位不纳入动销分析。',
'',
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
'',
'【动销等级口径】按「商品日销」分档:商品日销 = 该商品近 N 天销量 ÷ N。',
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该商品无销量。',
' 等级判断的是商品本身的销售速度,与其摆放位置无关 —— 同一件商品占几个仓位,等级都相同。',
'默认统计窗口为近 30 天,可切换 7 / 15 / 30 / 60 天。',
'',
'【为什么不用分摊销量来定等级】',
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
' 若把销量先摊到每个仓位再定等级,一件日销 100 的爆款放在 10 个仓位就会被算成日销 10 —— 判断的是「摆法」而不是商品。',
'',
'【商品销量为什么不能直接按行求和】',
' 商品销量取的是整档销量(不除以占用仓位数),同一件商品跨仓位会重复出现,所以该列相加无意义。',
' 需要按行求和请用导出文件里的「分摊销量」列:商品销量 ÷ 其占用的同类型仓位数。',
' 恒等式已实测校验(差异 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

@ -1,13 +1,20 @@
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
export const SIDEBAR_PAGES = [
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
{ pageKey: 'large_stock_monitor', path: '/planning/large-stock-monitor/:warehouseSlug', name: '大货库存监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
acc[item.pageKey] = item;
return acc;
}, {});
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
export const SIDEBAR_PAGES = [
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
{ pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'picking_bin_sales_analysis', path: '/planning/operation/picking-bin-sales-analysis', name: '拣选库位动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
acc[item.pageKey] = item;
return acc;
}, {});

View File

@ -0,0 +1,57 @@
/** 仓库库存动销分析 —— 表头与口径说明(与需求表一致) */
export const STOCK_SALES_COLUMNS = [
{
key: 'warehouse',
label: '仓库',
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
},
{
key: 'grade',
label: '动销等级',
tip: '按单个 SKU 的日均销量分档:日销 = 统计窗口内已发货件数 / 窗口天数',
},
{
key: 'skuCount',
label: 'SKU数量',
tip: '该动销等级下「有库存或有销量」的 SKU 个数',
},
{
key: 'skuRatio',
label: 'SKU占比',
tip: '该等级 SKU 数量 ÷ 该仓库 SKU 总数',
},
{
key: 'stockQty',
label: '库存数量',
tip: '该等级 SKU 的实时库存件数(jst_inventoryInfo,按 wms_co_id + sku_id 汇总)',
},
{
key: 'stockRatio',
label: '库存占比',
tip: '该等级库存数量 ÷ 该仓库库存总件数',
},
{
key: 'salesQty',
label: '销量',
tip: '统计窗口内该等级 SKU 的已发货件数(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled)',
},
];
export const STOCK_SALES_WINDOW_OPTIONS = [
{ label: '近 7 天', value: 7 },
{ label: '近 14 天', value: 14 },
{ label: '近 30 天', value: 30 },
];
export const STOCK_SALES_DEFAULT_DAYS = 30;
export const STOCK_SALES_DESCRIPTION = [
'动销等级口径:按「单个 SKU 的日均销量(日销)」分档,日销 = 统计窗口内已发货件数 ÷ 窗口天数。',
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内无销量的 SKU。',
'SKU数量 / 库存数量 / 销量:仓库内按等级汇总;「汇总」行为该仓库六个等级的合计。',
'SKU占比 = 该等级 SKU 数量 ÷ 该仓库 SKU 总数;库存占比 = 该等级库存数量 ÷ 该仓库库存总件数。',
'统计范围:只统计「有库存或有销量」的 SKU;库存为 0 且窗口内无销量的 SKU 已下架,不计入分母。',
'库存取业务库实时库存表;销量取销售出库单(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
];

View File

@ -8,6 +8,7 @@ import {
DownOutlined,
ExperimentOutlined,
FundProjectionScreenOutlined,
InboxOutlined,
LineChartOutlined,
LogoutOutlined,
MenuFoldOutlined,
@ -36,7 +37,6 @@ import {
markDesktopEmbedSession,
requestDesktopSessionRenewal,
} from '../utils/desktopEmbedSession';
import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js';
const AUTH_USER_STORAGE_KEY = 'auth_user';
const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode';
@ -61,6 +61,7 @@ const SIDEBAR_ICON_MAP = {
dashboard: DashboardOutlined,
desktop: DesktopOutlined,
experiment: ExperimentOutlined,
inbox: InboxOutlined,
'line-chart': LineChartOutlined,
message: MessageOutlined,
projection: FundProjectionScreenOutlined,
@ -80,6 +81,31 @@ const SIDEBAR_ROUTE_MAP = {
},
'promotion-module': { path: '/planning/operation/promotion-module' },
'product-monitoring': { path: '/planning/operation/product-monitoring' },
'returns-inventory-cuiqiao': {
path: '/planning/operation/returns-inventory/cuiqiao',
activePath: '/planning/operation/returns-inventory/cuiqiao',
matchMode: 'prefix',
},
'returns-inventory-subei': {
path: '/planning/operation/returns-inventory/subei',
activePath: '/planning/operation/returns-inventory/subei',
matchMode: 'prefix',
},
'returns-inventory-suining': {
path: '/planning/operation/returns-inventory/suining',
activePath: '/planning/operation/returns-inventory/suining',
matchMode: 'prefix',
},
'returns-inventory-shandong': {
path: '/planning/operation/returns-inventory/shandong',
activePath: '/planning/operation/returns-inventory/shandong',
matchMode: 'prefix',
},
'returns-inventory-henan': {
path: '/planning/operation/returns-inventory/henan',
activePath: '/planning/operation/returns-inventory/henan',
matchMode: 'prefix',
},
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
'product-analysis': { path: '/planning/operation/product-analysis' },
'work-orders': { path: '/planning/operation/work-orders' },
@ -93,25 +119,18 @@ const SIDEBAR_ROUTE_MAP = {
activePath: '/planning/operation/mkh-bom-data',
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' },
settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
};
const LARGE_STOCK_MONITOR_ROUTE_ENTRIES = LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => [
`stock-monitor-large-${entry.idx}`,
{ path: `/planning/large-stock-monitor/${entry.slug}`, matchMode: 'prefix' },
]);
LARGE_STOCK_MONITOR_ROUTE_ENTRIES.forEach(([key, value]) => {
SIDEBAR_ROUTE_MAP[key] = value;
});
const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => {
if (entry.requiresAdmin && !canUseSettings) return [];
if (entry.type === 'section') return [{ ...entry }];
const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {};
const routeMatches = (itemRoute = {}, pathname = '') => {
const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : [];
if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
@ -129,9 +148,16 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
? pathname === matchPath || pathname.startsWith(`${matchPath}/`)
: pathname === matchPath;
};
// 递归构建菜单节点:支持任意层级(分组 -> 子分组 -> 叶子)
const buildItem = (item) => {
if (!item) return null;
if (item.requiresAdmin && !canUseSettings) return null;
const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {};
const rawChildren = Array.isArray(item.children) ? item.children : null;
const children = rawChildren
? sortSidebarEntries(rawChildren).map((child) => buildItem(child)).filter(Boolean)
: undefined;
return {
...item,
icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined,
@ -139,25 +165,88 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
external: Boolean(itemRoute.externalUrl),
onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined,
match: (path) => routeMatches(itemRoute, path),
...(children && children.length ? { children } : (rawChildren ? { children: [] } : {})),
};
};
const normalized = {
...entry,
icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined,
path: route.path,
external: Boolean(route.externalUrl),
onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined,
match: (path) => routeMatches(route, path),
};
if (entry.type === 'group') {
normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean);
}
const normalized = buildItem(entry);
if (!normalized) return [];
normalized.type = entry.type;
return [normalized];
});
// 递归收集默认展开的菜单 key(支持多级)
const collectDefaultExpandedKeys = (entries = []) => entries.flatMap((entry) => [
...(entry?.defaultExpanded && entry?.key ? [entry.key] : []),
...collectDefaultExpandedKeys(entry?.children || []),
]);
// 递归判断菜单项(含其子孙)是否命中当前路径
const isNavEntryActive = (entry, pathname) => {
if (!entry) return false;
if (entry.match?.(pathname)) return true;
return Array.isArray(entry.children) && entry.children.some((child) => isNavEntryActive(child, pathname));
};
// 侧边栏菜单节点(递归渲染,支持三级及以上)
const SidebarNavNode = ({
item, level, pathname, expandedKeys, onToggle, onLeaf, collapsed,
}) => {
const Icon = item.icon || AppstoreOutlined;
const expandable = Array.isArray(item.children) && item.children.length > 0;
const descendantActive = expandable && item.children.some((child) => isNavEntryActive(child, pathname));
const selfActive = Boolean(item.match?.(pathname));
const active = selfActive || (collapsed && descendantActive);
const expanded = expandable && expandedKeys.includes(item.key) && !collapsed;
const isTop = level <= 1;
const baseClass = isTop ? 'planning-subsystem-nav-item' : 'planning-subsystem-subnav-item';
const iconClass = isTop ? 'planning-subsystem-nav-icon' : 'planning-subsystem-subnav-icon';
return (
<div className="planning-subsystem-nav-group">
<button
type="button"
className={`${baseClass}${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
style={isTop ? undefined : { paddingLeft: 36 + (level - 2) * 20 }}
onClick={() => {
if (expandable) {
onToggle(item.key);
return;
}
onLeaf(item);
}}
title={item.label}
aria-expanded={expandable ? expanded : undefined}
>
<Icon className={iconClass} />
<span className={isTop ? 'planning-subsystem-nav-label' : 'planning-subsystem-subnav-label'}>{item.label}</span>
{expandable ? (
<span className="planning-subsystem-nav-chevron">
{expanded ? <DownOutlined /> : <RightOutlined />}
</span>
) : null}
</button>
{expandable && expanded ? (
<div className="planning-subsystem-subnav">
{item.children.map((child) => (
<SidebarNavNode
key={child.key}
item={child}
level={level + 1}
pathname={pathname}
expandedKeys={expandedKeys}
onToggle={onToggle}
onLeaf={onLeaf}
collapsed={collapsed}
/>
))}
</div>
) : null}
</div>
);
};
function parseJwtPayload(token) {
if (!token) {
return null;
@ -302,9 +391,7 @@ const BasicLayout = () => {
setSidebarConfig(nextSidebarConfig);
setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault));
setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault));
setExpandedSidebarKeys(
nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key)
);
setExpandedSidebarKeys(collectDefaultExpandedKeys(nextSidebarConfig.entries));
document.title = nextSiteName;
let link = document.querySelector("link[rel*='icon']");
@ -514,12 +601,6 @@ const BasicLayout = () => {
},
].filter(Boolean);
const isSidebarEntryActive = (entry) => {
if (!entry) return false;
if (entry.match?.(pathname)) return true;
return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child));
};
const toggleSidebarGroup = (key) => {
setSidebarCollapsed(false);
setExpandedSidebarKeys((keys) => (
@ -577,58 +658,17 @@ const BasicLayout = () => {
);
}
const Icon = item.icon;
const expandable = Array.isArray(item.children) && item.children.length > 0;
const descendantActive = expandable && item.children.some((child) => isSidebarEntryActive(child));
const selfActive = Boolean(item.match?.(pathname));
const active = selfActive || (sidebarCollapsed && descendantActive);
const expanded = expandable && expandedSidebarKeys.includes(item.key) && !sidebarCollapsed;
return (
<div key={item.key} className={`planning-subsystem-nav-group${expanded ? ' is-expanded' : ''}`}>
<button
type="button"
className={`planning-subsystem-nav-item${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
onClick={() => {
if (expandable) {
toggleSidebarGroup(item.key);
return;
}
handleSidebarLeafClick(item);
}}
title={item.label}
aria-expanded={expandable ? expanded : undefined}
>
<Icon className="planning-subsystem-nav-icon" />
<span className="planning-subsystem-nav-label">{item.label}</span>
{expandable ? (
<span className="planning-subsystem-nav-chevron">
{expanded ? <DownOutlined /> : <RightOutlined />}
</span>
) : null}
</button>
{expandable && expanded ? (
<div className="planning-subsystem-subnav">
{item.children.map((child) => {
const ChildIcon = child.icon;
const childActive = isSidebarEntryActive(child);
return (
<button
key={child.key}
type="button"
className={`planning-subsystem-subnav-item${childActive ? ' is-active' : ''}`}
onClick={() => handleSidebarLeafClick(child)}
title={child.label}
>
<ChildIcon className="planning-subsystem-subnav-icon" />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
<SidebarNavNode
key={item.key}
item={item}
level={1}
pathname={pathname}
expandedKeys={expandedSidebarKeys}
onToggle={toggleSidebarGroup}
onLeaf={handleSidebarLeafClick}
collapsed={sidebarCollapsed}
/>
);
})}
</nav>
@ -986,6 +1026,22 @@ const BasicLayout = () => {
background: var(--sidebar-active-bg);
}
.planning-subsystem-subnav-item.is-descendant-active:not(.is-active) {
color: var(--sidebar-active-text);
}
.planning-subsystem-subnav-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-subsystem-subnav-item.is-expandable .planning-subsystem-nav-chevron {
margin-left: 0;
}
.planning-subsystem-subnav-icon {
flex: 0 0 14px;
color: var(--sidebar-icon-muted);

View File

@ -0,0 +1,682 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, DatePicker, Input, Modal, Select, Space, Spin, Switch, Table, Tag, TimePicker,
Tooltip, Typography, Upload,
} from 'antd';
import {
BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined, UploadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import { getDailyInventory, importDailyInventoryOfficial } from '../../api/dailyInventory';
import {
getDailyInventoryNotificationConfig,
runDailyInventoryNotificationTest,
saveDailyInventoryNotificationConfig,
} from '../../api/dailyInventoryNotification.js';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
FORMULA_DESCRIPTION,
} from '../../constants/dailyInventory.js';
import './style.css';
const { Title, Text } = Typography;
const TIME_FORMAT = 'HH:mm';
/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */
function readIsAdmin() {
try {
const stored = JSON.parse(localStorage.getItem('auth_user') || 'null');
if (stored?.role) return stored.role === 'admin';
const token = localStorage.getItem('token') || '';
const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload?.role === 'admin';
} catch {
return false;
}
}
/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */
function normalizeRecipientList(list = []) {
const seen = new Set();
const result = [];
for (const raw of list) {
const name = String(raw ?? '').trim().slice(0, 50);
if (!name || seen.has(name)) continue;
seen.add(name);
result.push(name);
}
return result;
}
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="daily-inventory-th-tip">{children}</span>
</Tooltip>
);
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const percent = (value) => {
if (value === null || value === undefined || value === '') return '—';
return `${Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
const renderSigned = (value) => {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return <span className="daily-inventory-cell--zero">0</span>;
return <span className={n < 0 ? 'daily-inventory-cell--out' : 'daily-inventory-cell--in'}>{int(n)}</span>;
};
const DailyInventory = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
// ── 定时截图通知设置 ──
const isAdmin = useMemo(readIsAdmin, []);
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const [notifyConfig, setNotifyConfig] = useState(null);
const [notifyResult, setNotifyResult] = useState(null);
const [recipientDraft, setRecipientDraft] = useState('');
const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] });
// ── 业务日切换 + 聚水潭官方进销存导入 ──
const [selectedDate, setSelectedDate] = useState(''); // 空 = 业务库最新业务日
const [importOpen, setImportOpen] = useState(false);
const [importDate, setImportDate] = useState(null);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const params = {};
if (selectedDate) params.date = selectedDate;
if (force) params.refresh = '1';
const res = await getDailyInventory(params);
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const officialFlowKeys = useMemo(() => new Set(data?.flowColumnsOfficial || []), [data]);
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
const isRuntime = col.source === COLUMN_SOURCE.runtime;
const isUnavailable = col.source === COLUMN_SOURCE.unavailable;
// 该列已导入聚水潭官方数据时,不再按「无数据来源」置灰
const noSource = isUnavailable && !officialFlowKeys.has(col.key);
return {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
dataIndex: col.key,
key: col.key,
align: col.key === 'warehouse' ? 'left' : 'right',
width: col.key === 'warehouse' ? 260 : 130,
fixed: col.key === 'warehouse' ? 'left' : undefined,
className: isRuntime
? 'daily-inventory-col--runtime'
: (noSource ? 'daily-inventory-col--unavailable' : undefined),
render: (value, record) => {
if (record?.isTotal) {
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
// 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0
if (value === null || value === undefined || value === '') {
return <span className="daily-inventory-cell--na">—</span>;
}
if (col.key === 'turnoverDays') return percent(value);
return int(value);
}
if (col.key === 'warehouse') {
return (
<span className="daily-inventory-warehouse">
{value}
{record?.source?.hasOfficial ? (
<Tooltip title="该仓已导入聚水潭官方进销存,期初/流水/期末均为官方值">
<Tag color="blue" className="daily-inventory-tag">官方</Tag>
</Tooltip>
) : (
<Tooltip title="该仓未导入聚水潭官方进销存,数值为业务库推算(流水列可能缺 8 类单据)">
<Tag className="daily-inventory-tag">推算</Tag>
</Tooltip>
)}
</span>
);
}
if (value === null || value === undefined || value === '') {
return <span className="daily-inventory-cell--na">—</span>;
}
// 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异
const cellSource = col.key === 'openingQty' ? record?.source?.opening
: col.key === 'closingQty' ? record?.source?.closing
: record?.source?.flows?.[col.key];
const wrap = (node) => (cellSource === 'derived' ? (
<Tooltip title="业务库推算值,口径与聚水潭进销存存在差异;导入该业务日的聚水潭导出后自动切换为官方值">
<span className="daily-inventory-cell--derived">{node}</span>
</Tooltip>
) : node);
if (col.key === 'openingQty' || col.key === 'openingQtyExtra' || col.key === 'closingQty') {
return wrap(int(value));
}
if (col.key === 'turnoverDays') return percent(value);
return wrap(renderSigned(value));
},
};
}), [officialFlowKeys]);
const tableRows = useMemo(() => {
if (!data?.totals) return items;
return [...items, { ...data.totals, key: '__total__' }];
}, [items, data]);
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
/** 业务日下拉:最新业务日 + 已导入聚水潭官方数据的日期 */
const dateOptions = useMemo(() => {
const opts = [{
value: '__latest__',
label: `最新业务日${data?.systemBizDate ? `(${data.systemBizDate})` : ''}`,
}];
(data?.official?.availableDates || [])
.filter((d) => d !== data?.systemBizDate)
.forEach((d) => opts.push({ value: d, label: `${d}(聚水潭官方)` }));
return opts;
}, [data]);
const openImport = useCallback(() => {
setImportResult(null);
setImportFile(null);
setImportDate(data?.latestBizDate ? dayjs(data.latestBizDate) : dayjs());
setImportOpen(true);
}, [data]);
const submitImport = useCallback(async () => {
if (!importFile) {
setImportResult({ type: 'error', text: '请先选择聚水潭导出的 xlsx 文件' });
return;
}
if (!importDate) {
setImportResult({ type: 'error', text: '请选择业务日期' });
return;
}
setImporting(true);
setImportResult(null);
try {
const res = await importDailyInventoryOfficial({
file: importFile,
date: importDate.format('YYYY-MM-DD'),
});
const result = res?.data ?? {};
const bizDate = result.bizDate || importDate.format('YYYY-MM-DD');
const notes = [
...(result.warnings || []),
...(result.unknownWarehouses || []).map((w) => `未匹配到仓库 ID,已按仓库名存档:${w}`),
...(result.mismatches || []).map((m) => `${m.warehouse}:期初 + 流水 − 期末 = ${m.diff}`),
];
setImportResult({
type: 'success',
text: `${bizDate} 已导入 ${result.imported || 0} 个仓库${result.mismatches?.length ? ',存在恒等式不闭合的仓库' : ',恒等式逐仓闭合'}`,
notes,
});
if (selectedDate === bizDate) await load(true);
else setSelectedDate(bizDate);
} catch (err) {
setImportResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '导入失败',
notes: err?.response?.data?.detail ? [err.response.data.detail] : [],
});
} finally {
setImporting(false);
}
}, [importFile, importDate, selectedDate, load]);
// ── 通知设置:读取 / 保存 / 立即测试 ──
const applyNotifyConfig = useCallback((config = {}) => {
setNotifyConfig(config);
setNotifyForm({
enabled: config.enabled === true,
notifyTime: config.notifyTime || '01:00',
recipients: normalizeRecipientList(config.recipients || []),
});
}, []);
const loadNotifyConfig = useCallback(async () => {
setNotifyLoading(true);
try {
const res = await getDailyInventoryNotificationConfig();
applyNotifyConfig(res?.data ?? {});
} catch (err) {
setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' });
} finally {
setNotifyLoading(false);
}
}, [applyNotifyConfig]);
useEffect(() => {
if (notifyOpen) {
setNotifyResult(null);
loadNotifyConfig();
}
}, [notifyOpen, loadNotifyConfig]);
const saveNotifySettings = useCallback(async () => {
setNotifySaving(true);
setNotifyResult(null);
try {
const res = await saveDailyInventoryNotificationConfig({
enabled: notifyForm.enabled,
notifyTime: notifyForm.notifyTime,
recipients: normalizeRecipientList(notifyForm.recipients),
});
applyNotifyConfig(res?.data?.admin ?? res?.data ?? {});
setNotifyResult({ type: 'success', text: '通知设置已保存' });
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '保存通知设置失败',
});
} finally {
setNotifySaving(false);
}
}, [notifyForm, applyNotifyConfig]);
const runNotifyTest = useCallback(async () => {
setNotifyTesting(true);
setNotifyResult(null);
try {
const res = await runDailyInventoryNotificationTest();
const result = res?.data ?? {};
if (result.success) {
setNotifyResult({
type: 'success',
text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`,
url: result.screenshotUrl,
});
} else {
setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` });
}
loadNotifyConfig();
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '执行测试失败',
});
} finally {
setNotifyTesting(false);
}
}, [loadNotifyConfig]);
const addRecipients = useCallback(() => {
const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/));
if (!incoming.length) return;
setNotifyForm((old) => ({
...old,
recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]),
}));
setRecipientDraft('');
}, [recipientDraft]);
const removeRecipient = useCallback((name) => {
setNotifyForm((old) => ({
...old,
recipients: (old.recipients || []).filter((item) => item !== name),
}));
}, []);
return (
<SycmPage className="daily-inventory-page">
<div className="daily-inventory-header">
<div className="daily-inventory-header__title">
<Title level={4} style={{ margin: 0 }}>每日进销存</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="daily-inventory-header__help"
>
口径说明
</Button>
</div>
<div className="daily-inventory-header__meta">
<span>期初日期:{data?.openingDate || '—'}</span>
<span>库存快照日:{data?.latestBizDate || '—'}</span>
<span>分级基准时刻:{data?.asOf || '—'}</span>
{data?.shipWindow ? (
<span>
发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
</span>
) : null}
{data?.items?.[0]?.closingTakenAt ? (
<span>
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
</span>
) : null}
</div>
<Space wrap>
<Tooltip title="切换业务日:导入过聚水潭进销存导出的日期会以官方值显示">
<Select
style={{ width: 210 }}
value={selectedDate || '__latest__'}
onChange={(v) => setSelectedDate(v === '__latest__' ? '' : v)}
options={dateOptions}
/>
</Tooltip>
{isAdmin ? (
<Tooltip title="上传聚水潭「进销存按仓导出」xlsx,按业务日校正期初 / 12 个流水列 / 期末">
<Button icon={<UploadOutlined />} onClick={openImport}>导入聚水潭进销存</Button>
</Tooltip>
) : null}
{isAdmin ? (
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
</Tooltip>
</Space>
</div>
<Alert
type={data?.official?.hasData ? 'success' : 'info'}
showIcon
message={data?.official?.hasData
? `已按聚水潭官方进销存校正(${data.latestBizDate}):12 个流水列全部为官方值`
: '尚未导入聚水潭官方进销存:流水列为业务库推算值'}
description={
<span>
{data?.official?.hasData ? (
<>
本页 <b>{data.latestBizDate}</b> 的期初、12 个流水列、期末取自聚水潭「进销存按仓导出」,
共 <b>{data.official.warehouseCount}</b> 个仓;
{data.official.balanceMismatch?.length
? `其中 ${data.official.balanceMismatch.length} 个仓「期初 + 流水 ≠ 期末」,请核对导出文件。`
: '「期初 + 流水 = 期末」逐仓闭合。'}
{' '}未纳入该导出的仓库仍用业务库推算(仓库列标记为「推算」)。
可在右上角切换业务日或重新导入。
</>
) : (
<>
业务库只能还原 4/12 个流水列,且口径与聚水潭进销存不一致(采购按单据日期、销售按订单发货日期),
调拨/盘点/加工/其它出入库没有对应单据表。
请在右上角「导入聚水潭进销存」上传导出文件,导入后该业务日的期初与 12 个流水列即为官方值。
</>
)}
{data?.staleMetrics ? ` 当前查看的是历史业务日 ${data.latestBizDate},右侧周转率与四级库存量为当前实时口径。` : ''}
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}
</span>
}
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Table
className="daily-inventory-table"
columns={columns}
dataSource={tableRows}
rowKey={(record) => record.key || record.warehouse}
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }}
rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')}
/>
</Spin>
<Text type="secondary" className="daily-inventory-footer">
共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。
</Text>
<Modal
title="定时截图通知"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
destroyOnHidden
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
footer={(
<Space wrap>
<Button type="primary" loading={notifySaving} disabled={notifyLoading} onClick={saveNotifySettings}>
保存设置
</Button>
<Tooltip title="真实执行一次截图、上传与通知;测试不会占用正式推送的幂等记录">
<Button loading={notifyTesting} disabled={notifyLoading} onClick={runNotifyTest}>
立即测试
</Button>
</Tooltip>
<Button onClick={() => setNotifyOpen(false)}>关闭</Button>
</Space>
)}
>
<Spin spinning={notifyLoading}>
{notifyConfig?.loadError ? (
<Alert type="error" showIcon message={notifyConfig.loadError} style={{ marginBottom: 12 }} />
) : null}
{notifyConfig?.blockingReasons?.length ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="当前不会自动推送,原因如下"
description={<span>{notifyConfig.blockingReasons.join(';')}</span>}
/>
) : null}
<div className="daily-inventory-notify-form">
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">启用每日截图通知</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
/>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知时间</span>
<TimePicker
format={TIME_FORMAT}
minuteStep={5}
allowClear={false}
value={dayjs(`2026-01-01T${notifyForm.notifyTime}:00`)}
onChange={(value) => setNotifyForm((old) => ({
...old,
notifyTime: value ? value.format(TIME_FORMAT) : '01:00',
}))}
style={{ width: 140 }}
/>
<Text type="secondary">({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)</Text>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知用户</span>
<Input
allowClear
placeholder="输入通知中心中的姓名或别名,回车添加;多个可用逗号分隔"
value={recipientDraft}
onChange={(e) => setRecipientDraft(e.target.value)}
onPressEnter={addRecipients}
style={{ width: 320 }}
/>
<Button icon={<PlusOutlined />} onClick={addRecipients} disabled={!String(recipientDraft || '').trim()}>
添加
</Button>
</div>
<div className="daily-inventory-notify-recipients">
{notifyForm.recipients.length ? (
notifyForm.recipients.map((name) => (
<Tag key={name} closable onClose={() => removeRecipient(name)}>{name}</Tag>
))
) : (
<Text type="secondary">尚未添加通知用户</Text>
)}
</div>
<Text type="secondary">
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
</Text>
</div>
{notifyResult ? (
<Alert
type={notifyResult.type}
showIcon
style={{ marginTop: 12 }}
message={notifyResult.text}
description={notifyResult.url ? (
<a href={notifyResult.url} target="_blank" rel="noreferrer">{notifyResult.url}</a>
) : null}
/>
) : null}
{notifyConfig && !notifyConfig.loadError ? (
<div className="daily-inventory-notify-meta">
<div>
统一通知中心:
<Text type={notifyConfig.notificationCenter?.configured ? 'success' : 'warning'} strong>
{notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.notificationCenter?.baseUrl ? (
<Text type="secondary">{` ${notifyConfig.notificationCenter.baseUrl}`}</Text>
) : null}
</div>
<div>
对象存储:
<Text type={notifyConfig.objectStorage?.configured ? 'success' : 'warning'} strong>
{notifyConfig.objectStorage?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.objectStorage?.bucket ? (
<Text type="secondary">{` 桶 ${notifyConfig.objectStorage.bucket}`}</Text>
) : null}
</div>
<div>
上次执行:
<b>{notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
{notifyConfig.lastRunSuccess === null ? '' : (
<Text type={notifyConfig.lastRunSuccess ? 'success' : 'danger'}>
{` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`}
</Text>
)}
</div>
</div>
) : null}
{notifyConfig?.runs?.length ? (
<div className="daily-inventory-notify-runs">
<Text strong>最近执行记录</Text>
{notifyConfig.runs.slice(0, 5).map((run, index) => (
<div key={`${run.runAt}-${index}`} className="daily-inventory-notify-run">
<span>{new Date(run.runAt).toLocaleString('zh-CN')}</span>
<Text type={run.success ? 'success' : 'danger'}>
{run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`}
</Text>
{run.screenshotUrl ? (
<a href={run.screenshotUrl} target="_blank" rel="noreferrer">截图</a>
) : null}
</div>
))}
</div>
) : null}
</Spin>
</Modal>
<Modal
title="导入聚水潭进销存(按仓)"
open={importOpen}
onCancel={() => setImportOpen(false)}
width={620}
destroyOnHidden
footer={(
<Space wrap>
<Button type="primary" loading={importing} onClick={submitImport}>开始导入</Button>
<Button onClick={() => setImportOpen(false)}>关闭</Button>
</Space>
)}
>
<div className="daily-inventory-import">
<div className="daily-inventory-import-row">
<span className="daily-inventory-import-label">业务日期</span>
<DatePicker
value={importDate}
onChange={(v) => setImportDate(v)}
allowClear={false}
style={{ width: 180 }}
/>
<Text type="secondary">导出文件对应的进销存日期</Text>
</div>
<div className="daily-inventory-import-row">
<span className="daily-inventory-import-label">导出文件</span>
<Upload
accept=".xlsx,.xls"
maxCount={1}
fileList={importFile ? [{ uid: 'import', name: importFile.name, status: 'done' }] : []}
beforeUpload={(file) => { setImportFile(file); return false; }}
onRemove={() => { setImportFile(null); return true; }}
>
<Button icon={<UploadOutlined />}>选择文件</Button>
</Upload>
</div>
<Text type="secondary">
需包含「仓库 / 期初数量 / 12 个流水列 / 期末数量」表头;同一业务日重复导入会整体覆盖。
导入后会自动回写本地库存快照,使「今日期初 = 昨日结存」的推算也对齐聚水潭口径。
</Text>
{importResult ? (
<Alert
type={importResult.type}
showIcon
style={{ marginTop: 12 }}
message={importResult.text}
description={importResult.notes?.length
? <span>{importResult.notes.join(';')}</span>
: null}
/>
) : null}
</div>
</Modal>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={720}
>
<div className="daily-inventory-help">
{FORMULA_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default DailyInventory;

View File

@ -0,0 +1,184 @@
.daily-inventory-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.daily-inventory-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.daily-inventory-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.daily-inventory-header__help {
font-size: 13px;
}
.daily-inventory-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.daily-inventory-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.daily-inventory-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.daily-inventory-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
}
/* 运行时取数的列:加淡蓝底纹,与基准值区分 */
.daily-inventory-table .daily-inventory-col--runtime {
background: rgba(22, 119, 255, 0.04);
}
/* 暂无单据来源的列:淡灰底纹,提示该列待 DBA 补表 */
.daily-inventory-table .daily-inventory-col--unavailable {
background: rgba(0, 0, 0, 0.03);
}
.daily-inventory-cell--na {
color: rgba(0, 0, 0, 0.25);
}
.daily-inventory-warehouse {
white-space: nowrap;
}
.daily-inventory-cell--out {
color: #cf1322;
}
.daily-inventory-cell--in {
color: rgba(0, 0, 0, 0.85);
}
.daily-inventory-cell--zero {
color: rgba(0, 0, 0, 0.3);
}
/* 业务库推算值:标灰 + 斜体,提示与聚水潭官方口径存在差异 */
.daily-inventory-cell--derived {
color: rgba(0, 0, 0, 0.42);
font-style: italic;
}
/* 仓库名后的来源标签 */
.daily-inventory-tag {
margin-left: 6px;
transform: scale(0.85);
transform-origin: left center;
}
/* 导入聚水潭进销存弹窗 */
.daily-inventory-import {
display: flex;
flex-direction: column;
gap: 12px;
}
.daily-inventory-import-row {
display: flex;
align-items: center;
gap: 10px;
}
.daily-inventory-import-label {
width: 72px;
color: rgba(0, 0, 0, 0.75);
}
.daily-inventory-row--total > td {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
}
/* 定时截图通知设置弹窗 */
.daily-inventory-notify-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.daily-inventory-notify-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.daily-inventory-notify-label {
width: 120px;
flex: none;
color: rgba(0, 0, 0, 0.75);
}
.daily-inventory-notify-recipients {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-left: 128px;
min-height: 24px;
}
.daily-inventory-notify-meta {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.daily-inventory-notify-runs {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.daily-inventory-notify-run {
display: flex;
align-items: center;
gap: 10px;
color: rgba(0, 0, 0, 0.6);
}
.daily-inventory-footer {
font-size: 12px;
}
.daily-inventory-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.daily-inventory-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -1,502 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Input,
Popover,
Segmented,
Skeleton,
Table,
Tag,
Tooltip,
} from 'antd';
import {
InfoCircleOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SearchOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
import {
findWarehouseBySlug,
FORMULA_DESCRIPTION,
LARGE_STOCK_MONITOR_WAREHOUSES,
LEVEL_COLOR,
LEVEL_FILTER_OPTIONS,
LEVEL_LABEL,
OVERVIEW_COLUMNS,
} from '../../constants/largeStockMonitor';
import {
getLargeStockMonitorOverview,
getLargeStockMonitorSkuList,
} from '../../api/largeStockMonitor';
import './style.css';
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
const n = Number(value);
if (!Number.isFinite(n)) return value;
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
}
function formatPercent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
const n = Number(value);
if (!Number.isFinite(n)) return value;
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`;
}
function formatMonths(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
const n = Number(value);
if (!Number.isFinite(n)) return value;
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 月`;
}
function formatStock(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
const n = Number(value);
if (!Number.isFinite(n)) return value;
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
const OVERVIEW_RENDERERS = {
periodStart: formatStock,
openingBalance: formatStock,
purchaseReturn: formatStock,
purchaseIn: formatStock,
salesOut: formatStock,
salesReturn: formatStock,
closingQty: formatStock,
turnoverDays: formatPercent,
warnLevel: formatStock,
slowLevel: formatStock,
staleLevel: formatStock,
zombieLevel: formatStock,
};
const FormulaHelp = () => (
<div className="large-stock-monitor-formula">
<div className="large-stock-monitor-formula__title">计算口径</div>
<ul>
{FORMULA_DESCRIPTION.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
);
const LargeStockMonitor = () => {
const { warehouseSlug } = useParams();
const navigate = useNavigate();
const warehouse = useMemo(() => {
if (!warehouseSlug) return null;
return findWarehouseBySlug(warehouseSlug) || LARGE_STOCK_MONITOR_WAREHOUSES[0] || null;
}, [warehouseSlug]);
const [overview, setOverview] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState(null);
const [skuListState, setSkuListState] = useState({
items: [],
total: 0,
page: 1,
pageSize: 20,
loading: false,
});
const [filterLevel, setFilterLevel] = useState('');
const [keyword, setKeyword] = useState('');
useEffect(() => {
if (!warehouse) {
setOverview(null);
setOverviewLoading(false);
return;
}
let cancelled = false;
setOverviewLoading(true);
setOverviewError(null);
getLargeStockMonitorOverview(warehouse.warehouse)
.then((res) => {
if (cancelled) return;
setOverview(res?.data || null);
})
.catch((error) => {
if (cancelled) return;
setOverviewError(error?.message || '获取概览失败');
setOverview(null);
})
.finally(() => {
if (cancelled) return;
setOverviewLoading(false);
});
return () => {
cancelled = true;
};
}, [warehouse]);
const reloadSkuList = (nextPage, nextPageSize, nextLevel, nextKeyword) => {
if (!warehouse) return;
setSkuListState((prev) => ({ ...prev, loading: true }));
getLargeStockMonitorSkuList({
warehouse: warehouse.warehouse,
page: nextPage,
pageSize: nextPageSize,
level: nextLevel,
keyword: nextKeyword,
})
.then((res) => {
setSkuListState({
items: res?.data?.items || [],
total: res?.data?.total || 0,
page: nextPage,
pageSize: nextPageSize,
loading: false,
});
})
.catch(() => {
setSkuListState((prev) => ({ ...prev, loading: false }));
});
};
useEffect(() => {
if (!warehouse) return;
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, filterLevel]);
const handleTableChange = (pagination) => {
const nextPage = pagination.current || 1;
const nextPageSize = pagination.pageSize || 20;
reloadSkuList(nextPage, nextPageSize, filterLevel, keyword.trim());
};
const handleSearch = () => {
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
};
const overviewCells = useMemo(() => {
const map = overview?.overview || null;
return OVERVIEW_COLUMNS.map((column) => {
const value = map ? map[column.key] : '—';
const rendered = column.available && value !== '—' && OVERVIEW_RENDERERS[column.key]
? OVERVIEW_RENDERERS[column.key](value)
: value;
return { ...column, value, rendered };
});
}, [overview]);
const skuColumns = useMemo(() => ([
{
title: 'SKU',
dataIndex: 'sku',
key: 'sku',
width: 130,
fixed: 'left',
render: (value, record) => (
<div className="large-stock-monitor-sku-cell">
<strong>{value || '-'}</strong>
{record.spu ? <span className="large-stock-monitor-sku-cell__meta">SPU {record.spu}</span> : null}
</div>
),
},
{
title: '商品名称',
dataIndex: 'skuName',
key: 'skuName',
width: 260,
render: (value) => <span title={value}>{value || '-'}</span>,
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: (value) => value || '-',
},
{
title: '供应商',
dataIndex: 'supplier',
key: 'supplier',
width: 140,
render: (value) => value || '-',
},
{
title: '当前库存',
dataIndex: 'jsStock',
key: 'jsStock',
width: 100,
align: 'right',
render: formatStock,
},
{
title: () => (
<span>
库龄
<Tooltip title="(今天 - 入库时间) / 30,单位:月">
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
</span>
),
dataIndex: 'ageMonths',
key: 'ageMonths',
width: 100,
align: 'right',
render: formatMonths,
},
{
title: () => (
<span>
月度发货
<Tooltip title="≈ threeAvgSale × 30(业务库仅有 3 天日均销量)">
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
</span>
),
dataIndex: 'monthlyShipment',
key: 'monthlyShipment',
width: 110,
align: 'right',
render: formatNumber,
},
{
title: () => (
<span>
日均库存
<Tooltip title="≈ jsStock(业务库仅保留最近一天快照,作为日均近似)">
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
</span>
),
dataIndex: 'avgDailyStock',
key: 'avgDailyStock',
width: 110,
align: 'right',
render: formatStock,
},
{
title: () => (
<span>
库存周转天数
<Tooltip title="= 月度发货 / 日均库存 × 100%(实际为周转率 %,沿用 Excel 列名)">
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
</span>
),
dataIndex: 'turnoverDays',
key: 'turnoverDays',
width: 130,
align: 'right',
render: formatPercent,
},
{
title: '级别',
dataIndex: 'level',
key: 'level',
width: 130,
fixed: 'right',
render: (value) => {
if (!value) return <span style={{ color: '#999' }}>—</span>;
return <Tag color={LEVEL_COLOR[value]}>{LEVEL_LABEL[value] || value}</Tag>;
},
},
]), []);
const siblingOptions = useMemo(() => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
label: `${entry.group} · ${entry.warehouse.replace(/^简尚-/, '')}`,
value: entry.slug,
idx: entry.idx,
group: entry.group,
})), []);
const fallbackNotice = warehouse && overview && !overview.hasData;
return (
<SycmPage className="large-stock-monitor-page">
<SycmBusinessPanel className="large-stock-monitor-header" aria-label="大货库存监控概览">
<div className="large-stock-monitor-header__top">
<div>
<h2>
大货库存监控 · {warehouse ? warehouse.warehouse : '请选择仓库'}
<Popover
content={<FormulaHelp />}
title="计算口径"
trigger="hover"
placement="rightTop"
>
<InfoCircleOutlined
style={{ marginLeft: 8, fontSize: 16, color: '#1677ff', cursor: 'help' }}
/>
</Popover>
</h2>
<p className="large-stock-monitor-header__meta">
<span>二级分类:{warehouse ? warehouse.group : '-'}</span>
<span>分布时间:{overview?.asOf || overview?.latestBizDate || '—'}</span>
{overview?.latestBizDate ? <span>库存快照日:{overview.latestBizDate}</span> : null}
{overview?.closingTakenAt ? (
<span>库存取数时间:{new Date(overview.closingTakenAt).toLocaleString('zh-CN', { hour12: false })}(实时)</span>
) : null}
{overview?.period ? (
<span>
流水区间:{overview.period.start} ~ {overview.period.end}(左闭右开)
</span>
) : null}
<span>SKU 数:{overview?.skuCount ?? '—'}</span>
<span>平均周转率:{overview?.turnoverDaysAvg != null ? formatPercent(overview.turnoverDaysAvg) : '—'}</span>
<span>最大周转率:{overview?.turnoverDaysMax != null ? formatPercent(overview.turnoverDaysMax) : '—'}</span>
</p>
</div>
<div className="large-stock-monitor-header__actions">
<Segmented
value={warehouse?.slug}
onChange={(value) => {
if (value) {
navigate(`/planning/large-stock-monitor/${value}`);
}
}}
options={siblingOptions.slice(0, 6)}
className="large-stock-monitor-quick-switch"
/>
<Button
icon={<ReloadOutlined />}
onClick={() => {
if (warehouse) navigate(`/planning/large-stock-monitor/${warehouse.slug}`);
}}
>
刷新
</Button>
</div>
</div>
{overviewError ? (
<Alert
type="error"
showIcon
message={`获取概览失败:${overviewError}`}
style={{ marginBottom: 12 }}
/>
) : null}
{fallbackNotice ? (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message="业务库暂未同步该仓库的库存快照,周转率与四级库存量无数据源,显示「—」"
style={{ marginBottom: 12 }}
/>
) : null}
{overview && overview.hasGrade === false ? (
<Alert
type="info"
showIcon
message="该仓库没有库存快照(pm_stockMonitor),库龄、周转天数与四级库存量无数据源,显示「—」;期末数量已按聚水潭实际库存取数"
style={{ marginBottom: 12 }}
/>
) : null}
{overview?.hasGrade && overview?.gradeBasisStock ? (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message={`期末数量按聚水潭实际库存统计;四级库存量由库存快照分级得出,快照为「归属仓」口径(含已调拨到外仓的货,合计 ${formatStock(overview.gradeBasisStock)}),两者口径不同,不能直接相加核对`}
style={{ marginBottom: 12 }}
/>
) : null}
{overview?.requestedDate && overview?.fallbackDate ? (
<Alert
type="info"
showIcon
message={`未找到 ${overview.requestedDate} 的库存快照,已自动回退并展示 ${overview.fallbackDate} 的数据`}
style={{ marginBottom: 12 }}
/>
) : null}
{overview?.requestedDate && !overview?.fallbackDate && !overview?.hasData ? (
<Alert
type="warning"
showIcon
message={`未找到 ${overview.requestedDate} 及之前的库存快照,当前汇总字段全部为占位符「—」`}
style={{ marginBottom: 12 }}
/>
) : null}
<div className="large-stock-monitor-overview">
{overviewLoading ? (
<Skeleton active paragraph={{ rows: 2 }} />
) : (
<div className="large-stock-monitor-grid">
{overviewCells.map((cell) => (
<div
key={cell.key}
className={`large-stock-monitor-grid__cell${cell.available ? ' is-available' : ' is-placeholder'}`}
>
<span className="large-stock-monitor-grid__label">
{cell.label}
{cell.estimated ? <em className="large-stock-monitor-grid__est">估</em> : null}
{cell.tip ? (
<Tooltip title={cell.tip}>
<QuestionCircleOutlined className="large-stock-monitor-grid__help" />
</Tooltip>
) : null}
</span>
<strong className="large-stock-monitor-grid__value">{cell.rendered}</strong>
</div>
))}
</div>
)}
</div>
</SycmBusinessPanel>
<SycmBusinessPanel className="large-stock-monitor-table" aria-label="SKU 库存明细">
<div className="large-stock-monitor-toolbar">
<span className="large-stock-monitor-toolbar__note">
明细按库存快照(归属仓口径)分级,用于识别滞销/慢流/呆滞/僵尸 SKU
</span>
<Segmented
value={filterLevel}
onChange={setFilterLevel}
options={LEVEL_FILTER_OPTIONS}
/>
<Input.Search
allowClear
placeholder="搜索 SKU 编码或商品名称"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onSearch={handleSearch}
enterButton={<><SearchOutlined /> 查询</>}
style={{ maxWidth: 320 }}
/>
</div>
<Table
className="large-stock-monitor-data-table"
rowKey={(record) => `${record.sku}-${record.warehouse}`}
columns={skuColumns}
dataSource={skuListState.items}
loading={skuListState.loading}
scroll={{ x: 1280 }}
pagination={{
current: skuListState.page,
pageSize: skuListState.pageSize,
total: skuListState.total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
pageSizeOptions: ['20', '50', '100'],
}}
onChange={handleTableChange}
locale={{ emptyText: '该仓库暂无 SKU 库存快照' }}
/>
</SycmBusinessPanel>
</SycmPage>
);
};
export default LargeStockMonitor;

View File

@ -1,149 +0,0 @@
.large-stock-monitor-page {
display: flex;
flex-direction: column;
gap: var(--sycm-space-md);
padding: var(--sycm-space-md);
}
.large-stock-monitor-header__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.large-stock-monitor-header__top h2 {
margin: 0 0 4px;
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
}
.large-stock-monitor-header__meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.large-stock-monitor-header__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.large-stock-monitor-quick-switch {
max-width: 480px;
}
.large-stock-monitor-overview {
margin-top: 12px;
}
.large-stock-monitor-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.large-stock-monitor-grid__cell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: var(--sycm-radius-sm);
background: var(--sycm-bg-subtle);
border: 1px solid transparent;
}
.large-stock-monitor-grid__cell.is-available {
background: var(--sycm-bg-elevated, #fff);
border-color: var(--sycm-color-primary-border);
}
.large-stock-monitor-grid__cell.is-placeholder {
color: var(--sycm-text-tertiary);
}
.large-stock-monitor-grid__label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
color: var(--sycm-text-secondary);
}
/* 估算值标记:由已有字段推导,非业务库原始字段 */
.large-stock-monitor-grid__est {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-color-warning-bg, #fffbe6);
border: 1px solid var(--sycm-color-warning-border, #ffe58f);
color: var(--sycm-color-warning-text, #d46b08);
font-size: var(--sycm-font-size-micro);
font-style: normal;
line-height: 16px;
}
.large-stock-monitor-grid__help {
color: var(--sycm-text-tertiary);
cursor: help;
font-size: var(--sycm-font-size-caption);
}
.large-stock-monitor-grid__value {
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
color: var(--sycm-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.large-stock-monitor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.large-stock-monitor-toolbar__note {
font-size: 12px;
color: #8c8c8c;
line-height: 1.5;
}
.large-stock-monitor-data-table .large-stock-monitor-sku-cell {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.large-stock-monitor-data-table .large-stock-monitor-sku-cell__meta {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
.large-stock-monitor-formula {
max-width: 480px;
font-size: var(--sycm-font-size-caption);
color: var(--sycm-text-secondary);
}
.large-stock-monitor-formula__title {
font-weight: var(--sycm-font-weight-semibold);
color: var(--sycm-text-primary);
margin-bottom: 8px;
}
.large-stock-monitor-formula ul {
margin: 0;
padding-left: 18px;
line-height: 1.7;
}

View File

@ -0,0 +1,146 @@
import React from 'react';
import { Tooltip } from 'antd';
/**
* 仓储中心 24 小时发货时效 —— 列表左侧侧栏
* ────────────────────────────────────────────────────────────────────────────
* 业务需求(yanhui 2026-10-20):
* 「在列表左侧加一栏仓储中心24小时发货时效,下面列 11 个仓…只显示超时未发」
*
* 设计取舍:
* · **只显示超时未发** —— 数量为 0 的仓也保留一行(浅色显示「—」),
* 这样业务能一眼看出「这个仓今天全发完了」,比直接隐藏更有信息量;
* · 点击某个仓 → 把该仓的超时订单筛到右侧表格里(不跳页,避免业务来回切);
* · 崔桥工厂半成品仓系统里没有同名仓,`placeholder=true` 时显示「未接入」。
*
* 数据来自 getWarehouseShipTimeliness,字段见后端 warehouseShipTimelinessService。
*/
function WarehouseShipTimelinessSidebar({
data, loading, error, activeCenter, onSelectCenter, collapsed, onToggleCollapse,
}) {
const centers = data && Array.isArray(data.centers) ? data.centers : [];
const summary = data && data.summary ? data.summary : null;
const round = data && data.round ? data.round : null;
const freshness = data && data.freshness ? data.freshness : null;
const totalOverdue = summary ? summary.overdueUnshipped : 0;
const hasRound = Boolean(round);
// 12:00 之前后端返回 round=null + notStarted=true:
// 业务口径是「12:00 才开始第一轮核验」,此时**不展示任何超时数字**,
// 也不回填昨天的结算结果(否则会被误当成今天的超时)。
const notStarted = Boolean(data && data.notStarted);
// 核验截止时刻:12:00 轮截止 07:00、16:00 轮截止 16:00,直接展示,业务不用换算
const cutoffText = String((round && round.windowEnd) || '').slice(11, 16);
return (
<aside className={`mkh-ship-side${collapsed ? ' mkh-ship-side--collapsed' : ''}`}>
<div className="mkh-ship-side__head">
<div className="mkh-ship-side__title">
<span>仓储中心24小时发货时效</span>
<button
type="button"
className="mkh-ship-side__collapse"
onClick={onToggleCollapse}
title={collapsed ? '展开侧栏' : '收起侧栏'}
>
{collapsed ? '»' : '«'}
</button>
</div>
{collapsed ? null : (
<>
<div className="mkh-ship-side__meta">
{hasRound ? (
<>
<span className="mkh-ship-side__round">{round.label}</span>
<span>核验 {cutoffText} 前</span>
</>
) : (
<span className="mkh-ship-side__round mkh-ship-side__round--idle">未到核验时间</span>
)}
</div>
<div className="mkh-ship-side__total">
<span className="mkh-ship-side__total-num" data-zero={totalOverdue === 0}>
{notStarted ? '—' : totalOverdue}
</span>
<span className="mkh-ship-side__total-label">单超时未发</span>
</div>
</>
)}
</div>
{collapsed ? null : (
<>
<ul className="mkh-ship-side__list">
{centers.map((c) => {
const isActive = activeCenter === c.key;
const empty = !c.overdueUnshipped;
// 12:00 前未开始核验时,所有仓都不能点(点了也筛不出东西,
// 反而让人以为「这个仓没问题」)
const disabled = c.placeholder || notStarted;
return (
<li key={c.key}>
<Tooltip
placement="right"
title={c.placeholder
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
: notStarted
? '今日 12:00 首轮核验开始后才展示超时数据'
: `${c.name}:超时未发 ${c.overdueUnshipped} 单`
+ `(已发但超时 ${c.overdueShipped},窗口订单 ${c.windowOrderCount}`
+ `${c.onTimeRate === null ? '' : `,按时率 ${c.onTimeRate}%`})`
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
>
<button
type="button"
className={[
'mkh-ship-side__item',
isActive ? 'is-active' : '',
empty ? 'is-empty' : '',
disabled ? 'is-disabled' : '',
].filter(Boolean).join(' ')}
onClick={() => {
if (disabled) return;
onSelectCenter?.(isActive ? '' : c.key);
}}
>
<span className="mkh-ship-side__name">{c.name}</span>
{c.placeholder ? (
<span className="mkh-ship-side__badge mkh-ship-side__badge--none">未接入</span>
) : (
<span className={`mkh-ship-side__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
{c.overdueUnshipped > 0 ? c.overdueUnshipped : '—'}
</span>
)}
</button>
</Tooltip>
</li>
);
})}
</ul>
{error ? (
<div className="mkh-ship-side__warn" title={error}>时效数据加载失败</div>
) : null}
{freshness && freshness.stale ? (
<div className="mkh-ship-side__warn" title={freshness.hint}>
发货数据可能滞后
</div>
) : null}
{/* 12:00 前:首轮核验还没开始,明确告诉业务「几点开始看」,
而不是拿昨天的结算数字冒充今天。 */}
{notStarted ? (
<div className="mkh-ship-side__hint">
{String(data.nextRoundAt || '').slice(11, 16) || '12:00'} 开始首轮核验
(核验当日 07:00 前创建的订单)
</div>
) : null}
{loading ? <div className="mkh-ship-side__loading">加载中…</div> : null}
</>
)}
</aside>
);
}
export default WarehouseShipTimelinessSidebar;

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch,
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch,
Table, Tooltip, Typography, message,
} from 'antd';
import {
@ -18,6 +18,9 @@ import {
syncMkhBom,
} from '../../api/mkhBom';
import { createRequestGate } from '../../utils/requestGate';
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
import WarehouseShipTimelinessSidebar from './WarehouseShipTimelinessSidebar';
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -32,7 +35,33 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
// 超时时长(分钟)→ 「3小时20分」。业务看的是「晚了多久」,不是精确到分钟的秒数。
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;
return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
};
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
const AUTO_REFRESH_MS = 2 * 60 * 1000;
const getRefreshProgress = (elapsedSeconds) => {
if (elapsedSeconds < 3) {
return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' };
}
if (elapsedSeconds < 15) {
return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' };
}
return {
percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)),
stage: '正在合并最新数据与 BOM 配件结果…',
};
};
// 预警等级 → 标签配色
const WARNING_TONE = {
@ -263,6 +292,7 @@ const MkhBomData = () => {
// 导出(当前筛选条件下的全部数据)
const [exporting, setExporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' });
// 补货预警推送设置
const [alertOpen, setAlertOpen] = useState(false);
@ -277,8 +307,57 @@ const MkhBomData = () => {
const tableWrapRef = useRef(null);
const listRequestGateRef = useRef(createRequestGate());
const detailGenerationRef = useRef(0);
const refreshProgressGenerationRef = useRef(0);
const lastListRefreshAtRef = useRef(0);
const autoRefreshPendingRef = useRef(false);
const [bodyHeight, setBodyHeight] = useState(420);
// ── 仓储中心 24 小时发货时效(左侧侧栏)────────────────────────────────
// 与 MKH 列表**完全独立**的两套数据:列表来自 BOM/库存,侧栏来自订单+出库表。
// 所以侧栏单独 load、单独失败,不能因为发货时效查询慢就把整个列表拖住。
const [shipTimeliness, setShipTimeliness] = useState(null);
const [shipLoading, setShipLoading] = useState(false);
const [shipError, setShipError] = useState('');
const [shipCollapsed, setShipCollapsed] = useState(false);
// 选中的主仓:非空时右侧表格只显示该仓的超时订单
const [shipCenter, setShipCenter] = useState('');
// 当前选中主仓的名字 + 该仓的超时订单(从同一次快照里筛,保证与侧栏数字一致)
const shipCenterName = useMemo(() => {
if (!shipCenter || !shipTimeliness) return '';
const hit = (shipTimeliness.centers || []).find((c) => c.key === shipCenter);
return hit ? hit.name : shipCenter;
}, [shipCenter, shipTimeliness]);
const shipCenterOrders = useMemo(() => {
if (!shipCenter || !shipTimeliness) return [];
return (shipTimeliness.orders || []).filter((o) => o.centerKey === shipCenter);
}, [shipCenter, shipTimeliness]);
const beginRefreshProgress = useCallback((title) => {
refreshProgressGenerationRef.current += 1;
const generation = refreshProgressGenerationRef.current;
setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation });
return generation;
}, []);
const endRefreshProgress = useCallback((generation) => {
if (generation !== refreshProgressGenerationRef.current) return;
setRefreshProgress((current) => ({ ...current, open: false }));
}, []);
useEffect(() => {
if (!refreshProgress.open) return undefined;
const startedAt = Date.now();
const timer = window.setInterval(() => {
setRefreshProgress((current) => ({
...current,
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
}));
}, 1000);
return () => window.clearInterval(timer);
}, [refreshProgress.open]);
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
@ -313,28 +392,99 @@ const MkhBomData = () => {
return params;
}, [page, pageSize, sorter, filters]);
const load = useCallback(async () => {
const load = useCallback(async ({ background = false } = {}) => {
const requestGeneration = listRequestGateRef.current.begin();
setLoading(true);
setError('');
let showProgress = false;
let progressGeneration = null;
if (!background) setLoading(true);
if (!background) setError('');
try {
const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() });
const now = Date.now();
const forceFresh = !lastListRefreshAtRef.current
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
showProgress = forceFresh && !background;
if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据');
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
const result = await getMkhBomList({
...query,
live: 1,
...(forceFresh ? { fresh: 1 } : null),
_refresh: now,
});
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
return;
}
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
const snapshot = result?.data || null;
try {
requireMkhFreshContract(snapshot, { requireApplied: forceFresh });
} catch (contractError) {
setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
throw contractError;
}
setData(snapshot);
if (forceFresh) lastListRefreshAtRef.current = Date.now();
setError('');
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
if (showProgress) endRefreshProgress(progressGeneration);
}
}, [query]);
}, [beginRefreshProgress, endRefreshProgress, query]);
useEffect(() => { load(); }, [load]);
// ── 发货时效侧栏加载 ────────────────────────────────────────────────────
// 注意:这个接口要扫两张订单表 + 出库表(rpt 全是视图),冷启动实测 40~50 秒,
// 所以**不能**跟着列表每 2 分钟的轮询一起跑,否则后端一直被打。
// 独立节奏:进页面拉一次,之后每 10 分钟一次(核验点只有 12:00 / 16:00 两个)。
const loadShipTimeliness = useCallback(async () => {
setShipLoading(true);
setShipError('');
try {
const result = await getWarehouseShipTimeliness({ limit: 500 });
if (!result || result.success === false) {
setShipError(result?.error || '加载发货时效失败');
return;
}
setShipTimeliness(result.data || null);
} catch (shipLoadError) {
setShipError(shipLoadError?.response?.data?.error || shipLoadError?.message || '加载发货时效失败');
} finally {
setShipLoading(false);
}
}, []);
useEffect(() => {
loadShipTimeliness();
const timer = window.setInterval(loadShipTimeliness, 10 * 60 * 1000);
return () => window.clearInterval(timer);
}, [loadShipTimeliness]);
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
useEffect(() => {
const refreshIfStale = () => {
if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return;
if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return;
autoRefreshPendingRef.current = true;
load({ background: true }).finally(() => {
autoRefreshPendingRef.current = false;
});
};
const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS);
document.addEventListener('visibilitychange', refreshIfStale);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refreshIfStale);
};
}, [load, syncing]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
@ -813,6 +963,7 @@ const MkhBomData = () => {
const handleSync = async () => {
setSyncing(true);
setLoading(true);
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
@ -826,7 +977,8 @@ const MkhBomData = () => {
if (!result || result.success === false || !result?.data?.snapshot) {
throw new Error(result?.error || '同步接口未返回数据库快照');
}
setData(result.data.snapshot);
setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true }));
lastListRefreshAtRef.current = Date.now();
setError('');
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
} catch (syncError) {
@ -837,11 +989,31 @@ const MkhBomData = () => {
} finally {
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
setSyncing(false);
endRefreshProgress(progressGeneration);
}
};
const progressView = getRefreshProgress(refreshProgress.elapsedSeconds);
return (
<SycmPage className="mkh-page">
<Modal
open={refreshProgress.open}
title={refreshProgress.title}
footer={null}
closable={false}
keyboard={false}
maskClosable={false}
centered
width={480}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Progress percent={progressView.percent} status="active" strokeColor="#1677ff" />
<Text>{progressView.stage}</Text>
<Text type="secondary">已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。</Text>
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
</Space>
</Modal>
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>
@ -875,6 +1047,73 @@ const MkhBomData = () => {
</div>
</header>
{/*
左侧「仓储中心24小时发货时效」侧栏 + 右侧原有内容,横向两栏。
侧栏收起时只留一条窄条,主区自动占满 —— 用 flex,不用固定宽度判断。
*/}
<div className="mkh-with-side">
<WarehouseShipTimelinessSidebar
data={shipTimeliness}
loading={shipLoading}
error={shipError}
activeCenter={shipCenter}
onSelectCenter={setShipCenter}
collapsed={shipCollapsed}
onToggleCollapse={() => setShipCollapsed((v) => !v)}
/>
<div className="mkh-with-side__main">
{/*
超时明细:只有点了左侧某个仓才展开。
明细来自同一次 list 接口(已按 order_date 升序,最早的最急),
不再单独请求 —— 两套数据必须同源同快照,否则数字对不上。
*/}
{shipCenter && shipTimeliness ? (
<Card
bordered={false}
className="mkh-ship-detail"
title={(
<span>
超时未发订单 · <b>{shipCenterName}</b>
<span className="mkh-ship-detail__count">
{shipCenterOrders.length} 单
{shipTimeliness.truncated && shipCenterOrders.length >= (shipTimeliness.detailLimit || 0)
? `(已截断,仅显示前 ${shipTimeliness.detailLimit} 条)`
: ''}
</span>
</span>
)}
extra={<button type="button" className="mkh-ship-detail__clear" onClick={() => setShipCenter('')}>关闭</button>}
>
<Table
rowKey={(r) => `${r.source}-${r.oId}`}
size="small"
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
dataSource={shipCenterOrders}
columns={[
{ title: '订单号', dataIndex: 'oId', width: 110 },
{ title: '店铺', dataIndex: 'shopName', width: 190, ellipsis: true },
{ title: '发货仓', dataIndex: 'warehouseName', width: 190, ellipsis: true },
{ title: '创建时间', dataIndex: 'orderDate', width: 150 },
{
title: '超时时长',
dataIndex: 'overdueMinutes',
width: 96,
render: (v) => <span className="mkh-ship-detail__overdue">{formatOverdue(v)}</span>,
},
{
title: '金额',
dataIndex: 'payAmount',
width: 96,
align: 'right',
render: (v) => `¥${number(v, 2)}`,
},
{ title: '状态', dataIndex: 'status', width: 110 },
]}
/>
</Card>
) : null}
<Card className="mkh-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
@ -1235,6 +1474,8 @@ const MkhBomData = () => {
</div>
</Spin>
</Modal>
</div>
</div>
</SycmPage>
);
};

View File

@ -391,3 +391,280 @@
.mkh-alert-form--inner {
margin-top: 4px;
}
/* ============ 仓储中心 24 小时发货时效(左侧侧栏,2026-10-20)============ */
/* 左侧固定宽侧栏 + 右侧自适应主区。不用 float,避免表格宽度塌陷。
侧栏 sticky 贴顶:表格很长时侧栏跟着滚走会看不到告警数,业务要一直看得见。 */
.mkh-with-side {
display: flex;
align-items: flex-start;
gap: 14px;
}
.mkh-with-side__main {
flex: 1 1 auto;
min-width: 0;
}
.mkh-ship-side {
flex: none;
align-self: flex-start;
width: 208px;
/* 11 个仓 + 头部合计约 400px,给足高度;超出才滚动。
不加 max-height 会被 flex 拉伸/压缩,撑开后又不该无限长(表格很长时)。 */
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 12px 10px 14px;
border: 1px solid var(--sycm-border-color);
border-radius: 10px;
background: var(--sycm-bg-container);
box-sizing: border-box;
position: sticky;
top: 8px;
}
.mkh-ship-side--collapsed {
width: 44px;
padding: 12px 6px;
}
.mkh-ship-side__head {
margin-bottom: 10px;
}
.mkh-ship-side__title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: var(--sycm-text-strong);
}
.mkh-ship-side--collapsed .mkh-ship-side__title > span {
writing-mode: vertical-rl;
letter-spacing: 2px;
font-size: 12px;
}
.mkh-ship-side__collapse {
flex: none;
width: 18px;
height: 18px;
padding: 0;
border: 1px solid var(--sycm-border-color);
border-radius: 4px;
background: transparent;
color: var(--sycm-text-tertiary);
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.mkh-ship-side__collapse:hover {
color: var(--sycm-text-primary);
border-color: var(--sycm-text-tertiary);
}
.mkh-ship-side__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
margin-top: 6px;
font-size: 11px;
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__round {
padding: 0 5px;
border-radius: 3px;
background: rgba(22, 119, 255, 0.12);
color: #0958d9;
}
.mkh-ship-side__round--idle {
background: rgba(136, 135, 128, 0.14);
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__total {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--sycm-border-color);
}
.mkh-ship-side__total-num {
font-size: 22px;
font-weight: 700;
line-height: 1;
color: #cf1322;
font-variant-numeric: tabular-nums;
}
.mkh-ship-side__total-num[data-zero='true'] {
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__total-label {
font-size: 11px;
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__list {
margin: 0;
padding: 0;
list-style: none;
}
.mkh-ship-side__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
padding: 5px 7px;
margin-bottom: 2px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 12px;
text-align: left;
color: var(--sycm-text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.mkh-ship-side__item:hover {
background: var(--sycm-bg-subtle);
}
.mkh-ship-side__item.is-active {
border-color: rgba(22, 119, 255, 0.45);
background: rgba(22, 119, 255, 0.08);
}
.mkh-ship-side__item.is-empty .mkh-ship-side__name {
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__item.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.mkh-ship-side__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mkh-ship-side__badge {
flex: none;
min-width: 22px;
padding: 0 5px;
border-radius: 9px;
background: rgba(136, 135, 128, 0.12);
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-align: center;
color: var(--sycm-text-tertiary);
font-variant-numeric: tabular-nums;
}
.mkh-ship-side__badge.is-warn {
background: rgba(255, 77, 79, 0.16);
color: #cf1322;
}
.mkh-ship-side__badge--none {
font-weight: 400;
}
.mkh-ship-side__warn {
margin-top: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(250, 173, 20, 0.14);
font-size: 11px;
line-height: 1.6;
color: #ad4e00;
cursor: help;
}
.mkh-ship-side__hint {
margin-top: 10px;
font-size: 11px;
line-height: 1.6;
color: var(--sycm-text-tertiary);
}
.mkh-ship-side__loading {
margin-top: 8px;
font-size: 11px;
color: var(--sycm-text-tertiary);
}
/* 窄屏兜底(<1100px):此时右侧表格已经放不下,侧栏整体铺到上方。
* ⚠️ 1100~1500px 之间**保持左侧竖栏** —— 那是业务最常用的笔记本分辨率
* (1366×768 / 1440×900),侧栏必须还在左边,否则「列表左侧加一栏」的需求就不成立。
* 列表始终竖向排列,绝不横排(横排后 11 个仓挤成一行完全没法读)。 */
@media (max-width: 1100px) {
.mkh-with-side {
flex-direction: column;
}
.mkh-ship-side,
.mkh-ship-side--collapsed {
position: static;
width: 100%;
max-height: none;
}
.mkh-ship-side--collapsed .mkh-ship-side__title > span {
writing-mode: horizontal-tb;
}
}
/* 中窄屏(1100~1500px,含 1366×768 笔记本):侧栏仍在左侧,只是收窄 */
@media (max-width: 1500px) and (min-width: 1101px) {
.mkh-ship-side {
width: 176px;
}
.mkh-ship-side__name {
font-size: 12px;
}
}
/* 超时明细卡(点左侧某个仓才出现) */
.mkh-ship-detail {
margin-bottom: 12px;
}
.mkh-ship-detail__count {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--sycm-text-tertiary);
}
.mkh-ship-detail__clear {
padding: 0;
border: none;
background: transparent;
color: #0958d9;
font-size: 12px;
cursor: pointer;
}
.mkh-ship-detail__overdue {
color: #cf1322;
font-weight: 600;
font-variant-numeric: tabular-nums;
}

View File

@ -0,0 +1,288 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import {
exportPickingBinSalesAnalysis,
getPickingBinSalesAnalysis,
} from '../../api/pickingBinSalesAnalysis';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import {
PICKING_BIN_COLUMNS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_GRADE_COLORS,
PICKING_BIN_DESCRIPTION,
} from '../../constants/pickingBinSalesAnalysis';
import './style.css';
const { Title, Text } = Typography;
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const num = (value, digits = 2) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="picking-bin-th-tip">{children}</span>
</Tooltip>
);
const PickingBinSalesAnalysis = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
const [warehouse, setWarehouse] = useState('');
const [grades, setGrades] = useState([]);
const [keyword, setKeyword] = useState('');
const [exporting, setExporting] = useState(false);
const params = useMemo(() => ({
days,
...(warehouse ? { wmsCoId: warehouse } : {}),
}), [days, warehouse]);
const load = useCallback(async (options = {}) => {
setLoading(true);
setError('');
try {
const res = await getPickingBinSalesAnalysis({
...(options.params ?? params),
...(options.force ? { refresh: '1' } : {}),
});
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]);
const filteredRows = useMemo(() => {
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)
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
return true;
});
}, [items, grades, keyword]);
const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: 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) => {
if (col.key === 'gradeKey') {
const g = gradeMap.get(value);
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
}
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') return int(value);
return value || '—';
},
})), [gradeMap]);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const blob = await exportPickingBinSalesAnalysis({
...params,
...(grades.length > 0 ? { grade: grades.join(',') } : {}),
format: 'xlsx',
});
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
const fileName = `拣选库位动销分析-${dateKey}.xlsx`;
await downloadBlobAsFile(blob, fileName);
message.success(`已开始下载:${fileName}`);
} catch (exportError) {
message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败'));
} finally {
setExporting(false);
}
}, [params, grades, data?.latestBizDate]);
const toggleGrade = useCallback((key) => {
setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}, []);
const summary = data?.summary ?? [];
const total = data?.total ?? null;
return (
<SycmPage className="picking-bin-page">
<div className="picking-bin-header">
<div className="picking-bin-header__title">
<Title level={4} style={{ margin: 0 }}>拣选库位动销分析</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="picking-bin-header__help"
>
口径说明
</Button>
</div>
<div className="picking-bin-header__meta">
<span>统计基准日:{data?.latestBizDate || '—'}</span>
{data?.shipWindow ? (
<span>销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)</span>
) : null}
{data?.takenAt ? (
<span>仓位数据时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
) : null}
</div>
<Space wrap>
<Space size={4}>
<Text type="secondary">统计天数</Text>
<Select
value={days}
options={PICKING_BIN_WINDOW_OPTIONS}
onChange={setDays}
style={{ width: 110 }}
/>
</Space>
<Select
allowClear
value={warehouse || undefined}
options={(data?.warehouses ?? []).map((w) => ({ label: w.warehouse, value: w.wmsCoId }))}
onChange={(v) => setWarehouse(v || '')}
showSearch
optionFilterProp="label"
style={{ minWidth: 240 }}
placeholder="全部仓库"
/>
<Input.Search
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
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数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级),仅含拣选位。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
</Tooltip>
</Space>
</div>
<Alert
type="info"
showIcon
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Row gutter={[8, 8]} className="picking-bin-summary">
{summary.map((s) => (
<Col key={s.key} flex="1 1 150px">
<Card
size="small"
hoverable
onClick={() => toggleGrade(s.key)}
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
>
<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.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
</div>
</Card>
</Col>
))}
{total ? (
<Col flex="1 1 150px">
<Card size="small" className="picking-bin-card is-total">
<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.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
</div>
</Card>
</Col>
) : null}
</Row>
<Table
className="picking-bin-table"
columns={columns}
dataSource={filteredRows}
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
size="small"
bordered
pagination={{
pageSize: 50,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
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} 条)` : ''};
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={760}
>
<div className="picking-bin-help">
{PICKING_BIN_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default PickingBinSalesAnalysis;

View File

@ -0,0 +1,95 @@
.picking-bin-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.picking-bin-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.picking-bin-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.picking-bin-header__help {
font-size: 13px;
}
.picking-bin-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.picking-bin-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.picking-bin-summary {
margin-bottom: 12px;
}
.picking-bin-card .ant-statistic-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picking-bin-card.is-active {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
.picking-bin-card.is-total {
background: rgba(22, 119, 255, 0.06);
}
.picking-bin-card__meta {
margin-top: 4px;
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 {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.picking-bin-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
vertical-align: middle;
}
.picking-bin-footer {
font-size: 12px;
}
.picking-bin-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.picking-bin-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import { SycmPage } from '../../components/Common';
import StockWarning from './StockWarning';
import { resolveWarehouseConfig } from './warehouses';
import './monitor.css';
/**
* 客退数据监控(按仓库出数)
*
* 同一个页面支撑 5 张看板:崔桥工厂仓 / 苏北 / 睢宁 / 山东 / 河南。
* 结构完全一致,差异只有「取哪个仓」——由路由传入的 warehouseKey 决定,
* 页面本身不拼任何仓库相关的 SQL 或口径。
*
* 页面已收敛为单一「退货库存预警」视图:
* 原先并列的「退货入库明细 / 超期清单 / 按商品编码汇总 / 差异清单」四个页签已下线,
* 因此这里不再保留页签容器,也不再拉取核销看板数据(对应接口保留在服务端,未删除)。
*/
const Monitor = ({ warehouseKey }) => {
const warehouse = resolveWarehouseConfig(warehouseKey);
return (
<SycmPage className="rim-page">
<div className="rim-hero">
<div>
<h1 className="rim-hero__title">{warehouse.title}</h1>
<p className="rim-hero__desc">
数据取自聚水潭「箱及仓位库存 · {warehouse.warehouseName} · {warehouse.returnBinName}」:
在库天数 = 今天 − 创建时间,超过预警天数标红。
创建时间、件数口径与退厂判定说明见页面底部「数据来源与口径说明」。
</p>
{warehouse.hasReturnStock && warehouse.returnSlot !== 'customize_qty_1' ? (
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
注:该仓位在业务库里占用的是 <b>{warehouse.returnSlot}</b>
(崔桥工厂仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。
</p>
) : null}
{!warehouse.hasReturnStock ? (
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
提示:该仓位当前在业务库副本里没有客退在库数据(三个自定义库存位全为 0),表格会是空的;
等聚水潭侧有客退库存同步进来后会自动出现内容,也可以用「导入」上传聚水潭导出的文件。
</p>
) : null}
</div>
</div>
<StockWarning warehouse={warehouse.key} />
</SycmPage>
);
};
export default Monitor;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,485 @@
import React, { useMemo } from 'react';
import { Empty, InputNumber, Tooltip } from 'antd';
import ReactECharts from 'echarts-for-react';
import { sycmEchartsBase } from '../../design-system/chartTheme';
/**
* 客退数据监控 · 可视化分析(折叠区内容)
*
* 只负责「看」:单价预警阈值 + 可点击的单价预警汇总条 + 4 张分布图。
* 数据全部由父组件(StockWarning.jsx)传进来,这里不做任何请求。
*
* 页面的指标卡与「退厂要求」提示条已上移到父组件的主视图里,
* 这里保留的都是在默认视图下折叠收起的内容,保证主视图足够干净。
*/
const num = (value) => Number(value || 0).toLocaleString('zh-CN');
const money = (value) => Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** 图表轴上的金额压缩显示:过万折成「万」,避免坐标轴被长数字撑爆 */
const compactMoney = (value) => {
const parsed = Number(value || 0);
if (Math.abs(parsed) >= 10000) return `${(parsed / 10000).toFixed(1)}万`;
return parsed.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
// 单价预警四挡(与后端 returnStockWarningService.PRICE_WARNING_LABELS 保持一致)
const PRICE_ORDER = ['high', 'focus', 'normal', 'unknown'];
const PRICE_TONES = {
high: { label: '重点关注', short: '重点关注', color: '#ff4d4f', tone: 'red', dot: '#ff4d4f' },
focus: { label: '关注', short: '关注', color: '#ec6c00', tone: 'orange', dot: '#ec6c00' },
normal: { label: '正常', short: '正常', color: '#1bb574', tone: 'green', dot: '#1bb574' },
unknown: { label: '无单价', short: '无单价', color: '#94a3b8', tone: 'grey', dot: '#94a3b8' },
};
// 图表基座:pie 不需要 grid / xAxis / yAxis,单独取一份干净的基础配置
const pieBase = {
color: sycmEchartsBase.color,
textStyle: sycmEchartsBase.textStyle,
tooltip: sycmEchartsBase.tooltip,
legend: sycmEchartsBase.legend,
};
const chartHeight = 236;
/** 库龄分档:以预警线为基准逐级放大,阈值改了分档标签跟着变,不会出现空档 */
const buildAgeTiers = (focusDays, alertDays) => {
const safeAlert = Math.max(alertDays, focusDays + 1);
return [
{ label: `0 ~ ${focusDays} 天`, from: 0, to: focusDays, color: '#1bb574', hint: '库龄正常' },
{ label: `${focusDays + 1} ~ ${safeAlert} 天`, from: focusDays + 1, to: safeAlert, color: '#ec6c00', hint: '库龄关注' },
{ label: `${safeAlert + 1} ~ ${safeAlert * 2} 天`, from: safeAlert + 1, to: safeAlert * 2, color: '#ff7a45', hint: '红色预警' },
{ label: `${safeAlert * 2 + 1} ~ ${safeAlert * 4} 天`, from: safeAlert * 2 + 1, to: safeAlert * 4, color: '#ff4d4f', hint: '红色预警' },
{ label: `> ${safeAlert * 4} 天`, from: safeAlert * 4 + 1, to: Infinity, color: '#a8071a', hint: '红色预警·严重' },
];
};
/**
* 指标卡(父组件主视图也用,因此具名导出)
* 白底 + 左侧色条 + 大数字,和商品明细页的卡片同一种视觉语言
*/
export const MetricCard = ({ tone, label, value, unit, foot, tip }) => (
<div className="rim-metric" data-tone={tone}>
<div className="rim-metric__head">
<span>{label}</span>
</div>
<div className="rim-metric__value">
{value}
{unit ? <span className="rim-metric__unit">{unit}</span> : null}
</div>
<div className="rim-metric__foot">
{tip ? <Tooltip title={tip}><span>{foot}</span></Tooltip> : foot}
</div>
</div>
);
const StockWarningDashboard = ({
rows = [],
thresholds = {},
priceThresholds = {},
onPriceFocusChange,
onPriceHighChange,
priceLevel = 'all',
onPriceLevelToggle,
returnLimitDays = 7,
onReturnLimitChange,
loading = false,
}) => {
const focusDays = Number(thresholds.focusDays || 0);
const alertDays = Number(thresholds.alertDays || 0);
const priceFocusMin = Number(priceThresholds.priceFocusMin || 0);
const priceHighMin = Number(priceThresholds.priceHighMin || 0);
const returnLimit = Number(returnLimitDays || 0);
// ── 库龄分档统计 ───────────────────────────────────────────
const ageStats = useMemo(() => {
const tiers = buildAgeTiers(focusDays, alertDays).map((tier) => ({
...tier, codes: 0, qty: 0, amount: 0,
}));
let unknownCodes = 0;
(rows || []).forEach((row) => {
const days = row.stockDays;
if (days === null || days === undefined) {
unknownCodes += 1;
return;
}
const tier = tiers.find((item) => days >= item.from && days <= item.to);
if (!tier) return;
tier.codes += 1;
tier.qty += Number(row.inboundQty || 0);
tier.amount += Number(row.amount || 0);
});
return { tiers, unknownCodes };
}, [rows, focusDays, alertDays]);
// ── 单价预警统计 ───────────────────────────────────────────
const priceStats = useMemo(() => {
const stats = {
high: { codes: 0, qty: 0, amount: 0 },
focus: { codes: 0, qty: 0, amount: 0 },
normal: { codes: 0, qty: 0, amount: 0 },
unknown: { codes: 0, qty: 0, amount: 0 },
};
(rows || []).forEach((row) => {
const level = PRICE_TONES[row.unitPriceLevel] ? row.unitPriceLevel : 'unknown';
stats[level].codes += 1;
stats[level].qty += Number(row.inboundQty || 0);
stats[level].amount += Number(row.amount || 0);
});
return stats;
}, [rows]);
// ── 供应商 / 编码 按库存金额 TOP10 ─────────────────────────
const topSuppliers = useMemo(() => {
const map = new Map();
(rows || []).forEach((row) => {
const name = String(row.supplier || '').trim() || '未填写供应商';
const current = map.get(name) || { codes: 0, qty: 0, amount: 0 };
current.codes += 1;
current.qty += Number(row.inboundQty || 0);
current.amount += Number(row.amount || 0);
map.set(name, current);
});
return Array.from(map, ([name, value]) => ({ name, ...value }))
.filter((item) => item.amount > 0)
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
}, [rows]);
const topCodes = useMemo(() => (rows || [])
.filter((row) => Number(row.amount || 0) > 0)
.slice()
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
.slice(0, 10), [rows]);
// ── 图表配置 ───────────────────────────────────────────────
const ageChartOption = useMemo(() => ({
...sycmEchartsBase,
grid: { ...sycmEchartsBase.grid, top: 44, right: 62, bottom: 30, left: 52 },
legend: { ...sycmEchartsBase.legend, data: ['编码数', '库存金额'] },
xAxis: {
...sycmEchartsBase.xAxis,
type: 'category',
data: ageStats.tiers.map((tier) => tier.label),
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, interval: 0, fontSize: 11 },
},
yAxis: [
{ ...sycmEchartsBase.yAxis, type: 'value', name: '编码数', nameTextStyle: { color: '#8c8c8c', fontSize: 11 } },
{
...sycmEchartsBase.yAxis,
type: 'value',
name: '库存金额',
nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
axisLabel: { ...sycmEchartsBase.yAxis.axisLabel, formatter: compactMoney },
splitLine: { show: false },
},
],
tooltip: {
...sycmEchartsBase.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
if (!list.length) return '';
const index = list[0].dataIndex;
const tier = ageStats.tiers[index];
if (!tier) return '';
const rowsHtml = list.map((item) => {
const isMoney = item.seriesName === '库存金额';
const text = isMoney ? `¥${money(item.value)}` : `${num(item.value)} 个编码`;
return `<div style="display:flex;align-items:center;gap:6px;line-height:20px">${item.marker}<span>${item.seriesName}</span><b style="margin-left:auto">${text}</b></div>`;
}).join('');
return `<div style="font-weight:600;margin-bottom:4px">${tier.label}(${tier.hint})</div>${rowsHtml}
<div style="margin-top:4px;opacity:.78">在库 ${num(tier.qty)} 件</div>`;
},
},
series: [
{
name: '编码数',
type: 'bar',
barMaxWidth: 34,
data: ageStats.tiers.map((tier) => ({
value: tier.codes,
itemStyle: { color: tier.color, borderRadius: [4, 4, 0, 0] },
})),
},
{
name: '库存金额',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#5980ff' },
itemStyle: { color: '#5980ff' },
data: ageStats.tiers.map((tier) => Math.round(tier.amount * 100) / 100),
},
],
}), [ageStats]);
const priceChartOption = useMemo(() => {
const data = PRICE_ORDER
.map((key) => ({
name: PRICE_TONES[key].label,
value: priceStats[key].codes,
itemStyle: { color: PRICE_TONES[key].color },
}))
.filter((item) => item.value > 0);
const total = data.reduce((acc, item) => acc + item.value, 0);
return {
...pieBase,
tooltip: {
...pieBase.tooltip,
trigger: 'item',
formatter: (param) => `<div style="font-weight:600;margin-bottom:4px">${param.marker}${param.name}</div>
<div>${num(param.value)} 个编码 · 占比 ${param.percent}%</div>`,
},
legend: {
...pieBase.legend,
orient: 'vertical',
right: 4,
top: 'middle',
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
},
title: {
text: String(total),
subtext: '个编码',
left: '33%',
top: '38%',
textAlign: 'center',
textStyle: { fontSize: 24, fontWeight: 600, color: '#262626' },
subtextStyle: { fontSize: 12, color: '#8c8c8c' },
},
series: [{
type: 'pie',
radius: ['56%', '76%'],
center: ['35%', '50%'],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
emphasis: { scale: true, scaleSize: 4 },
data,
}],
};
}, [priceStats]);
const supplierChartOption = useMemo(() => {
const source = topSuppliers.slice().reverse();
return {
...sycmEchartsBase,
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
tooltip: {
...sycmEchartsBase.tooltip,
trigger: 'item',
formatter: (param) => {
const item = source[param.dataIndex];
if (!item) return '';
return `<div style="font-weight:600;margin-bottom:4px">${item.name}</div>
<div>库存金额 <b>¥${money(item.amount)}</b></div>
<div style="opacity:.78">${num(item.codes)} 个编码 · ${num(item.qty)} 件</div>`;
},
},
xAxis: {
...sycmEchartsBase.xAxis,
type: 'value',
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
},
yAxis: {
...sycmEchartsBase.yAxis,
type: 'category',
data: source.map((item) => item.name),
splitLine: { show: false },
axisLabel: { color: '#595959', fontSize: 12, width: 130, overflow: 'truncate' },
},
series: [{
type: 'bar',
barMaxWidth: 14,
itemStyle: { color: '#5980ff', borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
color: '#595959',
fontSize: 11,
formatter: (param) => `¥${compactMoney(param.value)}`,
},
data: source.map((item) => Math.round(item.amount * 100) / 100),
}],
};
}, [topSuppliers]);
const codeChartOption = useMemo(() => {
const source = topCodes.slice().reverse();
return {
...sycmEchartsBase,
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
tooltip: {
...sycmEchartsBase.tooltip,
trigger: 'item',
formatter: (param) => {
const row = source[param.dataIndex];
if (!row) return '';
const tone = PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown;
return `<div style="font-weight:600;margin-bottom:4px">${row.productCode}</div>
<div>库存金额 <b>¥${money(row.amount)}</b></div>
<div style="opacity:.78">单价 ¥${money(row.unitPrice)} · ${tone.label} · ${num(row.inboundQty)} 件</div>
<div style="opacity:.78">供应商 ${row.supplier || '—'}</div>`;
},
},
xAxis: {
...sycmEchartsBase.xAxis,
type: 'value',
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
},
yAxis: {
...sycmEchartsBase.yAxis,
type: 'category',
data: source.map((row) => row.productCode),
splitLine: { show: false },
axisLabel: { color: '#595959', fontSize: 12, width: 140, overflow: 'truncate' },
},
series: [{
type: 'bar',
barMaxWidth: 14,
data: source.map((row) => ({
value: Math.round(Number(row.amount || 0) * 100) / 100,
itemStyle: {
color: (PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown).color,
borderRadius: [0, 4, 4, 0],
},
})),
label: {
show: true,
position: 'right',
color: '#595959',
fontSize: 11,
formatter: (param) => `¥${compactMoney(param.value)}`,
},
}],
};
}, [topCodes]);
const priceBar = PRICE_ORDER.map((key) => ({
key,
tone: PRICE_TONES[key].tone,
label: PRICE_TONES[key].label,
hint: key === 'high'
? `单价 > ${priceHighMin} 元`
: key === 'focus'
? `单价 ${priceFocusMin} ~ ${priceHighMin} 元`
: key === 'normal'
? `单价 < ${priceFocusMin} 元`
: '商品资料里没有成本价 / 采购价',
codes: priceStats[key].codes,
qty: priceStats[key].qty,
amount: priceStats[key].amount,
}));
const hasRows = (rows || []).length > 0;
return (
<>
<div className="rsw-price">
<div className="rsw-price__thresholds">
<span className="rim-toolbar__label">关注 ≥</span>
<InputNumber
min={0}
value={priceFocusMin}
onChange={onPriceFocusChange}
style={{ width: 100 }}
addonAfter="元"
/>
<span className="rim-toolbar__label" style={{ marginLeft: 6 }}>重点关注 &gt;</span>
<InputNumber
min={0}
value={priceHighMin}
onChange={onPriceHighChange}
style={{ width: 100 }}
addonAfter="元"
/>
<Tooltip title={`单价高于重点关注线的编码,要求入库后这么多天内完成库存退厂;在库天数 > ${returnLimit} 天即为「已超期」`}>
<span className="rim-toolbar__label rsw-price__limit-label">退厂时限</span>
</Tooltip>
<InputNumber
min={0}
value={returnLimit}
onChange={onReturnLimitChange}
style={{ width: 96 }}
addonAfter="天"
/>
</div>
<div className="rsw-warn">
{priceBar.map((item) => (
<Tooltip key={item.key} title={`${item.hint}|点击${priceLevel === item.key ? '取消' : '只'}看这类编码`}>
<div
role="button"
tabIndex={0}
data-tone={item.tone}
className={`rsw-warn__item${priceLevel === item.key ? ' is-active' : ''}`}
onClick={() => onPriceLevelToggle && onPriceLevelToggle(item.key)}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (onPriceLevelToggle) onPriceLevelToggle(item.key);
}}
>
<span className="rsw-warn__label">{item.label}</span>
<span className="rsw-warn__value">{num(item.codes)}<small>个编码</small></span>
<span className="rsw-warn__foot">{num(item.qty)} 件 · ¥{money(item.amount)}</span>
</div>
</Tooltip>
))}
</div>
</div>
{hasRows ? (
<div className="rsw-charts">
<section className="rsw-chart">
<h3 className="rsw-chart__title">
库龄分布
<span className="rsw-chart__hint">分档跟随上方「关注 / 预警」天数自动调整</span>
</h3>
<ReactECharts option={ageChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
</section>
<section className="rsw-chart">
<h3 className="rsw-chart__title">
单价预警分布
<span className="rsw-chart__hint">按编码数占比</span>
</h3>
<ReactECharts option={priceChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
</section>
<section className="rsw-chart">
<h3 className="rsw-chart__title">
供应商库存金额 TOP10
<span className="rsw-chart__hint">压资金最多的供应商</span>
</h3>
{topSuppliers.length ? (
<ReactECharts option={supplierChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
) : <Empty description="没有可统计的供应商金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</section>
<section className="rsw-chart">
<h3 className="rsw-chart__title">
商品编码库存金额 TOP10
<span className="rsw-chart__hint">柱子颜色 = 该编码的单价预警等级</span>
</h3>
{topCodes.length ? (
<ReactECharts option={codeChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
) : <Empty description="没有可统计的编码金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</section>
</div>
) : (
<Empty
description={loading ? '正在加载数据…' : '当前筛选条件下没有数据'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</>
);
};
export default StockWarningDashboard;

View File

@ -0,0 +1,730 @@
/* 横林二仓客退数据监控 —— 页头 / 工具栏 / 指标卡 / 库龄分布 / 数据区 */
.rim-page {
--rim-gap: 12px;
--rim-card-border: var(--sycm-border-card, #e6e6e6);
--rim-card-radius: var(--sycm-radius-xl, 12px);
--rim-card-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
--rim-blue: #2062e6;
--rim-green: #1bb574;
--rim-orange: #ec6c00;
--rim-red: #ff4d4f;
--rim-cyan: #0ea5e9;
--rim-violet: #7c5cf0;
}
/* ── 页头(坐在页面渐变背景上) ───────────────────────────── */
.rim-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: var(--rim-gap);
}
.rim-hero__title {
margin: 0;
color: var(--sycm-text-strong, #262626);
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.rim-hero__desc {
margin: 4px 0 0;
max-width: 900px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.6;
}
.rim-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.rim-hero__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.rim-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 10px;
border: 1px solid var(--rim-card-border);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.82);
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
white-space: nowrap;
}
.rim-chip b {
color: var(--sycm-text-strong, #262626);
font-weight: 600;
}
.rim-chip.is-ok {
border-color: var(--sycm-color-success-border, #c8f3dd);
background: var(--sycm-color-success-bg, #e8fff4);
}
.rim-chip.is-warn {
border-color: var(--sycm-color-warning-border, #ffe3b8);
background: var(--sycm-color-warning-bg, #fff8e6);
}
.rim-chip.is-danger {
border-color: var(--sycm-color-danger-border, #ffd0d0);
background: var(--sycm-color-danger-bg, #fff1f0);
}
/* ── 退货库存预警:库龄预警汇总条 ─────────────────────────── */
.rsw-warn {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.rsw-warn__item {
--rsw-tone: #595959;
--rsw-tone-soft: rgba(0, 0, 0, 0.02);
flex: 1 1 190px;
min-width: 180px;
padding: 8px 12px;
border: 1px solid var(--sycm-border-card, #e6e6e6);
border-left: 4px solid var(--rsw-tone);
border-radius: 10px;
background: var(--rsw-tone-soft);
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.rsw-warn__item:hover {
box-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
}
.rsw-warn__item.is-active {
border-color: var(--rsw-tone);
box-shadow: 0 0 0 2px var(--rsw-tone-soft);
}
.rsw-warn__item[data-tone='red'] {
--rsw-tone: #ff4d4f;
--rsw-tone-soft: rgba(255, 77, 79, 0.08);
}
.rsw-warn__item[data-tone='orange'] {
--rsw-tone: #ec6c00;
--rsw-tone-soft: rgba(236, 108, 0, 0.08);
}
.rsw-warn__item[data-tone='green'] {
--rsw-tone: #1bb574;
--rsw-tone-soft: rgba(27, 181, 116, 0.08);
}
.rsw-warn__item[data-tone='grey'] {
--rsw-tone: #94a3b8;
--rsw-tone-soft: rgba(148, 163, 184, 0.1);
}
.rsw-warn__label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--sycm-text-secondary, #595959);
}
.rsw-warn__value {
margin-top: 2px;
font-size: 20px;
font-weight: 600;
line-height: 1.2;
color: var(--rsw-tone);
}
.rsw-warn__value small {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: var(--sycm-text-secondary, #8c8c8c);
}
.rsw-warn__foot {
font-size: 12px;
color: var(--sycm-text-tertiary, #8c8c8c);
}
@media (max-width: 900px) {
.rsw-warn__item {
flex-basis: 100%;
}
}
/* ── 状态页签(替代原来的三色预警大卡) ───────────────────── */
.rim-tabs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 2px 12px;
margin-bottom: var(--rim-gap);
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
}
.rim-tabs__item {
--rim-tab-tone: #595959;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 12px 8px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--sycm-text-secondary, #595959);
font-size: 13px;
line-height: 20px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.rim-tabs__item[data-tone='red'] { --rim-tab-tone: var(--rim-red); }
.rim-tabs__item[data-tone='orange'] { --rim-tab-tone: var(--rim-orange); }
.rim-tabs__item[data-tone='green'] { --rim-tab-tone: var(--rim-green); }
.rim-tabs__item[data-tone='grey'] { --rim-tab-tone: #94a3b8; }
.rim-tabs__item[data-tone='blue'] { --rim-tab-tone: var(--rim-blue); }
.rim-tabs__item:hover {
color: var(--sycm-text-strong, #262626);
}
.rim-tabs__item.is-active {
border-bottom-color: var(--rim-tab-tone);
color: var(--rim-tab-tone);
font-weight: 600;
}
.rim-tabs__dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--rim-tab-tone);
}
.rim-tabs__count {
color: var(--rim-tab-tone);
font-size: 15px;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rim-tabs__qty {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
/* ── 已选筛选回显 ─────────────────────────────────────────── */
.rim-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: -4px 0 10px;
}
.rim-filters .ant-tag {
margin: 0;
}
/* ── 折叠区(可视化分析 / 口径说明) ──────────────────────── */
.rim-fold {
margin-bottom: var(--rim-gap);
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
overflow: hidden;
}
.rim-fold__head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 11px 16px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.rim-fold__head:hover {
background: var(--sycm-bg-subtle, #f5f7fa);
}
.rim-fold__title {
flex: none;
color: var(--sycm-text-strong, #262626);
font-size: 14px;
font-weight: 600;
}
.rim-fold__hint {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.rim-fold__icon {
flex: none;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
}
.rim-fold__body {
padding: 2px 16px 14px;
}
.rim-fold__list {
margin: 0;
padding-left: 18px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.75;
}
/* ── 通用白卡 ─────────────────────────────────────────────── */
.rim-panel {
padding: 12px 16px;
margin-bottom: var(--rim-gap);
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
}
.rim-panel__title {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
color: var(--sycm-text-strong, #262626);
font-size: 14px;
font-weight: 600;
}
.rim-panel__hint {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
/* 表格卡标题右侧的一行元信息(仓库 / 统计日 / 库存更新时间 / 最长在库 …) */
.rim-panel__meta {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
/* ── 筛选工具栏 ───────────────────────────────────────────── */
.rim-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px 16px;
margin-bottom: var(--rim-gap);
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
}
.rim-toolbar__field {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rim-toolbar__label {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
white-space: nowrap;
}
.rim-toolbar__spacer {
flex: 1 1 auto;
}
.rim-toolbar__group {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 次级筛选行(「更多筛选」展开后出现):浅底,不抢视觉 */
.rim-toolbar--sub {
margin-top: -6px;
background: var(--sycm-bg-subtle, #fafbfc);
box-shadow: none;
}
.rim-toolbar__more {
color: var(--sycm-text-secondary, #595959);
}
.rim-toolbar__more.is-open {
color: var(--rim-blue);
}
/* ── 指标卡 ───────────────────────────────────────────────── */
.rim-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--rim-gap);
margin-bottom: var(--rim-gap);
}
.rim-metric {
position: relative;
overflow: hidden;
padding: 12px 14px 11px;
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
}
.rim-metric::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--rim-tone, var(--rim-blue));
}
.rim-metric__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
}
.rim-metric__icon {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border-radius: 6px;
background: var(--rim-tone-soft, rgba(32, 98, 230, 0.08));
color: var(--rim-tone, var(--rim-blue));
font-size: 12px;
}
.rim-metric__value {
margin-top: 6px;
color: var(--sycm-text-strong, #262626);
font-size: 24px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.15;
}
.rim-metric__unit {
margin-left: 2px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
.rim-metric__foot {
margin-top: 4px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
}
.rim-metric[data-tone='blue'] { --rim-tone: var(--rim-blue); --rim-tone-soft: rgba(32, 98, 230, 0.1); }
.rim-metric[data-tone='cyan'] { --rim-tone: var(--rim-cyan); --rim-tone-soft: rgba(14, 165, 233, 0.1); }
.rim-metric[data-tone='green'] { --rim-tone: var(--rim-green); --rim-tone-soft: rgba(27, 181, 116, 0.1); }
.rim-metric[data-tone='orange'] { --rim-tone: var(--rim-orange); --rim-tone-soft: rgba(236, 108, 0, 0.1); }
.rim-metric[data-tone='red'] { --rim-tone: var(--rim-red); --rim-tone-soft: rgba(255, 77, 79, 0.1); }
.rim-metric[data-tone='violet'] { --rim-tone: var(--rim-violet); --rim-tone-soft: rgba(124, 92, 240, 0.1); }
/* ── 库龄分布 ─────────────────────────────────────────────── */
.rim-age {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--rim-gap);
}
.rim-age__item {
padding: 12px 14px;
border: 1px solid var(--sycm-border-light, #f0f0f0);
border-radius: var(--sycm-radius-lg, 8px);
background: var(--sycm-bg-subtle, #f5f7fa);
}
.rim-age__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
}
.rim-age__dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
}
.rim-age__value {
margin-top: 6px;
color: var(--sycm-text-strong, #262626);
font-size: 20px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.2;
}
.rim-age__value small {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
.rim-age__track {
height: 6px;
margin: 8px 0 6px;
border-radius: 9999px;
background: #e9edf5;
overflow: hidden;
}
.rim-age__fill {
display: block;
min-width: 2px;
height: 100%;
border-radius: 9999px;
}
.rim-age__foot {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
}
/* 退货库存预警:总金额(库存金额)单元格,跟随金额口径强调 */
.rsw-amount {
color: var(--sycm-color-danger, #cf1322);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ── 退货库存预警:单价预警(阈值 + 可点击汇总条) ─────────── */
.rsw-price__thresholds {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.rsw-price .rsw-warn {
margin-bottom: 14px;
}
/* ── 退货库存预警:重点关注商品「退厂要求」提示条 ─────────── */
.rsw-policy {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #ffe0a3;
border-left: 3px solid #faad14;
border-radius: 8px;
background: #fffbe6;
}
/* 有超期编码时整条转红,提醒优先处理 */
.rsw-policy.is-overdue {
border-color: #ffccc7;
border-left-color: #ff4d4f;
background: #fff1f0;
}
.rsw-policy__icon {
flex: none;
font-size: 16px;
line-height: 20px;
color: #d48806;
}
.rsw-policy.is-overdue .rsw-policy__icon {
color: #ff4d4f;
}
.rsw-policy__body {
flex: 1 1 auto;
min-width: 0;
}
.rsw-policy__title {
font-size: 13px;
font-weight: 600;
line-height: 20px;
color: #874d00;
}
.rsw-policy.is-overdue .rsw-policy__title {
color: #a8071a;
}
.rsw-policy__days {
margin: 0 3px;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.rsw-policy__desc {
margin-top: 2px;
font-size: 12px;
line-height: 18px;
color: #8c6b2f;
}
.rsw-policy.is-overdue .rsw-policy__desc {
color: #a15c5c;
}
.rsw-policy__desc b {
font-variant-numeric: tabular-nums;
}
.rsw-policy__danger {
color: #cf1322;
}
.rsw-price__limit-label {
margin-left: 6px;
}
/* 表格「退厂时限」列:超期标签加粗,扫一眼就能找到 */
.rsw-deadline-tag {
font-weight: 600;
}
/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */
.rsw-exit-date-empty {
color: var(--sycm-text-quaternary, #bfbfbf);
}
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
.rsw-charts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--rim-gap, 12px);
}
.rsw-chart {
padding: 10px 12px 4px;
border: 1px solid var(--sycm-border-light, #f0f0f0);
border-radius: var(--sycm-radius-lg, 8px);
background: var(--sycm-bg-subtle, #f5f7fa);
}
.rsw-chart__title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 4px;
color: var(--sycm-text-strong, #262626);
font-size: 13px;
font-weight: 600;
}
.rsw-chart__hint {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
@media (max-width: 1200px) {
.rsw-charts {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── 数据区 ───────────────────────────────────────────────── */
.rim-data {
padding: 8px 16px 16px;
border: 1px solid var(--rim-card-border);
border-radius: var(--rim-card-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--rim-card-shadow);
}
.rim-data .ant-tabs-nav {
margin-bottom: 12px;
}
.rim-data .ant-table {
font-variant-numeric: tabular-nums;
}
.rim-data__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 12px;
}
/* ── 响应式 ───────────────────────────────────────────────── */
@media (max-width: 1440px) {
.rim-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.rim-metrics,
.rim-age {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,69 @@
/**
* 客退数据监控 —— 前端仓库配置
* --------------------------------------------------------------------------
* 与后端 returnStockWarehouseRegistry.js 一一对应。后端才是取数的唯一来源,
* 这里只负责「路由 → 仓库标识 → 页面标题/描述」的映射,不改动任何口径。
*
* 各仓的「客退上架仓」在聚水潭里的仓位名不同,占用的自定义库存位也不同
* (崔桥/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来,
* 否则看数据的人会对不上聚水潭。仓位名以用户口径为准(2026-09-25 起首仓改为崔桥工厂仓)。
*/
export const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
export const WAREHOUSES = [
{
key: 'cuiqiao',
title: '崔桥工厂仓客退数据监控',
shortName: '崔桥工厂仓',
warehouseName: '简尚-崔桥工厂仓',
returnBinName: '崔桥工厂客退临时上架仓',
returnSlot: 'customize_qty_1',
// 2026-09-25 实测:副本里该仓客退位只有 2 编码 / 2 件(退货单有量,库存没同步)
hasReturnStock: true,
},
{
key: 'subei',
title: '苏北客退数据监控',
shortName: '苏北',
warehouseName: '简尚-新苏北仓',
returnBinName: '新苏北客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'suining',
title: '睢宁客退数据监控',
shortName: '睢宁',
warehouseName: '简尚-睢宁仓',
returnBinName: '睢宁客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'shandong',
title: '山东客退数据监控',
shortName: '山东',
warehouseName: '简尚-山东青岗集仓',
returnBinName: '青岗集客退上架仓',
returnSlot: 'customize_qty_2',
hasReturnStock: true,
},
{
key: 'henan',
title: '河南客退数据监控',
shortName: '河南',
warehouseName: '简尚-河南洛宁仓',
returnBinName: '河南洛宁客退上架仓',
// 三个自定义库存位全为 0,该仓位在副本里取不到数据
returnSlot: 'customize_qty_1',
hasReturnStock: false,
},
];
const WAREHOUSE_MAP = new Map(WAREHOUSES.map((item) => [item.key, item]));
/** 取仓库配置,非法 key 回落到默认仓(崔桥工厂仓) */
export const resolveWarehouseConfig = (key) => (
WAREHOUSE_MAP.get(String(key || '').trim()) || WAREHOUSE_MAP.get(DEFAULT_WAREHOUSE_KEY)
);

View File

@ -0,0 +1,267 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Modal, Select, Space, Spin, Table, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { exportStockSalesZeroDetail, getStockSalesAnalysis } from '../../api/stockSalesAnalysis';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import {
STOCK_SALES_COLUMNS,
STOCK_SALES_WINDOW_OPTIONS,
STOCK_SALES_DEFAULT_DAYS,
STOCK_SALES_DESCRIPTION,
} from '../../constants/stockSalesAnalysis';
import './style.css';
const { Title, Text } = Typography;
const ALL_WAREHOUSES = '__all__';
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const percent = (value) => {
if (value === null || value === undefined || value === '') return '—';
return `${(Number(value) * 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
};
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="stock-sales-th-tip">{children}</span>
</Tooltip>
);
const StockSalesAnalysis = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [days, setDays] = useState(STOCK_SALES_DEFAULT_DAYS);
const [warehouse, setWarehouse] = useState(ALL_WAREHOUSES);
const [exporting, setExporting] = useState(false);
const load = useCallback(async (options = {}) => {
setLoading(true);
setError('');
try {
const res = await getStockSalesAnalysis({
days: options.days ?? STOCK_SALES_DEFAULT_DAYS,
...(options.force ? { refresh: '1' } : {}),
});
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '库存动销分析数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load({ days: STOCK_SALES_DEFAULT_DAYS }); }, [load]);
const handleDaysChange = useCallback((value) => {
setDays(value);
load({ days: value });
}, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const grand = data?.grand ?? null;
/** 导出「销售=0」档 SKU 明细:跟随当前统计天数与仓库筛选 */
const handleExportZero = useCallback(async () => {
setExporting(true);
try {
const wmsCoId = warehouse === ALL_WAREHOUSES
? undefined
: items.find((it) => it.warehouse === warehouse)?.wmsCoId;
const params = { days, format: 'xlsx', ...(wmsCoId != null ? { wmsCoId: String(wmsCoId) } : {}) };
const blob = await exportStockSalesZeroDetail(params);
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
const scope = warehouse === ALL_WAREHOUSES ? '' : `-${warehouse}`;
const fileName = `库存动销分析-销售0明细${scope}-${dateKey}.xlsx`;
downloadBlobAsFile(blob, fileName);
message.success(`已开始下载:${fileName}`);
} catch (exportError) {
message.error(await readBlobErrorMessage(exportError, '导出「销售=0」明细失败'));
} finally {
setExporting(false);
}
}, [days, warehouse, items, data?.latestBizDate]);
const warehouseOptions = useMemo(() => ([
{ label: '全部仓库', value: ALL_WAREHOUSES },
...items.map((it) => ({ label: it.warehouse, value: it.warehouse })),
]), [items]);
const visibleItems = useMemo(() => (
warehouse === ALL_WAREHOUSES ? items : items.filter((it) => it.warehouse === warehouse)
), [items, warehouse]);
/** 表格行:每个仓库 6 个等级 + 1 行汇总;仓库列跨行合并 */
const tableRows = useMemo(() => {
const rows = [];
const pushGroup = (warehouseName, buckets, total, extra = {}) => {
// 注意:bucket / total 自带 key(等级 key),必须放在展开之后覆盖,否则跨仓行 key 会重复
buckets.forEach((bucket, index) => {
rows.push({
...bucket,
...extra,
key: `${warehouseName}::${bucket.key}`,
warehouse: warehouseName,
warehouseRowSpan: index === 0 ? buckets.length + 1 : 0,
grade: bucket.label,
});
});
rows.push({
...total,
...extra,
key: `${warehouseName}::__total__`,
warehouse: warehouseName,
warehouseRowSpan: 0,
grade: total.label,
isSubtotal: true,
});
};
visibleItems.forEach((item) => pushGroup(item.warehouse, item.buckets, item.total));
if (grand && (warehouse === ALL_WAREHOUSES || visibleItems.length === 0)) {
pushGroup(grand.warehouse, grand.buckets, grand.total, { isGrand: true });
}
return rows;
}, [visibleItems, grand, warehouse]);
const columns = useMemo(() => STOCK_SALES_COLUMNS.map((col) => ({
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: col.key,
align: ['warehouse', 'grade'].includes(col.key) ? 'left' : 'right',
width: col.key === 'warehouse' ? 260 : (col.key === 'grade' ? 180 : 130),
fixed: col.key === 'warehouse' ? 'left' : undefined,
onCell: col.key === 'warehouse'
? (record) => ({ rowSpan: record.warehouseRowSpan })
: undefined,
render: (value, record) => {
if (col.key === 'warehouse') {
if (record.warehouseRowSpan === 0) return null;
return <span className="stock-sales-warehouse">{value}</span>;
}
if (col.key === 'grade') {
return <span className={record.isSubtotal ? 'stock-sales-grade is-total' : 'stock-sales-grade'}>{value}</span>;
}
if (col.key === 'skuRatio' || col.key === 'stockRatio') return percent(value);
return int(value);
},
})), []);
const gradeCount = data?.grades?.length ?? 0;
return (
<SycmPage className="stock-sales-page">
<div className="stock-sales-header">
<div className="stock-sales-header__title">
<Title level={4} style={{ margin: 0 }}>库存动销分析</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="stock-sales-header__help"
>
口径说明
</Button>
</div>
<div className="stock-sales-header__meta">
<span>统计基准日:{data?.latestBizDate || '—'}</span>
{data?.shipWindow ? (
<span>
销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
</span>
) : null}
{data?.takenAt ? (
<span>库存取数时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
) : null}
</div>
<Space wrap>
<Space size={4}>
<Text type="secondary">统计天数</Text>
<Select
value={days}
options={STOCK_SALES_WINDOW_OPTIONS}
onChange={handleDaysChange}
style={{ width: 120 }}
/>
</Space>
<Select
value={warehouse}
options={warehouseOptions}
onChange={setWarehouse}
showSearch
optionFilterProp="label"
style={{ minWidth: 240 }}
placeholder="选择仓库"
/>
<Button icon={<ReloadOutlined />} onClick={() => load({ days })} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
</Tooltip>
<Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 系统仓位 / 库存数量),跟随统计天数与仓库筛选;每个仓位独立一行,库存数量为该仓位的实际数量,仓位数据未覆盖的 SKU 保留一行(仓位留空、库存为总量)">
<Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button>
</Tooltip>
</Space>
</div>
<Alert
type="info"
showIcon
message={`按「单 SKU 日均销量」把库存拆成 ${gradeCount} 个动销等级,全部数值来自业务库实时取数`}
description="库存取实时库存表,销量取销售出库单;占比为「该等级 ÷ 该仓库合计」。切换统计天数会按新窗口重算日销与分档。"
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Table
className="stock-sales-table"
columns={columns}
dataSource={tableRows}
rowKey="key"
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }}
rowClassName={(record) => [
record?.isGrand ? 'stock-sales-row--grand' : '',
record?.isSubtotal ? 'stock-sales-row--subtotal' : '',
].filter(Boolean).join(' ')}
/>
</Spin>
<Text type="secondary" className="stock-sales-footer">
共 {visibleItems.length} 个仓库;每个仓库 {gradeCount} 个动销等级 + 1 行汇总
{data?.emptyWarehouseCount > 0 ? `;其中 ${data.emptyWarehouseCount} 个仓在业务库无有效库存或销量` : ''}。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={720}
>
<div className="stock-sales-help">
{STOCK_SALES_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default StockSalesAnalysis;

View File

@ -0,0 +1,90 @@
.stock-sales-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-sales-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.stock-sales-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.stock-sales-header__help {
font-size: 13px;
}
.stock-sales-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.stock-sales-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.stock-sales-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.stock-sales-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
vertical-align: middle;
}
.stock-sales-warehouse {
white-space: nowrap;
}
.stock-sales-grade {
white-space: nowrap;
}
.stock-sales-grade.is-total {
font-weight: 600;
}
/* 每个仓库的汇总行 */
.stock-sales-table .stock-sales-row--subtotal > td {
background: rgba(0, 0, 0, 0.03);
font-weight: 600;
}
/* 全仓合计行 */
.stock-sales-table .stock-sales-row--grand > td {
background: rgba(22, 119, 255, 0.06);
font-weight: 600;
}
.stock-sales-footer {
font-size: 12px;
}
.stock-sales-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.stock-sales-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -19,7 +19,10 @@ 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 LargeStockMonitor = lazy(() => import('../pages/LargeStockMonitor'));
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
const lazyLoadingElement = <PageLoadingFallback />;
@ -112,6 +115,61 @@ const router = createBrowserRouter([
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{
path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory),
},
{
path: 'planning/operation/stock-sales-analysis',
element: renderLazyComponent(StockSalesAnalysis),
},
{
path: 'planning/operation/picking-bin-sales-analysis',
element: renderLazyComponent(PickingBinSalesAnalysis),
},
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{
path: 'planning/operation/returns-inventory/cuiqiao',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'cuiqiao' }),
},
{
path: 'planning/operation/returns-inventory/subei',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'subei' }),
},
{
path: 'planning/operation/returns-inventory/suining',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'suining' }),
},
{
path: 'planning/operation/returns-inventory/shandong',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'shandong' }),
},
{
path: 'planning/operation/returns-inventory/henan',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henan' }),
},
// 历史路径(子看板 / 页签形态)→ 统一回到主看板
{
path: 'planning/operation/returns-inventory/henlin2',
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
},
{
path: 'planning/operation/returns-inventory/henlin2/monitor',
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
},
{
path: 'planning/operation/returns-inventory/henlin2/inbound',
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
},
{
path: 'planning/operation/returns-inventory/henlin2/stock',
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
},
{
path: 'planning/operation/returns-inventory/cuiqiao/monitor',
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
},
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
@ -132,10 +190,6 @@ const router = createBrowserRouter([
path: 'planning/dev-style-preview',
element: renderLazyComponent(DevelopmentStylePreview),
},
{
path: 'planning/large-stock-monitor/:warehouseSlug',
element: renderLazyComponent(LargeStockMonitor),
},
{
path: 'settings',
element: renderLazyComponent(Settings),

View File

@ -0,0 +1,15 @@
export const MKH_FRESH_DATA_CONTRACT_VERSION = 1;
const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。'
+ '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。';
export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) {
const contractVersion = Number(snapshot?.freshDataContractVersion) || 0;
if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) {
throw new Error(OUTDATED_BACKEND_MESSAGE);
}
if (requireApplied && snapshot?.freshDataApplied !== true) {
throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。');
}
return snapshot;
}

View File

@ -41,6 +41,28 @@ const saveBlob = (blob, fileName) => {
URL.revokeObjectURL(url);
};
// 服务端直接返回文件的场景(/export 接口,responseType: 'blob')在这里统一落盘
export const downloadBlobAsFile = (blob, fileName) => saveBlob(blob, fileName);
// 导出接口出错时,错误响应体同样是 Blob(内含 JSON),这里统一解出可读文案
export const readBlobErrorMessage = async (error, fallback = '导出失败') => {
const data = error?.response?.data;
if (typeof Blob !== 'undefined' && data instanceof Blob) {
try {
const text = await data.text();
try {
const parsed = JSON.parse(text);
return parsed?.error || parsed?.message || fallback;
} catch {
return text.slice(0, 300) || fallback;
}
} catch {
return fallback;
}
}
return error?.response?.data?.error || error?.message || fallback;
};
const quoteCsvCell = (value) => {
const text = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;

View File

@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js';
test('MKH fresh contract accepts a backend-confirmed live refresh', () => {
const snapshot = { freshDataContractVersion: 1, freshDataApplied: true };
assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot);
});
test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => {
assert.throws(
() => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }),
/本地后端版本过旧/
);
});
test('MKH fresh contract requires explicit refresh completion for fresh requests', () => {
assert.throws(
() => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }),
/未确认已重读/
);
});

View File

@ -11,6 +11,31 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
/**
* 解析 ssh 可执行文件路径。
* 隧道子进程是按「裸命令名 ssh」拉起的,依赖 PATH —— 而计划任务 / 非交互式
* 启动时 PATH 往往不含 OpenSSH 目录,隧道就会直接 spawn ENOENT,
* 表现为「服务起不来但日志没线索」。这里改成绝对路径优先,找不到再退回裸名。
*/
function resolveSshBinary() {
const candidates = [
process.env.CANGKU_SSH,
'C:\\Windows\\System32\\OpenSSH\\ssh.exe',
'C:\\Program Files\\OpenSSH-Win64\\ssh.exe',
'C:\\Program Files (x86)\\OpenSSH-Win64\\ssh.exe',
].filter(Boolean);
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* 忽略不可访问的候选路径 */
}
}
return 'ssh';
}
const SSH_BINARY = resolveSshBinary();
/**
* 解析后端实际监听端口。
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
@ -52,6 +77,10 @@ export function computeTunnelRetryDelay(attempt) {
}
export function buildSshArgs(config) {
// 隧道稳定性:这条隧道承载「系统配置库」,一旦掉线监督器会连带重启后端
// (约 20~90 秒不可用)。日志里高发的 `client_loop: send disconnect: Connection reset`
// 属于长时间空闲被中间设备掐断,因此这里缩短保活探测间隔、开启 TCPKeepAlive,
// 并给建连加超时,避免连接挂在半死状态。
return [
'-N', '-T',
'-o', 'BatchMode=yes',
@ -146,7 +175,14 @@ export async function main(mode = process.argv[2] || 'tunnel') {
*/
const startTunnel = async () => {
const { value: config } = loadConnectionConfig(ROOT_DIR);
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
const tunnel = spawn(SSH_BINARY, buildSshArgs(config), {
cwd: ROOT_DIR,
stdio: 'inherit',
// Windows 下必须显式隐藏:父进程没有控制台时,spawn 一个控制台程序
// 会让系统新分配一个控制台窗口(就是那个反复弹出的 ssh.exe 黑窗)。
// 隧道每 60 秒重连一次,不加这个就会一直弹。
windowsHide: true,
});
children.add(tunnel);
const exitPromise = new Promise((resolve) => {
@ -232,6 +268,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
env: childEnvironment,
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
shell: process.platform === 'win32',
// 同理:避免 cmd.exe / node 弹出控制台窗口
windowsHide: true,
});
children.add(child);
child.once('exit', (code) => {