ProductionScheduleGenerationService.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. namespace Admin.NET.Plugin.AiDOP.Production;
  2. using Admin.NET.Plugin.AiDOP.WorkOrder;
  3. /// <summary>生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。</summary>
  4. public class ProductionScheduleGenerationService : ITransient
  5. {
  6. private readonly ISqlSugarClient _db;
  7. private readonly WorkOrderKittingCheckService _kittingCheck;
  8. public ProductionScheduleGenerationService(ISqlSugarClient db, WorkOrderKittingCheckService kittingCheck)
  9. {
  10. _db = db;
  11. _kittingCheck = kittingCheck;
  12. }
  13. public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account)
  14. {
  15. var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
  16. if (workOrders.Count == 0)
  17. return new ScheduleGenerationResult { Message = "没有待排产的工单(状态 p/r)" };
  18. return await ScheduleWorkOrdersAsync(tenantId, domain, account, workOrders);
  19. }
  20. public async Task<ScheduleGenerationResult> RegenerateForWorkOrderAsync(
  21. long tenantId,
  22. string workOrd,
  23. string? domain,
  24. string account)
  25. {
  26. var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  27. """
  28. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent
  29. FROM WorkOrdMaster
  30. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  31. LIMIT 1
  32. """,
  33. new SugarParameter("@TenantId", tenantId),
  34. new SugarParameter("@WorkOrd", workOrd.Trim()));
  35. var wo = rows.FirstOrDefault();
  36. if (wo is null)
  37. return new ScheduleGenerationResult { Message = $"工单 {workOrd} 不存在" };
  38. return await ScheduleWorkOrdersAsync(tenantId, domain, account, new List<PendingWorkOrderRow> { wo });
  39. }
  40. private async Task<ScheduleGenerationResult> ScheduleWorkOrdersAsync(
  41. long tenantId,
  42. string? domain,
  43. string account,
  44. List<PendingWorkOrderRow> workOrders)
  45. {
  46. var now = DateTime.Now;
  47. var calendar = await LoadWorkCenterCalendarAsync(tenantId);
  48. var occupiedSlots = await LoadOccupiedSlotsAsync(tenantId);
  49. var usedCalendar = calendar.Count > 0;
  50. var scheduledCount = 0;
  51. var rowCount = 0;
  52. var skipped = new List<string>();
  53. // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量)
  54. var consumedStock = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  55. // 清理旧的占用记录,确保每次排程重新计算
  56. const long scheduleBangId = 1;
  57. await _db.Ado.ExecuteCommandAsync(
  58. "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
  59. new SugarParameter("@TenantId", tenantId),
  60. new SugarParameter("@BangId", scheduleBangId));
  61. await _db.Ado.ExecuteCommandAsync(
  62. "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
  63. new SugarParameter("@TenantId", tenantId),
  64. new SugarParameter("@BangId", scheduleBangId));
  65. foreach (var wo in workOrders)
  66. {
  67. // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine)
  68. try
  69. {
  70. await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId);
  71. }
  72. catch (Exception ex)
  73. {
  74. skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}");
  75. }
  76. // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存
  77. var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock);
  78. if (locationStock.HasValue)
  79. {
  80. await _db.Ado.ExecuteCommandAsync(
  81. """
  82. UPDATE WorkOrdMaster
  83. SET LocationStock = @Stock, UpdateTime = @Now
  84. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  85. """,
  86. new SugarParameter("@Stock", locationStock.Value),
  87. new SugarParameter("@Now", now),
  88. new SugarParameter("@TenantId", tenantId),
  89. new SugarParameter("@WorkOrd", wo.WorkOrd));
  90. }
  91. var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
  92. if (routings.Count == 0)
  93. {
  94. skipped.Add($"{wo.WorkOrd}(无工艺路线)");
  95. continue;
  96. }
  97. var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
  98. await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd, woDomain);
  99. var planStart = (wo.OrdDate ?? now).Date;
  100. var planEnd = (wo.DueDate ?? planStart.AddDays(Math.Max(routings.Count, 7))).Date;
  101. if (planEnd < planStart)
  102. planEnd = planStart;
  103. var cursor = planStart;
  104. var seq = 1;
  105. for (var i = 0; i < routings.Count; i++)
  106. {
  107. var routing = routings[i];
  108. DateTime planDate;
  109. if (usedCalendar)
  110. {
  111. planDate = ResolveNextAvailablePlanDate(cursor, routing.WorkCtr, calendar, occupiedSlots);
  112. cursor = planDate.AddDays(1);
  113. }
  114. else
  115. {
  116. var spanDays = Math.Max((planEnd - planStart).Days, 0);
  117. planDate = routings.Count <= 1
  118. ? planStart
  119. : planStart.AddDays(spanDays * i / Math.Max(routings.Count - 1, 1));
  120. }
  121. if (planDate > planEnd)
  122. planEnd = planDate;
  123. var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
  124. occupiedSlots.Add(slotKey);
  125. var recId = await NextPeriodRecIdAsync();
  126. await InsertScheduleRowAsync(
  127. recId, woDomain, routing, wo, planDate, seq, account, now, tenantId);
  128. seq++;
  129. rowCount++;
  130. }
  131. scheduledCount++;
  132. }
  133. return new ScheduleGenerationResult
  134. {
  135. WorkOrderCount = scheduledCount,
  136. ScheduleRowCount = rowCount,
  137. SkippedWorkOrders = skipped,
  138. UsedWorkCenterCalendar = usedCalendar,
  139. Message = scheduledCount > 0
  140. ? $"已为 {scheduledCount} 个工单生成 {rowCount} 条工序排程"
  141. : "未生成排程,请确认工单已同步工艺路线"
  142. };
  143. }
  144. private async Task InsertScheduleRowAsync(
  145. int recId,
  146. string woDomain,
  147. RoutingRow routing,
  148. PendingWorkOrderRow wo,
  149. DateTime planDate,
  150. int seq,
  151. string account,
  152. DateTime now,
  153. long tenantId)
  154. {
  155. await _db.Ado.ExecuteCommandAsync(
  156. """
  157. INSERT INTO PeriodSequenceDet (
  158. RecID, `Domain`, Site, ItemNum, Line, Op, WorkCtr, ProdDate, PlanDate,
  159. Sequence, OrdQty, CompQty, WorkOrds, Status, Employee,
  160. CreateUser, CreateTime, UpdateUser, UpdateTime,
  161. IsActive, IsConfirm, BusinessID, tenant_id
  162. ) VALUES (
  163. @RecId, @Domain, @Site, @ItemNum, @Line, @Op, @WorkCtr, @ProdDate, @PlanDate,
  164. @Sequence, @OrdQty, 0, @WorkOrd, '', '',
  165. @User, @Now, @User, @Now,
  166. 1, 0, @BusinessId, @TenantId
  167. )
  168. """,
  169. new SugarParameter("@RecId", recId),
  170. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  171. new SugarParameter("@Site", routing.Site ?? (object)DBNull.Value),
  172. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  173. new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
  174. new SugarParameter("@Op", routing.Op),
  175. new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
  176. new SugarParameter("@ProdDate", planDate),
  177. new SugarParameter("@PlanDate", planDate),
  178. new SugarParameter("@Sequence", seq),
  179. new SugarParameter("@OrdQty", wo.QtyOrded ?? 0),
  180. new SugarParameter("@WorkOrd", wo.WorkOrd),
  181. new SugarParameter("@BusinessId", wo.RecId),
  182. new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
  183. new SugarParameter("@Now", now),
  184. new SugarParameter("@TenantId", tenantId));
  185. }
  186. private static DateTime ResolveNextAvailablePlanDate(
  187. DateTime start,
  188. string? workCtr,
  189. Dictionary<string, HashSet<int>> calendar,
  190. HashSet<string> occupiedSlots)
  191. {
  192. var date = start.Date;
  193. for (var guard = 0; guard < 366; guard++)
  194. {
  195. if (IsWorkDay(date, workCtr, calendar))
  196. {
  197. var key = BuildOccupancyKey(workCtr, date);
  198. if (!occupiedSlots.Contains(key))
  199. return date;
  200. }
  201. date = date.AddDays(1);
  202. }
  203. return start.Date;
  204. }
  205. private static bool IsWorkDay(DateTime date, string? workCtr, Dictionary<string, HashSet<int>> calendar)
  206. {
  207. if (calendar.Count == 0)
  208. return true;
  209. var key = NormalizeWorkCtr(workCtr);
  210. if (!calendar.TryGetValue(key, out var days) || days.Count == 0)
  211. return date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday;
  212. var weekDay = (int)date.DayOfWeek;
  213. return days.Contains(weekDay);
  214. }
  215. private static string BuildOccupancyKey(string? workCtr, DateTime date) =>
  216. $"{NormalizeWorkCtr(workCtr)}|{date:yyyy-MM-dd}";
  217. private static string NormalizeWorkCtr(string? workCtr) =>
  218. string.IsNullOrWhiteSpace(workCtr) ? "*" : workCtr.Trim();
  219. private async Task<Dictionary<string, HashSet<int>>> LoadWorkCenterCalendarAsync(long tenantId)
  220. {
  221. var rows = await _db.Ado.SqlQueryAsync<CalendarRow>(
  222. """
  223. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, WeekDay
  224. FROM ShopCalendarWorkCtr
  225. WHERE tenant_id = @TenantId
  226. AND IFNULL(IsActive, 0) = 1
  227. AND IFNULL(IsWorkDay, 0) = 1
  228. AND WeekDay IS NOT NULL
  229. """,
  230. new SugarParameter("@TenantId", tenantId));
  231. var map = new Dictionary<string, HashSet<int>>(StringComparer.OrdinalIgnoreCase);
  232. foreach (var row in rows)
  233. {
  234. if (row.WeekDay is null) continue;
  235. var key = NormalizeWorkCtr(row.WorkCtr);
  236. if (!map.TryGetValue(key, out var set))
  237. {
  238. set = new HashSet<int>();
  239. map[key] = set;
  240. }
  241. set.Add(row.WeekDay.Value);
  242. }
  243. return map;
  244. }
  245. private async Task<HashSet<string>> LoadOccupiedSlotsAsync(long tenantId)
  246. {
  247. var rows = await _db.Ado.SqlQueryAsync<OccupiedSlotRow>(
  248. """
  249. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, DATE(PlanDate) AS PlanDate
  250. FROM PeriodSequenceDet
  251. WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL
  252. """,
  253. new SugarParameter("@TenantId", tenantId));
  254. return rows
  255. .Where(x => x.PlanDate.HasValue)
  256. .Select(x => BuildOccupancyKey(x.WorkCtr, x.PlanDate!.Value))
  257. .ToHashSet(StringComparer.OrdinalIgnoreCase);
  258. }
  259. private async Task<List<PendingWorkOrderRow>> LoadPendingWorkOrdersAsync(long tenantId)
  260. {
  261. return await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  262. """
  263. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent,
  264. IFNULL(Status, '') AS Status
  265. FROM WorkOrdMaster
  266. WHERE tenant_id = @TenantId
  267. AND LOWER(TRIM(IFNULL(Status,''))) IN ('w', 's', 'r', 'p')
  268. ORDER BY
  269. CASE LOWER(TRIM(IFNULL(Status,'')))
  270. WHEN 'w' THEN 1
  271. WHEN 's' THEN 1
  272. WHEN 'r' THEN 2
  273. WHEN 'p' THEN 3
  274. ELSE 4
  275. END,
  276. IFNULL(Urgent, 0) DESC,
  277. IFNULL(Priority, 0) ASC,
  278. DueDate, WorkOrd
  279. """,
  280. new SugarParameter("@TenantId", tenantId));
  281. }
  282. private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd)
  283. {
  284. return await _db.Ado.SqlQueryAsync<RoutingRow>(
  285. """
  286. SELECT OP AS Op, ProdLine, WorkCtr, WorkCtr AS Site
  287. FROM WorkOrdRouting
  288. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
  289. ORDER BY (OP + 0), OP
  290. """,
  291. new SugarParameter("@TenantId", tenantId),
  292. new SugarParameter("@WorkOrd", workOrd));
  293. }
  294. public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd, string domain)
  295. {
  296. await _db.Ado.ExecuteCommandAsync(
  297. """
  298. UPDATE PeriodSequenceDet
  299. SET IsActive = 0, UpdateTime = @Now
  300. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND `Domain` = @Domain AND IFNULL(IsActive, 0) = 1
  301. """,
  302. new SugarParameter("@Now", DateTime.Now),
  303. new SugarParameter("@TenantId", tenantId),
  304. new SugarParameter("@WorkOrd", workOrd),
  305. new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain));
  306. }
  307. private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
  308. {
  309. if (!string.IsNullOrWhiteSpace(woDomain))
  310. return woDomain.Trim();
  311. if (!string.IsNullOrWhiteSpace(requestDomain))
  312. return requestDomain.Trim();
  313. return tenantId.ToString();
  314. }
  315. private async Task<int> NextPeriodRecIdAsync()
  316. {
  317. var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM PeriodSequenceDet");
  318. return max + 1;
  319. }
  320. public sealed class ScheduleGenerationResult
  321. {
  322. public int WorkOrderCount { get; set; }
  323. public int ScheduleRowCount { get; set; }
  324. public bool UsedWorkCenterCalendar { get; set; }
  325. public List<string> SkippedWorkOrders { get; set; } = new();
  326. public string Message { get; set; } = string.Empty;
  327. }
  328. /// <summary>
  329. /// 计算工单齐套数量:MIN(可用库存 / qty) 得可生产成品数,超过工单需求量则取工单需求量。
  330. /// 若工单领料单已全部发完料,则齐套数量 = 工单需求量。
  331. /// consumedStock 跟踪已被高优先级工单占用的库存量,避免重复占用。
  332. /// </summary>
  333. private async Task<decimal?> CalcLocationStockAsync(
  334. long tenantId, string workOrd, decimal qtyOrded,
  335. Dictionary<string, decimal> consumedStock)
  336. {
  337. // 检查领料单是否已全部发完料
  338. var allIssued = await _db.Ado.GetIntAsync(
  339. """
  340. SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
  341. FROM NbrMaster nm
  342. WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd
  343. AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1
  344. AND NOT EXISTS (
  345. SELECT 1 FROM NbrDetail nd
  346. WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr
  347. AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1
  348. AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0)
  349. )
  350. """,
  351. new SugarParameter("@TenantId", tenantId),
  352. new SugarParameter("@WorkOrd", workOrd));
  353. if (allIssued > 0 && qtyOrded > 0)
  354. return qtyOrded;
  355. // 从最新资源检查获取各物料的 use_qty 和 qty
  356. var materials = await _db.Ado.SqlQueryAsync<LocationStockMaterialRow>(
  357. """
  358. SELECT
  359. bce.item_number AS ItemNumber,
  360. IFNULL(bce.use_qty, 0) AS UseQty,
  361. IFNULL(bce.qty, 0) AS Qty
  362. FROM b_examine_result ber
  363. INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
  364. WHERE ber.tenant_id = @TenantId
  365. AND ber.IsDeleted = 0
  366. AND ber.morder_no = @WorkOrd
  367. AND ber.Id = (
  368. SELECT br.Id FROM b_examine_result br
  369. WHERE br.tenant_id = @TenantId AND br.morder_no = @WorkOrd AND br.IsDeleted = 0
  370. ORDER BY br.create_time DESC LIMIT 1
  371. )
  372. AND IFNULL(bce.qty, 0) > 0
  373. AND IFNULL(bce.erp_cls, 3) = 3
  374. """,
  375. new SugarParameter("@TenantId", tenantId),
  376. new SugarParameter("@WorkOrd", workOrd));
  377. if (materials.Count == 0)
  378. return null;
  379. // MIN(可用库存 / qty) = 扣减已占用后的瓶颈物料可生产成品数
  380. decimal? minProducible = null;
  381. foreach (var mat in materials)
  382. {
  383. if (mat.Qty <= 0) continue;
  384. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  385. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  386. var producible = availableUseQty / mat.Qty;
  387. if (minProducible is null || producible < minProducible)
  388. minProducible = producible;
  389. }
  390. if (minProducible is null)
  391. return null;
  392. // 可生产数 > 工单需求量则取工单需求量
  393. var result = Math.Ceiling(minProducible.Value);
  394. if (qtyOrded > 0 && result > qtyOrded)
  395. result = qtyOrded;
  396. // 记录本工单占用的库存量
  397. foreach (var mat in materials)
  398. {
  399. if (mat.Qty <= 0) continue;
  400. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  401. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  402. var consume = Math.Min(availableUseQty, result * mat.Qty);
  403. if (!consumedStock.ContainsKey(mat.ItemNumber))
  404. consumedStock[mat.ItemNumber] = 0m;
  405. consumedStock[mat.ItemNumber] += consume;
  406. }
  407. return result;
  408. }
  409. private sealed class LocationStockMaterialRow
  410. {
  411. public string ItemNumber { get; set; } = string.Empty;
  412. public decimal UseQty { get; set; }
  413. public decimal Qty { get; set; }
  414. }
  415. private sealed class PendingWorkOrderRow
  416. {
  417. public long RecId { get; set; }
  418. public string WorkOrd { get; set; } = string.Empty;
  419. public string? ItemNum { get; set; }
  420. public string? Domain { get; set; }
  421. public decimal? QtyOrded { get; set; }
  422. public DateTime? OrdDate { get; set; }
  423. public DateTime? DueDate { get; set; }
  424. public decimal? Priority { get; set; }
  425. public int? Urgent { get; set; }
  426. public string Status { get; set; } = string.Empty;
  427. }
  428. private sealed class RoutingRow
  429. {
  430. public int Op { get; set; }
  431. public string? ProdLine { get; set; }
  432. public string? WorkCtr { get; set; }
  433. public string? Site { get; set; }
  434. }
  435. private sealed class CalendarRow
  436. {
  437. public string? WorkCtr { get; set; }
  438. public int? WeekDay { get; set; }
  439. }
  440. private sealed class OccupiedSlotRow
  441. {
  442. public string? WorkCtr { get; set; }
  443. public DateTime? PlanDate { get; set; }
  444. }
  445. }