InventoryBalanceReader.cs 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Microsoft.Extensions.Logging;
  4. using Microsoft.Extensions.Options;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  7. public interface IInventoryBalanceReader
  8. {
  9. Task<InventoryBalancePage> QueryPageAsync(
  10. InventoryBalanceQuery query,
  11. CancellationToken cancellationToken = default);
  12. /// <summary>
  13. /// 业务可用量:仅白名单库位、qty_available=AvailStatusQty(不加 Assay)。
  14. /// </summary>
  15. Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  16. long tenantId,
  17. IReadOnlyCollection<string> itemNumbers,
  18. CancellationToken cancellationToken = default);
  19. }
  20. /// <summary>余额门面:LiveThenStd / StdOnly。</summary>
  21. public sealed class InventoryBalanceReader : IInventoryBalanceReader, ITransient
  22. {
  23. private readonly Live165InventorySource _live;
  24. private readonly StdInventorySource _std;
  25. private readonly SourceDomainTenantResolver _domainTenant;
  26. private readonly AidopInventoryOptions _opt;
  27. private readonly ILogger _logger;
  28. public InventoryBalanceReader(
  29. Live165InventorySource live,
  30. StdInventorySource std,
  31. SourceDomainTenantResolver domainTenant,
  32. IOptions<AidopInventoryOptions> opt,
  33. ILoggerFactory loggerFactory)
  34. {
  35. _live = live;
  36. _std = std;
  37. _domainTenant = domainTenant;
  38. _opt = opt.Value;
  39. _logger = loggerFactory.CreateLogger(nameof(InventoryBalanceReader));
  40. }
  41. public async Task<InventoryBalancePage> QueryPageAsync(
  42. InventoryBalanceQuery query,
  43. CancellationToken cancellationToken = default)
  44. {
  45. var maxPage = _opt.MaxPageSize <= 0 ? 200 : _opt.MaxPageSize;
  46. var pageSize = Math.Clamp(query.PageSize <= 0 ? 10 : query.PageSize, 1, maxPage);
  47. var page = query.Page <= 0 ? 1 : query.Page;
  48. var q = new InventoryBalanceQuery
  49. {
  50. TenantId = query.TenantId,
  51. Domain = query.Domain,
  52. Type = query.Type,
  53. Location = query.Location,
  54. MaterialCode = query.MaterialCode,
  55. BatchNo = query.BatchNo,
  56. StockGt = query.StockGt,
  57. UnrestrictedGt = query.UnrestrictedGt,
  58. QcStockGt = query.QcStockGt,
  59. FrozenGt = query.FrozenGt,
  60. Page = page,
  61. PageSize = pageSize
  62. };
  63. var selective = HasSelectiveFilter(q);
  64. var mode = (_opt.BalanceMode ?? "LiveThenStd").Trim();
  65. var stdOnly = string.Equals(mode, "StdOnly", StringComparison.OrdinalIgnoreCase);
  66. // 是否真的尝试过实时源。未尝试(无选择性筛选 / StdOnly)时回落标准层属正常策略,
  67. // 不能对用户提示「实时库不可用」——只有尝试过且失败才算降级。
  68. var liveFailed = false;
  69. if (!stdOnly && selective)
  70. {
  71. try
  72. {
  73. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
  74. cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, _opt.LiveTimeoutSeconds)));
  75. var live = await _live.QueryPageAsync(q, cts.Token);
  76. return live;
  77. }
  78. catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
  79. {
  80. liveFailed = true;
  81. _logger.LogWarning("[InventoryBalanceReader] LIVE_165 超时,回落 STD tenant={Tenant}", q.TenantId);
  82. }
  83. catch (Exception ex)
  84. {
  85. liveFailed = true;
  86. _logger.LogWarning(ex, "[InventoryBalanceReader] LIVE_165 失败,回落 STD tenant={Tenant}", q.TenantId);
  87. }
  88. }
  89. var std = await _std.QueryPageAsync(q, cancellationToken);
  90. std.FallbackFromLive = liveFailed;
  91. return std;
  92. }
  93. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  94. long tenantId,
  95. IReadOnlyCollection<string> itemNumbers,
  96. CancellationToken cancellationToken = default)
  97. {
  98. var items = itemNumbers
  99. .Where(x => !string.IsNullOrWhiteSpace(x))
  100. .Select(x => x.Trim())
  101. .Distinct(StringComparer.OrdinalIgnoreCase)
  102. .ToList();
  103. if (items.Count == 0)
  104. return new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  105. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  106. var domain = await _domainTenant.ResolveDomainAsync(sourceCode, tenantId, cancellationToken);
  107. var mode = (_opt.BalanceMode ?? "LiveThenStd").Trim();
  108. var stdOnly = string.Equals(mode, "StdOnly", StringComparison.OrdinalIgnoreCase);
  109. if (!stdOnly)
  110. {
  111. try
  112. {
  113. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
  114. cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, _opt.LiveTimeoutSeconds)));
  115. return await _live.LoadAvailableQtyAsync(tenantId, domain, items, cts.Token);
  116. }
  117. catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
  118. {
  119. _logger.LogWarning("[InventoryBalanceReader] LoadAvailableQty LIVE 超时,回落 STD");
  120. }
  121. catch (Exception ex)
  122. {
  123. _logger.LogWarning(ex, "[InventoryBalanceReader] LoadAvailableQty LIVE 失败,回落 STD");
  124. }
  125. }
  126. var std = await _std.LoadAvailableQtyAsync(tenantId, domain, items, cancellationToken);
  127. if (std.Count == 0 && items.Count > 0)
  128. {
  129. // STD 无快照时不得静默 0:仍返回空字典由调用方当 0,但打 Error 便于运维
  130. _logger.LogError(
  131. "[InventoryBalanceReader] STD 无可用库存快照 tenant={Tenant} domain={Domain} items={Count}",
  132. tenantId, domain, items.Count);
  133. }
  134. return std;
  135. }
  136. private static bool HasSelectiveFilter(InventoryBalanceQuery q) =>
  137. !string.IsNullOrWhiteSpace(q.MaterialCode)
  138. || !string.IsNullOrWhiteSpace(q.BatchNo)
  139. || !string.IsNullOrWhiteSpace(q.Location);
  140. }
  141. /// <summary>
  142. /// 165 LocationDetail 实时余额(查询页用)。
  143. /// **只返回当前租户 LocationMaster 白名单(Typed &lt;&gt; 'Supp')内的库位**,
  144. /// 不再按 Domain 全量返回;白名单为空则 fail closed 返回 0 行。
  145. /// </summary>
  146. public sealed class Live165InventorySource : ITransient
  147. {
  148. private readonly MdpSourceScopeFactory _scopeFactory;
  149. private readonly ISqlSugarClient _db;
  150. private readonly AidopInventoryOptions _opt;
  151. public Live165InventorySource(
  152. MdpSourceScopeFactory scopeFactory,
  153. ISqlSugarClient db,
  154. IOptions<AidopInventoryOptions> opt)
  155. {
  156. _scopeFactory = scopeFactory;
  157. _db = db;
  158. _opt = opt.Value;
  159. }
  160. public async Task<InventoryBalancePage> QueryPageAsync(
  161. InventoryBalanceQuery query,
  162. CancellationToken cancellationToken = default)
  163. {
  164. // —— 安全第一层:当前租户的合法库位边界(Typed<>'Supp'),先于任何远端访问求得。
  165. // 用户传入的 Location 只能在其内部收窄(Intersect),不得替代、不得绕过。
  166. var scope = TenantLocationScope
  167. .FromWhitelist(await LoadWhitelistLocationsAsync(query.TenantId, query.Domain, cancellationToken))
  168. .Intersect(query.Location);
  169. // fail closed:空边界绝不等于全 Domain,直接返回空页且不触碰源库。
  170. if (scope.IsEmpty) return EmptyLivePage(query);
  171. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  172. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  173. const string locTypeExpr = "CASE WHEN t.Typed='VMI' THEN 'VMI' WHEN t.Typed='Supp' THEN N'O库' ELSE '' END";
  174. var (locationClause, locationPars) = scope.BuildInClause("d.Location", "loc");
  175. var where = new List<string>
  176. {
  177. "t.Domain = @Domain",
  178. locationClause,
  179. "(ISNULL(d.AvailStatusQty,0)+ISNULL(d.Assay,0)+ISNULL(d.FreezeQty,0)) > 0"
  180. };
  181. var pars = new List<SugarParameter> { new("@Domain", query.Domain) };
  182. pars.AddRange(locationPars);
  183. if (!string.IsNullOrWhiteSpace(query.Type))
  184. {
  185. where.Add($"({locTypeExpr}) = @Type");
  186. pars.Add(new SugarParameter("@Type", query.Type.Trim()));
  187. }
  188. if (!string.IsNullOrWhiteSpace(query.MaterialCode))
  189. {
  190. where.Add("d.ItemNum LIKE @MaterialCode");
  191. pars.Add(new SugarParameter("@MaterialCode", $"%{query.MaterialCode.Trim()}%"));
  192. }
  193. if (!string.IsNullOrWhiteSpace(query.BatchNo))
  194. {
  195. where.Add("ISNULL(d.LotSerial,'') LIKE @BatchNo");
  196. pars.Add(new SugarParameter("@BatchNo", $"%{query.BatchNo.Trim()}%"));
  197. }
  198. if (query.StockGt.HasValue)
  199. {
  200. where.Add("d.QtyOnHand > @StockGt");
  201. pars.Add(new SugarParameter("@StockGt", query.StockGt.Value));
  202. }
  203. if (query.UnrestrictedGt.HasValue)
  204. {
  205. where.Add("d.AvailStatusQty > @UnrestrictedGt");
  206. pars.Add(new SugarParameter("@UnrestrictedGt", query.UnrestrictedGt.Value));
  207. }
  208. if (query.QcStockGt.HasValue)
  209. {
  210. where.Add("d.Assay > @QcStockGt");
  211. pars.Add(new SugarParameter("@QcStockGt", query.QcStockGt.Value));
  212. }
  213. if (query.FrozenGt.HasValue)
  214. {
  215. where.Add("d.FreezeQty > @FrozenGt");
  216. pars.Add(new SugarParameter("@FrozenGt", query.FrozenGt.Value));
  217. }
  218. var whereSql = string.Join(" AND ", where);
  219. var baseQuery =
  220. $"""
  221. SELECT d.Location, d.ItemNum, ISNULL(d.LotSerial,'') AS LotSerial,
  222. d.QtyOnHand, d.AvailStatusQty, d.Assay, d.FreezeQty,
  223. {locTypeExpr} AS LocType, t.Descr AS LocationName
  224. FROM LocationMaster t
  225. INNER JOIN LocationDetail d ON t.Domain = d.Domain AND t.Location = d.Location
  226. WHERE {whereSql}
  227. """;
  228. var total = await remote.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseQuery}) n", pars);
  229. var grand = (await remote.Ado.SqlQueryAsync<GrandRow>(
  230. $"""
  231. SELECT ISNULL(SUM(n.QtyOnHand),0) AS QtyOnHand,
  232. ISNULL(SUM(n.AvailStatusQty),0) AS Unrestricted,
  233. ISNULL(SUM(n.Assay),0) AS Qc,
  234. ISNULL(SUM(n.FreezeQty),0) AS Frozen
  235. FROM ({baseQuery}) n
  236. """, pars)).FirstOrDefault() ?? new GrandRow();
  237. var offset = (query.Page - 1) * query.PageSize;
  238. var raw = await remote.Ado.SqlQueryAsync<RawBalanceRow>(
  239. $"""
  240. SELECT n.LocType, n.Location, n.LocationName, n.ItemNum, n.LotSerial,
  241. n.AvailStatusQty, n.Assay, n.FreezeQty, n.QtyOnHand
  242. FROM ({baseQuery}) n
  243. ORDER BY n.Location, n.ItemNum, n.LotSerial
  244. OFFSET {offset} ROWS FETCH NEXT {query.PageSize} ROWS ONLY
  245. """, pars);
  246. var list = await EnrichLocalAsync(query.TenantId, query.Domain, raw, cancellationToken);
  247. return new InventoryBalancePage
  248. {
  249. Total = total,
  250. Page = query.Page,
  251. PageSize = query.PageSize,
  252. List = list,
  253. GrandTotalQtyOnHand = grand.QtyOnHand,
  254. GrandUnrestricted = grand.Unrestricted,
  255. GrandQc = grand.Qc,
  256. GrandFrozen = grand.Frozen,
  257. DataSource = InventoryDataSources.Live165,
  258. AsOf = DateTime.Now,
  259. IsRealtime = true
  260. };
  261. }
  262. /// <summary>租户库位边界为空时的 fail-closed 结果:0 行,且不发起任何源库查询。</summary>
  263. private static InventoryBalancePage EmptyLivePage(InventoryBalanceQuery query) => new()
  264. {
  265. Total = 0,
  266. Page = query.Page,
  267. PageSize = query.PageSize,
  268. List = Array.Empty<InventoryBalanceRow>(),
  269. GrandTotalQtyOnHand = 0m,
  270. GrandUnrestricted = 0m,
  271. GrandQc = 0m,
  272. GrandFrozen = 0m,
  273. DataSource = InventoryDataSources.Live165,
  274. AsOf = DateTime.Now,
  275. IsRealtime = true
  276. };
  277. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  278. long tenantId,
  279. string domain,
  280. IReadOnlyList<string> itemNumbers,
  281. CancellationToken cancellationToken = default)
  282. {
  283. var scopes = await LoadWhitelistLocationsAsync(tenantId, domain, cancellationToken);
  284. if (scopes.Count == 0)
  285. throw new InvalidOperationException($"库存库位白名单为空:tenant={tenantId}, domain={domain}");
  286. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  287. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  288. var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  289. var batchSize = _opt.BatchSize <= 0 ? 500 : _opt.BatchSize;
  290. for (var offset = 0; offset < itemNumbers.Count; offset += batchSize)
  291. {
  292. cancellationToken.ThrowIfCancellationRequested();
  293. var chunk = itemNumbers.Skip(offset).Take(batchSize).ToList();
  294. var names = new List<string>(chunk.Count);
  295. var locNames = new List<string>(scopes.Count);
  296. var pars = new List<SugarParameter> { new("@Domain", domain) };
  297. for (var i = 0; i < chunk.Count; i++)
  298. {
  299. names.Add($"@i{i}");
  300. pars.Add(new SugarParameter($"@i{i}", chunk[i]));
  301. }
  302. for (var i = 0; i < scopes.Count; i++)
  303. {
  304. locNames.Add($"@l{i}");
  305. pars.Add(new SugarParameter($"@l{i}", scopes[i]));
  306. }
  307. var sql =
  308. $"""
  309. SELECT ItemNum AS ItemNum, SUM(ISNULL(AvailStatusQty, 0)) AS Qty
  310. FROM LocationDetail
  311. WHERE Domain = @Domain
  312. AND ItemNum IN ({string.Join(",", names)})
  313. AND Location IN ({string.Join(",", locNames)})
  314. GROUP BY ItemNum
  315. """;
  316. var rows = await remote.Ado.SqlQueryAsync<QtyRow>(sql, pars);
  317. foreach (var r in rows)
  318. {
  319. if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
  320. result[r.ItemNum.Trim()] = r.Qty;
  321. }
  322. }
  323. return result;
  324. }
  325. /// <summary>与 STD 共用同一口径(<see cref="TenantLocationScopeLoader"/>),禁止两处各写一份 SQL。</summary>
  326. private Task<List<string>> LoadWhitelistLocationsAsync(
  327. long tenantId, string domain, CancellationToken ct)
  328. => TenantLocationScopeLoader.LoadAsync(_db, tenantId, domain, ct);
  329. private async Task<List<InventoryBalanceRow>> EnrichLocalAsync(
  330. long tenantId, string domain, List<RawBalanceRow> raw, CancellationToken ct)
  331. {
  332. if (raw.Count == 0) return new List<InventoryBalanceRow>();
  333. var itemNums = raw.Select(x => x.ItemNum ?? "").Where(x => x.Length > 0).Distinct().ToList();
  334. var itemMap = new Dictionary<string, (string? Descr, string? Um)>(StringComparer.OrdinalIgnoreCase);
  335. if (itemNums.Count > 0)
  336. {
  337. var names = itemNums.Select((_, i) => $"@i{i}").ToList();
  338. var pars = new List<SugarParameter>
  339. {
  340. new("@Domain", domain),
  341. new("@TenantId", tenantId)
  342. };
  343. for (var i = 0; i < itemNums.Count; i++)
  344. pars.Add(new SugarParameter($"@i{i}", itemNums[i]));
  345. // ItemMaster 可能无 tenant 列:优先带 tenant,失败则仅 Domain
  346. List<ItemRow> items;
  347. try
  348. {
  349. items = await _db.Ado.SqlQueryAsync<ItemRow>(
  350. $"""
  351. SELECT ItemNum, Descr, UM
  352. FROM ItemMaster
  353. WHERE Domain=@Domain AND tenant_id=@TenantId
  354. AND ItemNum IN ({string.Join(",", names)})
  355. """, pars);
  356. }
  357. catch
  358. {
  359. items = await _db.Ado.SqlQueryAsync<ItemRow>(
  360. $"""
  361. SELECT ItemNum, Descr, UM
  362. FROM ItemMaster
  363. WHERE Domain=@Domain AND ItemNum IN ({string.Join(",", names)})
  364. """, pars.Where(p => p.ParameterName != "@TenantId").ToList());
  365. }
  366. foreach (var it in items)
  367. if (!string.IsNullOrWhiteSpace(it.ItemNum))
  368. itemMap[it.ItemNum] = (it.Descr, it.UM);
  369. }
  370. // 标签富化(Shelf / LevelChar / Supply)必须按租户取:JOIN 键 (Location, ItemNum, LotSerial)
  371. // 在 mdp_std_inventory 里已存在跨租户碰撞(UAT 与 797 都有 1000|81HC0744|260811013 等组合),
  372. // 只按 Domain 会把别租户的货架/版本/供应商贴到本租户的库存行上。
  373. // Domain 保留为业务约束,但不再单独承担租户边界。
  374. var mp = await _db.Ado.SqlQueryAsync<MpRow>(
  375. """
  376. SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
  377. MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
  378. FROM MissedPrint
  379. WHERE tenant_id=@TenantId AND IFNULL(Status,'') NOT IN ('U','P','C')
  380. AND IFNULL(Location,'') <> '' AND Qty > 0
  381. GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
  382. """,
  383. new List<SugarParameter> { new("@TenantId", tenantId) });
  384. var mpMap = mp.ToDictionary(
  385. x => $"{x.Location}|{x.ItemNum}|{x.LotSerial}",
  386. x => x,
  387. StringComparer.OrdinalIgnoreCase);
  388. var list = new List<InventoryBalanceRow>(raw.Count);
  389. foreach (var r in raw)
  390. {
  391. itemMap.TryGetValue(r.ItemNum ?? "", out var im);
  392. mpMap.TryGetValue($"{r.Location}|{r.ItemNum}|{r.LotSerial}", out var m);
  393. list.Add(new InventoryBalanceRow
  394. {
  395. Type = r.LocType,
  396. Location = r.Location,
  397. LocationName = r.LocationName,
  398. MaterialCode = r.ItemNum,
  399. MaterialName = im.Descr,
  400. BatchNo = r.LotSerial,
  401. Shelf = m?.Shelf ?? "",
  402. Version = m?.LevelChar ?? "",
  403. UnrestrictedStock = r.AvailStatusQty,
  404. QcStock = r.Assay,
  405. FrozenStock = r.FreezeQty,
  406. QtyOnHand = r.QtyOnHand,
  407. Unit = im.Um,
  408. Supplier = m?.Supply ?? ""
  409. });
  410. }
  411. return list;
  412. }
  413. private sealed class GrandRow
  414. {
  415. public decimal QtyOnHand { get; set; }
  416. public decimal Unrestricted { get; set; }
  417. public decimal Qc { get; set; }
  418. public decimal Frozen { get; set; }
  419. }
  420. private sealed class RawBalanceRow
  421. {
  422. public string? LocType { get; set; }
  423. public string? Location { get; set; }
  424. public string? LocationName { get; set; }
  425. public string? ItemNum { get; set; }
  426. public string? LotSerial { get; set; }
  427. public decimal AvailStatusQty { get; set; }
  428. public decimal Assay { get; set; }
  429. public decimal FreezeQty { get; set; }
  430. public decimal QtyOnHand { get; set; }
  431. }
  432. private sealed class ItemRow
  433. {
  434. public string? ItemNum { get; set; }
  435. public string? Descr { get; set; }
  436. public string? UM { get; set; }
  437. }
  438. private sealed class MpRow
  439. {
  440. public string? Location { get; set; }
  441. public string? ItemNum { get; set; }
  442. public string? LotSerial { get; set; }
  443. public string? Shelf { get; set; }
  444. public string? LevelChar { get; set; }
  445. public string? Supply { get; set; }
  446. }
  447. private sealed class QtyRow
  448. {
  449. public string? ItemNum { get; set; }
  450. public decimal Qty { get; set; }
  451. }
  452. }
  453. /// <summary>
  454. /// 标准层余额(mdp_std_inventory)。
  455. /// **只返回当前租户 LocationMaster 白名单(Typed &lt;&gt; 'Supp')内的库位**,口径与 LIVE 一致;
  456. /// 白名单为空则 fail closed 返回 0 行。历史错误归属的脏快照会被这一层挡住。
  457. /// </summary>
  458. public sealed class StdInventorySource : ITransient
  459. {
  460. private readonly ISqlSugarClient _db;
  461. private readonly AidopInventoryOptions _opt;
  462. public StdInventorySource(ISqlSugarClient db, IOptions<AidopInventoryOptions> opt)
  463. {
  464. _db = db;
  465. _opt = opt.Value;
  466. }
  467. /// <summary>与 LIVE 共用同一口径(<see cref="TenantLocationScopeLoader"/>)。</summary>
  468. private Task<List<string>> LoadWhitelistLocationsAsync(
  469. long tenantId, string domain, CancellationToken ct)
  470. => TenantLocationScopeLoader.LoadAsync(_db, tenantId, domain, ct);
  471. /// <summary>租户库位边界为空时的 fail-closed 结果:0 行。</summary>
  472. private static InventoryBalancePage EmptyStdPage(InventoryBalanceQuery query) => new()
  473. {
  474. Total = 0,
  475. Page = query.Page,
  476. PageSize = query.PageSize,
  477. List = Array.Empty<InventoryBalanceRow>(),
  478. GrandTotalQtyOnHand = 0m,
  479. GrandUnrestricted = 0m,
  480. GrandQc = 0m,
  481. GrandFrozen = 0m,
  482. DataSource = InventoryDataSources.Std,
  483. AsOf = DateTime.Now,
  484. IsRealtime = false
  485. };
  486. public async Task<InventoryBalancePage> QueryPageAsync(
  487. InventoryBalanceQuery query,
  488. CancellationToken cancellationToken = default)
  489. {
  490. // —— 安全第一层:与 LIVE 同一口径的租户库位边界(Typed<>'Supp'),
  491. // 用户传入的 Location 只能在其内部收窄。历史脏快照即使还留在表里也会被这一层挡住。
  492. var scope = TenantLocationScope
  493. .FromWhitelist(await LoadWhitelistLocationsAsync(query.TenantId, query.Domain, cancellationToken))
  494. .Intersect(query.Location);
  495. // fail closed:空边界绝不等于整个 domain
  496. if (scope.IsEmpty) return EmptyStdPage(query);
  497. const string locTypeExpr = "CASE WHEN t.typed='VMI' THEN 'VMI' WHEN t.typed='Supp' THEN 'O库' ELSE '' END";
  498. var (locationClause, locationPars) = scope.BuildInClause("s.location", "loc");
  499. var where = new List<string>
  500. {
  501. "s.tenant_id = @TenantId",
  502. "s.domain = @Domain",
  503. "s.source_system = @SourceSystem",
  504. locationClause,
  505. "(IFNULL(s.qty_unrestricted,0)+IFNULL(s.qty_inspection,0)+IFNULL(s.qty_frozen,0)) > 0",
  506. "t.tenant_id = @TenantId",
  507. // 第二层:JOIN 到的库位主数据本身也不得是供应商库存
  508. "IFNULL(t.typed,'') <> 'Supp'"
  509. };
  510. var sourceSystem = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  511. var pars = new List<SugarParameter>
  512. {
  513. new("@TenantId", query.TenantId),
  514. new("@Domain", query.Domain),
  515. new("@SourceSystem", sourceSystem)
  516. };
  517. pars.AddRange(locationPars);
  518. if (!string.IsNullOrWhiteSpace(query.Type))
  519. {
  520. where.Add($"({locTypeExpr}) = @Type");
  521. pars.Add(new SugarParameter("@Type", query.Type.Trim()));
  522. }
  523. if (!string.IsNullOrWhiteSpace(query.MaterialCode))
  524. {
  525. where.Add("s.item_num LIKE @MaterialCode");
  526. pars.Add(new SugarParameter("@MaterialCode", $"%{query.MaterialCode.Trim()}%"));
  527. }
  528. if (!string.IsNullOrWhiteSpace(query.BatchNo))
  529. {
  530. where.Add("IFNULL(s.lot_serial,'') LIKE @BatchNo");
  531. pars.Add(new SugarParameter("@BatchNo", $"%{query.BatchNo.Trim()}%"));
  532. }
  533. if (query.StockGt.HasValue)
  534. {
  535. where.Add("s.qty_on_hand > @StockGt");
  536. pars.Add(new SugarParameter("@StockGt", query.StockGt.Value));
  537. }
  538. if (query.UnrestrictedGt.HasValue)
  539. {
  540. where.Add("s.qty_unrestricted > @UnrestrictedGt");
  541. pars.Add(new SugarParameter("@UnrestrictedGt", query.UnrestrictedGt.Value));
  542. }
  543. if (query.QcStockGt.HasValue)
  544. {
  545. where.Add("s.qty_inspection > @QcStockGt");
  546. pars.Add(new SugarParameter("@QcStockGt", query.QcStockGt.Value));
  547. }
  548. if (query.FrozenGt.HasValue)
  549. {
  550. where.Add("s.qty_frozen > @FrozenGt");
  551. pars.Add(new SugarParameter("@FrozenGt", query.FrozenGt.Value));
  552. }
  553. var whereSql = string.Join(" AND ", where);
  554. var baseQuery =
  555. $"""
  556. SELECT s.id AS Id,
  557. s.location AS Location, s.item_num AS ItemNum, IFNULL(s.lot_serial,'') AS LotSerial,
  558. s.qty_on_hand AS QtyOnHand, s.qty_unrestricted AS AvailStatusQty,
  559. s.qty_inspection AS Assay, s.qty_frozen AS FreezeQty,
  560. {locTypeExpr} AS LocType, t.descr AS LocationName, s.as_of AS AsOf
  561. FROM mdp_std_inventory s
  562. INNER JOIN LocationMaster t
  563. ON t.Domain = s.domain AND t.location = s.location AND t.tenant_id = s.tenant_id
  564. WHERE {whereSql}
  565. """;
  566. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseQuery}) n", pars);
  567. var grand = (await _db.Ado.SqlQueryAsync<GrandRow>(
  568. $"""
  569. SELECT IFNULL(SUM(n.QtyOnHand),0) AS QtyOnHand,
  570. IFNULL(SUM(n.AvailStatusQty),0) AS Unrestricted,
  571. IFNULL(SUM(n.Assay),0) AS Qc,
  572. IFNULL(SUM(n.FreezeQty),0) AS Frozen,
  573. MAX(n.AsOf) AS AsOf
  574. FROM ({baseQuery}) n
  575. """, pars)).FirstOrDefault() ?? new GrandRow();
  576. var offset = (query.Page - 1) * query.PageSize;
  577. var list = await _db.Ado.SqlQueryAsync<InventoryBalanceRow>(
  578. $"""
  579. SELECT
  580. n.LocType AS Type,
  581. n.Location AS Location,
  582. n.LocationName AS LocationName,
  583. n.ItemNum AS MaterialCode,
  584. i.Descr AS MaterialName,
  585. n.LotSerial AS BatchNo,
  586. IFNULL(mp.Shelf,'') AS Shelf,
  587. IFNULL(mp.LevelChar,'') AS Version,
  588. n.AvailStatusQty AS UnrestrictedStock,
  589. n.Assay AS QcStock,
  590. n.FreezeQty AS FrozenStock,
  591. n.QtyOnHand AS QtyOnHand,
  592. i.UM AS Unit,
  593. IFNULL(mp.Supply,'') AS Supplier
  594. FROM ({baseQuery}) n
  595. -- ItemMaster 同 Domain 下存在重复 ItemNum(实测 8010 有 151 个),
  596. -- 直接 LEFT JOIN 会放大行数,使分页取回行数大于 COUNT 出来的 total。
  597. -- 与下方 MissedPrint 同款处理:先按业务键收敛成一行再关联。
  598. LEFT JOIN (
  599. SELECT Domain, ItemNum, MAX(Descr) AS Descr, MAX(UM) AS UM
  600. FROM ItemMaster
  601. WHERE Domain=@Domain
  602. GROUP BY Domain, ItemNum
  603. ) i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
  604. -- 同上:标签富化按租户取。(tenant_id, Location, ItemNum, LotSerial) 实测无重复,
  605. -- 外层 GROUP BY + MAX() 亦已把多行收敛成一行,不会放大分页。
  606. LEFT JOIN (
  607. SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
  608. MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
  609. FROM MissedPrint
  610. WHERE tenant_id=@TenantId AND IFNULL(Status,'') NOT IN ('U','P','C')
  611. AND IFNULL(Location,'') <> '' AND Qty > 0
  612. GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
  613. ) mp ON mp.Location=n.Location AND mp.ItemNum=n.ItemNum AND mp.LotSerial=n.LotSerial
  614. -- 业务排序保持 库位/物料/批次 不变,末尾补主键作唯一 tiebreaker:
  615. -- 三者组合非唯一,OFFSET 分页在并列行上会跨页重复/漏行。
  616. ORDER BY n.Location, n.ItemNum, n.LotSerial, n.Id
  617. LIMIT {query.PageSize} OFFSET {offset}
  618. """, pars);
  619. return new InventoryBalancePage
  620. {
  621. Total = total,
  622. Page = query.Page,
  623. PageSize = query.PageSize,
  624. List = list,
  625. GrandTotalQtyOnHand = grand.QtyOnHand,
  626. GrandUnrestricted = grand.Unrestricted,
  627. GrandQc = grand.Qc,
  628. GrandFrozen = grand.Frozen,
  629. DataSource = InventoryDataSources.Std,
  630. AsOf = grand.AsOf ?? DateTime.Now,
  631. IsRealtime = false
  632. };
  633. }
  634. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  635. long tenantId,
  636. string domain,
  637. IReadOnlyList<string> itemNumbers,
  638. CancellationToken cancellationToken = default)
  639. {
  640. var scopes = await LoadWhitelistLocationsAsync(tenantId, domain, cancellationToken);
  641. if (scopes.Count == 0)
  642. throw new InvalidOperationException($"库存库位白名单为空:tenant={tenantId}, domain={domain}");
  643. var sourceSystem = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  644. var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  645. var batchSize = _opt.BatchSize <= 0 ? 500 : _opt.BatchSize;
  646. for (var offset = 0; offset < itemNumbers.Count; offset += batchSize)
  647. {
  648. cancellationToken.ThrowIfCancellationRequested();
  649. var chunk = itemNumbers.Skip(offset).Take(batchSize).ToList();
  650. var names = chunk.Select((_, i) => $"@i{i}").ToList();
  651. var locNames = scopes.Select((_, i) => $"@l{i}").ToList();
  652. var pars = new List<SugarParameter>
  653. {
  654. new("@TenantId", tenantId),
  655. new("@Domain", domain),
  656. new("@SourceSystem", sourceSystem)
  657. };
  658. for (var i = 0; i < chunk.Count; i++)
  659. pars.Add(new SugarParameter($"@i{i}", chunk[i]));
  660. for (var i = 0; i < scopes.Count; i++)
  661. pars.Add(new SugarParameter($"@l{i}", scopes[i]));
  662. var rows = await _db.Ado.SqlQueryAsync<QtyRow>(
  663. $"""
  664. SELECT item_num AS ItemNum, SUM(IFNULL(qty_available,0)) AS Qty
  665. FROM mdp_std_inventory
  666. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  667. AND item_num IN ({string.Join(",", names)})
  668. AND location IN ({string.Join(",", locNames)})
  669. GROUP BY item_num
  670. """, pars);
  671. foreach (var r in rows)
  672. {
  673. if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
  674. result[r.ItemNum.Trim()] = r.Qty;
  675. }
  676. }
  677. return result;
  678. }
  679. private sealed class GrandRow
  680. {
  681. public decimal QtyOnHand { get; set; }
  682. public decimal Unrestricted { get; set; }
  683. public decimal Qc { get; set; }
  684. public decimal Frozen { get; set; }
  685. public DateTime? AsOf { get; set; }
  686. }
  687. private sealed class QtyRow
  688. {
  689. public string? ItemNum { get; set; }
  690. public decimal Qty { get; set; }
  691. }
  692. }