feat: MKH-BOM 补货预警排除在途加工单、缺货通知推送、导出功能

- 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列
- 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键)
- 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量
- 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用)
This commit is contained in:
yanhui 2026-09-17 10:01:13 +08:00
parent 50202557d9
commit fe831c32fd
10 changed files with 2610 additions and 90 deletions

View File

@ -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;

View File

@ -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.

View 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,
};

View File

@ -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,
},
};

View 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,
};

View File

@ -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);
});

View 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('加工单数据源暂不可用'));
});

View File

@ -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
);

View File

@ -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>
);
};

View File

@ -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;
}