2668 lines
126 KiB
JavaScript
2668 lines
126 KiB
JavaScript
'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<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) 汇总
|
||
* · 结果按编码缓存(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}`;
|
||
});
|
||
// 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<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(),
|
||
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,
|
||
},
|
||
};
|