MaterialRequirementCalculator.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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. {
  19. var itemNum = entry.ItemNumber!.Trim();
  20. var orderQty = entry.Qty ?? 0;
  21. var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
  22. var lines = new List<ResourceCheckBomLine>();
  23. var rootFid = YitIdHelper.NextId();
  24. lines.Add(new ResourceCheckBomLine
  25. {
  26. Fid = rootFid,
  27. Num = "1",
  28. Level = 1,
  29. ItemNumber = itemNum,
  30. ItemName = entry.ItemName,
  31. Model = entry.Specification,
  32. Unit = entry.Unit,
  33. BomNumber = entry.BomNumber,
  34. ErpCls = 1,
  35. ErpClsName = "自制",
  36. NeedCount = orderQty,
  37. NeedCountNoLoss = orderQty,
  38. KittingTime = needTime,
  39. SatisfyTime = needTime,
  40. IsUse = 1
  41. });
  42. var bomId = await ResolveBomIdAsync(entry, itemNum);
  43. if (bomId is null)
  44. {
  45. warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
  46. await ApplySupplyAsync(lines, entry.TenantId);
  47. return lines;
  48. }
  49. var children = await LoadBomChildrenAsync(bomId.Value, entry.TenantId);
  50. if (children.Count == 0)
  51. {
  52. warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
  53. await ApplySupplyAsync(lines, entry.TenantId);
  54. return lines;
  55. }
  56. var seq = 1;
  57. foreach (var child in children)
  58. {
  59. seq = await AppendBomChildAsync(
  60. lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
  61. }
  62. await ApplySupplyAsync(lines, entry.TenantId);
  63. return lines;
  64. }
  65. private async Task<int> AppendBomChildAsync(
  66. List<ResourceCheckBomLine> lines,
  67. long parentFid,
  68. string parentNum,
  69. int parentLevel,
  70. decimal parentNeedQty,
  71. BomChildRow child,
  72. OrderWorkOrderGenerationService.OrderEntryLine entry,
  73. DateTime needTime,
  74. List<string> warnings,
  75. int seq,
  76. int depth)
  77. {
  78. var unitQty = child.Qty <= 0 ? 1m : child.Qty;
  79. var wastageFactor = 1m + (child.Wastage / 100m);
  80. var scrapFactor = 1m + (child.Scrap / 100m);
  81. var needNoLoss = parentNeedQty * unitQty;
  82. var needCount = RoundQty(needNoLoss * wastageFactor * scrapFactor);
  83. if (needCount > 10000 && parentNeedQty > 0 && unitQty > 100)
  84. {
  85. warnings.Add($"物料 {child.ItemNumber} BOM用量({unitQty}) × 父需求({parentNeedQty}) = 需求数量({needCount}),请核实 BOM 配置");
  86. }
  87. var isVirtual = child.ErpCls == 4;
  88. var num = $"{parentNum}.{seq}";
  89. var fid = YitIdHelper.NextId();
  90. var nextSeq = seq + 1;
  91. if (!isVirtual)
  92. {
  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 = 1
  120. });
  121. }
  122. seq = nextSeq;
  123. var expandParentFid = isVirtual ? parentFid : fid;
  124. var expandParentNum = num;
  125. var expandNeed = needCount;
  126. if (child.IsBom == 1 && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
  127. {
  128. var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
  129. if (subBomId is null)
  130. {
  131. if (!isVirtual)
  132. warnings.Add($"物料 {child.ItemNumber} 标记为 BOM 但未找到子阶 BOM,按单层处理");
  133. return seq;
  134. }
  135. var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
  136. if (grandchildren.Count == 0)
  137. {
  138. warnings.Add($"物料 {child.ItemNumber} 子阶 BOM 无明细");
  139. return seq;
  140. }
  141. var childSeq = 1;
  142. foreach (var gc in grandchildren)
  143. {
  144. childSeq = await AppendBomChildAsync(
  145. lines,
  146. expandParentFid,
  147. expandParentNum,
  148. parentLevel + 1,
  149. expandNeed,
  150. gc,
  151. entry,
  152. needTime,
  153. warnings,
  154. childSeq,
  155. depth + 1);
  156. }
  157. }
  158. else if (child.IsBom == 1 && depth >= MaxBomDepth)
  159. {
  160. warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
  161. }
  162. return seq;
  163. }
  164. private async Task<long?> ResolveBomIdAsync(
  165. OrderWorkOrderGenerationService.OrderEntryLine entry,
  166. string itemNum)
  167. {
  168. var bomNumber = entry.BomNumber?.Trim();
  169. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  170. """
  171. SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
  172. FROM ic_bom
  173. WHERE tenant_id = @TenantId AND IsDeleted = 0
  174. AND (
  175. (@BomNumber <> '' AND bom_number = @BomNumber)
  176. OR (@BomNumber = '' AND item_number = @ItemNum)
  177. OR item_number = @ItemNum
  178. )
  179. ORDER BY
  180. CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
  181. Id DESC
  182. LIMIT 1
  183. """,
  184. new SugarParameter("@TenantId", entry.TenantId),
  185. new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
  186. new SugarParameter("@ItemNum", itemNum));
  187. return rows.FirstOrDefault()?.Id;
  188. }
  189. private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
  190. {
  191. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  192. """
  193. SELECT Id
  194. FROM ic_bom
  195. WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
  196. ORDER BY Id DESC
  197. LIMIT 1
  198. """,
  199. new SugarParameter("@TenantId", tenantId),
  200. new SugarParameter("@ItemNum", itemNum.Trim()));
  201. return rows.FirstOrDefault()?.Id;
  202. }
  203. private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
  204. {
  205. return await _db.Ado.SqlQueryAsync<BomChildRow>(
  206. """
  207. SELECT
  208. c.Id,
  209. c.bom_number AS BomNumber,
  210. c.item_number AS ItemNumber,
  211. c.item_name AS ItemName,
  212. IFNULL(im.Descr1, '') AS Model,
  213. c.unit AS Unit,
  214. IFNULL(c.qty, 1) AS Qty,
  215. IFNULL(c.wastage, 0) AS Wastage,
  216. IFNULL(c.scrap, 0) AS Scrap,
  217. IFNULL(c.backflush, 0) AS Backflush,
  218. IFNULL(c.type, 0) AS Type,
  219. IFNULL(c.erp_cls, 3) AS ErpCls,
  220. IFNULL(c.is_bom, 0) AS IsBom,
  221. IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
  222. c.substitute_code AS SubstituteCode
  223. FROM ic_bom_child c
  224. LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum
  225. WHERE c.bom_id = @BomId
  226. AND c.tenant_id = @TenantId
  227. AND c.IsDeleted = 0
  228. ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
  229. """,
  230. new SugarParameter("@BomId", bomId),
  231. new SugarParameter("@TenantId", tenantId));
  232. }
  233. private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId)
  234. {
  235. var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  236. var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  237. foreach (var line in lines)
  238. {
  239. if (string.IsNullOrWhiteSpace(line.ItemNumber))
  240. continue;
  241. if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
  242. {
  243. stock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
  244. stockMap[line.ItemNumber] = stock;
  245. }
  246. if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
  247. {
  248. inTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
  249. transitMap[line.ItemNumber] = inTransit;
  250. }
  251. line.StockQty = stock;
  252. line.PurchaseOccupyQty = inTransit;
  253. var available = RoundQty(stock + inTransit);
  254. line.Sqty = RoundQty(Math.Min(line.NeedCount, available));
  255. line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.Sqty));
  256. line.SelfLackQty = line.LackQty;
  257. line.UseQty = line.Sqty;
  258. line.MoQty = 0;
  259. line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
  260. line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
  261. var consumedFromStock = RoundQty(Math.Min(stock, line.Sqty));
  262. stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
  263. var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
  264. transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
  265. }
  266. }
  267. private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
  268. {
  269. var qty = await _db.Ado.GetDecimalAsync(
  270. """
  271. SELECT COALESCE(SUM(
  272. CASE
  273. WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
  274. WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
  275. ELSE 0
  276. END
  277. ), 0)
  278. FROM InvMaster
  279. WHERE ItemNum = @ItemNum
  280. AND (tenant_id = @TenantId OR @TenantId = 0)
  281. """,
  282. new List<SugarParameter>
  283. {
  284. new("@ItemNum", itemNum),
  285. new("@TenantId", tenantId)
  286. });
  287. return RoundQty(qty);
  288. }
  289. private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
  290. {
  291. var qty = await _db.Ado.GetDecimalAsync(
  292. """
  293. SELECT COALESCE(SUM(
  294. GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
  295. ), 0)
  296. FROM PurOrdDetail d
  297. INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
  298. WHERE d.ItemNum = @ItemNum
  299. AND m.tenant_id = @TenantId
  300. AND IFNULL(d.IsActive, 1) = 1
  301. AND IFNULL(m.IsActive, 1) = 1
  302. """,
  303. new SugarParameter("@ItemNum", itemNum),
  304. new SugarParameter("@TenantId", tenantId));
  305. return RoundQty(qty);
  306. }
  307. private static decimal RoundQty(decimal value) =>
  308. Math.Round(value, 4, MidpointRounding.AwayFromZero);
  309. private static string MapErpClsName(int erpCls) => erpCls switch
  310. {
  311. 0 => "配置类",
  312. 1 => "自制",
  313. 2 => "委外加工",
  314. 3 => "外购",
  315. 4 => "虚拟件",
  316. _ => string.Empty
  317. };
  318. private sealed class BomHeaderRow
  319. {
  320. public long Id { get; set; }
  321. public string? BomNumber { get; set; }
  322. public string? ItemNumber { get; set; }
  323. }
  324. private sealed class BomChildRow
  325. {
  326. public long Id { get; set; }
  327. public string? BomNumber { get; set; }
  328. public string? ItemNumber { get; set; }
  329. public string? ItemName { get; set; }
  330. public string? Model { get; set; }
  331. public string? Unit { get; set; }
  332. public decimal Qty { get; set; }
  333. public decimal Wastage { get; set; }
  334. public decimal Scrap { get; set; }
  335. public int Backflush { get; set; }
  336. public int Type { get; set; }
  337. public int ErpCls { get; set; }
  338. public int IsBom { get; set; }
  339. public int HaveIcSubs { get; set; }
  340. public string? SubstituteCode { get; set; }
  341. }
  342. }