forked from lunanting/cangku
补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:
1. 待发订单驱动
- MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
- 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
- 列表新增待发货订单列,展示件数、单数与最早下单时间。
2. 配件不足
- 对判定为缺货或预警的商品,按 BOM 计算可组装数:
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
- 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
建议补货量显示「—」,同时给出短板配件及其库存。
- 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
按业务确认不计采购在途。
- 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
不写入缓存,避免把瞬时故障固化为错误数据。
导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。
验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
'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,
|
||
};
|