MaterialRequirementCalculator.cs 31 KB

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