'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { _private } = require('../src/services/mkhBomService'); const emptyBom = () => ({ bySku: new Map(), bySpuPrefix: [], entries: [] }); test('official Jushuitan BOM rows use external component codes and consolidate duplicates', () => { const bom = _private.groupOfficialBomRows([ { skuId: 'MKHDNZ0409260600022', componentSkuId: 'DNZ039804N1410V01', componentName: '桌面', qty: '1.000000', }, { skuId: 'MKHDNZ0409260600022', componentSkuId: 'DNZ040928N1440V01', componentName: 'K脚架', qty: '1.000000', }, { skuId: 'MKHDNZ0409260600022', componentSkuId: 'DNZ040928N1440V01', componentName: 'K脚架', qty: '1.000000', }, { skuId: 'MKHDNZ0409260600022', componentSkuId: ' ', componentName: '无外部编码的内部物料', qty: 1, }, ]); const matched = _private.matchComponents(bom, 'MKHDNZ0409260600022'); assert.deepEqual( matched.components.map((item) => [item.barcode, item.name, item.qty]), [ ['DNZ039804N1410V01', '桌面', 1], ['DNZ040928N1440V01', 'K脚架', 2], ] ); }); test('business MKH BOM rows are grouped for drill-down and match codes without separators', () => { const bom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', componentSkuId: 'DNY004624N1015V01', componentName: '配件 A', qty: 1, }, { comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', componentSkuId: 'DNZ038569N1280V01', componentName: '配件 B', qty: 2, }, ]); const matched = _private.matchComponents( bom, 'MKHDNY004624N1015V011DNZ038569N1280V011' ); assert.equal(bom.entries.length, 1); assert.equal(matched.matchType, 'sku'); assert.deepEqual( matched.components.map((item) => [item.barcode, item.name, item.qty]), [ ['DNY004624N1015V01', '配件 A', 1], ['DNZ038569N1280V01', '配件 B', 2], ] ); }); test('duplicate business BOM component rows are consolidated without losing quantity', () => { const bom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 1 }, { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 2 }, ]); const matched = _private.matchComponents(bom, 'MKHA1'); assert.equal(matched.components.length, 1); assert.equal(matched.components[0].qty, 3); }); test('business BOM takes precedence while product database BOM remains a fallback', () => { const businessBom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, ]); const productBom = { bySku: new Map([['MKHA1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), bySpuPrefix: [], entries: [], }; const matched = _private.matchBomSources(emptyBom(), businessBom, productBom, 'MKHA1'); assert.equal(matched.dataSource, 'business_bom'); assert.equal(matched.components[0].barcode, 'BUSINESS-PART'); }); test('official Jushuitan BOM takes precedence over all fallback sources', () => { const officialBom = _private.groupOfficialBomRows([ { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 2 }, ]); const businessBom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, ]); const productBom = { bySku: new Map([['MKHA1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), bySpuPrefix: [], entries: [], }; const matched = _private.matchBomSources(officialBom, businessBom, productBom, 'MKHA1'); assert.equal(matched.dataSource, 'official_bom'); assert.equal(matched.components[0].barcode, 'OFFICIAL-PART'); assert.equal(matched.components[0].qty, 2); }); test('BOM summary excludes combinations outside the designated warehouse SKU set', () => { const businessBom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, { comboSkuId: 'MKH-OUTSIDE-1', componentSkuId: 'OUTSIDE-PART', qty: 1 }, ]); businessBom.existingSkuKeys = new Set(['MKHA1', 'MKHB1']); const productBom = { bySku: new Map([['MKHB1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), bySpuPrefix: [], entries: [], }; const officialBom = _private.groupOfficialBomRows([ { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 1 }, ]); assert.deepEqual(_private.summarizeConfiguredBoms(officialBom, businessBom, productBom), { official: 1, business: 0, product: 1, total: 2, }); }); test('pack inventory locations match a BOM component and its package suffixes', () => { assert.equal(_private.matchesPackSku('PART-1', 'PART-1'), true); assert.equal(_private.matchesPackSku('PART-1', 'PART-1-10'), true); assert.equal(_private.matchesPackSku('PART-1', 'PART-10'), false); }); test('pack inventory location prefers bins and falls back to positive-stock box ids', () => { const locations = _private.buildPackLocationMap([ { skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, }, { skuId: 'PART-1-2', warehouseId: 1, binGroup: null, bin: '', packId: 'BOX-2', qty: 1, }, ], ['PART-1']); assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { const locations = _private.buildInventoryWarehouseMap([ { skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, }, { skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, }, ], ['PART-1', 'PART-2', 'PART-3']); assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); assert.equal(locations.has('PART-3'), false); }); // ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── const metrics = (row) => _private.buildReplenishMetrics(row); test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => { const stockout = metrics({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); assert.equal(stockout.stockDays, 0); assert.equal(stockout.warningLevel, '缺货'); assert.equal(stockout.hasWarning, true); assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65) const warning = metrics({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 }); assert.equal(warning.stockDays, 0.5); assert.equal(warning.warningLevel, '预警'); assert.equal(warning.hasWarning, true); assert.equal(warning.replenishQty, 5); // ceil(6 - 1) }); test('replenish: the threshold is exactly 3 days of sales', () => { const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 }); assert.equal(atThreshold.stockDays, 3); assert.equal(atThreshold.warningLevel, '正常'); assert.equal(atThreshold.hasWarning, false); assert.equal(atThreshold.replenishQty, 0); const below = metrics({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); assert.equal(below.hasWarning, true); }); test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => { const realtime = metrics({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 }); assert.equal(realtime.stock, 4); assert.equal(realtime.stockSource, 'realtime'); // 实时库存「无记录」(null)才回退监控快照 const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 }); assert.equal(fallback.stock, 9); assert.equal(fallback.stockSource, 'monitor'); // 实时库存为 0 是有效值,不得被快照覆盖 const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 }); assert.equal(zero.stock, 0); assert.equal(zero.warningLevel, '缺货'); }); test('replenish: products without 7-day sales never warn', () => { const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.stockDays, null); assert.equal(noSale.replenishQty, 0); assert.equal(noSale.thresholdQty, 0); }); test('replenish scope resolves from scope or the legacy warningOnly flag, then filters rows', () => { const rows = [ { skuId: 'A', hasWarning: true, dailySale: 2 }, { skuId: 'B', hasWarning: false, dailySale: 2 }, { skuId: 'C', hasWarning: false, dailySale: 0 }, ]; assert.equal(_private.resolveScope({}), 'all'); assert.equal(_private.resolveScope({ warningOnly: '1' }), 'warning'); assert.equal(_private.resolveScope({ scope: 'sold' }), 'sold'); assert.equal(_private.resolveScope({ scope: 'bogus', warningOnly: 'true' }), 'warning'); assert.deepEqual(_private.filterRows(rows, { scope: 'warning' }).map((r) => r.skuId), ['A']); assert.deepEqual(_private.filterRows(rows, { scope: 'sold' }).map((r) => r.skuId), ['A', 'B']); assert.deepEqual(_private.filterRows(rows, { keyword: 'b' }).map((r) => r.skuId), ['B']); }); test('replenish sort puts the most urgent first and no-sale products last', () => { const rows = [ { skuId: 'NO-SALE', stockDays: null }, { skuId: 'B', stockDays: 2.8 }, { skuId: 'A', stockDays: 0 }, { skuId: 'C', stockDays: 12 }, ]; assert.deepEqual( _private.sortRows(rows, 'stockDays', 'ASC').map((r) => r.skuId), ['A', 'B', 'C', 'NO-SALE'] ); assert.deepEqual( _private.sortRows(rows, 'stockDays', 'DESC').map((r) => r.skuId), ['C', 'B', 'A', 'NO-SALE'] ); assert.deepEqual( _private.sortRows(rows, 'skuId', 'ASC').map((r) => r.skuId), ['A', 'B', 'C', 'NO-SALE'] ); }); // ── 在途加工单:半成品包装仓有未完成加工单就不再预警(2026-09-17 口径)── test('replenish: a pending work order suppresses the replenish warning', () => { // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 const scheduled = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0, pendingWorkOrderQty: 20, pendingWorkOrderCount: 1, // 用本地时间构造,避免测试机时区不同导致日期串变化 pendingWorkOrderEta: new Date(2026, 9, 1), pendingWorkOrderNo: '893495', }); assert.equal(scheduled.hasWarning, false); assert.equal(scheduled.warningLevel, '已排产'); assert.equal(scheduled.replenishQty, 0); assert.equal(scheduled.hasPendingWorkOrder, true); assert.equal(scheduled.pendingWorkOrderQty, 20); assert.equal(scheduled.pendingWorkOrderCount, 1); assert.equal(scheduled.pendingWorkOrderEta, '2026-10-01'); assert.equal(scheduled.pendingWorkOrderNo, '893495'); // 可用天数仍如实反映现有库存,便于业务复核 assert.equal(scheduled.stockDays, 0); // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0 }); assert.equal(bare.hasWarning, true); assert.equal(bare.warningLevel, '缺货'); }); test('replenish: pending work order fields default to empty without any order', () => { const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); assert.equal(none.hasPendingWorkOrder, false); assert.equal(none.pendingWorkOrderQty, 0); assert.equal(none.pendingWorkOrderCount, 0); assert.equal(none.pendingWorkOrderEta, null); assert.equal(none.pendingWorkOrderNo, null); // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 const zeroQty = metrics({ sevenAvgSale: 1, realtimeStock: 0, pendingWorkOrderQty: 0, pendingWorkOrderCount: 2, }); assert.equal(zeroQty.hasPendingWorkOrder, true); assert.equal(zeroQty.warningLevel, '已排产'); assert.equal(zeroQty.hasWarning, false); }); test('replenish: products without sales stay "no sale" even with a pending work order', () => { const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingWorkOrderQty: 5, pendingWorkOrderCount: 1, }); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.hasWarning, false); // 在途量仍要透出,页面上应能看到 assert.equal(noSale.pendingWorkOrderQty, 5); }); test('pending work order filters keep the business-confirmed source and exclusions', () => { assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废'); assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库'); assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); }); // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── test('export: product rows follow the page table columns', () => { const rows = _private.buildProductExportRows([ { row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转|25刨花板斜切', warningLevel: '缺货', dailySale: 2.97, stock: 0, pendingWorkOrderQty: 0, stockDays: 0, replenishQty: 9, }, components: [{ barcode: 'A1', name: '铁搁板销', qty: 4 }, { barcode: 'A2', name: '自攻螺丝', qty: 12 }], }, { row: { skuId: 'MKHDNZ0409260600117', productName: '无销量商品', warningLevel: '无销量', dailySale: 0, stock: 10, pendingWorkOrderQty: null, stockDays: null, replenishQty: 0, }, components: [], }, ]); assert.equal(rows.length, 2); assert.deepEqual(rows[0], { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转|25刨花板斜切', warningLevel: '缺货', dailySale: 2.97, stock: 0, pendingWorkOrderQty: 0, stockDays: 0, replenishQty: 9, bomConfigured: '已配置', accessoryCount: 2, accessoryQtySum: 16, }); // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 assert.equal(rows[1].stockDays, null); assert.equal(rows[1].pendingWorkOrderQty, 0); assert.equal(rows[1].bomConfigured, '未配置'); assert.equal(rows[1].accessoryQtySum, 0); }); test('export: component rows are flattened one row per component', () => { const rows = _private.buildComponentExportRows([ { row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转' }, components: [ { barcode: 'DNZ039864N1410V01', name: '台面', qty: 1, location: 'A-01 成品区' }, { barcode: 'GBSFY-01', name: '说明书', qty: 2, location: null }, ], }, { row: { skuId: 'MKHDNZ0409260600117', productName: '无配件商品' }, components: [] }, ]); // 无配件的商品不产生任何导出行(不像页面那样占位) assert.equal(rows.length, 2); assert.deepEqual(rows[0], { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转', index: 1, componentBarcode: 'DNZ039864N1410V01', componentName: '台面', qty: 1, location: 'A-01 成品区', }); // 同一商品的编码逐行重复(不做纵向合并,保证 Excel 可筛选) assert.equal(rows[1].skuId, 'MKHDNZ0409260700428'); assert.equal(rows[1].index, 2); assert.equal(rows[1].location, null); assert.equal(rows[1].qty, 2); }); test('export: component rows fall back to code when barcode is missing', () => { const rows = _private.buildComponentExportRows([ { row: { skuId: 'MKH-X' }, components: [{ code: 'ONLY-CODE', name: '仅编码配件', qty: 3 }] }, ]); assert.equal(rows[0].componentBarcode, 'ONLY-CODE'); }); test('export: mode constants cover both shapes', () => { assert.deepEqual(_private.EXPORT_MODES, { PRODUCTS: 'products', COMPONENTS: 'components' }); }); test('pack inventory location prefers the warehouse name and keeps the box fallback', () => { const locations = _private.buildPackLocationMap([ { skuId: 'PART-1-1', warehouseId: 1, warehouseName: '常州简尚家居有限公司', binGroup: null, bin: '', packId: 'BOX-1', qty: 3, }, { skuId: 'PART-1-2', warehouseId: 1, warehouseName: '常州简尚家居有限公司', binGroup: '成品区', bin: 'A-01', packId: 'BOX-2', qty: 1, }, ], ['PART-1']); assert.equal( locations.get('PART-1'), '常州简尚家居有限公司 · 箱号 BOX-1、常州简尚家居有限公司 · 成品区 / A-01' ); }); test('pack inventory location falls back to the warehouse id when the name is missing', () => { const locations = _private.buildPackLocationMap([ { skuId: 'PART-1-1', warehouseId: 4, warehouseName: null, bin: '', packId: 'BOX-9', qty: 1 }, ], ['PART-1']); assert.equal(locations.get('PART-1'), '仓库 4 · 箱号 BOX-9'); }); test('pack inventory location chunk size stays under the MSSQL parameter limit', () => { // 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100 assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true); });