using Admin.NET.Plugin.AiDOP.DataPlatform; using Admin.NET.Plugin.AiDOP.Entity.DataPlatform; using Admin.NET.Plugin.AiDOP.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse; /// 165 LocationDetail ↔ mdp_std_inventory 日终对账 + 冷链滞后检测。 public sealed class InventoryReconService : ITransient { private readonly ISqlSugarClient _db; private readonly MdpSourceScopeFactory _scopeFactory; private readonly SourceDomainTenantResolver _domainTenant; private readonly AidopInventoryOptions _opt; private readonly ILogger _logger; public InventoryReconService( ISqlSugarClient db, MdpSourceScopeFactory scopeFactory, SourceDomainTenantResolver domainTenant, IOptions opt, ILoggerFactory loggerFactory) { _db = db; _scopeFactory = scopeFactory; _domainTenant = domainTenant; _opt = opt.Value; _logger = loggerFactory.CreateLogger(nameof(InventoryReconService)); } public async Task RunDailyReconAsync(CancellationToken cancellationToken = default) { var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim(); var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim(); var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken); var asOf = DateTime.Now; var batchId = $"S5_INV_RECON_{asOf:yyyyMMddHHmmss}"; await CheckSyncLagAsync(cancellationToken); var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken); var live = await remote.Ado.SqlQueryAsync( """ SELECT Domain, ItemNum, ISNULL(LotSerial,'') AS LotSerial, Location, ISNULL(AvailStatusQty,0) AS Qty FROM LocationDetail WHERE Domain = @Domain AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0 """, new List { new("@Domain", domain) }); var std = await _db.Ado.SqlQueryAsync( """ SELECT domain AS Domain, item_num AS ItemNum, IFNULL(lot_serial,'') AS LotSerial, location AS Location, IFNULL(qty_unrestricted,0) AS Qty FROM mdp_std_inventory WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem """, new List { new("@TenantId", tenantId), new("@Domain", domain), new("@SourceSystem", sourceCode) }); var liveMap = live.ToDictionary( x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase); var stdMap = std.ToDictionary( x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase); var keys = liveMap.Keys.Union(stdMap.Keys, StringComparer.OrdinalIgnoreCase).ToList(); var diffCount = 0; foreach (var key in keys) { cancellationToken.ThrowIfCancellationRequested(); liveMap.TryGetValue(key, out var qLive); stdMap.TryGetValue(key, out var qStd); var diff = qLive - qStd; if (diff == 0) continue; diffCount++; var parts = key.Split('\u001f'); await _db.Ado.ExecuteCommandAsync( """ INSERT INTO ado_inventory_recon_diff (tenant_id, domain, item_num, lot_serial, location, qty_live, qty_std, qty_diff, recon_batch_id, as_of, remark) VALUES (@TenantId, @Domain, @ItemNum, @Lot, @Loc, @QtyLive, @QtyStd, @QtyDiff, @BatchId, @AsOf, NULL) """, new List { new("@TenantId", tenantId), new("@Domain", parts.ElementAtOrDefault(0) ?? domain), new("@ItemNum", parts.ElementAtOrDefault(1) ?? ""), new("@Lot", parts.ElementAtOrDefault(2) ?? ""), new("@Loc", parts.ElementAtOrDefault(3) ?? ""), new("@QtyLive", qLive), new("@QtyStd", qStd), new("@QtyDiff", diff), new("@BatchId", batchId), new("@AsOf", asOf) }); } if (diffCount > 0) _logger.LogWarning("[InventoryRecon] batch={Batch} diffs={Diffs}", batchId, diffCount); else _logger.LogInformation("[InventoryRecon] batch={Batch} clean", batchId); return diffCount; } private async Task CheckSyncLagAsync(CancellationToken ct) { var maxAge = _opt.StdMaxAgeMinutes <= 0 ? 90 : _opt.StdMaxAgeMinutes; var entities = await _db.Queryable() .Where(x => x.EntityCode == "S5_LOCATION_DETAIL_SQLSERVER" || x.EntityCode == "S5_INV_TRANS_HIST_SQLSERVER") .ToListAsync(ct); foreach (var e in entities) { if (!e.LastSyncTo.HasValue) { _logger.LogWarning("[InventoryRecon] 冷链无 last_sync_to entity={Code}", e.EntityCode); continue; } var age = (DateTime.Now - e.LastSyncTo.Value).TotalMinutes; if (age > maxAge) _logger.LogWarning( "[InventoryRecon] 冷链滞后 entity={Code} lastSync={Last} ageMin={Age} threshold={Th}", e.EntityCode, e.LastSyncTo, (int)age, maxAge); } } private static string Key(QtyKeyRow r) => $"{r.Domain}\u001f{r.ItemNum}\u001f{r.LotSerial}\u001f{r.Location}"; private sealed class QtyKeyRow { public string? Domain { get; set; } public string? ItemNum { get; set; } public string? LotSerial { get; set; } public string? Location { get; set; } public decimal Qty { get; set; } } }