MaterialRequirementCalculator.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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 = 20;
  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, entry.Id);
  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, entry.Id);
  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, entry.Id);
  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. // 虚拟件(erp_cls=4)也写入资源检查记录,IsUse=0 标识为虚拟件,不参与缺料计算
  93. lines.Add(new ResourceCheckBomLine
  94. {
  95. Fid = fid,
  96. ParentFid = parentFid,
  97. BomChildId = child.Id,
  98. Num = num,
  99. Level = parentLevel + 1,
  100. Type = child.Type,
  101. ItemNumber = child.ItemNumber ?? string.Empty,
  102. ItemName = child.ItemName,
  103. Model = child.Model,
  104. Unit = child.Unit,
  105. BomNumber = child.BomNumber,
  106. ErpCls = child.ErpCls,
  107. ErpClsName = MapErpClsName(child.ErpCls),
  108. Backflush = child.Backflush,
  109. Qty = unitQty,
  110. Wastage = child.Wastage,
  111. Scrap = child.Scrap,
  112. NeedCount = needCount,
  113. NeedCountNoLoss = RoundQty(needNoLoss),
  114. KittingTime = needTime,
  115. SatisfyTime = needTime,
  116. IsBom = child.IsBom,
  117. HaveIcSubs = child.HaveIcSubs,
  118. SubstituteCode = child.SubstituteCode,
  119. IsUse = isVirtual ? 0 : 1
  120. });
  121. seq = nextSeq;
  122. var expandParentFid = isVirtual ? parentFid : fid;
  123. var expandParentNum = num;
  124. var expandNeed = needCount;
  125. // 仅虚拟件(erp_cls=4)继续向下展开子阶BOM,其他类型作为叶子节点不再下钻
  126. if (isVirtual && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
  127. {
  128. var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
  129. if (subBomId is null)
  130. return seq;
  131. var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
  132. if (grandchildren.Count == 0)
  133. return seq;
  134. var childSeq = 1;
  135. foreach (var gc in grandchildren)
  136. {
  137. childSeq = await AppendBomChildAsync(
  138. lines,
  139. expandParentFid,
  140. expandParentNum,
  141. parentLevel + 1,
  142. expandNeed,
  143. gc,
  144. entry,
  145. needTime,
  146. warnings,
  147. childSeq,
  148. depth + 1);
  149. }
  150. }
  151. else if (depth >= MaxBomDepth)
  152. {
  153. warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
  154. }
  155. return seq;
  156. }
  157. private async Task<long?> ResolveBomIdAsync(
  158. OrderWorkOrderGenerationService.OrderEntryLine entry,
  159. string itemNum)
  160. {
  161. var bomNumber = entry.BomNumber?.Trim();
  162. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  163. """
  164. SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
  165. FROM ic_bom
  166. WHERE tenant_id = @TenantId AND IsDeleted = 0
  167. AND (
  168. (@BomNumber <> '' AND bom_number = @BomNumber)
  169. OR (@BomNumber = '' AND item_number = @ItemNum)
  170. OR item_number = @ItemNum
  171. )
  172. ORDER BY
  173. CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
  174. Id DESC
  175. LIMIT 1
  176. """,
  177. new SugarParameter("@TenantId", entry.TenantId),
  178. new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
  179. new SugarParameter("@ItemNum", itemNum));
  180. return rows.FirstOrDefault()?.Id;
  181. }
  182. private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
  183. {
  184. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  185. """
  186. SELECT Id
  187. FROM ic_bom
  188. WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
  189. ORDER BY Id DESC
  190. LIMIT 1
  191. """,
  192. new SugarParameter("@TenantId", tenantId),
  193. new SugarParameter("@ItemNum", itemNum.Trim()));
  194. return rows.FirstOrDefault()?.Id;
  195. }
  196. private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
  197. {
  198. return await _db.Ado.SqlQueryAsync<BomChildRow>(
  199. """
  200. SELECT
  201. c.Id,
  202. c.bom_number AS BomNumber,
  203. c.item_number AS ItemNumber,
  204. c.item_name AS ItemName,
  205. IFNULL(im.Descr1, '') AS Model,
  206. c.unit AS Unit,
  207. IFNULL(c.qty, 1) AS Qty,
  208. IFNULL(c.wastage, 0) AS Wastage,
  209. IFNULL(c.scrap, 0) AS Scrap,
  210. IFNULL(c.backflush, 0) AS Backflush,
  211. IFNULL(c.type, 0) AS Type,
  212. IFNULL(c.erp_cls, 3) AS ErpCls,
  213. IFNULL(c.is_bom, 0) AS IsBom,
  214. IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
  215. c.substitute_code AS SubstituteCode
  216. FROM ic_bom_child c
  217. LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum
  218. WHERE c.bom_id = @BomId
  219. AND c.tenant_id = @TenantId
  220. AND c.IsDeleted = 0
  221. ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
  222. """,
  223. new SugarParameter("@BomId", bomId),
  224. new SugarParameter("@TenantId", tenantId));
  225. }
  226. private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId, long bangId = 0, long entryId = 0)
  227. {
  228. var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  229. var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  230. // 查询本次计算批次(bangId)已记录的库存占用和采购在途占用
  231. var occupiedStockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  232. var occupiedTransitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  233. if (bangId > 0)
  234. {
  235. var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  236. """
  237. SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
  238. FROM ic_item_stockoccupy
  239. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
  240. GROUP BY icitem_number
  241. """,
  242. new SugarParameter("@TenantId", tenantId),
  243. new SugarParameter("@BangId", bangId));
  244. foreach (var r in occupiedRows)
  245. occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
  246. var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  247. """
  248. SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
  249. FROM srm_po_occupy
  250. WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
  251. GROUP BY ItemNumber
  252. """,
  253. new SugarParameter("@TenantId", tenantId),
  254. new SugarParameter("@BangId", bangId));
  255. foreach (var r in occupiedTransitRows)
  256. occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
  257. }
  258. foreach (var line in lines)
  259. {
  260. if (string.IsNullOrWhiteSpace(line.ItemNumber))
  261. continue;
  262. if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
  263. {
  264. var rawStock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
  265. // 扣减已被同批次其他工单占用的库存
  266. var occupied = occupiedStockMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  267. stock = Math.Max(0m, rawStock - occupied);
  268. stockMap[line.ItemNumber] = stock;
  269. }
  270. if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
  271. {
  272. var rawTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
  273. // 扣减已被同批次其他工单占用的在途
  274. var occupied = occupiedTransitMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
  275. inTransit = Math.Max(0m, rawTransit - occupied);
  276. transitMap[line.ItemNumber] = inTransit;
  277. }
  278. line.StockQty = stock;
  279. line.PurchaseOccupyQty = inTransit;
  280. var available = RoundQty(stock + inTransit);
  281. line.Sqty = RoundQty(Math.Min(line.NeedCount, available));
  282. line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.Sqty));
  283. line.SelfLackQty = line.LackQty;
  284. line.UseQty = line.Sqty;
  285. line.MoQty = 0;
  286. line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
  287. line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
  288. // 虚拟件(erp_cls=4)仅展示BOM结构,不参与库存消耗与缺料计算
  289. if (line.ErpCls == 4)
  290. {
  291. line.Sqty = 0;
  292. line.LackQty = 0;
  293. line.SelfLackQty = 0;
  294. line.UseQty = 0;
  295. line.MakeQty = 0;
  296. line.PurchaseQty = 0;
  297. continue;
  298. }
  299. var consumedFromStock = RoundQty(Math.Min(stock, line.Sqty));
  300. stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
  301. var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
  302. transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
  303. }
  304. // 写入占用记录(库存占用和在途占用)
  305. if (bangId > 0)
  306. {
  307. foreach (var line in lines)
  308. {
  309. if (string.IsNullOrWhiteSpace(line.ItemNumber) || line.IsUse != 1)
  310. continue;
  311. if (line.UseQty > 0)
  312. {
  313. var stockUsed = RoundQty(Math.Min(line.StockQty, line.UseQty));
  314. if (stockUsed > 0)
  315. {
  316. await _db.Ado.ExecuteCommandAsync(
  317. """
  318. INSERT INTO ic_item_stockoccupy (
  319. Id, icitem_number, quantity, morder_mo, bang_id,
  320. orderentry_id, tenant_id, occupy_time, create_time, update_time
  321. ) VALUES (
  322. @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
  323. @EntryId, @TenantId, @Now, @Now, @Now
  324. )
  325. """,
  326. new SugarParameter("@Id", YitIdHelper.NextId()),
  327. new SugarParameter("@ItemNumber", line.ItemNumber),
  328. new SugarParameter("@Qty", stockUsed),
  329. new SugarParameter("@WorkOrd", ""),
  330. new SugarParameter("@BangId", bangId),
  331. new SugarParameter("@EntryId", entryId),
  332. new SugarParameter("@TenantId", tenantId),
  333. new SugarParameter("@Now", DateTime.Now));
  334. }
  335. var transitUsed = RoundQty(Math.Max(0m, line.UseQty - stockUsed));
  336. if (transitUsed > 0)
  337. {
  338. await _db.Ado.ExecuteCommandAsync(
  339. """
  340. INSERT INTO srm_po_occupy (
  341. Id, ItemNumber, OccupyQty, morder_mo, bang_id,
  342. polist_id, entry_id, tenant_id, create_time, update_time
  343. ) VALUES (
  344. @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
  345. @PolistId, @EntryId, @TenantId, @Now, @Now
  346. )
  347. """,
  348. new SugarParameter("@Id", YitIdHelper.NextId()),
  349. new SugarParameter("@ItemNumber", line.ItemNumber),
  350. new SugarParameter("@Qty", transitUsed),
  351. new SugarParameter("@WorkOrd", ""),
  352. new SugarParameter("@BangId", bangId),
  353. new SugarParameter("@PolistId", 0),
  354. new SugarParameter("@EntryId", entryId),
  355. new SugarParameter("@TenantId", tenantId),
  356. new SugarParameter("@Now", DateTime.Now));
  357. }
  358. }
  359. }
  360. }
  361. }
  362. private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
  363. {
  364. var qty = await _db.Ado.GetDecimalAsync(
  365. """
  366. SELECT COALESCE(SUM(
  367. CASE
  368. WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
  369. WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
  370. ELSE 0
  371. END
  372. ), 0)
  373. FROM InvMaster
  374. WHERE ItemNum = @ItemNum
  375. AND (tenant_id = @TenantId OR @TenantId = 0)
  376. """,
  377. new List<SugarParameter>
  378. {
  379. new("@ItemNum", itemNum),
  380. new("@TenantId", tenantId)
  381. });
  382. return RoundQty(qty);
  383. }
  384. private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
  385. {
  386. var qty = await _db.Ado.GetDecimalAsync(
  387. """
  388. SELECT COALESCE(SUM(
  389. GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
  390. ), 0)
  391. FROM PurOrdDetail d
  392. INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
  393. WHERE d.ItemNum = @ItemNum
  394. AND m.tenant_id = @TenantId
  395. AND IFNULL(d.IsActive, 1) = 1
  396. AND IFNULL(m.IsActive, 1) = 1
  397. """,
  398. new SugarParameter("@ItemNum", itemNum),
  399. new SugarParameter("@TenantId", tenantId));
  400. return RoundQty(qty);
  401. }
  402. private static decimal RoundQty(decimal value) =>
  403. Math.Round(value, 4, MidpointRounding.AwayFromZero);
  404. private static string MapErpClsName(int erpCls) => erpCls switch
  405. {
  406. 0 => "配置类",
  407. 1 => "自制",
  408. 2 => "委外加工",
  409. 3 => "外购",
  410. 4 => "虚拟件",
  411. _ => string.Empty
  412. };
  413. private sealed class BomHeaderRow
  414. {
  415. public long Id { get; set; }
  416. public string? BomNumber { get; set; }
  417. public string? ItemNumber { get; set; }
  418. }
  419. private sealed class BomChildRow
  420. {
  421. public long Id { get; set; }
  422. public string? BomNumber { get; set; }
  423. public string? ItemNumber { get; set; }
  424. public string? ItemName { get; set; }
  425. public string? Model { get; set; }
  426. public string? Unit { get; set; }
  427. public decimal Qty { get; set; }
  428. public decimal Wastage { get; set; }
  429. public decimal Scrap { get; set; }
  430. public int Backflush { get; set; }
  431. public int Type { get; set; }
  432. public int ErpCls { get; set; }
  433. public int IsBom { get; set; }
  434. public int HaveIcSubs { get; set; }
  435. public string? SubstituteCode { get; set; }
  436. }
  437. private sealed class OccupyRow
  438. {
  439. public string ItemNumber { get; set; } = string.Empty;
  440. public decimal TotalOccupied { get; set; }
  441. }
  442. }