MaterialRequirementCalculator.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. using Microsoft.Extensions.Logging;
  2. using Microsoft.Extensions.Options;
  3. using Yitter.IdGenerator;
  4. namespace Admin.NET.Plugin.AiDOP.Order;
  5. /// <summary>
  6. /// 订单评审资源检查:多层 BOM 展开与库存/采购在途缺口计算。
  7. /// </summary>
  8. public class MaterialRequirementCalculator : ITransient
  9. {
  10. private const int MaxBomDepth = 20;
  11. private readonly ISqlSugarClient _db;
  12. private readonly AidopStockOptions _stockOpt;
  13. private readonly Mes165StockReader _mesStock;
  14. private readonly ILogger _logger;
  15. public MaterialRequirementCalculator(
  16. ISqlSugarClient db,
  17. IOptions<AidopStockOptions> stockOpt,
  18. Mes165StockReader mesStock,
  19. ILoggerFactory loggerFactory)
  20. {
  21. _db = db;
  22. _stockOpt = stockOpt.Value;
  23. _mesStock = mesStock;
  24. _logger = loggerFactory.CreateLogger(nameof(MaterialRequirementCalculator));
  25. }
  26. /// <param name="writeOccupy">
  27. /// 是否登记库存/在途占用。只有排产才写(占用有明确的版本与清理时机);
  28. /// 手动齐套检查是查询语义,多点几次不应把库存占光。
  29. /// </param>
  30. /// <param name="excludeOccupyWorkOrd">
  31. /// 扣减占用时要排除的工单号(通常是本工单)。不排除会把自己上一次的占用当成别人的,
  32. /// 导致同一工单反复检查时缺料量不断翻倍。
  33. /// </param>
  34. public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
  35. OrderWorkOrderGenerationService.OrderHeader order,
  36. OrderWorkOrderGenerationService.OrderEntryLine entry,
  37. List<string> warnings,
  38. long bangId = 0,
  39. string? workOrd = null,
  40. long morderId = 0,
  41. bool writeOccupy = true,
  42. string? excludeOccupyWorkOrd = null)
  43. {
  44. var itemNum = entry.ItemNumber!.Trim();
  45. var orderQty = entry.Qty ?? 0;
  46. var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
  47. var lines = new List<ResourceCheckBomLine>();
  48. var rootFid = YitIdHelper.NextId();
  49. lines.Add(new ResourceCheckBomLine
  50. {
  51. Fid = rootFid,
  52. Num = "1",
  53. Level = 1,
  54. ItemNumber = itemNum,
  55. ItemName = entry.ItemName,
  56. Model = entry.Specification,
  57. Unit = entry.Unit,
  58. BomNumber = entry.BomNumber,
  59. ErpCls = 1,
  60. ErpClsName = "自制",
  61. NeedCount = orderQty,
  62. NeedCountNoLoss = orderQty,
  63. KittingTime = needTime,
  64. SatisfyTime = needTime,
  65. IsUse = 1
  66. });
  67. var bomId = await ResolveBomIdAsync(entry, itemNum);
  68. if (bomId is null)
  69. {
  70. warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
  71. await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
  72. return lines;
  73. }
  74. var children = await LoadBomChildrenAsync(bomId.Value, entry.TenantId);
  75. if (children.Count == 0)
  76. {
  77. warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
  78. await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
  79. return lines;
  80. }
  81. var seq = 1;
  82. foreach (var child in children)
  83. {
  84. seq = await AppendBomChildAsync(
  85. lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
  86. }
  87. await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
  88. return lines;
  89. }
  90. private async Task<int> AppendBomChildAsync(
  91. List<ResourceCheckBomLine> lines,
  92. long parentFid,
  93. string parentNum,
  94. int parentLevel,
  95. decimal parentNeedQty,
  96. BomChildRow child,
  97. OrderWorkOrderGenerationService.OrderEntryLine entry,
  98. DateTime needTime,
  99. List<string> warnings,
  100. int seq,
  101. int depth)
  102. {
  103. var unitQty = child.Qty <= 0 ? 1m : child.Qty;
  104. var wastageFactor = 1m + (child.Wastage / 100m);
  105. var scrapFactor = 1m + (child.Scrap / 100m);
  106. var needNoLoss = parentNeedQty * unitQty;
  107. var needCount = RoundQty(needNoLoss * wastageFactor * scrapFactor);
  108. if (needCount > 10000 && parentNeedQty > 0 && unitQty > 100)
  109. {
  110. warnings.Add($"物料 {child.ItemNumber} BOM用量({unitQty}) × 父需求({parentNeedQty}) = 需求数量({needCount}),请核实 BOM 配置");
  111. }
  112. var isVirtual = child.ErpCls == 4;
  113. var num = $"{parentNum}.{seq}";
  114. var fid = YitIdHelper.NextId();
  115. var nextSeq = seq + 1;
  116. // 虚拟件(erp_cls=4)也写入资源检查记录,IsUse=0 标识为虚拟件,不参与缺料计算
  117. lines.Add(new ResourceCheckBomLine
  118. {
  119. Fid = fid,
  120. ParentFid = parentFid,
  121. BomChildId = child.Id,
  122. Num = num,
  123. Level = parentLevel + 1,
  124. Type = child.Type,
  125. ItemNumber = child.ItemNumber ?? string.Empty,
  126. ItemName = child.ItemName,
  127. Model = child.Model,
  128. Unit = child.Unit,
  129. BomNumber = child.BomNumber,
  130. ErpCls = child.ErpCls,
  131. ErpClsName = MapErpClsName(child.ErpCls),
  132. Backflush = child.Backflush,
  133. Qty = unitQty,
  134. Wastage = child.Wastage,
  135. Scrap = child.Scrap,
  136. NeedCount = needCount,
  137. NeedCountNoLoss = RoundQty(needNoLoss),
  138. KittingTime = needTime,
  139. SatisfyTime = needTime,
  140. IsBom = child.IsBom,
  141. HaveIcSubs = child.HaveIcSubs,
  142. SubstituteCode = child.SubstituteCode,
  143. IsUse = isVirtual ? 0 : 1
  144. });
  145. seq = nextSeq;
  146. var expandParentFid = isVirtual ? parentFid : fid;
  147. var expandParentNum = num;
  148. var expandNeed = needCount;
  149. // 仅虚拟件(erp_cls=4)继续向下展开子阶BOM,其他类型作为叶子节点不再下钻
  150. if (isVirtual && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
  151. {
  152. var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
  153. if (subBomId is null)
  154. return seq;
  155. var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
  156. if (grandchildren.Count == 0)
  157. return seq;
  158. var childSeq = 1;
  159. foreach (var gc in grandchildren)
  160. {
  161. childSeq = await AppendBomChildAsync(
  162. lines,
  163. expandParentFid,
  164. expandParentNum,
  165. parentLevel + 1,
  166. expandNeed,
  167. gc,
  168. entry,
  169. needTime,
  170. warnings,
  171. childSeq,
  172. depth + 1);
  173. }
  174. }
  175. else if (depth >= MaxBomDepth)
  176. {
  177. warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
  178. }
  179. return seq;
  180. }
  181. private async Task<long?> ResolveBomIdAsync(
  182. OrderWorkOrderGenerationService.OrderEntryLine entry,
  183. string itemNum)
  184. {
  185. var bomNumber = entry.BomNumber?.Trim();
  186. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  187. """
  188. SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
  189. FROM ic_bom
  190. WHERE tenant_id = @TenantId AND IsDeleted = 0
  191. AND (
  192. (@BomNumber <> '' AND bom_number = @BomNumber)
  193. OR (@BomNumber = '' AND item_number = @ItemNum)
  194. OR item_number = @ItemNum
  195. )
  196. ORDER BY
  197. CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
  198. Id DESC
  199. LIMIT 1
  200. """,
  201. new SugarParameter("@TenantId", entry.TenantId),
  202. new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
  203. new SugarParameter("@ItemNum", itemNum));
  204. return rows.FirstOrDefault()?.Id;
  205. }
  206. private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
  207. {
  208. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  209. """
  210. SELECT Id
  211. FROM ic_bom
  212. WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
  213. ORDER BY Id DESC
  214. LIMIT 1
  215. """,
  216. new SugarParameter("@TenantId", tenantId),
  217. new SugarParameter("@ItemNum", itemNum.Trim()));
  218. return rows.FirstOrDefault()?.Id;
  219. }
  220. private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
  221. {
  222. return await _db.Ado.SqlQueryAsync<BomChildRow>(
  223. """
  224. SELECT
  225. c.Id,
  226. c.bom_number AS BomNumber,
  227. c.item_number AS ItemNumber,
  228. c.item_name AS ItemName,
  229. IFNULL(im.Descr1, '') AS Model,
  230. c.unit AS Unit,
  231. IFNULL(c.qty, 1) AS Qty,
  232. IFNULL(c.wastage, 0) AS Wastage,
  233. IFNULL(c.scrap, 0) AS Scrap,
  234. IFNULL(c.backflush, 0) AS Backflush,
  235. IFNULL(c.type, 0) AS Type,
  236. IFNULL(c.erp_cls, 3) AS ErpCls,
  237. IFNULL(c.is_bom, 0) AS IsBom,
  238. IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
  239. c.substitute_code AS SubstituteCode
  240. FROM ic_bom_child c
  241. LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum AND (c.tenant_id = im.tenant_id OR c.tenant_id IS NULL)
  242. WHERE c.bom_id = @BomId
  243. AND c.tenant_id = @TenantId
  244. AND c.IsDeleted = 0
  245. ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
  246. """,
  247. new SugarParameter("@BomId", bomId),
  248. new SugarParameter("@TenantId", tenantId));
  249. }
  250. private async Task ApplySupplyAsync(
  251. List<ResourceCheckBomLine> lines,
  252. long tenantId,
  253. long bangId = 0,
  254. long entryId = 0,
  255. string? workOrd = null,
  256. long morderId = 0,
  257. bool writeOccupy = true,
  258. string? excludeOccupyWorkOrd = null)
  259. {
  260. var workOrdValue = string.IsNullOrWhiteSpace(workOrd) ? "" : workOrd.Trim();
  261. var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  262. var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  263. // 查询本次计算批次(bangId)已记录的库存占用和采购在途占用
  264. var occupiedStockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  265. var occupiedTransitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  266. if (bangId > 0)
  267. {
  268. // 排除指定工单(通常是本工单)自己的占用,否则同一工单重复检查会把自己的占用当别人的
  269. var excludeWo = (excludeOccupyWorkOrd ?? "").Trim();
  270. var excludeClause = excludeWo.Length == 0 ? "" : " AND IFNULL(morder_mo, '') <> @ExcludeWo";
  271. var occupyPars = new List<SugarParameter>
  272. {
  273. new("@TenantId", tenantId),
  274. new("@BangId", bangId)
  275. };
  276. if (excludeWo.Length > 0)
  277. occupyPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
  278. var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  279. $"""
  280. SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
  281. FROM ic_item_stockoccupy
  282. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
  283. GROUP BY icitem_number
  284. """,
  285. occupyPars);
  286. foreach (var r in occupiedRows)
  287. occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
  288. var transitPars = new List<SugarParameter>
  289. {
  290. new("@TenantId", tenantId),
  291. new("@BangId", bangId)
  292. };
  293. if (excludeWo.Length > 0)
  294. transitPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
  295. var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  296. $"""
  297. SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
  298. FROM srm_po_occupy
  299. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
  300. GROUP BY ItemNumber
  301. """,
  302. transitPars);
  303. foreach (var r in occupiedTransitRows)
  304. occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
  305. }
  306. // 库存基数一次性批量取:MES 权威时读 165 实时值,避免逐行跨库往返
  307. var itemNumbers = lines
  308. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNumber))
  309. .Select(x => x.ItemNumber!.Trim())
  310. .Distinct(StringComparer.OrdinalIgnoreCase)
  311. .ToList();
  312. var rawStockMap = await LoadRawStockMapAsync(itemNumbers, tenantId);
  313. foreach (var line in lines)
  314. {
  315. if (string.IsNullOrWhiteSpace(line.ItemNumber))
  316. continue;
  317. if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
  318. {
  319. var rawStock = rawStockMap.TryGetValue(line.ItemNumber, out var rs) ? rs : 0m;
  320. // 扣减已被同批次其他工单占用的库存
  321. var occupied = occupiedStockMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  322. stock = Math.Max(0m, rawStock - occupied);
  323. stockMap[line.ItemNumber] = stock;
  324. }
  325. if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
  326. {
  327. var rawTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
  328. // 扣减已被同批次其他工单占用的在途
  329. var occupied = occupiedTransitMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  330. inTransit = Math.Max(0m, rawTransit - occupied);
  331. transitMap[line.ItemNumber] = inTransit;
  332. }
  333. line.StockQty = stock;
  334. line.PurchaseOccupyQty = inTransit;
  335. // sqty = 可用库存数量(不截断)
  336. line.Sqty = RoundQty(stock);
  337. var available = RoundQty(stock + inTransit);
  338. // use_qty = 实际占用数量 = MIN(需求量, 可用总量)
  339. line.UseQty = RoundQty(Math.Min(line.NeedCount, available));
  340. line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.UseQty));
  341. line.SelfLackQty = line.LackQty;
  342. line.MoQty = 0;
  343. line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
  344. line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
  345. // 虚拟件(erp_cls=4)仅展示BOM结构,不参与库存消耗与缺料计算
  346. if (line.ErpCls == 4)
  347. {
  348. line.Sqty = 0;
  349. line.LackQty = 0;
  350. line.SelfLackQty = 0;
  351. line.UseQty = 0;
  352. line.MakeQty = 0;
  353. line.PurchaseQty = 0;
  354. continue;
  355. }
  356. var consumedFromStock = RoundQty(Math.Min(stock, line.UseQty));
  357. stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
  358. var remainingUseQty = RoundQty(Math.Max(0m, line.UseQty - consumedFromStock));
  359. transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingUseQty));
  360. }
  361. // 写入占用记录(库存占用和在途占用)。只有排产写;手动齐套检查是查询语义,只读不写
  362. if (bangId > 0 && writeOccupy)
  363. {
  364. foreach (var line in lines)
  365. {
  366. if (string.IsNullOrWhiteSpace(line.ItemNumber) || line.IsUse != 1)
  367. continue;
  368. if (line.UseQty > 0)
  369. {
  370. var stockUsed = RoundQty(Math.Min(line.StockQty, line.UseQty));
  371. if (stockUsed > 0)
  372. {
  373. await _db.Ado.ExecuteCommandAsync(
  374. """
  375. INSERT INTO ic_item_stockoccupy (
  376. Id, icitem_number, quantity, morder_mo, morder_id, bang_id,
  377. orderentry_id, tenant_id, occupy_time, create_time, update_time
  378. ) VALUES (
  379. @Id, @ItemNumber, @Qty, @WorkOrd, @MorderId, @BangId,
  380. @EntryId, @TenantId, @Now, @Now, @Now
  381. )
  382. """,
  383. new SugarParameter("@Id", YitIdHelper.NextId()),
  384. new SugarParameter("@ItemNumber", line.ItemNumber),
  385. new SugarParameter("@Qty", stockUsed),
  386. new SugarParameter("@WorkOrd", workOrdValue),
  387. new SugarParameter("@MorderId", morderId),
  388. new SugarParameter("@BangId", bangId),
  389. new SugarParameter("@EntryId", entryId),
  390. new SugarParameter("@TenantId", tenantId),
  391. new SugarParameter("@Now", DateTime.Now));
  392. }
  393. var transitUsed = RoundQty(Math.Max(0m, line.UseQty - stockUsed));
  394. if (transitUsed > 0)
  395. {
  396. await _db.Ado.ExecuteCommandAsync(
  397. """
  398. INSERT INTO srm_po_occupy (
  399. Id, ItemNumber, OccupyQty, morder_mo, morder_id, bang_id,
  400. polist_id, entry_id, tenant_id, create_time, update_time
  401. ) VALUES (
  402. @Id, @ItemNumber, @Qty, @WorkOrd, @MorderId, @BangId,
  403. @PolistId, @EntryId, @TenantId, @Now, @Now
  404. )
  405. """,
  406. new SugarParameter("@Id", YitIdHelper.NextId()),
  407. new SugarParameter("@ItemNumber", line.ItemNumber),
  408. new SugarParameter("@Qty", transitUsed),
  409. new SugarParameter("@WorkOrd", workOrdValue),
  410. new SugarParameter("@MorderId", morderId),
  411. new SugarParameter("@BangId", bangId),
  412. new SugarParameter("@PolistId", 0),
  413. new SugarParameter("@EntryId", entryId),
  414. new SugarParameter("@TenantId", tenantId),
  415. new SugarParameter("@Now", DateTime.Now));
  416. }
  417. }
  418. }
  419. }
  420. }
  421. /// <summary>
  422. /// 批量取库存基数。
  423. ///
  424. /// 默认走 165 实时(AiDOP:Stock:Source=Mes):本库 InvMaster 是 165 的一次性快照、
  425. /// 不随交易更新,用它判齐套会得出过于乐观的结论。
  426. /// 仅配置指定的租户可走 165——本库 InvMaster 含多个租户,其余租户与 165 无对应关系。
  427. /// 读 165 失败时回落本库并告警,不让齐套检查整体失败。
  428. /// </summary>
  429. private async Task<Dictionary<string, decimal>> LoadRawStockMapAsync(
  430. List<string> itemNumbers, long tenantId)
  431. {
  432. if (tenantId <= 0)
  433. throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
  434. if (itemNumbers.Count == 0)
  435. return new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  436. if (_stockOpt.IsMes && tenantId == _stockOpt.MesTenantId)
  437. {
  438. try
  439. {
  440. var mes = await _mesStock.LoadAvailableQtyAsync(
  441. itemNumbers, _stockOpt.MesDomain, _stockOpt.BatchSize);
  442. return new Dictionary<string, decimal>(
  443. mes.ToDictionary(kv => kv.Key, kv => RoundQty(kv.Value)),
  444. StringComparer.OrdinalIgnoreCase);
  445. }
  446. catch (Exception ex)
  447. {
  448. // 读 165 失败:回落本库快照,结果偏乐观,须在日志里可追溯
  449. _logger.LogWarning(ex, "齐套库存读取 165 失败,回落本库 InvMaster 快照(结果可能偏高)");
  450. }
  451. }
  452. return await LoadLocalStockMapAsync(itemNumbers, tenantId);
  453. }
  454. /// <summary>本库 InvMaster 批量口径(与 QueryStockQtyAsync 逐条口径一致)。</summary>
  455. private async Task<Dictionary<string, decimal>> LoadLocalStockMapAsync(
  456. List<string> itemNumbers, long tenantId)
  457. {
  458. var names = new List<string>(itemNumbers.Count);
  459. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  460. for (var i = 0; i < itemNumbers.Count; i++)
  461. {
  462. names.Add($"@i{i}");
  463. pars.Add(new SugarParameter($"@i{i}", itemNumbers[i]));
  464. }
  465. var rows = await _db.Ado.SqlQueryAsync<LocalStockRow>(
  466. $"""
  467. SELECT ItemNum AS ItemNum,
  468. COALESCE(SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)), 0) AS Qty
  469. FROM InvMaster
  470. WHERE tenant_id = @TenantId
  471. AND ItemNum IN ({string.Join(",", names)})
  472. GROUP BY ItemNum
  473. """,
  474. pars);
  475. var map = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  476. foreach (var r in rows)
  477. {
  478. if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
  479. map[r.ItemNum.Trim()] = RoundQty(r.Qty);
  480. }
  481. return map;
  482. }
  483. private sealed class LocalStockRow
  484. {
  485. public string? ItemNum { get; set; }
  486. public decimal Qty { get; set; }
  487. }
  488. // 保留:ApplySupplyAsync 已改为批量预取,本方法供对照/潜在调用方使用
  489. private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
  490. {
  491. if (tenantId <= 0)
  492. throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
  493. var qty = await _db.Ado.GetDecimalAsync(
  494. """
  495. SELECT COALESCE(SUM(
  496. CASE
  497. WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
  498. WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
  499. ELSE 0
  500. END
  501. ), 0)
  502. FROM InvMaster
  503. WHERE ItemNum = @ItemNum
  504. AND tenant_id = @TenantId
  505. """,
  506. new List<SugarParameter>
  507. {
  508. new("@ItemNum", itemNum),
  509. new("@TenantId", tenantId)
  510. });
  511. return RoundQty(qty);
  512. }
  513. private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
  514. {
  515. var qty = await _db.Ado.GetDecimalAsync(
  516. """
  517. SELECT COALESCE(SUM(
  518. GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
  519. ), 0)
  520. FROM PurOrdDetail d
  521. INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
  522. WHERE d.ItemNum = @ItemNum
  523. AND m.tenant_id = @TenantId
  524. AND IFNULL(d.IsActive, 1) = 1
  525. AND IFNULL(m.IsActive, 1) = 1
  526. """,
  527. new SugarParameter("@ItemNum", itemNum),
  528. new SugarParameter("@TenantId", tenantId));
  529. return RoundQty(qty);
  530. }
  531. private static decimal RoundQty(decimal value) =>
  532. Math.Round(value, 4, MidpointRounding.AwayFromZero);
  533. private static string MapErpClsName(int erpCls) => erpCls switch
  534. {
  535. 0 => "配置类",
  536. 1 => "自制",
  537. 2 => "委外加工",
  538. 3 => "外购",
  539. 4 => "虚拟件",
  540. _ => string.Empty
  541. };
  542. private sealed class BomHeaderRow
  543. {
  544. public long Id { get; set; }
  545. public string? BomNumber { get; set; }
  546. public string? ItemNumber { get; set; }
  547. }
  548. private sealed class BomChildRow
  549. {
  550. public long Id { get; set; }
  551. public string? BomNumber { get; set; }
  552. public string? ItemNumber { get; set; }
  553. public string? ItemName { get; set; }
  554. public string? Model { get; set; }
  555. public string? Unit { get; set; }
  556. public decimal Qty { get; set; }
  557. public decimal Wastage { get; set; }
  558. public decimal Scrap { get; set; }
  559. public int Backflush { get; set; }
  560. public int Type { get; set; }
  561. public int ErpCls { get; set; }
  562. public int IsBom { get; set; }
  563. public int HaveIcSubs { get; set; }
  564. public string? SubstituteCode { get; set; }
  565. }
  566. private sealed class OccupyRow
  567. {
  568. public string ItemNumber { get; set; } = string.Empty;
  569. public decimal TotalOccupied { get; set; }
  570. }
  571. }