'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, 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'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); test('components without pack inventory rows do not receive a fallback warehouse location', () => { const locations = _private.buildPackLocationMap([], ['PART-1']); assert.equal(locations.has('PART-1'), false); }); // ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)── test('component location is sourced only from the pack inventory table', () => { assert.equal(typeof _private.loadComponentLocations, 'function'); // 兜底链已删除:这两个符号不应再存在(存在即有代码走回退) assert.equal(_private.loadComponentLocationData, undefined); assert.equal(_private.buildInventoryWarehouseMap, undefined); }); test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => { const locations = _private.buildPackLocationMap([ { skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3, }, ], ['PART-1', 'PART-2']); assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9'); // 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库 assert.equal(locations.has('PART-2'), false); }); // ── 补货预警:近 15 天日销 > 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 1, realtimeStock: 4, monitorStock: 9 }); assert.equal(realtime.stock, 4); assert.equal(realtime.stockSource, 'realtime'); // 实时库存「无记录」(null)才回退监控快照 const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 }); assert.equal(fallback.stock, 9); assert.equal(fallback.stockSource, 'monitor'); // 实时库存为 0 是有效值,不得被快照覆盖 const zero = metrics({ avgSale: 1, realtimeStock: 0, monitorStock: 9 }); assert.equal(zero.stock, 0); assert.equal(zero.warningLevel, '缺货'); }); test('replenish: live refresh ignores delayed sales and stock snapshots', () => { const live = metrics({ preferLiveData: true, avgSale: 9, recentShippedQty: 30, realtimeStock: null, packStock: 0, monitorStock: 99, }); assert.equal(live.dailySale, 2, '实时日销必须按 30 件 ÷ 15 天计算'); assert.equal(live.dailySaleSource, 'orders-live'); assert.equal(live.stock, 0, '实时库存源无记录时不得回退延迟快照库存 99'); assert.equal(live.stockSource, 'live-missing'); }); test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); assert.equal(fillFromZero.stock, 4); assert.equal(fillFromZero.stockSource, 'pack'); assert.notEqual(fillFromZero.warningLevel, '缺货'); const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 }); assert.equal(fillFromMissing.stock, 5); assert.equal(fillFromMissing.stockSource, 'pack'); // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); assert.equal(realtimeWins.stock, 4); assert.equal(realtimeWins.stockSource, 'realtime'); // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 }); assert.equal(bothZero.stock, 0); assert.equal(bothZero.stockSource, 'realtime'); // 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照 const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 }); assert.equal(snapshot.stock, 9); assert.equal(snapshot.stockSource, 'monitor'); }); test('replenish: products without 7-day sales never warn', () => { const noSale = metrics({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 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('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => { // 单商品、省略 *数量 assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [ { code: 'MKHDNZ0409260700426', qty: 1 }, ]); // 多商品、每个带 *数量(总件数前缀 2. 不参与计算) assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [ { code: 'A', qty: 1 }, { code: 'B', qty: 1 }, ]); // 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定 assert.deepEqual( _private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'), [ { code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 }, { code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 }, { code: 'MKHDNZ0409260600022', qty: 1 }, ] ); // 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组) assert.deepEqual(_private.parseOrderSkus('0.'), []); assert.deepEqual(_private.parseOrderSkus(''), []); assert.deepEqual(_private.parseOrderSkus(null), []); assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [ { code: 'MKHDNZ0409260601388', qty: 2 }, ]); }); test('pending order: only MKH finished products count, combination codes are excluded', () => { assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true); assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true); // `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求 assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false); assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); assert.equal(_private.isMkhProductCode(''), false); assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的需求, // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)。 // ⚠️ 待发货(⑩)与近15天已发货(⑦-a)共用这一组表。 assert.deepEqual( [..._private.ORDER_TABLES], ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] ); // 日销兜底(⑦-a)排除样品/测试专用店铺:上游同样不计这些单,不排除会误拉进预警 assert.deepEqual([..._private.SALE_EXCLUDED_SHOP_KEYWORDS], ['样品', '测试']); assert.equal(_private.isExcludedSaleShop('样品专用店铺'), true); assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); assert.equal(_private.isExcludedSaleShop(null), false); // 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售 assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01); assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']); assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单'); assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单'); assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别'); assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true, '标签含「特殊单」→ 异常单(即使实付正常)'); // ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀 assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除'); assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除'); // 金额取不到时不排除(宁可用数据也别误杀) assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除'); assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除'); assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); }); test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; const originalGetPool = cloudDatabase.getPool; const queried = []; // 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条, // 只有小时级的箱表有;监控快照 jsStock = 0。 cloudDatabase.query = async (sql) => { if (sql.includes('jst_packInventoryInfo')) { queried.push('pack'); return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }]; } if (sql.includes('jst_inventoryInfo')) { queried.push('inventory'); return []; } if (sql.includes('pm_stockMonitor')) { queried.push('monitor'); return [{ skuId: 'MKHDNZ0409260700491', fifteenAvgSale: 0, monitorStock: 0, safetyDays: 3, suggestStock: 0, monitorDate: new Date('2026-09-18T00:00:00Z'), }]; } if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { queried.push('outbound'); return []; } if (sql.includes('jst_qmcust_orderlist')) { queried.push('qmcust'); return []; } queried.push('orders'); return []; }; cloudDatabase.getPool = async () => ({ request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), }); try { const index = await svc._private.loadReplenishIndex(); assert.ok(queried.includes('pack'), '箱表必须被读到'); assert.ok(queried.includes('inventory'), '实时库存表仍要读'); const row = index.bySku.get('MKHDNZ0409260700491'); assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0'); assert.equal(row.stockSource, 'pack'); } finally { cloudDatabase.query = originalQuery; cloudDatabase.getPool = originalGetPool; svc.clearMkhBomCache(); } }); test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; const queried = []; // 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录; // 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列 const outboundHits = ['9005', '9007', '9011']; cloudDatabase.query = async (sql) => { if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { queried.push('outbound'); return outboundHits.map((oId) => ({ oId })); } if (sql.includes('jst_qmcust_orderlist')) { queried.push('qmcust'); return [ { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, // 已发货行(⑦-a):与待发货**同一次查询**返回,靠 send_date 是否有值区分 { oId: 9005, sendDate: new Date('2026-09-09T00:00:00Z'), shopName: '天猫-HX旗舰店101', skus: '1.MKHDNZ0409260601548', }, // 样品专用店铺的已发货单必须排除(上游同样不计这些单) { oId: 9006, sendDate: new Date('2026-09-10T00:00:00Z'), shopName: '样品专用店铺', skus: '2.MKHDNZ0409260609999', }, ]; } queried.push('orders'); return [ // 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表) { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059*2' }, // 组合装(MKH- 打头)不计入成品需求 { oId: 9004, orderDate: new Date('2026-09-16T00:00:00Z'), sendDate: null, skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', }, // 已发货:真实店铺,同编码跨表累加 { oId: 9007, sendDate: new Date('2026-09-11T00:00:00Z'), shopName: '京东-HX旗舰店102', skus: '1.MKHDNZ0409260601548*2', }, // 已发货:测试专用店铺 → 排除 { oId: 9008, sendDate: new Date('2026-09-12T00:00:00Z'), shopName: '某某测试专用店铺', skus: '1.MKHDNZ0409260608888', }, // 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除 // (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」) { oId: 9009, sendDate: new Date('2026-09-18T00:00:00Z'), shopName: '拼多多-亿家达书房家具官方旗舰店', payAmount: 0.01, freeAmount: 908, labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005', skus: '9.MKHDNZ0410260700005*9', }, // 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除 { oId: 9010, sendDate: new Date('2026-09-18T00:00:00Z'), shopName: '拼多多-亿家达书房家具官方旗舰店', payAmount: 500, labels: '模块化成品,特殊单', skus: '1.MKHDNZ0410260700099', }, // 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它) { oId: 9011, sendDate: new Date('2026-09-18T00:00:00Z'), shopName: '拼多多-HX旗舰店103', payAmount: 356, freeAmount: 50, labels: '店铺-拼多多,模块化成品', skus: '1.MKHDNZ0410260700023', }, // ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除 // (业务原话:标了已发货但没有实际出库的不需要出现缺货预警) { oId: 9012, sendDate: new Date('2026-09-10T00:00:00Z'), shopName: '拼多多-HX旗舰店105', payAmount: 388, labels: '发货后取消,发货后售后,风控订单', skus: '1.MKHDNZ0409260601888', }, // ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留 // (实测窗口内 40 张 Split 单 100% 无出库记录) { oId: 9013, status: 'Split', sendDate: new Date('2026-09-15T00:00:00Z'), shopName: '京东-HX旗舰店104', payAmount: 1194.14, labels: '以旧换新,模块化成品', skus: '1.MKHDNZ0409260601777', }, // ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留 { oId: 9014, sendDate: new Date(), shopName: '京东-HX旗舰店104', payAmount: 500, labels: '模块化成品', skus: '1.MKHDNZ0409260601666', }, // ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效 { oId: 9015, status: 'Split', sendDate: new Date('2026-09-15T00:00:00Z'), shopName: '拼多多-亿家达书房家具官方旗舰店', payAmount: 0.01, labels: '特殊单,模块化成品', skus: '1.MKHDNZ0410260700777', }, ]; }; try { svc.clearMkhBomCache(); const index = await _private.loadOrderIndex(); assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到'); assert.equal(index.degraded, false); const a = index.pendingBySku.get('MKHDNZ0409260700059'); assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); assert.equal(a.pendingOrderCount, 2); const b = index.pendingBySku.get('MKHDNZ0409260601228'); assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); assert.equal(b.pendingOrderCount, 1); assert.equal(index.pendingBySku.size, 2, '组合装不进入索引'); // 已发货(⑦-a):同一次查询拿到,真实店铺跨表累加、样品/测试店排除 assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); // 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货 assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销'); assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销'); // 有免额的正常单必须照常计入 assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除'); // ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销 assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销'); // ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留 assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀'); assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀'); // ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效 assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除'); assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); test('order index: outbound lookup failure skips the check instead of dropping sales', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; // 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀: // 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。 cloudDatabase.query = async (sql) => { if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { throw new Error('出库视图不可用'); } if (sql.includes('jst_qmcust_orderlist')) return []; return [ { oId: 9101, sendDate: new Date('2026-09-10T00:00:00Z'), shopName: '京东-HX旗舰店104', payAmount: 388, labels: '模块化成品', skus: '1.MKHDNZ0409260601777', }, ]; }; try { svc.clearMkhBomCache(); const index = await _private.loadOrderIndex(); assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试'); assert.equal( index.shippedBySku.get('MKHDNZ0409260601777'), 1, '出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)' ); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); test('order index: one failing channel degrades but still returns the other table', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; cloudDatabase.query = async (sql) => { if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable'); return [ { oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, { oId: 9101, sendDate: new Date('2026-09-15T00:00:00Z'), shopName: '京东-HX旗舰店102', skus: '1.MKHDNZ0409260601548', }, ]; }; try { svc.clearMkhBomCache(); const index = await _private.loadOrderIndex(); assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); assert.match(index.error, /qmcust unavailable/); assert.equal(index.pendingBySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 1, '成功源的已发货数据同样保留'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); test('replenish: daily sale falls back to order-derived value when snapshot is 0', () => { // 上游快照漏算(⑦-a):`MKHDNZ0409260601548` 近15天实发 2 件、快照日销 0、库存 0, // 旧口径走「无销量」分支彻底隐身,连缺货都不报。 const fallback = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 0 }); assert.equal(fallback.dailySaleSource, 'orders', '快照为 0 且有发货 → 用订单实算兜底'); assert.equal(fallback.snapshotDailySale, 0); assert.equal(fallback.recentShippedQty, 2); assert.equal(fallback.dailySale, Number((2 / 15).toFixed(4)), '日销 = 2 件 ÷ 15 天'); assert.equal(fallback.warningLevel, '缺货', '兜底后库存 0 必须报缺货'); assert.equal(fallback.hasWarning, true); // 建议补货量 = ceil(3 × 0.1333 + 0 − 0) = 1 assert.equal(fallback.replenishQty, 1); // 快照 > 0 → 一律以上游为准,不被订单实算值覆盖 const snapshotWins = metrics({ avgSale: 0.5, recentShippedQty: 30, realtimeStock: 0 }); assert.equal(snapshotWins.dailySaleSource, 'snapshot'); assert.equal(snapshotWins.dailySale, 0.5, '不覆盖上游快照'); // 快照 0 且无发货 → 仍是「无销量」,不参与预警(兜底不能凭空造需求) const noFallback = metrics({ avgSale: 0, recentShippedQty: 0, realtimeStock: 0 }); assert.equal(noFallback.dailySale, 0); assert.equal(noFallback.dailySaleSource, 'snapshot'); assert.equal(noFallback.warningLevel, '无销量'); assert.equal(noFallback.hasWarning, false); // 兜底日销很小但库存充足 → 正常,不制造噪声 const enough = metrics({ avgSale: 0, recentShippedQty: 1, realtimeStock: 50, pendingOrderQty: 0 }); assert.equal(enough.dailySaleSource, 'orders'); assert.equal(enough.warningLevel, '正常'); assert.equal(enough.hasWarning, false); // 兜底后又快又慢:兜底日销 > 0 时走日销驱动分支(①),不再走订单驱动(②)—— // 因为此时已经有真实需求历史,用「结余库存 ÷ 日销」衡量比「库存 ÷ 代发件数」更准 const both = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, }); assert.equal(both.dailySaleSource, 'orders'); assert.equal(both.orderDriven, false, '有兜底日销后不再是订单驱动'); assert.equal(both.warningLevel, '缺货'); // 建议补货量 = ceil(3 × 0.1333 + 5 − 0) = ceil(5.3999) = 6(先补订单缺口再补 3 天缓冲) assert.equal(both.replenishQty, 6); // 订单源整体不可用 → recentShippedQty 缺省 0 → 退化成旧口径(不会误报) const degraded = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); assert.equal(degraded.recentShippedQty, 0); assert.equal(degraded.dailySaleSource, 'snapshot'); assert.equal(degraded.warningLevel, '无销量'); }); test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 const outOfStock = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2, }); assert.equal(outOfStock.hasWarning, true); assert.equal(outOfStock.warningLevel, '缺货'); assert.equal(outOfStock.orderDriven, true); assert.equal(outOfStock.stockDays, 0); assert.equal(outOfStock.replenishQty, 4); assert.equal(outOfStock.pendingOrderQty, 4); assert.equal(outOfStock.pendingOrderCount, 2); // 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货) const short = metrics({ avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, }); assert.equal(short.warningLevel, '预警'); assert.equal(short.hasWarning, true); assert.equal(short.stockDays, 0.25); // 补到 3 天缓冲:ceil(3 × 4 − 1) = 11 assert.equal(short.replenishQty, 11); // 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8 const oneDay = metrics({ avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, }); assert.equal(oneDay.warningLevel, '预警'); assert.equal(oneDay.stockDays, 1); assert.equal(oneDay.replenishQty, 8); // 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警 const atThreshold = metrics({ avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4, }); assert.equal(atThreshold.warningLevel, '正常'); assert.equal(atThreshold.hasWarning, false); assert.equal(atThreshold.stockDays, 3); assert.equal(atThreshold.replenishQty, 0); // 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven const enough = metrics({ avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4, }); assert.equal(enough.warningLevel, '正常'); assert.equal(enough.hasWarning, false); assert.equal(enough.orderDriven, true); assert.equal(enough.stockDays, 3.25); }); test('replenish: order-driven warning does not override the pending work order exemption', () => { const scheduled = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, pendingWorkOrderQty: 6, pendingWorkOrderCount: 1, }); assert.equal(scheduled.warningLevel, '已排产'); assert.equal(scheduled.hasWarning, false); assert.equal(scheduled.replenishQty, 0); // 待发货量仍要透出,页面上应能看到 assert.equal(scheduled.pendingOrderQty, 5); assert.equal(scheduled.orderDriven, true); }); test('replenish: pending order fields default to empty without any unshipped order', () => { const none = metrics({ avgSale: 1, realtimeStock: 0 }); assert.equal(none.pendingOrderQty, 0); assert.equal(none.pendingOrderCount, 0); assert.equal(none.pendingOrderEarliest, null); assert.equal(none.orderDriven, false); // 无日销且无订单 → 仍是「无销量」,不参与预警 const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.orderDriven, false); // 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫) const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); assert.equal(withSale.warningLevel, '缺货'); assert.equal(withSale.orderDriven, false); assert.equal(withSale.pendingOrderQty, 3); assert.equal(withSale.availableStock, 0); // max(0, 0 − 3) assert.equal(withSale.stockDays, 0); assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6 // 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常 const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 }); assert.equal(buffered.availableStock, 7); assert.equal(buffered.stockDays, 3.5); assert.equal(buffered.warningLevel, '正常'); assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0 // 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27) const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 }); assert.equal(tight.availableStock, 2); assert.equal(tight.stockDays, 2); assert.equal(tight.warningLevel, '预警'); assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5) }); // 回归:MKHDNZ0409260601376(2026-09-18 业务反馈) // 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。 // 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。 test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => { const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 }); assert.equal(row.orderDriven, false); assert.equal(row.availableStock, 0); assert.equal(row.stockDays, 0); assert.equal(row.warningLevel, '缺货'); assert.equal(row.hasWarning, true); assert.equal(row.warningOrder, 1); assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81) }); // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── 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, pendingOrderQty: 0, stockDays: 0, replenishQty: 9, componentShortage: '', assemblableQty: null, bomConfigured: '已配置', accessoryCount: 2, accessoryQtySum: 16, }); // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 assert.equal(rows[1].stockDays, null); assert.equal(rows[1].pendingWorkOrderQty, 0); assert.equal(rows[1].pendingOrderQty, 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: 15718196, warehouseName: '简尚-崔桥工厂半成品包装项目仓', binGroup: null, bin: '', packId: 'BOX-1', qty: 3, }, { skuId: 'PART-1-2', warehouseId: 15718196, 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); }); // ── 配件不足 / 可组装数(见服务文件头 ⑪)────────────────────────── test('component readiness: assemblable qty is limited by the tightest component', () => { const components = [ { barcode: 'A', name: '配件A', qty: 1 }, { barcode: 'B', name: '配件B', qty: 2 }, ]; // A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map()); assert.equal(ready.assemblableQty, 3); assert.equal(ready.shortage, false); assert.equal(ready.bottlenecks[0].code, 'B'); assert.equal(ready.bottlenecks[0].can, 3); // Tooltip 要能显示「单台用量」,不能默认当 1 assert.equal(ready.bottlenecks[0].perQty, 2); }); test('component readiness: zero assemblable qty marks 配件不足', () => { const components = [ { barcode: 'A', name: '配件A', qty: 1 }, { barcode: 'B', name: '配件B', qty: 1 }, ]; // B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来 const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map()); assert.equal(ready.assemblableQty, 0); assert.equal(ready.shortage, true); assert.equal(ready.bottlenecks[0].code, 'B'); assert.equal(ready.bottlenecks[0].stock, 0); assert.equal(ready.bottlenecks[0].available, 0); }); test('component readiness: pending work order occupation is deducted from stock', () => { const components = [{ barcode: 'A', name: '配件A', qty: 2 }]; // 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2 const ready = _private.buildComponentReadiness( components, new Map([['A', 10]]), new Map([['A', 6]]) ); assert.equal(ready.assemblableQty, 2); assert.equal(ready.bottlenecks[0].occupied, 6); assert.equal(ready.bottlenecks[0].available, 4); // 占用超过库存 → 可支配数归 0,不能算成负数 const exhausted = _private.buildComponentReadiness( components, new Map([['A', 10]]), new Map([['A', 20]]) ); assert.equal(exhausted.assemblableQty, 0); assert.equal(exhausted.shortage, true); assert.equal(exhausted.bottlenecks[0].available, 0); }); test('component readiness: missing BOM detail means unknown, not 配件不足', () => { // 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记 assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null); assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null); assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null); }); test('component occupation: pending work orders expand into component quantities', () => { const bomBySku = new Map([['MKHA1', [ { barcode: 'A', qty: 2 }, { barcode: 'B', qty: 1 }, ]]]); const occupation = _private.buildComponentOccupation([ { skuId: 'MKHA1', pendingWorkOrderQty: 5 }, // 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据) { skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 }, // 在途 0 或非数字 → 忽略 { skuId: 'MKHA1', pendingWorkOrderQty: 0 }, { skuId: 'MKHA1', pendingWorkOrderQty: null }, ], bomBySku); assert.equal(occupation.get('A'), 10); assert.equal(occupation.get('B'), 5); assert.equal(occupation.size, 2); }); test('component shortage keeps the business-confirmed source and scope', () => { // 配件库存只认半成品包装仓、只判现有库存(不含采购在途) assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196); assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); // 每个编码占 1 个参数,MSSQL 单语句上限 2100 assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true); assert.equal(typeof _private.loadComponentStock, 'function'); assert.equal(typeof _private.loadMkhComponentReadiness, 'function'); }); test('export: 配件不足 rows are flagged and carry no replenish target', () => { const rows = _private.buildProductExportRows([ { row: { skuId: 'MKHDNZ0409260600843', productName: 'K字单板基础', warningLevel: '缺货', dailySale: 0, stock: 0, pendingOrderQty: 1, replenishQty: 1, componentShortage: true, assemblableQty: 0, }, components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }], }, ]); assert.equal(rows[0].componentShortage, '配件不足'); // 可组装数一并导出,便于业务核对短板 assert.equal(rows[0].assemblableQty, 0); }); // ───────────────────────────────────────────────────────────────────────────── // 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」 // // 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。 // 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了 // 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值 // 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。 // // 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。 // ───────────────────────────────────────────────────────────────────────────── test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; const CODE = 'DNZTESTCACHE01'; const staleQty = 9; // 清缓存前那一刻库里/旧快照的值 const freshQty = 1; // 清缓存后重新查到的值 let phase = 'old'; let stockQueries = 0; let releaseStale; const staleGate = new Promise((resolve) => { releaseStale = resolve; }); let markStaleStarted; const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; }); cloudDatabase.query = async (sql) => { if (!sql.includes('jst_inventoryInfo')) return []; stockQueries += 1; if (phase === 'old') { markStaleStarted(); await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询 return [{ code: CODE, qty: staleQty }]; } return [{ code: CODE, qty: freshQty }]; }; try { // 1) 旧查询发起并卡住 const inFlight = svc._private.loadComponentStock([CODE]); await staleStarted; // 2) 用户点「同步最新数据」→ 清缓存并推进代际 const cleared = svc.clearMkhBomCache(); assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号'); phase = 'new'; // 3) 清缓存后的新查询先返回,拿到新值并写缓存 const fresh = await svc._private.loadComponentStock([CODE]); assert.equal(fresh.stockMap.get(CODE), freshQty); assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源'); // 4) 旧查询这才返回(它读到的仍是旧快照) releaseStale(); const staleResult = await inFlight; assert.equal(staleResult.stockMap.get(CODE), staleQty, '过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义'); // 5) 🔥 关键断言:缓存里必须还是新值 const after = await svc._private.loadComponentStock([CODE]); assert.equal(after.stockMap.get(CODE), freshQty, '清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」'); assert.equal(stockQueries, 2, '第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; const CODE = 'DNZTESTCACHE02'; let stockQueries = 0; cloudDatabase.query = async (sql) => { if (!sql.includes('jst_inventoryInfo')) return []; stockQueries += 1; return [{ code: CODE, qty: 3 }]; }; try { svc.clearMkhBomCache(); const first = await svc._private.loadComponentStock([CODE]); const second = await svc._private.loadComponentStock([CODE]); assert.equal(first.stockMap.get(CODE), 3); assert.equal(second.stockMap.get(CODE), 3); assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } });