MaterialRequirementCalculator.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. var isVirtual = child.ErpCls == 4;
  84. var num = $"{parentNum}.{seq}";
  85. var fid = YitIdHelper.NextId();
  86. var nextSeq = seq + 1;
  87. if (!isVirtual)
  88. {
  89. lines.Add(new ResourceCheckBomLine
  90. {
  91. Fid = fid,
  92. ParentFid = parentFid,
  93. BomChildId = child.Id,
  94. Num = num,
  95. Level = parentLevel + 1,
  96. Type = child.Type,
  97. ItemNumber = child.ItemNumber ?? string.Empty,
  98. ItemName = child.ItemName,
  99. Model = child.Model,
  100. Unit = child.Unit,
  101. BomNumber = child.BomNumber,
  102. ErpCls = child.ErpCls,
  103. ErpClsName = MapErpClsName(child.ErpCls),
  104. Backflush = child.Backflush,
  105. Qty = unitQty,
  106. Wastage = child.Wastage,
  107. Scrap = child.Scrap,
  108. NeedCount = needCount,
  109. NeedCountNoLoss = RoundQty(needNoLoss),
  110. KittingTime = needTime,
  111. SatisfyTime = needTime,
  112. IsBom = child.IsBom,
  113. HaveIcSubs = child.HaveIcSubs,
  114. SubstituteCode = child.SubstituteCode,
  115. IsUse = 1
  116. });
  117. }
  118. seq = nextSeq;
  119. var expandParentFid = isVirtual ? parentFid : fid;
  120. var expandParentNum = num;
  121. var expandNeed = needCount;
  122. if (child.IsBom == 1 && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
  123. {
  124. var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
  125. if (subBomId is null)
  126. {
  127. if (!isVirtual)
  128. warnings.Add($"物料 {child.ItemNumber} 标记为 BOM 但未找到子阶 BOM,按单层处理");
  129. return seq;
  130. }
  131. var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
  132. if (grandchildren.Count == 0)
  133. {
  134. warnings.Add($"物料 {child.ItemNumber} 子阶 BOM 无明细");
  135. return seq;
  136. }
  137. var childSeq = 1;
  138. foreach (var gc in grandchildren)
  139. {
  140. childSeq = await AppendBomChildAsync(
  141. lines,
  142. expandParentFid,
  143. expandParentNum,
  144. parentLevel + 1,
  145. expandNeed,
  146. gc,
  147. entry,
  148. needTime,
  149. warnings,
  150. childSeq,
  151. depth + 1);
  152. }
  153. }
  154. else if (child.IsBom == 1 && depth >= MaxBomDepth)
  155. {
  156. warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
  157. }
  158. return seq;
  159. }
  160. private async Task<long?> ResolveBomIdAsync(
  161. OrderWorkOrderGenerationService.OrderEntryLine entry,
  162. string itemNum)
  163. {
  164. var bomNumber = entry.BomNumber?.Trim();
  165. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  166. """
  167. SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
  168. FROM ic_bom
  169. WHERE tenant_id = @TenantId AND IsDeleted = 0
  170. AND (
  171. (@BomNumber <> '' AND bom_number = @BomNumber)
  172. OR (@BomNumber = '' AND item_number = @ItemNum)
  173. OR item_number = @ItemNum
  174. )
  175. ORDER BY
  176. CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
  177. Id DESC
  178. LIMIT 1
  179. """,
  180. new SugarParameter("@TenantId", entry.TenantId),
  181. new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
  182. new SugarParameter("@ItemNum", itemNum));
  183. return rows.FirstOrDefault()?.Id;
  184. }
  185. private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
  186. {
  187. var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
  188. """
  189. SELECT Id
  190. FROM ic_bom
  191. WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
  192. ORDER BY Id DESC
  193. LIMIT 1
  194. """,
  195. new SugarParameter("@TenantId", tenantId),
  196. new SugarParameter("@ItemNum", itemNum.Trim()));
  197. return rows.FirstOrDefault()?.Id;
  198. }
  199. private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
  200. {
  201. return await _db.Ado.SqlQueryAsync<BomChildRow>(
  202. """
  203. SELECT
  204. c.Id,
  205. c.bom_number AS BomNumber,
  206. c.item_number AS ItemNumber,
  207. c.item_name AS ItemName,
  208. IFNULL(im.Descr1, '') AS Model,
  209. c.unit AS Unit,
  210. IFNULL(c.qty, 1) AS Qty,
  211. IFNULL(c.wastage, 0) AS Wastage,
  212. IFNULL(c.scrap, 0) AS Scrap,
  213. IFNULL(c.backflush, 0) AS Backflush,
  214. IFNULL(c.type, 0) AS Type,
  215. IFNULL(c.erp_cls, 3) AS ErpCls,
  216. IFNULL(c.is_bom, 0) AS IsBom,
  217. IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
  218. c.substitute_code AS SubstituteCode
  219. FROM ic_bom_child c
  220. LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum
  221. WHERE c.bom_id = @BomId
  222. AND c.tenant_id = @TenantId
  223. AND c.IsDeleted = 0
  224. ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
  225. """,
  226. new SugarParameter("@BomId", bomId),
  227. new SugarParameter("@TenantId", tenantId));
  228. }
  229. private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId)
  230. {
  231. var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  232. var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  233. foreach (var line in lines)
  234. {
  235. if (string.IsNullOrWhiteSpace(line.ItemNumber))
  236. continue;
  237. if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
  238. {
  239. stock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
  240. stockMap[line.ItemNumber] = stock;
  241. }
  242. if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
  243. {
  244. inTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
  245. transitMap[line.ItemNumber] = inTransit;
  246. }
  247. line.StockQty = stock;
  248. line.PurchaseOccupyQty = inTransit;
  249. var available = RoundQty(stock + inTransit);
  250. line.Sqty = RoundQty(Math.Min(line.NeedCount, available));
  251. line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.Sqty));
  252. line.SelfLackQty = line.LackQty;
  253. line.UseQty = line.Sqty;
  254. line.MoQty = 0;
  255. line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
  256. line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
  257. var consumedFromStock = RoundQty(Math.Min(stock, line.Sqty));
  258. stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
  259. var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
  260. transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
  261. }
  262. }
  263. private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
  264. {
  265. var qty = await _db.Ado.GetDecimalAsync(
  266. """
  267. SELECT COALESCE(SUM(
  268. CASE
  269. WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
  270. WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
  271. ELSE 0
  272. END
  273. ), 0)
  274. FROM InvMaster
  275. WHERE ItemNum = @ItemNum
  276. AND (tenant_id = @TenantId OR @TenantId = 0)
  277. """,
  278. new List<SugarParameter>
  279. {
  280. new("@ItemNum", itemNum),
  281. new("@TenantId", tenantId)
  282. });
  283. return RoundQty(qty);
  284. }
  285. private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
  286. {
  287. var qty = await _db.Ado.GetDecimalAsync(
  288. """
  289. SELECT COALESCE(SUM(
  290. GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
  291. ), 0)
  292. FROM PurOrdDetail d
  293. INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
  294. WHERE d.ItemNum = @ItemNum
  295. AND m.tenant_id = @TenantId
  296. AND IFNULL(d.IsActive, 1) = 1
  297. AND IFNULL(m.IsActive, 1) = 1
  298. """,
  299. new SugarParameter("@ItemNum", itemNum),
  300. new SugarParameter("@TenantId", tenantId));
  301. return RoundQty(qty);
  302. }
  303. private static decimal RoundQty(decimal value) =>
  304. Math.Round(value, 4, MidpointRounding.AwayFromZero);
  305. private static string MapErpClsName(int erpCls) => erpCls switch
  306. {
  307. 0 => "配置类",
  308. 1 => "自制",
  309. 2 => "委外加工",
  310. 3 => "外购",
  311. 4 => "虚拟件",
  312. _ => string.Empty
  313. };
  314. private sealed class BomHeaderRow
  315. {
  316. public long Id { get; set; }
  317. public string? BomNumber { get; set; }
  318. public string? ItemNumber { get; set; }
  319. }
  320. private sealed class BomChildRow
  321. {
  322. public long Id { get; set; }
  323. public string? BomNumber { get; set; }
  324. public string? ItemNumber { get; set; }
  325. public string? ItemName { get; set; }
  326. public string? Model { get; set; }
  327. public string? Unit { get; set; }
  328. public decimal Qty { get; set; }
  329. public decimal Wastage { get; set; }
  330. public decimal Scrap { get; set; }
  331. public int Backflush { get; set; }
  332. public int Type { get; set; }
  333. public int ErpCls { get; set; }
  334. public int IsBom { get; set; }
  335. public int HaveIcSubs { get; set; }
  336. public string? SubstituteCode { get; set; }
  337. }
  338. }