'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 分钟。 // ⚠️ 关联列别踩(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 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; // 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 // // ⑥ 配件库存 —— 业务库 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 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) // · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓 // (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 // 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, // 跨仓正库存为 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。 // · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, // 只额外打标记;「已排产」商品本来就不预警,不参与判定。 // · 无 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} bomBySku 归一化成品编码 → 配件明细 * @returns {Map} 配件编码 → 被占用量 */ 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} stockByCode 配件现有库存 * @param {Map} 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) 汇总 * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), * 且失败的分片不写缓存,下次调用重试 * @returns {Promise<{stockMap: Map, 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}`; }); // eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池 const rows = await cloudDatabase.query( `SELECT 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`, params, { requestTimeout: 120000 } ); const found = new Map(); rows.forEach((row) => { const code = toText(row.code); if (code) found.set(code, toNumber(row.qty) ?? 0); }); const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; const writable = epoch === cacheEpoch; chunk.forEach((code) => { const qty = found.get(code) ?? 0; // 本批结果仍要返回给调用方(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, 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>} 配件编码 → 仓位文本(查不到的编码不含 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(), 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, }, };