cangku/backend/src/services/mkhBomService.js
yanhui 06952d8a04 Merge remote-tracking branch 'origin/main' into codex/mkh-alert-export-0917
origin/main 已前进 11 个提交(PR #11~#15),其中 #11「配件仓位只取箱及仓位库存表」
与 #12「从模块化成品列表排除配件档案」改动了与本分支相同的三个文件。

冲突处理(3 个文件,逐处保留双方意图,未使用 ours/theirs 批量取舍):
- backend/src/services/mkhBomService.js(4 处):取本分支侧。本分支的 ⑤~⑪ 注释与
  loadComponentLocations 是 origin/main 那段的超集,且双方都已删除
  buildInventoryWarehouseMap 回退链,语义一致;同时并入 origin/main 收紧后的
  MKH_WHERE(必须是 jst_skuBomItem 正式父项)与文件头 ① 的新口径说明。
- backend/test/mkhBomService.test.js(1 处):hunk 边界冲突,双方各缺一个 `});`。
  origin/main 新增的「无箱记录不回退仓库」测试按语义插在仓位测试之后,
  再接本分支新增的测试,两处闭合各自补回。
- frontend/src/pages/MkhBomData/index.jsx(2 处):合并双方文案。数据口径采用
  origin/main 的表述(正式 BOM 父项、仓位只取 rpt.jst_packInventoryInfo),
  并保留本分支的补货预警 / 待发货订单 / 在途加工单 / 配件不足四段说明。

顺带校正 mkhBomService.js 注释中因成品口径收紧而过期的数字
(成品总数 4408 → 见 ①,预警数 26 → 25),仅改注释,不动逻辑。

合并后重新验证:后端语法检查 151 个文件通过;mkhBomService + mkhReplenishAlertJob
单测 52/52 通过;接口实测 total 4223 / warningCount 25 / componentShortageCount 10。
2026-09-17 13:52:40 +08:00

1940 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict';
// 模块化库存监控 —— MKH-BOM 资料
//
// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位
//
// ── 数据口径 ──────────────────────────────────────────────────────
//
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品'
// AND other_3 = N'简尚-崔桥工厂品牌仓'
// AND 该编码存在正式 BOM 配件明细 (当前 4223 条)
// · 商品档案里的 item_type/labels 不能单独作为成品依据:上游也会把部分配件
// 标成“成品/模块化成品”。只有 jst_skuBomItem 的父项才进入本页面。
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
//
// ② 配件明细(BOM)—— 聚水潭商品 BOM(主来源)
// rpt.jst_skuBomInfo / rpt.jst_skuBomItem
// · jst_skuBomInfo.sku_id = MKH 成品编码
// · jst_skuBomItem.map_outer_sku_id = 配件编码,rm_qty = 配件数量
// · 两表当前分别约 4,257 / 49,012 行;远端副本暂未提供 sku_id 索引,
// 因此只读取必要列并在进程内短时缓存,避免分页和下钻重复扫描。
//
// ③ 业务中台组合商品表(降级来源)
// JSDataMiddlePlatform.dbo.jst_comboProductItem
// · combine_sku_id = MKH 成品编码,src_sku_id = 配件编码,qty = 配件数量
// · 当前实测 1,406 个 MKH 成品、3,022 条配件关系、159 个不同配件编码
//
// ④ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源
// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails'
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }]
// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配
// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001)
// · BOM 只用于补充指定仓库商品的配件明细,不能把无 jst_sku 仓库档案的编码追加为商品。
//
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
// (无 SKU 条码),也不能直接当配件 BOM。
//
// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套
// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 ——
// 那不是仓位,标出来会让人以为料就在那儿)。
// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。
// 取值只有两层:
// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」
// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
// 只打一条查询,按编码缓存 10 分钟。
// ⚠️ 关联列别踩(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<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));
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<string, number>, 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<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() {
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<Map<string, string>>} 配件编码 → 仓位文本(查不到的编码不含 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,
},
};