补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:
1. 待发订单驱动
- MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
- 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
- 列表新增待发货订单列,展示件数、单数与最早下单时间。
2. 配件不足
- 对判定为缺货或预警的商品,按 BOM 计算可组装数:
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
- 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
建议补货量显示「—」,同时给出短板配件及其库存。
- 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
按业务确认不计采购在途。
- 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
不写入缓存,避免把瞬时故障固化为错误数据。
导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。
验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
195 lines
7.5 KiB
JavaScript
195 lines
7.5 KiB
JavaScript
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const {
|
||
buildAlertSignature,
|
||
buildMkhReplenishNotification,
|
||
buildNotifiedSnapshot,
|
||
chinaDateKey,
|
||
diffAlertState,
|
||
formatQty,
|
||
} = require('../src/jobs/mkhReplenishAlertJob');
|
||
|
||
const alert = (skuId, level = '缺货', extra = {}) => ({
|
||
skuId,
|
||
warningLevel: level,
|
||
productName: `商品 ${skuId}`,
|
||
dailySale: 1,
|
||
stock: 0,
|
||
replenishQty: 3,
|
||
...extra,
|
||
});
|
||
|
||
test('diffAlertState splits fresh / persisted / restored', () => {
|
||
const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } };
|
||
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state);
|
||
|
||
assert.deepEqual(fresh.map((r) => r.skuId), ['C']);
|
||
assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']);
|
||
// Z 已补货恢复,应从已推送记录里剔除
|
||
assert.deepEqual(restored, ['Z']);
|
||
});
|
||
|
||
test('diffAlertState treats empty state as all fresh', () => {
|
||
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {});
|
||
assert.equal(fresh.length, 2);
|
||
assert.equal(persisted.length, 0);
|
||
assert.deepEqual(restored, []);
|
||
});
|
||
|
||
test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => {
|
||
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } };
|
||
const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z');
|
||
|
||
assert.deepEqual(Object.keys(next).sort(), ['A', 'B']);
|
||
assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z');
|
||
assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z');
|
||
assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z');
|
||
});
|
||
|
||
test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => {
|
||
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } };
|
||
const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false });
|
||
assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z');
|
||
});
|
||
|
||
test('buildAlertSignature is order independent but content sensitive', () => {
|
||
const a = alert('A');
|
||
const b = alert('B', '预警');
|
||
assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a]));
|
||
assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a]));
|
||
// 等级变化也算变化(缺货 → 预警 应产生新指纹)
|
||
assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')]));
|
||
});
|
||
|
||
test('chinaDateKey renders Beijing date regardless of UTC offset', () => {
|
||
// 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17
|
||
assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917');
|
||
assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916');
|
||
});
|
||
|
||
test('formatQty trims trailing zeros and tolerates bad input', () => {
|
||
assert.equal(formatQty(3), '3');
|
||
assert.equal(formatQty(2.9700000001), '2.97');
|
||
assert.equal(formatQty(0.28), '0.28');
|
||
assert.equal(formatQty(null), '—');
|
||
assert.equal(formatQty('abc'), '—');
|
||
});
|
||
|
||
test('notification carries recipient, idempotency key and shortfall details', () => {
|
||
const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })];
|
||
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
|
||
const snapshot = {
|
||
monitorDate: '2026-09-16',
|
||
saleWindowDays: 7,
|
||
replenishDaysThreshold: 3,
|
||
outOfStockCount: 1,
|
||
warningOnlyCount: 1,
|
||
replenishQtyTotal: 14,
|
||
};
|
||
|
||
const payload = buildMkhReplenishNotification({
|
||
alerts: fresh,
|
||
allAlerts: all,
|
||
snapshot,
|
||
recipientAlias: '苏适',
|
||
dateKey: '20260917',
|
||
});
|
||
|
||
assert.equal(payload.recipientAlias, '苏适');
|
||
assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`);
|
||
assert.ok(payload.title.includes('新增 1 个'));
|
||
assert.ok(payload.title.includes('含 1 个已缺货'));
|
||
assert.ok(payload.content.includes('MKHDNZ0409260700428'));
|
||
assert.ok(payload.content.includes('现有库存 0'));
|
||
assert.ok(payload.content.includes('建议补货 9'));
|
||
// 汇总用全量口径,不是本批新增
|
||
assert.ok(payload.content.includes('当前共 **2** 个'));
|
||
assert.equal(payload.actionTitle, '查看补货清单');
|
||
assert.ok(payload.content.length <= 4800);
|
||
});
|
||
|
||
test('notification truncates long lists but keeps the totals', () => {
|
||
const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`));
|
||
const payload = buildMkhReplenishNotification({
|
||
alerts: many,
|
||
allAlerts: many,
|
||
snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 },
|
||
recipientAlias: '苏适',
|
||
dateKey: '20260917',
|
||
});
|
||
|
||
assert.ok(payload.content.includes('仅列出前 20 个'));
|
||
assert.ok(payload.content.includes('当前共 **40** 个'));
|
||
assert.equal(payload.content.includes('MKH0039'), false);
|
||
assert.ok(payload.content.length <= 4800);
|
||
});
|
||
|
||
test('notification flags degraded work-order source', () => {
|
||
const payload = buildMkhReplenishNotification({
|
||
alerts: [alert('A')],
|
||
allAlerts: [alert('A')],
|
||
snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true },
|
||
recipientAlias: '苏适',
|
||
dateKey: '20260917',
|
||
});
|
||
assert.ok(payload.content.includes('加工单数据源暂不可用'));
|
||
});
|
||
|
||
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
|
||
test('notification spells out 配件不足 and drops the replenish target', () => {
|
||
const shortage = alert('MKHDNZ0409260600843', '缺货', {
|
||
dailySale: 0,
|
||
stock: 0,
|
||
replenishQty: 1,
|
||
componentShortage: true,
|
||
assemblableQty: 0,
|
||
bottleneckComponents: [
|
||
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
|
||
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
|
||
],
|
||
});
|
||
|
||
const payload = buildMkhReplenishNotification({
|
||
alerts: [shortage],
|
||
allAlerts: [shortage],
|
||
snapshot: {
|
||
outOfStockCount: 1,
|
||
warningOnlyCount: 0,
|
||
replenishQtyTotal: 1,
|
||
componentShortageCount: 1,
|
||
},
|
||
recipientAlias: '苏适',
|
||
dateKey: '20260917',
|
||
});
|
||
|
||
assert.ok(payload.content.includes('配件不足,无法补货'));
|
||
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
|
||
assert.ok(payload.content.includes('建议补货 —'));
|
||
assert.equal(payload.content.includes('建议补货 1'), false);
|
||
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
|
||
assert.ok(payload.content.includes('DNZ040958N1440V01'));
|
||
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
|
||
// 汇总段说明本批里有几个配件不足
|
||
assert.ok(payload.content.includes('因**配件库存不足**'));
|
||
});
|
||
|
||
test('notification flags degraded component stock source', () => {
|
||
const payload = buildMkhReplenishNotification({
|
||
alerts: [alert('A')],
|
||
allAlerts: [alert('A')],
|
||
snapshot: {
|
||
outOfStockCount: 1,
|
||
warningOnlyCount: 0,
|
||
replenishQtyTotal: 3,
|
||
componentStockDegraded: true,
|
||
},
|
||
recipientAlias: '苏适',
|
||
dateKey: '20260917',
|
||
});
|
||
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
|
||
assert.ok(payload.content.includes('未做'));
|
||
});
|