forked from lunanting/cangku
业务要求「近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
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: 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('未做'));
|
||
});
|