'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: 15, 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('未做')); });