InventoryReconService.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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>
  9. /// 165 LocationDetail ↔ mdp_std_inventory 日终对账 + 冷链滞后检测。
  10. /// <para>
  11. /// **对账口径不变量**:LIVE 与 STD 必须是同一集合。标准层是「租户 + 该租户 LocationMaster
  12. /// 合法库位(Typed &lt;&gt; 'Supp')」维度的快照,因此 LIVE 侧也必须按同一租户的同一库位白名单收窄,
  13. /// 且逐租户各对一次。历史缺陷:LIVE 取整个 Domain、STD 取单一租户,
  14. /// 差集里全是「本租户根本不该有」的库位,产生系统性假差异(详见 CHANGELOG / 报告)。
  15. /// </para>
  16. /// <para>
  17. /// 库位白名单一律由 <see cref="TenantLocationScopeLoader"/> 提供,与查询层、标准层写入层同源;
  18. /// 白名单为空 → 该租户跳过(fail closed),**绝不退回整个 Domain**。
  19. /// </para>
  20. /// </summary>
  21. public sealed class InventoryReconService : ITransient
  22. {
  23. private readonly ISqlSugarClient _db;
  24. private readonly MdpSourceScopeFactory _scopeFactory;
  25. private readonly SourceDomainTenantResolver _domainTenant;
  26. private readonly AidopInventoryOptions _opt;
  27. private readonly ILogger _logger;
  28. public InventoryReconService(
  29. ISqlSugarClient db,
  30. MdpSourceScopeFactory scopeFactory,
  31. SourceDomainTenantResolver domainTenant,
  32. IOptions<AidopInventoryOptions> opt,
  33. ILoggerFactory loggerFactory)
  34. {
  35. _db = db;
  36. _scopeFactory = scopeFactory;
  37. _domainTenant = domainTenant;
  38. _opt = opt.Value;
  39. _logger = loggerFactory.CreateLogger(nameof(InventoryReconService));
  40. }
  41. public async Task<int> RunDailyReconAsync(CancellationToken cancellationToken = default)
  42. {
  43. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  44. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  45. // 源归属租户:只校验 (source, domain) 已登记贴源落地区(未登记 → fail closed)。
  46. // 它是 stg 落地区归属,**不是**业务归属,绝不能当成对账的租户口径。
  47. var sourceOwnerTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  48. var asOf = DateTime.Now;
  49. var batchId = $"S5_INV_RECON_{asOf:yyyyMMddHHmmss}";
  50. await CheckSyncLagAsync(cancellationToken);
  51. var tenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  52. if (tenants.Count == 0)
  53. {
  54. _logger.LogWarning(
  55. "[InventoryRecon] domain={Domain} 无任何配置了合法库位的租户,本轮不对账 batch={Batch}", domain, batchId);
  56. return 0;
  57. }
  58. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  59. var diffCount = 0;
  60. foreach (var tenantId in tenants)
  61. {
  62. cancellationToken.ThrowIfCancellationRequested();
  63. diffCount += await ReconcileTenantAsync(
  64. remote, tenantId, domain, sourceCode, batchId, asOf, cancellationToken);
  65. }
  66. if (diffCount > 0)
  67. _logger.LogWarning(
  68. "[InventoryRecon] batch={Batch} tenants={Tenants} sourceOwner={Owner} diffs={Diffs}",
  69. batchId, tenants.Count, sourceOwnerTenantId, diffCount);
  70. else
  71. _logger.LogInformation(
  72. "[InventoryRecon] batch={Batch} tenants={Tenants} clean", batchId, tenants.Count);
  73. return diffCount;
  74. }
  75. /// <summary>
  76. /// 单租户对账:LIVE 与 STD 用**同一份库位白名单**收窄,保证差集只反映真实数量偏差。
  77. /// </summary>
  78. private async Task<int> ReconcileTenantAsync(
  79. ISqlSugarClient remote,
  80. long tenantId,
  81. string domain,
  82. string sourceCode,
  83. string batchId,
  84. DateTime asOf,
  85. CancellationToken cancellationToken)
  86. {
  87. var scope = TenantLocationScope.FromWhitelist(
  88. await TenantLocationScopeLoader.LoadAsync(_db, tenantId, domain, cancellationToken));
  89. // fail closed:空白名单不等于全 Domain;不查源库、不写差异。
  90. if (scope.IsEmpty)
  91. {
  92. _logger.LogWarning(
  93. "[InventoryRecon] tenant={Tenant} domain={Domain} 库位白名单为空,跳过对账(fail closed)",
  94. tenantId, domain);
  95. return 0;
  96. }
  97. // 165 = SQL Server:库位值一律参数化下发,禁止拼进 SQL 文本。
  98. var (liveLocationClause, liveLocationPars) = scope.BuildInClause("Location", "loc");
  99. var livePars = new List<SugarParameter> { new("@Domain", domain) };
  100. livePars.AddRange(liveLocationPars);
  101. var live = await remote.Ado.SqlQueryAsync<QtyKeyRow>(
  102. $"""
  103. SELECT Domain, ItemNum, ISNULL(LotSerial,'') AS LotSerial, Location,
  104. ISNULL(AvailStatusQty,0) AS Qty
  105. FROM LocationDetail
  106. WHERE Domain = @Domain
  107. AND {liveLocationClause}
  108. AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0
  109. """,
  110. livePars);
  111. // STD 侧同样按白名单收窄:标准层历史脏快照(修复前写入、越界库位)不得被当成真实差异。
  112. var (stdLocationClause, stdLocationPars) = scope.BuildInClause("location", "loc");
  113. var stdPars = new List<SugarParameter>
  114. {
  115. new("@TenantId", tenantId),
  116. new("@Domain", domain),
  117. new("@SourceSystem", sourceCode)
  118. };
  119. stdPars.AddRange(stdLocationPars);
  120. var std = await _db.Ado.SqlQueryAsync<QtyKeyRow>(
  121. $"""
  122. SELECT domain AS Domain, item_num AS ItemNum, IFNULL(lot_serial,'') AS LotSerial,
  123. location AS Location, IFNULL(qty_unrestricted,0) AS Qty
  124. FROM mdp_std_inventory
  125. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  126. AND {stdLocationClause}
  127. """,
  128. stdPars);
  129. var liveMap = live.ToDictionary(
  130. x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase);
  131. var stdMap = std.ToDictionary(
  132. x => Key(x), x => x.Qty, StringComparer.OrdinalIgnoreCase);
  133. var keys = liveMap.Keys.Union(stdMap.Keys, StringComparer.OrdinalIgnoreCase).ToList();
  134. var diffCount = 0;
  135. foreach (var key in keys)
  136. {
  137. cancellationToken.ThrowIfCancellationRequested();
  138. liveMap.TryGetValue(key, out var qLive);
  139. stdMap.TryGetValue(key, out var qStd);
  140. var diff = qLive - qStd;
  141. if (diff == 0) continue;
  142. diffCount++;
  143. var parts = key.Split('\u001f');
  144. await _db.Ado.ExecuteCommandAsync(
  145. """
  146. INSERT INTO ado_inventory_recon_diff
  147. (tenant_id, domain, item_num, lot_serial, location,
  148. qty_live, qty_std, qty_diff, recon_batch_id, as_of, remark)
  149. VALUES
  150. (@TenantId, @Domain, @ItemNum, @Lot, @Loc,
  151. @QtyLive, @QtyStd, @QtyDiff, @BatchId, @AsOf, NULL)
  152. """,
  153. new List<SugarParameter>
  154. {
  155. new("@TenantId", tenantId),
  156. new("@Domain", parts.ElementAtOrDefault(0) ?? domain),
  157. new("@ItemNum", parts.ElementAtOrDefault(1) ?? ""),
  158. new("@Lot", parts.ElementAtOrDefault(2) ?? ""),
  159. new("@Loc", parts.ElementAtOrDefault(3) ?? ""),
  160. new("@QtyLive", qLive),
  161. new("@QtyStd", qStd),
  162. new("@QtyDiff", diff),
  163. new("@BatchId", batchId),
  164. new("@AsOf", asOf)
  165. });
  166. }
  167. _logger.LogInformation(
  168. "[InventoryRecon] tenant={Tenant} domain={Domain} allowedLoc={Loc} live={Live} std={Std} diffs={Diffs} batch={Batch}",
  169. tenantId, domain, scope.Count, liveMap.Count, stdMap.Count, diffCount, batchId);
  170. return diffCount;
  171. }
  172. /// <summary>
  173. /// 枚举该 domain 下**拥有合法库存范围**的租户 —— 必须与标准层物化时的租户枚举口径一致,
  174. /// 否则会出现「物化了却从不对账」或「对账一个没有快照的租户」的盲区。
  175. /// (口径同 <c>InventoryMdpSyncService.ListInventoryScopedTenantsAsync</c>,改动须两处同步。)
  176. /// </summary>
  177. private async Task<List<long>> ListInventoryScopedTenantsAsync(string domain, CancellationToken ct)
  178. {
  179. return await _db.Ado.SqlQueryAsync<long>(
  180. """
  181. SELECT DISTINCT lm.tenant_id
  182. FROM LocationMaster lm
  183. JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
  184. WHERE lm.Domain = @Domain
  185. AND IFNULL(lm.typed,'') <> 'Supp'
  186. AND TRIM(lm.location) <> ''
  187. ORDER BY lm.tenant_id
  188. """,
  189. new List<SugarParameter> { new("@Domain", domain) });
  190. }
  191. private async Task CheckSyncLagAsync(CancellationToken ct)
  192. {
  193. var maxAge = _opt.StdMaxAgeMinutes <= 0 ? 90 : _opt.StdMaxAgeMinutes;
  194. var entities = await _db.Queryable<MdpEntity>()
  195. .Where(x => x.EntityCode == "S5_LOCATION_DETAIL_SQLSERVER"
  196. || x.EntityCode == "S5_INV_TRANS_HIST_SQLSERVER")
  197. .ToListAsync(ct);
  198. foreach (var e in entities)
  199. {
  200. if (!e.LastSyncTo.HasValue)
  201. {
  202. _logger.LogWarning("[InventoryRecon] 冷链无 last_sync_to entity={Code}", e.EntityCode);
  203. continue;
  204. }
  205. var age = (DateTime.Now - e.LastSyncTo.Value).TotalMinutes;
  206. if (age > maxAge)
  207. _logger.LogWarning(
  208. "[InventoryRecon] 冷链滞后 entity={Code} lastSync={Last} ageMin={Age} threshold={Th}",
  209. e.EntityCode, e.LastSyncTo, (int)age, maxAge);
  210. }
  211. }
  212. private static string Key(QtyKeyRow r) =>
  213. $"{r.Domain}\u001f{r.ItemNum}\u001f{r.LotSerial}\u001f{r.Location}";
  214. private sealed class QtyKeyRow
  215. {
  216. public string? Domain { get; set; }
  217. public string? ItemNum { get; set; }
  218. public string? LotSerial { get; set; }
  219. public string? Location { get; set; }
  220. public decimal Qty { get; set; }
  221. }
  222. }