ProductionScheduleGenerationService.cs 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. namespace Admin.NET.Plugin.AiDOP.Production;
  2. using System.Diagnostics;
  3. using Admin.NET.Plugin.AiDOP.WorkOrder;
  4. /// <summary>生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。</summary>
  5. public class ProductionScheduleGenerationService : ITransient
  6. {
  7. private readonly ISqlSugarClient _db;
  8. private readonly WorkOrderKittingCheckService _kittingCheck;
  9. public ProductionScheduleGenerationService(ISqlSugarClient db, WorkOrderKittingCheckService kittingCheck)
  10. {
  11. _db = db;
  12. _kittingCheck = kittingCheck;
  13. }
  14. public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = true)
  15. {
  16. var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
  17. if (workOrders.Count == 0)
  18. return new ScheduleGenerationResult { Message = "没有待排产的工单(状态 p/r)" };
  19. return await ScheduleWorkOrdersAsync(tenantId, domain, account, workOrders, enableCapacityConstraint);
  20. }
  21. public async Task<ScheduleGenerationResult> RegenerateForWorkOrderAsync(
  22. long tenantId,
  23. string workOrd,
  24. string? domain,
  25. string account,
  26. bool enableCapacityConstraint = true)
  27. {
  28. var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  29. """
  30. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent
  31. FROM WorkOrdMaster
  32. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  33. LIMIT 1
  34. """,
  35. new SugarParameter("@TenantId", tenantId),
  36. new SugarParameter("@WorkOrd", workOrd.Trim()));
  37. var wo = rows.FirstOrDefault();
  38. if (wo is null)
  39. return new ScheduleGenerationResult { Message = $"工单 {workOrd} 不存在" };
  40. return await ScheduleWorkOrdersAsync(tenantId, domain, account, new List<PendingWorkOrderRow> { wo }, enableCapacityConstraint);
  41. }
  42. private async Task<ScheduleGenerationResult> ScheduleWorkOrdersAsync(
  43. long tenantId,
  44. string? domain,
  45. string account,
  46. List<PendingWorkOrderRow> workOrders,
  47. bool enableCapacityConstraint = true)
  48. {
  49. var totalSw = Stopwatch.StartNew();
  50. var now = DateTime.Now;
  51. var calendar = await LoadWorkCenterCalendarAsync(tenantId);
  52. var occupiedSlots = await LoadOccupiedSlotsAsync(tenantId);
  53. var usedCalendar = calendar.Count > 0;
  54. var scheduledCount = 0;
  55. var rowCount = 0;
  56. var skipped = new List<string>();
  57. // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量)
  58. var consumedStock = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  59. // 跟踪每日各工作中心已占用设备数(Key = "WorkCtr|yyyy-MM-dd")
  60. var equipmentDailyOccupancy = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  61. // 诊断计时
  62. var kittingMs = 0L;
  63. var otherMs = 0L;
  64. // 清理旧的占用记录,确保每次排程重新计算
  65. const long scheduleBangId = 1;
  66. await _db.Ado.ExecuteCommandAsync(
  67. "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
  68. new SugarParameter("@TenantId", tenantId),
  69. new SugarParameter("@BangId", scheduleBangId));
  70. await _db.Ado.ExecuteCommandAsync(
  71. "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
  72. new SugarParameter("@TenantId", tenantId),
  73. new SugarParameter("@BangId", scheduleBangId));
  74. var woIndex = 0;
  75. foreach (var wo in workOrders)
  76. {
  77. woIndex++;
  78. var woSw = Stopwatch.StartNew();
  79. // 0. 先加载工艺路线,无路线的工单跳过齐套检查(性能优化)
  80. var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd, wo.ItemNum);
  81. if (routings.Count == 0)
  82. {
  83. skipped.Add($"{wo.WorkOrd}(无工艺路线)");
  84. await InsertScheduleExceptionAsync(tenantId, wo, domain, "无工艺路线", "工单无工艺路线,无法生成排程");
  85. woSw.Stop();
  86. otherMs += woSw.ElapsedMilliseconds;
  87. if (woIndex % 5 == 0 || woIndex == workOrders.Count)
  88. {
  89. Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
  90. $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
  91. $"本工单[{wo.WorkOrd}]=跳过(无路线)");
  92. }
  93. continue;
  94. }
  95. // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine)
  96. var kitSw = Stopwatch.StartNew();
  97. try
  98. {
  99. await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId);
  100. }
  101. catch (Exception ex)
  102. {
  103. skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}");
  104. }
  105. kitSw.Stop();
  106. kittingMs += kitSw.ElapsedMilliseconds;
  107. // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存
  108. var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock);
  109. if (locationStock.HasValue)
  110. {
  111. await _db.Ado.ExecuteCommandAsync(
  112. """
  113. UPDATE WorkOrdMaster
  114. SET LocationStock = @Stock, UpdateTime = @Now
  115. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  116. """,
  117. new SugarParameter("@Stock", locationStock.Value),
  118. new SugarParameter("@Now", now),
  119. new SugarParameter("@TenantId", tenantId),
  120. new SugarParameter("@WorkOrd", wo.WorkOrd));
  121. }
  122. // 检查各道工序的数据完整性(标准工艺路线、设备、技能)
  123. var missingStdOp = routings.Where(r => string.IsNullOrWhiteSpace(r.StdOp)).Select(r => r.Op).ToList();
  124. var missingWorkCtr = routings.Where(r => string.IsNullOrWhiteSpace(r.WorkCtr)).Select(r => r.Op).ToList();
  125. var missingMachine = routings.Where(r => string.IsNullOrWhiteSpace(r.Machine)).Select(r => r.Op).ToList();
  126. var missingEngineer = routings.Where(r => string.IsNullOrWhiteSpace(r.Engineer)).Select(r => r.Op).ToList();
  127. if (missingStdOp.Count > 0)
  128. {
  129. var ops = string.Join("、", missingStdOp);
  130. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到标准工艺路线)");
  131. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到标准工艺路线",
  132. $"工序 {ops} 未查到标准工艺路线,排程可能不准确");
  133. }
  134. var missingEquip = missingWorkCtr.Concat(missingMachine).Distinct().ToList();
  135. if (missingEquip.Count > 0)
  136. {
  137. var ops = string.Join("、", missingEquip);
  138. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到设备)");
  139. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到设备",
  140. $"工序 {ops} 未查到设备(WorkCtr或设备编码为空),排程可能不准确");
  141. }
  142. if (missingEngineer.Count > 0)
  143. {
  144. var ops = string.Join("、", missingEngineer);
  145. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到技能)");
  146. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到技能",
  147. $"工序 {ops} 未查到技能(SkillNo为空),排程可能不准确");
  148. }
  149. var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
  150. await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd);
  151. var planStart = (wo.OrdDate ?? now).Date;
  152. var planEnd = (wo.DueDate ?? planStart.AddDays(Math.Max(routings.Count, 7))).Date;
  153. if (planEnd < planStart)
  154. planEnd = planStart;
  155. var cursor = planStart;
  156. var seq = 1;
  157. for (var i = 0; i < routings.Count; i++)
  158. {
  159. var routing = routings[i];
  160. DateTime planDate;
  161. if (usedCalendar)
  162. {
  163. planDate = ResolveNextAvailablePlanDate(cursor, routing.WorkCtr, calendar, occupiedSlots);
  164. cursor = planDate.AddDays(1);
  165. }
  166. else
  167. {
  168. var spanDays = Math.Max((planEnd - planStart).Days, 0);
  169. planDate = routings.Count <= 1
  170. ? planStart
  171. : planStart.AddDays(spanDays * i / Math.Max(routings.Count - 1, 1));
  172. }
  173. if (planDate > planEnd)
  174. planEnd = planDate;
  175. var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
  176. occupiedSlots.Add(slotKey);
  177. // 始终解析设备编码和技能编号(供 ScheduleResultOpMaster 写入)
  178. var capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
  179. var capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
  180. var isNonManual = !string.IsNullOrWhiteSpace(routing.WorkCode)
  181. ? routing.WorkCode.Trim().ToUpperInvariant() != "P"
  182. : (!string.IsNullOrWhiteSpace(routing.OpType)
  183. && routing.OpType.Trim().ToUpperInvariant() != "P");
  184. // 非人工工序:无设备可用时写入排产异常
  185. if (isNonManual && string.IsNullOrWhiteSpace(capacityMachineCode))
  186. {
  187. await InsertScheduleExceptionAsync(tenantId, wo, domain, "无设备可用",
  188. $"工单{wo.WorkOrd}工序{routing.Op}无设备可用");
  189. }
  190. // 始终查询设备可用数量(用于产能约束和兜底上限)
  191. var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
  192. // 产能约束:非人工工序,根据设备和人员瓶颈限制排产数量
  193. decimal? effectiveQty = null;
  194. int? capacityDeviceAllocation = null;
  195. decimal? capacityAssignedPersonnel = null;
  196. if (enableCapacityConstraint && isNonManual)
  197. {
  198. var personnelCount = await LoadAvailablePersonnelCountAsync(tenantId, capacitySkillNo);
  199. var maxAvailable = Math.Min(equipCount, personnelCount);
  200. if (maxAvailable > 0 && routing.MachBdnRate.HasValue && routing.MachBdnRate.Value > 0)
  201. {
  202. var workDays = Math.Max(1, (planEnd - planStart).Days);
  203. var dailyQty = Math.Ceiling((wo.QtyOrded ?? 0) / workDays);
  204. var standardNeed = Math.Ceiling(dailyQty / routing.MachBdnRate.Value);
  205. var occupancyKey = $"{NormalizeWorkCtr(routing.WorkCtr)}|{planDate:yyyy-MM-dd}";
  206. var alreadyOccupied = equipmentDailyOccupancy.TryGetValue(occupancyKey, out var occ) ? occ : 0m;
  207. var remainingAvailable = (decimal)maxAvailable - alreadyOccupied;
  208. if (remainingAvailable > 0)
  209. {
  210. var actualOccupied = Math.Min(standardNeed, remainingAvailable);
  211. equipmentDailyOccupancy[occupancyKey] = alreadyOccupied + actualOccupied;
  212. effectiveQty = Math.Min(wo.QtyOrded ?? 0, actualOccupied * routing.MachBdnRate.Value);
  213. capacityDeviceAllocation = (int)actualOccupied;
  214. capacityAssignedPersonnel = actualOccupied;
  215. }
  216. }
  217. }
  218. // 设备占用数兜底:取 MachinesperOp(但不超过实际设备可用数),默认 1
  219. if (!capacityDeviceAllocation.HasValue)
  220. {
  221. var desired = routing.MachinestPerOp > 0 ? routing.MachinestPerOp.Value : 1;
  222. capacityDeviceAllocation = equipCount > 0 ? Math.Min(desired, equipCount) : desired;
  223. }
  224. var recId = await NextPeriodRecIdAsync();
  225. await InsertScheduleRowAsync(
  226. recId, woDomain, routing, wo, planDate, seq, account, now, tenantId,
  227. capacityQty: effectiveQty);
  228. // 始终写入 ScheduleResultOpMaster(基础排产数据 + 可选产能分配数据)
  229. var resultRecId = await NextScheduleResultOpRecIdAsync();
  230. await InsertScheduleResultOpAsync(
  231. resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
  232. capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel,
  233. periodDetRecId: recId);
  234. seq++;
  235. rowCount++;
  236. }
  237. scheduledCount++;
  238. woSw.Stop();
  239. otherMs += woSw.ElapsedMilliseconds - kitSw.ElapsedMilliseconds;
  240. // 每5个工单输出一次诊断日志
  241. if (woIndex % 5 == 0 || woIndex == workOrders.Count)
  242. {
  243. Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
  244. $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
  245. $"本工单[{wo.WorkOrd}]={woSw.ElapsedMilliseconds}ms");
  246. }
  247. }
  248. totalSw.Stop();
  249. Console.WriteLine($"[排程诊断] 完成: {workOrders.Count}工单/{rowCount}条, " +
  250. $"齐套检查={kittingMs}ms 其他={otherMs}ms 总计={totalSw.ElapsedMilliseconds}ms");
  251. return new ScheduleGenerationResult
  252. {
  253. WorkOrderCount = scheduledCount,
  254. ScheduleRowCount = rowCount,
  255. SkippedWorkOrders = skipped,
  256. UsedWorkCenterCalendar = usedCalendar,
  257. Message = scheduledCount > 0
  258. ? $"已为 {scheduledCount} 个工单生成 {rowCount} 条工序排程"
  259. : "未生成排程,请确认工单已同步工艺路线"
  260. };
  261. }
  262. private async Task InsertScheduleRowAsync(
  263. int recId,
  264. string woDomain,
  265. RoutingRow routing,
  266. PendingWorkOrderRow wo,
  267. DateTime planDate,
  268. int seq,
  269. string account,
  270. DateTime now,
  271. long tenantId,
  272. decimal? capacityQty = null)
  273. {
  274. var ordQty = capacityQty ?? wo.QtyOrded ?? 0;
  275. await _db.Ado.ExecuteCommandAsync(
  276. """
  277. INSERT INTO PeriodSequenceDet (
  278. RecID, `Domain`, Site, ItemNum, Line, Op, WorkCtr, ProdDate, PlanDate,
  279. Sequence, OrdQty, CompQty, WorkOrds, Status, Employee,
  280. CreateUser, CreateTime, UpdateUser, UpdateTime,
  281. IsActive, IsConfirm, BusinessID, tenant_id
  282. ) VALUES (
  283. @RecId, @Domain, @Site, @ItemNum, @Line, @Op, @WorkCtr, @ProdDate, @PlanDate,
  284. @Sequence, @OrdQty, 0, @WorkOrd, '', '',
  285. @User, @Now, @User, @Now,
  286. 1, 0, @BusinessId, @TenantId
  287. )
  288. """,
  289. new SugarParameter("@RecId", recId),
  290. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  291. new SugarParameter("@Site", routing.Site ?? (object)DBNull.Value),
  292. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  293. new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
  294. new SugarParameter("@Op", routing.Op),
  295. new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
  296. new SugarParameter("@ProdDate", planDate),
  297. new SugarParameter("@PlanDate", planDate),
  298. new SugarParameter("@Sequence", seq),
  299. new SugarParameter("@OrdQty", ordQty),
  300. new SugarParameter("@WorkOrd", wo.WorkOrd),
  301. new SugarParameter("@BusinessId", wo.RecId),
  302. new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
  303. new SugarParameter("@Now", now),
  304. new SugarParameter("@TenantId", tenantId));
  305. }
  306. private static DateTime ResolveNextAvailablePlanDate(
  307. DateTime start,
  308. string? workCtr,
  309. Dictionary<string, HashSet<int>> calendar,
  310. HashSet<string> occupiedSlots)
  311. {
  312. var date = start.Date;
  313. for (var guard = 0; guard < 366; guard++)
  314. {
  315. if (IsWorkDay(date, workCtr, calendar))
  316. {
  317. var key = BuildOccupancyKey(workCtr, date);
  318. if (!occupiedSlots.Contains(key))
  319. return date;
  320. }
  321. date = date.AddDays(1);
  322. }
  323. return start.Date;
  324. }
  325. private static bool IsWorkDay(DateTime date, string? workCtr, Dictionary<string, HashSet<int>> calendar)
  326. {
  327. if (calendar.Count == 0)
  328. return true;
  329. var key = NormalizeWorkCtr(workCtr);
  330. if (!calendar.TryGetValue(key, out var days) || days.Count == 0)
  331. return date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday;
  332. var weekDay = (int)date.DayOfWeek;
  333. return days.Contains(weekDay);
  334. }
  335. private static string BuildOccupancyKey(string? workCtr, DateTime date) =>
  336. $"{NormalizeWorkCtr(workCtr)}|{date:yyyy-MM-dd}";
  337. private static string NormalizeWorkCtr(string? workCtr) =>
  338. string.IsNullOrWhiteSpace(workCtr) ? "*" : workCtr.Trim();
  339. private async Task<Dictionary<string, HashSet<int>>> LoadWorkCenterCalendarAsync(long tenantId)
  340. {
  341. var rows = await _db.Ado.SqlQueryAsync<CalendarRow>(
  342. """
  343. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, WeekDay
  344. FROM ShopCalendarWorkCtr
  345. WHERE tenant_id = @TenantId
  346. AND IFNULL(IsActive, 0) = 1
  347. AND IFNULL(IsWorkDay, 0) = 1
  348. AND WeekDay IS NOT NULL
  349. """,
  350. new SugarParameter("@TenantId", tenantId));
  351. var map = new Dictionary<string, HashSet<int>>(StringComparer.OrdinalIgnoreCase);
  352. foreach (var row in rows)
  353. {
  354. if (row.WeekDay is null) continue;
  355. var key = NormalizeWorkCtr(row.WorkCtr);
  356. if (!map.TryGetValue(key, out var set))
  357. {
  358. set = new HashSet<int>();
  359. map[key] = set;
  360. }
  361. set.Add(row.WeekDay.Value);
  362. }
  363. return map;
  364. }
  365. private async Task<HashSet<string>> LoadOccupiedSlotsAsync(long tenantId)
  366. {
  367. var rows = await _db.Ado.SqlQueryAsync<OccupiedSlotRow>(
  368. """
  369. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, DATE(PlanDate) AS PlanDate
  370. FROM PeriodSequenceDet
  371. WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL
  372. """,
  373. new SugarParameter("@TenantId", tenantId));
  374. return rows
  375. .Where(x => x.PlanDate.HasValue)
  376. .Select(x => BuildOccupancyKey(x.WorkCtr, x.PlanDate!.Value))
  377. .ToHashSet(StringComparer.OrdinalIgnoreCase);
  378. }
  379. private async Task<List<PendingWorkOrderRow>> LoadPendingWorkOrdersAsync(long tenantId)
  380. {
  381. return await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  382. """
  383. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent,
  384. IFNULL(Status, '') AS Status
  385. FROM WorkOrdMaster
  386. WHERE tenant_id = @TenantId
  387. AND LOWER(TRIM(IFNULL(Status,''))) IN ('w', 's', 'r', 'p')
  388. ORDER BY
  389. CASE LOWER(TRIM(IFNULL(Status,'')))
  390. WHEN 'w' THEN 1
  391. WHEN 's' THEN 1
  392. WHEN 'r' THEN 2
  393. WHEN 'p' THEN 3
  394. ELSE 4
  395. END,
  396. IFNULL(Urgent, 0) DESC,
  397. IFNULL(Priority, 0) ASC,
  398. DueDate, WorkOrd
  399. """,
  400. new SugarParameter("@TenantId", tenantId));
  401. }
  402. private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd, string? itemNum = null)
  403. {
  404. var routings = await _db.Ado.SqlQueryAsync<RoutingRow>(
  405. """
  406. SELECT r.OP AS Op,
  407. CASE WHEN IFNULL(r.ProdLine, '') = '' THEN pd.Line ELSE r.ProdLine END AS ProdLine,
  408. r.WorkCtr,
  409. CASE WHEN IFNULL(r.WorkCtr, '') = '' THEN IFNULL(pd.Site, '') ELSE r.WorkCtr END AS Site,
  410. IFNULL(r.StdOp, '') AS StdOp,
  411. IFNULL(r.Machine, '') AS Machine,
  412. IFNULL(r.Engineer, '') AS Engineer,
  413. IFNULL(r.RunCrew, 0) AS RunCrew,
  414. IFNULL(r.MachBdnRate, 0) AS MachBdnRate,
  415. IFNULL(r.MachinesperOp, 0) AS MachinestPerOp,
  416. IFNULL(r.RunTime, 0) AS RunTime,
  417. IFNULL(r.WorkCode, '') AS WorkCode,
  418. IFNULL(pd.OpType, '') AS OpType,
  419. IFNULL(r.Descr, '') AS OpDescr,
  420. IFNULL(r.StdSetupTime, 0) AS SetupTime
  421. FROM WorkOrdRouting r
  422. LEFT JOIN ProdLineDetail pd ON pd.Part = r.ItemNum AND pd.Op = r.OP
  423. WHERE r.tenant_id = @TenantId AND r.WorkOrd = @WorkOrd AND IFNULL(r.IsActive, 0) = 1
  424. ORDER BY (r.OP + 0), r.OP
  425. """,
  426. new SugarParameter("@TenantId", tenantId),
  427. new SugarParameter("@WorkOrd", workOrd));
  428. // ── C# 层补充回退:若 SQL JOIN 未命中,用 WorkOrdMaster.ItemNum 直接查 ProdLineDetail ──
  429. var needFill = routings.Any(r => string.IsNullOrWhiteSpace(r.ProdLine)
  430. || string.IsNullOrWhiteSpace(r.Site) || string.IsNullOrWhiteSpace(r.OpType));
  431. if (needFill && !string.IsNullOrWhiteSpace(itemNum))
  432. {
  433. var fallback = await _db.Ado.SqlQueryAsync<ProdLineFallbackRow>(
  434. """
  435. SELECT Op, Line, Site, OpType
  436. FROM ProdLineDetail
  437. WHERE Part = @Part
  438. """,
  439. new SugarParameter("@Part", itemNum));
  440. var fbMap = fallback.ToDictionary(f => f.Op, f => f);
  441. foreach (var r in routings)
  442. {
  443. if (fbMap.TryGetValue(r.Op, out var fb))
  444. {
  445. if (string.IsNullOrWhiteSpace(r.ProdLine) && !string.IsNullOrWhiteSpace(fb.Line))
  446. r.ProdLine = fb.Line;
  447. if (string.IsNullOrWhiteSpace(r.Site) && !string.IsNullOrWhiteSpace(fb.Site))
  448. r.Site = fb.Site;
  449. if (string.IsNullOrWhiteSpace(r.OpType) && !string.IsNullOrWhiteSpace(fb.OpType))
  450. r.OpType = fb.OpType;
  451. }
  452. }
  453. }
  454. return routings;
  455. }
  456. private sealed class ProdLineFallbackRow
  457. {
  458. public int Op { get; set; }
  459. public string? Line { get; set; }
  460. public string? Site { get; set; }
  461. public string? OpType { get; set; }
  462. }
  463. public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd)
  464. {
  465. await _db.Ado.ExecuteCommandAsync(
  466. """
  467. UPDATE PeriodSequenceDet
  468. SET IsActive = 0, UpdateTime = @Now
  469. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND IFNULL(IsActive, 0) = 1
  470. """,
  471. new SugarParameter("@Now", DateTime.Now),
  472. new SugarParameter("@TenantId", tenantId),
  473. new SugarParameter("@WorkOrd", workOrd));
  474. // 清理旧的排产结果数据
  475. await _db.Ado.ExecuteCommandAsync(
  476. """
  477. DELETE FROM ScheduleResultOpMaster
  478. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  479. """,
  480. new SugarParameter("@TenantId", tenantId),
  481. new SugarParameter("@WorkOrd", workOrd));
  482. }
  483. private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
  484. {
  485. if (!string.IsNullOrWhiteSpace(woDomain))
  486. return woDomain.Trim();
  487. if (!string.IsNullOrWhiteSpace(requestDomain))
  488. return requestDomain.Trim();
  489. return tenantId.ToString();
  490. }
  491. private async Task<int> NextPeriodRecIdAsync()
  492. {
  493. var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM PeriodSequenceDet");
  494. return max + 1;
  495. }
  496. private async Task<long> NextScheduleExceptionRecIdAsync()
  497. {
  498. var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ScheduleExceptionMaster");
  499. return max + 1;
  500. }
  501. private async Task<long> NextScheduleResultOpRecIdAsync()
  502. {
  503. var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ScheduleResultOpMaster");
  504. return max + 1;
  505. }
  506. /// <summary>
  507. /// 查询可用设备数量(仅排产设备)。
  508. /// 支持复合编码:"/" 分隔表示任一可用(SUM),"+" 分隔表示需同时使用(MIN)。
  509. /// </summary>
  510. private async Task<int> LoadAvailableEquipmentCountAsync(long tenantId, string? machineCode)
  511. {
  512. if (string.IsNullOrWhiteSpace(machineCode)) return 0;
  513. var code = machineCode.Trim();
  514. // "+" 分隔:需要所有设备同时使用 → 取 MIN
  515. if (code.Contains('+'))
  516. {
  517. var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
  518. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  519. if (parts.Length == 0) return 0;
  520. var counts = new List<int>();
  521. foreach (var part in parts)
  522. {
  523. var c = await CountSingleEquipmentAsync(part);
  524. counts.Add(c);
  525. }
  526. return counts.Count > 0 ? counts.Min() : 0;
  527. }
  528. // "/" 分隔:任一设备可用 → 取 SUM
  529. if (code.Contains('/'))
  530. {
  531. var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
  532. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  533. if (parts.Length == 0) return 0;
  534. var total = 0;
  535. foreach (var part in parts)
  536. {
  537. total += await CountSingleEquipmentAsync(part);
  538. }
  539. return total;
  540. }
  541. // 单设备编码
  542. return await CountSingleEquipmentAsync(code);
  543. }
  544. /// <summary>查询单个设备编码的可用设备数量。</summary>
  545. private async Task<int> CountSingleEquipmentAsync(string code)
  546. {
  547. return await _db.Ado.GetIntAsync(
  548. """
  549. SELECT COUNT(*) FROM EquipmentList
  550. WHERE InternalEquipmentCode = @Code AND IFNULL(IsSchedulable, 0) = 1
  551. """,
  552. new SugarParameter("@Code", code));
  553. }
  554. /// <summary>
  555. /// 查询可用人员数量(持有指定技能的人员)。
  556. /// 支持复合编码:"/" 分隔表示任一技能可用(SUM),"+" 分隔表示需同时持有(MIN)。
  557. /// </summary>
  558. private async Task<int> LoadAvailablePersonnelCountAsync(long tenantId, string? skillNo)
  559. {
  560. if (string.IsNullOrWhiteSpace(skillNo)) return 0;
  561. var code = skillNo.Trim();
  562. // "+" 分隔:需要同时持有所有技能 → 取 MIN
  563. if (code.Contains('+'))
  564. {
  565. var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
  566. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  567. if (parts.Length == 0) return 0;
  568. var counts = new List<int>();
  569. foreach (var part in parts)
  570. {
  571. var c = await CountSingleSkillAsync(tenantId, part);
  572. counts.Add(c);
  573. }
  574. return counts.Count > 0 ? counts.Min() : 0;
  575. }
  576. // "/" 分隔:任一技能可用 → 取 SUM
  577. if (code.Contains('/'))
  578. {
  579. var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
  580. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  581. if (parts.Length == 0) return 0;
  582. var total = 0;
  583. foreach (var part in parts)
  584. {
  585. total += await CountSingleSkillAsync(tenantId, part);
  586. }
  587. return total;
  588. }
  589. // 单技能编码
  590. return await CountSingleSkillAsync(tenantId, code);
  591. }
  592. /// <summary>查询持有单个技能编码的人员数量。</summary>
  593. private async Task<int> CountSingleSkillAsync(long tenantId, string skillNo)
  594. {
  595. return await _db.Ado.GetIntAsync(
  596. "SELECT COUNT(*) FROM EmpSkills WHERE SkillNo = @SkillNo AND tenant_id = @TenantId",
  597. new SugarParameter("@SkillNo", skillNo),
  598. new SugarParameter("@TenantId", tenantId));
  599. }
  600. /// <summary>解析设备编码:WorkOrdRouting.Machine 为空时回退查 ProdLineDetail。</summary>
  601. private async Task<string?> ResolveMachineCodeAsync(long tenantId, string? machine, string? itemNum, int op)
  602. {
  603. if (!string.IsNullOrWhiteSpace(machine)) return machine.Trim();
  604. if (string.IsNullOrWhiteSpace(itemNum)) return null;
  605. return await _db.Ado.GetStringAsync(
  606. """
  607. SELECT InternalEquipmentCode FROM ProdLineDetail
  608. WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
  609. """,
  610. new SugarParameter("@ItemNum", itemNum),
  611. new SugarParameter("@Op", op),
  612. new SugarParameter("@TenantId", tenantId));
  613. }
  614. /// <summary>解析技能编码:WorkOrdRouting.Engineer 为空时回退查 ProdLineDetail。</summary>
  615. private async Task<string?> ResolveSkillNoAsync(long tenantId, string? engineer, string? itemNum, int op)
  616. {
  617. if (!string.IsNullOrWhiteSpace(engineer)) return engineer.Trim();
  618. if (string.IsNullOrWhiteSpace(itemNum)) return null;
  619. return await _db.Ado.GetStringAsync(
  620. """
  621. SELECT SkillNo FROM ProdLineDetail
  622. WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
  623. """,
  624. new SugarParameter("@ItemNum", itemNum),
  625. new SugarParameter("@Op", op),
  626. new SugarParameter("@TenantId", tenantId));
  627. }
  628. private async Task InsertScheduleExceptionAsync(
  629. long tenantId,
  630. PendingWorkOrderRow wo,
  631. string? domain,
  632. string type,
  633. string remark)
  634. {
  635. var recId = await NextScheduleExceptionRecIdAsync();
  636. var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
  637. await _db.Ado.ExecuteCommandAsync(
  638. """
  639. INSERT INTO ScheduleExceptionMaster (
  640. RecID, `Domain`, WorkOrd, ItemNum, CreateTime, Remark, Type, OptTime, tenant_id
  641. ) VALUES (
  642. @RecId, @Domain, @WorkOrd, @ItemNum, @CreateTime, @Remark, @Type, @OptTime, @TenantId
  643. )
  644. """,
  645. new SugarParameter("@RecId", recId),
  646. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  647. new SugarParameter("@WorkOrd", wo.WorkOrd),
  648. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  649. new SugarParameter("@CreateTime", DateTime.Now),
  650. new SugarParameter("@Remark", remark),
  651. new SugarParameter("@Type", type),
  652. new SugarParameter("@OptTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm")),
  653. new SugarParameter("@TenantId", tenantId));
  654. }
  655. /// <summary>写入排产结果(ScheduleResultOpMaster),包含设备/人员产能分配数据。</summary>
  656. private async Task InsertScheduleResultOpAsync(
  657. long recId,
  658. string woDomain,
  659. RoutingRow routing,
  660. PendingWorkOrderRow wo,
  661. DateTime planDate,
  662. int seq,
  663. DateTime now,
  664. long tenantId,
  665. string? machineCode,
  666. int? deviceAllocationCount,
  667. string? skillNo,
  668. decimal? assignedPersonnelCount,
  669. long? periodDetRecId)
  670. {
  671. await _db.Ado.ExecuteCommandAsync(
  672. """
  673. INSERT INTO ScheduleResultOpMaster (
  674. RecID, `Domain`, WorkOrd, WorkCtr, Line, ItemNum, Op,
  675. WorkDate, WorkQty, WorkSort, OpDescr, SetupTime, WorkActivateTime,
  676. InternalEquipmentCode, DeviceAllocationCount, MoldAllocationCount,
  677. SkillNo, AssignedPersonnelCount,
  678. PeriodDetRecId,
  679. Remark, CreateTime, tenant_id
  680. ) VALUES (
  681. @RecId, @Domain, @WorkOrd, @WorkCtr, @Line, @ItemNum, @Op,
  682. @WorkDate, @WorkQty, @WorkSort, @OpDescr, @SetupTime, @WorkActivateTime,
  683. @InternalEquipmentCode, @DeviceAllocationCount, @MoldAllocationCount,
  684. @SkillNo, @AssignedPersonnelCount,
  685. @PeriodDetRecId,
  686. @Remark, @CreateTime, @TenantId
  687. )
  688. """,
  689. new SugarParameter("@RecId", recId),
  690. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  691. new SugarParameter("@WorkOrd", wo.WorkOrd),
  692. new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
  693. new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
  694. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  695. new SugarParameter("@Op", routing.Op),
  696. new SugarParameter("@WorkDate", planDate),
  697. new SugarParameter("@WorkQty", wo.QtyOrded ?? 0),
  698. new SugarParameter("@WorkSort", seq),
  699. new SugarParameter("@OpDescr", string.IsNullOrWhiteSpace(routing.OpDescr) ? (object)DBNull.Value : routing.OpDescr.Trim()),
  700. new SugarParameter("@SetupTime", routing.SetupTime ?? (object)DBNull.Value),
  701. new SugarParameter("@WorkActivateTime", now),
  702. new SugarParameter("@InternalEquipmentCode", string.IsNullOrWhiteSpace(machineCode) ? (object)DBNull.Value : machineCode.Trim()),
  703. new SugarParameter("@DeviceAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
  704. new SugarParameter("@MoldAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
  705. new SugarParameter("@SkillNo", string.IsNullOrWhiteSpace(skillNo) ? (object)DBNull.Value : skillNo.Trim()),
  706. new SugarParameter("@AssignedPersonnelCount", assignedPersonnelCount ?? (object)DBNull.Value),
  707. new SugarParameter("@PeriodDetRecId", periodDetRecId ?? (object)DBNull.Value),
  708. new SugarParameter("@Remark", string.Empty),
  709. new SugarParameter("@CreateTime", now),
  710. new SugarParameter("@TenantId", tenantId));
  711. }
  712. public sealed class ScheduleGenerationResult
  713. {
  714. public int WorkOrderCount { get; set; }
  715. public int ScheduleRowCount { get; set; }
  716. public bool UsedWorkCenterCalendar { get; set; }
  717. public List<string> SkippedWorkOrders { get; set; } = new();
  718. public string Message { get; set; } = string.Empty;
  719. }
  720. /// <summary>
  721. /// 计算工单齐套数量:MIN(可用库存 / qty) 得可生产成品数,超过工单需求量则取工单需求量。
  722. /// 若工单领料单已全部发完料,则齐套数量 = 工单需求量。
  723. /// consumedStock 跟踪已被高优先级工单占用的库存量,避免重复占用。
  724. /// </summary>
  725. private async Task<decimal?> CalcLocationStockAsync(
  726. long tenantId, string workOrd, decimal qtyOrded,
  727. Dictionary<string, decimal> consumedStock)
  728. {
  729. // 检查领料单是否已全部发完料
  730. var allIssued = await _db.Ado.GetIntAsync(
  731. """
  732. SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
  733. FROM NbrMaster nm
  734. WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd
  735. AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1
  736. AND NOT EXISTS (
  737. SELECT 1 FROM NbrDetail nd
  738. WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr
  739. AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1
  740. AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0)
  741. )
  742. """,
  743. new SugarParameter("@TenantId", tenantId),
  744. new SugarParameter("@WorkOrd", workOrd));
  745. if (allIssued > 0 && qtyOrded > 0)
  746. return qtyOrded;
  747. // 从最新资源检查获取各物料的 use_qty 和 qty
  748. var materials = await _db.Ado.SqlQueryAsync<LocationStockMaterialRow>(
  749. """
  750. SELECT
  751. bce.item_number AS ItemNumber,
  752. IFNULL(bce.use_qty, 0) AS UseQty,
  753. IFNULL(bce.qty, 0) AS Qty
  754. FROM b_examine_result ber
  755. INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
  756. WHERE ber.tenant_id = @TenantId
  757. AND ber.IsDeleted = 0
  758. AND ber.morder_no = @WorkOrd
  759. AND ber.Id = (
  760. SELECT br.Id FROM b_examine_result br
  761. WHERE br.tenant_id = @TenantId AND br.morder_no = @WorkOrd AND br.IsDeleted = 0
  762. ORDER BY br.create_time DESC LIMIT 1
  763. )
  764. AND IFNULL(bce.qty, 0) > 0
  765. AND IFNULL(bce.erp_cls, 3) = 3
  766. """,
  767. new SugarParameter("@TenantId", tenantId),
  768. new SugarParameter("@WorkOrd", workOrd));
  769. if (materials.Count == 0)
  770. return null;
  771. // MIN(可用库存 / qty) = 扣减已占用后的瓶颈物料可生产成品数
  772. decimal? minProducible = null;
  773. foreach (var mat in materials)
  774. {
  775. if (mat.Qty <= 0) continue;
  776. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  777. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  778. var producible = availableUseQty / mat.Qty;
  779. if (minProducible is null || producible < minProducible)
  780. minProducible = producible;
  781. }
  782. if (minProducible is null)
  783. return null;
  784. // 可生产数 > 工单需求量则取工单需求量
  785. var result = Math.Ceiling(minProducible.Value);
  786. if (qtyOrded > 0 && result > qtyOrded)
  787. result = qtyOrded;
  788. // 记录本工单占用的库存量
  789. foreach (var mat in materials)
  790. {
  791. if (mat.Qty <= 0) continue;
  792. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  793. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  794. var consume = Math.Min(availableUseQty, result * mat.Qty);
  795. if (!consumedStock.ContainsKey(mat.ItemNumber))
  796. consumedStock[mat.ItemNumber] = 0m;
  797. consumedStock[mat.ItemNumber] += consume;
  798. }
  799. return result;
  800. }
  801. private sealed class LocationStockMaterialRow
  802. {
  803. public string ItemNumber { get; set; } = string.Empty;
  804. public decimal UseQty { get; set; }
  805. public decimal Qty { get; set; }
  806. }
  807. private sealed class PendingWorkOrderRow
  808. {
  809. public long RecId { get; set; }
  810. public string WorkOrd { get; set; } = string.Empty;
  811. public string? ItemNum { get; set; }
  812. public string? Domain { get; set; }
  813. public decimal? QtyOrded { get; set; }
  814. public DateTime? OrdDate { get; set; }
  815. public DateTime? DueDate { get; set; }
  816. public decimal? Priority { get; set; }
  817. public int? Urgent { get; set; }
  818. public string Status { get; set; } = string.Empty;
  819. }
  820. private sealed class RoutingRow
  821. {
  822. public int Op { get; set; }
  823. public string? ProdLine { get; set; }
  824. public string? WorkCtr { get; set; }
  825. public string? Site { get; set; }
  826. public string? StdOp { get; set; }
  827. public string? Machine { get; set; }
  828. public string? Engineer { get; set; }
  829. public decimal RunCrew { get; set; }
  830. public decimal? MachBdnRate { get; set; }
  831. public int? MachinestPerOp { get; set; }
  832. public decimal? RunTime { get; set; }
  833. public string? WorkCode { get; set; }
  834. public string? OpType { get; set; }
  835. public string? OpDescr { get; set; }
  836. public decimal? SetupTime { get; set; }
  837. }
  838. private sealed class CalendarRow
  839. {
  840. public string? WorkCtr { get; set; }
  841. public int? WeekDay { get; set; }
  842. }
  843. private sealed class OccupiedSlotRow
  844. {
  845. public string? WorkCtr { get; set; }
  846. public DateTime? PlanDate { get; set; }
  847. }
  848. }