diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 4e2bc3e..5b3d0da 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -41,7 +41,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({ @@ -93,6 +94,22 @@ const buildConfiguredExistsSql = () => ( )` ); +/** + * 统一的「预警等级(数值)」表达式(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` +); + const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( @@ -139,16 +156,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, @@ -209,20 +216,14 @@ const buildFilters = (query = {}, monitorDate = null) => { 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 }; @@ -283,7 +284,14 @@ 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), @@ -292,7 +300,7 @@ 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, @@ -326,11 +334,7 @@ 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 = {}) { @@ -395,9 +399,9 @@ 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, + 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}`,