InventoryReconService.cs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Microsoft.Extensions.Logging;
  5. using Microsoft.Extensions.Options;
  6. using SqlSugar;
  7. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  8. /// <summary>165 LocationDetail ↔ mdp_std_inventory 日终对账 + 冷链滞后检测。</summary>
  9. public sealed class InventoryReconService : ITransient
  10. {
  11. private readonly ISqlSugarClient _db;
  12. private readonly MdpSourceScopeFactory _scopeFactory;
  13. private readonly SourceDomainTenantResolver _domainTenant;
  14. private readonly AidopInventoryOptions _opt;
  15. private readonly ILogger _logger;
  16. public InventoryReconService(
  17. ISqlSugarClient db,
  18. MdpSourceScopeFactory scopeFactory,
  19. SourceDomainTenantResolver domainTenant,
  20. IOptions<AidopInventoryOptions> opt,
  21. ILoggerFactory loggerFactory)
  22. {
  23. _db = db;
  24. _scopeFactory = scopeFactory;
  25. _domainTenant = domainTenant;
  26. _opt = opt.Value;
  27. _logger = loggerFactory.CreateLogger(nameof(InventoryReconService));
  28. }
  29. public async Task<int> RunDailyReconAsync(CancellationToken cancellationToken = default)
  30. {
  31. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  32. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  33. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  34. var asOf = DateTime.Now;
  35. var batchId = $"S5_INV_RECON_{asOf:yyyyMMddHHmmss}";
  36. await CheckSyncLagAsync(cancellationToken);
  37. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  38. var live = await remote.Ado.SqlQueryAsync<QtyKeyRow>(
  39. """
  40. SELECT Domain, ItemNum, ISNULL(LotSerial,'') AS LotSerial, Location,
  41. ISNULL(AvailStatusQty,0) AS Qty
  42. FROM LocationDetail
  43. WHERE Domain = @Domain
  44. AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0
  45. """,
  46. new List<SugarParameter> { new("@Domain", domain) });
  47. var std = await _db.Ado.SqlQueryAsync<QtyKeyRow>(
  48. """
  49. SELECT domain AS Domain, item_num AS ItemNum, IFNULL(lot_serial,'') AS LotSerial,
  50. location AS Location, IFNULL(qty_unrestricted,0) AS Qty
  51. FROM mdp_std_inventory
  52. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  53. """,
  54. new List<SugarParameter>
  55. {
  56. new("@TenantId", tenantId),
  57. new("@Domain", domain),
  58. new("@SourceSystem", sourceCode)
  59. });
  60. var liveMap = live.ToDictionary(
  61. x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase);
  62. var stdMap = std.ToDictionary(
  63. x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase);
  64. var keys = liveMap.Keys.Union(stdMap.Keys, StringComparer.OrdinalIgnoreCase).ToList();
  65. var diffCount = 0;
  66. foreach (var key in keys)
  67. {
  68. cancellationToken.ThrowIfCancellationRequested();
  69. liveMap.TryGetValue(key, out var qLive);
  70. stdMap.TryGetValue(key, out var qStd);
  71. var diff = qLive - qStd;
  72. if (diff == 0) continue;
  73. diffCount++;
  74. var parts = key.Split('\u001f');
  75. await _db.Ado.ExecuteCommandAsync(
  76. """
  77. INSERT INTO ado_inventory_recon_diff
  78. (tenant_id, domain, item_num, lot_serial, location,
  79. qty_live, qty_std, qty_diff, recon_batch_id, as_of, remark)
  80. VALUES
  81. (@TenantId, @Domain, @ItemNum, @Lot, @Loc,
  82. @QtyLive, @QtyStd, @QtyDiff, @BatchId, @AsOf, NULL)
  83. """,
  84. new List<SugarParameter>
  85. {
  86. new("@TenantId", tenantId),
  87. new("@Domain", parts.ElementAtOrDefault(0) ?? domain),
  88. new("@ItemNum", parts.ElementAtOrDefault(1) ?? ""),
  89. new("@Lot", parts.ElementAtOrDefault(2) ?? ""),
  90. new("@Loc", parts.ElementAtOrDefault(3) ?? ""),
  91. new("@QtyLive", qLive),
  92. new("@QtyStd", qStd),
  93. new("@QtyDiff", diff),
  94. new("@BatchId", batchId),
  95. new("@AsOf", asOf)
  96. });
  97. }
  98. if (diffCount > 0)
  99. _logger.LogWarning("[InventoryRecon] batch={Batch} diffs={Diffs}", batchId, diffCount);
  100. else
  101. _logger.LogInformation("[InventoryRecon] batch={Batch} clean", batchId);
  102. return diffCount;
  103. }
  104. private async Task CheckSyncLagAsync(CancellationToken ct)
  105. {
  106. var maxAge = _opt.StdMaxAgeMinutes <= 0 ? 90 : _opt.StdMaxAgeMinutes;
  107. var entities = await _db.Queryable<MdpEntity>()
  108. .Where(x => x.EntityCode == "S5_LOCATION_DETAIL_SQLSERVER"
  109. || x.EntityCode == "S5_INV_TRANS_HIST_SQLSERVER")
  110. .ToListAsync(ct);
  111. foreach (var e in entities)
  112. {
  113. if (!e.LastSyncTo.HasValue)
  114. {
  115. _logger.LogWarning("[InventoryRecon] 冷链无 last_sync_to entity={Code}", e.EntityCode);
  116. continue;
  117. }
  118. var age = (DateTime.Now - e.LastSyncTo.Value).TotalMinutes;
  119. if (age > maxAge)
  120. _logger.LogWarning(
  121. "[InventoryRecon] 冷链滞后 entity={Code} lastSync={Last} ageMin={Age} threshold={Th}",
  122. e.EntityCode, e.LastSyncTo, (int)age, maxAge);
  123. }
  124. }
  125. private static string Key(QtyKeyRow r) =>
  126. $"{r.Domain}\u001f{r.ItemNum}\u001f{r.LotSerial}\u001f{r.Location}";
  127. private sealed class QtyKeyRow
  128. {
  129. public string? Domain { get; set; }
  130. public string? ItemNum { get; set; }
  131. public string? LotSerial { get; set; }
  132. public string? Location { get; set; }
  133. public decimal Qty { get; set; }
  134. }
  135. }