InventoryBalanceReader.cs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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. if (!stdOnly && selective)
  67. {
  68. try
  69. {
  70. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
  71. cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, _opt.LiveTimeoutSeconds)));
  72. var live = await _live.QueryPageAsync(q, cts.Token);
  73. return live;
  74. }
  75. catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
  76. {
  77. _logger.LogWarning("[InventoryBalanceReader] LIVE_165 超时,回落 STD tenant={Tenant}", q.TenantId);
  78. }
  79. catch (Exception ex)
  80. {
  81. _logger.LogWarning(ex, "[InventoryBalanceReader] LIVE_165 失败,回落 STD tenant={Tenant}", q.TenantId);
  82. }
  83. }
  84. return await _std.QueryPageAsync(q, cancellationToken);
  85. }
  86. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  87. long tenantId,
  88. IReadOnlyCollection<string> itemNumbers,
  89. CancellationToken cancellationToken = default)
  90. {
  91. var items = itemNumbers
  92. .Where(x => !string.IsNullOrWhiteSpace(x))
  93. .Select(x => x.Trim())
  94. .Distinct(StringComparer.OrdinalIgnoreCase)
  95. .ToList();
  96. if (items.Count == 0)
  97. return new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  98. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  99. var domain = await _domainTenant.ResolveDomainAsync(sourceCode, tenantId, cancellationToken);
  100. var mode = (_opt.BalanceMode ?? "LiveThenStd").Trim();
  101. var stdOnly = string.Equals(mode, "StdOnly", StringComparison.OrdinalIgnoreCase);
  102. if (!stdOnly)
  103. {
  104. try
  105. {
  106. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
  107. cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, _opt.LiveTimeoutSeconds)));
  108. return await _live.LoadAvailableQtyAsync(tenantId, domain, items, cts.Token);
  109. }
  110. catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
  111. {
  112. _logger.LogWarning("[InventoryBalanceReader] LoadAvailableQty LIVE 超时,回落 STD");
  113. }
  114. catch (Exception ex)
  115. {
  116. _logger.LogWarning(ex, "[InventoryBalanceReader] LoadAvailableQty LIVE 失败,回落 STD");
  117. }
  118. }
  119. var std = await _std.LoadAvailableQtyAsync(tenantId, domain, items, cancellationToken);
  120. if (std.Count == 0 && items.Count > 0)
  121. {
  122. // STD 无快照时不得静默 0:仍返回空字典由调用方当 0,但打 Error 便于运维
  123. _logger.LogError(
  124. "[InventoryBalanceReader] STD 无可用库存快照 tenant={Tenant} domain={Domain} items={Count}",
  125. tenantId, domain, items.Count);
  126. }
  127. return std;
  128. }
  129. private static bool HasSelectiveFilter(InventoryBalanceQuery q) =>
  130. !string.IsNullOrWhiteSpace(q.MaterialCode)
  131. || !string.IsNullOrWhiteSpace(q.BatchNo)
  132. || !string.IsNullOrWhiteSpace(q.Location);
  133. }
  134. /// <summary>165 LocationDetail 实时余额(查询页用,展示全部库位)。</summary>
  135. public sealed class Live165InventorySource : ITransient
  136. {
  137. private readonly MdpSourceScopeFactory _scopeFactory;
  138. private readonly ISqlSugarClient _db;
  139. private readonly AidopInventoryOptions _opt;
  140. public Live165InventorySource(
  141. MdpSourceScopeFactory scopeFactory,
  142. ISqlSugarClient db,
  143. IOptions<AidopInventoryOptions> opt)
  144. {
  145. _scopeFactory = scopeFactory;
  146. _db = db;
  147. _opt = opt.Value;
  148. }
  149. public async Task<InventoryBalancePage> QueryPageAsync(
  150. InventoryBalanceQuery query,
  151. CancellationToken cancellationToken = default)
  152. {
  153. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  154. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  155. const string locTypeExpr = "CASE WHEN t.Typed='VMI' THEN 'VMI' WHEN t.Typed='Supp' THEN N'O库' ELSE '' END";
  156. var where = new List<string>
  157. {
  158. "t.Domain = @Domain",
  159. "(ISNULL(d.AvailStatusQty,0)+ISNULL(d.Assay,0)+ISNULL(d.FreezeQty,0)) > 0"
  160. };
  161. var pars = new List<SugarParameter> { new("@Domain", query.Domain) };
  162. if (!string.IsNullOrWhiteSpace(query.Type))
  163. {
  164. where.Add($"({locTypeExpr}) = @Type");
  165. pars.Add(new SugarParameter("@Type", query.Type.Trim()));
  166. }
  167. if (!string.IsNullOrWhiteSpace(query.Location))
  168. {
  169. where.Add("d.Location = @Location");
  170. pars.Add(new SugarParameter("@Location", query.Location.Trim()));
  171. }
  172. if (!string.IsNullOrWhiteSpace(query.MaterialCode))
  173. {
  174. where.Add("d.ItemNum LIKE @MaterialCode");
  175. pars.Add(new SugarParameter("@MaterialCode", $"%{query.MaterialCode.Trim()}%"));
  176. }
  177. if (!string.IsNullOrWhiteSpace(query.BatchNo))
  178. {
  179. where.Add("ISNULL(d.LotSerial,'') LIKE @BatchNo");
  180. pars.Add(new SugarParameter("@BatchNo", $"%{query.BatchNo.Trim()}%"));
  181. }
  182. if (query.StockGt.HasValue)
  183. {
  184. where.Add("d.QtyOnHand > @StockGt");
  185. pars.Add(new SugarParameter("@StockGt", query.StockGt.Value));
  186. }
  187. if (query.UnrestrictedGt.HasValue)
  188. {
  189. where.Add("d.AvailStatusQty > @UnrestrictedGt");
  190. pars.Add(new SugarParameter("@UnrestrictedGt", query.UnrestrictedGt.Value));
  191. }
  192. if (query.QcStockGt.HasValue)
  193. {
  194. where.Add("d.Assay > @QcStockGt");
  195. pars.Add(new SugarParameter("@QcStockGt", query.QcStockGt.Value));
  196. }
  197. if (query.FrozenGt.HasValue)
  198. {
  199. where.Add("d.FreezeQty > @FrozenGt");
  200. pars.Add(new SugarParameter("@FrozenGt", query.FrozenGt.Value));
  201. }
  202. var whereSql = string.Join(" AND ", where);
  203. var baseQuery =
  204. $"""
  205. SELECT d.Location, d.ItemNum, ISNULL(d.LotSerial,'') AS LotSerial,
  206. d.QtyOnHand, d.AvailStatusQty, d.Assay, d.FreezeQty,
  207. {locTypeExpr} AS LocType, t.Descr AS LocationName
  208. FROM LocationMaster t
  209. INNER JOIN LocationDetail d ON t.Domain = d.Domain AND t.Location = d.Location
  210. WHERE {whereSql}
  211. """;
  212. var total = await remote.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseQuery}) n", pars);
  213. var grand = (await remote.Ado.SqlQueryAsync<GrandRow>(
  214. $"""
  215. SELECT ISNULL(SUM(n.QtyOnHand),0) AS QtyOnHand,
  216. ISNULL(SUM(n.AvailStatusQty),0) AS Unrestricted,
  217. ISNULL(SUM(n.Assay),0) AS Qc,
  218. ISNULL(SUM(n.FreezeQty),0) AS Frozen
  219. FROM ({baseQuery}) n
  220. """, pars)).FirstOrDefault() ?? new GrandRow();
  221. var offset = (query.Page - 1) * query.PageSize;
  222. var raw = await remote.Ado.SqlQueryAsync<RawBalanceRow>(
  223. $"""
  224. SELECT n.LocType, n.Location, n.LocationName, n.ItemNum, n.LotSerial,
  225. n.AvailStatusQty, n.Assay, n.FreezeQty, n.QtyOnHand
  226. FROM ({baseQuery}) n
  227. ORDER BY n.Location, n.ItemNum, n.LotSerial
  228. OFFSET {offset} ROWS FETCH NEXT {query.PageSize} ROWS ONLY
  229. """, pars);
  230. var list = await EnrichLocalAsync(query.TenantId, query.Domain, raw, cancellationToken);
  231. return new InventoryBalancePage
  232. {
  233. Total = total,
  234. Page = query.Page,
  235. PageSize = query.PageSize,
  236. List = list,
  237. GrandTotalQtyOnHand = grand.QtyOnHand,
  238. GrandUnrestricted = grand.Unrestricted,
  239. GrandQc = grand.Qc,
  240. GrandFrozen = grand.Frozen,
  241. DataSource = InventoryDataSources.Live165,
  242. AsOf = DateTime.Now,
  243. IsRealtime = true
  244. };
  245. }
  246. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  247. long tenantId,
  248. string domain,
  249. IReadOnlyList<string> itemNumbers,
  250. CancellationToken cancellationToken = default)
  251. {
  252. var scopes = await LoadWhitelistLocationsAsync(tenantId, domain, cancellationToken);
  253. if (scopes.Count == 0)
  254. throw new InvalidOperationException($"库存库位白名单为空:tenant={tenantId}, domain={domain}");
  255. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  256. var remote = await _scopeFactory.GetScopeAsync(sourceCode, cancellationToken);
  257. var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  258. var batchSize = _opt.BatchSize <= 0 ? 500 : _opt.BatchSize;
  259. for (var offset = 0; offset < itemNumbers.Count; offset += batchSize)
  260. {
  261. cancellationToken.ThrowIfCancellationRequested();
  262. var chunk = itemNumbers.Skip(offset).Take(batchSize).ToList();
  263. var names = new List<string>(chunk.Count);
  264. var locNames = new List<string>(scopes.Count);
  265. var pars = new List<SugarParameter> { new("@Domain", domain) };
  266. for (var i = 0; i < chunk.Count; i++)
  267. {
  268. names.Add($"@i{i}");
  269. pars.Add(new SugarParameter($"@i{i}", chunk[i]));
  270. }
  271. for (var i = 0; i < scopes.Count; i++)
  272. {
  273. locNames.Add($"@l{i}");
  274. pars.Add(new SugarParameter($"@l{i}", scopes[i]));
  275. }
  276. var sql =
  277. $"""
  278. SELECT ItemNum AS ItemNum, SUM(ISNULL(AvailStatusQty, 0)) AS Qty
  279. FROM LocationDetail
  280. WHERE Domain = @Domain
  281. AND ItemNum IN ({string.Join(",", names)})
  282. AND Location IN ({string.Join(",", locNames)})
  283. GROUP BY ItemNum
  284. """;
  285. var rows = await remote.Ado.SqlQueryAsync<QtyRow>(sql, pars);
  286. foreach (var r in rows)
  287. {
  288. if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
  289. result[r.ItemNum.Trim()] = r.Qty;
  290. }
  291. }
  292. return result;
  293. }
  294. private async Task<List<string>> LoadWhitelistLocationsAsync(
  295. long tenantId, string domain, CancellationToken ct)
  296. {
  297. return await _db.Ado.SqlQueryAsync<string>(
  298. """
  299. SELECT DISTINCT Location
  300. FROM LocationMaster
  301. WHERE tenant_id=@TenantId AND Domain=@Domain
  302. AND IFNULL(Typed,'')<>'Supp'
  303. AND TRIM(Location)<>''
  304. """,
  305. new List<SugarParameter>
  306. {
  307. new("@TenantId", tenantId),
  308. new("@Domain", domain)
  309. });
  310. }
  311. private async Task<List<InventoryBalanceRow>> EnrichLocalAsync(
  312. long tenantId, string domain, List<RawBalanceRow> raw, CancellationToken ct)
  313. {
  314. if (raw.Count == 0) return new List<InventoryBalanceRow>();
  315. var itemNums = raw.Select(x => x.ItemNum ?? "").Where(x => x.Length > 0).Distinct().ToList();
  316. var itemMap = new Dictionary<string, (string? Descr, string? Um)>(StringComparer.OrdinalIgnoreCase);
  317. if (itemNums.Count > 0)
  318. {
  319. var names = itemNums.Select((_, i) => $"@i{i}").ToList();
  320. var pars = new List<SugarParameter>
  321. {
  322. new("@Domain", domain),
  323. new("@TenantId", tenantId)
  324. };
  325. for (var i = 0; i < itemNums.Count; i++)
  326. pars.Add(new SugarParameter($"@i{i}", itemNums[i]));
  327. // ItemMaster 可能无 tenant 列:优先带 tenant,失败则仅 Domain
  328. List<ItemRow> items;
  329. try
  330. {
  331. items = await _db.Ado.SqlQueryAsync<ItemRow>(
  332. $"""
  333. SELECT ItemNum, Descr, UM
  334. FROM ItemMaster
  335. WHERE Domain=@Domain AND tenant_id=@TenantId
  336. AND ItemNum IN ({string.Join(",", names)})
  337. """, pars);
  338. }
  339. catch
  340. {
  341. items = await _db.Ado.SqlQueryAsync<ItemRow>(
  342. $"""
  343. SELECT ItemNum, Descr, UM
  344. FROM ItemMaster
  345. WHERE Domain=@Domain AND ItemNum IN ({string.Join(",", names)})
  346. """, pars.Where(p => p.ParameterName != "@TenantId").ToList());
  347. }
  348. foreach (var it in items)
  349. if (!string.IsNullOrWhiteSpace(it.ItemNum))
  350. itemMap[it.ItemNum] = (it.Descr, it.UM);
  351. }
  352. var mp = await _db.Ado.SqlQueryAsync<MpRow>(
  353. """
  354. SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
  355. MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
  356. FROM MissedPrint
  357. WHERE Domain=@Domain AND IFNULL(Status,'') NOT IN ('U','P','C')
  358. AND IFNULL(Location,'') <> '' AND Qty > 0
  359. GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
  360. """,
  361. new List<SugarParameter> { new("@Domain", domain) });
  362. var mpMap = mp.ToDictionary(
  363. x => $"{x.Location}|{x.ItemNum}|{x.LotSerial}",
  364. x => x,
  365. StringComparer.OrdinalIgnoreCase);
  366. var list = new List<InventoryBalanceRow>(raw.Count);
  367. foreach (var r in raw)
  368. {
  369. itemMap.TryGetValue(r.ItemNum ?? "", out var im);
  370. mpMap.TryGetValue($"{r.Location}|{r.ItemNum}|{r.LotSerial}", out var m);
  371. list.Add(new InventoryBalanceRow
  372. {
  373. Type = r.LocType,
  374. Location = r.Location,
  375. LocationName = r.LocationName,
  376. MaterialCode = r.ItemNum,
  377. MaterialName = im.Descr,
  378. BatchNo = r.LotSerial,
  379. Shelf = m?.Shelf ?? "",
  380. Version = m?.LevelChar ?? "",
  381. UnrestrictedStock = r.AvailStatusQty,
  382. QcStock = r.Assay,
  383. FrozenStock = r.FreezeQty,
  384. QtyOnHand = r.QtyOnHand,
  385. Unit = im.Um,
  386. Supplier = m?.Supply ?? ""
  387. });
  388. }
  389. return list;
  390. }
  391. private sealed class GrandRow
  392. {
  393. public decimal QtyOnHand { get; set; }
  394. public decimal Unrestricted { get; set; }
  395. public decimal Qc { get; set; }
  396. public decimal Frozen { get; set; }
  397. }
  398. private sealed class RawBalanceRow
  399. {
  400. public string? LocType { get; set; }
  401. public string? Location { get; set; }
  402. public string? LocationName { get; set; }
  403. public string? ItemNum { get; set; }
  404. public string? LotSerial { get; set; }
  405. public decimal AvailStatusQty { get; set; }
  406. public decimal Assay { get; set; }
  407. public decimal FreezeQty { get; set; }
  408. public decimal QtyOnHand { get; set; }
  409. }
  410. private sealed class ItemRow
  411. {
  412. public string? ItemNum { get; set; }
  413. public string? Descr { get; set; }
  414. public string? UM { get; set; }
  415. }
  416. private sealed class MpRow
  417. {
  418. public string? Location { get; set; }
  419. public string? ItemNum { get; set; }
  420. public string? LotSerial { get; set; }
  421. public string? Shelf { get; set; }
  422. public string? LevelChar { get; set; }
  423. public string? Supply { get; set; }
  424. }
  425. private sealed class QtyRow
  426. {
  427. public string? ItemNum { get; set; }
  428. public decimal Qty { get; set; }
  429. }
  430. }
  431. /// <summary>标准层余额(mdp_std_inventory),查询页展示全部库位。</summary>
  432. public sealed class StdInventorySource : ITransient
  433. {
  434. private readonly ISqlSugarClient _db;
  435. private readonly AidopInventoryOptions _opt;
  436. public StdInventorySource(ISqlSugarClient db, IOptions<AidopInventoryOptions> opt)
  437. {
  438. _db = db;
  439. _opt = opt.Value;
  440. }
  441. public async Task<InventoryBalancePage> QueryPageAsync(
  442. InventoryBalanceQuery query,
  443. CancellationToken cancellationToken = default)
  444. {
  445. const string locTypeExpr = "CASE WHEN t.typed='VMI' THEN 'VMI' WHEN t.typed='Supp' THEN 'O库' ELSE '' END";
  446. var where = new List<string>
  447. {
  448. "s.tenant_id = @TenantId",
  449. "s.domain = @Domain",
  450. "s.source_system = @SourceSystem",
  451. "(IFNULL(s.qty_unrestricted,0)+IFNULL(s.qty_inspection,0)+IFNULL(s.qty_frozen,0)) > 0",
  452. "t.tenant_id = @TenantId"
  453. };
  454. var sourceSystem = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  455. var pars = new List<SugarParameter>
  456. {
  457. new("@TenantId", query.TenantId),
  458. new("@Domain", query.Domain),
  459. new("@SourceSystem", sourceSystem)
  460. };
  461. if (!string.IsNullOrWhiteSpace(query.Type))
  462. {
  463. where.Add($"({locTypeExpr}) = @Type");
  464. pars.Add(new SugarParameter("@Type", query.Type.Trim()));
  465. }
  466. if (!string.IsNullOrWhiteSpace(query.Location))
  467. {
  468. where.Add("s.location = @Location");
  469. pars.Add(new SugarParameter("@Location", query.Location.Trim()));
  470. }
  471. if (!string.IsNullOrWhiteSpace(query.MaterialCode))
  472. {
  473. where.Add("s.item_num LIKE @MaterialCode");
  474. pars.Add(new SugarParameter("@MaterialCode", $"%{query.MaterialCode.Trim()}%"));
  475. }
  476. if (!string.IsNullOrWhiteSpace(query.BatchNo))
  477. {
  478. where.Add("IFNULL(s.lot_serial,'') LIKE @BatchNo");
  479. pars.Add(new SugarParameter("@BatchNo", $"%{query.BatchNo.Trim()}%"));
  480. }
  481. if (query.StockGt.HasValue)
  482. {
  483. where.Add("s.qty_on_hand > @StockGt");
  484. pars.Add(new SugarParameter("@StockGt", query.StockGt.Value));
  485. }
  486. if (query.UnrestrictedGt.HasValue)
  487. {
  488. where.Add("s.qty_unrestricted > @UnrestrictedGt");
  489. pars.Add(new SugarParameter("@UnrestrictedGt", query.UnrestrictedGt.Value));
  490. }
  491. if (query.QcStockGt.HasValue)
  492. {
  493. where.Add("s.qty_inspection > @QcStockGt");
  494. pars.Add(new SugarParameter("@QcStockGt", query.QcStockGt.Value));
  495. }
  496. if (query.FrozenGt.HasValue)
  497. {
  498. where.Add("s.qty_frozen > @FrozenGt");
  499. pars.Add(new SugarParameter("@FrozenGt", query.FrozenGt.Value));
  500. }
  501. var whereSql = string.Join(" AND ", where);
  502. var baseQuery =
  503. $"""
  504. SELECT s.location AS Location, s.item_num AS ItemNum, IFNULL(s.lot_serial,'') AS LotSerial,
  505. s.qty_on_hand AS QtyOnHand, s.qty_unrestricted AS AvailStatusQty,
  506. s.qty_inspection AS Assay, s.qty_frozen AS FreezeQty,
  507. {locTypeExpr} AS LocType, t.descr AS LocationName, s.as_of AS AsOf
  508. FROM mdp_std_inventory s
  509. INNER JOIN LocationMaster t
  510. ON t.Domain = s.domain AND t.location = s.location AND t.tenant_id = s.tenant_id
  511. WHERE {whereSql}
  512. """;
  513. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseQuery}) n", pars);
  514. var grand = (await _db.Ado.SqlQueryAsync<GrandRow>(
  515. $"""
  516. SELECT IFNULL(SUM(n.QtyOnHand),0) AS QtyOnHand,
  517. IFNULL(SUM(n.AvailStatusQty),0) AS Unrestricted,
  518. IFNULL(SUM(n.Assay),0) AS Qc,
  519. IFNULL(SUM(n.FreezeQty),0) AS Frozen,
  520. MAX(n.AsOf) AS AsOf
  521. FROM ({baseQuery}) n
  522. """, pars)).FirstOrDefault() ?? new GrandRow();
  523. var offset = (query.Page - 1) * query.PageSize;
  524. var list = await _db.Ado.SqlQueryAsync<InventoryBalanceRow>(
  525. $"""
  526. SELECT
  527. n.LocType AS Type,
  528. n.Location AS Location,
  529. n.LocationName AS LocationName,
  530. n.ItemNum AS MaterialCode,
  531. i.Descr AS MaterialName,
  532. n.LotSerial AS BatchNo,
  533. IFNULL(mp.Shelf,'') AS Shelf,
  534. IFNULL(mp.LevelChar,'') AS Version,
  535. n.AvailStatusQty AS UnrestrictedStock,
  536. n.Assay AS QcStock,
  537. n.FreezeQty AS FrozenStock,
  538. n.QtyOnHand AS QtyOnHand,
  539. i.UM AS Unit,
  540. IFNULL(mp.Supply,'') AS Supplier
  541. FROM ({baseQuery}) n
  542. LEFT JOIN ItemMaster i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
  543. LEFT JOIN (
  544. SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
  545. MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
  546. FROM MissedPrint
  547. WHERE Domain=@Domain AND IFNULL(Status,'') NOT IN ('U','P','C')
  548. AND IFNULL(Location,'') <> '' AND Qty > 0
  549. GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
  550. ) mp ON mp.Location=n.Location AND mp.ItemNum=n.ItemNum AND mp.LotSerial=n.LotSerial
  551. ORDER BY n.Location, n.ItemNum, n.LotSerial
  552. LIMIT {query.PageSize} OFFSET {offset}
  553. """, pars);
  554. return new InventoryBalancePage
  555. {
  556. Total = total,
  557. Page = query.Page,
  558. PageSize = query.PageSize,
  559. List = list,
  560. GrandTotalQtyOnHand = grand.QtyOnHand,
  561. GrandUnrestricted = grand.Unrestricted,
  562. GrandQc = grand.Qc,
  563. GrandFrozen = grand.Frozen,
  564. DataSource = InventoryDataSources.Std,
  565. AsOf = grand.AsOf ?? DateTime.Now,
  566. IsRealtime = false
  567. };
  568. }
  569. public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
  570. long tenantId,
  571. string domain,
  572. IReadOnlyList<string> itemNumbers,
  573. CancellationToken cancellationToken = default)
  574. {
  575. var scopes = await _db.Ado.SqlQueryAsync<string>(
  576. """
  577. SELECT DISTINCT Location
  578. FROM LocationMaster
  579. WHERE tenant_id=@TenantId AND Domain=@Domain
  580. AND IFNULL(Typed,'')<>'Supp'
  581. AND TRIM(Location)<>''
  582. """,
  583. new List<SugarParameter>
  584. {
  585. new("@TenantId", tenantId),
  586. new("@Domain", domain)
  587. });
  588. if (scopes.Count == 0)
  589. throw new InvalidOperationException($"库存库位白名单为空:tenant={tenantId}, domain={domain}");
  590. var sourceSystem = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
  591. var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  592. var batchSize = _opt.BatchSize <= 0 ? 500 : _opt.BatchSize;
  593. for (var offset = 0; offset < itemNumbers.Count; offset += batchSize)
  594. {
  595. cancellationToken.ThrowIfCancellationRequested();
  596. var chunk = itemNumbers.Skip(offset).Take(batchSize).ToList();
  597. var names = chunk.Select((_, i) => $"@i{i}").ToList();
  598. var locNames = scopes.Select((_, i) => $"@l{i}").ToList();
  599. var pars = new List<SugarParameter>
  600. {
  601. new("@TenantId", tenantId),
  602. new("@Domain", domain),
  603. new("@SourceSystem", sourceSystem)
  604. };
  605. for (var i = 0; i < chunk.Count; i++)
  606. pars.Add(new SugarParameter($"@i{i}", chunk[i]));
  607. for (var i = 0; i < scopes.Count; i++)
  608. pars.Add(new SugarParameter($"@l{i}", scopes[i]));
  609. var rows = await _db.Ado.SqlQueryAsync<QtyRow>(
  610. $"""
  611. SELECT item_num AS ItemNum, SUM(IFNULL(qty_available,0)) AS Qty
  612. FROM mdp_std_inventory
  613. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  614. AND item_num IN ({string.Join(",", names)})
  615. AND location IN ({string.Join(",", locNames)})
  616. GROUP BY item_num
  617. """, pars);
  618. foreach (var r in rows)
  619. {
  620. if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
  621. result[r.ItemNum.Trim()] = r.Qty;
  622. }
  623. }
  624. return result;
  625. }
  626. private sealed class GrandRow
  627. {
  628. public decimal QtyOnHand { get; set; }
  629. public decimal Unrestricted { get; set; }
  630. public decimal Qc { get; set; }
  631. public decimal Frozen { get; set; }
  632. public DateTime? AsOf { get; set; }
  633. }
  634. private sealed class QtyRow
  635. {
  636. public string? ItemNum { get; set; }
  637. public decimal Qty { get; set; }
  638. }
  639. }