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