diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1718467..affb51f 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -13,6 +13,8 @@ // 目标仓库共 794 个 SKU // 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库 // 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得) +// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐; +// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。 // 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU: // fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下) // jsUseDays 库存可用天数 @@ -77,13 +79,39 @@ const SORTABLE_FIELDS = Object.freeze({ category: 's.category', realtimeStock: 'realtimeStock', avgShip15d: 'm.fifteenAvgSale', - stockDays: 'm.jsUseDays', + stockDays: 'stockDays', warningLevel: 'warningOrder', mkhStatus: 'mkhStatus', location: 'location', warehouse: 's.other_3', }); +// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。 +// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。 +// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。 +const buildRealtimeStockSql = () => ( + `CASE + WHEN inv.qty > 0 THEN inv.qty + WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty + WHEN inv.qty IS NOT NULL THEN inv.qty + ELSE ISNULL(m.jsStock, 0) + END` +); + +// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。 +// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。 +const buildStockDaysSql = () => ( + `CASE + WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN + CASE + WHEN ISNULL(m.fifteenAvgSale, 0) > 0 + THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2)) + ELSE NULL + END + ELSE m.jsUseDays + END` +); + /** * 「已配置MKH」判定表达式(2026-09-17 晚修正口径): * 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护 @@ -107,8 +135,9 @@ const buildConfiguredExistsSql = () => ( 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 + WHEN ${buildRealtimeStockSql()} <= 0 THEN 0 + WHEN ${buildStockDaysSql()} IS NOT NULL + AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 2 END` ); @@ -171,15 +200,29 @@ OUTER APPLY ( 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 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 +-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU +-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。 +LEFT JOIN ( + SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty + FROM ( + SELECT + pk.sku_id, + pk.wms_co_id, + NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin, + SUM(ISNULL(pk.qty, 0)) AS binQty, + SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty, + ROW_NUMBER() OVER ( + PARTITION BY pk.sku_id, pk.wms_co_id + ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC + ) AS rn + FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) + WHERE pk.wms_co_id = @wmsCoId + AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL + GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'') + HAVING SUM(ISNULL(pk.qty, 0)) > 0 + ) ranked + WHERE ranked.rn = 1 +) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId -- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured @@ -362,13 +405,13 @@ const buildProjection = () => ` s.sku_id AS skuId, s.name AS productName, s.category AS category, - COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ${buildRealtimeStockSql()} AS realtimeStock, ISNULL(ptr.transit, 0) AS purchaseTransit, ISNULL(pin.inQty, 0) AS inQtyToday, ISNULL(m.nearSecondSale, 0) AS outQtyToday, ${buildLocationSql()} AS location, m.fifteenAvgSale AS avgShip15d, - m.jsUseDays AS stockDays, + ${buildStockDaysSql()} AS stockDays, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, s.other_3 AS warehouse, @@ -445,7 +488,7 @@ async function buildSummary(whereSql, params) { `SELECT COUNT_BIG(*) AS skuCount, 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, + CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock, 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, diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index ec08b52..dd3c9a2 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -271,7 +271,10 @@ const AccessoryStockPanel = () => { : int(value)), }, { - title: '实时库存', + title: , dataIndex: 'realtimeStock', width: 96, align: 'right', @@ -281,7 +284,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'purchaseTransit', width: 120,