Merge pull request 'feat: MKH-BOM 补货预警(订单驱动 + 配件不足)与日销口径改近 15 天' (#16)
This commit is contained in:
commit
cc66e5100f
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
396
backend/src/jobs/mkhReplenishAlertJob.js
Normal file
396
backend/src/jobs/mkhReplenishAlertJob.js
Normal 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
227
backend/src/services/mkhReplenishAlertSettings.js
Normal file
227
backend/src/services/mkhReplenishAlertSettings.js
Normal file
@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
|
||||
//
|
||||
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
|
||||
// { enabled, recipientAlias, scope }
|
||||
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
|
||||
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
|
||||
//
|
||||
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
|
||||
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
|
||||
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
|
||||
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
toAdminConfig: toNotificationCenterAdminConfig,
|
||||
} = require('./yydNotificationCenterSettings');
|
||||
|
||||
const CONFIG_KEY = 'mkh_replenish_alert_config';
|
||||
const STATE_KEY = 'mkh_replenish_alert_state';
|
||||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
|
||||
|
||||
const SCOPE_OPTIONS = Object.freeze([
|
||||
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
|
||||
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
|
||||
]);
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 200) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || ''));
|
||||
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScope(value) {
|
||||
const text = normalizeText(value, 40).toLowerCase();
|
||||
if (!text) return SCOPES.WARNING;
|
||||
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
|
||||
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
|
||||
return SCOPES.WARNING;
|
||||
}
|
||||
|
||||
function normalizeFlag(value, fallback = true) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function readSystemConfigValue(key) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
return rows?.[0]?.configValue;
|
||||
}
|
||||
|
||||
async function upsertSystemConfigValue(key, value, configType = 'json') {
|
||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
if (rows?.[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, configType, key]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: key,
|
||||
config_value: payload,
|
||||
config_type: configType,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────────────────
|
||||
async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
let stored = {};
|
||||
try {
|
||||
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取补货预警推送配置失败,使用默认值:', error.message);
|
||||
}
|
||||
cachedConfig = {
|
||||
enabled: normalizeFlag(stored.enabled, true),
|
||||
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
|
||||
scope: normalizeScope(stored.scope),
|
||||
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
|
||||
*/
|
||||
async function resolveMkhReplenishAlertRecipient(options = {}) {
|
||||
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
|
||||
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
|
||||
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const alias = normalizeText(center?.recipientAlias, 50);
|
||||
return { alias, source: alias ? 'notification_center' : 'none' };
|
||||
}
|
||||
|
||||
async function getMkhReplenishAlertAdminConfig() {
|
||||
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const recipient = await resolveMkhReplenishAlertRecipient({ config });
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||||
const state = await loadMkhReplenishAlertState();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
scope: config.scope,
|
||||
recipientAlias: config.recipientAlias,
|
||||
effectiveRecipientAlias: recipient.alias || '',
|
||||
recipientSource: recipient.source,
|
||||
notificationCenterConfigured: centerAdmin.configured,
|
||||
notificationCenterBaseUrl: centerAdmin.baseUrl,
|
||||
notificationCenter: {
|
||||
configured: centerAdmin.configured,
|
||||
baseUrl: centerAdmin.baseUrl,
|
||||
projectCode: centerAdmin.projectCode,
|
||||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||||
recipientAlias: centerAdmin.recipientAlias,
|
||||
},
|
||||
notifiedCount: Object.keys(state.notified || {}).length,
|
||||
lastRunAt: state.lastRunAt || null,
|
||||
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertConfig(input = {}) {
|
||||
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const rawAlias = input.recipientAlias ?? input.recipient_alias;
|
||||
const next = {
|
||||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||||
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
|
||||
scope: normalizeScope(input.scope || current.scope),
|
||||
};
|
||||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||||
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
|
||||
cachedAt = Date.now();
|
||||
return getMkhReplenishAlertAdminConfig();
|
||||
}
|
||||
|
||||
// ── 推送状态 ────────────────────────────────────────────────────
|
||||
async function loadMkhReplenishAlertState() {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取补货预警推送状态失败,按空状态处理:', error.message);
|
||||
}
|
||||
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
|
||||
? parsed.notified
|
||||
: {};
|
||||
return {
|
||||
version: 1,
|
||||
notified,
|
||||
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertState(state = {}) {
|
||||
const value = {
|
||||
version: 1,
|
||||
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
|
||||
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
|
||||
};
|
||||
await upsertSystemConfigValue(STATE_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
|
||||
async function resetMkhReplenishAlertState() {
|
||||
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
STATE_KEY,
|
||||
SCOPES,
|
||||
SCOPE_OPTIONS,
|
||||
getMkhReplenishAlertAdminConfig,
|
||||
getMkhReplenishAlertRuntimeConfig,
|
||||
loadMkhReplenishAlertState,
|
||||
normalizeFlag,
|
||||
normalizeScope,
|
||||
resetMkhReplenishAlertState,
|
||||
resolveMkhReplenishAlertRecipient,
|
||||
saveMkhReplenishAlertConfig,
|
||||
saveMkhReplenishAlertState,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
194
backend/test/mkhReplenishAlertJob.test.js
Normal file
194
backend/test/mkhReplenishAlertJob.test.js
Normal 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('未做'));
|
||||
});
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
|
||||
可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user