feat: MKH-BOM 补货预警排除在途加工单、缺货通知推送、导出功能
- 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列 - 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键) - 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量 - 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用)
This commit is contained in:
parent
50202557d9
commit
fe831c32fd
@ -4,7 +4,17 @@ const express = require('express');
|
||||
const {
|
||||
getMkhBomList,
|
||||
getMkhBomDetail,
|
||||
listMkhBomExportRows,
|
||||
} = require('../services/mkhBomService');
|
||||
const {
|
||||
getMkhReplenishAlertAdminConfig,
|
||||
resetMkhReplenishAlertState,
|
||||
saveMkhReplenishAlertConfig,
|
||||
} = require('../services/mkhReplenishAlertSettings');
|
||||
const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob');
|
||||
const {
|
||||
saveYydNotificationCenterConfig,
|
||||
} = require('../services/yydNotificationCenterSettings');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -37,4 +47,58 @@ router.get('/detail', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 导出:当前筛选条件下的全部数据(不分页)
|
||||
// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出 MKH-BOM 资料失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 补货预警推送设置(接收人 / 开关 / 推送范围)
|
||||
router.get('/alert-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载补货预警推送设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存补货预警推送设置
|
||||
router.put('/alert-config', async (req, res) => {
|
||||
try {
|
||||
const data = await saveMkhReplenishAlertConfig(req.body || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存补货预警推送设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录)
|
||||
router.post('/alert-check', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (body.reset === true) await resetMkhReplenishAlertState();
|
||||
const result = await checkMkhReplenishAlerts({
|
||||
dryRun: body.dryRun === true,
|
||||
force: body.force === true,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '执行补货预警推送失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存统一通知中心凭据(补货预警的推送通道)
|
||||
router.put('/notification-center', async (req, res) => {
|
||||
try {
|
||||
await saveYydNotificationCenterConfig(req.body || {});
|
||||
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存统一通知中心配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -50,6 +50,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
const {
|
||||
startMkhReplenishAlertScheduler,
|
||||
} = require('./jobs/mkhReplenishAlertJob');
|
||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
@ -724,6 +727,7 @@ async function startServer(options = {}) {
|
||||
startOpsBIncentiveSnapshotSchedulers();
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
startMkhReplenishAlertScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
// inspection round-trip over the planning database tunnel.
|
||||
|
||||
343
backend/src/jobs/mkhReplenishAlertJob.js
Normal file
343
backend/src/jobs/mkhReplenishAlertJob.js
Normal file
@ -0,0 +1,343 @@
|
||||
'use strict';
|
||||
|
||||
// MKH 成品补货预警 · 定时推送
|
||||
//
|
||||
// 业务口径(2026-09-17 业务确认):
|
||||
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
|
||||
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
|
||||
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
|
||||
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
|
||||
// ④ 时间:每天早上 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);
|
||||
}
|
||||
|
||||
function describeAlert(row) {
|
||||
const lines = [
|
||||
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
|
||||
String(row.productName || '').trim() || '(无名称)',
|
||||
`日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`,
|
||||
];
|
||||
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)} 件。`,
|
||||
snapshot.workOrderDegraded === true
|
||||
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
|
||||
: '',
|
||||
].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,
|
||||
freshCount: fresh.length,
|
||||
persistedCount: persisted.length,
|
||||
restoredCount: restored.length,
|
||||
monitorDate: snapshot.monitorDate,
|
||||
workOrderDegraded: snapshot.workOrderDegraded,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
@ -37,11 +37,65 @@
|
||||
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||||
// (无 SKU 条码),也不能直接当配件 BOM。
|
||||
//
|
||||
// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo;
|
||||
// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin,
|
||||
// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。
|
||||
// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
|
||||
// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配;
|
||||
// 仓库取 jst_warehouseInfo.name(wh_id → Id);有真货位(bin_group / bin)就显示货位,
|
||||
// 没有则退回「仓库 · 箱号 pack_id」,两者都取不到时前端显示「未维护」。
|
||||
// · 列表展开(/detail)与导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。
|
||||
// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
|
||||
// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
|
||||
// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**,`wms_co_id` 全 NULL。
|
||||
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码(DNZ0398xx / DNZ0409xx,
|
||||
// jst_sku.item_type=成品)在该表一个都查不到;表里存的是老型号半成品
|
||||
// (DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品,名称如
|
||||
// 「G4号;云霜灰色+珍珠白色抽面;100/60/71.2」)。
|
||||
// 结论:MKH 配件在**任何已接入数据源**里都没有货位/装箱数据
|
||||
// (jst_sku.bin / other_bin 全库 558,674 行全空;ERP 主库 JianSun.jst_packInventoryInfo
|
||||
// 同源同量;产品资料库 external.warehouse_locations_v1 仅 1 行)。
|
||||
// → 仓位列只能显示「未维护」,需业务在聚水潭为配件维护装箱/货位后才会自动出现。
|
||||
//
|
||||
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||
//
|
||||
// ── ⑦ 补货预警(2026-09-17 业务确认口径)──────────────────────────
|
||||
// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货
|
||||
// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale
|
||||
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
|
||||
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
|
||||
// 该仓对 MKH 成品只覆盖约 256 / 4408 条,缺失时回退监控快照 jsStock
|
||||
// · 可用天数 = 现有库存 / 近7日日销
|
||||
// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0
|
||||
// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源
|
||||
// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67,
|
||||
// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。
|
||||
// 实测(2026-09-16 快照):4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。
|
||||
//
|
||||
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
|
||||
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
||||
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
||||
// · 数据源:业务库同实例的链接服务器
|
||||
// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders
|
||||
// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表)
|
||||
// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓'
|
||||
// AND 状态 <> '作废' ← 作废单据一律不核算
|
||||
// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成)
|
||||
// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集
|
||||
// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query:
|
||||
// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`,
|
||||
// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成
|
||||
// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。
|
||||
// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 /
|
||||
// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。
|
||||
// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。
|
||||
//
|
||||
// ── ⑨ 导出(2026-09-17)──────────────────────────────────────────
|
||||
// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点:
|
||||
// · 不分页 —— 返回当前筛选条件下的全部行,而不是当前页
|
||||
// · 两种形态 —— products(商品清单,列同页面表格)/ components(配件明细摊平)
|
||||
// 官方 BOM 明细按 SKU 分批读取(单批上限 MAX_PAGE_SIZE=200,见 loadOfficialBomForSkuBatches),
|
||||
// 只对筛选结果取数,因此「仅预警」这类小范围导出只需 1 批查询;
|
||||
// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。
|
||||
// ⚠️ products 形态**不返回 components** —— 4408 行商品若都挂上配件明细,
|
||||
// 响应会膨胀到几十 MB,而商品清单本身用不到明细。
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productDb = require('../config/productInformationDatabase');
|
||||
@ -50,16 +104,48 @@ const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
const BOM_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
// 配件仓位(箱及仓位库)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数上限约束
|
||||
// (每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。
|
||||
const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const COMPONENT_LOCATION_CHUNK_SIZE = 300;
|
||||
|
||||
let businessBomCache = null;
|
||||
let officialBomIndexCache = null;
|
||||
const officialBomDetailCache = new Map();
|
||||
let productBomCache = null;
|
||||
const componentLocationCache = new Map();
|
||||
|
||||
// MKH 商品编码 = 模块化成品
|
||||
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
|
||||
const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品' AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'`;
|
||||
|
||||
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
|
||||
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
|
||||
const BRAND_WMS_CO_ID = 15870495;
|
||||
const SALE_WINDOW_DAYS = 7;
|
||||
// 预警阈值:现有库存可用天数低于 3 天即需补货
|
||||
const REPLENISH_DAYS_THRESHOLD = 3;
|
||||
|
||||
const WARNING_LEVEL = Object.freeze({
|
||||
OUT_OF_STOCK: '缺货',
|
||||
WARNING: '预警',
|
||||
SCHEDULED: '已排产',
|
||||
NORMAL: '正常',
|
||||
NO_SALE: '无销量',
|
||||
});
|
||||
|
||||
// ── 在途加工单参数(见文件头 ⑧)──────────────────────────────────
|
||||
// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓)
|
||||
const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
|
||||
// 作废状态:作废单据不参与在途量核算
|
||||
const WORK_ORDER_VOID_STATUS = '作废';
|
||||
// 收货状态:该值表示已全部入库,等价于「已完成」
|
||||
const WORK_ORDER_RECEIVED_STATUS = '全部入库';
|
||||
// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧)
|
||||
const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders';
|
||||
// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量
|
||||
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||
|
||||
const toText = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
@ -72,24 +158,104 @@ const toNumber = (value) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
// 日期转 YYYY-MM-DD(按本地时区),用于监控日展示
|
||||
const toDateText = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
};
|
||||
|
||||
// 编码归一化:去横线/空格 + 大写,用于跨库匹配
|
||||
const normalizeCode = (value) => {
|
||||
const text = toText(value);
|
||||
return text ? text.toUpperCase().replace(/[-\s]/g, '') : null;
|
||||
};
|
||||
|
||||
const buildFilters = (query = {}) => {
|
||||
const conditions = [];
|
||||
const params = {};
|
||||
// ── 补货预警:进程内索引(见文件头 ⑦)────────────────────────────
|
||||
// ⚠️ 为什么不在 SQL 里关联:
|
||||
// 实测(2026-09-17)在 ORDER BY / CASE 中对 OUTER APPLY 的列做 DECIMAL 算术,
|
||||
// 会让 SQL Server 选到灾难性执行计划 ——
|
||||
// · 按「可用天数」排序:128.8 秒
|
||||
// · 统计预警条数(CASE 内引用 apply 列):97.1 秒
|
||||
// · 同结构但只 COUNT:0.098 秒
|
||||
// 而「一次全量取数 + 应用层计算」只要约 0.2 秒。
|
||||
// 故 MKH 商品(4,408 条)与监控快照(4,411 行)一次性读入进程内索引,
|
||||
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
|
||||
const REPLENISH_PARAMS = Object.freeze({
|
||||
brandWarehouse: DESIGNATED_WAREHOUSE,
|
||||
brandWmsCoId: BRAND_WMS_CO_ID,
|
||||
});
|
||||
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够
|
||||
const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
let mkhProductCache = null;
|
||||
let replenishIndexCache = null;
|
||||
// 加工单在途量索引(见文件头 ⑧)
|
||||
let workOrderIndexCache = null;
|
||||
|
||||
const keyword = toText(query.keyword);
|
||||
if (keyword) {
|
||||
params.keyword = `%${keyword}%`;
|
||||
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||||
const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase());
|
||||
|
||||
// 列表展示范围(内存过滤)
|
||||
const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' });
|
||||
|
||||
/**
|
||||
* 解析展示范围:
|
||||
* warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天)
|
||||
* sold —— 有近7日日销的商品
|
||||
* all —— 全部 MKH 成品
|
||||
* 兼容旧参数 warningOnly=1(等价 scope=warning)。
|
||||
*/
|
||||
function resolveScope(query = {}) {
|
||||
const raw = String(query.scope ?? '').trim().toLowerCase();
|
||||
if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.ALL) return raw;
|
||||
if (parseFlag(query.warningOnly)) return SCOPES.WARNING;
|
||||
return SCOPES.ALL;
|
||||
}
|
||||
|
||||
const matchesKeyword = (row, keyword) => {
|
||||
if (!keyword) return true;
|
||||
const needle = keyword.toLowerCase();
|
||||
return String(row.skuId || '').toLowerCase().includes(needle)
|
||||
|| String(row.productName || '').toLowerCase().includes(needle);
|
||||
};
|
||||
|
||||
function filterRows(rows, { keyword, scope }) {
|
||||
return rows.filter((row) => {
|
||||
if (scope === SCOPES.WARNING && !row.hasWarning) return false;
|
||||
if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false;
|
||||
return matchesKeyword(row, keyword);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序:stockDays(可用天数)/ skuId。
|
||||
* ⚠️ 无销量的商品 stockDays 为 null,无论升降序都恒定排在最后,
|
||||
* 否则它们会占据「最紧急」的首屏位置。
|
||||
*/
|
||||
function sortRows(rows, field, order) {
|
||||
const direction = order === 'DESC' ? -1 : 1;
|
||||
const bySku = (a, b) => String(a.skuId || '').localeCompare(String(b.skuId || ''));
|
||||
const list = [...rows];
|
||||
|
||||
if (field === 'stockDays') {
|
||||
list.sort((a, b) => {
|
||||
const av = toNumber(a.stockDays);
|
||||
const bv = toNumber(b.stockDays);
|
||||
if (av === null && bv === null) return bySku(a, b);
|
||||
if (av === null) return 1;
|
||||
if (bv === null) return -1;
|
||||
if (av !== bv) return (av - bv) * direction;
|
||||
return bySku(a, b);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
};
|
||||
list.sort((a, b) => bySku(a, b) * direction);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 componentDetails 原始 JSON 解析成统一结构。
|
||||
@ -510,6 +676,332 @@ function summarizeConfiguredBoms(officialBom, businessBom, productBom) {
|
||||
|
||||
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
|
||||
|
||||
/**
|
||||
* 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。
|
||||
*
|
||||
* 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。
|
||||
* 无近7日日销的商品不参与预警,标记为「无销量」。
|
||||
*
|
||||
* @param {object} row
|
||||
* row.sevenAvgSale 近7日日均发货量
|
||||
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
|
||||
* row.monitorStock 监控快照库存(fallback)
|
||||
* row.safetyDays 源系统安全天数(仅展示)
|
||||
* row.suggestStock 源系统建议备货量(仅展示对照)
|
||||
* row.monitorDate 监控快照日期
|
||||
*/
|
||||
function buildReplenishMetrics(row = {}) {
|
||||
const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0);
|
||||
const realtimeStock = toNumber(row.realtimeStock);
|
||||
const monitorStock = toNumber(row.monitorStock) ?? 0;
|
||||
const hasRealtime = realtimeStock !== null;
|
||||
const stock = hasRealtime ? realtimeStock : monitorStock;
|
||||
|
||||
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
|
||||
const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0);
|
||||
const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0);
|
||||
const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0;
|
||||
|
||||
const base = {
|
||||
dailySale,
|
||||
stock,
|
||||
pendingWorkOrderQty,
|
||||
pendingWorkOrderCount,
|
||||
hasPendingWorkOrder,
|
||||
pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta),
|
||||
pendingWorkOrderNo: toText(row.pendingWorkOrderNo),
|
||||
thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)),
|
||||
stockSource: hasRealtime ? 'realtime' : 'monitor',
|
||||
safetyDays: toNumber(row.safetyDays),
|
||||
suggestStock: toNumber(row.suggestStock) ?? 0,
|
||||
monitorDate: toDateText(row.monitorDate),
|
||||
};
|
||||
|
||||
if (dailySale <= 0) {
|
||||
return {
|
||||
...base,
|
||||
thresholdQty: 0,
|
||||
stockDays: null,
|
||||
replenishQty: 0,
|
||||
warningLevel: WARNING_LEVEL.NO_SALE,
|
||||
warningOrder: 5,
|
||||
hasWarning: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stockDays = stock / dailySale;
|
||||
|
||||
// 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧)
|
||||
if (hasPendingWorkOrder) {
|
||||
return {
|
||||
...base,
|
||||
stockDays: Number(stockDays.toFixed(2)),
|
||||
replenishQty: 0,
|
||||
warningLevel: WARNING_LEVEL.SCHEDULED,
|
||||
warningOrder: 4,
|
||||
hasWarning: false,
|
||||
};
|
||||
}
|
||||
|
||||
const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock));
|
||||
const level = stockDays <= 0
|
||||
? WARNING_LEVEL.OUT_OF_STOCK
|
||||
: (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL);
|
||||
|
||||
return {
|
||||
...base,
|
||||
stockDays: Number(stockDays.toFixed(2)),
|
||||
replenishQty,
|
||||
warningLevel: level,
|
||||
// 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5
|
||||
warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3),
|
||||
hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。
|
||||
* 返回 [{ skuId, productName, category, material }],按编码升序。
|
||||
*/
|
||||
async function loadMkhProductIndex() {
|
||||
const now = Date.now();
|
||||
if (mkhProductCache?.value && mkhProductCache.expiresAt > now) {
|
||||
return mkhProductCache.value;
|
||||
}
|
||||
if (mkhProductCache?.pending) return mkhProductCache.pending;
|
||||
|
||||
const pending = cloudDatabase.query(
|
||||
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material
|
||||
FROM dbo.jst_sku s
|
||||
WHERE ${MKH_WHERE}
|
||||
ORDER BY s.sku_id ASC`,
|
||||
{},
|
||||
{ requestTimeout: 180000 }
|
||||
).then((rows) => rows.map((row) => ({
|
||||
skuId: toText(row.skuId),
|
||||
productName: toText(row.productName),
|
||||
category: toText(row.category),
|
||||
material: toText(row.material),
|
||||
})).filter((row) => row.skuId));
|
||||
|
||||
mkhProductCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
|
||||
return value;
|
||||
} catch (error) {
|
||||
mkhProductCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。
|
||||
* 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。
|
||||
* ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑),
|
||||
* 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。
|
||||
*/
|
||||
async function loadPendingWorkOrderIndex() {
|
||||
const now = Date.now();
|
||||
if (workOrderIndexCache?.value && workOrderIndexCache.expiresAt > now) {
|
||||
return workOrderIndexCache.value;
|
||||
}
|
||||
if (workOrderIndexCache?.pending) return workOrderIndexCache.pending;
|
||||
|
||||
const pending = (async () => {
|
||||
// ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`,
|
||||
// 跨库四段名会被改坏(见文件头 ⑧)。
|
||||
const pool = await cloudDatabase.getPool();
|
||||
const request = pool.request();
|
||||
request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE);
|
||||
request.input('voidStatus', WORK_ORDER_VOID_STATUS);
|
||||
request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS);
|
||||
|
||||
const result = await request.query(`
|
||||
SELECT [商品编码] AS skuId,
|
||||
SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty,
|
||||
COUNT(DISTINCT [加工单号]) AS orderCount,
|
||||
MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta,
|
||||
MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo
|
||||
FROM ${WORK_ORDER_TABLE} WITH (NOLOCK)
|
||||
WHERE [加工仓储方] = @workOrderWarehouse
|
||||
AND [状态] <> @voidStatus
|
||||
AND [收货状态] <> @receivedStatus
|
||||
GROUP BY [商品编码]`);
|
||||
|
||||
const bySku = new Map();
|
||||
(result.recordset || []).forEach((row) => {
|
||||
const key = normalizeCode(row.skuId);
|
||||
if (!key) return;
|
||||
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
|
||||
const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0);
|
||||
if (pendingQty <= 0 && orderCount <= 0) return;
|
||||
bySku.set(key, {
|
||||
pendingWorkOrderQty: pendingQty,
|
||||
pendingWorkOrderCount: orderCount,
|
||||
pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null,
|
||||
pendingWorkOrderNo: toText(row.firstOrderNo),
|
||||
});
|
||||
});
|
||||
return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
|
||||
})().catch((error) => {
|
||||
console.warn('[MKH-BOM] 加工单在途量读取失败,已按「无在途加工单」降级:', error.message);
|
||||
return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS };
|
||||
});
|
||||
|
||||
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
|
||||
return value;
|
||||
} catch (error) {
|
||||
workOrderIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。
|
||||
* 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。
|
||||
*/
|
||||
async function loadReplenishIndex() {
|
||||
const now = Date.now();
|
||||
if (replenishIndexCache?.value && replenishIndexCache.expiresAt > now) {
|
||||
return replenishIndexCache.value;
|
||||
}
|
||||
if (replenishIndexCache?.pending) return replenishIndexCache.pending;
|
||||
|
||||
const pending = Promise.all([
|
||||
// 监控快照:同一天只有一行,按 date DESC 取首条即可
|
||||
cloudDatabase.query(
|
||||
`SELECT m.sku_id AS skuId, m.sevenAvgSale AS sevenAvgSale, m.jsStock AS monitorStock,
|
||||
m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate
|
||||
FROM dbo.pm_stockMonitor m
|
||||
WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%'
|
||||
ORDER BY m.sku_id ASC, m.date DESC`,
|
||||
REPLENISH_PARAMS,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
// 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
|
||||
cloudDatabase.query(
|
||||
`SELECT i.sku_id AS skuId, i.qty AS qty
|
||||
FROM dbo.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%'
|
||||
ORDER BY i.sku_id ASC, i.updateTime DESC`,
|
||||
REPLENISH_PARAMS,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
// 在途加工单(见文件头 ⑧)
|
||||
loadPendingWorkOrderIndex(),
|
||||
]).then(([monitorRows, stockRows, workOrderIndex]) => {
|
||||
const workOrderBySku = workOrderIndex.bySku;
|
||||
const latestStock = new Map();
|
||||
stockRows.forEach((row) => {
|
||||
const key = normalizeCode(row.skuId);
|
||||
if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty));
|
||||
});
|
||||
|
||||
const seenMonitor = new Set();
|
||||
const bySku = new Map();
|
||||
let monitorDate = null;
|
||||
monitorRows.forEach((row) => {
|
||||
const key = normalizeCode(row.skuId);
|
||||
if (!key || seenMonitor.has(key)) return;
|
||||
seenMonitor.add(key);
|
||||
if (!monitorDate) monitorDate = toDateText(row.monitorDate);
|
||||
bySku.set(key, buildReplenishMetrics({
|
||||
sevenAvgSale: row.sevenAvgSale,
|
||||
// 实时库存缺失(而非为 0)时才回退监控快照
|
||||
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
|
||||
monitorStock: row.monitorStock,
|
||||
safetyDays: row.safetyDays,
|
||||
suggestStock: row.suggestStock,
|
||||
monitorDate: row.monitorDate,
|
||||
...(workOrderBySku.get(key) || null),
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
bySku,
|
||||
workOrderBySku,
|
||||
workOrderDegraded: workOrderIndex.degraded === true,
|
||||
monitorDate,
|
||||
};
|
||||
});
|
||||
|
||||
replenishIndexCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
|
||||
return value;
|
||||
} catch (error) {
|
||||
replenishIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。
|
||||
* 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。
|
||||
*/
|
||||
async function buildMergedReplenishRows() {
|
||||
const [products, replenishIndex] = await Promise.all([
|
||||
loadMkhProductIndex(),
|
||||
loadReplenishIndex(),
|
||||
]);
|
||||
|
||||
const rows = products.map((product) => {
|
||||
const key = normalizeCode(product.skuId);
|
||||
const metrics = replenishIndex.bySku.get(key);
|
||||
if (metrics) return { ...product, ...metrics };
|
||||
// 监控快照里没有该商品时,仍要带上在途加工单信息(见文件头 ⑧)
|
||||
return {
|
||||
...product,
|
||||
...buildReplenishMetrics(replenishIndex.workOrderBySku.get(key) || {}),
|
||||
};
|
||||
});
|
||||
|
||||
return { rows, replenishIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* 补货预警清单(全量、不分页)—— 供定时推送任务使用。
|
||||
* scope = 'warning'(默认)→ 缺货 + 3 天内预警
|
||||
* scope = 'outOfStock' → 仅缺货
|
||||
* ⚠️ 返回的是「需要补货」的商品,不包含已被未完成加工单豁免的「已排产」商品。
|
||||
*/
|
||||
async function listReplenishAlertRows(options = {}) {
|
||||
const scope = toText(options.scope);
|
||||
const levels = scope === 'outOfStock'
|
||||
? [WARNING_LEVEL.OUT_OF_STOCK]
|
||||
: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING];
|
||||
|
||||
const { rows, replenishIndex } = await buildMergedReplenishRows();
|
||||
const alerts = rows.filter((row) => levels.includes(row.warningLevel));
|
||||
|
||||
// 最紧急的排前面:缺货 → 可用天数最少 → 编码升序
|
||||
alerts.sort((a, b) => (
|
||||
(a.warningOrder || 9) - (b.warningOrder || 9)
|
||||
|| ((toNumber(a.stockDays) ?? 0) - (toNumber(b.stockDays) ?? 0))
|
||||
|| String(a.skuId).localeCompare(String(b.skuId))
|
||||
));
|
||||
|
||||
return {
|
||||
rows: alerts,
|
||||
totalCount: alerts.length,
|
||||
outOfStockCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length,
|
||||
warningOnlyCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.WARNING).length,
|
||||
replenishQtyTotal: Number(
|
||||
alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
monitorDate: replenishIndex.monitorDate,
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
|
||||
saleWindowDays: SALE_WINDOW_DAYS,
|
||||
scope: scope === 'outOfStock' ? 'outOfStock' : 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
function matchesPackSku(componentCode, packSkuCode) {
|
||||
const component = toText(componentCode)?.toUpperCase();
|
||||
const packSku = toText(packSkuCode)?.toUpperCase();
|
||||
@ -518,11 +1010,14 @@ function matchesPackSku(componentCode, packSkuCode) {
|
||||
}
|
||||
|
||||
function formatPackLocation(row) {
|
||||
const warehouseName = toText(row.warehouseName);
|
||||
const warehouseId = toText(row.warehouseId);
|
||||
const binGroup = toText(row.binGroup);
|
||||
const bin = toText(row.bin);
|
||||
const packId = toText(row.packId);
|
||||
const warehouse = warehouseId ? `仓库 ${warehouseId}` : '';
|
||||
// 仓库优先显示名称(wh_id → jst_warehouseInfo.name),取不到时退回 id
|
||||
const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : '');
|
||||
// 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号
|
||||
const position = [binGroup, bin].filter(Boolean).join(' / ')
|
||||
|| (packId ? `箱号 ${packId}` : '');
|
||||
return [warehouse, position].filter(Boolean).join(' · ');
|
||||
@ -550,6 +1045,72 @@ function buildPackLocationMap(rows, componentCodes) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。
|
||||
* 数据源:rpt.jst_packInventoryInfo(箱及仓位库)+ rpt.jst_warehouseInfo(仓名)
|
||||
* · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态
|
||||
* · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」
|
||||
* · 结果按编码缓存(TTL 10 分钟);查询失败按 null 降级且不写缓存,下次重试
|
||||
*/
|
||||
async function loadComponentLocations(codes = []) {
|
||||
const now = Date.now();
|
||||
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
||||
const result = new Map();
|
||||
const missing = [];
|
||||
unique.forEach((code) => {
|
||||
const hit = componentLocationCache.get(code);
|
||||
if (hit && hit.expiresAt > now) result.set(code, hit.location);
|
||||
else missing.push(code);
|
||||
});
|
||||
if (missing.length === 0) return result;
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) {
|
||||
chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
// 组内串行(单个接口最多同时打两条查询,避免拖垮连接池)
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const params = {};
|
||||
const conditions = chunk.map((code, index) => {
|
||||
params[`lc${index}`] = code;
|
||||
params[`lp${index}`] = `${code}-%`;
|
||||
return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`;
|
||||
});
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT p.sku_id AS skuId,
|
||||
p.pack_id AS packId,
|
||||
p.wh_id AS warehouseId,
|
||||
p.bin AS bin,
|
||||
p.bin_group AS binGroup,
|
||||
w.name AS warehouseName,
|
||||
SUM(p.qty) AS qty
|
||||
FROM rpt.jst_packInventoryInfo p WITH (NOLOCK)
|
||||
LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id
|
||||
WHERE ${conditions.join(' OR ')}
|
||||
GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group, w.name
|
||||
HAVING SUM(p.qty) > 0`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
const byCode = buildPackLocationMap(rows, chunk);
|
||||
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
||||
chunk.forEach((code) => {
|
||||
const location = byCode.get(code) || null;
|
||||
componentLocationCache.set(code, { location, expiresAt });
|
||||
result.set(code, location);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[MkhBom] 配件仓位查询降级:', error.message);
|
||||
chunk.forEach((code) => {
|
||||
if (!result.has(code)) result.set(code, null);
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。
|
||||
* BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。
|
||||
@ -558,53 +1119,52 @@ async function getMkhBomList(query = {}) {
|
||||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||
const { conditions, params } = buildFilters(query);
|
||||
|
||||
const whereSql = conditions.length
|
||||
? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}`
|
||||
: `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`;
|
||||
|
||||
const sortField = String(query.sortField || 'skuId');
|
||||
const keyword = toText(query.keyword);
|
||||
const scope = resolveScope(query);
|
||||
// 默认按可用天数升序 —— 最紧急的排最前
|
||||
const sortField = String(query.sortField || 'stockDays');
|
||||
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT
|
||||
s.sku_id AS skuId,
|
||||
s.name AS productName,
|
||||
s.category AS category,
|
||||
s.other_10 AS material
|
||||
${whereSql}
|
||||
ORDER BY ${orderSql}
|
||||
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
|
||||
buildMergedReplenishRows(),
|
||||
loadOfficialBomIndex(),
|
||||
loadBusinessBom(),
|
||||
loadPgBom(),
|
||||
]);
|
||||
const officialBom = await loadOfficialBomForSkus(businessRows.map((row) => row.skuId));
|
||||
// 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用)
|
||||
const { rows: merged, replenishIndex } = mergedResult;
|
||||
|
||||
// 全量统计(不受关键词 / 范围筛选影响)
|
||||
const warningRows = merged.filter((row) => row.hasWarning);
|
||||
const stats = {
|
||||
mkhTotalCount: merged.length,
|
||||
soldCount: merged.filter((row) => toNumber(row.dailySale) > 0).length,
|
||||
warningCount: warningRows.length,
|
||||
outOfStockCount: warningRows.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length,
|
||||
// 已排产:有未完成加工单、按业务口径不再预警的商品数(见文件头 ⑧)
|
||||
scheduledCount: merged.filter((row) => row.warningLevel === WARNING_LEVEL.SCHEDULED).length,
|
||||
pendingWorkOrderSkuCount: merged.filter((row) => row.hasPendingWorkOrder).length,
|
||||
pendingWorkOrderQtyTotal: Number(
|
||||
merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
monitorDate: replenishIndex.monitorDate,
|
||||
};
|
||||
|
||||
// 筛选 → 排序 → 分页(全部在内存完成,见文件头 ⑦)
|
||||
const filtered = filterRows(merged, { keyword, scope });
|
||||
const sorted = sortRows(filtered, sortField, sortOrder);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const pageRows = sorted.slice(offset, offset + pageSize);
|
||||
|
||||
// 只对当前页补 BOM 明细,避免全量扫描
|
||||
const officialBom = await loadOfficialBomForSkus(pageRows.map((row) => row.skuId));
|
||||
const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom);
|
||||
const [totalRow] = totalRows;
|
||||
|
||||
const mapped = businessRows.map((row) => {
|
||||
const skuId = toText(row.skuId);
|
||||
const matched = matchBomSources(officialBom, businessBom, productBom, skuId);
|
||||
const rows = pageRows.map((row) => {
|
||||
const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId);
|
||||
const components = matched?.components || [];
|
||||
|
||||
return {
|
||||
skuId,
|
||||
productName: toText(row.productName),
|
||||
category: toText(row.category),
|
||||
material: toText(row.material),
|
||||
...row,
|
||||
accessoryCount: components.length,
|
||||
accessoryQtySum: sumQty(components),
|
||||
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||||
@ -615,8 +1175,8 @@ async function getMkhBomList(query = {}) {
|
||||
});
|
||||
|
||||
return {
|
||||
rows: mapped,
|
||||
total: Number(totalRow?.total || 0),
|
||||
rows,
|
||||
total: sorted.length,
|
||||
bomRowCount: bomSummary.total,
|
||||
bomOfficialRowCount: bomSummary.official,
|
||||
bomBusinessRowCount: bomSummary.business,
|
||||
@ -625,7 +1185,15 @@ async function getMkhBomList(query = {}) {
|
||||
bomReady: bomSummary.total > 0,
|
||||
page,
|
||||
pageSize,
|
||||
configuredInPage: mapped.filter((row) => row.accessoryCount > 0).length,
|
||||
scope,
|
||||
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
|
||||
...stats,
|
||||
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
|
||||
saleWindowDays: SALE_WINDOW_DAYS,
|
||||
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
|
||||
warningInPage: rows.filter((row) => row.hasWarning).length,
|
||||
};
|
||||
}
|
||||
|
||||
@ -692,27 +1260,9 @@ async function getMkhBomDetail(mkhSkuId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const locationParams = {};
|
||||
const locationConditions = codes.map((code, index) => {
|
||||
locationParams[`lc${index}`] = code;
|
||||
locationParams[`lp${index}`] = `${code}-%`;
|
||||
return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`;
|
||||
});
|
||||
const locRows = await cloudDatabase.query(
|
||||
`SELECT p.sku_id AS skuId,
|
||||
p.pack_id AS packId,
|
||||
p.wh_id AS warehouseId,
|
||||
p.bin AS bin,
|
||||
p.bin_group AS binGroup,
|
||||
SUM(p.qty) AS qty
|
||||
FROM rpt.jst_packInventoryInfo p
|
||||
WHERE ${locationConditions.join(' OR ')}
|
||||
GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group
|
||||
HAVING SUM(p.qty) > 0`,
|
||||
locationParams,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
buildPackLocationMap(locRows, codes).forEach((location, code) => {
|
||||
// 与列表展开 / 导出配件明细走同一个批量加载器(含仓名解析与 10 分钟缓存)
|
||||
const located = await loadComponentLocations(codes);
|
||||
located.forEach((location, code) => {
|
||||
locationMap.set(code, location);
|
||||
});
|
||||
} catch (error) {
|
||||
@ -742,9 +1292,164 @@ async function getMkhBomDetail(mkhSkuId) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── ⑨ 导出 ────────────────────────────────────────────────────────
|
||||
const EXPORT_MODES = Object.freeze({
|
||||
PRODUCTS: 'products',
|
||||
COMPONENTS: 'components',
|
||||
});
|
||||
|
||||
/**
|
||||
* 分批读取正式 BOM 明细。
|
||||
* loadOfficialBomForSkus() 单次只处理 MAX_PAGE_SIZE 个 SKU,导出全量时必须自行分批;
|
||||
* 组内并发、组间串行,避免一次打满 mssql 连接池。
|
||||
* 每批结果都会写入 officialBomDetailCache,同一天重复导出直接命中缓存、不再查库。
|
||||
*/
|
||||
async function loadOfficialBomForSkuBatches(skuIds = [], concurrency = 4) {
|
||||
const unique = Array.from(new Map(
|
||||
skuIds
|
||||
.map((skuId) => [normalizeCode(skuId), toText(skuId)])
|
||||
.filter(([key, value]) => key && value)
|
||||
).values());
|
||||
const bySku = new Map();
|
||||
if (!unique.length) return { bySku, bySpuPrefix: [], entries: [] };
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < unique.length; i += MAX_PAGE_SIZE) {
|
||||
batches.push(unique.slice(i, i + MAX_PAGE_SIZE));
|
||||
}
|
||||
|
||||
for (let i = 0; i < batches.length; i += concurrency) {
|
||||
const group = batches.slice(i, i + concurrency);
|
||||
// eslint-disable-next-line no-await-in-loop -- 有意分组串行:组内并发 4 路,避免打满连接池
|
||||
const parts = await Promise.all(group.map((batch) => loadOfficialBomForSkus(batch)));
|
||||
parts.forEach((part) => {
|
||||
part.bySku.forEach((components, key) => bySku.set(key, components));
|
||||
});
|
||||
}
|
||||
|
||||
return { bySku, bySpuPrefix: [], entries: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品清单导出行(纯函数,便于回归测试)。
|
||||
* 列口径与页面表格保持一致,只是去掉「操作」列、把 null 统一成可读值。
|
||||
*/
|
||||
function buildProductExportRows(matchedRows = []) {
|
||||
return matchedRows.map(({ row, components = [] }) => ({
|
||||
skuId: toText(row.skuId),
|
||||
productName: toText(row.productName),
|
||||
warningLevel: toText(row.warningLevel),
|
||||
dailySale: toNumber(row.dailySale) ?? 0,
|
||||
stock: toNumber(row.stock) ?? 0,
|
||||
pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0,
|
||||
stockDays: toNumber(row.stockDays),
|
||||
replenishQty: toNumber(row.replenishQty) ?? 0,
|
||||
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||||
accessoryCount: components.length,
|
||||
accessoryQtySum: sumQty(components),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配件明细导出行(纯函数)—— 按打印模板摊平:一行一个配件。
|
||||
* ⚠️ 同一商品的 MKH 编码逐行重复、不做纵向合并:合并单元格会让 Excel 筛选/透视失效,
|
||||
* 业务拿这份数据多半是继续加工,扁平结构更合用。
|
||||
*/
|
||||
function buildComponentExportRows(matchedRows = []) {
|
||||
const rows = [];
|
||||
matchedRows.forEach(({ row, components = [] }) => {
|
||||
components.forEach((component, index) => {
|
||||
rows.push({
|
||||
skuId: toText(row.skuId),
|
||||
productName: toText(row.productName),
|
||||
index: index + 1,
|
||||
componentBarcode: toText(component.barcode || component.code),
|
||||
componentName: toText(component.name),
|
||||
qty: toNumber(component.qty) ?? 0,
|
||||
location: toText(component.location),
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出:当前筛选条件下的全部行(不分页),见文件头 ⑨。
|
||||
* mode = products(默认)→ 商品清单;mode = components → 配件明细摊平
|
||||
*/
|
||||
async function listMkhBomExportRows(query = {}) {
|
||||
const requestedMode = String(query.mode || '').trim().toLowerCase();
|
||||
const mode = requestedMode === EXPORT_MODES.COMPONENTS
|
||||
? EXPORT_MODES.COMPONENTS
|
||||
: EXPORT_MODES.PRODUCTS;
|
||||
const keyword = toText(query.keyword);
|
||||
const scope = resolveScope(query);
|
||||
const sortField = String(query.sortField || 'stockDays');
|
||||
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
// 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦)
|
||||
const { rows: merged, replenishIndex } = await buildMergedReplenishRows();
|
||||
const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder);
|
||||
|
||||
const [officialBom, businessBom, productBom] = await Promise.all([
|
||||
loadOfficialBomForSkuBatches(sorted.map((row) => row.skuId)),
|
||||
loadBusinessBom(),
|
||||
loadPgBom(),
|
||||
]);
|
||||
|
||||
const matchedRows = sorted.map((row) => {
|
||||
const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId);
|
||||
return {
|
||||
row,
|
||||
components: matched?.components || [],
|
||||
matchType: matched?.matchType || null,
|
||||
dataSource: matched?.dataSource || null,
|
||||
};
|
||||
});
|
||||
|
||||
// ⚠️ 配件明细导出必须单独补仓位:BOM 明细本身不带仓位(见文件头 ⑤),
|
||||
// 需要拿配件编码去「箱及仓位库」批量取,再按编码合并回明细行。
|
||||
// 页面展开走 /detail(同样会补),但导出走的是这条路径,漏掉会整列出空。
|
||||
if (mode === EXPORT_MODES.COMPONENTS) {
|
||||
const componentCodes = matchedRows
|
||||
.flatMap((item) => item.components.map((component) => component.barcode || component.code));
|
||||
const locationMap = await loadComponentLocations(componentCodes);
|
||||
matchedRows.forEach((item) => {
|
||||
item.components = item.components.map((component) => ({
|
||||
...component,
|
||||
location: locationMap.get(toText(component.barcode || component.code))
|
||||
|| component.location
|
||||
|| null,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const rows = mode === EXPORT_MODES.COMPONENTS
|
||||
? buildComponentExportRows(matchedRows)
|
||||
: buildProductExportRows(matchedRows);
|
||||
|
||||
return {
|
||||
mode,
|
||||
rows,
|
||||
total: rows.length,
|
||||
skuCount: sorted.length,
|
||||
bomConfiguredCount: matchedRows.filter((item) => item.components.length > 0).length,
|
||||
scope,
|
||||
keyword,
|
||||
sortField,
|
||||
sortOrder,
|
||||
monitorDate: replenishIndex.monitorDate,
|
||||
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
|
||||
saleWindowDays: SALE_WINDOW_DAYS,
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMkhBomList,
|
||||
getMkhBomDetail,
|
||||
listReplenishAlertRows,
|
||||
listMkhBomExportRows,
|
||||
_private: {
|
||||
normalizeCode,
|
||||
groupOfficialBomRows,
|
||||
@ -755,5 +1460,24 @@ module.exports = {
|
||||
matchesPackSku,
|
||||
formatPackLocation,
|
||||
buildPackLocationMap,
|
||||
loadComponentLocations,
|
||||
COMPONENT_LOCATION_CHUNK_SIZE,
|
||||
buildReplenishMetrics,
|
||||
loadPendingWorkOrderIndex,
|
||||
parseFlag,
|
||||
resolveScope,
|
||||
filterRows,
|
||||
sortRows,
|
||||
matchesKeyword,
|
||||
REPLENISH_DAYS_THRESHOLD,
|
||||
SALE_WINDOW_DAYS,
|
||||
SCOPES,
|
||||
WARNING_LEVEL,
|
||||
WORK_ORDER_WAREHOUSE,
|
||||
WORK_ORDER_VOID_STATUS,
|
||||
WORK_ORDER_RECEIVED_STATUS,
|
||||
EXPORT_MODES,
|
||||
buildProductExportRows,
|
||||
buildComponentExportRows,
|
||||
},
|
||||
};
|
||||
|
||||
227
backend/src/services/mkhReplenishAlertSettings.js
Normal file
227
backend/src/services/mkhReplenishAlertSettings.js
Normal file
@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
|
||||
//
|
||||
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
|
||||
// { enabled, recipientAlias, scope }
|
||||
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
|
||||
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
|
||||
//
|
||||
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
|
||||
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
|
||||
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
|
||||
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
toAdminConfig: toNotificationCenterAdminConfig,
|
||||
} = require('./yydNotificationCenterSettings');
|
||||
|
||||
const CONFIG_KEY = 'mkh_replenish_alert_config';
|
||||
const STATE_KEY = 'mkh_replenish_alert_state';
|
||||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
|
||||
|
||||
const SCOPE_OPTIONS = Object.freeze([
|
||||
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
|
||||
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
|
||||
]);
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 200) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || ''));
|
||||
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScope(value) {
|
||||
const text = normalizeText(value, 40).toLowerCase();
|
||||
if (!text) return SCOPES.WARNING;
|
||||
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
|
||||
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
|
||||
return SCOPES.WARNING;
|
||||
}
|
||||
|
||||
function normalizeFlag(value, fallback = true) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function readSystemConfigValue(key) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
return rows?.[0]?.configValue;
|
||||
}
|
||||
|
||||
async function upsertSystemConfigValue(key, value, configType = 'json') {
|
||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
if (rows?.[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, configType, key]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: key,
|
||||
config_value: payload,
|
||||
config_type: configType,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────────────────
|
||||
async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
let stored = {};
|
||||
try {
|
||||
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取补货预警推送配置失败,使用默认值:', error.message);
|
||||
}
|
||||
cachedConfig = {
|
||||
enabled: normalizeFlag(stored.enabled, true),
|
||||
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
|
||||
scope: normalizeScope(stored.scope),
|
||||
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
|
||||
*/
|
||||
async function resolveMkhReplenishAlertRecipient(options = {}) {
|
||||
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
|
||||
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
|
||||
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const alias = normalizeText(center?.recipientAlias, 50);
|
||||
return { alias, source: alias ? 'notification_center' : 'none' };
|
||||
}
|
||||
|
||||
async function getMkhReplenishAlertAdminConfig() {
|
||||
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const recipient = await resolveMkhReplenishAlertRecipient({ config });
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||||
const state = await loadMkhReplenishAlertState();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
scope: config.scope,
|
||||
recipientAlias: config.recipientAlias,
|
||||
effectiveRecipientAlias: recipient.alias || '',
|
||||
recipientSource: recipient.source,
|
||||
notificationCenterConfigured: centerAdmin.configured,
|
||||
notificationCenterBaseUrl: centerAdmin.baseUrl,
|
||||
notificationCenter: {
|
||||
configured: centerAdmin.configured,
|
||||
baseUrl: centerAdmin.baseUrl,
|
||||
projectCode: centerAdmin.projectCode,
|
||||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||||
recipientAlias: centerAdmin.recipientAlias,
|
||||
},
|
||||
notifiedCount: Object.keys(state.notified || {}).length,
|
||||
lastRunAt: state.lastRunAt || null,
|
||||
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertConfig(input = {}) {
|
||||
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const rawAlias = input.recipientAlias ?? input.recipient_alias;
|
||||
const next = {
|
||||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||||
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
|
||||
scope: normalizeScope(input.scope || current.scope),
|
||||
};
|
||||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||||
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
|
||||
cachedAt = Date.now();
|
||||
return getMkhReplenishAlertAdminConfig();
|
||||
}
|
||||
|
||||
// ── 推送状态 ────────────────────────────────────────────────────
|
||||
async function loadMkhReplenishAlertState() {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取补货预警推送状态失败,按空状态处理:', error.message);
|
||||
}
|
||||
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
|
||||
? parsed.notified
|
||||
: {};
|
||||
return {
|
||||
version: 1,
|
||||
notified,
|
||||
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertState(state = {}) {
|
||||
const value = {
|
||||
version: 1,
|
||||
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
|
||||
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
|
||||
};
|
||||
await upsertSystemConfigValue(STATE_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
|
||||
async function resetMkhReplenishAlertState() {
|
||||
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
STATE_KEY,
|
||||
SCOPES,
|
||||
SCOPE_OPTIONS,
|
||||
getMkhReplenishAlertAdminConfig,
|
||||
getMkhReplenishAlertRuntimeConfig,
|
||||
loadMkhReplenishAlertState,
|
||||
normalizeFlag,
|
||||
normalizeScope,
|
||||
resetMkhReplenishAlertState,
|
||||
resolveMkhReplenishAlertRecipient,
|
||||
saveMkhReplenishAlertConfig,
|
||||
saveMkhReplenishAlertState,
|
||||
};
|
||||
@ -164,3 +164,299 @@ test('pack inventory location prefers bins and falls back to positive-stock box
|
||||
|
||||
assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2');
|
||||
});
|
||||
|
||||
// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ────────────────
|
||||
const metrics = (row) => _private.buildReplenishMetrics(row);
|
||||
|
||||
test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => {
|
||||
const stockout = metrics({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 });
|
||||
assert.equal(stockout.stockDays, 0);
|
||||
assert.equal(stockout.warningLevel, '缺货');
|
||||
assert.equal(stockout.hasWarning, true);
|
||||
assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65)
|
||||
|
||||
const warning = metrics({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 });
|
||||
assert.equal(warning.stockDays, 0.5);
|
||||
assert.equal(warning.warningLevel, '预警');
|
||||
assert.equal(warning.hasWarning, true);
|
||||
assert.equal(warning.replenishQty, 5); // ceil(6 - 1)
|
||||
});
|
||||
|
||||
test('replenish: the threshold is exactly 3 days of sales', () => {
|
||||
const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 });
|
||||
assert.equal(atThreshold.stockDays, 3);
|
||||
assert.equal(atThreshold.warningLevel, '正常');
|
||||
assert.equal(atThreshold.hasWarning, false);
|
||||
assert.equal(atThreshold.replenishQty, 0);
|
||||
|
||||
const below = metrics({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 });
|
||||
assert.equal(below.hasWarning, true);
|
||||
});
|
||||
|
||||
test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => {
|
||||
const realtime = metrics({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 });
|
||||
assert.equal(realtime.stock, 4);
|
||||
assert.equal(realtime.stockSource, 'realtime');
|
||||
|
||||
// 实时库存「无记录」(null)才回退监控快照
|
||||
const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 });
|
||||
assert.equal(fallback.stock, 9);
|
||||
assert.equal(fallback.stockSource, 'monitor');
|
||||
|
||||
// 实时库存为 0 是有效值,不得被快照覆盖
|
||||
const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 });
|
||||
assert.equal(zero.stock, 0);
|
||||
assert.equal(zero.warningLevel, '缺货');
|
||||
});
|
||||
|
||||
test('replenish: products without 7-day sales never warn', () => {
|
||||
const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 });
|
||||
assert.equal(noSale.hasWarning, false);
|
||||
assert.equal(noSale.warningLevel, '无销量');
|
||||
assert.equal(noSale.stockDays, null);
|
||||
assert.equal(noSale.replenishQty, 0);
|
||||
assert.equal(noSale.thresholdQty, 0);
|
||||
});
|
||||
|
||||
test('replenish scope resolves from scope or the legacy warningOnly flag, then filters rows', () => {
|
||||
const rows = [
|
||||
{ skuId: 'A', hasWarning: true, dailySale: 2 },
|
||||
{ skuId: 'B', hasWarning: false, dailySale: 2 },
|
||||
{ skuId: 'C', hasWarning: false, dailySale: 0 },
|
||||
];
|
||||
|
||||
assert.equal(_private.resolveScope({}), 'all');
|
||||
assert.equal(_private.resolveScope({ warningOnly: '1' }), 'warning');
|
||||
assert.equal(_private.resolveScope({ scope: 'sold' }), 'sold');
|
||||
assert.equal(_private.resolveScope({ scope: 'bogus', warningOnly: 'true' }), 'warning');
|
||||
|
||||
assert.deepEqual(_private.filterRows(rows, { scope: 'warning' }).map((r) => r.skuId), ['A']);
|
||||
assert.deepEqual(_private.filterRows(rows, { scope: 'sold' }).map((r) => r.skuId), ['A', 'B']);
|
||||
assert.deepEqual(_private.filterRows(rows, { keyword: 'b' }).map((r) => r.skuId), ['B']);
|
||||
});
|
||||
|
||||
test('replenish sort puts the most urgent first and no-sale products last', () => {
|
||||
const rows = [
|
||||
{ skuId: 'NO-SALE', stockDays: null },
|
||||
{ skuId: 'B', stockDays: 2.8 },
|
||||
{ skuId: 'A', stockDays: 0 },
|
||||
{ skuId: 'C', stockDays: 12 },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
_private.sortRows(rows, 'stockDays', 'ASC').map((r) => r.skuId),
|
||||
['A', 'B', 'C', 'NO-SALE']
|
||||
);
|
||||
assert.deepEqual(
|
||||
_private.sortRows(rows, 'stockDays', 'DESC').map((r) => r.skuId),
|
||||
['C', 'B', 'A', 'NO-SALE']
|
||||
);
|
||||
assert.deepEqual(
|
||||
_private.sortRows(rows, 'skuId', 'ASC').map((r) => r.skuId),
|
||||
['A', 'B', 'C', 'NO-SALE']
|
||||
);
|
||||
});
|
||||
|
||||
// ── 在途加工单:半成品包装仓有未完成加工单就不再预警(2026-09-17 口径)──
|
||||
test('replenish: a pending work order suppresses the replenish warning', () => {
|
||||
// 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单
|
||||
const scheduled = metrics({
|
||||
sevenAvgSale: 2,
|
||||
realtimeStock: 0,
|
||||
monitorStock: 0,
|
||||
pendingWorkOrderQty: 20,
|
||||
pendingWorkOrderCount: 1,
|
||||
// 用本地时间构造,避免测试机时区不同导致日期串变化
|
||||
pendingWorkOrderEta: new Date(2026, 9, 1),
|
||||
pendingWorkOrderNo: '893495',
|
||||
});
|
||||
assert.equal(scheduled.hasWarning, false);
|
||||
assert.equal(scheduled.warningLevel, '已排产');
|
||||
assert.equal(scheduled.replenishQty, 0);
|
||||
assert.equal(scheduled.hasPendingWorkOrder, true);
|
||||
assert.equal(scheduled.pendingWorkOrderQty, 20);
|
||||
assert.equal(scheduled.pendingWorkOrderCount, 1);
|
||||
assert.equal(scheduled.pendingWorkOrderEta, '2026-10-01');
|
||||
assert.equal(scheduled.pendingWorkOrderNo, '893495');
|
||||
// 可用天数仍如实反映现有库存,便于业务复核
|
||||
assert.equal(scheduled.stockDays, 0);
|
||||
|
||||
// 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单
|
||||
const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0 });
|
||||
assert.equal(bare.hasWarning, true);
|
||||
assert.equal(bare.warningLevel, '缺货');
|
||||
});
|
||||
|
||||
test('replenish: pending work order fields default to empty without any order', () => {
|
||||
const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 });
|
||||
assert.equal(none.hasPendingWorkOrder, false);
|
||||
assert.equal(none.pendingWorkOrderQty, 0);
|
||||
assert.equal(none.pendingWorkOrderCount, 0);
|
||||
assert.equal(none.pendingWorkOrderEta, null);
|
||||
assert.equal(none.pendingWorkOrderNo, null);
|
||||
|
||||
// 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理
|
||||
const zeroQty = metrics({
|
||||
sevenAvgSale: 1,
|
||||
realtimeStock: 0,
|
||||
pendingWorkOrderQty: 0,
|
||||
pendingWorkOrderCount: 2,
|
||||
});
|
||||
assert.equal(zeroQty.hasPendingWorkOrder, true);
|
||||
assert.equal(zeroQty.warningLevel, '已排产');
|
||||
assert.equal(zeroQty.hasWarning, false);
|
||||
});
|
||||
|
||||
test('replenish: products without sales stay "no sale" even with a pending work order', () => {
|
||||
const noSale = metrics({
|
||||
sevenAvgSale: 0,
|
||||
realtimeStock: 0,
|
||||
pendingWorkOrderQty: 5,
|
||||
pendingWorkOrderCount: 1,
|
||||
});
|
||||
assert.equal(noSale.warningLevel, '无销量');
|
||||
assert.equal(noSale.hasWarning, false);
|
||||
// 在途量仍要透出,页面上应能看到
|
||||
assert.equal(noSale.pendingWorkOrderQty, 5);
|
||||
});
|
||||
|
||||
test('pending work order filters keep the business-confirmed source and exclusions', () => {
|
||||
assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓');
|
||||
assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废');
|
||||
assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库');
|
||||
assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产');
|
||||
});
|
||||
|
||||
// ── 导出(见服务文件头 ⑨)────────────────────────────────────────
|
||||
test('export: product rows follow the page table columns', () => {
|
||||
const rows = _private.buildProductExportRows([
|
||||
{
|
||||
row: {
|
||||
skuId: 'MKHDNZ0409260700428',
|
||||
productName: 'Z字翻转|25刨花板斜切',
|
||||
warningLevel: '缺货',
|
||||
dailySale: 2.97,
|
||||
stock: 0,
|
||||
pendingWorkOrderQty: 0,
|
||||
stockDays: 0,
|
||||
replenishQty: 9,
|
||||
},
|
||||
components: [{ barcode: 'A1', name: '铁搁板销', qty: 4 }, { barcode: 'A2', name: '自攻螺丝', qty: 12 }],
|
||||
},
|
||||
{
|
||||
row: {
|
||||
skuId: 'MKHDNZ0409260600117',
|
||||
productName: '无销量商品',
|
||||
warningLevel: '无销量',
|
||||
dailySale: 0,
|
||||
stock: 10,
|
||||
pendingWorkOrderQty: null,
|
||||
stockDays: null,
|
||||
replenishQty: 0,
|
||||
},
|
||||
components: [],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
skuId: 'MKHDNZ0409260700428',
|
||||
productName: 'Z字翻转|25刨花板斜切',
|
||||
warningLevel: '缺货',
|
||||
dailySale: 2.97,
|
||||
stock: 0,
|
||||
pendingWorkOrderQty: 0,
|
||||
stockDays: 0,
|
||||
replenishQty: 9,
|
||||
bomConfigured: '已配置',
|
||||
accessoryCount: 2,
|
||||
accessoryQtySum: 16,
|
||||
});
|
||||
// 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0
|
||||
assert.equal(rows[1].stockDays, null);
|
||||
assert.equal(rows[1].pendingWorkOrderQty, 0);
|
||||
assert.equal(rows[1].bomConfigured, '未配置');
|
||||
assert.equal(rows[1].accessoryQtySum, 0);
|
||||
});
|
||||
|
||||
test('export: component rows are flattened one row per component', () => {
|
||||
const rows = _private.buildComponentExportRows([
|
||||
{
|
||||
row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转' },
|
||||
components: [
|
||||
{ barcode: 'DNZ039864N1410V01', name: '台面', qty: 1, location: 'A-01 成品区' },
|
||||
{ barcode: 'GBSFY-01', name: '说明书', qty: 2, location: null },
|
||||
],
|
||||
},
|
||||
{ row: { skuId: 'MKHDNZ0409260600117', productName: '无配件商品' }, components: [] },
|
||||
]);
|
||||
|
||||
// 无配件的商品不产生任何导出行(不像页面那样占位)
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
skuId: 'MKHDNZ0409260700428',
|
||||
productName: 'Z字翻转',
|
||||
index: 1,
|
||||
componentBarcode: 'DNZ039864N1410V01',
|
||||
componentName: '台面',
|
||||
qty: 1,
|
||||
location: 'A-01 成品区',
|
||||
});
|
||||
// 同一商品的编码逐行重复(不做纵向合并,保证 Excel 可筛选)
|
||||
assert.equal(rows[1].skuId, 'MKHDNZ0409260700428');
|
||||
assert.equal(rows[1].index, 2);
|
||||
assert.equal(rows[1].location, null);
|
||||
assert.equal(rows[1].qty, 2);
|
||||
});
|
||||
|
||||
test('export: component rows fall back to code when barcode is missing', () => {
|
||||
const rows = _private.buildComponentExportRows([
|
||||
{ row: { skuId: 'MKH-X' }, components: [{ code: 'ONLY-CODE', name: '仅编码配件', qty: 3 }] },
|
||||
]);
|
||||
assert.equal(rows[0].componentBarcode, 'ONLY-CODE');
|
||||
});
|
||||
|
||||
test('export: mode constants cover both shapes', () => {
|
||||
assert.deepEqual(_private.EXPORT_MODES, { PRODUCTS: 'products', COMPONENTS: 'components' });
|
||||
});
|
||||
|
||||
test('pack inventory location prefers the warehouse name and keeps the box fallback', () => {
|
||||
const locations = _private.buildPackLocationMap([
|
||||
{
|
||||
skuId: 'PART-1-1',
|
||||
warehouseId: 1,
|
||||
warehouseName: '常州简尚家居有限公司',
|
||||
binGroup: null,
|
||||
bin: '',
|
||||
packId: 'BOX-1',
|
||||
qty: 3,
|
||||
},
|
||||
{
|
||||
skuId: 'PART-1-2',
|
||||
warehouseId: 1,
|
||||
warehouseName: '常州简尚家居有限公司',
|
||||
binGroup: '成品区',
|
||||
bin: 'A-01',
|
||||
packId: 'BOX-2',
|
||||
qty: 1,
|
||||
},
|
||||
], ['PART-1']);
|
||||
|
||||
assert.equal(
|
||||
locations.get('PART-1'),
|
||||
'常州简尚家居有限公司 · 箱号 BOX-1、常州简尚家居有限公司 · 成品区 / A-01'
|
||||
);
|
||||
});
|
||||
|
||||
test('pack inventory location falls back to the warehouse id when the name is missing', () => {
|
||||
const locations = _private.buildPackLocationMap([
|
||||
{ skuId: 'PART-1-1', warehouseId: 4, warehouseName: null, bin: '', packId: 'BOX-9', qty: 1 },
|
||||
], ['PART-1']);
|
||||
|
||||
assert.equal(locations.get('PART-1'), '仓库 4 · 箱号 BOX-9');
|
||||
});
|
||||
|
||||
test('pack inventory location chunk size stays under the MSSQL parameter limit', () => {
|
||||
// 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100
|
||||
assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true);
|
||||
});
|
||||
|
||||
139
backend/test/mkhReplenishAlertJob.test.js
Normal file
139
backend/test/mkhReplenishAlertJob.test.js
Normal file
@ -0,0 +1,139 @@
|
||||
'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('加工单数据源暂不可用'));
|
||||
});
|
||||
@ -2,6 +2,7 @@ import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const DETAIL_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const EXPORT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||
export const getMkhBomList = (params = {}) => request.get(
|
||||
@ -14,3 +15,33 @@ export const getMkhBomDetail = (skuId) => request.get(
|
||||
'/api/mkh-bom/detail',
|
||||
{ params: { skuId }, timeout: DETAIL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出:当前筛选条件下的全部数据(不分页)
|
||||
// mode=products → 商品清单;mode=components → 配件明细摊平
|
||||
// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时
|
||||
export const getMkhBomExportRows = (params = {}) => request.get(
|
||||
'/api/mkh-bom/export',
|
||||
{ params, timeout: EXPORT_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 补货预警推送设置(接收人 / 开关 / 推送范围)
|
||||
export const getMkhReplenishAlertConfig = () => request.get('/api/mkh-bom/alert-config');
|
||||
|
||||
// 保存补货预警推送设置
|
||||
export const saveMkhReplenishAlertConfig = (payload = {}) => request.put(
|
||||
'/api/mkh-bom/alert-config',
|
||||
payload
|
||||
);
|
||||
|
||||
// 立即检查并推送(dryRun 只预览 / force 忽略「只推新增」/ reset 先清空已推送记录)
|
||||
export const checkMkhReplenishAlert = (payload = {}) => request.post(
|
||||
'/api/mkh-bom/alert-check',
|
||||
payload,
|
||||
{ timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 保存统一通知中心凭据(补货预警的推送通道)
|
||||
export const saveMkhNotificationCenter = (payload = {}) => request.put(
|
||||
'/api/mkh-bom/notification-center',
|
||||
payload
|
||||
);
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch,
|
||||
Table, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom';
|
||||
import {
|
||||
getMkhBomList,
|
||||
getMkhBomDetail,
|
||||
getMkhBomExportRows,
|
||||
getMkhReplenishAlertConfig,
|
||||
saveMkhReplenishAlertConfig,
|
||||
saveMkhNotificationCenter,
|
||||
checkMkhReplenishAlert,
|
||||
} from '../../api/mkhBom';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@ -18,7 +30,93 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
|
||||
|
||||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||
|
||||
const EMPTY_FILTERS = { keyword: '' };
|
||||
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
|
||||
|
||||
// 预警等级 → 标签配色
|
||||
const WARNING_TONE = {
|
||||
缺货: 'danger',
|
||||
预警: 'warn',
|
||||
已排产: 'scheduled',
|
||||
正常: 'ok',
|
||||
无销量: 'muted',
|
||||
};
|
||||
|
||||
// 展示范围切换
|
||||
const SCOPE_OPTIONS = [
|
||||
{ label: '仅预警', value: 'warning' },
|
||||
{ label: '有日销', value: 'sold' },
|
||||
{ label: '全部', value: 'all' },
|
||||
];
|
||||
|
||||
// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)──────────
|
||||
// 商品清单:与页面表格列一致,去掉「操作」列
|
||||
const PRODUCT_EXPORT_COLUMNS = [
|
||||
{ title: 'MKH商品编码', dataIndex: 'skuId' },
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '预警', dataIndex: 'warningLevel' },
|
||||
{ title: '近7日日销', dataIndex: 'dailySale' },
|
||||
{ title: '现有库存', dataIndex: 'stock' },
|
||||
{ title: '在途加工量', dataIndex: 'pendingWorkOrderQty' },
|
||||
{
|
||||
title: '可用天数',
|
||||
dataIndex: 'stockDays',
|
||||
// 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨
|
||||
exportValue: (value) => (value === null || value === undefined ? '无销量' : value),
|
||||
},
|
||||
{
|
||||
title: '建议补货量',
|
||||
dataIndex: 'replenishQty',
|
||||
// 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」
|
||||
exportValue: (value, row) => (
|
||||
(row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : ''
|
||||
),
|
||||
},
|
||||
{ title: 'BOM 状态', dataIndex: 'bomConfigured' },
|
||||
{ title: '配件种类', dataIndex: 'accessoryCount' },
|
||||
{ title: '配件总数量', dataIndex: 'accessoryQtySum' },
|
||||
];
|
||||
|
||||
// 配件明细:与打印模板一致,额外带部件条码便于下游核对
|
||||
const COMPONENT_EXPORT_COLUMNS = [
|
||||
{ title: 'MKH商品编码', dataIndex: 'skuId' },
|
||||
{ title: '部件条码', dataIndex: 'componentBarcode' },
|
||||
{ title: '配件名称', dataIndex: 'componentName' },
|
||||
{ title: '配件数量', dataIndex: 'qty' },
|
||||
{ title: '仓位', dataIndex: 'location' },
|
||||
];
|
||||
|
||||
// 导出下拉菜单:key = `<mode>-<format>`
|
||||
const EXPORT_MENU_ITEMS = [
|
||||
{ key: 'products-xlsx', label: '商品清单 · XLSX' },
|
||||
{ key: 'products-csv', label: '商品清单 · CSV' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'components-xlsx', label: '配件明细 · XLSX' },
|
||||
{ key: 'components-csv', label: '配件明细 · CSV' },
|
||||
];
|
||||
|
||||
const EXPORT_SCOPE_LABEL = { warning: '仅预警', sold: '有日销', all: '全部' };
|
||||
|
||||
// 补货预警推送:范围选项与跳过原因文案
|
||||
const ALERT_SCOPE_OPTIONS = [
|
||||
{ label: '缺货 + 3天内预警', value: 'warning' },
|
||||
{ label: '仅缺货', value: 'outOfStock' },
|
||||
];
|
||||
|
||||
const ALERT_REASON_TEXT = {
|
||||
no_new_alert: '没有新出现的预警,未发送(已推送过的不重复提醒)',
|
||||
no_alert: '当前没有需要补货的 MKH 商品,未发送',
|
||||
disabled: '推送开关已关闭,未发送',
|
||||
recipient_not_configured: '未配置收件人,未发送',
|
||||
notification_center_not_configured: '统一通知中心尚未配置,未发送',
|
||||
error: '执行出错,未发送',
|
||||
};
|
||||
|
||||
const ALERT_RECIPIENT_SOURCE_TEXT = {
|
||||
config: '来自本页配置',
|
||||
environment: '来自环境变量',
|
||||
notification_center: '来自统一通知中心默认收件人',
|
||||
none: '未找到可用的收件人',
|
||||
};
|
||||
|
||||
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||||
const PrintPreview = ({ data, onClose }) => {
|
||||
@ -99,7 +197,8 @@ const MkhBomData = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' });
|
||||
// 默认按可用天数升序 —— 补货预警场景下最紧急的排最前
|
||||
const [sorter, setSorter] = useState({ field: 'stockDays', order: 'ASC' });
|
||||
|
||||
const [expandedKeys, setExpandedKeys] = useState({});
|
||||
const [detailCache, setDetailCache] = useState({});
|
||||
@ -108,6 +207,19 @@ const MkhBomData = () => {
|
||||
const [printData, setPrintData] = useState(null);
|
||||
const [printLoading, setPrintLoading] = useState(false);
|
||||
|
||||
// 导出(当前筛选条件下的全部数据)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// 补货预警推送设置
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [alertConfig, setAlertConfig] = useState(null);
|
||||
const [alertLoading, setAlertLoading] = useState(false);
|
||||
const [alertBusy, setAlertBusy] = useState(false);
|
||||
const [alertForm, setAlertForm] = useState({ enabled: true, recipientAlias: '', scope: 'warning' });
|
||||
const [alertResult, setAlertResult] = useState(null);
|
||||
const [centerForm, setCenterForm] = useState({ baseUrl: '', projectCode: '', projectSecret: '' });
|
||||
const [centerSaving, setCenterSaving] = useState(false);
|
||||
|
||||
const tableWrapRef = useRef(null);
|
||||
const [bodyHeight, setBodyHeight] = useState(420);
|
||||
|
||||
@ -170,6 +282,146 @@ const MkhBomData = () => {
|
||||
};
|
||||
const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); };
|
||||
|
||||
// ── 导出 ────────────────────────────────────────────────────
|
||||
// 与列表共用同一套筛选/排序口径,但走不分页接口(导出当前筛选条件下的「全部」行)。
|
||||
const handleExport = async (menuKey) => {
|
||||
const [mode, format] = String(menuKey || '').split('-');
|
||||
if (!mode || !format) return;
|
||||
|
||||
setExporting(true);
|
||||
try {
|
||||
const result = await getMkhBomExportRows({
|
||||
mode,
|
||||
keyword: filters.keyword,
|
||||
scope: filters.scope,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
});
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '导出失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = result.data || {};
|
||||
const rows = payload.rows || [];
|
||||
if (!rows.length) {
|
||||
message.warning('当前筛选条件下没有可导出的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const isComponents = mode === 'components';
|
||||
const scopeLabel = EXPORT_SCOPE_LABEL[payload.scope] || '全部';
|
||||
const stamp = payload.monitorDate || new Date().toISOString().slice(0, 10);
|
||||
|
||||
await exportTableFile(
|
||||
format,
|
||||
`${isComponents ? 'MKH配件明细' : 'MKH商品清单'}_${scopeLabel}_${stamp}`,
|
||||
isComponents ? COMPONENT_EXPORT_COLUMNS : PRODUCT_EXPORT_COLUMNS,
|
||||
rows,
|
||||
isComponents ? '配件明细' : '商品清单'
|
||||
);
|
||||
message.success(`已导出 ${rows.length} 行(${format.toUpperCase()})`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 补货预警推送 ────────────────────────────────────────────
|
||||
const applyAlertConfig = (config = {}) => {
|
||||
setAlertConfig(config);
|
||||
setAlertForm({
|
||||
enabled: config.enabled !== false,
|
||||
recipientAlias: config.recipientAlias || '',
|
||||
scope: config.scope || 'warning',
|
||||
});
|
||||
setCenterForm({
|
||||
baseUrl: config.notificationCenter?.baseUrl || '',
|
||||
projectCode: config.notificationCenter?.projectCode || '',
|
||||
projectSecret: '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveCenterConfig = async () => {
|
||||
setCenterSaving(true);
|
||||
try {
|
||||
const result = await saveMkhNotificationCenter(centerForm);
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '保存推送通道失败');
|
||||
return;
|
||||
}
|
||||
applyAlertConfig(result.data || {});
|
||||
message.success('推送通道配置已保存');
|
||||
} catch (e) {
|
||||
message.error(e?.response?.data?.error || e?.message || '保存推送通道失败');
|
||||
} finally {
|
||||
setCenterSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAlertSettings = async () => {
|
||||
setAlertOpen(true);
|
||||
setAlertResult(null);
|
||||
setAlertLoading(true);
|
||||
try {
|
||||
const result = await getMkhReplenishAlertConfig();
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '加载推送设置失败');
|
||||
return;
|
||||
}
|
||||
applyAlertConfig(result.data || {});
|
||||
} catch (e) {
|
||||
message.error(e?.response?.data?.error || e?.message || '加载推送设置失败');
|
||||
} finally {
|
||||
setAlertLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAlertSettings = async () => {
|
||||
setAlertBusy(true);
|
||||
try {
|
||||
const result = await saveMkhReplenishAlertConfig(alertForm);
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '保存推送设置失败');
|
||||
return;
|
||||
}
|
||||
applyAlertConfig(result.data || {});
|
||||
message.success('推送设置已保存');
|
||||
} catch (e) {
|
||||
message.error(e?.response?.data?.error || e?.message || '保存推送设置失败');
|
||||
} finally {
|
||||
setAlertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runAlertCheck = async (payload, doneText) => {
|
||||
setAlertBusy(true);
|
||||
setAlertResult(null);
|
||||
try {
|
||||
const result = await checkMkhReplenishAlert(payload);
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '执行推送失败');
|
||||
return;
|
||||
}
|
||||
const outcome = result.data || {};
|
||||
setAlertResult(outcome);
|
||||
if (outcome.sent) {
|
||||
message.success(`${doneText || '已推送'}:新增 ${outcome.newCount} 个预警 → ${outcome.recipient}`);
|
||||
} else if (outcome.reason === 'dry_run') {
|
||||
message.info('已生成预览内容,未实际发送');
|
||||
} else {
|
||||
message.warning(ALERT_REASON_TEXT[outcome.reason] || `未发送:${outcome.reason || '无新预警'}`);
|
||||
}
|
||||
const refreshed = await getMkhReplenishAlertConfig().catch(() => null);
|
||||
if (refreshed?.success) applyAlertConfig(refreshed.data || {});
|
||||
} catch (e) {
|
||||
message.error(e?.response?.data?.error || e?.message || '执行推送失败');
|
||||
} finally {
|
||||
setAlertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = useCallback(async (skuId) => {
|
||||
if (detailCache[skuId] || detailLoading[skuId]) return;
|
||||
setDetailLoading((old) => ({ ...old, [skuId]: true }));
|
||||
@ -207,7 +459,7 @@ const MkhBomData = () => {
|
||||
{
|
||||
title: 'MKH商品编码',
|
||||
dataIndex: 'skuId',
|
||||
width: 240,
|
||||
width: 230,
|
||||
fixed: 'left',
|
||||
sorter: true,
|
||||
render: (value) => <span className="mkh-sku">{text(value)}</span>,
|
||||
@ -216,9 +468,76 @@ const MkhBomData = () => {
|
||||
title: '商品名称',
|
||||
dataIndex: 'productName',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
|
||||
},
|
||||
{
|
||||
title: '预警',
|
||||
dataIndex: 'warningLevel',
|
||||
width: 84,
|
||||
align: 'center',
|
||||
render: (value) => (
|
||||
<span className={`mkh-tag mkh-tag--${WARNING_TONE[value] || 'muted'}`}>{text(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '近7日日销',
|
||||
dataIndex: 'dailySale',
|
||||
width: 106,
|
||||
align: 'right',
|
||||
render: (value) => (Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>),
|
||||
},
|
||||
{
|
||||
title: '现有库存',
|
||||
dataIndex: 'stock',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value, row) => (
|
||||
<Tooltip
|
||||
title={row.stockSource === 'realtime'
|
||||
? '品牌仓实时库存'
|
||||
: '品牌仓实时库存缺失,取监控快照库存'}
|
||||
>
|
||||
<span>{number(value)}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '在途加工量',
|
||||
dataIndex: 'pendingWorkOrderQty',
|
||||
width: 116,
|
||||
align: 'right',
|
||||
render: (value, row) => (row.hasPendingWorkOrder ? (
|
||||
<Tooltip
|
||||
title={[
|
||||
`半成品包装项目仓未完成加工单 ${row.pendingWorkOrderCount || 0} 张`,
|
||||
row.pendingWorkOrderEta ? `预计 ${row.pendingWorkOrderEta} 完成` : '',
|
||||
row.pendingWorkOrderNo ? `单号 ${row.pendingWorkOrderNo} 起` : '',
|
||||
'作废单据已剔除,不计入在途量',
|
||||
].filter(Boolean).join(';')}
|
||||
>
|
||||
<span className="mkh-inbound">{number(value)}</span>
|
||||
</Tooltip>
|
||||
) : <span className="mkh-muted">—</span>),
|
||||
},
|
||||
{
|
||||
title: '可用天数',
|
||||
dataIndex: 'stockDays',
|
||||
width: 106,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (value, row) => (value === null || value === undefined
|
||||
? <span className="mkh-muted">无销量</span>
|
||||
: <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>),
|
||||
},
|
||||
{
|
||||
title: '建议补货量',
|
||||
dataIndex: 'replenishQty',
|
||||
width: 116,
|
||||
align: 'right',
|
||||
render: (value, row) => (row.hasWarning && Number(value) > 0
|
||||
? <span className="mkh-replenish">{number(value)}</span>
|
||||
: <span className="mkh-muted">—</span>),
|
||||
},
|
||||
{
|
||||
title: 'BOM 状态',
|
||||
dataIndex: 'bomConfigured',
|
||||
@ -231,23 +550,21 @@ const MkhBomData = () => {
|
||||
{
|
||||
title: '配件种类',
|
||||
dataIndex: 'accessoryCount',
|
||||
width: 110,
|
||||
width: 96,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (value) => number(value),
|
||||
},
|
||||
{
|
||||
title: '配件总数量',
|
||||
dataIndex: 'accessoryQtySum',
|
||||
width: 120,
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (value) => number(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 190,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_value, row) => (
|
||||
<Space size={6}>
|
||||
@ -334,12 +651,18 @@ const MkhBomData = () => {
|
||||
<div className="mkh-header__heading">
|
||||
<Title level={3}>MKH-BOM 资料</Title>
|
||||
<Text type="secondary">
|
||||
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
|
||||
模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警
|
||||
</Text>
|
||||
<Text type="secondary" className="mkh-note">
|
||||
数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
|
||||
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充;
|
||||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。
|
||||
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。
|
||||
<br />
|
||||
补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
|
||||
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识;
|
||||
建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。
|
||||
<br />
|
||||
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算
|
||||
(作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
|
||||
</Text>
|
||||
</div>
|
||||
</header>
|
||||
@ -354,13 +677,69 @@ const MkhBomData = () => {
|
||||
onChange={(e) => changeFilter('keyword')(e.target.value)}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Segmented
|
||||
value={filters.scope}
|
||||
onChange={changeFilter('scope')}
|
||||
options={[
|
||||
{ label: `仅预警 (${data.warningCount ?? 0})`, value: 'warning' },
|
||||
{ label: `有日销 (${data.soldCount ?? 0})`, value: 'sold' },
|
||||
{ label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={exporting}
|
||||
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
|
||||
>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
|
||||
</Dropdown>
|
||||
<Button icon={<BellOutlined />} onClick={openAlertSettings}>预警推送</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||
|
||||
{!loading && !error && data.workOrderDegraded ? (
|
||||
<Alert
|
||||
className="mkh-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && (data.warningCount || 0) > 0 ? (
|
||||
<Alert
|
||||
className="mkh-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`补货预警:${data.warningCount} 个 MKH 商品现有库存低于 ${data.replenishDaysThreshold || 3} 天日销`}
|
||||
description={(
|
||||
<span>
|
||||
按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}):
|
||||
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
|
||||
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
|
||||
建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。
|
||||
{(data.scheduledCount || 0) > 0 ? (
|
||||
<>
|
||||
<br />
|
||||
另有 <b>{data.scheduledCount}</b> 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单
|
||||
(在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
action={(
|
||||
<Button size="small" type="primary" onClick={() => changeFilter('scope')('warning')}>
|
||||
只看预警
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && (data.total || 0) > 0 ? (
|
||||
<Alert
|
||||
type={data.bomRowCount > 0 ? 'info' : 'warning'}
|
||||
@ -418,13 +797,197 @@ const MkhBomData = () => {
|
||||
pageSizeOptions: [50, 100, 200],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 1000, y: bodyHeight }}
|
||||
scroll={{ x: 1696, y: bodyHeight }}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{printData ? <PrintPreview data={printData} onClose={() => setPrintData(null)} /> : null}
|
||||
|
||||
<Modal
|
||||
title="补货预警推送"
|
||||
open={alertOpen}
|
||||
onCancel={() => setAlertOpen(false)}
|
||||
width={680}
|
||||
destroyOnHidden
|
||||
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
|
||||
footer={(
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={alertBusy} disabled={alertLoading} onClick={saveAlertSettings}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({ dryRun: true, force: true })}>
|
||||
预览推送内容
|
||||
</Button>
|
||||
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({}, '已推送')}>
|
||||
立即检查并推送
|
||||
</Button>
|
||||
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({ force: true, reset: true }, '已重推')}>
|
||||
重推全部预警
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={alertLoading}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`每天 09:00 自动检查「${alertConfig?.scope === 'outOfStock' ? '缺货' : '缺货 + 3天内预警'}」商品,只推送新出现的预警;已推送过但尚未补货的商品不重复提醒。`}
|
||||
/>
|
||||
|
||||
<div className="mkh-alert-form">
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">开启推送</span>
|
||||
<Switch
|
||||
checked={alertForm.enabled}
|
||||
onChange={(checked) => setAlertForm((old) => ({ ...old, enabled: checked }))}
|
||||
/>
|
||||
<Text type="secondary">关闭后自动任务不再发送,本页仍可手动推送</Text>
|
||||
</div>
|
||||
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">收件人</span>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="通知中心收件人别名,留空则用默认收件人"
|
||||
value={alertForm.recipientAlias}
|
||||
onChange={(e) => setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">推送范围</span>
|
||||
<Select
|
||||
value={alertForm.scope}
|
||||
onChange={(value) => setAlertForm((old) => ({ ...old, scope: value }))}
|
||||
options={ALERT_SCOPE_OPTIONS}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alertConfig ? (
|
||||
<div className="mkh-alert-meta">
|
||||
<div>
|
||||
生效收件人:<b>{alertConfig.effectiveRecipientAlias || '未设置'}</b>
|
||||
<Text type="secondary">
|
||||
({ALERT_RECIPIENT_SOURCE_TEXT[alertConfig.recipientSource] || '未知来源'})
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
统一通知中心:
|
||||
<Text type={alertConfig.notificationCenterConfigured ? 'success' : 'warning'} strong>
|
||||
{alertConfig.notificationCenterConfigured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
{alertConfig.notificationCenterBaseUrl ? (
|
||||
<Text type="secondary">{` ${alertConfig.notificationCenterBaseUrl}`}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
已推送记录:<b>{alertConfig.notifiedCount || 0}</b> 个商品
|
||||
{` | `}上次检查:<b>{alertConfig.lastRunAt ? new Date(alertConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
className="mkh-alert-center"
|
||||
defaultActiveKey={alertConfig?.notificationCenterConfigured ? [] : ['center']}
|
||||
items={[{
|
||||
key: 'center',
|
||||
label: (
|
||||
<Space size={6}>
|
||||
<span>推送通道(统一通知中心)</span>
|
||||
<Text type={alertConfig?.notificationCenterConfigured ? 'success' : 'warning'} strong>
|
||||
{alertConfig?.notificationCenterConfigured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div className="mkh-alert-form mkh-alert-form--inner">
|
||||
{!alertConfig?.notificationCenterConfigured ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="统一通知中心尚未配置,补货预警无法送达。请向运维获取「项目代码 / 项目密钥」后在此填写。"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">服务地址</span>
|
||||
<Input
|
||||
value={centerForm.baseUrl}
|
||||
onChange={(e) => setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))}
|
||||
placeholder="https://sc.jiansun.vip/notification-center"
|
||||
style={{ width: 380 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">项目代码</span>
|
||||
<Input
|
||||
value={centerForm.projectCode}
|
||||
onChange={(e) => setCenterForm((old) => ({ ...old, projectCode: e.target.value }))}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mkh-alert-row">
|
||||
<span className="mkh-alert-label">项目密钥</span>
|
||||
<Input.Password
|
||||
value={centerForm.projectSecret}
|
||||
onChange={(e) => setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))}
|
||||
placeholder={alertConfig?.notificationCenter?.projectSecretConfigured
|
||||
? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改`
|
||||
: '未配置'}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="primary" loading={centerSaving} onClick={saveCenterConfig}>
|
||||
保存推送通道
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
|
||||
{alertResult ? (
|
||||
<Alert
|
||||
className="mkh-alert-result"
|
||||
type={alertResult.sent ? 'success' : (alertResult.reason === 'dry_run' ? 'info' : 'warning')}
|
||||
showIcon
|
||||
message={alertResult.sent
|
||||
? `已推送给 ${alertResult.recipient}:新增 ${alertResult.newCount} 个预警`
|
||||
: (alertResult.reason === 'dry_run'
|
||||
? '推送内容预览(未实际发送)'
|
||||
: (ALERT_REASON_TEXT[alertResult.reason] || `未发送:${alertResult.reason || ''}`))}
|
||||
description={(
|
||||
<div className="mkh-alert-result-body">
|
||||
<div>
|
||||
当前预警 {alertResult.summary?.totalCount ?? 0} 个
|
||||
(缺货 {alertResult.summary?.outOfStockCount ?? 0} |
|
||||
{alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0})
|
||||
,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。
|
||||
</div>
|
||||
{alertResult.payload ? (
|
||||
<div className="mkh-alert-preview">
|
||||
<div className="mkh-alert-preview-title">{alertResult.payload.title}</div>
|
||||
<pre>{alertResult.payload.content}</pre>
|
||||
<Text type="secondary">收件人:{alertResult.payload.recipientAlias}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mkh-alert-hint">
|
||||
「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
@ -82,6 +82,47 @@
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ============ 补货预警 ============ */
|
||||
.mkh-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 缺货 */
|
||||
.mkh-tag--danger {
|
||||
color: #a8071a;
|
||||
background: rgba(255, 77, 79, 0.14);
|
||||
}
|
||||
|
||||
/* 预警 */
|
||||
.mkh-tag--warn {
|
||||
color: #ad4e00;
|
||||
background: rgba(250, 173, 20, 0.2);
|
||||
}
|
||||
|
||||
/* 可用天数低于阈值 */
|
||||
.mkh-days--warn {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 建议补货量 */
|
||||
.mkh-replenish {
|
||||
color: #0958d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 已排产:半成品包装仓已有未完成加工单,按业务口径不再预警 */
|
||||
.mkh-tag--scheduled {
|
||||
color: #0958d9;
|
||||
background: rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
|
||||
/* 在途加工量(未完成加工单待入库数量) */
|
||||
.mkh-inbound {
|
||||
color: #0958d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============ A4 打印预览 ============ */
|
||||
.mkh-print-mask {
|
||||
position: fixed;
|
||||
@ -237,3 +278,91 @@
|
||||
margin: 12mm;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 补货预警推送设置弹窗 ──────────────────────────── */
|
||||
.mkh-alert-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mkh-alert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mkh-alert-label {
|
||||
flex: none;
|
||||
width: 68px;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.mkh-alert-meta {
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-bg-subtle);
|
||||
font-size: 12px;
|
||||
line-height: 2;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.mkh-alert-result {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mkh-alert-result-body {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.mkh-alert-preview {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.mkh-alert-preview-title {
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--sycm-text-strong);
|
||||
}
|
||||
|
||||
.mkh-alert-preview pre {
|
||||
margin: 0 0 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--sycm-text-primary);
|
||||
}
|
||||
|
||||
.mkh-alert-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
/* 推送通道(统一通知中心)折叠区 */
|
||||
.mkh-alert-center {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mkh-alert-center .ant-collapse-header {
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.mkh-alert-center .ant-collapse-content-box {
|
||||
padding: 0 0 4px !important;
|
||||
}
|
||||
|
||||
.mkh-alert-form--inner {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user