- 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列 - 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键) - 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量 - 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用)
140 lines
5.5 KiB
JavaScript
140 lines
5.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('加工单数据源暂不可用'));
|
|
});
|