cangku/backend/test/mkhReplenishAlertJob.test.js
yanhui 3e02c3e393 feat: 日销口径由近 7 天改为近 15 天(MKH 补货预警 + 模块化库存监控)
业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销,
已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。

为什么没有 30 天(2026-09-17 实测,避免重复排查):
- rpt.pm_stockMonitor 只有 threeAvgSale(3天)/sevenAvgSale(7天)/fifteenAvgSale(15天),
  且整表只保留 1 天快照(2026-09-16),攒不出 30 天历史
- rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,新鲜到 09-15)
  里 MKH 商品 0 个;目标仓 794 个 SKU 命中也是 0
- rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
- rpt.jst_salesOutboundInfo 只有出库单头,无 SKU 明细;业务库无出库明细表
- rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice 没有销量

改动:
- backend/src/services/mkhBomService.js
  · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale',
    监控快照 SQL 按该常量插值取数(以后改口径只改常量)
  · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦)
  · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新
- backend/src/services/modularStockMonitorService.js
  · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d
    (字段名同步改,避免名不副实)
- frontend/src/pages/MkhBomData/index.jsx
  · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15
- frontend/src/pages/ModularStockMonitor/index.jsx
  · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改
  · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗

影响(同一份 09-16 快照,7 天 vs 15 天):
  有销商品  114 → 170   周期拉长,更多商品进得了「有销」集合
  补货预警   25 → 19    反而变少:15 天日销普遍低于 7 天(近期销量走低),
                         可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
  缺货        9 → 10
  已排产     11 → 13
  订单驱动   14 → 8     更多商品自己有日销,不再需要靠订单兜底
  配件不足   10 → 12
「预警变少」是口径变化的正常结果,不是 bug。

验证:
- 后端语法检查 151 个文件通过
- mkhBomService + mkhReplenishAlertJob 单测 52/52 通过
- 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的
  Windows 文件权限断言,与本改动无关的环境固有问题)
- 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 /
  saleWindowDays 15 / componentShortageCount 12
2026-09-17 14:06:50 +08:00

195 lines
7.5 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 {
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('未做'));
});