cangku/backend/src/jobs/mkhReplenishAlertJob.js
yanhui f69d26796e feat: MKH-BOM 补货预警支持待发订单驱动与配件不足标记
补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:

1. 待发订单驱动
   - MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
   - 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
   - 列表新增待发货订单列,展示件数、单数与最早下单时间。

2. 配件不足
   - 对判定为缺货或预警的商品,按 BOM 计算可组装数:
     可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
   - 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
     建议补货量显示「—」,同时给出短板配件及其库存。
   - 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
     按业务确认不计采购在途。
   - 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
     不写入缓存,避免把瞬时故障固化为错误数据。

导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。

验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
2026-09-17 12:18:32 +08:00

397 lines
16 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';
// MKH 成品补货预警 · 定时推送
//
// 业务口径(2026-09-17 业务确认):
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品
// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示)
// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪)
// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件,
// 不给出建议补货量(否则业务会照着补不到的目标去采购)
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
// ④ 时间:每天早上 09:00(Asia/Shanghai)
//
// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。
const crypto = require('crypto');
const cron = require('node-cron');
const {
listReplenishAlertRows,
} = require('../services/mkhBomService');
const {
getMkhReplenishAlertRuntimeConfig,
loadMkhReplenishAlertState,
resolveMkhReplenishAlertRecipient,
saveMkhReplenishAlertState,
} = require('../services/mkhReplenishAlertSettings');
const {
isYydNotificationCenterConfigured,
sendYydNotification,
} = require('../services/yydNotificationCenterClient');
const {
getYydNotificationCenterRuntimeConfig,
} = require('../services/yydNotificationCenterSettings');
const DEFAULT_CRON = '0 9 * * *';
const TIMEZONE = 'Asia/Shanghai';
const OUT_OF_STOCK = '缺货';
const WARNING = '预警';
// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限)
const MAX_LISTED_ITEMS = 20;
const STARTUP_DELAY_MS = 45 * 1000;
function formatQty(value) {
if (value === null || value === undefined || value === '') return '—';
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2)));
}
function formatChinaTime(value) {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */
function chinaDateKey(date = new Date()) {
const text = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date instanceof Date ? date : new Date(date));
return text.replace(/\D/g, '');
}
/**
* 对比「当前预警清单」与「已推送记录」:
* fresh 本次新出现的(需要推送)
* persisted 之前推过、目前仍在预警的(不重复推)
* restored 之前推过、现在已经不预警的(补货完成,从记录里剔除)
*/
function diffAlertState(alerts = [], state = {}) {
const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const fresh = [];
const persisted = [];
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
if (notified[skuId]) persisted.push(row);
else fresh.push(row);
});
const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean));
const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId));
return { fresh, persisted, restored };
}
/**
* 生成下一版「已推送」记录:只保留当前仍在预警的商品。
* touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。
*/
function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) {
const touch = options.touch !== false;
const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const next = {};
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
const old = prev[skuId];
const firstNotifiedAt = old?.firstNotifiedAt || nowIso;
next[skuId] = {
level: row.warningLevel || null,
firstNotifiedAt,
lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt),
dailySale: Number(row.dailySale) || 0,
stock: Number(row.stock) || 0,
};
});
return next;
}
/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */
function buildAlertSignature(alerts = []) {
const parts = alerts
.map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`)
.sort();
return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16);
}
/**
* 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件,
* 即真正卡住组装的那些,最多 2 个,避免正文过长。
*/
function formatShortageNote(row) {
const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : [];
const detail = bottlenecks
.filter((item) => Number(item?.available) <= 0)
.slice(0, 2)
.map((item) => {
const occupied = Number(item.occupied) || 0;
return `${item.code}(库存 ${formatQty(item.stock)}`
+ `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`;
})
.join('、');
return detail ? `,短板配件:${detail}` : '';
}
function describeAlert(row) {
// 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来,
// 建议补货量不再给数字(显示「—」),与页面口径保持一致。
const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`;
// 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩):
// 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量
const metric = row.orderDriven
? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)`
: `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`;
const lines = [
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
String(row.productName || '').trim() || '(无名称)',
metric,
];
if (row.componentShortage) {
lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`);
}
return lines.join('\n');
}
function buildMkhReplenishNotification({
alerts = [],
allAlerts = alerts,
snapshot = {},
recipientAlias,
dateKey,
}) {
const listed = alerts.slice(0, MAX_LISTED_ITEMS);
const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalCount = allAlerts.length || alerts.length;
const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount))
? Number(snapshot.outOfStockCount)
: allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount))
? Number(snapshot.warningOnlyCount)
: Math.max(0, totalCount - totalOutOfStock);
const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`];
if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`);
const blocks = [
`**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`,
`**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`,
`**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`,
];
listed.forEach((row, index) => {
blocks.push(`**${index + 1}.** ${describeAlert(row)}`);
});
blocks.push(
[
`---`,
`当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`,
`建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`,
Number(snapshot.orderDrivenCount) > 0
? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。`
: '',
Number(snapshot.componentShortageCount) > 0
? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。`
: '',
snapshot.workOrderDegraded === true
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
: '',
snapshot.pendingOrderDegraded === true
? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。`
: '',
snapshot.componentStockDegraded === true
? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。`
: '',
].filter(Boolean).join('\n\n')
);
const actionUrl = String(
process.env.MKH_REPLENISH_ALERT_ACTION_URL
|| 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning'
).trim();
return {
idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`,
recipientAlias,
title: titleParts.join(' · ').slice(0, 100),
content: blocks.join('\n\n').slice(0, 4800),
actionTitle: '查看补货清单',
actionUrl,
};
}
/**
* 执行一次检查。可选参数:
* force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用)
* dryRun 只计算不发送,用于预览将推送的内容
*/
async function checkMkhReplenishAlerts(options = {}) {
const now = options.now === undefined ? new Date() : new Date(options.now);
const nowIso = now.toISOString();
const dryRun = options.dryRun === true;
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const snapshot = await listReplenishAlertRows({ scope: config.scope });
const alerts = snapshot.rows || [];
const state = await loadMkhReplenishAlertState();
const { fresh, persisted, restored } = diffAlertState(alerts, state);
const summary = {
scope: snapshot.scope,
totalCount: snapshot.totalCount,
outOfStockCount: snapshot.outOfStockCount,
warningOnlyCount: snapshot.warningOnlyCount,
replenishQtyTotal: snapshot.replenishQtyTotal,
// 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩
orderDrivenCount: snapshot.orderDrivenCount,
pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal,
// 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪
componentShortageCount: snapshot.componentShortageCount,
freshCount: fresh.length,
persistedCount: persisted.length,
restoredCount: restored.length,
monitorDate: snapshot.monitorDate,
workOrderDegraded: snapshot.workOrderDegraded,
pendingOrderDegraded: snapshot.pendingOrderDegraded,
componentStockDegraded: snapshot.componentStockDegraded,
};
if (!config.enabled) return { sent: false, reason: 'disabled', summary };
// 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒
if (!alerts.length) {
if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso });
return { sent: false, reason: 'no_alert', summary };
}
const targets = options.force === true ? alerts : fresh;
if (!targets.length) {
if (!dryRun) {
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }),
lastRunAt: nowIso,
});
}
return { sent: false, reason: 'no_new_alert', summary };
}
const recipient = await resolveMkhReplenishAlertRecipient({ config });
if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary };
const payload = buildMkhReplenishNotification({
alerts: targets,
allAlerts: alerts,
snapshot,
recipientAlias: recipient.alias,
dateKey: chinaDateKey(now),
});
// 预览不受「通知中心是否配置」影响,方便先核对口径
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
summary,
recipient: recipient.alias,
recipientSource: recipient.source,
payload,
};
}
let centerConfig = null;
try {
centerConfig = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
centerConfig = null;
}
if (!isYydNotificationCenterConfigured(centerConfig)) {
return {
sent: false,
reason: 'notification_center_not_configured',
summary,
recipient: recipient.alias,
payload,
};
}
const response = await sendYydNotification(payload);
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso),
lastRunAt: nowIso,
});
return {
sent: true,
recipient: recipient.alias,
recipientSource: recipient.source,
newCount: targets.length,
deliveryId: response?.id || response?.data?.id || null,
summary,
};
}
async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) {
try {
const result = await checkMkhReplenishAlerts(options);
if (result.sent) {
console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`);
} else {
console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error);
return { sent: false, reason: 'error', error: error.message || String(error) };
}
}
function startMkhReplenishAlertScheduler() {
const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] MKH 补货预警推送已禁用');
return;
}
const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim();
cron.schedule(expression, () => {
runMkhReplenishAlertJob('daily_schedule').catch(() => {});
}, { timezone: TIMEZONE });
const startupTimer = setTimeout(() => {
runMkhReplenishAlertJob('startup').catch(() => {});
}, STARTUP_DELAY_MS);
if (typeof startupTimer.unref === 'function') startupTimer.unref();
console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`);
}
module.exports = {
DEFAULT_CRON,
MAX_LISTED_ITEMS,
TIMEZONE,
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
checkMkhReplenishAlerts,
chinaDateKey,
diffAlertState,
formatQty,
runMkhReplenishAlertJob,
startMkhReplenishAlertScheduler,
};