From 9b77acb859f0866f3a854d2a2842072b2fe9cca8 Mon Sep 17 00:00:00 2001
From: wanshenmean <cathay_xy@163.com>
Date: 星期二, 31 三月 2026 16:43:27 +0800
Subject: [PATCH] feat(wms): 完善库存三维看板与库存/货位变更追踪

---
 Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs |  348 ++++++++++++++++++++++++++++++++++++++++++++-------------
 1 files changed, 265 insertions(+), 83 deletions(-)

diff --git a/Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs b/Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs
index 074c48e..201ccea 100644
--- a/Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs
+++ b/Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs
@@ -1,8 +1,10 @@
 using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 using System;
 using System.Collections.Concurrent;
+using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -14,20 +16,35 @@
 namespace WIDESEA_WMSServer.BackgroundServices
 {
     /// <summary>
-    /// 搴撳瓨鐩戞帶鍚庡彴鏈嶅姟
-    /// 瀹氭湡妫�鏌ュ簱瀛樺拰璐т綅鏁版嵁鍙樺寲骞堕�氳繃SignalR鎺ㄩ�佸埌鍓嶇
+    /// 搴撳瓨鐩戞帶鍚庡彴鏈嶅姟銆�
+    /// 鍚姩鏃跺垵濮嬪寲涓�娆″叏閲忓揩鐓э紝鍚庣画浠呮寜鏇存柊鏃堕棿澧為噺妫�鏌ュ彈褰卞搷璐т綅锛屽苟閫氳繃 SignalR 鎺ㄩ�佸彉鍖栥��
     /// </summary>
     public class StockMonitorBackgroundService : BackgroundService
     {
+        private const int MonitorIntervalMs = 3000;
+
         private readonly ILogger<StockMonitorBackgroundService> _logger;
         private readonly IHubContext<StockHub> _hubContext;
         private readonly IServiceProvider _serviceProvider;
 
-        // 璐т綅鐘舵�佸揩鐓э細key = LocationId
-        private ConcurrentDictionary<int, LocationSnapshot> _lastLocationSnapshots = new();
+        /// <summary>
+        /// 璐т綅鐘舵�佸揩鐓э細key = LocationId銆�
+        /// </summary>
+        private readonly ConcurrentDictionary<int, LocationSnapshot> _lastLocationSnapshots = new();
 
-        // 鐩戞帶闂撮殧锛堟绉掞級
-        private const int MonitorIntervalMs = 3000;
+        /// <summary>
+        /// 搴撳瓨鎵�鍦ㄨ揣浣嶆槧灏勶細key = StockId, value = LocationId銆�
+        /// 鐢ㄤ簬璇嗗埆搴撳瓨浠庢棫璐т綅绉诲姩鍒版柊璐т綅鏃讹紝涓よ竟閮介渶瑕佹帹閫佸埛鏂般��
+        /// </summary>
+        private readonly ConcurrentDictionary<int, int> _lastStockLocationMap = new();
+
+        /// <summary>
+        /// 涓婃澧為噺妫�鏌ユ椂闂存埑銆�
+        /// 鍒嗗埆璺熻釜璐т綅銆佸簱瀛樹富琛ㄣ�佸簱瀛樻槑缁嗭紝閬垮厤姣忔鍏ㄨ〃鎵弿銆�
+        /// </summary>
+        private DateTime _lastLocationCheckTime = DateTime.MinValue;
+        private DateTime _lastStockCheckTime = DateTime.MinValue;
+        private DateTime _lastDetailCheckTime = DateTime.MinValue;
 
         public StockMonitorBackgroundService(
             ILogger<StockMonitorBackgroundService> logger,
@@ -43,8 +60,9 @@
         {
             _logger.LogInformation("搴撳瓨鐩戞帶鍚庡彴鏈嶅姟宸插惎鍔�");
 
-            // 绛夊緟搴旂敤瀹屽叏鍚姩
+            // 绛夊緟搴旂敤鍒濆鍖栧畬鎴愶紝閬垮厤鍚姩闃舵涓庡叾浠栧垵濮嬪寲浠诲姟浜夋姠鏁版嵁搴撹祫婧愩��
             await Task.Delay(5000, stoppingToken);
+            await InitializeSnapshotsAsync();
 
             while (!stoppingToken.IsCancellationRequested)
             {
@@ -54,7 +72,7 @@
                 }
                 catch (Exception ex)
                 {
-                    _logger.LogError(ex, "妫�鏌ユ暟鎹彉鍖栨椂鍙戠敓閿欒");
+                    _logger.LogError(ex, "妫�鏌ュ簱瀛樺彉鏇存椂鍙戠敓寮傚父");
                 }
 
                 await Task.Delay(MonitorIntervalMs, stoppingToken);
@@ -64,110 +82,252 @@
         }
 
         /// <summary>
-        /// 妫�鏌ヨ揣浣嶅拰搴撳瓨鍙樺寲
+        /// 鍒濆鍖栧叏閲忓揩鐓с�備粎鍦ㄦ湇鍔″惎鍔ㄦ椂鎵ц涓�娆★紝鍚庣画璧板閲忔鏌ャ��
+        /// </summary>
+        private async Task InitializeSnapshotsAsync()
+        {
+            using var scope = _serviceProvider.CreateScope();
+            var stockService = scope.ServiceProvider.GetRequiredService<IStockInfoService>();
+            var locationRepo = scope.ServiceProvider.GetRequiredService<IRepository<Dt_LocationInfo>>();
+            var initializedAt = DateTime.Now;
+
+            var allLocations = await locationRepo.Db.Queryable<Dt_LocationInfo>()
+                .Where(x => x.LocationStatus != 99)
+                .ToListAsync();
+
+            var allStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
+                .Includes(x => x.Details)
+                .Where(x => x.LocationId != 0)
+                .ToListAsync();
+
+            var stockDict = allStocks
+                .Where(x => x.LocationId > 0)
+                .GroupBy(x => x.LocationId)
+                .ToDictionary(x => x.Key, x => x.OrderByDescending(item => item.ModifyDate ?? item.CreateDate).First());
+
+            foreach (var location in allLocations)
+            {
+                stockDict.TryGetValue(location.Id, out var stock);
+                _lastLocationSnapshots[location.Id] = BuildLocationSnapshot(location, stock);
+            }
+
+            foreach (var stock in allStocks.Where(x => x.LocationId > 0))
+            {
+                _lastStockLocationMap[stock.Id] = stock.LocationId;
+            }
+
+            _lastLocationCheckTime = initializedAt;
+            _lastStockCheckTime = initializedAt;
+            _lastDetailCheckTime = initializedAt;
+
+            _logger.LogInformation("搴撳瓨鐩戞帶蹇収鍒濆鍖栧畬鎴愶紝璐т綅鏁�={LocationCount}锛屽簱瀛樻暟={StockCount}", allLocations.Count, allStocks.Count);
+        }
+
+        /// <summary>
+        /// 澧為噺妫�鏌ヨ揣浣嶅拰搴撳瓨鍙樺寲銆�
+        /// 鍙煡璇笂娆℃鏌ヤ箣鍚庡彂鐢熷彉鍖栫殑璐т綅銆佸簱瀛樺拰鏄庣粏锛屽啀鍥炴煡鍙楀奖鍝嶈揣浣嶇殑褰撳墠瀹屾暣鏁版嵁銆�
         /// </summary>
         private async Task CheckChangesAsync()
         {
             using var scope = _serviceProvider.CreateScope();
             var stockService = scope.ServiceProvider.GetRequiredService<IStockInfoService>();
             var locationRepo = scope.ServiceProvider.GetRequiredService<IRepository<Dt_LocationInfo>>();
+            var checkStartedAt = DateTime.Now;
 
-            // 1. 鑾峰彇鎵�鏈夎揣浣嶆暟鎹�
-            var allLocations = await locationRepo.QueryDataAsync(x => x.LocationStatus != 99); // 鎺掗櫎绂佺敤鐨勮揣浣�
-
-            // 2. 鑾峰彇鎵�鏈夊簱瀛樻暟鎹紙鍖呭惈鏄庣粏锛�
-            var allStockData = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
-                .Includes(x => x.Details)
+            var changedLocationIds = await locationRepo.Db.Queryable<Dt_LocationInfo>()
+                .Where(x => x.LocationStatus != 99 && (x.ModifyDate ?? x.CreateDate) > _lastLocationCheckTime)
+                .Select(x => x.Id)
                 .ToListAsync();
 
-            // 鏋勫缓搴撳瓨瀛楀吀锛歀ocationId -> StockInfo
-            var stockDict = allStockData
-                .Where(s => s.LocationId > 0)
-                .ToDictionary(s => s.LocationId, s => s);
-
-            // 鏋勫缓褰撳墠璐т綅蹇収瀛楀吀
-            var currentSnapshots = new ConcurrentDictionary<int, LocationSnapshot>();
-
-            foreach (var location in allLocations)
-            {
-                // 鑾峰彇璇ヨ揣浣嶇殑搴撳瓨淇℃伅
-                stockDict.TryGetValue(location.Id, out var stock);
-
-                // 璁$畻搴撳瓨鏁伴噺
-                float totalQuantity = 0;
-                string detailsHash = string.Empty;
-                if (stock?.Details != null && stock.Details.Any())
+            var changedStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
+                .Where(x => (x.ModifyDate ?? x.CreateDate) > _lastStockCheckTime)
+                .Select(x => new StockLocationChange
                 {
-                    totalQuantity = stock.Details.Sum(d => d.StockQuantity);
-                    detailsHash = GenerateDetailsHash(stock.Details.ToList());
+                    StockId = x.Id,
+                    LocationId = x.LocationId
+                })
+                .ToListAsync();
+
+            var changedDetailStockIds = await stockService.Repository.Db.Queryable<Dt_StockInfoDetail>()
+                .Where(x => (x.ModifyDate ?? x.CreateDate) > _lastDetailCheckTime)
+                .Select(x => x.StockId)
+                .Distinct()
+                .ToListAsync();
+
+            var affectedLocationIds = new HashSet<int>(changedLocationIds);
+
+            foreach (var stock in changedStocks)
+            {
+                var previousLocationId = _lastStockLocationMap.TryGetValue(stock.StockId, out var oldLocationId)
+                    ? oldLocationId
+                    : 0;
+
+                if (previousLocationId > 0 && previousLocationId != stock.LocationId)
+                {
+                    affectedLocationIds.Add(previousLocationId);
                 }
 
-                var snapshot = new LocationSnapshot
+                if (stock.LocationId > 0)
                 {
-                    LocationId = location.Id,
-                    WarehouseId = location.WarehouseId,
-                    LocationCode = location.LocationCode,
-                    LocationStatus = location.LocationStatus,
-                    PalletCode = stock?.PalletCode,
-                    StockStatus = stock?.StockStatus ?? 0,
-                    StockQuantity = totalQuantity,
-                    DetailsHash = detailsHash
-                };
-
-                currentSnapshots.TryAdd(location.Id, snapshot);
-
-                // 妫�鏌ユ槸鍚︽湁鍙樺寲
-                if (_lastLocationSnapshots.TryGetValue(location.Id, out var lastSnapshot))
+                    affectedLocationIds.Add(stock.LocationId);
+                    _lastStockLocationMap[stock.StockId] = stock.LocationId;
+                }
+                else
                 {
-                    // 妫�娴嬪彉鍖栵細璐т綅鐘舵�併�佸簱瀛樼姸鎬併�佹暟閲忋�佹槑缁嗗彉鍖�
-                    if (lastSnapshot.LocationStatus != snapshot.LocationStatus ||
-                        lastSnapshot.StockStatus != snapshot.StockStatus ||
-                        lastSnapshot.PalletCode != snapshot.PalletCode ||
-                        Math.Abs(lastSnapshot.StockQuantity - snapshot.StockQuantity) > 0.001f ||
-                        lastSnapshot.DetailsHash != snapshot.DetailsHash)
-                    {
-                        // 鏋勫缓鏇存柊DTO
-                        var update = new StockUpdateDTO
-                        {
-                            LocationId = snapshot.LocationId,
-                            WarehouseId = snapshot.WarehouseId,
-                            PalletCode = snapshot.PalletCode,
-                            StockQuantity = snapshot.StockQuantity,
-                            StockStatus = snapshot.StockStatus,
-                            LocationStatus = snapshot.LocationStatus,
-                            Details = BuildDetailDtos(stock?.Details?.ToList())
-                        };
-
-                        await _hubContext.Clients.All.SendAsync("StockUpdated", update);
-                        _logger.LogDebug("鏁版嵁鍙樺寲鎺ㄩ��: LocationId={LocationId}, LocStatus={LocStatus}, StockStatus={StockStatus}, Quantity={Quantity}",
-                            snapshot.LocationId, snapshot.LocationStatus, snapshot.StockStatus, snapshot.StockQuantity);
-                    }
+                    _lastStockLocationMap.TryRemove(stock.StockId, out _);
                 }
             }
 
-            // 鏇存柊蹇収鏁版嵁
-            _lastLocationSnapshots = currentSnapshots;
+            if (changedDetailStockIds.Count > 0)
+            {
+                var detailAffectedLocationIds = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
+                    .Where(x => changedDetailStockIds.Contains(x.Id) && x.LocationId != 0)
+                    .Select(x => x.LocationId)
+                    .Distinct()
+                    .ToListAsync();
+
+                foreach (var locationId in detailAffectedLocationIds)
+                {
+                    affectedLocationIds.Add(locationId);
+                }
+            }
+
+            if (affectedLocationIds.Count == 0)
+            {
+                UpdateCheckTimes(checkStartedAt);
+                return;
+            }
+
+            var affectedLocations = await locationRepo.Db.Queryable<Dt_LocationInfo>()
+                .Where(x => affectedLocationIds.Contains(x.Id) && x.LocationStatus != 99)
+                .ToListAsync();
+
+            var locationDict = affectedLocations.ToDictionary(x => x.Id, x => x);
+
+            var affectedStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
+                .Includes(x => x.Details)
+                .Where(x => affectedLocationIds.Contains(x.LocationId))
+                .ToListAsync();
+
+            var stockDict = affectedStocks
+                .Where(x => x.LocationId > 0)
+                .GroupBy(x => x.LocationId)
+                .ToDictionary(x => x.Key, x => x.OrderByDescending(item => item.ModifyDate ?? item.CreateDate).First());
+
+            foreach (var locationId in affectedLocationIds)
+            {
+                if (!locationDict.TryGetValue(locationId, out var location))
+                {
+                    _lastLocationSnapshots.TryRemove(locationId, out _);
+                    continue;
+                }
+
+                stockDict.TryGetValue(locationId, out var stock);
+                var snapshot = BuildLocationSnapshot(location, stock);
+
+                if (HasSnapshotChanged(locationId, snapshot))
+                {
+                    var update = new StockUpdateDTO
+                    {
+                        LocationId = snapshot.LocationId,
+                        WarehouseId = snapshot.WarehouseId,
+                        PalletCode = snapshot.PalletCode,
+                        StockQuantity = snapshot.StockQuantity,
+                        StockStatus = snapshot.StockStatus,
+                        LocationStatus = snapshot.LocationStatus,
+                        OutboundDate = snapshot.OutboundDate,
+                        Details = BuildDetailDtos(stock?.Details?.ToList())
+                    };
+
+                    await _hubContext.Clients.All.SendAsync("StockUpdated", update);
+                    _logger.LogDebug(
+                        "澧為噺搴撳瓨鍙樻洿鎺ㄩ�侊紝LocationId={LocationId}锛孡ocationStatus={LocationStatus}锛孲tockStatus={StockStatus}锛孮uantity={Quantity}",
+                        snapshot.LocationId,
+                        snapshot.LocationStatus,
+                        snapshot.StockStatus, 
+                        snapshot.OutboundDate,
+                        snapshot.StockQuantity);
+                }
+
+                _lastLocationSnapshots[locationId] = snapshot;
+            }
+
+            UpdateCheckTimes(checkStartedAt);
         }
 
         /// <summary>
-        /// 鐢熸垚鏄庣粏鏁版嵁鍝堝笇
+        /// 鏋勫缓璐т綅蹇収锛岀敤浜庡垽鏂揣浣嶆槸鍚﹂渶瑕佹帹閫佹洿鏂般��
+        /// </summary>
+        private LocationSnapshot BuildLocationSnapshot(Dt_LocationInfo location, Dt_StockInfo stock)
+        {
+            float totalQuantity = 0;
+            string detailsHash = string.Empty;
+
+            if (stock?.Details != null && stock.Details.Any())
+            {
+                totalQuantity = stock.Details.Sum(d => d.StockQuantity);
+                detailsHash = GenerateDetailsHash(stock.Details.ToList());
+            }
+
+            return new LocationSnapshot
+            {
+                LocationId = location.Id,
+                WarehouseId = location.WarehouseId,
+                LocationCode = location.LocationCode,
+                LocationStatus = location.LocationStatus,
+                PalletCode = stock?.PalletCode,
+                StockStatus = stock?.StockStatus ?? 0,
+                StockQuantity = totalQuantity,
+                DetailsHash = detailsHash,
+                OutboundDate = stock?.OutboundDate ?? default
+            };
+        }
+
+        /// <summary>
+        /// 瀵规瘮蹇収鍙樺寲锛屼粎鍦ㄥ叧閿瓧娈靛彉鍖栨椂瑙﹀彂鎺ㄩ�併��
+        /// </summary>
+        private bool HasSnapshotChanged(int locationId, LocationSnapshot snapshot)
+        {
+            if (!_lastLocationSnapshots.TryGetValue(locationId, out var lastSnapshot))
+            {
+                return true;
+            }
+
+            return lastSnapshot.LocationStatus != snapshot.LocationStatus ||
+                   lastSnapshot.StockStatus != snapshot.StockStatus ||
+                   lastSnapshot.PalletCode != snapshot.PalletCode ||
+                   Math.Abs(lastSnapshot.StockQuantity - snapshot.StockQuantity) > 0.001f ||
+                   lastSnapshot.DetailsHash != snapshot.DetailsHash;
+        }
+
+        /// <summary>
+        /// 鐢熸垚搴撳瓨鏄庣粏鍝堝笇锛岀‘淇濇槑缁嗗唴瀹瑰彉鍖栨椂鑳借Е鍙戝墠绔埛鏂般��
         /// </summary>
         private string GenerateDetailsHash(List<Dt_StockInfoDetail> details)
         {
-            if (details == null || !details.Any()) return string.Empty;
+            if (details == null || !details.Any())
+            {
+                return string.Empty;
+            }
 
-            var hashString = string.Join("|", details
-                .OrderBy(d => d.Id)
-                .Select(d => $"{d.Id}:{d.MaterielCode}:{d.BatchNo}:{d.StockQuantity}"));
+            var hashString = string.Join(
+                "|",
+                details
+                    .OrderBy(d => d.Id)
+                    .Select(d => $"{d.Id}:{d.MaterielCode}:{d.BatchNo}:{d.StockQuantity}:{d.SerialNumber}:{d.InboundOrderRowNo}:{d.Status}"));
+
             return hashString.GetHashCode().ToString();
         }
 
         /// <summary>
-        /// 鏋勫缓鏄庣粏DTO鍒楄〃
+        /// 鏋勫缓鎺ㄩ�佺粰鍓嶇鐨勬槑缁嗘暟鎹��
         /// </summary>
         private List<StockDetailUpdateDTO> BuildDetailDtos(List<Dt_StockInfoDetail> details)
         {
-            if (details == null || !details.Any()) return new List<StockDetailUpdateDTO>();
+            if (details == null || !details.Any())
+            {
+                return new List<StockDetailUpdateDTO>();
+            }
 
             return details.Select(d => new StockDetailUpdateDTO
             {
@@ -177,12 +337,24 @@
                 BatchNo = d.BatchNo,
                 StockQuantity = d.StockQuantity,
                 Unit = d.Unit,
-                Status = d.Status
+                Status = d.Status,
+                SerialNumber = d.SerialNumber,
+                InboundOrderRowNo = d.InboundOrderRowNo
             }).ToList();
         }
 
         /// <summary>
-        /// 璐т綅蹇収
+        /// 鏇存柊鍚勭被澧為噺妫�鏌ユ椂闂淬��
+        /// </summary>
+        private void UpdateCheckTimes(DateTime checkStartedAt)
+        {
+            _lastLocationCheckTime = checkStartedAt;
+            _lastStockCheckTime = checkStartedAt;
+            _lastDetailCheckTime = checkStartedAt;
+        }
+
+        /// <summary>
+        /// 璐т綅蹇収銆�
         /// </summary>
         private class LocationSnapshot
         {
@@ -194,6 +366,16 @@
             public int StockStatus { get; set; }
             public float StockQuantity { get; set; }
             public string DetailsHash { get; set; }
+            public DateTime OutboundDate { get; set; }
+        }
+
+        /// <summary>
+        /// 搴撳瓨鍙樻洿瀹氫綅淇℃伅銆�
+        /// </summary>
+        private class StockLocationChange
+        {
+            public int StockId { get; set; }
+            public int LocationId { get; set; }
         }
     }
 }

--
Gitblit v1.9.3