cangku/backend/test/mkhBomService.test.js

1338 lines
56 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, 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: the larger current-stock source wins 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');
// 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住
const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 });
assert.equal(packWins.stock, 47);
assert.equal(packWins.stockSource, 'pack');
// 散货只在 inventoryInfo 中:该表较大时仍保留完整库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 });
assert.equal(realtimeWins.stock, 100);
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);
// 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理
const zeroQty = metrics({
avgSale: 1,
realtimeStock: 0,
pendingWorkOrderQty: 0,
pendingWorkOrderCount: 2,
});
assert.equal(zeroQty.hasPendingWorkOrder, false);
assert.equal(zeroQty.warningLevel, '缺货');
assert.equal(zeroQty.hasWarning, true);
});
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();
}
});
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
let queryCount = 0;
let releaseMonitor;
let markMonitorStarted;
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
cloudDatabase.query = async (sql) => {
queryCount += 1;
if (sql.includes('pm_stockMonitor')) {
markMonitorStarted();
await monitorGate;
}
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({
input() { return this; },
async query() { queryCount += 1; return { recordset: [] }; },
}),
});
try {
svc.clearMkhBomCache();
const first = svc.refreshMkhOperationalData();
await monitorStarted;
const second = svc.refreshMkhOperationalData();
releaseMonitor();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult.refreshed, true);
assert.equal(secondResult.joined, true);
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
const afterConcurrent = queryCount;
const reused = await svc.refreshMkhOperationalData();
assert.equal(reused.reused, true);
assert.equal(queryCount, afterConcurrent,
'15 秒保护窗内不应重复访问业务云库');
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});