Merge pull request 'feat: MKH-BOM 补货预警(订单驱动 + 配件不足)与日销口径改近 15 天' (#16)

This commit is contained in:
yanhui 2026-09-18 17:56:13 +08:00
commit cc66e5100f
15 changed files with 4308 additions and 317 deletions

View File

@ -4,7 +4,18 @@ const express = require('express');
const {
getMkhBomList,
getMkhBomDetail,
listMkhBomExportRows,
clearMkhBomCache,
} = 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 +48,68 @@ 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, '保存统一通知中心配置失败');
}
});
// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照
router.post('/sync', async (_req, res) => {
try {
res.json({ success: true, data: clearMkhBomCache() });
} catch (error) {
sendError(res, error, '同步 MKH-BOM 数据失败');
}
});
module.exports = router;

View File

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

File diff suppressed because it is too large Load Diff

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

@ -4,9 +4,9 @@
//
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码
// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
@ -14,7 +14,7 @@
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// sevenAvgSale 近7日日均发货量
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
@ -28,8 +28,11 @@
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
//
// ⚠️ 业务库确实无数据源的字段:
// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0
// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废):
// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」
// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后
// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1)
// - 详见 buildLocationSql() 注释
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
@ -41,7 +44,8 @@ const WAREHOUSE_WMS_CO_ID = 15718196;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEFAULT_SAFETY_DAYS = 15;
// 预警阈值:库存可用天数 <= 7 天即预警
// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0)
// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql)
const LOW_STOCK_DAYS_THRESHOLD = 7;
const WARNING_LEVEL = Object.freeze({
@ -50,33 +54,29 @@ const WARNING_LEVEL = Object.freeze({
NORMAL: '正常',
});
// 模块化配置状态:
// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案
// (external.product_catalog_wide_base_v1 按 barcode 匹配)
// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案
//
// 口径变更说明(2026-09-15,按业务确认):
// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」,
// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与
// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合,
// 按款号(SPU)放宽也只有 3 个款号重叠。
// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径,
// 实测目标仓 661/794 命中。
// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正):
// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中,
// 被某个 MKH 模块化成品引用:
// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU
// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。
// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。
// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。
// ⚠️ 历史口径均已废弃:
// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关;
// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装,
// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。
// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。
const MKH_STATUS = Object.freeze({
CONFIGURED: '已配置MKH',
UNCONFIGURED: '未配置MKH',
});
// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库
const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000;
let productCoverageCache = null;
const SORTABLE_FIELDS = Object.freeze({
skuId: 's.sku_id',
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip7d: 'm.sevenAvgSale',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'm.jsUseDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
@ -85,21 +85,53 @@ const SORTABLE_FIELDS = Object.freeze({
});
/**
* 「已配置MKH」判定表达式。
* available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码)
* available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。
*/
const buildConfiguredExistsSql = (coverage = {}) => (
coverage.available
? `EXISTS (
SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id
)`
: `EXISTS (
SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id
)`
const buildConfiguredExistsSql = () => (
`EXISTS (
SELECT 1 FROM rpt.jst_skuBomItem x
WHERE x.map_outer_sku_id = s.sku_id
AND x.sku_id LIKE N'MKH%'
)`
);
const buildBaseSql = (coverage = {}) => `
/**
* 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。
* 0 = 缺货 / 1 = 预警 / 2 = 正常
* 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。
* 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。
* 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。
*/
const buildWarningOrderSql = () => (
`CASE
WHEN cfg.configured = 0 THEN 2
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END`
);
/**
* 统一的「仓位」表达式(2026-09-18 改源)。
* 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」);
* 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后,
* 目标仓 794 个 SKU **命中 727 个**(实测),如 ZT-8 / BCP01-01-1。
* · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位)
* · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果)
* 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。
* ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。
*/
const buildLocationSql = () => (
`COALESCE(
ploc.bin,
NULLIF(LTRIM(RTRIM(s.bin)), N''),
NULLIF(LTRIM(RTRIM(s.other_bin)), N'')
)`
);
const buildBaseSql = () => `
FROM dbo.jst_sku s
OUTER APPLY (
SELECT TOP 1 i.qty
@ -108,7 +140,7 @@ OUTER APPLY (
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
@ -121,15 +153,34 @@ OUTER APPLY (
AND p.delivery_date >= @monitorDate
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
) pin
-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计
-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、
-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。
-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明,
-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。
OUTER APPLY (
SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
) ptr
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
FROM dbo.fys_skuCostPriceUpdate c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 模块化配置状态:见文件头部「口径变更说明」
-- 仓位:主货位(真货位),见 buildLocationSql() 说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured
SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.sku_id = s.sku_id
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
AND ISNULL(pk.qty, 0) > 0
ORDER BY pk.qty DESC
) ploc
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
) cfg
WHERE s.other_3 = @warehouseName`;
@ -145,16 +196,6 @@ const toText = (value) => {
return text === '' ? null : text;
};
const normalizeWarningLevel = (realtimeStock, stockDays) => {
const stock = Number(realtimeStock || 0);
if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK;
const days = toNumber(stockDays);
if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING;
return WARNING_LEVEL.NORMAL;
};
const WARNING_ORDER = {
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
[WARNING_LEVEL.WARNING]: 1,
@ -180,7 +221,7 @@ async function resolveMonitorDate() {
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
}
const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
const buildFilters = (query = {}, monitorDate = null) => {
const conditions = [];
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
@ -188,11 +229,6 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
monitorDate,
};
// 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码)
if (coverage.available) {
params.productCoveredCsv = coverage.csv || '';
}
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
@ -208,7 +244,7 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
const location = toText(query.location);
if (location) {
params.location = location;
conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`);
conditions.push(`${buildLocationSql()} = @location`);
}
const mkhStatus = toText(query.mkhStatus);
@ -220,20 +256,14 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
const warningLevel = toText(query.warningLevel);
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
params.warningOrder = WARNING_ORDER[warningLevel];
conditions.push(`
CASE
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END = @warningOrder`);
// 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH)
conditions.push(`${buildWarningOrderSql()} = @warningOrder`);
}
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
if (['1', 'true', 'yes'].includes(onlyWarning)) {
conditions.push(`(
COALESCE(inv.qty, m.jsStock, 0) <= 0
OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD})
)`);
// 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除)
conditions.push(`${buildWarningOrderSql()} <= 1`);
}
return { conditions, params };
@ -284,59 +314,24 @@ async function attachProductImages(rows = []) {
}
/**
* 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。
* 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。
* 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。
* 「已配置MKH」判定说明(2026-09-17 晚修正):
* 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头),
* 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。
*/
async function resolveProductCoverage() {
const now = Date.now();
if (
productCoverageCache
&& productCoverageCache.warehouse === WAREHOUSE_NAME
&& now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS
) {
return productCoverageCache;
}
const skuRows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 180000 }
);
const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean);
let covered = [];
let available = true;
try {
const rows = await productDb.query(
`SELECT DISTINCT barcode
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
covered = rows.map((row) => toText(row.barcode)).filter(Boolean);
} catch (error) {
available = false;
console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message);
}
productCoverageCache = {
warehouse: WAREHOUSE_NAME,
available,
csv: covered.join(','),
set: new Set(covered),
total: codes.length,
coveredCount: covered.length,
at: now,
};
return productCoverageCache;
}
const mapRow = (row = {}) => {
const realtimeStock = Number(row.realtimeStock || 0);
const stockDays = toNumber(row.stockDays);
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
const warningLevel = normalizeWarningLevel(realtimeStock, stockDays);
// 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则),
// 前端显示与排序口径保持一致,避免前后端各算一份。
const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]);
const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK]
? WARNING_LEVEL.OUT_OF_STOCK
: warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING]
? WARNING_LEVEL.WARNING
: WARNING_LEVEL.NORMAL;
return {
skuId: toText(row.skuId),
@ -345,14 +340,15 @@ const mapRow = (row = {}) => {
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
warningLevel,
warningOrder: WARNING_ORDER[warningLevel],
warningOrder,
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
inQtyToday: toNumber(row.inQtyToday) ?? 0,
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
purchaseTransit: toNumber(row.purchaseTransit) ?? 0,
location: toText(row.location),
avgShip7d: toNumber(row.avgShip7d),
avgShip15d: toNumber(row.avgShip15d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
@ -365,10 +361,11 @@ const buildProjection = () => `
s.name AS productName,
s.category AS category,
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
ISNULL(ptr.transit, 0) AS purchaseTransit,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location,
m.sevenAvgSale AS avgShip7d,
${buildLocationSql()} AS location,
m.fifteenAvgSale AS avgShip15d,
m.jsUseDays AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
@ -379,23 +376,16 @@ const buildProjection = () => `
cost.pictureSkucode
) AS imageUrl,
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
CASE
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END AS warningOrder,
${buildWarningOrderSql()} AS warningOrder,
m.date AS monitorDate`;
async function getModularStockList(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 [monitorDate, coverage] = await Promise.all([
resolveMonitorDate(),
resolveProductCoverage(),
]);
const { conditions, params } = buildFilters(query, monitorDate, coverage);
const baseSql = buildBaseSql(coverage);
const monitorDate = await resolveMonitorDate();
const { conditions, params } = buildFilters(query, monitorDate);
const baseSql = buildBaseSql();
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
@ -443,6 +433,7 @@ async function getModularStockList(query = {}) {
configuredCount: summaryRow?.configuredCount || 0,
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
totalStock: summaryRow?.totalStock || 0,
totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0,
},
};
}
@ -451,22 +442,23 @@ async function buildSummary(whereSql, params) {
const rows = await cloudDatabase.query(
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount,
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount,
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
MAX(m.date) AS monitorDate
${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const row = rows[0] || {};
return [{
skuCount: Number(row.skuCount || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
totalStock: Number(row.totalStock || 0),
totalPurchaseTransit: Number(row.totalPurchaseTransit || 0),
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
configuredCount: Number(row.configuredCount || 0),
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
@ -474,25 +466,17 @@ async function buildSummary(whereSql, params) {
}
async function getModularStockFilters() {
const [monitorDate, coverage] = await Promise.all([
resolveMonitorDate(),
resolveProductCoverage(),
]);
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
if (coverage.available) {
params.productCoveredCsv = coverage.csv || '';
}
// 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可,
// 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。
const rows = await cloudDatabase.query(
`SELECT DISTINCT s.category
${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N''
FROM dbo.jst_sku s
WHERE s.other_3 = @warehouseName
AND s.category IS NOT NULL
AND LTRIM(RTRIM(s.category)) <> N''
ORDER BY s.category`,
params,
{ requestTimeout: 180000 }
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 60000 }
);
return {
@ -503,6 +487,97 @@ async function getModularStockFilters() {
};
}
/**
* 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。
* 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。
* 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。
*/
async function getPurchaseInTransitList(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 keyword = toText(query.keyword);
const params = { warehouseName: WAREHOUSE_NAME };
let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`;
if (keyword) {
params.keyword = `%${keyword}%`;
whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`;
}
const sortField = String(query.sortField || '').trim();
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderMap = {
skuId: 'p.sku_id',
deliveryDate: 'p.delivery_date',
transitQty: 'transitQty',
purchaseQty: 'p.qty',
};
const orderCol = orderMap[sortField] || 'transitQty';
const orderClause = sortField === 'skuId'
? `${orderCol} ${sortOrder}`
: `${orderCol} ${sortOrder}, p.sku_id ASC`;
const offset = (page - 1) * pageSize;
const [rows, totalRow] = await Promise.all([
cloudDatabase.query(
`SELECT
p.sku_id AS skuId,
p.name AS productName,
CAST(p.qty AS BIGINT) AS purchaseQty,
CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty,
CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty,
p.delivery_date AS deliveryDate,
CAST(p.po_id AS NVARCHAR(50)) AS poId,
CAST(p.poi_id AS NVARCHAR(50)) AS poiId
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
),
cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}`,
params,
{ requestTimeout: 180000 }
),
]);
return {
rows: rows.map((row) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
purchaseQty: Number(row.purchaseQty || 0),
inQty: Number(row.inQty || 0),
transitQty: Number(row.transitQty || 0),
deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null,
poId: toText(row.poId),
poiId: toText(row.poiId),
})),
total: Number(totalRow?.[0]?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
};
}
// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回,
// 前端据此刷新列表到最新快照。
// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。
async function syncModularStock() {
const monitorDate = await resolveMonitorDate();
return {
syncedAt: new Date().toISOString(),
monitorDate,
};
}
module.exports = {
WAREHOUSE_NAME,
WAREHOUSE_WMS_CO_ID,
@ -510,4 +585,6 @@ module.exports = {
DEFAULT_SAFETY_DAYS,
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
};

View File

@ -169,3 +169,594 @@ test('components without pack inventory rows do not receive a fallback warehouse
const locations = _private.buildPackLocationMap([], ['PART-1']);
assert.equal(locations.has('PART-1'), false);
});
// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)──
test('component location is sourced only from the pack inventory table', () => {
assert.equal(typeof _private.loadComponentLocations, 'function');
// 兜底链已删除:这两个符号不应再存在(存在即有代码走回退)
assert.equal(_private.loadComponentLocationData, undefined);
assert.equal(_private.buildInventoryWarehouseMap, undefined);
});
test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => {
const locations = _private.buildPackLocationMap([
{
skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3,
},
], ['PART-1', 'PART-2']);
assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9');
// 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库
assert.equal(locations.has('PART-2'), false);
});
// ── 补货预警:近 15 天日销 > 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 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({ avgSale: 1, realtimeStock: 4, monitorStock: 9 });
assert.equal(realtime.stock, 4);
assert.equal(realtime.stockSource, 'realtime');
// 实时库存「无记录」(null)才回退监控快照
const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 });
assert.equal(fallback.stock, 9);
assert.equal(fallback.stockSource, 'monitor');
// 实时库存为 0 是有效值,不得被快照覆盖
const zero = metrics({ avgSale: 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({ avgSale: 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({
avgSale: 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({ avgSale: 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({ avgSale: 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({
avgSale: 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({
avgSale: 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('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => {
// 单商品、省略 *数量
assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [
{ code: 'MKHDNZ0409260700426', qty: 1 },
]);
// 多商品、每个带 *数量(总件数前缀 2. 不参与计算)
assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [
{ code: 'A', qty: 1 },
{ code: 'B', qty: 1 },
]);
// 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定
assert.deepEqual(
_private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'),
[
{ code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 },
{ code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 },
{ code: 'MKHDNZ0409260600022', qty: 1 },
]
);
// 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组)
assert.deepEqual(_private.parseOrderSkus('0.'), []);
assert.deepEqual(_private.parseOrderSkus(''), []);
assert.deepEqual(_private.parseOrderSkus(null), []);
assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [
{ code: 'MKHDNZ0409260601388', qty: 2 },
]);
});
test('pending order: only MKH finished products count, combination codes are excluded', () => {
assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true);
assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true);
// `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求
assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false);
assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false);
assert.equal(_private.isMkhProductCode(''), false);
assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']);
});
test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => {
// 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。
// 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存
const outOfStock = metrics({
avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2,
});
assert.equal(outOfStock.hasWarning, true);
assert.equal(outOfStock.warningLevel, '缺货');
assert.equal(outOfStock.orderDriven, true);
assert.equal(outOfStock.stockDays, 0);
assert.equal(outOfStock.replenishQty, 4);
assert.equal(outOfStock.pendingOrderQty, 4);
assert.equal(outOfStock.pendingOrderCount, 2);
// 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货)
const short = metrics({
avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4,
});
assert.equal(short.warningLevel, '预警');
assert.equal(short.hasWarning, true);
assert.equal(short.stockDays, 0.25);
// 补到 3 天缓冲:ceil(3 × 4 − 1) = 11
assert.equal(short.replenishQty, 11);
// 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8
const oneDay = metrics({
avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
});
assert.equal(oneDay.warningLevel, '预警');
assert.equal(oneDay.stockDays, 1);
assert.equal(oneDay.replenishQty, 8);
// 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警
const atThreshold = metrics({
avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4,
});
assert.equal(atThreshold.warningLevel, '正常');
assert.equal(atThreshold.hasWarning, false);
assert.equal(atThreshold.stockDays, 3);
assert.equal(atThreshold.replenishQty, 0);
// 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven
const enough = metrics({
avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4,
});
assert.equal(enough.warningLevel, '正常');
assert.equal(enough.hasWarning, false);
assert.equal(enough.orderDriven, true);
assert.equal(enough.stockDays, 3.25);
});
test('replenish: order-driven warning does not override the pending work order exemption', () => {
const scheduled = metrics({
avgSale: 0,
realtimeStock: 0,
pendingOrderQty: 5,
pendingOrderCount: 1,
pendingWorkOrderQty: 6,
pendingWorkOrderCount: 1,
});
assert.equal(scheduled.warningLevel, '已排产');
assert.equal(scheduled.hasWarning, false);
assert.equal(scheduled.replenishQty, 0);
// 待发货量仍要透出,页面上应能看到
assert.equal(scheduled.pendingOrderQty, 5);
assert.equal(scheduled.orderDriven, true);
});
test('replenish: pending order fields default to empty without any unshipped order', () => {
const none = metrics({ avgSale: 1, realtimeStock: 0 });
assert.equal(none.pendingOrderQty, 0);
assert.equal(none.pendingOrderCount, 0);
assert.equal(none.pendingOrderEarliest, null);
assert.equal(none.orderDriven, false);
// 无日销且无订单 → 仍是「无销量」,不参与预警
const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
assert.equal(noSale.warningLevel, '无销量');
assert.equal(noSale.orderDriven, false);
// 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫)
const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 });
assert.equal(withSale.warningLevel, '缺货');
assert.equal(withSale.orderDriven, false);
assert.equal(withSale.pendingOrderQty, 3);
assert.equal(withSale.availableStock, 0); // max(0, 0 − 3)
assert.equal(withSale.stockDays, 0);
assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6
// 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常
const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 });
assert.equal(buffered.availableStock, 7);
assert.equal(buffered.stockDays, 3.5);
assert.equal(buffered.warningLevel, '正常');
assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0
// 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27)
const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 });
assert.equal(tight.availableStock, 2);
assert.equal(tight.stockDays, 2);
assert.equal(tight.warningLevel, '预警');
assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5)
});
// 回归:MKHDNZ0409260601376(2026-09-18 业务反馈)
// 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。
// 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。
test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => {
const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 });
assert.equal(row.orderDriven, false);
assert.equal(row.availableStock, 0);
assert.equal(row.stockDays, 0);
assert.equal(row.warningLevel, '缺货');
assert.equal(row.hasWarning, true);
assert.equal(row.warningOrder, 1);
assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81)
});
// ── 导出(见服务文件头 ⑨)────────────────────────────────────────
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,
pendingOrderQty: 0,
stockDays: 0,
replenishQty: 9,
componentShortage: '',
assemblableQty: null,
bomConfigured: '已配置',
accessoryCount: 2,
accessoryQtySum: 16,
});
// 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0
assert.equal(rows[1].stockDays, null);
assert.equal(rows[1].pendingWorkOrderQty, 0);
assert.equal(rows[1].pendingOrderQty, 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);
});
// ── 配件不足 / 可组装数(见服务文件头 ⑪)──────────────────────────
test('component readiness: assemblable qty is limited by the tightest component', () => {
const components = [
{ barcode: 'A', name: '配件A', qty: 1 },
{ barcode: 'B', name: '配件B', qty: 2 },
];
// A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B
const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map());
assert.equal(ready.assemblableQty, 3);
assert.equal(ready.shortage, false);
assert.equal(ready.bottlenecks[0].code, 'B');
assert.equal(ready.bottlenecks[0].can, 3);
// Tooltip 要能显示「单台用量」,不能默认当 1
assert.equal(ready.bottlenecks[0].perQty, 2);
});
test('component readiness: zero assemblable qty marks 配件不足', () => {
const components = [
{ barcode: 'A', name: '配件A', qty: 1 },
{ barcode: 'B', name: '配件B', qty: 1 },
];
// B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来
const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map());
assert.equal(ready.assemblableQty, 0);
assert.equal(ready.shortage, true);
assert.equal(ready.bottlenecks[0].code, 'B');
assert.equal(ready.bottlenecks[0].stock, 0);
assert.equal(ready.bottlenecks[0].available, 0);
});
test('component readiness: pending work order occupation is deducted from stock', () => {
const components = [{ barcode: 'A', name: '配件A', qty: 2 }];
// 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2
const ready = _private.buildComponentReadiness(
components,
new Map([['A', 10]]),
new Map([['A', 6]])
);
assert.equal(ready.assemblableQty, 2);
assert.equal(ready.bottlenecks[0].occupied, 6);
assert.equal(ready.bottlenecks[0].available, 4);
// 占用超过库存 → 可支配数归 0,不能算成负数
const exhausted = _private.buildComponentReadiness(
components,
new Map([['A', 10]]),
new Map([['A', 20]])
);
assert.equal(exhausted.assemblableQty, 0);
assert.equal(exhausted.shortage, true);
assert.equal(exhausted.bottlenecks[0].available, 0);
});
test('component readiness: missing BOM detail means unknown, not 配件不足', () => {
// 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记
assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null);
assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null);
assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null);
});
test('component occupation: pending work orders expand into component quantities', () => {
const bomBySku = new Map([['MKHA1', [
{ barcode: 'A', qty: 2 },
{ barcode: 'B', qty: 1 },
]]]);
const occupation = _private.buildComponentOccupation([
{ skuId: 'MKHA1', pendingWorkOrderQty: 5 },
// 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据)
{ skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 },
// 在途 0 或非数字 → 忽略
{ skuId: 'MKHA1', pendingWorkOrderQty: 0 },
{ skuId: 'MKHA1', pendingWorkOrderQty: null },
], bomBySku);
assert.equal(occupation.get('A'), 10);
assert.equal(occupation.get('B'), 5);
assert.equal(occupation.size, 2);
});
test('component shortage keeps the business-confirmed source and scope', () => {
// 配件库存只认半成品包装仓、只判现有库存(不含采购在途)
assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196);
assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓');
// 每个编码占 1 个参数,MSSQL 单语句上限 2100
assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true);
assert.equal(typeof _private.loadComponentStock, 'function');
assert.equal(typeof _private.loadMkhComponentReadiness, 'function');
});
test('export: 配件不足 rows are flagged and carry no replenish target', () => {
const rows = _private.buildProductExportRows([
{
row: {
skuId: 'MKHDNZ0409260600843',
productName: 'K字单板基础',
warningLevel: '缺货',
dailySale: 0,
stock: 0,
pendingOrderQty: 1,
replenishQty: 1,
componentShortage: true,
assemblableQty: 0,
},
components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }],
},
]);
assert.equal(rows[0].componentShortage, '配件不足');
// 可组装数一并导出,便于业务核对短板
assert.equal(rows[0].assemblableQty, 0);
});

View File

@ -0,0 +1,194 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
chinaDateKey,
diffAlertState,
formatQty,
} = require('../src/jobs/mkhReplenishAlertJob');
const alert = (skuId, level = '缺货', extra = {}) => ({
skuId,
warningLevel: level,
productName: `商品 ${skuId}`,
dailySale: 1,
stock: 0,
replenishQty: 3,
...extra,
});
test('diffAlertState splits fresh / persisted / restored', () => {
const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } };
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state);
assert.deepEqual(fresh.map((r) => r.skuId), ['C']);
assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']);
// Z 已补货恢复,应从已推送记录里剔除
assert.deepEqual(restored, ['Z']);
});
test('diffAlertState treats empty state as all fresh', () => {
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {});
assert.equal(fresh.length, 2);
assert.equal(persisted.length, 0);
assert.deepEqual(restored, []);
});
test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z');
assert.deepEqual(Object.keys(next).sort(), ['A', 'B']);
assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z');
assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z');
assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z');
});
test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false });
assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z');
});
test('buildAlertSignature is order independent but content sensitive', () => {
const a = alert('A');
const b = alert('B', '预警');
assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a]));
assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a]));
// 等级变化也算变化(缺货 → 预警 应产生新指纹)
assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')]));
});
test('chinaDateKey renders Beijing date regardless of UTC offset', () => {
// 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17
assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917');
assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916');
});
test('formatQty trims trailing zeros and tolerates bad input', () => {
assert.equal(formatQty(3), '3');
assert.equal(formatQty(2.9700000001), '2.97');
assert.equal(formatQty(0.28), '0.28');
assert.equal(formatQty(null), '—');
assert.equal(formatQty('abc'), '—');
});
test('notification carries recipient, idempotency key and shortfall details', () => {
const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })];
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
const snapshot = {
monitorDate: '2026-09-16',
saleWindowDays: 15,
replenishDaysThreshold: 3,
outOfStockCount: 1,
warningOnlyCount: 1,
replenishQtyTotal: 14,
};
const payload = buildMkhReplenishNotification({
alerts: fresh,
allAlerts: all,
snapshot,
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.equal(payload.recipientAlias, '苏适');
assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`);
assert.ok(payload.title.includes('新增 1 个'));
assert.ok(payload.title.includes('含 1 个已缺货'));
assert.ok(payload.content.includes('MKHDNZ0409260700428'));
assert.ok(payload.content.includes('现有库存 0'));
assert.ok(payload.content.includes('建议补货 9'));
// 汇总用全量口径,不是本批新增
assert.ok(payload.content.includes('当前共 **2** 个'));
assert.equal(payload.actionTitle, '查看补货清单');
assert.ok(payload.content.length <= 4800);
});
test('notification truncates long lists but keeps the totals', () => {
const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`));
const payload = buildMkhReplenishNotification({
alerts: many,
allAlerts: many,
snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('仅列出前 20 个'));
assert.ok(payload.content.includes('当前共 **40** 个'));
assert.equal(payload.content.includes('MKH0039'), false);
assert.ok(payload.content.length <= 4800);
});
test('notification flags degraded work-order source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('加工单数据源暂不可用'));
});
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
test('notification spells out 配件不足 and drops the replenish target', () => {
const shortage = alert('MKHDNZ0409260600843', '缺货', {
dailySale: 0,
stock: 0,
replenishQty: 1,
componentShortage: true,
assemblableQty: 0,
bottleneckComponents: [
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
],
});
const payload = buildMkhReplenishNotification({
alerts: [shortage],
allAlerts: [shortage],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 1,
componentShortageCount: 1,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件不足,无法补货'));
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
assert.ok(payload.content.includes('建议补货 —'));
assert.equal(payload.content.includes('建议补货 1'), false);
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
assert.ok(payload.content.includes('DNZ040958N1440V01'));
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
// 汇总段说明本批里有几个配件不足
assert.ok(payload.content.includes('因**配件库存不足**'));
});
test('notification flags degraded component stock source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 3,
componentStockDegraded: true,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
assert.ok(payload.content.includes('未做'));
});

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,41 @@ 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
);
// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 随后重新拉列表即直连副本库取最新快照,可随时点击
export const syncMkhBom = () => request.post(
'/api/mkh-bom/sync',
null,
{ timeout: LIST_TIMEOUT_MS }
);

View File

@ -14,3 +14,16 @@ export const getModularStockFilters = () => request.get(
'/api/modular-stock-monitor/filters',
{ timeout: META_TIMEOUT_MS }
);
// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细)
export const getPurchaseInTransitList = (params = {}) => request.get(
'/api/modular-stock-monitor/purchase-in-transit',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照
export const syncModularStock = () => request.post(
'/api/modular-stock-monitor/sync',
null,
{ timeout: META_TIMEOUT_MS }
);

View File

@ -1,10 +1,23 @@
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, SyncOutlined,
} from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom';
import {
getMkhBomList,
getMkhBomDetail,
getMkhBomExportRows,
getMkhReplenishAlertConfig,
saveMkhReplenishAlertConfig,
saveMkhNotificationCenter,
checkMkhReplenishAlert,
syncMkhBom,
} from '../../api/mkhBom';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
@ -18,9 +31,134 @@ 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',
};
// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪):
// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来
const shortageTooltip = (row) => {
const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : [];
const details = bottlenecks
.filter((item) => Number(item.available) <= 0)
.map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}`
+ `:库存 ${number(item.stock)}`
+ `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}`
+ `,单台用量 ${number(item.perQty)}`);
return [
`配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`,
...details,
'口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来',
].filter(Boolean).join(';');
};
// 展示范围切换
const SCOPE_OPTIONS = [
{ label: '仅预警', value: 'warning' },
{ label: '有日销', value: 'sold' },
{ label: '加工在途', value: 'work-order' },
{ label: '全部', value: 'all' },
];
// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)──────────
// 商品清单:与页面表格列一致,去掉「操作」列
const PRODUCT_EXPORT_COLUMNS = [
{ title: 'MKH商品编码', dataIndex: 'skuId' },
{ title: '商品名称', dataIndex: 'productName' },
{ title: '预警', dataIndex: 'warningLevel' },
{ title: '近15天日销', dataIndex: 'dailySale' },
{ title: '现有库存', dataIndex: 'stock' },
{ title: '在途加工量', dataIndex: 'pendingWorkOrderQty' },
{ title: '待发货订单', dataIndex: 'pendingOrderQty' },
{ title: '结余库存', dataIndex: 'availableStock' },
{
title: '可用天数',
dataIndex: 'stockDays',
// 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白;
// 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里
exportValue: (value, row) => {
if (value !== null && value !== undefined) return value;
return row.orderDriven ? '无日销·按订单' : '无销量';
},
},
{
title: '建议补货量',
dataIndex: 'replenishQty',
// 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标
exportValue: (value, row) => {
if (row.componentShortage) return '—';
return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '';
},
},
{ title: '配件不足', dataIndex: 'componentShortage' },
{ 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: '有日销', 'work-order': '加工在途', 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商品编码 | 配件明细 | 数量 | 配件仓位
// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」),
// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。
const stripWarehousePrefix = (value) => String(value || '')
.split('、')
.map((part) => {
const idx = part.lastIndexOf('·');
return (idx >= 0 ? part.slice(idx + 1) : part).trim();
})
.filter(Boolean)
.join('、');
const PrintPreview = ({ data, onClose }) => {
const now = useMemo(() => new Date().toLocaleString('zh-CN'), []);
const items = data.items || [];
@ -55,7 +193,7 @@ const PrintPreview = ({ data, onClose }) => {
<table className="mkh-print-table">
<thead>
<tr>
<th style={{ width: '170px' }}>MKH商品编码</th>
<th style={{ width: '190px' }}>MKH商品编码</th>
<th>配件明细</th>
<th style={{ width: '64px' }}>数量</th>
<th style={{ width: '110px' }}>配件仓位</th>
@ -69,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => {
) : null}
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
<td className="mkh-center">{number(item.qty)}</td>
<td className="mkh-center">{item.location ? text(item.location) : <span className="mkh-muted">未维护</span>}</td>
<td className="mkh-center">{item.location ? text(stripWarehousePrefix(item.location)) : <span className="mkh-muted">未维护</span>}</td>
</tr>
))}
{items.length === 0 ? (
@ -84,7 +222,7 @@ const PrintPreview = ({ data, onClose }) => {
<div className="mkh-print-foot">
<span>
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充;
配件仓位只取箱及仓位库存表;没有箱号或仓位记录时显示「未维护」。
配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
</span>
</div>
</div>
@ -99,7 +237,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 +247,20 @@ const MkhBomData = () => {
const [printData, setPrintData] = useState(null);
const [printLoading, setPrintLoading] = useState(false);
// 导出(当前筛选条件下的全部数据)
const [exporting, setExporting] = useState(false);
const [syncing, setSyncing] = 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);
@ -167,9 +320,155 @@ const MkhBomData = () => {
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
// 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细
if (key === 'scope') {
setTimeout(() => {
tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
}
};
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 +506,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 +515,150 @@ const MkhBomData = () => {
title: '商品名称',
dataIndex: 'productName',
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 140,
align: 'center',
render: (value, row) => (
<Space size={4}>
<span className={`mkh-tag mkh-tag--${WARNING_TONE[value] || 'muted'}`}>{text(value)}</span>
{/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */}
{row.componentShortage ? (
<Tooltip title={shortageTooltip(row)}>
<span className="mkh-tag mkh-tag--shortage">配件不足</span>
</Tooltip>
) : null}
</Space>
),
},
{
title: '近15天日销',
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: 'pendingOrderQty',
width: 116,
align: 'right',
render: (value, row) => (Number(value) > 0 ? (
<Tooltip
title={[
`聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`,
row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '',
'已取消 / 已合并单据不核算',
row.orderDriven ? '该商品近 15 天暂无日销,按代发(待发货)订单量列入补货预警,可用天数 = 库存 ÷ 代发件数' : '',
].filter(Boolean).join(';')}
>
<span className="mkh-order">{number(value)}</span>
</Tooltip>
) : <span className="mkh-muted">—</span>),
},
{
// 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存,
// 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存)
title: '结余库存',
dataIndex: 'availableStock',
width: 100,
align: 'right',
render: (value, row) => (
<Tooltip
title={Number(row.pendingOrderQty) > 0
? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}`
: '无待发货订单,结余库存 = 现有库存'}
>
<span className={Number(value) <= 0 ? 'mkh-days--warn' : undefined}>{number(value)}</span>
</Tooltip>
),
},
{
title: '可用天数',
dataIndex: 'stockDays',
width: 106,
align: 'right',
sorter: true,
render: (value, row) => {
if (value === null || value === undefined) {
// 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里
return <span className="mkh-muted">无销量</span>;
}
const text = <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
if (row.orderDriven) {
// 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警
return (
<Tooltip title="近 15 天暂无日销,可用天数 = 现有库存 ÷ 代发(待发货)订单件数(≤ 3 天即预警)">
{text}
</Tooltip>
);
}
return (
<Tooltip title={
Number(row.pendingOrderQty) > 0
? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)`
: `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}`
}>
{text}
</Tooltip>
);
},
},
{
title: '建议补货量',
dataIndex: 'replenishQty',
width: 130,
align: 'right',
render: (value, row) => {
// 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购
if (row.componentShortage) {
return (
<Tooltip title={shortageTooltip(row)}>
<span className="mkh-muted">—</span>
</Tooltip>
);
}
return row.hasWarning && Number(value) > 0
? <span className="mkh-replenish">{number(value)}</span>
: <span className="mkh-muted">—</span>;
},
},
{
title: 'BOM 状态',
dataIndex: 'bomConfigured',
@ -231,23 +671,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}>
@ -328,19 +766,53 @@ const MkhBomData = () => {
);
};
// 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新
const handleSync = async () => {
setSyncing(true);
try {
await syncMkhBom();
setDetailCache({});
await load();
message.success('已同步最新数据');
} catch (syncError) {
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
setSyncing(false);
}
};
return (
<SycmPage className="mkh-page">
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>
<Text type="secondary">
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项;
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充;
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo,
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
<br />
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
该仓无记录时回退监控快照库存;<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
无日销且无订单的商品不参与预警。
<br />
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
(新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
可用天数 = 现有库存 ÷ 代发(待发货)订单件数,&lt; 3 天预警、= 0(无库存)缺货、≥ 3 天不预警;
建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
<br />
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算
(作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
<br />
配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定),
配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
</Text>
</div>
</header>
@ -355,13 +827,108 @@ 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.pendingWorkOrderSkuCount ?? 0})`, value: 'work-order' },
{ label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' },
]}
/>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
<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 && !error && data.pendingOrderDegraded ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
/>
) : null}
{!loading && !error && data.componentStockDegraded ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
/>
) : null}
{!loading && (data.warningCount || 0) > 0 ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
description={(
<span>
按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}):
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。
{(data.orderDrivenCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.orderDrivenCount}</b> 个近 15 天暂无日销,按代发(待发货)订单列入预警
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。
</>
) : null}
{(data.scheduledCount || 0) > 0 ? (
<>
<br />
另有 <b>{data.scheduledCount}</b> 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单
(在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。
</>
) : null}
{(data.componentShortageCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.componentShortageCount}</b> 个商品<span className="mkh-tag mkh-tag--shortage">配件不足</span>
:现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),<b>补不出来</b>,
建议补货量显示「—」,需先采购配件。
</>
) : 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'}
@ -419,13 +986,197 @@ const MkhBomData = () => {
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1000, y: bodyHeight }}
scroll={{ x: 1866, 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,60 @@
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;
}
/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */
.mkh-order {
color: #ad4e00;
font-weight: 600;
}
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
.mkh-tag--shortage {
color: #722ed1;
background: rgba(114, 46, 209, 0.14);
border-color: rgba(114, 46, 209, 0.35);
}
/* ============ A4 打印预览 ============ */
.mkh-print-mask {
position: fixed;
@ -181,7 +235,10 @@
.mkh-print-table .mkh-print-sku {
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 11px;
/* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
vertical-align: middle;
}
@ -189,8 +246,10 @@
display: block;
margin-top: 2px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 10px;
color: #666;
/* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */
font-size: 12px;
font-weight: 600;
color: #222;
}
.mkh-print-foot {
@ -237,3 +296,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;
}

View File

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select,
Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select,
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor';
import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -38,12 +38,22 @@ const AccessoryStockPanel = () => {
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [syncing, setSyncing] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' });
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
// 采购在途明细弹窗状态
const [transitVisible, setTransitVisible] = useState(false);
const [transitData, setTransitData] = useState({ rows: [], total: 0 });
const [transitLoading, setTransitLoading] = useState(false);
const [transitPage, setTransitPage] = useState(1);
const [transitPageSize, setTransitPageSize] = useState(50);
const [transitKeyword, setTransitKeyword] = useState('');
const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' });
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
useEffect(() => {
@ -134,6 +144,56 @@ const AccessoryStockPanel = () => {
setPage(1);
};
// 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细
const applySummaryFilter = (type) => () => {
const next = { ...EMPTY_FILTERS };
let warning = false;
if (type === 'outOfStock') {
next.warningLevel = '缺货';
warning = true;
} else if (type === 'lowStockDays') {
next.warningLevel = '预警';
warning = true;
} else if (type === 'configured') {
next.mkhStatus = '已配置MKH';
} else if (type === 'unconfigured') {
next.mkhStatus = '未配置MKH';
}
setFilters(next);
setOnlyWarning(warning);
setPage(1);
setTimeout(() => {
tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
// 打开采购在途明细弹窗
const openPurchaseTransit = () => {
setTransitVisible(true);
setTransitPage(1);
setTransitKeyword('');
setTransitSorter({ field: 'transitQty', order: 'DESC' });
loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' });
};
const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => {
setTransitLoading(true);
try {
const result = await getPurchaseInTransitList({
page: p,
pageSize: transitPageSize,
keyword: kw,
sortField: sort.field,
sortOrder: sort.order,
});
setTransitData(result?.data || { rows: [], total: 0 });
} catch (err) {
message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败');
} finally {
setTransitLoading(false);
}
}, [transitPage, transitPageSize, transitKeyword, transitSorter]);
const columns = useMemo(() => [
{
title: '图片',
@ -157,7 +217,7 @@ const AccessoryStockPanel = () => {
{
title: <TipTitle
title="状态"
tip="该 SKU 是否已在「产品资料系统数据库」中建立商品档案。已配置MKH = 产品资料库中能查到该 SKU 编码的商品资料;未配置MKH = 产品资料库中查不到该 SKU 档案"
tip="该商品(配件)是否作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中、被某 MKH 模块化成品引用。已配置MKH = 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id 命中该商品编码、且父项 sku_id 以 MKH 开头(被某 MKH 成品 BOM 引用为配件);未配置MKH = 未出现在任何 MKH 成品 BOM 的配件清单中"
/>,
dataIndex: 'mkhStatus',
width: 110,
@ -170,7 +230,7 @@ const AccessoryStockPanel = () => {
},
},
{
title: 'SKU编码',
title: '商品编码',
dataIndex: 'skuId',
width: 176,
fixed: 'left',
@ -218,10 +278,22 @@ const AccessoryStockPanel = () => {
sorter: true,
render: int,
},
{
title: <TipTitle
title="采购在途数量"
tip="该商品(配件)全部采购单中『已下单 − 已入库』的未到货数量合计,取自 jst_purchaseItem.qty − inQty"
/>,
dataIndex: 'purchaseTransit',
width: 120,
align: 'right',
render: (value) => (value === null || value === undefined || Number(value) === 0
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="仓位"
tip="业务库 jst_sku.bin / other_bin 全库 55.7 万行均为空,上游未回传库位数据,因此该列暂无值"
tip="取自聚水潭「箱及仓位库存」jst_packInventoryInfo 的真货位(bin),同商品多箱时显示库存最大的主货位;该表查不到才显示「未维护」"
/>,
dataIndex: 'location',
width: 88,
@ -233,8 +305,8 @@ const AccessoryStockPanel = () => {
</Tooltip>),
},
{
title: '近7日日均发货量',
dataIndex: 'avgShip7d',
title: '近15天日均发货量',
dataIndex: 'avgShip15d',
width: 138,
align: 'right',
sorter: true,
@ -282,6 +354,21 @@ const AccessoryStockPanel = () => {
}
};
// 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表,
// 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制
const handleSync = async () => {
setSyncing(true);
try {
await syncModularStock();
await load();
message.success('已同步最新数据');
} catch (syncError) {
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
setSyncing(false);
}
};
const summary = data.summary || {};
return (
@ -291,7 +378,7 @@ const AccessoryStockPanel = () => {
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="SKU 编码 / 名称"
placeholder="商品编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 220 }}
@ -327,6 +414,9 @@ const AccessoryStockPanel = () => {
</Space>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
</Space>
</Card>
@ -334,11 +424,32 @@ const AccessoryStockPanel = () => {
<Row gutter={[12, 12]} className="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-red"><Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-orange"><Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-red is-clickable" onClick={applySummaryFilter('outOfStock')}>
<Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-orange is-clickable" onClick={applySummaryFilter('lowStockDays')}>
<Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" />
</Card>
</Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="已配置MKH" value={summary.configuredCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-blue is-clickable" onClick={openPurchaseTransit}>
<Statistic title="采购在途" value={summary.totalPurchaseTransit || 0} suffix="件" />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('configured')}>
<Statistic title="已配置MKH" value={summary.configuredCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('unconfigured')}>
<Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} />
</Card>
</Col>
</Row>
<Card
@ -392,6 +503,75 @@ const AccessoryStockPanel = () => {
</div>
</Spin>
</Card>
<Modal
title={(
<Space>
<span>采购在途明细</span>
<Text type="secondary" style={{ fontSize: 13 }}>
共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件
</Text>
</Space>
)}
open={transitVisible}
onCancel={() => setTransitVisible(false)}
width={960}
footer={null}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Input.Search
allowClear
placeholder="商品编码 / 名称"
defaultValue={transitKeyword}
onSearch={(value) => { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }}
style={{ width: 260 }}
/>
<Table
rowKey={(row) => `${row.skuId}-${row.poiId || row.poId || Math.random()}`}
size="small"
loading={transitLoading}
columns={[
{
title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => <span className="msm-sku">{text(v)}</span>,
},
{
title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => <Tooltip title={v}>{text(v)}</Tooltip>,
},
{ title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true },
{ title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true },
{
title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true,
render: (value) => <strong style={{ color: '#1677ff' }}>{int(value)}</strong>,
},
{
title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true,
render: (value) => text(value),
},
{ title: '采购单号', dataIndex: 'poId', width: 116, render: text },
]}
dataSource={transitData.rows || []}
pagination={{
current: transitPage,
pageSize: transitPageSize,
total: transitData.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
onChange={(pagination, _filters, nextSorter) => {
setTransitPage(pagination.current || 1);
setTransitPageSize(pagination.pageSize || 50);
const nextSort = nextSorter?.field
? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' }
: { field: 'transitQty', order: 'DESC' };
setTransitSorter(nextSort);
loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort);
}}
scroll={{ x: 860, y: 360 }}
/>
</Space>
</Modal>
</>
);
};
@ -402,14 +582,17 @@ const ModularStockMonitor = () => (
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;
仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。
状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
</Text>
</div>
</header>

View File

@ -87,6 +87,7 @@
.msm-summary-card.is-red::before { background: var(--sycm-color-danger); }
.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
.msm-summary-card.is-green::before { background: var(--sycm-color-success); }
.msm-summary-card.is-blue::before { background: var(--sycm-color-primary); }
.msm-summary-card .ant-card-body {
display: flex;
min-height: 98px;
@ -107,6 +108,15 @@
}
.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
.msm-summary-card.is-clickable {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.msm-summary-card.is-clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.msm-panel { margin-bottom: 0; overflow: hidden; }
.msm-panel .ant-card-head {
min-height: 48px;