cangku/backend/test/mkhBomService.test.js
yanhui 897bbd2afd Merge remote-tracking branch 'origin/main' into codex/mkh-alert-export-0917
远端 6c00977(PR #10 "show MKH component warehouse locations")与本地的
通知推送 / 导出 / 在途加工单改动改到了同一批文件,手工合并如下:

配件的仓位与库存统一到 loadComponentLocationData(),列表展开、单品明细、
导出配件明细三处共用(新增 loadComponentLocations 作为兼容入口):

- 仓位四级兜底链:真货位 bin_group/bin → 箱号 pack_id → 库存正数量仓库
  (未分箱)→ jst_sku.other_3 商品档案仓(暂无箱号)
- 库存与仓位一次查询同时产出,两条查询都成功才写缓存(TTL 10 分钟)
- 修正远端版本的仓库关联列:仓名必须用 jst_warehouseInfo.Id = p.wh_id;
  jst_packInventoryInfo.wms_co_id 全表 NULL,用它关联 85,152 行全部取不到仓名。
  (库存表相反,必须用 w.wms_co_id = i.wms_co_id)
- 前端口径说明同时保留补货预警/在途加工单与新仓位口径
2026-09-17 10:06:26 +08:00

478 lines
17 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');
});
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);
});