cangku/backend/test/mkhBomService.test.js
yanhui f69d26796e feat: MKH-BOM 补货预警支持待发订单驱动与配件不足标记
补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:

1. 待发订单驱动
   - MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
   - 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
   - 列表新增待发货订单列,展示件数、单数与最早下单时间。

2. 配件不足
   - 对判定为缺货或预警的商品,按 BOM 计算可组装数:
     可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
   - 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
     建议补货量显示「—」,同时给出短板配件及其库存。
   - 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
     按业务确认不计采购在途。
   - 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
     不写入缓存,避免把瞬时故障固化为错误数据。

导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。

验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
2026-09-17 12:18:32 +08:00

702 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict';
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');
});
// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(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);
});
// ── 补货预警:近 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('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']);
});
test('replenish: no sales but unshipped orders still trigger the warning', () => {
// 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存
const notEnough = metrics({
sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2,
});
assert.equal(notEnough.hasWarning, true);
assert.equal(notEnough.warningLevel, '缺货');
assert.equal(notEnough.replenishQty, 3);
assert.equal(notEnough.orderDriven, true);
// 无日销基数 → 缺货时记 0,保证排在预警清单最前
assert.equal(notEnough.stockDays, 0);
assert.equal(notEnough.pendingOrderQty, 4);
assert.equal(notEnough.pendingOrderCount, 2);
// 库存刚好够发 → 预警(不再缺货),补货量为 0
const exactlyEnough = metrics({
sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
});
assert.equal(exactlyEnough.warningLevel, '预警');
assert.equal(exactlyEnough.hasWarning, true);
assert.equal(exactlyEnough.replenishQty, 0);
// 库存已够发时算不出可用天数,记 null(页面显示「按订单」)
assert.equal(exactlyEnough.stockDays, null);
});
test('replenish: order-driven warning does not override the pending work order exemption', () => {
const scheduled = metrics({
sevenAvgSale: 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({ sevenAvgSale: 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({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
assert.equal(noSale.warningLevel, '无销量');
assert.equal(noSale.orderDriven, false);
// 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列)
const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 });
assert.equal(withSale.warningLevel, '缺货');
assert.equal(withSale.orderDriven, false);
assert.equal(withSale.pendingOrderQty, 3);
assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响
});
// ── 导出(见服务文件头 ⑨)────────────────────────────────────────
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: 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);
});
// ── 配件不足 / 可组装数(见服务文件头 ⑪)──────────────────────────
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);
});