cangku/backend/src/services/mkhBomService.js
yanhui 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

2705 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// 模块化库存监控 —— MKH-BOM 资料
//
// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位
//
// ── 数据口径 ──────────────────────────────────────────────────────
//
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品'
// AND other_3 = N'简尚-崔桥工厂品牌仓'
// AND 该编码存在正式 BOM 配件明细 (当前 4223 条)
// · 商品档案里的 item_type/labels 不能单独作为成品依据:上游也会把部分配件
// 标成“成品/模块化成品”。只有 jst_skuBomItem 的父项才进入本页面。
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
//
// ② 配件明细(BOM)—— 聚水潭商品 BOM(主来源)
// rpt.jst_skuBomInfo / rpt.jst_skuBomItem
// · jst_skuBomInfo.sku_id = MKH 成品编码
// · jst_skuBomItem.map_outer_sku_id = 配件编码,rm_qty = 配件数量
// · 两表当前分别约 4,257 / 49,012 行;远端副本暂未提供 sku_id 索引,
// 因此只读取必要列并在进程内短时缓存,避免分页和下钻重复扫描。
//
// ③ 业务中台组合商品表(降级来源)
// JSDataMiddlePlatform.dbo.jst_comboProductItem
// · combine_sku_id = MKH 成品编码,src_sku_id = 配件编码,qty = 配件数量
// · 当前实测 1,406 个 MKH 成品、3,022 条配件关系、159 个不同配件编码
//
// ④ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源
// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails'
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }]
// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配
// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001)
// · BOM 只用于补充指定仓库商品的配件明细,不能把无 jst_sku 仓库档案的编码追加为商品。
//
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
// (无 SKU 条码),也不能直接当配件 BOM。
//
// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套
// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 ——
// 那不是仓位,标出来会让人以为料就在那儿)。
// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。
// 取值只有两层:
// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」
// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
// 只打一条查询,按编码缓存 10 分钟。
// ⚠️ 关联列(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 汇总(**商品**库存,不是配件仓位)。
//
// ── ⑦ 补货预警(2026-09-17 业务确认口径)──────────────────────────
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
// 缺失时回退监控快照 jsStock
// · 结余库存 = max(0, 现有库存 − 待发货订单)
// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
// (见 ⑫)。待发货订单 = 0 时结余库存 ≡ 现有库存,与旧口径完全等价。
// · 可用天数 = 结余库存 / 近15天日销
// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存),不足 0 取 0
// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源
// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67,
// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。
//
// ── 为什么是 15 天而不是业务最初要求的 30 天 ──
// 2026-09-17 业务要求「近7日日销」改「近30天日销」,但实测全库**没有 30 天日销**:
// · pm_stockMonitor 只有 threeAvgSale(3天) / sevenAvgSale(7天) / fifteenAvgSale(15天),
// 且整表**只保留 1 天快照**(2026-09-16),攒不出 30 天历史
// · rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,数据新鲜到 09-15)
// 里 MKH 商品 **0 个**;目标仓 794 个 SKU 命中也是 0
// · rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
// · rpt.jst_salesOutboundInfo 只有出库**单头**,无 SKU 明细;业务库无出库明细表
// · rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice **没有销量**
// → 经与业务确认,改用十五天口径 fifteenAvgSale(已实现的最长周期)。
// ⚠️ 若日后上游补了 30 天字段,只需改 loadReplenishIndex 里的列名 + DAILY_SALE_DAYS。
//
// 实测(2026-09-16 品牌仓快照,当时成品口径尚未收紧为「正式 BOM 父项」):
// 4408 个 MKH 成品中,7 天有销 114 个、15 天有销 170 个
// 实测(2026-09-17 切换当天,同一份 09-16 快照,7 天 vs 15 天):
// 有销商品 114 → 170 ← 周期拉长,更多商品进得了「有销」集合
// 补货预警 25 → 19 ← 反而变少:15 天日销普遍低于 7 天(近期销量走低),
// 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
// 缺货 9 → 10
// 已排产 11 → 13
// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底
// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。
//
// ── ⑦-a 日销兜底:订单表实算(2026-09-19 新增)────────────────────
// 问题:上游快照 pm_stockMonitor.fifteenAvgSale 会**漏算**。实测(快照 09-18,
// 窗口内有发货的 MKH 成品共 178 个编码)**24 个被记成 0**,其中 16 个是
// 「订单全部来自样品专用店铺」(上游同样不计样品单,属合理口径),
// 剩 **8 个是真实店铺**(天猫 / 京东 / 拼多多),这才是异常。
// 业务实例:`MKHDNZ0409260601548` 近 15 天实发 2 件(09-09 / 09-19),
// 快照日销却是 0,库存又是 0 → 走「无销量」分支彻底隐身,**连缺货都不报**。
// ⚠️ 该列是**保留两位小数的日均**,不是取整值:品牌仓 177 个非零值范围
// 0.07 ~ 4.96,含小数的占 170 个,最小非零 0.07 = 1 件 ÷ 15 天。
// 所以「发过货却记 0」**无法用四舍五入解释**,就是上游没算进去。
// 兜底规则:**快照日销 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货**时,
// 日销改用订单表实算值 = 已发货件数 ÷ SALE_WINDOW_DAYS,
// 随后照常走 ⑦/⑫ 的日销分支(结余库存、3 天阈值、建议补货量都不改)。
// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架
// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐;
// 不排除的话那 16 个样品单编码会被误拉进预警
// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售
// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径
// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到
// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货
// (均约 1.25s),而拆成两条独立查询要在现有开销之上**再加一次全表扫描**。
// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`,
// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判
// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报)
//
// 🔥 **必须排除「异常单」(2026-09-19 业务反馈后修正)**
// 业务报「MKHDNZ0410260700005 没发过货,为啥有日销」→ 查下来是兜底把 **0.01 元特殊单**
// 当成了销售。该单画像(拼多多-亿家达书房家具官方旗舰店):
// · 实付 **0.01 元**、商品额 908.01、**免额 908(全额免)**、备注 `SSDD`、标签含「特殊单」
// · 该店在 **09-18 10:35–10:36 一分钟内**批量生成 18 张同样的 0.01 元 MKH 单
// (实付合计 0.18 元、免额合计 16,344 元),另有 18 张占位码 `1.X00005` 的 0.01 元单
// · **`rpt.jst_salesOutboundInfo` 里这些 o_id 查不到任何出库记录**
// (同店同时段其他单有 6,467 行)→ 系统标「已发货」,货其实没出
// · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警
// 规则:已发货分支排除 **`pay_amount ≤ 0.01`** 或 **`labels` 含「特殊单」** 的单。
// ⚠️ **别用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
// ⚠️ 只作用于**已发货**分支;待发货(⑩)口径本次未动。
//
// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中
// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**,
// 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元,
// 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/
// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。
// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。
//
// ── ⑦-c 出库校验:**必须真出库才算销量**(2026-09-19 业务确认口径)────
// 业务原话:「这些异常 SKU,系统标记了『已发货』,但没有实际出库的,不需要出现缺货预警」。
// 即 ⑦-a 的金额/标签判据再往根上退一步——**以出库表为准**:
// 已发货分支要计入销量,必须同时满足
// ① 不是异常单(`pay_amount ≤ 0.01` 或 `labels` 含「特殊单」,⑦-a)
// ② 在出库表里查得到该 `o_id`(`jst_salesOutboundInfo` / `jst_qmcust_salesoutbound`)
// 实测(2026-09-19,窗口 15 天、1,086 张已发货 MKH 单):
// · 出库表命中 889 张 → 197 张无出库,其中 **40 张是拆分单、157 张是 Sent**
// · 157 张里 **131 张是 ≤0.01 元或「特殊单」**(即 ⑦-a 已排除的那批刷单),
// 剩下唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消 / 发货后售后 /
// 风控订单」)—— ② 这条判据正是为它这类「标了发货其实没出库」的单兜底
// · 与只按金额/标签判据相比,**当前窗口的兜底生效 SKU 集合完全一致(12 个)**,
// 属"同解但更根本",代价是每次冷缓存多约 0.5s 的出库查询
// 🔥 **两个必须豁免的情形**(不豁免就会大面积误杀真实单,实测数据如下):
// ① **拆分单**(`status='Split'`):父单被拆成子单后出库挂在**子单**上,
// 父单必然查不到出库 —— 窗口内 40 张 Split 单 **100% 无出库记录**,
// 若不豁免会误杀「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单
// ② **今天发货的单**:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行,
// 而 09-18 有 14,690 行)→ 09-19 的 7 张 Sent 无出库单里特殊单为 0,
// 说明无出库是**同步滞后**而非虚假发货。
// 注意豁免只到「昨天」为止:09-18 的 18 张无出库 Sent 单 **100% 是特殊单**,
// 出库表 09-18 数据完整,所以 09-18 不能被豁免(否则会放过刷单)
// ⚠️ 只判**有没有出库行**:`jst_salesOutboundInfo.qty` 全表 1,119,606 行恒为 0
// (`SUM(qty)` 永远得 0,近 15 天 180,433 行同样),别拿数量做判据。
// ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded:
// 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。
//
// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)──────────
// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有……
// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。
// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表,
// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。
// 2026-09-19 实测:
// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**,
// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19
// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行),
// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见**
// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新)
// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留)
// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**,
// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。
// 兜底规则(2026-09-24 修正):
// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值
// ② 只有其中一个当前库存源有值 → 用该源
// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock
// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级
// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住;
// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。
// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件),
// 不过滤会虚增。
// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个**
// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4,
// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行);
// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。
// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。
//
// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。
// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存
// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
// · 数据源:业务库同实例的链接服务器
// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders
// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表)
// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓'
// AND 状态 <> '作废' ← 作废单据一律不核算
// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量
// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。
// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。
// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query:
// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`,
// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成
// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。
// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 /
// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。
// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。
//
// ── ⑨ 导出(2026-09-17)──────────────────────────────────────────
// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点:
// · 不分页 —— 返回当前筛选条件下的全部行,而不是当前页
// · 两种形态 —— products(商品清单,列同页面表格)/ components(配件明细摊平)
// 官方 BOM 明细按 SKU 分批读取(单批上限 MAX_PAGE_SIZE=200,见 loadOfficialBomForSkuBatches),
// 只对筛选结果取数,因此「仅预警」这类小范围导出只需 1 批查询;
// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。
// ⚠️ products 形态**不返回 components** —— 全量商品(4000+ 行)若都挂上配件明细,
// 响应会膨胀到几十 MB,而商品清单本身用不到明细。
//
// ── ⑩ 待发货订单(2026-09-17 业务确认口径)────────────────────────
// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。
// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」
// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。
// · 数据源:业务库**两张订单主表,必须都读**(2026-09-19 修正,见下 ⑩-a)
// · rpt.jst_Orders —— 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会
// · rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单
// 两张表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]`
// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。
// ⚠️ 不要用明细表(jst_OrderItem / jst_qmcust_orderlist_item):主表直读免关联,
// skus 列一次性带出全部商品;实测 MKH 成品订单 100% 可解析,故只解析
// MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。
//
// ── ⑩-a 为什么是两张表(2026-09-19 修正,重要)────────────────────
// 现象:`MKHDNZ0409260700059` / `MKHDNZ0409260601228` 明明有待发货订单,
// 系统却显示 0 单、不给预警,业务反馈到系统。
// 根因:**业务库的订单按渠道拆成了互不重叠的两张表**,本服务原先只读了
// `rpt.jst_Orders` —— 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全被漏掉**。
// 实测(2026-09-19,近 30 天窗口):
// · 两表店铺名**交集 = 0**(jst_Orders 398 店 vs qmcust 78 店),so_id 交集仅 5 条,
// o_id 交集 **0 条**(两表的 o_id 完全独立,合并安全);
// · 同一品牌在不同平台是**不同店铺名**(例「欧意朗」在 jst_Orders 是拼多多/得物/唯品会,
// 在天猫/淘宝则是另一张表里的店)→ **按品牌名反查会误判「表里有」,必须按完整店铺名核对**;
// · `jst_Orders` 里 `shop_name LIKE '%天猫%'` = **0 行**(不是命名差异,是真的没有)。
// 影响面(MKH 成品待发货):仅 jst_Orders = 36 编码 / 50 件;
// 合并后 = **40 编码 / 56 件**,补进来的新编码 4 个:
// MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498;
// 实时单量:jst_Orders 未发货含 MKH 52 单 + qmcust 10 单。
// 性能:4.91M 行的 qmcust 上该查询约 0.17s(LIKE '%MKH%' 全扫),可忽略。
// 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。
// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')`
// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单;
// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求)
// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加)
// · 预警判定(2026-09-17 业务补充口径):日销为 0,但仍用「可用天数」衡量风险 ——
// 可用天数 = 现有库存 ÷ 代发(待发货)订单件数
// · 可用天数 = 0(无库存) → 缺货
// · 0 < 可用天数 < 3 天 → 预警
// · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致)
// ⚠️ 代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0),不会出现除零;
// 可用天数统一如实记录(保留 2 位小数),不再像以前那样缺货记 0 / 够发记 null。
// · 建议补货量:
// 缺货 → max(0, 代发订单件数 − 现有库存)(先补到能覆盖整笔订单)
// 预警(0<天数<3) → max(0, 3 × 代发订单件数 − 现有库存)(补到 3 天缓冲)
// 正常(天数≥3) → 0
// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)——
// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。
// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件,
// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。
// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个**
// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。
// ⚠️ 同日又叠加「可用天数 ≤ 3 天」阈值(2026-09-17 补充):代发件数大、库存也能撑 3 天
// 以上的商品不再预警,订单驱动预警数会在此 8 个基础上进一步减少(具体数以接口为准)。
//
// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)────────────────
// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示
// 「配件不足无法补货」**,建议补货量显示为「—」(不给出补不到的补货目标)。
// · 可组装数 = min over 配件 ⌊ 可支配配件数 ÷ 单台用量 ⌋ (木桶短板决定)
// 可支配配件数 = 现有库存 − 未完成加工单占用(不足 0 取 0)
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
// · 配件现有库存 **两级来源**(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。
// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。
// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0
// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。
// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序,
// 只额外打标记;「已排产」商品本来就不预警,不参与判定。
// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。
// 实测(2026-09-17,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0**
// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台),
// 说明占用规模小但口径上必须扣。
// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标),
// 接口透出 componentStockDegraded=true。
//
// ── ⑫ 结余库存(2026-09-18 业务口径修正)──────────────────────────
// 背景:`MKHDNZ0409260601376` 现有库存 3、待发货订单 3,业务认为结余库存应为 0、
// 可用天数 0、应判缺货;但旧代码里「待发货订单」**只在日销 = 0 时参与判定**
// (`orderDriven = dailySale <= 0 && pendingOrderQty > 0`),该品日销 0.27 > 0,
// 于是走日销分支 3 ≥ 0.81(3 天日销)→ 判「正常」,3 件待发订单被完全忽略。
// 新口径:**待发货订单对所有商品都参与计算,不再区分日销是否为 0**
// · 结余库存 availableStock = max(0, 现有库存 − 待发货订单)
// · 可用天数 = 结余库存 ÷ 近15天日销(日销 > 0 时)
// · 缺货:结余库存 ≤ 0(订单都发不出去);预警:0 < 可用天数 < 3;否则正常
// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存)
// (先补到能把订单发掉,再补 3 天缓冲)
// ⚠️ ⑩ 的订单驱动分支(日销 = 0)**保持不变**:那里本来就是以代发订单件数为分母,
// 语义是「库存能覆盖几倍订单」,与日销无关,改动会误伤已有的订单驱动预警。
// 实测(2026-09-18,4,223 个成品):既有日销又有待发订单的商品 38 个,
// 其中 **4 个等级变化、全部升级为缺货**:
// MKHDNZ0409260700503(日销1.67 库存1 待发2) 预警0.60天 → 缺货0天
// MKHDNZ0409260601547(日销0.42 库存1 待发1) 预警2.38天 → 缺货0天
// MKHDNZ0409260601527(日销0.13 库存1 待发1) 正常7.69天 → 缺货0天
// MKHDNZ0409260601376(日销0.27 库存3 待发3) 正常11.11天 → 缺货0天
// 全量分布:缺货 10→14、预警 6→4、正常 165→163(已排产 / 无销量不变)。
//
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const BOM_CACHE_TTL_MS = 2 * 60 * 1000;
const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数
// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。
const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000;
const COMPONENT_LOCATION_CHUNK_SIZE = 300;
let businessBomCache = null;
let officialBomIndexCache = null;
const officialBomDetailCache = new Map();
const componentLocationCache = new Map();
let productBomCache = null;
/**
* 缓存代际(epoch):`clearMkhBomCache()` 每次自增。
*
* 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据:
* 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询
* in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询
* 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的
* 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。
* 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。
*
* 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存,
* 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。
*/
let cacheEpoch = 0;
// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰
// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工,
// 又避免用户每刷新一次就把所有稳定表全量重扫。
let operationalCacheEpoch = 0;
let operationalRefreshPending = null;
let operationalRefreshedAt = 0;
const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000;
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
const MKH_WHERE = `s.sku_id LIKE N'MKH%'
AND s.item_type = N'成品'
AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'
AND EXISTS (
SELECT 1
FROM rpt.jst_skuBomItem parent_bom WITH (NOLOCK)
WHERE parent_bom.sku_id = s.sku_id
AND NULLIF(LTRIM(RTRIM(parent_bom.map_outer_sku_id)), N'') IS NOT NULL
)`;
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
const BRAND_WMS_CO_ID = 15870495;
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
// 两者必须成对修改;下方 SQL 已按 DAILY_SALE_COLUMN 插值,改常量即可生效。
const SALE_WINDOW_DAYS = 15;
// 日销取数列(rpt.pm_stockMonitor)—— 必须与 SALE_WINDOW_DAYS 保持一致
const DAILY_SALE_COLUMN = 'fifteenAvgSale';
// 预警阈值:现有库存可用天数低于 3 天即需补货
const REPLENISH_DAYS_THRESHOLD = 3;
const WARNING_LEVEL = Object.freeze({
OUT_OF_STOCK: '缺货',
WARNING: '预警',
SCHEDULED: '已排产',
NORMAL: '正常',
NO_SALE: '无销量',
});
// ── 在途加工单参数(见文件头 ⑧)──────────────────────────────────
// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓)
const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
// 作废状态:作废单据不参与在途量核算
const WORK_ORDER_VOID_STATUS = '作废';
// 收货状态:该值表示已全部入库,等价于「已完成」
const WORK_ORDER_RECEIVED_STATUS = '全部入库';
// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧)
const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders';
// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)──────────────
// 订单主表本地副本;含 `send_date`(发货日)、`shop_name`(店铺)与 `status`,
// 明细在 `skus` 文本列里。
// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表,
// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部需求。
// ⚠️ 待发货(⑩)与近15天已发货(⑦-a)**共用这一组表、共用同一次查询**。
const ORDER_TABLES = Object.freeze([
'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会
'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单
]);
// 不计入需求的订单状态:Cancelled 已取消 / Merged 已合并进别的单
const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']);
// 只捞含 MKH 的订单,避免全表扫描(实测 1.18M 行 + 4.91M 行,合并查询约 1.25s)
const PENDING_ORDER_SKU_LIKE = '%MKH%';
// 订单数据源异常时的降级缓存时长
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警
const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']);
// 日销兜底(⑦-a):**异常单**同样不能计入「已发货」——
// 2026-09-19 实测:店铺在 1 分钟内批量生成 18 张「实付 0.01 元 / 商品额 908 元全额免 /
// 备注 SSDD / 标签含「特殊单」」的单(同批还有占位码 X00005 的单),
// 在 `rpt.jst_salesOutboundInfo` 里**查不到任何出库记录**(同店其他单有 6467 行)——
// 即系统标了「已发货」但货没真出。它们曾把 10 个 SKU 的日销从 0 抬到 0.6~1.8。
// 判定:实付 ≤ 0.01 元,或标签含「特殊单」。
// ⚠️ **不要用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01;
const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']);
// ── 出库校验参数(见文件头 ⑦-c)──────────────────────────────────
// 出库表:判定「标了已发货的单到底有没有真出货」。两张表按渠道分开,**必须都查**——
// 2026-09-19 实测近 15 天窗口内 1,086 张 MKH 已发货单:京东表命中 889、天猫表命中 198,
// jst_qmcust_orderlist 来源的 208 张单里 7 张只有京东表有记录、1 张只有天猫表有记录。
const OUTBOUND_TABLES = Object.freeze([
'rpt.jst_salesOutboundInfo', // 京东 / 拼多多 / 抖音 等
'rpt.jst_qmcust_salesoutbound', // 天猫 / 淘宝 / 1688 等
]);
// 单个查询里塞多少个 o_id:1000 个参数远低于 MSSQL 单语句 2100 上限,
// 实测 1086 个订单号分 2 块并行查询合计 ≈ 515ms(整窗口拉全量 DISTINCT 要 8s,别那么干)
const OUTBOUND_LOOKUP_CHUNK_SIZE = 1000;
// 「拆分单」状态:父单被拆成子单后,出库记录挂在**子单**上,父单必然查不到出库。
// 2026-09-19 实测窗口内 40 张 Split 单 **100% 无出库记录** → 不能拿「无出库」判它造假。
const SPLIT_ORDER_STATUSES = Object.freeze(['split']);
// ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
const COMPONENT_STOCK_WMS_CO_ID = 15718196;
const COMPONENT_STOCK_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
// 单批编码数:每个编码占 1 个参数,300 远低于 MSSQL 单语句 2100 上限
const COMPONENT_STOCK_CHUNK_SIZE = 300;
const COMPONENT_STOCK_CACHE_TTL_MS = 10 * 60 * 1000;
// 配件库存不可用时的降级缓存时长(短一点,尽快恢复判定)
const COMPONENT_STOCK_DEGRADED_TTL_MS = 60 * 1000;
// 文案 / Tooltip 里最多列出的短板配件数
const COMPONENT_SHORTAGE_MAX_LIST = 3;
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
// 日期转 YYYY-MM-DD(按本地时区),用于监控日展示
const toDateText = (value) => {
if (value === null || value === undefined || 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())}`;
};
// 编码归一化:去横线/空格 + 大写,用于跨库匹配
const normalizeCode = (value) => {
const text = toText(value);
return text ? text.toUpperCase().replace(/[-\s]/g, '') : null;
};
// ── 补货预警:进程内索引(见文件头 ⑦)────────────────────────────
// ⚠️ 为什么不在 SQL 里关联:
// 实测(2026-09-17)在 ORDER BY / CASE 中对 OUTER APPLY 的列做 DECIMAL 算术,
// 会让 SQL Server 选到灾难性执行计划 ——
// · 按「可用天数」排序:128.8 秒
// · 统计预警条数(CASE 内引用 apply 列):97.1 秒
// · 同结构但只 COUNT:0.098 秒
// 而「一次全量取数 + 应用层计算」只要约 0.2 秒。
// 故 MKH 商品(4,408 条)与监控快照(4,411 行)一次性读入进程内索引,
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
const REPLENISH_PARAMS = Object.freeze({
brandWarehouse: DESIGNATED_WAREHOUSE,
brandWmsCoId: BRAND_WMS_CO_ID,
});
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够
const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
let mkhProductCache = null;
let replenishIndexCache = null;
// 加工单在途量索引(见文件头 ⑧)
let workOrderIndexCache = null;
// 待发货订单索引(见文件头 ⑩)
let orderIndexCache = null;
// 配件库存(按编码缓存,见文件头 ⑪)
const componentStockCache = new Map();
const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']);
const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase());
// 列表展示范围(内存过滤)
const SCOPES = Object.freeze({
WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all',
});
/**
* 解析展示范围:
* warning —— 仅需补货(缺货 + 3 天内预警)
* sold —— 有近15天日销的商品
* work-order —— 有未完成加工单(在途加工量 > 0)的商品
* all —— 全部 MKH 成品
* 兼容旧参数 warningOnly=1(等价 scope=warning)。
*/
function resolveScope(query = {}) {
const raw = String(query.scope ?? '').trim().toLowerCase();
if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.WORK_ORDER || raw === SCOPES.ALL) {
return raw;
}
if (parseFlag(query.warningOnly)) return SCOPES.WARNING;
return SCOPES.ALL;
}
const matchesKeyword = (row, keyword) => {
if (!keyword) return true;
const needle = keyword.toLowerCase();
return String(row.skuId || '').toLowerCase().includes(needle)
|| String(row.productName || '').toLowerCase().includes(needle);
};
function filterRows(rows, { keyword, scope }) {
return rows.filter((row) => {
if (scope === SCOPES.WARNING && !row.hasWarning) return false;
if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false;
if (scope === SCOPES.WORK_ORDER && !row.hasPendingWorkOrder) return false;
return matchesKeyword(row, keyword);
});
}
/**
* 排序:stockDays(可用天数)/ skuId。
* ⚠️ 无销量的商品 stockDays 为 null,无论升降序都恒定排在最后,
* 否则它们会占据「最紧急」的首屏位置。
*/
function sortRows(rows, field, order) {
const direction = order === 'DESC' ? -1 : 1;
const bySku = (a, b) => String(a.skuId || '').localeCompare(String(b.skuId || ''));
const list = [...rows];
if (field === 'stockDays') {
list.sort((a, b) => {
const av = toNumber(a.stockDays);
const bv = toNumber(b.stockDays);
if (av === null && bv === null) return bySku(a, b);
if (av === null) return 1;
if (bv === null) return -1;
if (av !== bv) return (av - bv) * direction;
return bySku(a, b);
});
return list;
}
list.sort((a, b) => bySku(a, b) * direction);
return list;
}
/**
* 把 componentDetails 原始 JSON 解析成统一结构。
*/
function parseComponents(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => {
if (!item || typeof item !== 'object') return null;
const barcode = toText(item.barcode);
const code = toText(item.code);
const name = toText(item.name) || barcode || code;
return {
barcode: barcode || code,
code,
name,
role: toText(item.role) || toText(item.assetType),
qty: toNumber(item.quantity) ?? 1,
};
})
.filter(Boolean);
}
/**
* 将业务中台组合商品明细整理成按 MKH 成品编码查找的 BOM。
* 同一成品、同一配件出现多行时合并数量,避免下钻重复展示。
*/
function groupBusinessBomRows(rows = []) {
const grouped = new Map();
rows.forEach((row) => {
const skuCode = toText(row.comboSkuId);
const skuKey = normalizeCode(skuCode);
const componentCode = toText(row.componentSkuId);
const componentKey = normalizeCode(componentCode);
if (!skuCode || !skuKey || !componentCode || !componentKey) return;
if (!grouped.has(skuKey)) {
grouped.set(skuKey, {
skuCode,
components: new Map(),
});
}
const entry = grouped.get(skuKey);
const previous = entry.components.get(componentKey);
const qty = toNumber(row.qty) ?? 1;
if (previous) {
previous.qty += qty;
if (!previous.name) previous.name = toText(row.componentName) || componentCode;
return;
}
entry.components.set(componentKey, {
barcode: componentCode,
code: componentCode,
name: toText(row.componentName) || componentCode,
role: null,
qty,
});
});
const bySku = new Map();
const entries = [];
grouped.forEach((entry, skuKey) => {
const components = Array.from(entry.components.values());
bySku.set(skuKey, components);
entries.push({
skuCode: entry.skuCode,
spu: null,
productName: entry.skuCode,
components,
});
});
return { bySku, bySpuPrefix: [], entries };
}
/**
* 将聚水潭正式商品 BOM 明细整理成按成品编码查找的结构。
* 同一 BOM 中重复的配件编码会合并数量;外部配件编码为空时忽略该行,
* 避免把聚水潭内部数字 ID 当成可展示、可查库存的配件编码。
*/
function groupOfficialBomRows(rows = []) {
const grouped = new Map();
rows.forEach((row) => {
const skuCode = toText(row.skuId);
const skuKey = normalizeCode(skuCode);
const componentCode = toText(row.componentSkuId);
const componentKey = normalizeCode(componentCode);
if (!skuCode || !skuKey || !componentCode || !componentKey) return;
if (!grouped.has(skuKey)) {
grouped.set(skuKey, {
skuCode,
components: new Map(),
});
}
const entry = grouped.get(skuKey);
const previous = entry.components.get(componentKey);
const qty = toNumber(row.qty) ?? 1;
if (previous) {
previous.qty += qty;
if (!previous.name) previous.name = toText(row.componentName) || componentCode;
return;
}
entry.components.set(componentKey, {
barcode: componentCode,
code: componentCode,
name: toText(row.componentName) || componentCode,
role: null,
qty,
});
});
const bySku = new Map();
const entries = [];
grouped.forEach((entry, skuKey) => {
const components = Array.from(entry.components.values());
bySku.set(skuKey, components);
entries.push({
skuCode: entry.skuCode,
spu: null,
productName: entry.skuCode,
components,
});
});
return { bySku, bySpuPrefix: [], entries };
}
/**
* 读取聚水潭正式 BOM 主表中的成品编码索引。
* 主表只有约四千行,只传输 sku_id;用于全局统计与来源优先级判断。
*/
async function loadOfficialBomIndex() {
const now = Date.now();
if (officialBomIndexCache?.value && officialBomIndexCache.expiresAt > now) {
return officialBomIndexCache.value;
}
if (officialBomIndexCache?.pending) return officialBomIndexCache.pending;
const pending = cloudDatabase.query(
`SELECT h.sku_id AS skuId
FROM rpt.jst_skuBomInfo h WITH (NOLOCK)
WHERE h.sku_id LIKE N'MKH%'`,
{},
{ requestTimeout: 180000 }
).then((rows) => {
const bySku = new Map();
rows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (key) bySku.set(key, []);
});
return { bySku, bySpuPrefix: [], entries: [] };
});
const epoch = cacheEpoch;
officialBomIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
// epoch 变了 ⇒ 期间用户点过「同步最新数据」,本次结果来自清缓存前的旧快照,
// 只还给发起它的请求,不写回缓存(否则会把新值覆盖成旧值)
if (epoch === cacheEpoch) {
officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) officialBomIndexCache = null;
throw error;
}
}
/**
* 按当前页面或单品所需 SKU 批量读取正式 BOM 明细。
* 单次最多接收 MAX_PAGE_SIZE 个 SKU,并缓存每个 SKU 的结果(包括空结果),
* 避免把远端约 4.9 万条明细在每次缓存刷新时全部传回应用服务器。
*/
async function loadOfficialBomForSkus(skuIds = []) {
const requested = Array.from(new Map(
skuIds
.map((skuId) => [normalizeCode(skuId), toText(skuId)])
.filter(([key, value]) => key && value)
).entries()).slice(0, MAX_PAGE_SIZE);
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const missing = requested.filter(([key]) => {
const cached = officialBomDetailCache.get(key);
return !cached || cached.expiresAt <= now;
});
if (missing.length > 0) {
const params = {};
const placeholders = missing.map(([, skuId], index) => {
params[`officialSku${index}`] = skuId;
return `@officialSku${index}`;
});
const rows = await cloudDatabase.query(
`SELECT
i.sku_id AS skuId,
i.map_outer_sku_id AS componentSkuId,
i.map_name AS componentName,
i.rm_qty AS qty
FROM rpt.jst_skuBomItem i WITH (NOLOCK)
WHERE i.sku_id IN (${placeholders.join(', ')})
AND NULLIF(LTRIM(RTRIM(i.map_outer_sku_id)), N'') IS NOT NULL
AND EXISTS (
SELECT 1
FROM rpt.jst_skuBomInfo h WITH (NOLOCK)
WHERE h.sku_id = i.sku_id
)`,
params,
{ requestTimeout: 180000 }
);
const grouped = groupOfficialBomRows(rows);
const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
missing.forEach(([key]) => {
if (writable) {
officialBomDetailCache.set(key, {
components: grouped.bySku.get(key) || [],
expiresAt,
});
}
});
}
const bySku = new Map();
requested.forEach(([key]) => {
const cached = officialBomDetailCache.get(key);
if (cached?.components?.length) bySku.set(key, cached.components);
});
return { bySku, bySpuPrefix: [], entries: [] };
}
/**
* 从业务中台读取 MKH 组合商品 BOM,作为正式商品 BOM 缺失时的降级来源。
*/
async function loadBusinessBom() {
const now = Date.now();
if (businessBomCache?.value && businessBomCache.expiresAt > now) {
return businessBomCache.value;
}
if (businessBomCache?.pending) return businessBomCache.pending;
const pending = Promise.all([
cloudDatabase.query(
`SELECT
c.combine_sku_id AS comboSkuId,
c.src_sku_id AS componentSkuId,
c.qty AS qty,
s.name AS componentName
FROM JSDataMiddlePlatform.dbo.jst_comboProductItem c
LEFT JOIN dbo.jst_sku s ON s.sku_id = c.src_sku_id
WHERE c.combine_sku_id LIKE N'MKH%'
ORDER BY c.combine_sku_id, c.Id`,
{},
{ requestTimeout: 180000 }
),
cloudDatabase.query(
`SELECT s.sku_id AS skuId, s.name AS productName
FROM dbo.jst_sku s
WHERE ${MKH_WHERE}`,
{},
{ requestTimeout: 180000 }
),
]).then(([rows, productRows]) => {
const bom = groupBusinessBomRows(rows);
const productNames = new Map();
productRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (key) productNames.set(key, toText(row.productName));
});
bom.existingSkuKeys = new Set(productNames.keys());
bom.entries.forEach((entry) => {
entry.productName = productNames.get(normalizeCode(entry.skuCode)) || entry.productName;
});
return bom;
});
const epoch = cacheEpoch;
businessBomCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) businessBomCache = null;
throw error;
}
}
/**
* 从产品资料系统数据库读取全部「有配件明细」的模块化商品。
* 返回 { bySku: Map, bySpuPrefix: Array, entries: Array }
* 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。
*/
async function loadPgBom() {
const now = Date.now();
if (productBomCache?.value && productBomCache.expiresAt > now) {
return productBomCache.value;
}
if (productBomCache?.pending) return productBomCache.pending;
const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] };
const sql = `
SELECT
p.id AS product_id,
p.spu AS spu,
COALESCE(s->>'skuCode', s->>'barcode') AS sku_code,
s->'componentDetails' AS components
FROM public."IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
const pending = (async () => {
let rows = [];
try {
rows = await productDb.query(sql);
} catch (error) {
console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message);
return empty;
}
// 商品名称(资料库档案表,取不到不影响主流程)
const nameMap = new Map();
try {
const nameRows = await productDb.query(
`SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`);
nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName)));
} catch (error) {
console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message);
}
const bySku = new Map();
const bySpuPrefix = [];
const entries = [];
rows.forEach((row) => {
const components = parseComponents(row.components);
if (components.length === 0) return;
const skuCode = toText(row.sku_code);
const spu = toText(row.spu);
const skuKey = normalizeCode(skuCode);
if (skuKey) bySku.set(skuKey, components);
const spuKey = normalizeCode(spu);
if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu });
entries.push({
skuCode,
spu,
productName: nameMap.get(toText(row.product_id)) || spu || skuCode,
components,
});
});
// 前缀长优先,避免短前缀抢先命中
bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length);
return { bySku, bySpuPrefix, entries };
})();
const epoch = cacheEpoch;
productBomCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) productBomCache = null;
throw error;
}
}
/**
* 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。
*/
function matchComponents(bom, skuId) {
const key = normalizeCode(skuId);
if (!key) return null;
const exact = bom.bySku.get(key);
if (exact) return { components: exact, matchType: 'sku' };
const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix));
if (hit) return { components: hit.components, matchType: 'spu' };
return null;
}
function matchBomSources(officialBom, businessBom, productBom, skuId) {
const officialMatch = matchComponents(officialBom, skuId);
if (officialMatch) return { ...officialMatch, dataSource: 'official_bom' };
const businessMatch = matchComponents(businessBom, skuId);
if (businessMatch) return { ...businessMatch, dataSource: 'business_bom' };
const productMatch = matchComponents(productBom, skuId);
if (productMatch) return { ...productMatch, dataSource: 'product_db' };
return null;
}
function summarizeConfiguredBoms(officialBom, businessBom, productBom) {
const summary = { official: 0, business: 0, product: 0, total: 0 };
(businessBom?.existingSkuKeys || new Set()).forEach((skuKey) => {
if (matchComponents(officialBom, skuKey)) {
summary.official += 1;
summary.total += 1;
return;
}
if (matchComponents(businessBom, skuKey)) {
summary.business += 1;
summary.total += 1;
return;
}
if (matchComponents(productBom, skuKey)) {
summary.product += 1;
summary.total += 1;
}
});
return summary;
}
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
/**
* 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。
*
* 口径(见文件头 ⑦ + ⑦-a + ⑩ + ⑫):
* ⓪ 日销取值:上游快照(⑦);**快照 ≤ 0 且近15天有真实发货时**改用订单实算值(⑦-a),
* 结果带 `dailySaleSource`('snapshot' / 'orders')与 `snapshotDailySale` 便于对照。
* 下面 ① / ② 用的 `dailySale` 即此处的最终取值。
* ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警
* 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫)
* 结余库存 = 0 → 缺货(订单都发不出去)
* ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩):
* 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警
* 两者都不满足 → 无销量,不参与预警。
* 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。
*
* @param {object} row
* row.avgSale 上游近15天日均发货量(rpt.pm_stockMonitor.fifteenAvgSale)
* row.recentShippedQty 近15天已发货件数(订单表实算,⑦-a;已排除样品/测试店)
* → 快照 ≤ 0 且该值 > 0 时,日销改用 recentShippedQty ÷ 15
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
* row.monitorStock 监控快照库存(fallback)
* row.safetyDays 源系统安全天数(仅展示)
* row.suggestStock 源系统建议备货量(仅展示对照)
* row.monitorDate 监控快照日期
* row.pendingWorkOrderQty 在途加工量(未完成且非作废)
* row.pendingOrderQty 待发货订单量(未发货订单件数)
*/
function buildReplenishMetrics(row = {}) {
// 日销 = 上游快照(⑦) + 订单兜底(⑦-a):
// 上游 fifteenAvgSale 会漏算,**只在快照 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货时**
// 才改用订单实算值(已发货件数 ÷ 天数);快照 > 0 一律以上游为准,不覆盖。
const snapshotDailySale = Math.max(0, toNumber(row.avgSale) ?? 0);
const recentShippedQty = Math.max(0, toNumber(row.recentShippedQty) ?? 0);
const orderDailySale = recentShippedQty > 0
? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4))
: 0;
const preferLiveData = row.preferLiveData === true;
const useOrderDailySale = preferLiveData || (snapshotDailySale <= 0 && orderDailySale > 0);
const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale;
// 现有库存来源优先级(见文件头 ⑦-d):
// inventoryInfo 品牌仓有货 → 用它(旧口径不变)
// 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区)
// 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照)
// 否则回退监控快照
const realtimeStock = toNumber(row.realtimeStock);
const packStock = toNumber(row.packStock);
const monitorStock = toNumber(row.monitorStock) ?? 0;
const hasRealtime = realtimeStock !== null;
let stock;
let stockSource;
if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) {
const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0;
const packValue = packStock !== null && packStock > 0 ? packStock : 0;
stock = Math.max(realtimeValue, packValue);
stockSource = packValue > realtimeValue ? 'pack' : 'realtime';
} else if (hasRealtime) {
stock = realtimeStock;
stockSource = 'realtime';
} else if (!preferLiveData) {
stock = monitorStock;
stockSource = 'monitor';
} else {
// 手动实时刷新不能再退回延迟快照;两个当前库存源均无记录时按 0 展示并明确标记来源。
stock = 0;
stockSource = 'live-missing';
}
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0);
const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0);
const hasPendingWorkOrder = pendingWorkOrderQty > 0;
// 待发货订单(见文件头 ⑩):当前未发货订单里的件数
const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0);
const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0);
// 快照日销还是 0、但已经有订单 → 由订单驱动预警(⑩ 专属分支,分母用代发订单件数)
const orderDriven = dailySale <= 0 && pendingOrderQty > 0;
// 结余库存(见文件头 ⑫):已下单未发货的部分已被占用,先扣掉才是可自由支配的库存。
// ⚠️ 不分日销是否为 0,所有口径统一按结余库存计算(2026-09-18 口径修正)。
const availableStock = Math.max(0, stock - pendingOrderQty);
const base = {
dailySale,
// 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底
dailySaleSource: preferLiveData ? 'orders-live' : (useOrderDailySale ? 'orders' : 'snapshot'),
// 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效)
snapshotDailySale,
// 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店)
recentShippedQty,
stock,
availableStock,
pendingWorkOrderQty,
pendingWorkOrderCount,
hasPendingWorkOrder,
pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta),
pendingWorkOrderNo: toText(row.pendingWorkOrderNo),
pendingOrderQty,
pendingOrderCount,
pendingOrderEarliest: toDateText(row.pendingOrderEarliest),
orderDriven,
thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)),
stockSource,
safetyDays: toNumber(row.safetyDays),
suggestStock: toNumber(row.suggestStock) ?? 0,
monitorDate: toDateText(row.monitorDate),
};
// 既无近15天日销、也无未发货订单 → 不参与预警
if (dailySale <= 0 && !orderDriven) {
return {
...base,
thresholdQty: 0,
stockDays: null,
replenishQty: 0,
warningLevel: WARNING_LEVEL.NO_SALE,
warningOrder: 5,
hasWarning: false,
};
}
// 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动)
if (hasPendingWorkOrder) {
return {
...base,
stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null,
replenishQty: 0,
warningLevel: WARNING_LEVEL.SCHEDULED,
warningOrder: 4,
hasWarning: false,
};
}
// 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存
// → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警
// ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、
// ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0)
if (orderDriven) {
const orderStockDays = stock / pendingOrderQty;
let level;
let replenishQty;
if (orderStockDays <= 0) {
// 完全没货(库存 ≤ 0):缺货,建议补到能覆盖整笔代发订单
level = WARNING_LEVEL.OUT_OF_STOCK;
replenishQty = Math.max(0, pendingOrderQty - stock);
} else if (orderStockDays < REPLENISH_DAYS_THRESHOLD) {
// 有货但可用天数不足 3 天:预警,建议补到 3 天缓冲(3 × 代发件数 − 现有库存)
level = WARNING_LEVEL.WARNING;
replenishQty = Math.max(0, Math.ceil(REPLENISH_DAYS_THRESHOLD * pendingOrderQty - stock));
} else {
// 可用天数 ≥ 3 天:库存充足,不预警
level = WARNING_LEVEL.NORMAL;
replenishQty = 0;
}
return {
...base,
// 可用天数统一记录(保留 2 位小数,用于排序与展示)
stockDays: Number(orderStockDays.toFixed(2)),
replenishQty,
warningLevel: level,
// 排序权重:缺货 1 < 预警 2 < 正常 3(与日销分支一致)
warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1
: (level === WARNING_LEVEL.WARNING ? 2 : 3),
hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING,
};
}
// 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉
const stockDays = availableStock / dailySale;
// 建议补货量 = 3 天日销缓冲 + 待发货订单缺口(先补到能把订单发掉,再补 3 天缓冲)
const replenishQty = Math.max(0, Math.ceil(base.thresholdQty + pendingOrderQty - stock));
const level = availableStock <= 0
? WARNING_LEVEL.OUT_OF_STOCK
: (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL);
return {
...base,
stockDays: Number(stockDays.toFixed(2)),
replenishQty,
warningLevel: level,
// 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5
warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3),
hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING,
};
}
/**
* 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。
* 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
* 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。
* @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级)
* @param {Map<string, Array>} bomBySku 归一化成品编码 → 配件明细
* @returns {Map<string, number>} 配件编码 → 被占用量
*/
function buildComponentOccupation(workOrders = [], bomBySku) {
const occupation = new Map();
if (!bomBySku || typeof bomBySku.get !== 'function') return occupation;
workOrders.forEach((workOrder) => {
const pending = Math.max(0, toNumber(workOrder?.pendingWorkOrderQty) ?? 0);
if (pending <= 0) return;
const key = normalizeCode(workOrder.skuId);
const components = key ? bomBySku.get(key) : null;
if (!Array.isArray(components) || components.length === 0) return;
components.forEach((component) => {
const code = toText(component?.barcode) || toText(component?.code);
if (!code) return;
const perQty = Math.max(1, toNumber(component.qty) ?? 1);
occupation.set(code, (occupation.get(code) || 0) + pending * perQty);
});
});
return occupation;
}
/**
* 配件可组装数 / 配件不足判定(纯函数,见文件头 ⑪)。
* 可组装数 = min over 配件 ⌊ max(0, 库存 − 加工单占用) ÷ 单台用量 ⌋
* 「木桶短板」决定能组装几台;可组装数 = 0 即配件不足、无法补货。
* @param {Array} components 配件明细 [{ barcode, code, name, qty }]
* @param {Map<string, number>} stockByCode 配件现有库存
* @param {Map<string, number>} occupiedByCode 加工单占用
* @returns {null | {assemblableQty:number, shortage:boolean, bottlenecks:Array}} 无配件明细时返回 null(未知 ≠ 不足)
*/
function buildComponentReadiness(components, stockByCode, occupiedByCode) {
if (!Array.isArray(components) || components.length === 0) return null;
let assemblable = Infinity;
const bottlenecks = [];
components.forEach((component) => {
const code = toText(component?.barcode) || toText(component?.code);
if (!code) return;
const perQty = Math.max(1, toNumber(component.qty) ?? 1);
const stock = Math.max(0, toNumber(stockByCode?.get(code)) ?? 0);
const occupied = Math.max(0, toNumber(occupiedByCode?.get(code)) ?? 0);
const available = Math.max(0, stock - occupied);
const can = Math.floor(available / perQty);
if (can < assemblable) assemblable = can;
bottlenecks.push({
code,
name: toText(component.name) || code,
perQty,
stock,
occupied,
available,
can,
});
});
if (!Number.isFinite(assemblable)) return null;
// 短板优先(可组装数最少),同分按编码稳定排序
bottlenecks.sort((a, b) => a.can - b.can || a.code.localeCompare(b.code));
return {
assemblableQty: assemblable,
shortage: assemblable <= 0,
bottlenecks: bottlenecks.slice(0, COMPONENT_SHORTAGE_MAX_LIST),
};
}
/**
* MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。
* 返回 [{ skuId, productName, category, material }],按编码升序。
*/
async function loadMkhProductIndex() {
const now = Date.now();
if (mkhProductCache?.value && mkhProductCache.expiresAt > now) {
return mkhProductCache.value;
}
if (mkhProductCache?.pending) return mkhProductCache.pending;
const pending = cloudDatabase.query(
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material
FROM dbo.jst_sku s
WHERE ${MKH_WHERE}
ORDER BY s.sku_id ASC`,
{},
{ requestTimeout: 180000 }
).then((rows) => rows.map((row) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
category: toText(row.category),
material: toText(row.material),
})).filter((row) => row.skuId));
const epoch = cacheEpoch;
mkhProductCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) mkhProductCache = null;
throw error;
}
}
/**
* 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。
* 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。
* ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑),
* 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。
*/
async function loadPendingWorkOrderIndex() {
const now = Date.now();
if (workOrderIndexCache?.value && workOrderIndexCache.expiresAt > now) {
return workOrderIndexCache.value;
}
if (workOrderIndexCache?.pending) return workOrderIndexCache.pending;
const pending = (async () => {
// ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`,
// 跨库四段名会被改坏(见文件头 ⑧)。
const pool = await cloudDatabase.getPool();
const request = pool.request();
request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE);
request.input('voidStatus', WORK_ORDER_VOID_STATUS);
const result = await request.query(`
SELECT [商品编码] AS skuId,
SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) AS pendingQty,
COUNT(DISTINCT CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN [加工单号]
END) AS orderCount,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111)
END) AS eta,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([加工单号] AS NVARCHAR(40))
END) AS firstOrderNo
FROM ${WORK_ORDER_TABLE} WITH (NOLOCK)
WHERE [加工仓储方] = @workOrderWarehouse
AND [状态] <> @voidStatus
GROUP BY [商品编码]
HAVING SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) > 0`);
const bySku = new Map();
(result.recordset || []).forEach((row) => {
const key = normalizeCode(row.skuId);
if (!key) return;
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0);
if (pendingQty <= 0) return;
bySku.set(key, {
// 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留
skuId: toText(row.skuId),
pendingWorkOrderQty: pendingQty,
pendingWorkOrderCount: orderCount,
pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null,
pendingWorkOrderNo: toText(row.firstOrderNo),
});
});
return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
})().catch((error) => {
console.warn('[MKH-BOM] 加工单在途量读取失败,已按「无在途加工单」降级:', error.message);
return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS };
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null;
throw error;
}
}
/**
* 解析 jst_Orders.skus 文本列 → [{ code, qty }](纯函数,便于回归测试)。
* 格式:`<总件数>.<条码>[*数量][,<条码>[*数量]]…`
* 例:`1.MKHDNZ0409260700426` → 1 个成品 1 件
* `2.A*1,B*1` → A、B 各 1 件
* `3.C*1,D*1,E*1` → 总件数前缀只作校验,不参与计算
* · 省略 `*数量` 时按 1 件;解析不了的片段直接丢弃(可用率实测见文件头 ⑩)
*/
function parseOrderSkus(skus) {
const text = toText(skus);
if (!text) return [];
const body = text.replace(/^\d+\./, '');
const items = [];
body.split(',').forEach((part) => {
const segment = part.trim();
if (!segment) return;
const matched = segment.match(/^(.*?)(?:\*(\d+))?$/);
if (!matched) return;
const code = toText(matched[1]);
if (!code) return;
const parsedQty = matched[2] ? Number(matched[2]) : 1;
items.push({ code, qty: Number.isFinite(parsedQty) && parsedQty > 0 ? parsedQty : 1 });
});
return items;
}
/** 是否为 MKH **成品**编码(`MKH-` 打头的是组合装,见文件头 ⑩,不计入成品需求) */
const isMkhProductCode = (code) => {
const text = toText(code);
return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text);
};
/** 是否为「样品 / 测试专用店铺」—— 上游日销不计这些单,兜底也必须排除(见文件头 ⑦-a) */
const isExcludedSaleShop = (shopName) => {
const text = toText(shopName);
if (!text) return false;
return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword));
};
/**
* 是否为「异常单」—— 标了已发货但货没真出的单,不能计入兜底日销(见文件头 ⑦-a)。
* ① 标签含「特殊单」;
* ② 实付金额 ≤ 0.01 元(0.01 元全免单)。
* ⚠️ 金额未知(null / 非数字)时**不排除** —— 宁可用数据也别误杀,取不到就退化成旧口径。
*/
const isExcludedSaleOrder = (row = {}) => {
const labels = toText(row.labels);
if (labels && SALE_EXCLUDED_ORDER_LABEL_KEYWORDS.some((keyword) => labels.includes(keyword))) {
return true;
}
const paid = toNumber(row.payAmount);
return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX;
};
/** 是否为「拆分单」——父单的出库记录挂在子单上,父单查不到出库属正常(见 ⑦-c) */
const isSplitOrder = (status) => {
const text = toText(status);
return Boolean(text) && SPLIT_ORDER_STATUSES.includes(text.toLowerCase());
};
/** 发货日是否为**今天** —— 当天出库尚未同步完成,不能用出库判据(见 ⑦-c) */
const isShippedToday = (sendDate) => {
const date = sendDate instanceof Date ? sendDate : new Date(sendDate);
if (Number.isNaN(date.getTime())) return false;
const today = new Date();
return date.getFullYear() === today.getFullYear()
&& date.getMonth() === today.getMonth()
&& date.getDate() === today.getDate();
};
/**
* 该已发货单能否用「出库记录」来判定真伪(见 ⑦-c)。
* 不能判定的两种情形,退回只按 ⑦-a 的金额/标签判据,**不做无出库排除**,避免误杀:
* · 拆分单:出库挂在子单上,父单必然查不到
* · 今天发货:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行)
* 取不到订单号时同样豁免——宁可漏杀,不可错杀。
*/
const isOutboundCheckSkipped = (row = {}) => (
isSplitOrder(row.status)
|| isShippedToday(row.sendDate)
|| !toText(row.oId)
);
/**
* 批量查「这些订单号在出库表里有没有实际出库记录」(见 ⑦-c)。
* 只取 o_id 一列、分块并行查,失败时返回空集合 + 错误信息让调用方降级。
* ⚠️ 只判**有没有行**:`qty` 列全表 1,119,606 行恒为 0,`SUM(qty)` 永远得 0,别用它。
*/
async function loadOutboundOrderIds(orderIds) {
const ids = [...new Set((orderIds || []).map((id) => toText(id)).filter(Boolean))];
if (!ids.length) return { ids: new Set(), error: null };
const chunks = [];
for (let index = 0; index < ids.length; index += OUTBOUND_LOOKUP_CHUNK_SIZE) {
chunks.push(ids.slice(index, index + OUTBOUND_LOOKUP_CHUNK_SIZE));
}
const settled = await Promise.allSettled(chunks.map((chunk) => {
const params = {};
const placeholders = chunk
.map((value, index) => { params[`outboundOid${index}`] = value; return `@outboundOid${index}`; })
.join(', ');
const union = OUTBOUND_TABLES
.map((table) => `SELECT o_id AS oId FROM ${table} WITH (NOLOCK) WHERE o_id IN (${placeholders})`)
.join('\n UNION\n ');
return cloudDatabase.query(
`SELECT DISTINCT outbound_rows.oId AS oId
FROM (
${union}
) outbound_rows`,
params,
{ requestTimeout: 180000 }
);
}));
const hits = new Set();
const failed = [];
settled.forEach((result, index) => {
if (result.status !== 'fulfilled') {
failed.push(`出库表分块#${index + 1}: ${result.reason?.message || result.reason}`);
return;
}
result.value.forEach((row) => {
const text = toText(row.oId);
if (text) hits.add(text);
});
});
return { ids: hits, error: failed.length ? failed.join(' | ') : null };
}
/**
* 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a):
* pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
* shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
* 口径:
* · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且
* ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c)
* · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
* · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
*/
async function loadOrderIndex() {
const now = Date.now();
if (orderIndexCache?.value && orderIndexCache.expiresAt > now) {
return orderIndexCache.value;
}
if (orderIndexCache?.pending) return orderIndexCache.pending;
const statusParams = {};
const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => {
statusParams[`pendingOrderStatus${index}`] = status;
return `@pendingOrderStatus${index}`;
}).join(', ');
// 两张订单表**并行**读取后合并(见 ⑩:渠道互不重叠,只读一张会漏需求)
// ⚠️ 一条查询同时覆盖「待发货」(send_date IS NULL)与「近 N 天已发货」(send_date 在窗口内):
// 两者共用同一次表扫描,拆成两条查询会在现有开销之上再加一次全表扫描(实测约 +1.25s)。
// 窗口取含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径。
const pending = Promise.allSettled(ORDER_TABLES.map((table) => cloudDatabase.query(
`SELECT o.o_id AS oId,
o.order_date AS orderDate,
o.send_date AS sendDate,
o.shop_name AS shopName,
o.pay_amount AS payAmount,
o.labels AS labels,
o.status AS status,
o.skus AS skus
FROM ${table} o WITH (NOLOCK)
WHERE o.skus LIKE @pendingOrderSkuLike
AND o.status NOT IN (${statusPlaceholders})
AND (
o.send_date IS NULL
OR (
o.send_date >= DATEADD(DAY, -${SALE_WINDOW_DAYS - 1}, CAST(GETDATE() AS date))
AND o.send_date < DATEADD(DAY, 1, CAST(GETDATE() AS date))
)
)`,
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
{ requestTimeout: 180000 }
))).then(async (results) => {
const pendingBySku = new Map();
const shippedBySku = new Map();
const seenOrderIds = new Set();
const failed = [];
const shippedRows = [];
const pendingRows = [];
const outboundCheckIds = [];
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
failed.push(`${ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`);
return;
}
result.value.forEach((row) => {
// 两表 o_id 实测零交集(2026-09-19 校验),去重只是防御:
// 万一上游把同一张单补录进另一张表,也不会重复计件。
const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId);
if (orderId !== null) {
if (seenOrderIds.has(orderId)) return;
seenOrderIds.add(orderId);
}
// 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除
const shipped = row.sendDate !== null && row.sendDate !== undefined;
if (shipped && isExcludedSaleShop(row.shopName)) return;
// 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销
if (shipped && isExcludedSaleOrder(row)) return;
if (shipped) {
shippedRows.push(row);
// ⑦-c:可用于出库校验的单先记下订单号,第二遍统一过滤
if (!isOutboundCheckSkipped(row)) outboundCheckIds.push(orderId);
return;
}
pendingRows.push(row);
});
});
// ⑦-c:已发货单再过一道「有没有真出库」。
// 出库数据取不到(或只取到一部分)时**整体不做出库排除**,退化成只按金额/标签判据——
// 宁可漏杀不可错杀:漏杀只是多一条预警,错杀会让真实缺货彻底隐身。
let outboundReady = false;
let outboundIds = new Set();
if (outboundCheckIds.length) {
const outbound = await loadOutboundOrderIds(outboundCheckIds);
if (outbound.error) {
console.warn('[MKH-BOM] 出库数据源读取失败,本次跳过出库校验:', outbound.error);
failed.push(outbound.error);
} else {
outboundReady = true;
outboundIds = outbound.ids;
}
}
shippedRows.forEach((row) => {
const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId);
// ⑦-c:能判定、却没查到出库 → 系统标了「已发货」但货没真出,不计入销量
if (outboundReady && !isOutboundCheckSkipped(row) && !outboundIds.has(orderId)) return;
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return;
const key = normalizeCode(code);
if (!key) return;
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
});
});
pendingRows.forEach((row) => {
const orderDate = row.orderDate instanceof Date ? row.orderDate : null;
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return;
const key = normalizeCode(code);
if (!key) return;
const entry = pendingBySku.get(key) || {
pendingOrderQty: 0,
pendingOrderCount: 0,
pendingOrderEarliest: null,
};
entry.pendingOrderQty += qty;
entry.pendingOrderCount += 1;
if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) {
entry.pendingOrderEarliest = orderDate;
}
pendingBySku.set(key, entry);
});
});
if (failed.length) {
// 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出,
// 并用短 TTL 尽快重试补齐。
console.warn('[MKH-BOM] 订单数据源部分读取失败,需求与日销口径可能不完整:', failed.join(' | '));
return {
pendingBySku,
shippedBySku,
degraded: true,
error: failed.join(' | '),
ttl: PENDING_ORDER_DEGRADED_TTL_MS,
};
}
return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null;
throw error;
}
}
/**
* 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。
* 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。
*/
async function loadReplenishIndex() {
const now = Date.now();
if (replenishIndexCache?.value && replenishIndexCache.expiresAt > now) {
return replenishIndexCache.value;
}
if (replenishIndexCache?.pending) return replenishIndexCache.pending;
const pending = Promise.all([
// 监控快照:同一天只有一行,按 date DESC 取首条即可
cloudDatabase.query(
`SELECT m.sku_id AS skuId, m.${DAILY_SALE_COLUMN} AS fifteenAvgSale, m.jsStock AS monitorStock,
m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate
FROM dbo.pm_stockMonitor m
WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%'
ORDER BY m.sku_id ASC, m.date DESC`,
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
cloudDatabase.query(
`SELECT i.sku_id AS skuId, 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`,
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
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%'
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`,
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 在途加工单(见文件头 ⑧)
loadPendingWorkOrderIndex(),
// 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询
loadOrderIndex(),
]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => {
const workOrderBySku = workOrderIndex.bySku;
const pendingOrderBySku = orderIndex.pendingBySku;
const shippedBySku = orderIndex.shippedBySku;
const latestStock = new Map();
stockRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty));
});
const packStock = new Map();
packStockRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (key) packStock.set(key, toNumber(row.qty));
});
const seenMonitor = new Set();
const bySku = new Map();
const liveBySku = new Map();
let monitorDate = null;
monitorRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (!key || seenMonitor.has(key)) return;
seenMonitor.add(key);
if (!monitorDate) monitorDate = toDateText(row.monitorDate);
const metricInput = {
// 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据)
avgSale: row.fifteenAvgSale,
// 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量
recentShippedQty: shippedBySku.get(key) || 0,
// 实时库存缺失(而非为 0)时才回退监控快照
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
// 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有
packStock: packStock.has(key) ? packStock.get(key) : null,
monitorStock: row.monitorStock,
safetyDays: row.safetyDays,
suggestStock: row.suggestStock,
monitorDate: row.monitorDate,
...(workOrderBySku.get(key) || null),
...(pendingOrderBySku.get(key) || null),
};
bySku.set(key, buildReplenishMetrics(metricInput));
liveBySku.set(key, buildReplenishMetrics({ ...metricInput, preferLiveData: true }));
});
return {
bySku,
liveBySku,
latestStock,
packStock,
workOrderBySku,
pendingOrderBySku,
shippedBySku,
workOrderDegraded: workOrderIndex.degraded === true,
pendingOrderDegraded: orderIndex.degraded === true,
monitorDate,
};
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
replenishIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null;
throw error;
}
}
/**
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
* · 按基础配件编码精确匹配,SUM(qty) 汇总
* · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径):
* ① jst_inventoryInfo 有正库存 → 以它为准
* ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底
* (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足)
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
* 且失败的分片不写缓存,下次调用重试
* @returns {Promise<{stockMap: Map<string, number>, degraded: boolean}>}
*/
async function loadComponentStock(codes = []) {
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
const stockMap = new Map();
const missing = [];
unique.forEach((code) => {
const hit = componentStockCache.get(code);
if (hit && hit.expiresAt > now) stockMap.set(code, hit.qty);
else missing.push(code);
});
if (missing.length === 0) return { stockMap, degraded: false };
for (let i = 0; i < missing.length; i += COMPONENT_STOCK_CHUNK_SIZE) {
const chunk = missing.slice(i, i + COMPONENT_STOCK_CHUNK_SIZE);
try {
const params = { componentStockWmsCoId: COMPONENT_STOCK_WMS_CO_ID };
const placeholders = chunk.map((code, index) => {
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 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 ${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 inventoryQty = new Map();
const packQty = new Map();
rows.forEach((row) => {
const code = toText(row.code);
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) => {
// 只补 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);
});
} catch (error) {
console.warn('[MKH-BOM] 配件库存查询失败,本次不做配件不足判定:', error.message);
return { stockMap, degraded: true };
}
}
return { stockMap, degraded: false };
}
/**
* 批量计算商品的可组装数(见文件头 ⑪):列表 / 推送共用,防止口径漂移。
* 依赖三份已缓存的索引,本身不再单独缓存:
* · 成品 BOM 明细 —— loadOfficialBomForSkuBatches(按 SKU 缓存 15 分钟)
* · 配件现有库存 —— loadComponentStock(按编码缓存 10 分钟)
* · 加工单占用 —— loadPendingWorkOrderIndex(缓存 10 分钟)按 BOM 展开
* @param {string[]} skuIds 成品编码(只传缺货 / 预警的商品,避免全量扫描)
* @returns {Promise<{bySku: Map<string, object>, degraded: boolean}>} key = 归一化成品编码
*/
async function loadMkhComponentReadiness(skuIds = []) {
const requested = Array.from(new Set(skuIds.map((skuId) => toText(skuId)).filter(Boolean)));
if (requested.length === 0) return { bySku: new Map(), degraded: false };
const [alertBom, workOrderIndex] = await Promise.all([
loadOfficialBomForSkuBatches(requested),
loadPendingWorkOrderIndex(),
]);
// 配件全集:预警商品自己的配件 + 加工单商品的配件(后者用于算占用)
const workOrders = [...workOrderIndex.bySku.values()];
const workOrderSkuIds = workOrders.map((item) => item.skuId).filter(Boolean);
const workOrderBom = workOrderSkuIds.length > 0
? await loadOfficialBomForSkuBatches(workOrderSkuIds)
: { bySku: new Map() };
const occupation = buildComponentOccupation(workOrders, workOrderBom.bySku);
const codes = new Set(occupation.keys());
alertBom.bySku.forEach((components) => {
components.forEach((component) => {
const code = toText(component?.barcode) || toText(component?.code);
if (code) codes.add(code);
});
});
const { stockMap, degraded } = await loadComponentStock([...codes]);
// 配件库存不可用 → 整批放弃判定(组件缺失 ≠ 配件不足,误标会误导业务)
if (degraded) return { bySku: new Map(), degraded: true };
const bySku = new Map();
alertBom.bySku.forEach((components, key) => {
const readiness = buildComponentReadiness(components, stockMap, occupation);
if (readiness) bySku.set(key, readiness);
});
return { bySku, degraded: false };
}
/**
* MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。
* 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。
*/
async function buildMergedReplenishRows(options = {}) {
const preferLiveData = options.preferLiveData === true;
const [products, replenishIndex] = await Promise.all([
loadMkhProductIndex(),
loadReplenishIndex(),
]);
const rows = products.map((product) => {
const key = normalizeCode(product.skuId);
const metrics = (preferLiveData ? replenishIndex.liveBySku : replenishIndex.bySku).get(key);
if (metrics) return { ...product, ...metrics };
// 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与
// 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源
return {
...product,
...buildReplenishMetrics({
preferLiveData,
realtimeStock: replenishIndex.latestStock?.has(key)
? replenishIndex.latestStock.get(key)
: null,
packStock: replenishIndex.packStock?.has(key)
? replenishIndex.packStock.get(key)
: null,
...(replenishIndex.workOrderBySku?.get(key) || null),
...(replenishIndex.pendingOrderBySku?.get(key) || null),
recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0,
}),
};
});
// 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算,
// 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。
const alertSkuIds = rows.filter((row) => row.hasWarning).map((row) => row.skuId);
const readiness = alertSkuIds.length > 0
? await loadMkhComponentReadiness(alertSkuIds)
: { bySku: new Map(), degraded: false };
rows.forEach((row) => {
const hit = row.hasWarning ? readiness.bySku.get(normalizeCode(row.skuId)) : null;
// 未判定(非预警 / 无 BOM 明细 / 配件库存降级)一律保持 false + null,前端据此显示「—」
row.componentShortage = hit?.shortage === true;
row.assemblableQty = hit ? hit.assemblableQty : null;
row.bottleneckComponents = hit ? hit.bottlenecks : [];
});
return {
rows,
replenishIndex,
componentStockDegraded: readiness.degraded === true,
};
}
/**
* 补货预警清单(全量、不分页)—— 供定时推送任务使用。
* scope = 'warning'(默认)→ 缺货 + 3 天内预警
* scope = 'outOfStock' → 仅缺货
* ⚠️ 返回的是「需要补货」的商品,不包含已被未完成加工单豁免的「已排产」商品。
*/
async function listReplenishAlertRows(options = {}) {
const scope = toText(options.scope);
const levels = scope === 'outOfStock'
? [WARNING_LEVEL.OUT_OF_STOCK]
: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING];
const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows();
const alerts = rows.filter((row) => levels.includes(row.warningLevel));
// 最紧急的排前面:缺货 → 可用天数最少 → 编码升序
alerts.sort((a, b) => (
(a.warningOrder || 9) - (b.warningOrder || 9)
|| ((toNumber(a.stockDays) ?? 0) - (toNumber(b.stockDays) ?? 0))
|| String(a.skuId).localeCompare(String(b.skuId))
));
return {
rows: alerts,
totalCount: alerts.length,
outOfStockCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length,
warningOnlyCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.WARNING).length,
replenishQtyTotal: Number(
alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2)
),
// 订单驱动预警(日销=0 但有未发货订单)的条数,见文件头 ⑩
orderDrivenCount: alerts.filter((row) => row.orderDriven).length,
pendingOrderQtyTotal: Number(
alerts.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2)
),
monitorDate: replenishIndex.monitorDate,
workOrderDegraded: replenishIndex.workOrderDegraded === true,
pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true,
// 配件不足(见文件头 ⑪):可组装数 = 0、补不出货的商品数
componentShortageCount: alerts.filter((row) => row.componentShortage).length,
componentStockDegraded: componentStockDegraded === true,
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
saleWindowDays: SALE_WINDOW_DAYS,
scope: scope === 'outOfStock' ? 'outOfStock' : 'warning',
};
}
function matchesPackSku(componentCode, packSkuCode) {
const component = toText(componentCode)?.toUpperCase();
const packSku = toText(packSkuCode)?.toUpperCase();
if (!component || !packSku) return false;
return packSku === component || packSku.startsWith(`${component}-`);
}
function formatPackLocation(row) {
const warehouseName = toText(row.warehouseName);
const warehouseId = toText(row.warehouseId);
// 仓库优先显示名称(wms_co_id → jst_warehouseInfo.wms_co_id → name),取不到时退回 id。
// ⚠️ 关联列是 wms_co_id 不是 wh_id,见文件头 ⑤(09-19 修正)
const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : '');
const binGroup = toText(row.binGroup);
const bin = toText(row.bin);
const packId = toText(row.packId);
// 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号
const position = [binGroup, bin].filter(Boolean).join(' / ')
|| (packId ? `箱号 ${packId}` : '');
return [warehouse, position].filter(Boolean).join(' · ');
}
function buildPackLocationMap(rows, componentCodes) {
const locations = new Map(componentCodes.map((code) => [code, new Set()]));
rows.forEach((row) => {
const componentCode = componentCodes.find((code) => matchesPackSku(code, row.skuId));
if (!componentCode) return;
const location = formatPackLocation(row);
if (location) locations.get(componentCode).add(location);
});
const result = new Map();
locations.forEach((values, code) => {
const all = [...values];
if (!all.length) return;
const visible = all.slice(0, 10);
result.set(code, all.length > visible.length
? `${visible.join('、')} 等 ${all.length} 个位置`
: visible.join('、'));
});
return result;
}
/**
* 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。
* 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」),
* 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。
* · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态
* · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id;
* 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位)
* · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」
* · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试
* @returns {Promise<Map<string, string>>} 配件编码 → 仓位文本(查不到的编码不含 key)
*/
async function loadComponentLocations(codes = []) {
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
const locationMap = new Map();
const missing = [];
unique.forEach((code) => {
const hit = componentLocationCache.get(code);
if (hit && hit.expiresAt > now) {
if (hit.location) locationMap.set(code, hit.location);
} else {
missing.push(code);
}
});
if (missing.length === 0) return locationMap;
// 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明
const chunks = [];
for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) {
chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE));
}
// 组间串行(本函数只打一条查询,避免一次塞满连接池)
for (const chunk of chunks) {
try {
const params = {};
const conditions = chunk.map((code, index) => {
params[`lc${index}`] = code;
params[`lp${index}`] = `${code}-%`;
return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`;
});
const rows = await cloudDatabase.query(
`SELECT p.sku_id AS skuId,
p.pack_id AS packId,
p.wms_co_id AS warehouseId,
p.bin AS bin,
p.bin_group AS binGroup,
w.name AS warehouseName,
SUM(p.qty) AS qty
FROM rpt.jst_packInventoryInfo p WITH (NOLOCK)
LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id
WHERE ${conditions.join(' OR ')}
GROUP BY p.sku_id, p.pack_id, p.wms_co_id, p.bin, p.bin_group, w.name
HAVING SUM(p.qty) > 0`,
params,
{ requestTimeout: 120000 }
);
const byCode = buildPackLocationMap(rows, chunk);
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
chunk.forEach((code) => {
const location = byCode.get(code) || null;
// 本批结果仍要返回给调用方(locationMap),只是不再写缓存
if (writable) componentLocationCache.set(code, { location, expiresAt });
if (location) locationMap.set(code, location);
});
} catch (error) {
// 失败不写缓存,下次调用重试
console.warn('[MkhBom] 配件仓位查询降级:', error.message);
}
}
return locationMap;
}
/**
* 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。
* BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。
*/
async function getMkhBomList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const keyword = toText(query.keyword);
const scope = resolveScope(query);
// 默认按可用天数升序 —— 最紧急的排最前
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const forceFresh = parseFlag(query.fresh);
const preferLiveData = parseFlag(query.live) || forceFresh;
// fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户
// 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。
if (forceFresh) await refreshMkhOperationalData();
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
buildMergedReplenishRows({ preferLiveData }),
loadOfficialBomIndex(),
loadBusinessBom(),
loadPgBom(),
]);
// 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用)
const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult;
// 全量统计(不受关键词 / 范围筛选影响)
const warningRows = merged.filter((row) => row.hasWarning);
const stats = {
mkhTotalCount: merged.length,
soldCount: merged.filter((row) => toNumber(row.dailySale) > 0).length,
warningCount: warningRows.length,
outOfStockCount: warningRows.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length,
// 已排产:有未完成加工单、按业务口径不再预警的商品数(见文件头 ⑧)
scheduledCount: merged.filter((row) => row.warningLevel === WARNING_LEVEL.SCHEDULED).length,
pendingWorkOrderSkuCount: merged.filter((row) => row.hasPendingWorkOrder).length,
pendingWorkOrderQtyTotal: Number(
merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2)
),
// 待发货订单(见文件头 ⑩):有未发货订单的成品数 / 件数,以及其中「靠订单才预警」的数量
pendingOrderSkuCount: merged.filter((row) => (toNumber(row.pendingOrderQty) || 0) > 0).length,
pendingOrderQtyTotal: Number(
merged.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2)
),
orderDrivenCount: warningRows.filter((row) => row.orderDriven).length,
// 配件不足(见文件头 ⑪):可组装数 = 0,即现有配件凑不出一台、补不出来的商品数
componentShortageCount: warningRows.filter((row) => row.componentShortage).length,
monitorDate: replenishIndex.monitorDate,
};
// 筛选 → 排序 → 分页(全部在内存完成,见文件头 ⑦)
const filtered = filterRows(merged, { keyword, scope });
const sorted = sortRows(filtered, sortField, sortOrder);
const offset = (page - 1) * pageSize;
const pageRows = sorted.slice(offset, offset + pageSize);
// 只对当前页补 BOM 明细,避免全量扫描
const officialBom = await loadOfficialBomForSkus(pageRows.map((row) => row.skuId));
const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom);
const rows = pageRows.map((row) => {
const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId);
const components = matched?.components || [];
return {
...row,
accessoryCount: components.length,
accessoryQtySum: sumQty(components),
bomConfigured: components.length > 0 ? '已配置' : '未配置',
bomMatchType: matched?.matchType || null,
bomDataSource: matched?.dataSource || null,
source: 'business_db',
};
});
return {
rows,
total: sorted.length,
bomRowCount: bomSummary.total,
bomOfficialRowCount: bomSummary.official,
bomBusinessRowCount: bomSummary.business,
bomProductDbRowCount: bomSummary.product,
bomExtraCount: 0,
bomReady: bomSummary.total > 0,
page,
pageSize,
scope,
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
...stats,
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
// 前后端实时刷新协议。本地前端如果代理到未重启的旧后端,
// 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。
freshDataContractVersion: 1,
freshDataApplied: forceFresh && operationalRefreshedAt > 0,
operationalRefreshedAt: operationalRefreshedAt
? new Date(operationalRefreshedAt).toISOString()
: null,
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
workOrderDegraded: replenishIndex.workOrderDegraded === true,
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
// 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径)
pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true,
// 配件库存数据源不可用时为 true,此时**不做**配件不足判定(见文件头 ⑪)
componentStockDegraded: componentStockDegraded === true,
componentStockWarehouse: COMPONENT_STOCK_WAREHOUSE,
warningInPage: rows.filter((row) => row.hasWarning).length,
};
}
/**
* 明细:单个 MKH 成品的配件清单
* 结构按业务模板:配件明细 | 数量 | 配件仓位
*/
async function getMkhBomDetail(mkhSkuId) {
const skuId = toText(mkhSkuId);
if (!skuId) {
const error = new Error('缺少 MKH 商品编码');
error.statusCode = 400;
throw error;
}
const comboRows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category
FROM dbo.jst_sku s
WHERE s.sku_id = @mkhSkuId AND ${MKH_WHERE}`,
{ mkhSkuId: skuId },
{ requestTimeout: 120000 }
);
const combo = comboRows[0] ? {
skuId: toText(comboRows[0].skuId),
productName: toText(comboRows[0].productName),
category: toText(comboRows[0].category),
} : null;
if (!combo) {
const error = new Error('未找到指定仓库的该 MKH 商品');
error.statusCode = 404;
throw error;
}
const [officialBom, businessBom, productBom] = await Promise.all([
loadOfficialBomForSkus([skuId]),
loadBusinessBom(),
loadPgBom(),
]);
const matched = matchBomSources(officialBom, businessBom, productBom, skuId);
const components = matched?.components || [];
const codes = components.map((item) => item.barcode).filter(Boolean);
// 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤
const locationMap = await loadComponentLocations(codes);
const items = components.map((component, index) => ({
index: index + 1,
skuId: component.barcode,
barcode: component.barcode,
skuName: component.name,
role: component.role,
qty: toNumber(component.qty) ?? 0,
location: locationMap.get(component.barcode) || null,
}));
return {
skuId: toText(combo.skuId),
productName: toText(combo.productName),
category: toText(combo.category),
items,
bomReady: items.length > 0,
bomMatchType: matched?.matchType || null,
bomDataSource: matched?.dataSource || null,
};
}
// ── ⑨ 导出 ────────────────────────────────────────────────────────
const EXPORT_MODES = Object.freeze({
PRODUCTS: 'products',
COMPONENTS: 'components',
});
/**
* 分批读取正式 BOM 明细。
* loadOfficialBomForSkus() 单次只处理 MAX_PAGE_SIZE 个 SKU,导出全量时必须自行分批;
* 组内并发、组间串行,避免一次打满 mssql 连接池。
* 每批结果都会写入 officialBomDetailCache,同一天重复导出直接命中缓存、不再查库。
*/
async function loadOfficialBomForSkuBatches(skuIds = [], concurrency = 4) {
const unique = Array.from(new Map(
skuIds
.map((skuId) => [normalizeCode(skuId), toText(skuId)])
.filter(([key, value]) => key && value)
).values());
const bySku = new Map();
if (!unique.length) return { bySku, bySpuPrefix: [], entries: [] };
const batches = [];
for (let i = 0; i < unique.length; i += MAX_PAGE_SIZE) {
batches.push(unique.slice(i, i + MAX_PAGE_SIZE));
}
for (let i = 0; i < batches.length; i += concurrency) {
const group = batches.slice(i, i + concurrency);
// eslint-disable-next-line no-await-in-loop -- 有意分组串行:组内并发 4 路,避免打满连接池
const parts = await Promise.all(group.map((batch) => loadOfficialBomForSkus(batch)));
parts.forEach((part) => {
part.bySku.forEach((components, key) => bySku.set(key, components));
});
}
return { bySku, bySpuPrefix: [], entries: [] };
}
/**
* 商品清单导出行(纯函数,便于回归测试)。
* 列口径与页面表格保持一致,只是去掉「操作」列、把 null 统一成可读值。
*/
function buildProductExportRows(matchedRows = []) {
return matchedRows.map(({ row, components = [] }) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
warningLevel: toText(row.warningLevel),
dailySale: toNumber(row.dailySale) ?? 0,
stock: toNumber(row.stock) ?? 0,
pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0,
pendingOrderQty: toNumber(row.pendingOrderQty) ?? 0,
stockDays: toNumber(row.stockDays),
replenishQty: toNumber(row.replenishQty) ?? 0,
// 配件不足(见文件头 ⑪):可组装数 = 0 → 现有配件凑不出一台,标注出来避免业务照着补
componentShortage: row.componentShortage === true ? '配件不足' : '',
assemblableQty: toNumber(row.assemblableQty),
bomConfigured: components.length > 0 ? '已配置' : '未配置',
accessoryCount: components.length,
accessoryQtySum: sumQty(components),
}));
}
/**
* 配件明细导出行(纯函数)—— 按打印模板摊平:一行一个配件。
* ⚠️ 同一商品的 MKH 编码逐行重复、不做纵向合并:合并单元格会让 Excel 筛选/透视失效,
* 业务拿这份数据多半是继续加工,扁平结构更合用。
*/
function buildComponentExportRows(matchedRows = []) {
const rows = [];
matchedRows.forEach(({ row, components = [] }) => {
components.forEach((component, index) => {
rows.push({
skuId: toText(row.skuId),
productName: toText(row.productName),
index: index + 1,
componentBarcode: toText(component.barcode || component.code),
componentName: toText(component.name),
qty: toNumber(component.qty) ?? 0,
location: toText(component.location),
});
});
});
return rows;
}
/**
* 导出:当前筛选条件下的全部行(不分页),见文件头 ⑨。
* mode = products(默认)→ 商品清单;mode = components → 配件明细摊平
*/
async function listMkhBomExportRows(query = {}) {
const requestedMode = String(query.mode || '').trim().toLowerCase();
const mode = requestedMode === EXPORT_MODES.COMPONENTS
? EXPORT_MODES.COMPONENTS
: EXPORT_MODES.PRODUCTS;
const keyword = toText(query.keyword);
const scope = resolveScope(query);
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
// 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦)
const { rows: merged, replenishIndex } = await buildMergedReplenishRows({ preferLiveData });
const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder);
const [officialBom, businessBom, productBom] = await Promise.all([
loadOfficialBomForSkuBatches(sorted.map((row) => row.skuId)),
loadBusinessBom(),
loadPgBom(),
]);
const matchedRows = sorted.map((row) => {
const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId);
return {
row,
components: matched?.components || [],
matchType: matched?.matchType || null,
dataSource: matched?.dataSource || null,
};
});
// ⚠️ 配件明细导出必须单独补仓位:BOM 明细本身不带仓位(见文件头 ⑤),
// 需要拿配件编码去「箱及仓位库」批量取,再按编码合并回明细行。
// 页面展开走 /detail(同样会补),但导出走的是这条路径,漏掉会整列出空。
if (mode === EXPORT_MODES.COMPONENTS) {
const componentCodes = matchedRows
.flatMap((item) => item.components.map((component) => component.barcode || component.code));
const locationMap = await loadComponentLocations(componentCodes);
matchedRows.forEach((item) => {
item.components = item.components.map((component) => ({
...component,
location: locationMap.get(toText(component.barcode || component.code))
|| component.location
|| null,
}));
});
}
const rows = mode === EXPORT_MODES.COMPONENTS
? buildComponentExportRows(matchedRows)
: buildProductExportRows(matchedRows);
return {
mode,
rows,
total: rows.length,
skuCount: sorted.length,
bomConfiguredCount: matchedRows.filter((item) => item.components.length > 0).length,
scope,
keyword,
sortField,
sortOrder,
monitorDate: replenishIndex.monitorDate,
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
workOrderDegraded: replenishIndex.workOrderDegraded === true,
};
}
// 手动同步:清空本页全部服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 配件仓位)。
// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照,
// 而不是 2~15 分钟前的缓存结果。
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
function clearMkhOperationalCache() {
operationalCacheEpoch += 1;
operationalRefreshedAt = 0;
replenishIndexCache = null;
workOrderIndexCache = null;
orderIndexCache = null;
return {
operationalCacheEpoch,
clearedAt: new Date().toISOString(),
};
}
/**
* 强制重读会变的运营数据。
* - single-flight:并发用户共用同一次云库查询;
* - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库;
* - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。
*/
async function refreshMkhOperationalData() {
const now = Date.now();
if (operationalRefreshPending) {
const result = await operationalRefreshPending;
return { ...result, joined: true };
}
if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) {
return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() };
}
clearMkhOperationalCache();
const refreshEpoch = operationalCacheEpoch;
const pending = (async () => {
await loadReplenishIndex();
if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now();
return {
refreshed: true,
refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null,
};
})();
operationalRefreshPending = pending;
try {
return await pending;
} finally {
if (operationalRefreshPending === pending) operationalRefreshPending = null;
}
}
function clearMkhBomCache() {
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
cacheEpoch += 1;
operationalCacheEpoch += 1;
operationalRefreshPending = null;
operationalRefreshedAt = 0;
businessBomCache = null;
officialBomIndexCache = null;
productBomCache = null;
mkhProductCache = null;
replenishIndexCache = null;
workOrderIndexCache = null;
orderIndexCache = null;
const cleared = [
['officialBomDetailCache', officialBomDetailCache.size],
['componentLocationCache', componentLocationCache.size],
['componentStockCache', componentStockCache.size],
];
officialBomDetailCache.clear();
componentLocationCache.clear();
componentStockCache.clear();
return {
syncedAt: new Date().toISOString(),
cacheEpoch,
operationalCacheEpoch,
cleared: cleared.map(([name, entries]) => ({ name, entries })),
};
}
module.exports = {
getMkhBomList,
clearMkhBomCache,
refreshMkhOperationalData,
getMkhBomDetail,
listReplenishAlertRows,
listMkhBomExportRows,
_private: {
normalizeCode,
groupOfficialBomRows,
groupBusinessBomRows,
matchComponents,
matchBomSources,
summarizeConfiguredBoms,
matchesPackSku,
formatPackLocation,
buildPackLocationMap,
loadComponentLocations,
COMPONENT_LOCATION_CHUNK_SIZE,
COMPONENT_LOCATION_CACHE_TTL_MS,
buildReplenishMetrics,
loadPendingWorkOrderIndex,
loadOrderIndex,
loadReplenishIndex,
parseOrderSkus,
isMkhProductCode,
isExcludedSaleShop,
isExcludedSaleOrder,
SALE_EXCLUDED_SHOP_KEYWORDS,
SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX,
SALE_EXCLUDED_ORDER_LABEL_KEYWORDS,
// ⑦-c 出库校验
isSplitOrder,
isShippedToday,
isOutboundCheckSkipped,
loadOutboundOrderIds,
OUTBOUND_TABLES,
OUTBOUND_LOOKUP_CHUNK_SIZE,
SPLIT_ORDER_STATUSES,
PENDING_ORDER_EXCLUDED_STATUS,
ORDER_TABLES,
// ⑪ 配件不足 / 可组装数
buildComponentOccupation,
buildComponentReadiness,
loadComponentStock,
loadMkhComponentReadiness,
COMPONENT_STOCK_WMS_CO_ID,
COMPONENT_STOCK_WAREHOUSE,
COMPONENT_STOCK_CHUNK_SIZE,
COMPONENT_SHORTAGE_MAX_LIST,
parseFlag,
resolveScope,
filterRows,
sortRows,
matchesKeyword,
REPLENISH_DAYS_THRESHOLD,
SALE_WINDOW_DAYS,
SCOPES,
WARNING_LEVEL,
WORK_ORDER_WAREHOUSE,
WORK_ORDER_VOID_STATUS,
WORK_ORDER_RECEIVED_STATUS,
EXPORT_MODES,
buildProductExportRows,
buildComponentExportRows,
clearMkhOperationalCache,
refreshMkhOperationalData,
OPERATIONAL_REFRESH_DEDUPE_MS,
},
};