Compare commits

...

78 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
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
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
91 changed files with 18555 additions and 3524 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

@ -1,106 +0,0 @@
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
const cloudDb = require('../src/config/cloudDatabase');
// 表格的 12 个流水列 → 配置字段名
const FLOW_FIELDS = [
['加工进仓', 'processIn'],
['采购退货', 'purchaseReturn'],
['采购进仓', 'purchaseIn'],
['其它进仓', 'otherIn'],
['其它退货', 'otherReturn'],
['销售出仓', 'salesOut'],
['盘点', 'stocktaking'],
['加工出仓', 'processOut'],
['调拨出', 'transferOut'],
['销售退货', 'salesReturn'],
['调拨入', 'transferIn'],
['其它出库', 'otherOut'],
];
(async () => {
const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
const h = rows[0];
const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
const level1 = String(rows[1][idx['一级功能按键']]).trim();
const level2 = String(rows[1][idx['二级功能按键']]).trim();
console.log('一级功能:', level1, '| 二级功能:', level2);
const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`);
const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id]));
const records = [];
const missing = [];
for (const r of rows.slice(1)) {
const sheetName = String(r[idx['仓库']] || '').trim();
if (!sheetName) continue;
const base = sheetName.replace(/主仓$/, '');
const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null;
if (wmsCoId == null) missing.push(sheetName);
const num = (k) => {
const v = r[idx[k]];
return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0;
};
const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)]));
const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理
records.push({
warehouse: sheetName,
dbWarehouse: base,
wmsCoId,
openingQty: num('期初数量'),
openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0,
...flows,
});
}
console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)');
const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en }));
const out = `/**
* 每日进销存 —— 2026-09-22 进销存基准流水
*
* 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
* 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
* 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
*
* 口径约定:
* - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
* - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
* - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
*/
/** 一级/二级菜单名(取自表格) */
const DAILY_INVENTORY_LEVEL1 = '${level1}';
const DAILY_INVENTORY_LEVEL2 = '${level2}';
/** 基准数据日期 */
const BASELINE_DATE = '2026-09-22';
/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)};
/** 25 个仓库的 22 号基准流水 */
const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)};
module.exports = {
DAILY_INVENTORY_LEVEL1,
DAILY_INVENTORY_LEVEL2,
BASELINE_DATE,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};
`;
const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js');
fs.writeFileSync(target, out, 'utf8');
console.log('已生成:', target);
console.log('\n前 2 条预览:');
console.log(JSON.stringify(records.slice(0, 2), null, 1));
process.exit(0);
})().catch((e) => { console.log('ERR', e.message); process.exit(1); });

View File

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

View File

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

View File

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

View File

@ -1,96 +0,0 @@
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const http = require('http');
const XLSX = require('xlsx');
const FIELD_MAP = [
['期初数量', 'openingQty'],
['加工进仓', 'processIn'],
['采购退货', 'purchaseReturn'],
['采购进仓', 'purchaseIn'],
['其它进仓', 'otherIn'],
['其它退货', 'otherReturn'],
['销售出仓', 'salesOut'],
['盘点', 'stocktaking'],
['加工出仓', 'processOut'],
['调拨出', 'transferOut'],
['销售退货', 'salesReturn'],
['调拨入', 'transferIn'],
['期初', 'openingQtyExtra'],
['其它出库', 'otherOut'],
['期末数量', 'closingQty'],
];
function req(method, path, token, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const r = http.request({
host: '127.0.0.1', port: 4001, path, method,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
}, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); });
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
(async () => {
const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body);
const token = login.access_token || login.token;
const res = await req('GET', '/api/daily-inventory', token);
if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); }
const d = JSON.parse(res.body).data;
const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
const h = rows[0];
const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
const byName = new Map(d.items.map((it) => [it.warehouse, it]));
console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length);
console.log();
const diffCounts = {};
let totalCells = 0;
let diffCells = 0;
const samples = [];
for (const r of rows.slice(1)) {
const name = String(r[idx['仓库']] || '').trim();
if (!name) continue;
const item = byName.get(name);
if (!item) { console.log('接口缺少仓库:', name); continue; }
for (const [cn, field] of FIELD_MAP) {
const raw = r[idx[cn]];
const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0;
const actual = Number(item[field] ?? 0);
totalCells += 1;
// 期末允许 1 件内的副本同步误差
const tol = field === 'closingQty' ? 10 : 0;
if (Math.abs(expected - actual) > tol) {
diffCells += 1;
diffCounts[cn] = (diffCounts[cn] || 0) + 1;
if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`);
}
}
}
console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells);
if (diffCells) {
console.log('\n不一致列统计:', JSON.stringify(diffCounts));
console.log('样例:');
samples.forEach((s) => console.log(' ' + s));
} else {
console.log('✓ 全部单元格与表格一致');
}
const withGrade = d.items.filter((x) => x.hasGrade).length;
console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length);
console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、'));
console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty);
console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' '));
process.exit(0);
})().catch((e) => { console.log('ERR', e.message); process.exit(1); });

View File

@ -1,16 +1,40 @@
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,
BASELINE_DATE,
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);
@ -25,6 +49,14 @@ function nextDayAsOf(bizDate) {
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`
@ -32,147 +64,499 @@ async function fetchLatestBizDate() {
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 };
}
/**
* 派生列(SQL CASE WHEN 下沉计算)
* - 库龄(天) = DATEDIFF(asOf, created)
* - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
* 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock
* - 四级分级:库龄 + 周转率双维度
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
*/
function buildDerivedColumns(asOf) {
const A = '@asOf';
void A;
const ageDays = `DATEDIFF(DAY, m.created, @asOf)`;
const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`;
return {
ageDays,
turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`,
levelExpr: `
function buildLevelExpr() {
const t = 'b.turnoverPct';
return `
CASE
WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL
WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn'
WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow'
WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow'
WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie'
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`,
};
END`;
}
/** 该仓快照里的四级库存量与平均周转天数 */
async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) {
const D = buildDerivedColumns(asOf);
const rows = await cloudDb.query(
`SELECT COUNT(*) AS skuCount,
SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock,
AVG(${D.turnoverDays}) AS avgTurnover,
SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) 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, asOf }
);
const r = rows[0] || {};
if (!Number(r.skuCount || 0)) return null;
return {
skuCount: toNumber(r.skuCount),
gradeStock: toNumber(r.gradeStock),
turnoverDays: toNumber(r.avgTurnover),
warnLevel: toNumber(r.warnLevel),
slowLevel: toNumber(r.slowLevel),
staleLevel: toNumber(r.staleLevel),
zombieLevel: toNumber(r.zombieLevel),
};
/**
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
*
* 口径:
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 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;
}
/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */
async function fetchClosingQty(wmsCoId) {
if (wmsCoId == null) return null;
/** 实时库存表的数据时效(一次取全局即可) */
async function fetchInventoryTakenAt() {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty,
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 }
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
);
const r = rows[0] || {};
return {
closingQty: toNumber(r.qty),
closingSkuCount: toNumber(r.skuCount) || 0,
closingTakenAt: r.takenAt || null,
};
return rows[0]?.takenAt || null;
}
/**
* GET /api/daily-inventory
* 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
*
* 数据源分工:
* - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js)
* - 期末数量:jst_inventoryInfo 实时库存
* - 库存周转天数 / 四级库存量:pm_stockMonitor 快照
* 数据源分工(全部来自数据库,无硬编码业务数值):
* - 期初数量:本地快照表 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 latestBizDate = await fetchLatestBizDate().catch(() => null);
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 items = [];
for (const base of DAILY_INVENTORY_BASELINE) {
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 = {};
for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0;
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 closing = await fetchClosingQty(base.wmsCoId).catch(() => null);
const grade = base.dbWarehouse && latestBizDate
? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null)
: null;
// 期末:官方期末优先;无官方时,历史业务日不给实时库存(口径不对)
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';
}
items.push({
// 期初:官方期初优先,其次本地快照
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: base.openingQty,
openingQtyExtra: base.openingQtyExtra,
openingQty,
openingQtyExtra: null,
...flows,
closingQty: closing?.closingQty ?? null,
closingSkuCount: closing?.closingSkuCount ?? null,
closingTakenAt: closing?.closingTakenAt ?? null,
closingQty,
closingSkuCount: grade?.skuCount ?? null,
closingTakenAt: takenAt,
shipQty30d: grade?.shipQty ?? null,
turnoverDays: grade?.turnoverDays ?? null,
gradeStock: grade?.gradeStock ?? 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']) {
totals[k] = Number(sum(k).toFixed(2));
// 若所有仓库该列均无数据(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)' });
}
res.json({
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: {
baselineDate: BASELINE_DATE,
latestBizDate,
asOf,
flowColumns: FLOW_COLUMNS,
items,
totals,
...result,
sheetName,
warnings,
snapshotSynced: synced,
},
});
} catch (error) {
console.error('[daily-inventory] 查询失败:', error.message);
res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
console.error('[daily-inventory] 官方数据导入失败:', error.message);
return res.status(500).json({ success: false, error: '官方数据导入失败', detail: error.message });
}
});

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

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

@ -1,558 +1,216 @@
/**
* 每日进销存 —— 2026-09-22 进销存基准流水
* 每日进销存 —— 仓库清单(纯映射,不含业务数值)
*
* 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
* 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
* 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
* 重要:本文件只描述「要统计哪些仓库」以及它们与聚水潭 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 列缺调拨/盘点/加工/其它出入库单据表,
* 暂无法显示(前端渲染为「—」)
*
* 口径约定:
* - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
* - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
* - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
* 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。
*/
/** 一级/二级菜单名(取自表格) */
/** 一级/二级菜单名 */
const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
const DAILY_INVENTORY_LEVEL2 = '每日进销存';
/** 基准数据日期 */
const BASELINE_DATE = '2026-09-22';
/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
/** 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"
}
];
{
"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"
}
];
/** 25 个仓库的 22 号基准流水 */
/** 纳入口径的仓库(warehouse = 聚水潭主仓名,dbWarehouse = 库存监控表仓库名) */
const DAILY_INVENTORY_BASELINE = [
{
"warehouse": "简尚-崔桥工厂仓主仓",
"dbWarehouse": "简尚-崔桥工厂仓",
"wmsCoId": 15038426,
"openingQty": 92388,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -3924,
"stocktaking": -1,
"processOut": 0,
"transferOut": -1461,
"salesReturn": 0,
"transferIn": 5437,
"otherOut": 0
},
{
"warehouse": "简尚-山东青岗集仓主仓",
"dbWarehouse": "简尚-山东青岗集仓",
"wmsCoId": 15137523,
"openingQty": 88377,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 886,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1058,
"stocktaking": -1,
"processOut": 0,
"transferOut": -11,
"salesReturn": 0,
"transferIn": 23,
"otherOut": 0
},
{
"warehouse": "简尚-河南洛宁仓主仓",
"dbWarehouse": "简尚-河南洛宁仓",
"wmsCoId": 15539084,
"openingQty": 83305,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -314,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 15,
"otherOut": 0
},
{
"warehouse": "简尚-新苏北仓主仓",
"dbWarehouse": "简尚-新苏北仓",
"wmsCoId": 15038347,
"openingQty": 77145,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1744,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 649,
"otherOut": 0
},
{
"warehouse": "简尚-横林二仓主仓",
"dbWarehouse": "简尚-横林二仓",
"wmsCoId": 15033302,
"openingQty": 63780,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -982,
"stocktaking": 0,
"processOut": 0,
"transferOut": -17,
"salesReturn": 0,
"transferIn": 728,
"otherOut": 0
},
{
"warehouse": "简尚-山东品牌仓主仓",
"dbWarehouse": "简尚-山东品牌仓",
"wmsCoId": 15182705,
"openingQty": 41025,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 150,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -157,
"stocktaking": 0,
"processOut": 0,
"transferOut": -5,
"salesReturn": 0,
"transferIn": 4,
"otherOut": 0
},
{
"warehouse": "简尚-睢宁仓主仓",
"dbWarehouse": "简尚-睢宁仓",
"wmsCoId": 15032603,
"openingQty": 25761,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -922,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 333,
"otherOut": 0
},
{
"warehouse": "简尚-分销部专用仓主仓",
"dbWarehouse": "简尚-分销部专用仓",
"wmsCoId": 15045426,
"openingQty": 6380,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -264,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 200,
"otherOut": 0
},
{
"warehouse": "简尚-新外贸仓主仓",
"dbWarehouse": "简尚-新外贸仓",
"wmsCoId": 15045308,
"openingQty": 5165,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "京仓-横林二仓京东自营仓主仓",
"dbWarehouse": "京仓-横林二仓京东自营仓",
"wmsCoId": 15835893,
"openingQty": 4260,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部睢宁专用仓主仓",
"dbWarehouse": "简尚-分销部睢宁专用仓",
"wmsCoId": 15051960,
"openingQty": 3711,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-崔桥工厂品牌仓主仓",
"dbWarehouse": "简尚-崔桥工厂品牌仓",
"wmsCoId": 15870495,
"openingQty": 1831,
"openingQtyExtra": 0,
"processIn": 94,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -53,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部苏北专用仓主仓",
"dbWarehouse": "简尚-分销部苏北专用仓",
"wmsCoId": 15045325,
"openingQty": 1440,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C横林二仓专用仓主仓",
"dbWarehouse": "简尚-运营C横林二仓专用仓",
"wmsCoId": 15301814,
"openingQty": 2141,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -992,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-济南外贸仓主仓",
"dbWarehouse": "简尚-济南外贸仓",
"wmsCoId": 15051888,
"openingQty": 712,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部崔桥专用仓主仓",
"dbWarehouse": "简尚-分销部崔桥专用仓",
"wmsCoId": 15404402,
"openingQty": 620,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
"dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
"wmsCoId": 15301815,
"openingQty": 286,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D苏北专用仓主仓",
"dbWarehouse": "简尚-运营D苏北专用仓",
"wmsCoId": 15406876,
"openingQty": 280,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C睢宁专用仓主仓",
"dbWarehouse": "简尚-运营C睢宁专用仓",
"wmsCoId": 15301813,
"openingQty": 193,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C新苏北专用仓主仓",
"dbWarehouse": "简尚-运营C新苏北专用仓",
"wmsCoId": 15301812,
"openingQty": 155,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D睢宁专用仓主仓",
"dbWarehouse": "简尚-运营D睢宁专用仓",
"wmsCoId": 15051907,
"openingQty": 109,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-山东青岗集京东仓主仓",
"dbWarehouse": "简尚-山东青岗集京东仓",
"wmsCoId": 15406664,
"openingQty": 90,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D专用仓库主仓",
"dbWarehouse": "简尚-运营D专用仓库",
"wmsCoId": 15051915,
"openingQty": 180,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -125,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-崔桥工厂京东仓主仓",
"dbWarehouse": "简尚-崔桥工厂京东仓",
"wmsCoId": 15045345,
"openingQty": 1,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "京仓-横林二仓京东云仓主仓",
"dbWarehouse": "京仓-横林二仓京东云仓",
"wmsCoId": 15197138,
"openingQty": -3082,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -48,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
}
];
{
"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,
BASELINE_DATE,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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: '系统设置',

File diff suppressed because it is too large Load Diff

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

@ -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 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
@ -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 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -303,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。
@ -398,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 决定实际取数列,
@ -525,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;
@ -1745,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,
@ -1845,6 +1895,10 @@ async function loadReplenishIndex() {
/**
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
* · 按基础配件编码精确匹配,SUM(qty) 汇总
* · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径):
* ① jst_inventoryInfo 有正库存 → 以它为准
* ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底
* (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足)
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
* 且失败的分片不写缓存,下次调用重试
@ -1872,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);
@ -2271,6 +2342,10 @@ 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,
@ -2645,6 +2720,9 @@ module.exports = {
COMPONENT_STOCK_WAREHOUSE,
COMPONENT_STOCK_CHUNK_SIZE,
COMPONENT_SHORTAGE_MAX_LIST,
// ⑦-d 成品库存仓白名单(多仓求和口径)
BRAND_WMS_CO_IDS,
BRAND_WMS_CO_ID,
parseFlag,
resolveScope,
filterRows,

View File

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

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

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

@ -1,9 +1,29 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
const IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量
export const getDailyInventory = () => request.get(
// 每日进销存:进销存流水 + 实时期末 + 分级库存量
// refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s)
// date=YYYY-MM-DD 查看指定业务日(默认取业务库最新业务日)
export const getDailyInventory = (params = {}) => request.get(
'/api/daily-inventory',
{ timeout: LIST_TIMEOUT_MS }
{ 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

@ -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,201 +1,213 @@
export const DEFAULT_SIDEBAR_CONFIG = {
collapsedByDefault: false,
darkModeByDefault: false,
entries: [
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
{
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
children: [
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
],
},
{
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
children: [
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
],
},
{ 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,
children: [
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{
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: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
children: [
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
],
},
],
};
const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
entry.key,
...flattenEntryKeys(entry.children || []),
]).filter(Boolean);
const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
entry.routeKey,
...flattenRouteKeys(entry.children || []),
]).filter(Boolean);
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
const extractPromotedTopLevelEntries = (entries = []) => {
const promoted = [];
const nextEntries = entries.map((entry) => {
if (!Array.isArray(entry?.children)) return entry;
const children = [];
entry.children.forEach((child) => {
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
promoted.push(child);
} else {
children.push(child);
}
});
return children.length === entry.children.length ? entry : { ...entry, children };
});
return { nextEntries, promoted };
};
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
const configuredKeys = new Set();
const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
const merged = { ...defaultEntry, ...configuredEntry };
if (defaultEntry.type) {
merged.type = defaultEntry.type;
}
if (defaultEntry.routeKey) {
merged.routeKey = defaultEntry.routeKey;
}
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
merged.requiresAdmin = defaultEntry.requiresAdmin;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
) {
merged.label = defaultEntry.label;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
) {
merged.order = defaultEntry.order;
}
const hasDefaultChildren = Array.isArray(defaultEntry.children);
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
if (hasDefaultChildren || hasConfiguredChildren) {
if (hasDefaultChildren) {
merged.type = defaultEntry.type;
} else if (defaultEntry.routeKey) {
merged.type = defaultEntry.type || 'item';
delete merged.children;
delete merged.defaultExpanded;
return merged;
}
merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
}
return merged;
};
const configuredMerged = configuredEntries.map((entry) => {
if (entry?.key) {
configuredKeys.add(entry.key);
}
const defaultEntry = defaultByKey.get(entry?.key);
return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
});
const missingDefaults = defaultEntries
.filter((entry) => entry?.key && !configuredKeys.has(entry.key))
.map((entry) => mergeEntry(entry, {}));
return [...configuredMerged, ...missingDefaults];
};
const mergeSidebarConfigWithDefaults = (config) => {
const defaults = cloneDefaultConfig();
const nestedEntries = (config?.entries || []);
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
return {
...defaults,
...config,
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
};
};
const pruneSidebarEntry = (entry) => {
if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
return null;
}
const children = Array.isArray(entry.children)
? entry.children.map(pruneSidebarEntry).filter(Boolean)
: undefined;
const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
const hasAllowedChildren = Array.isArray(children) && children.length > 0;
const keepEntry = hasAllowedRoute || hasAllowedChildren;
if (!keepEntry) {
return null;
}
return {
...entry,
...(Array.isArray(entry.children) ? { children } : {}),
};
};
const removeRetiredEntries = (config) => ({
...config,
entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
});
export const parseSidebarConfig = (value) => {
if (!value) return cloneDefaultConfig();
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
} catch {
return cloneDefaultConfig();
}
};
export const sortSidebarEntries = (entries = []) => (
[...entries]
.filter((entry) => entry?.visible !== false)
.sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
);
export const DEFAULT_SIDEBAR_CONFIG = {
collapsedByDefault: false,
darkModeByDefault: false,
entries: [
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
{
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
children: [
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
],
},
{
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
children: [
{ 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,
children: [
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{
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,
children: [
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
],
},
],
};
const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
entry.key,
...flattenEntryKeys(entry.children || []),
]).filter(Boolean);
const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
entry.routeKey,
...flattenRouteKeys(entry.children || []),
]).filter(Boolean);
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
const extractPromotedTopLevelEntries = (entries = []) => {
const promoted = [];
const nextEntries = entries.map((entry) => {
if (!Array.isArray(entry?.children)) return entry;
const children = [];
entry.children.forEach((child) => {
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
promoted.push(child);
} else {
children.push(child);
}
});
return children.length === entry.children.length ? entry : { ...entry, children };
});
return { nextEntries, promoted };
};
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
const configuredKeys = new Set();
const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
const merged = { ...defaultEntry, ...configuredEntry };
if (defaultEntry.type) {
merged.type = defaultEntry.type;
}
if (defaultEntry.routeKey) {
merged.routeKey = defaultEntry.routeKey;
}
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
merged.requiresAdmin = defaultEntry.requiresAdmin;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
) {
merged.label = defaultEntry.label;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
) {
merged.order = defaultEntry.order;
}
const hasDefaultChildren = Array.isArray(defaultEntry.children);
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
if (hasDefaultChildren || hasConfiguredChildren) {
if (hasDefaultChildren) {
merged.type = defaultEntry.type;
} else if (defaultEntry.routeKey) {
merged.type = defaultEntry.type || 'item';
delete merged.children;
delete merged.defaultExpanded;
return merged;
}
merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
}
return merged;
};
const configuredMerged = configuredEntries.map((entry) => {
if (entry?.key) {
configuredKeys.add(entry.key);
}
const defaultEntry = defaultByKey.get(entry?.key);
return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
});
const missingDefaults = defaultEntries
.filter((entry) => entry?.key && !configuredKeys.has(entry.key))
.map((entry) => mergeEntry(entry, {}));
return [...configuredMerged, ...missingDefaults];
};
const mergeSidebarConfigWithDefaults = (config) => {
const defaults = cloneDefaultConfig();
const nestedEntries = (config?.entries || []);
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
return {
...defaults,
...config,
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
};
};
const pruneSidebarEntry = (entry) => {
if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
return null;
}
const children = Array.isArray(entry.children)
? entry.children.map(pruneSidebarEntry).filter(Boolean)
: undefined;
const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
const hasAllowedChildren = Array.isArray(children) && children.length > 0;
const keepEntry = hasAllowedRoute || hasAllowedChildren;
if (!keepEntry) {
return null;
}
return {
...entry,
...(Array.isArray(entry.children) ? { children } : {}),
};
};
const removeRetiredEntries = (config) => ({
...config,
entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
});
export const parseSidebarConfig = (value) => {
if (!value) return cloneDefaultConfig();
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
} catch {
return cloneDefaultConfig();
}
};
export const sortSidebarEntries = (entries = []) => (
[...entries]
.filter((entry) => entry?.visible !== false)
.sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
);

View File

@ -3,59 +3,81 @@
*
* 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
* source 标记该列数据来源,便于在页面上如实标注口径:
* - baseline:22 号基准值(业务库无对应单据表,无法还原)
* - snapshot:来自项目本地快照表 daily_inventory_snapshot(每日定时落库,自动滚动)
* - runtime:运行时从业务库实时取数
* - unavailable:业务库缺少对应单据表,暂无法计算(渲染为「—」)
*/
export const COLUMN_SOURCE = {
baseline: 'baseline',
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: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
{ key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' },
{ key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
{ key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' },
{ key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' },
{ key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
{ key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
{ key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' },
{ key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' },
{ key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
{ key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' },
{ key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' },
{ key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' },
{ key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' },
{ key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' },
{ key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' },
{ key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' },
{ key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' },
{ key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' },
{ key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' },
{ key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' },
{ 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 = [
'数据来源说明:',
'· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:',
' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,',
' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。',
'数据来源说明(全部来自数据库或聚水潭官方导出,无人工填数):',
'· 聚水潭官方进销存(优先):把聚水潭「进销存按仓导出」的 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 单仓),随刷新滚动。',
'· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。',
'· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。',
'',
'算式:',
'· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
'· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock',
'· 库存分级(库龄 + 周转率双维度):',
' 月度发货总件数 = 该仓近 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 个月 且 周转率 30%~99% → 商品慢流',
' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
'',
'注意:仓库整体周转率按「该仓发货总量 ÷ 该仓库存总量」计算,与四级分级的 SKU 级取舍不是同一个口径,',
' 两者不必相等(例如整体周转率 6% 的仓里,仍可以有部分 SKU 周转率超 30% 而被判为预警级)。',
];

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

@ -5,6 +5,13 @@ 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) => {

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),与「每日进销存」发货口径一致。',
];

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,19 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
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 } from '../../api/dailyInventory';
import { getDailyInventory, importDailyInventoryOfficial } from '../../api/dailyInventory';
import {
getDailyInventoryNotificationConfig,
runDailyInventoryNotificationTest,
saveDailyInventoryNotificationConfig,
} from '../../api/dailyInventoryNotification.js';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
@ -12,6 +23,34 @@ 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>
@ -23,12 +62,12 @@ const int = (value) => {
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const decimal = (value, digits = 1) => {
const percent = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
@ -45,11 +84,33 @@ const DailyInventory = () => {
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const load = useCallback(async () => {
// ── 定时截图通知设置 ──
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 res = await getDailyInventory();
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 || '每日进销存数据加载失败');
@ -57,35 +118,77 @@ const DailyInventory = () => {
} 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' ? 240 : 130,
width: col.key === 'warehouse' ? 260 : 130,
fixed: col.key === 'warehouse' ? 'left' : undefined,
className: isRuntime ? 'daily-inventory-col--runtime' : undefined,
className: isRuntime
? 'daily-inventory-col--runtime'
: (noSource ? 'daily-inventory-col--unavailable' : undefined),
render: (value, record) => {
if (record?.isTotal) {
// 合计行:周转天数不累加,显示「—」
if (col.key === 'turnoverDays') return '—';
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
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}</span>;
if (col.key === 'closingQty') return int(value);
if (col.key === 'turnoverDays') return decimal(value, 1);
return renderSigned(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;
@ -94,6 +197,159 @@ const DailyInventory = () => {
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">
@ -109,29 +365,71 @@ const DailyInventory = () => {
</Button>
</div>
<div className="daily-inventory-header__meta">
<span>基准日期:{data?.baselineDate || '—'}</span>
<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>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
<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="info"
type={data?.official?.hasData ? 'success' : 'info'}
showIcon
message="数据来源混合,请注意口径"
message={data?.official?.hasData
? `已按聚水潭官方进销存校正(${data.latestBizDate}):12 个流水列全部为官方值`
: '尚未导入聚水潭官方进销存:流水列为业务库推算值'}
description={
<span>
期初数量与 12 个流水列取自 <b>{data?.baselineDate || '2026-09-22'}</b> 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原);
期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''}
{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 }}
@ -154,9 +452,216 @@ const DailyInventory = () => {
</Spin>
<Text type="secondary" className="daily-inventory-footer">
共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。
共 {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}

View File

@ -53,6 +53,15 @@
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;
}
@ -69,11 +78,95 @@
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;
}

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,11 +35,34 @@ 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 = {
缺货: 'danger',
@ -266,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);
@ -280,10 +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;
@ -320,12 +394,16 @@ const MkhBomData = () => {
const load = useCallback(async ({ background = false } = {}) => {
const requestGeneration = listRequestGateRef.current.begin();
let showProgress = false;
let progressGeneration = null;
if (!background) setLoading(true);
if (!background) setError('');
try {
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({
@ -339,7 +417,14 @@ const MkhBomData = () => {
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) {
@ -347,11 +432,39 @@ const MkhBomData = () => {
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
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(() => {
@ -850,6 +963,7 @@ const MkhBomData = () => {
const handleSync = async () => {
setSyncing(true);
setLoading(true);
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
@ -863,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) {
@ -874,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>
@ -912,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
@ -1272,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

@ -1,159 +1,213 @@
import React, { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import BasicLayout from '../layouts/BasicLayout';
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
const Login = lazy(() => import('../pages/Login'));
const Install = lazy(() => import('../pages/Install'));
const Setup = lazy(() => import('../pages/Setup'));
const Settings = lazy(() => import('../pages/Settings'));
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const lazyLoadingElement = <PageLoadingFallback />;
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
const LazyView = lazyView;
return (
<Suspense fallback={fallback}>
<LazyView {...props} />
</Suspense>
);
};
const HOME_URL = '/planning/dev-style-preview';
const router = createBrowserRouter([
{
path: '/login',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/callback',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/setup',
element: renderLazyComponent(Setup),
},
{
path: '/install',
element: renderLazyComponent(Install),
},
{
path: '/',
element: <BasicLayout />,
children: [
{
index: true,
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'dashboard',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'channel',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/operation/marketing-events',
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
},
{
path: 'planning/operation/marketing-events/activity-products',
element: renderLazyComponent(MarketingEvents),
},
{
path: 'planning/operation/marketing-tools',
element: <Navigate to="/planning/operation/marketing-events" replace />,
},
{
path: 'planning/operation/product-information',
element: renderLazyComponent(ProductInformation),
},
{
path: 'planning/operation/product-analysis',
element: renderLazyComponent(ProductAnalysis),
},
{
path: 'planning/operation/promotion-module',
element: renderLazyComponent(PromotionModule),
},
{
path: 'planning/operation/ops-b-incentive',
element: renderLazyComponent(OpsBIncentiveBoard),
},
{
path: 'planning/operation/product-monitoring',
element: renderLazyComponent(ProductMonitoring),
},
{
path: 'planning/operation/modular-stock-monitor',
element: renderLazyComponent(ModularStockMonitor),
},
{
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{
path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory),
},
{ 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 /> },
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
{
path: 'planning/operation/:featureKey',
element: renderLazyComponent(OperationPlaceholder),
},
{
path: 'planning/upload',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/dev-style-preview',
element: renderLazyComponent(DevelopmentStylePreview),
},
{
path: 'settings',
element: renderLazyComponent(Settings),
},
{
path: 'settings/online-desktop-users',
element: renderLazyComponent(OnlineDesktopUsers),
},
{
path: 'settings/desktop-execution-tasks',
element: renderLazyComponent(DesktopExecutionTasks),
},
{
path: '*',
element: <Navigate to={HOME_URL} replace />,
},
],
},
]);
export default router;
import React, { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import BasicLayout from '../layouts/BasicLayout';
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
const Login = lazy(() => import('../pages/Login'));
const Install = lazy(() => import('../pages/Install'));
const Setup = lazy(() => import('../pages/Setup'));
const Settings = lazy(() => import('../pages/Settings'));
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
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 />;
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
const LazyView = lazyView;
return (
<Suspense fallback={fallback}>
<LazyView {...props} />
</Suspense>
);
};
const HOME_URL = '/planning/dev-style-preview';
const router = createBrowserRouter([
{
path: '/login',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/callback',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/setup',
element: renderLazyComponent(Setup),
},
{
path: '/install',
element: renderLazyComponent(Install),
},
{
path: '/',
element: <BasicLayout />,
children: [
{
index: true,
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'dashboard',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'channel',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/operation/marketing-events',
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
},
{
path: 'planning/operation/marketing-events/activity-products',
element: renderLazyComponent(MarketingEvents),
},
{
path: 'planning/operation/marketing-tools',
element: <Navigate to="/planning/operation/marketing-events" replace />,
},
{
path: 'planning/operation/product-information',
element: renderLazyComponent(ProductInformation),
},
{
path: 'planning/operation/product-analysis',
element: renderLazyComponent(ProductAnalysis),
},
{
path: 'planning/operation/promotion-module',
element: renderLazyComponent(PromotionModule),
},
{
path: 'planning/operation/ops-b-incentive',
element: renderLazyComponent(OpsBIncentiveBoard),
},
{
path: 'planning/operation/product-monitoring',
element: renderLazyComponent(ProductMonitoring),
},
{
path: 'planning/operation/modular-stock-monitor',
element: renderLazyComponent(ModularStockMonitor),
},
{
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 /> },
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
{
path: 'planning/operation/:featureKey',
element: renderLazyComponent(OperationPlaceholder),
},
{
path: 'planning/upload',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/dev-style-preview',
element: renderLazyComponent(DevelopmentStylePreview),
},
{
path: 'settings',
element: renderLazyComponent(Settings),
},
{
path: 'settings/online-desktop-users',
element: renderLazyComponent(OnlineDesktopUsers),
},
{
path: 'settings/desktop-execution-tasks',
element: renderLazyComponent(DesktopExecutionTasks),
},
{
path: '*',
element: <Navigate to={HOME_URL} replace />,
},
],
},
]);
export default router;

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) => {