MaterialRequirementCalculator.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. using Yitter.IdGenerator;
  2. namespace Admin.NET.Plugin.AiDOP.Order;
  3. /// <summary>
  4. /// 订单评审资源检查:多层 BOM 展开与库存/采购在途缺口计算。
  5. /// </summary>
  6. public class MaterialRequirementCalculator : ITransient
  7. {
  8. private const int MaxBomDepth = 6;
  9. private readonly ISqlSugarClient _db;
  10. public MaterialRequirementCalculator(ISqlSugarClient db)
  11. {
  12. _db = db;
  13. }
  14. public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
  15. OrderWorkOrderGenerationService.OrderHeader order,
  16. OrderWorkOrderGenerationService.OrderEntryLine entry,
  17. List<string> warnings,
  18. long bangId = 0)
  19. {
  20. var itemNum = entry.ItemNumber!.Trim();
  21. var orderQty = entry.Qty ?? 0;
  22. var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
  23. var lines = new List<ResourceCheckBomLine>();
  24. var rootFid = YitIdHelper.NextId();
  25. lines.Add(new ResourceCheckBomLine
  26. {
  27. Fid = rootFid,
  28. Num = "1",
  29. Level = 1,
  30. ItemNumber = itemNum,
  31. ItemName = entry.ItemName,
  32. Model = entry.Specification,
  33. Unit = entry.Unit,
  34. BomNumber = entry.BomNumber,
  35. ErpCls = 1,
  36. ErpClsName = "自制",
  37. NeedCount = orderQty,
  38. NeedCountNoLoss = orderQty,
  39. KittingTime = needTime,
  40. SatisfyTime = needTime,
  41. IsUse = 1
  42. });
  43. var bomId = await ResolveBomIdAsync(entry, itemNum);
  44. if (bomId is null)
  45. {
  46. warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
  47. await ApplySupplyAsync(lines, entry.TenantId, bangId);
  48. return lines;
  49. }
  50. var children = await LoadBomChildrenAsync(bomId.Value, entry.TenantId);
  51. if (children.Count == 0)
  52. {
  53. warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
  54. await ApplySupplyAsync(lines, entry.TenantId, bangId);
  55. return lines;
  56. }
  57. var seq = 1;
  58. foreach (var child in children)
  59. {
  60. seq = await AppendBomChildAsync(
  61. lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
  62. }
  63. await ApplySupplyAsync(lines, entry.TenantId, bangId);
  64. return lines;
  65. }
  66. private async Task<int> AppendBomChildAsync(
  67. List<ResourceCheckBomLine> lines,
  68. long parentFid,
  69. string parentNum,
  70. int parentLevel,
  71. decimal parentNeedQty,
  72. BomChildRow child,
  73. OrderWorkOrderGenerationService.OrderEntryLine entry,
  74. DateTime needTime,
  75. List<string> warnings,
  76. int seq,
  77. int depth)
  78. {
  79. var unitQty = child.Qty <= 0 ? 1m : child.Qty;
  80. var wastageFactor = 1m + (child.Wastage / 100m);
  81. var scrapFactor = 1m + (child.Scrap / 100m);
  82. var needNoLoss = parentNeedQty * unitQty;
  83. var needCount = RoundQty(needNoLoss * wastageFactor * scrapFactor);
  84. if (needCount > 10000 && parentNeedQty > 0 && unitQty > 100)
  85. {
  86. warnings.Add($"物料 {child.ItemNumber} BOM用量({unitQty}) × 父需求({parentNeedQty}) = 需求数量({needCount}),请核实 BOM 配置");
  87. }
  88. var isVirtual = child.ErpCls == 4;
  89. var num = $"{parentNum}.{seq}";
  90. var fid = YitIdHelper.NextId();
  91. var nextSeq = seq + 1;
  92. if (!isVirtual)
  93. {
  94. lines.Add(new ResourceCheckBomLine
  95. {
  96. Fid = fid,
  97. ParentFid = parentFid,
  98. BomChildId = child.Id,
  99. Num = num,
  100. Level = parentLevel + 1,
  101. Type = child.Type,
  102. ItemNumber = child.ItemNumber ?? string.Empty,
  103. ItemName = child.ItemName,
  104. Model = child.Model,
  105. Unit = child.Unit,
  106. BomNumber = child.BomNumber,
  107. ErpCls = child.ErpCls,
  108. ErpClsName = MapErpClsName(child.ErpCls),
  109. Backflush = child.Backflush,
  110. Qty = unitQty,
  111. Wastage = child.Wastage,
  112. Scrap = child.Scrap,
  113. NeedCount = needCount,
  114. NeedCountNoLoss = RoundQty(needNoLoss),
  115. KittingTime = needTime,
  116. SatisfyTime = needTime,
  117. IsBom = child.IsBom,
  118. HaveIcSubs = child.HaveIcSubs,
  119. SubstituteCode = child.SubstituteCode,
  120. IsUse = 1
  121. });
  122. }
  123. seq = nextSeq;
  124. var expandParentFid = isVirtual ? parentFid : fid;
  125. var expandParentNum = num;
  126. var expandNeed = needCount;
  127. if (child.IsBom == 1 && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
  128. {
  129. var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
  130. if (subBomId is null)
  131. {
  132. if (!isVirtual)
  133. warnings.Add($"物料 {child.ItemNumber} 标记为 BOM 但未找到子阶 BOM,按单层处理");
  134. return seq;
  135. }
  136. var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
  137. if (grandchildren.Count == 0)
  138. {
  139. warnings.Add($"物料 {child.ItemNumber} 子阶 BOM 无明细");
  140. return seq;
  141. }
  142. var childSeq = 1;
  143. foreach (var gc in grandchildren)
  144. {
  145. childSeq = await AppendBomChildAsync(
  146. lines,
  147. expandParentFid,
  148. expandParentNum,
  149. parentLevel + 1,
  150. expandNeed,
  151. gc,
  152. entry,
  153. needTime,
  154. warnings,
  155. childSeq,
  156. depth + 1);
  157. }
  158. }
  159. else if (child.IsBom == 1 && depth >= MaxBomDepth)
  160. {
  161. warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
  162. }
  163. return seq;
  164. }
  165. private async Task<long?> ResolveBomIdAsync(
  166. OrderWorkOrderGenerationService.OrderEntryLine entry,
  167. string itemNum)
  168. {
  169. var bomNumber = entry.BomNumber?.Trim();
  170. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  171. """
  172. SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
  173. FROM ic_bom
  174. WHERE tenant_id = @TenantId AND IsDeleted = 0
  175. AND (
  176. (@BomNumber <> '' AND bom_number = @BomNumber)
  177. OR (@BomNumber = '' AND item_number = @ItemNum)
  178. OR item_number = @ItemNum
  179. )
  180. ORDER BY
  181. CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
  182. Id DESC
  183. LIMIT 1
  184. """,
  185. new SugarParameter("@TenantId", entry.TenantId),
  186. new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
  187. new SugarParameter("@ItemNum", itemNum));
  188. return rows.FirstOrDefault()?.Id;
  189. }
  190. private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
  191. {
  192. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  193. """
  194. SELECT Id
  195. FROM ic_bom
  196. WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
  197. ORDER BY Id DESC
  198. LIMIT 1
  199. """,
  200. new SugarParameter("@TenantId", tenantId),
  201. new SugarParameter("@ItemNum", itemNum.Trim()));
  202. return rows.FirstOrDefault()?.Id;
  203. }
  204. private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
  205. {
  206. return await _db.Ado.SqlQueryAsync<BomChildRow>(
  207. """
  208. SELECT
  209. c.Id,
  210. c.bom_number AS BomNumber,
  211. c.item_number AS ItemNumber,
  212. c.item_name AS ItemName,
  213. IFNULL(im.Descr1, '') AS Model,
  214. c.unit AS Unit,
  215. IFNULL(c.qty, 1) AS Qty,
  216. IFNULL(c.wastage, 0) AS Wastage,
  217. IFNULL(c.scrap, 0) AS Scrap,
  218. IFNULL(c.backflush, 0) AS Backflush,
  219. IFNULL(c.type, 0) AS Type,
  220. IFNULL(c.erp_cls, 3) AS ErpCls,
  221. IFNULL(c.is_bom, 0) AS IsBom,
  222. IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
  223. c.substitute_code AS SubstituteCode
  224. FROM ic_bom_child c
  225. LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum
  226. WHERE c.bom_id = @BomId
  227. AND c.tenant_id = @TenantId
  228. AND c.IsDeleted = 0
  229. ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
  230. """,
  231. new SugarParameter("@BomId", bomId),
  232. new SugarParameter("@TenantId", tenantId));
  233. }
  234. private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId, long bangId = 0)
  235. {
  236. var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  237. var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  238. // 查询本次计算批次(bangId)已记录的库存占用和采购在途占用
  239. var occupiedStockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  240. var occupiedTransitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  241. if (bangId > 0)
  242. {
  243. var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  244. """
  245. SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
  246. FROM ic_item_stockoccupy
  247. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
  248. GROUP BY icitem_number
  249. """,
  250. new SugarParameter("@TenantId", tenantId),
  251. new SugarParameter("@BangId", bangId));
  252. foreach (var r in occupiedRows)
  253. occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
  254. var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  255. """
  256. SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
  257. FROM srm_po_occupy
  258. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
  259. GROUP BY ItemNumber
  260. """,
  261. new SugarParameter("@TenantId", tenantId),
  262. new SugarParameter("@BangId", bangId));
  263. foreach (var r in occupiedTransitRows)
  264. occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
  265. }
  266. foreach (var line in lines)
  267. {
  268. if (string.IsNullOrWhiteSpace(line.ItemNumber))
  269. continue;
  270. if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
  271. {
  272. var rawStock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
  273. // 扣减已被同批次其他工单占用的库存
  274. var occupied = occupiedStockMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  275. stock = Math.Max(0m, rawStock - occupied);
  276. stockMap[line.ItemNumber] = stock;
  277. }
  278. if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
  279. {
  280. var rawTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
  281. // 扣减已被同批次其他工单占用的在途
  282. var occupied = occupiedTransitMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  283. inTransit = Math.Max(0m, rawTransit - occupied);
  284. transitMap[line.ItemNumber] = inTransit;
  285. }
  286. line.StockQty = stock;
  287. line.PurchaseOccupyQty = inTransit;
  288. var available = RoundQty(stock + inTransit);
  289. line.Sqty = RoundQty(Math.Min(line.NeedCount, available));
  290. line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.Sqty));
  291. line.SelfLackQty = line.LackQty;
  292. line.UseQty = line.Sqty;
  293. line.MoQty = 0;
  294. line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
  295. line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
  296. var consumedFromStock = RoundQty(Math.Min(stock, line.Sqty));
  297. stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
  298. var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
  299. transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
  300. }
  301. // 写入占用记录(库存占用和在途占用)
  302. if (bangId > 0)
  303. {
  304. foreach (var line in lines)
  305. {
  306. if (string.IsNullOrWhiteSpace(line.ItemNumber) || line.IsUse != 1)
  307. continue;
  308. if (line.UseQty > 0)
  309. {
  310. var stockUsed = RoundQty(Math.Min(line.StockQty, line.UseQty));
  311. if (stockUsed > 0)
  312. {
  313. await _db.Ado.ExecuteCommandAsync(
  314. """
  315. INSERT INTO ic_item_stockoccupy (
  316. Id, icitem_number, quantity, morder_mo, bang_id,
  317. tenant_id, occupy_time, create_time, update_time
  318. ) VALUES (
  319. @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
  320. @TenantId, @Now, @Now, @Now
  321. )
  322. """,
  323. new SugarParameter("@Id", YitIdHelper.NextId()),
  324. new SugarParameter("@ItemNumber", line.ItemNumber),
  325. new SugarParameter("@Qty", stockUsed),
  326. new SugarParameter("@WorkOrd", ""),
  327. new SugarParameter("@BangId", bangId),
  328. new SugarParameter("@TenantId", tenantId),
  329. new SugarParameter("@Now", DateTime.Now));
  330. }
  331. var transitUsed = RoundQty(Math.Max(0m, line.UseQty - stockUsed));
  332. if (transitUsed > 0)
  333. {
  334. await _db.Ado.ExecuteCommandAsync(
  335. """
  336. INSERT INTO srm_po_occupy (
  337. Id, ItemNumber, OccupyQty, morder_mo, bang_id,
  338. tenant_id, create_time, update_time
  339. ) VALUES (
  340. @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
  341. @TenantId, @Now, @Now
  342. )
  343. """,
  344. new SugarParameter("@Id", YitIdHelper.NextId()),
  345. new SugarParameter("@ItemNumber", line.ItemNumber),
  346. new SugarParameter("@Qty", transitUsed),
  347. new SugarParameter("@WorkOrd", ""),
  348. new SugarParameter("@BangId", bangId),
  349. new SugarParameter("@TenantId", tenantId),
  350. new SugarParameter("@Now", DateTime.Now));
  351. }
  352. }
  353. }
  354. }
  355. }
  356. private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
  357. {
  358. var qty = await _db.Ado.GetDecimalAsync(
  359. """
  360. SELECT COALESCE(SUM(
  361. CASE
  362. WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
  363. WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
  364. ELSE 0
  365. END
  366. ), 0)
  367. FROM InvMaster
  368. WHERE ItemNum = @ItemNum
  369. AND (tenant_id = @TenantId OR @TenantId = 0)
  370. """,
  371. new List<SugarParameter>
  372. {
  373. new("@ItemNum", itemNum),
  374. new("@TenantId", tenantId)
  375. });
  376. return RoundQty(qty);
  377. }
  378. private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
  379. {
  380. var qty = await _db.Ado.GetDecimalAsync(
  381. """
  382. SELECT COALESCE(SUM(
  383. GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
  384. ), 0)
  385. FROM PurOrdDetail d
  386. INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
  387. WHERE d.ItemNum = @ItemNum
  388. AND m.tenant_id = @TenantId
  389. AND IFNULL(d.IsActive, 1) = 1
  390. AND IFNULL(m.IsActive, 1) = 1
  391. """,
  392. new SugarParameter("@ItemNum", itemNum),
  393. new SugarParameter("@TenantId", tenantId));
  394. return RoundQty(qty);
  395. }
  396. private static decimal RoundQty(decimal value) =>
  397. Math.Round(value, 4, MidpointRounding.AwayFromZero);
  398. private static string MapErpClsName(int erpCls) => erpCls switch
  399. {
  400. 0 => "配置类",
  401. 1 => "自制",
  402. 2 => "委外加工",
  403. 3 => "外购",
  404. 4 => "虚拟件",
  405. _ => string.Empty
  406. };
  407. private sealed class BomHeaderRow
  408. {
  409. public long Id { get; set; }
  410. public string? BomNumber { get; set; }
  411. public string? ItemNumber { get; set; }
  412. }
  413. private sealed class BomChildRow
  414. {
  415. public long Id { get; set; }
  416. public string? BomNumber { get; set; }
  417. public string? ItemNumber { get; set; }
  418. public string? ItemName { get; set; }
  419. public string? Model { get; set; }
  420. public string? Unit { get; set; }
  421. public decimal Qty { get; set; }
  422. public decimal Wastage { get; set; }
  423. public decimal Scrap { get; set; }
  424. public int Backflush { get; set; }
  425. public int Type { get; set; }
  426. public int ErpCls { get; set; }
  427. public int IsBom { get; set; }
  428. public int HaveIcSubs { get; set; }
  429. public string? SubstituteCode { get; set; }
  430. }
  431. private sealed class OccupyRow
  432. {
  433. public string ItemNumber { get; set; } = string.Empty;
  434. public decimal TotalOccupied { get; set; }
  435. }
  436. }