背景
- 业务反馈 MKHDNZ0409260601548 近 15 天实发 2 件、库存 0,页面既不报缺货也不进任何预警。
- 根因是双重盲区:
① 上游 rpt.pm_stockMonitor.fifteenAvgSale 漏算 —— 快照 09-18 下,窗口内有真实发货的
178 个 MKH 成品编码里有 24 个被记成 0;其中 16 个订单全部来自样品专用店铺(上游同样
不计样品单,属合理口径),剩 8 个是真实店铺(天猫/京东/拼多多)。该列是**保留两位
小数的日均**(最小非零 0.07 = 1 件 ÷ 15 天),无法用四舍五入解释。
② 快照日销 = 0 且待发货订单 = 0 时,判定直接落到「无销量」分支、hasWarning=false,
彻底排除在预警之外 —— 库存为 0 也救不回来。
改动
- 日销兜底(服务文件头新增 ⑦-a):**快照日销 ≤ 0 且近 15 天有真实发货**时,日销改用
订单表实算值 = 已发货件数 ÷ 15;快照 > 0 一律以上游为准,不覆盖,避免两套口径打架。
· 排除样品/测试专用店铺(shop_name 含「样品」「测试」),与上游口径对齐
· 窗口 = 含今天在内的 15 个自然日,与分母同口径
· 结果透出 dailySaleSource('snapshot'|'orders') / snapshotDailySale / recentShippedQty
- 取数:loadPendingOrderIndex 重构为 loadOrderIndex,一条查询同时覆盖「待发货
(send_date IS NULL)」与「近 15 天已发货(send_date 在窗口内)」。实测合并后总耗时
约 1.25s ≈ 单查待发货,而拆成两条独立查询要在现有开销之上再加一次全表扫描
(首轮冷执行计划下 qmcust 单表可劣化到 20s+)。
- PENDING_ORDER_TABLES 更名 ORDER_TABLES:两张表的口径没变,只是不再仅服务待发货。
- 前端:日销来自兜底时加虚线下划 + 悬停说明,业务拿该数字与源系统 fifteenAvgSale
对照时不会被误判为数据错误。
验证
- 语法检查:151 文件通过。
- 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 的权限断言(期望 0o600、
Windows 实际 0o666),平台固有,与本改动无关。
- 新增回归测试:兜底生效 / 快照非零不覆盖 / 无发货不兜底 / 与订单驱动的优先级 /
订单源降级时退化成旧口径;订单索引测试补充「已发货同编码跨表累加」与「样品、测试店排除」断言。
- 真实数据端到端(本地接口 /api/mkh-bom/list,4,223 个成品):
· MKHDNZ0409260601548:日销 0 → 0.1333(source=orders),不再隐身
· 日销 > 0 的商品 177 → 198;有效预警(缺货 + 预警)18 → 28
· 21 个等级变化**全部是「无销量 → 缺货/正常/已排产」**,没有任何既有预警被降级
· 其中 10 个新缺货均为「库存 0 + 近 15 天真实发货 9~27 件」的真盲区
· 样品店排除按整行生效:实测某编码窗口内 5 件(含样品店 1 件)→ 索引记为 4 件
- 前端 eslint 通过、vite build 通过。
风险与回退
- 只影响「快照日销 = 0」的商品,快照 > 0 的商品行为完全不变。
- 订单源不可用时兜底自动失效,退化成旧口径(不会误报)。
- 回退:本提交可整体 revert,无表结构变更、无数据迁移。
942 lines
38 KiB
JavaScript
942 lines
38 KiB
JavaScript
'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: 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);
|
||
});
|
||
|
||
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 = [];
|
||
|
||
cloudDatabase.query = async (sql) => {
|
||
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',
|
||
},
|
||
];
|
||
};
|
||
|
||
try {
|
||
svc.clearMkhBomCache();
|
||
const index = await _private.loadOrderIndex();
|
||
|
||
assert.deepEqual(queried.sort(), ['orders', '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, '测试专用店铺要排除');
|
||
assert.equal(index.shippedBySku.size, 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: 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);
|
||
});
|