'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 业务确认口径)────────────────────────── // 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 // · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale // (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock // · 可用天数 = 现有库存 / 近7日日销 // · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0 // ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源 // (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, // 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。 // 实测(2026-09-16 快照,当时成品口径尚未收紧为「正式 BOM 父项」): // 4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。 // // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 // · 数据源:业务库同实例的链接服务器 // LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders // (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) // · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' // AND 状态 <> '作废' ← 作废单据一律不核算 // AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成) // · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集 // ⚠️ 为什么走 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 业务确认口径)──────────────────────── // 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 // 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 // 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 // · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) // ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** // (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 // 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` // (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 // 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 // (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, // 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 // · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` // (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; // 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) // · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) // · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警 // · 建议补货量 = max(0, 待发货订单量 − 现有库存) // ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零): // 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 // ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— // 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 // 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, // 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 // // ── ⑪ 配件不足 / 可组装数(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,并入最新 main 后复核):预警 25 个(缺货 9 + 预警 16)中 **10 个可组装数 = 0** // (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台), // 说明占用规模小但口径上必须扣。 // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 componentStockDegraded=true。 // 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; // 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; const SALE_WINDOW_DAYS = 7; // 预警阈值:现有库存可用天数低于 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; // ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── // 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里 const PENDING_ORDER_TABLE = 'rpt.jst_Orders'; // 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); // 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s) const PENDING_ORDER_SKU_LIKE = '%MKH%'; // 订单数据源异常时的降级缓存时长 const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; // ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // 配件库存仓: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 pendingOrderIndexCache = 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', ALL: 'all' }); /** * 解析展示范围: * warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天) * sold —— 有近7日日销的商品 * 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.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; 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: [] }; }); officialBomIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; return value; } catch (error) { 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(); 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; missing.forEach(([key]) => { 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; }); businessBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; return value; } catch (error) { 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 }; })(); productBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; return value; } catch (error) { 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 成品的补货预警指标(纯函数,便于回归测试)。 * * 口径(见文件头 ⑦ + ⑩): * ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警 * ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) * 两者都不满足 → 无销量,不参与预警。 * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row * row.sevenAvgSale 近7日日均发货量 * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) * row.monitorStock 监控快照库存(fallback) * row.safetyDays 源系统安全天数(仅展示) * row.suggestStock 源系统建议备货量(仅展示对照) * row.monitorDate 监控快照日期 * row.pendingWorkOrderQty 在途加工量(未完成且非作废) * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); const realtimeStock = toNumber(row.realtimeStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; const stock = hasRealtime ? realtimeStock : monitorStock; // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); // 快照日销还是 0、但已经有订单 → 由订单驱动预警 const orderDriven = dailySale <= 0 && pendingOrderQty > 0; const base = { dailySale, stock, 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: hasRealtime ? 'realtime' : 'monitor', safetyDays: toNumber(row.safetyDays), suggestStock: toNumber(row.suggestStock) ?? 0, monitorDate: toDateText(row.monitorDate), }; // 既无近7日日销、也无未发货订单 → 不参与预警 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((stock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, hasWarning: false, }; } // 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级 if (orderDriven) { const notEnough = stock < pendingOrderQty; return { ...base, // 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数) stockDays: notEnough ? 0 : null, replenishQty: Math.max(0, pendingOrderQty - stock), warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING, warningOrder: notEnough ? 1 : 2, hasWarning: true, }; } const stockDays = stock / dailySale; const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); const level = stockDays <= 0 ? WARNING_LEVEL.OUT_OF_STOCK : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); return { ...base, stockDays: Number(stockDays.toFixed(2)), replenishQty, warningLevel: level, // 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5 warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3), hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, }; } /** * 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。 * 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) * 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。 * @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级) * @param {Map} bomBySku 归一化成品编码 → 配件明细 * @returns {Map} 配件编码 → 被占用量 */ function buildComponentOccupation(workOrders = [], bomBySku) { const occupation = new Map(); if (!bomBySku || typeof bomBySku.get !== 'function') return occupation; workOrders.forEach((workOrder) => { const pending = Math.max(0, toNumber(workOrder?.pendingWorkOrderQty) ?? 0); if (pending <= 0) return; const key = normalizeCode(workOrder.skuId); const components = key ? bomBySku.get(key) : null; if (!Array.isArray(components) || components.length === 0) return; components.forEach((component) => { const code = toText(component?.barcode) || toText(component?.code); if (!code) return; const perQty = Math.max(1, toNumber(component.qty) ?? 1); occupation.set(code, (occupation.get(code) || 0) + pending * perQty); }); }); return occupation; } /** * 配件可组装数 / 配件不足判定(纯函数,见文件头 ⑪)。 * 可组装数 = min over 配件 ⌊ max(0, 库存 − 加工单占用) ÷ 单台用量 ⌋ * 「木桶短板」决定能组装几台;可组装数 = 0 即配件不足、无法补货。 * @param {Array} components 配件明细 [{ barcode, code, name, qty }] * @param {Map} stockByCode 配件现有库存 * @param {Map} occupiedByCode 加工单占用 * @returns {null | {assemblableQty:number, shortage:boolean, bottlenecks:Array}} 无配件明细时返回 null(未知 ≠ 不足) */ function buildComponentReadiness(components, stockByCode, occupiedByCode) { if (!Array.isArray(components) || components.length === 0) return null; let assemblable = Infinity; const bottlenecks = []; components.forEach((component) => { const code = toText(component?.barcode) || toText(component?.code); if (!code) return; const perQty = Math.max(1, toNumber(component.qty) ?? 1); const stock = Math.max(0, toNumber(stockByCode?.get(code)) ?? 0); const occupied = Math.max(0, toNumber(occupiedByCode?.get(code)) ?? 0); const available = Math.max(0, stock - occupied); const can = Math.floor(available / perQty); if (can < assemblable) assemblable = can; bottlenecks.push({ code, name: toText(component.name) || code, perQty, stock, occupied, available, can, }); }); if (!Number.isFinite(assemblable)) return null; // 短板优先(可组装数最少),同分按编码稳定排序 bottlenecks.sort((a, b) => a.can - b.can || a.code.localeCompare(b.code)); return { assemblableQty: assemblable, shortage: assemblable <= 0, bottlenecks: bottlenecks.slice(0, COMPONENT_SHORTAGE_MAX_LIST), }; } /** * MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。 * 返回 [{ skuId, productName, category, material }],按编码升序。 */ async function loadMkhProductIndex() { const now = Date.now(); if (mkhProductCache?.value && mkhProductCache.expiresAt > now) { return mkhProductCache.value; } if (mkhProductCache?.pending) return mkhProductCache.pending; const pending = cloudDatabase.query( `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material FROM dbo.jst_sku s WHERE ${MKH_WHERE} ORDER BY s.sku_id ASC`, {}, { requestTimeout: 180000 } ).then((rows) => rows.map((row) => ({ skuId: toText(row.skuId), productName: toText(row.productName), category: toText(row.category), material: toText(row.material), })).filter((row) => row.skuId)); mkhProductCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; return value; } catch (error) { 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); request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS); const result = await request.query(` SELECT [商品编码] AS skuId, SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty, COUNT(DISTINCT [加工单号]) AS orderCount, MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta, MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) WHERE [加工仓储方] = @workOrderWarehouse AND [状态] <> @voidStatus AND [收货状态] <> @receivedStatus GROUP BY [商品编码]`); 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 && orderCount <= 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 }; }); workOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; return value; } catch (error) { 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); }; /** * 待发货订单索引 —— 进程内缓存 * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。 * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 */ async function loadPendingOrderIndex() { const now = Date.now(); if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) { return pendingOrderIndexCache.value; } if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending; const statusParams = {}; const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { statusParams[`pendingOrderStatus${index}`] = status; return `@pendingOrderStatus${index}`; }).join(', '); const pending = cloudDatabase.query( `SELECT o.o_id AS oId, o.order_date AS orderDate, o.skus AS skus FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike AND o.send_date IS NULL AND o.status NOT IN (${statusPlaceholders})`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } ).then((rows) => { const bySku = new Map(); rows.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 = bySku.get(key) || { pendingOrderQty: 0, pendingOrderCount: 0, pendingOrderEarliest: null, }; entry.pendingOrderQty += qty; entry.pendingOrderCount += 1; if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { entry.pendingOrderEarliest = orderDate; } bySku.set(key, entry); }); }); 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: PENDING_ORDER_DEGRADED_TTL_MS, }; }); pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; return value; } catch (error) { pendingOrderIndexCache = 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.sevenAvgSale AS sevenAvgSale, 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 } ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), // 待发货订单(见文件头 ⑩) loadPendingOrderIndex(), ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => { const workOrderBySku = workOrderIndex.bySku; const pendingOrderBySku = pendingOrderIndex.bySku; const latestStock = new Map(); stockRows.forEach((row) => { const key = normalizeCode(row.skuId); if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); }); const seenMonitor = new Set(); const bySku = 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); bySku.set(key, buildReplenishMetrics({ sevenAvgSale: row.sevenAvgSale, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, monitorStock: row.monitorStock, safetyDays: row.safetyDays, suggestStock: row.suggestStock, monitorDate: row.monitorDate, ...(workOrderBySku.get(key) || null), ...(pendingOrderBySku.get(key) || null), })); }); return { bySku, workOrderBySku, pendingOrderBySku, workOrderDegraded: workOrderIndex.degraded === true, pendingOrderDegraded: pendingOrderIndex.degraded === true, monitorDate, }; }); replenishIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; return value; } catch (error) { replenishIndexCache = null; throw error; } } /** * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 * · 按基础配件编码精确匹配,SUM(qty) 汇总 * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), * 且失败的分片不写缓存,下次调用重试 * @returns {Promise<{stockMap: Map, degraded: boolean}>} */ async function loadComponentStock(codes = []) { const now = Date.now(); 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; chunk.forEach((code) => { const qty = found.get(code) ?? 0; componentStockCache.set(code, { qty, expiresAt }); stockMap.set(code, qty); }); } catch (error) { console.warn('[MKH-BOM] 配件库存查询失败,本次不做配件不足判定:', error.message); return { stockMap, degraded: true }; } } return { stockMap, degraded: false }; } /** * 批量计算商品的可组装数(见文件头 ⑪):列表 / 推送共用,防止口径漂移。 * 依赖三份已缓存的索引,本身不再单独缓存: * · 成品 BOM 明细 —— loadOfficialBomForSkuBatches(按 SKU 缓存 15 分钟) * · 配件现有库存 —— loadComponentStock(按编码缓存 10 分钟) * · 加工单占用 —— loadPendingWorkOrderIndex(缓存 10 分钟)按 BOM 展开 * @param {string[]} skuIds 成品编码(只传缺货 / 预警的商品,避免全量扫描) * @returns {Promise<{bySku: Map, degraded: boolean}>} key = 归一化成品编码 */ async function loadMkhComponentReadiness(skuIds = []) { const requested = Array.from(new Set(skuIds.map((skuId) => toText(skuId)).filter(Boolean))); if (requested.length === 0) return { bySku: new Map(), degraded: false }; const [alertBom, workOrderIndex] = await Promise.all([ loadOfficialBomForSkuBatches(requested), loadPendingWorkOrderIndex(), ]); // 配件全集:预警商品自己的配件 + 加工单商品的配件(后者用于算占用) const workOrders = [...workOrderIndex.bySku.values()]; const workOrderSkuIds = workOrders.map((item) => item.skuId).filter(Boolean); const workOrderBom = workOrderSkuIds.length > 0 ? await loadOfficialBomForSkuBatches(workOrderSkuIds) : { bySku: new Map() }; const occupation = buildComponentOccupation(workOrders, workOrderBom.bySku); const codes = new Set(occupation.keys()); alertBom.bySku.forEach((components) => { components.forEach((component) => { const code = toText(component?.barcode) || toText(component?.code); if (code) codes.add(code); }); }); const { stockMap, degraded } = await loadComponentStock([...codes]); // 配件库存不可用 → 整批放弃判定(组件缺失 ≠ 配件不足,误标会误导业务) if (degraded) return { bySku: new Map(), degraded: true }; const bySku = new Map(); alertBom.bySku.forEach((components, key) => { const readiness = buildComponentReadiness(components, stockMap, occupation); if (readiness) bySku.set(key, readiness); }); return { bySku, degraded: false }; } /** * MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。 * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 */ async function buildMergedReplenishRows() { const [products, replenishIndex] = await Promise.all([ loadMkhProductIndex(), loadReplenishIndex(), ]); const rows = products.map((product) => { const key = normalizeCode(product.skuId); const metrics = replenishIndex.bySku.get(key); if (metrics) return { ...product, ...metrics }; // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 return { ...product, ...buildReplenishMetrics({ ...(replenishIndex.workOrderBySku?.get(key) || null), ...(replenishIndex.pendingOrderBySku?.get(key) || null), }), }; }); // 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算, // 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。 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); // 仓库优先显示名称(wh_id → jst_warehouseInfo.Id → name),取不到时退回 id const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); const binGroup = toText(row.binGroup); const bin = toText(row.bin); const packId = toText(row.packId); // 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号 const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); } function buildPackLocationMap(rows, componentCodes) { const locations = new Map(componentCodes.map((code) => [code, new Set()])); rows.forEach((row) => { const componentCode = componentCodes.find((code) => matchesPackSku(code, row.skuId)); if (!componentCode) return; const location = formatPackLocation(row); if (location) locations.get(componentCode).add(location); }); const result = new Map(); locations.forEach((values, code) => { const all = [...values]; if (!all.length) return; const visible = all.slice(0, 10); result.set(code, all.length > visible.length ? `${visible.join('、')} 等 ${all.length} 个位置` : visible.join('、')); }); return result; } /** * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 * 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」), * 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。 * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 * · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id; * 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位) * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 * · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试 * @returns {Promise>} 配件编码 → 仓位文本(查不到的编码不含 key) */ async function loadComponentLocations(codes = []) { const now = Date.now(); 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.wh_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.Id = p.wh_id WHERE ${conditions.join(' OR ')} GROUP BY p.sku_id, p.pack_id, p.wh_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; chunk.forEach((code) => { const location = byCode.get(code) || null; 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 [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ buildMergedReplenishRows(), 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, // 加工单数据源(链接服务器)不可用时为 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 { rows: merged, replenishIndex } = await buildMergedReplenishRows(); 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, workOrderDegraded: replenishIndex.workOrderDegraded === true, }; } module.exports = { getMkhBomList, 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, loadPendingOrderIndex, parseOrderSkus, isMkhProductCode, PENDING_ORDER_EXCLUDED_STATUS, // ⑪ 配件不足 / 可组装数 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, }, };