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,