ProductionScheduleGenerationService.cs 102 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507
  1. namespace Admin.NET.Plugin.AiDOP.Production;
  2. using System.Diagnostics;
  3. using System.Text.Json;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform;
  5. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  6. using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  7. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  8. using Admin.NET.Plugin.AiDOP.WorkOrder;
  9. using Yitter.IdGenerator;
  10. /// <summary>生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让),并经 Outbox 回写 165。</summary>
  11. public class ProductionScheduleGenerationService : ITransient
  12. {
  13. private readonly ISqlSugarClient _db;
  14. private readonly WorkOrderKittingCheckService _kittingCheck;
  15. private readonly MdpOutboxEnqueueService _enqueue;
  16. private readonly MdpSourceScopeFactory _scopeFactory;
  17. private readonly WorkOrdMesPushService _workOrdMesPush;
  18. private readonly short _defaultPeriod;
  19. public ProductionScheduleGenerationService(
  20. ISqlSugarClient db,
  21. WorkOrderKittingCheckService kittingCheck,
  22. MdpOutboxEnqueueService enqueue,
  23. MdpSourceScopeFactory scopeFactory,
  24. WorkOrdMesPushService workOrdMesPush)
  25. {
  26. _db = db;
  27. _kittingCheck = kittingCheck;
  28. _enqueue = enqueue;
  29. _scopeFactory = scopeFactory;
  30. _workOrdMesPush = workOrdMesPush;
  31. _defaultPeriod = ResolveDefaultPeriod();
  32. }
  33. private static short ResolveDefaultPeriod()
  34. {
  35. try
  36. {
  37. var v = App.GetConfig<int?>("AiDOP:Schedule:DefaultPeriod", true);
  38. if (v is > 0 and <= short.MaxValue) return (short)v.Value;
  39. }
  40. catch
  41. {
  42. // 配置缺失时回落默认班次 1
  43. }
  44. return 1;
  45. }
  46. public const string ActionScheduleGenerate = "S2_SCHEDULE_GENERATE";
  47. /// <summary>产线产能排期视界(自然日)。超期仍未排完则写异常,不再静默丢量。</summary>
  48. private const int ScheduleHorizonDays = 1095;
  49. private static readonly SemaphoreSlim GlobalScheduleGate = new(1, 1);
  50. public async Task<ScheduleGenerationResult> GenerateAsync(
  51. long tenantId,
  52. string? domain,
  53. string account,
  54. bool enableCapacityConstraint = true,
  55. long? runId = null)
  56. {
  57. await GlobalScheduleGate.WaitAsync();
  58. try
  59. {
  60. var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
  61. if (workOrders.Count == 0)
  62. return new ScheduleGenerationResult { Message = "没有待排产的工单(状态 p/r)" };
  63. var bangId = runId is > 0 ? runId.Value : YitIdHelper.NextId();
  64. return await ScheduleWorkOrdersAsync(
  65. tenantId, domain, account, workOrders, enableCapacityConstraint,
  66. bangId, isFullRun: true);
  67. }
  68. finally
  69. {
  70. GlobalScheduleGate.Release();
  71. }
  72. }
  73. public async Task<ScheduleGenerationResult> RegenerateForWorkOrderAsync(
  74. long tenantId,
  75. string workOrd,
  76. string? domain,
  77. string account,
  78. bool enableCapacityConstraint = true)
  79. {
  80. await GlobalScheduleGate.WaitAsync();
  81. try
  82. {
  83. var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  84. """
  85. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent
  86. FROM WorkOrdMaster
  87. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  88. LIMIT 1
  89. """,
  90. new SugarParameter("@TenantId", tenantId),
  91. new SugarParameter("@WorkOrd", workOrd.Trim()));
  92. var wo = rows.FirstOrDefault();
  93. if (wo is null)
  94. return new ScheduleGenerationResult { Message = $"工单 {workOrd} 不存在" };
  95. // 单工单重排:复用当前有效 S2 版本,只清本工单占用,避免洗掉全租户预留
  96. var bangId = await ResolveCurrentScheduleBangIdAsync(tenantId) ?? YitIdHelper.NextId();
  97. return await ScheduleWorkOrdersAsync(
  98. tenantId, domain, account, new List<PendingWorkOrderRow> { wo }, enableCapacityConstraint,
  99. bangId, isFullRun: false);
  100. }
  101. finally
  102. {
  103. GlobalScheduleGate.Release();
  104. }
  105. }
  106. private async Task<long?> ResolveCurrentScheduleBangIdAsync(long tenantId)
  107. {
  108. var id = await _db.Ado.GetLongAsync(
  109. """
  110. SELECT id FROM aidop_action_run_log
  111. WHERE tenant_id = @TenantId AND action_code = @ActionCode AND status = 'SUCCESS'
  112. ORDER BY start_time DESC, id DESC
  113. LIMIT 1
  114. """,
  115. new List<SugarParameter>
  116. {
  117. new("@TenantId", tenantId),
  118. new("@ActionCode", ActionScheduleGenerate)
  119. });
  120. return id > 0 ? id : null;
  121. }
  122. private async Task<ScheduleGenerationResult> ScheduleWorkOrdersAsync(
  123. long tenantId,
  124. string? domain,
  125. string account,
  126. List<PendingWorkOrderRow> workOrders,
  127. bool enableCapacityConstraint,
  128. long scheduleBangId,
  129. bool isFullRun)
  130. {
  131. var totalSw = Stopwatch.StartNew();
  132. var now = DateTime.Now;
  133. var calendar = await LoadWorkCenterCalendarAsync(tenantId);
  134. var occupiedSlots = await LoadOccupiedSlotsAsync(tenantId);
  135. var usedCalendar = calendar.Count > 0;
  136. var scheduledCount = 0;
  137. var rowCount = 0;
  138. var frozenWorkOrderCount = 0;
  139. var frozenOpCount = 0;
  140. var skipped = new List<string>();
  141. var pendingPush = new List<PendingPsdPushRow>();
  142. // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量)
  143. var consumedStock = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
  144. // 诊断计时
  145. var kittingMs = 0L;
  146. var otherMs = 0L;
  147. // 冻结期:已全发料工单作为既定约束载入,不参与重排
  148. var frozenWorkOrds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  149. foreach (var wo in workOrders)
  150. {
  151. if (await IsWorkOrderFullyFrozenAsync(tenantId, wo.WorkOrd))
  152. frozenWorkOrds.Add(wo.WorkOrd);
  153. }
  154. if (frozenWorkOrds.Count > 0)
  155. {
  156. await SeedConsumedStockFromFrozenAsync(tenantId, frozenWorkOrds, consumedStock);
  157. frozenWorkOrderCount = frozenWorkOrds.Count;
  158. }
  159. // H1/H3:产线产能占用从独立表加载。排除本批即将重算的非冻结工单(全量与单工单重排均排除,避免跟自己抢产能)
  160. var excludeFromCapacity = workOrders.Select(x => x.WorkOrd)
  161. .Where(x => !string.IsNullOrWhiteSpace(x) && !frozenWorkOrds.Contains(x))
  162. .ToHashSet(StringComparer.OrdinalIgnoreCase);
  163. var baseRunId = await ResolveCurrentScheduleBangIdAsync(tenantId);
  164. var lineCapacity = await LoadProdLineCapacityContextAsync(tenantId, baseRunId, excludeFromCapacity);
  165. var useLineCapacity = lineCapacity.Enabled;
  166. // H1:冻结工单占用结转到新 run_id(否则清理旧版本时保护消失)
  167. if (isFullRun && frozenWorkOrds.Count > 0 && baseRunId is > 0)
  168. await CarryOverFrozenCapacityOccupyAsync(tenantId, baseRunId.Value, scheduleBangId, frozenWorkOrds);
  169. // 占用记账:全量重排写新版本;单工单重排只清本批工单在当前版本下的占用
  170. if (!isFullRun)
  171. {
  172. foreach (var woNo in workOrders.Select(x => x.WorkOrd).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct())
  173. {
  174. await _db.Ado.ExecuteCommandAsync(
  175. """
  176. DELETE FROM ic_item_stockoccupy
  177. WHERE tenant_id = @TenantId AND bang_id = @BangId AND morder_mo = @WorkOrd
  178. """,
  179. new SugarParameter("@TenantId", tenantId),
  180. new SugarParameter("@BangId", scheduleBangId),
  181. new SugarParameter("@WorkOrd", woNo));
  182. await _db.Ado.ExecuteCommandAsync(
  183. """
  184. DELETE FROM srm_po_occupy
  185. WHERE tenant_id = @TenantId AND bang_id = @BangId AND morder_mo = @WorkOrd
  186. """,
  187. new SugarParameter("@TenantId", tenantId),
  188. new SugarParameter("@BangId", scheduleBangId),
  189. new SugarParameter("@WorkOrd", woNo));
  190. await _db.Ado.ExecuteCommandAsync(
  191. """
  192. DELETE FROM aidop_s2_line_capacity_occupy
  193. WHERE tenant_id = @TenantId AND run_id = @BangId AND work_ord = @WorkOrd
  194. """,
  195. new SugarParameter("@TenantId", tenantId),
  196. new SugarParameter("@BangId", scheduleBangId),
  197. new SugarParameter("@WorkOrd", woNo));
  198. }
  199. }
  200. // 三张表的 RecID 都是全表主键,号段必须按全表 MAX 分配,不能按租户从 1 重新开始。
  201. // 跨租户排程由统一调度 key 串行执行,避免多个任务读取同一 MAX 后发生主键碰撞。
  202. _periodRecIdSeq = await _db.Ado.GetLongAsync(
  203. "SELECT IFNULL(MAX(RecID), 0) FROM PeriodSequenceDet");
  204. _exceptionRecIdSeq = await _db.Ado.GetLongAsync(
  205. "SELECT IFNULL(MAX(RecID), 0) FROM ScheduleExceptionMaster");
  206. _resultOpRecIdSeq = await _db.Ado.GetLongAsync(
  207. "SELECT IFNULL(MAX(RecID), 0) FROM ScheduleResultOpMaster");
  208. var woIndex = 0;
  209. foreach (var wo in workOrders)
  210. {
  211. woIndex++;
  212. var woSw = Stopwatch.StartNew();
  213. if (frozenWorkOrds.Contains(wo.WorkOrd))
  214. {
  215. var keptOps = await CountActiveOpsAsync(tenantId, wo.WorkOrd);
  216. frozenOpCount += keptOps;
  217. skipped.Add($"{wo.WorkOrd}(冻结:已开工或已全发料,保留原计划)");
  218. woSw.Stop();
  219. otherMs += woSw.ElapsedMilliseconds;
  220. continue;
  221. }
  222. // 0. 先加载工艺路线,无路线的工单跳过齐套检查(性能优化)
  223. var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd, wo.ItemNum);
  224. if (routings.Count == 0)
  225. {
  226. skipped.Add($"{wo.WorkOrd}(无关键工序)");
  227. await InsertScheduleExceptionAsync(tenantId, wo, domain, "无关键工序", "工单无关键工序(MilestoneOp=1),无法生成排程");
  228. woSw.Stop();
  229. otherMs += woSw.ElapsedMilliseconds;
  230. if (woIndex % 5 == 0 || woIndex == workOrders.Count)
  231. {
  232. Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
  233. $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
  234. $"本工单[{wo.WorkOrd}]=跳过(无路线)");
  235. }
  236. continue;
  237. }
  238. // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine)
  239. var kitSw = Stopwatch.StartNew();
  240. try
  241. {
  242. await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId, checkScope: "SCHEDULE");
  243. }
  244. catch (Exception ex)
  245. {
  246. skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}");
  247. }
  248. kitSw.Stop();
  249. kittingMs += kitSw.ElapsedMilliseconds;
  250. // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存
  251. var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock);
  252. if (locationStock.HasValue)
  253. {
  254. await _db.Ado.ExecuteCommandAsync(
  255. """
  256. UPDATE WorkOrdMaster
  257. SET LocationStock = @Stock, UpdateTime = @Now
  258. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  259. """,
  260. new SugarParameter("@Stock", locationStock.Value),
  261. new SugarParameter("@Now", now),
  262. new SugarParameter("@TenantId", tenantId),
  263. new SugarParameter("@WorkOrd", wo.WorkOrd));
  264. }
  265. // 检查各道工序的数据完整性(标准工艺路线、设备、技能)
  266. var missingStdOp = routings.Where(r => string.IsNullOrWhiteSpace(r.StdOp)).Select(r => r.Op).ToList();
  267. var missingWorkCtr = routings.Where(r => string.IsNullOrWhiteSpace(r.WorkCtr)).Select(r => r.Op).ToList();
  268. var missingMachine = routings.Where(r => string.IsNullOrWhiteSpace(r.Machine)).Select(r => r.Op).ToList();
  269. var missingEngineer = routings.Where(r => string.IsNullOrWhiteSpace(r.Engineer)).Select(r => r.Op).ToList();
  270. if (missingStdOp.Count > 0)
  271. {
  272. var ops = string.Join("、", missingStdOp);
  273. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到标准工艺路线)");
  274. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到标准工艺路线",
  275. $"工序 {ops} 未查到标准工艺路线,排程可能不准确");
  276. }
  277. var missingEquip = missingWorkCtr.Concat(missingMachine).Distinct().ToList();
  278. if (missingEquip.Count > 0)
  279. {
  280. var ops = string.Join("、", missingEquip);
  281. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到设备)");
  282. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到设备",
  283. $"工序 {ops} 未查到设备(WorkCtr或设备编码为空),排程可能不准确");
  284. }
  285. if (missingEngineer.Count > 0)
  286. {
  287. var ops = string.Join("、", missingEngineer);
  288. skipped.Add($"{wo.WorkOrd}(工序{ops}未查到技能)");
  289. await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到技能",
  290. $"工序 {ops} 未查到技能(SkillNo为空),排程可能不准确");
  291. }
  292. var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
  293. var startedOpKeys = await LoadStartedPsdKeysFrom165Async(wo.WorkOrd);
  294. await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd, account, domain, occupiedSlots, startedOpKeys);
  295. // 冻结工序的既有计划日(作废后仍保留 IsActive=1),用于推进游标
  296. var activePlanByOp = await LoadActivePlanDatesByOpAsync(tenantId, wo.WorkOrd);
  297. // 计划日期下界:不早于 max(今天, OrdDate)
  298. var planStart = MaxDate(DateTime.Today, (wo.OrdDate ?? now).Date);
  299. var planEnd = (wo.DueDate ?? planStart.AddDays(Math.Max(routings.Count, 7))).Date;
  300. if (planEnd < planStart)
  301. planEnd = planStart;
  302. var dueDate = wo.DueDate?.Date;
  303. var cursor = planStart;
  304. var seq = 1;
  305. for (var i = 0; i < routings.Count; i++)
  306. {
  307. var routing = routings[i];
  308. // 部分冻结:165 已开工工序保留原计划,不重排;游标推进到其计划日之后
  309. if (startedOpKeys.Count > 0 && IsOpStarted(startedOpKeys, woDomain, routing, wo))
  310. {
  311. frozenOpCount++;
  312. if (activePlanByOp.TryGetValue(routing.Op, out var frozenPlan))
  313. cursor = MaxDate(cursor, frozenPlan.AddDays(1));
  314. seq++;
  315. continue;
  316. }
  317. var prodLine = routing.ProdLine?.Trim() ?? "";
  318. var rate = await ResolveProdLineRateAsync(tenantId, wo.ItemNum, routing.Op, routing);
  319. DateTime planDate;
  320. decimal? effectiveQty = null;
  321. CapacityAllocation? capacityAlloc = null;
  322. if (useLineCapacity && string.IsNullOrWhiteSpace(prodLine))
  323. {
  324. await InsertScheduleExceptionAsync(tenantId, wo, domain, "产线为空",
  325. $"工单{wo.WorkOrd}工序{routing.Op}产线(Line/ProdLine)为空,不参与产能占用");
  326. }
  327. if (useLineCapacity && !string.IsNullOrWhiteSpace(prodLine) && rate > 0)
  328. {
  329. var qtyNeed = wo.QtyOrded ?? 0;
  330. capacityAlloc = AllocateByProdLineCapacity(
  331. cursor, prodLine, qtyNeed, rate, routing.SetupTime ?? 0, lineCapacity);
  332. if (capacityAlloc.NoCapacityCalendar)
  333. {
  334. await InsertScheduleExceptionAsync(tenantId, wo, domain, "产线无可用工作日历",
  335. $"产线{prodLine}无可用工作日历,工序{routing.Op}未参与产能占用");
  336. capacityAlloc = null;
  337. }
  338. }
  339. if (capacityAlloc != null)
  340. {
  341. var qtyNeed = wo.QtyOrded ?? 0;
  342. planDate = capacityAlloc.StartDate;
  343. effectiveQty = qtyNeed;
  344. if (capacityAlloc.Daily.Count > 0)
  345. {
  346. await InsertCapacityOccupyBatchAsync(
  347. tenantId, scheduleBangId, prodLine, wo.WorkOrd, routing.Op, capacityAlloc.Daily);
  348. }
  349. if (capacityAlloc.Exhausted)
  350. {
  351. await InsertScheduleExceptionAsync(tenantId, wo, domain, "排程视界耗尽",
  352. $"排程视界耗尽:工单{wo.WorkOrd}工序{routing.Op}在产线{prodLine}上 {ScheduleHorizonDays} 天内仍有 {capacityAlloc.RemainingUnallocated:0.####} 件未排入,请检查产能或数量");
  353. }
  354. if (dueDate.HasValue && capacityAlloc.EndDate.Date > dueDate.Value)
  355. {
  356. var lateDays = (capacityAlloc.EndDate.Date - dueDate.Value).Days;
  357. await InsertScheduleExceptionAsync(tenantId, wo, domain, "产能不足/交期不可达",
  358. $"产能不足 / 交期不可达:工单{wo.WorkOrd}工序{routing.Op}预计 {capacityAlloc.EndDate:yyyy-MM-dd} 完工,晚于交期 {dueDate:yyyy-MM-dd} 共 {lateDays} 天");
  359. }
  360. cursor = capacityAlloc.EndDate.AddDays(1);
  361. }
  362. else if (usedCalendar)
  363. {
  364. planDate = ResolveNextAvailablePlanDate(cursor, routing.WorkCtr, calendar, occupiedSlots);
  365. cursor = planDate.AddDays(1);
  366. }
  367. else
  368. {
  369. var spanDays = Math.Max((planEnd - planStart).Days, 0);
  370. planDate = routings.Count <= 1
  371. ? planStart
  372. : planStart.AddDays(spanDays * i / Math.Max(routings.Count - 1, 1));
  373. }
  374. if (planDate < DateTime.Today)
  375. planDate = DateTime.Today;
  376. if (planDate > planEnd)
  377. planEnd = planDate;
  378. // 产线产能占用已写入独立表;非产能路径仍用 WorkCtr 日槽避让
  379. if (capacityAlloc is null)
  380. {
  381. var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
  382. if (slotKey != null)
  383. occupiedSlots.Add(slotKey);
  384. }
  385. // 始终解析设备编码和技能编号(供 ScheduleResultOpMaster 写入)
  386. var capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
  387. var capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
  388. var isNonManual = !string.IsNullOrWhiteSpace(routing.WorkCode)
  389. ? routing.WorkCode.Trim().ToUpperInvariant() != "P"
  390. : (!string.IsNullOrWhiteSpace(routing.OpType)
  391. && routing.OpType.Trim().ToUpperInvariant() != "P");
  392. // 非人工工序:无设备可用时写入排产异常(信息性,不阻断产线产能排期)
  393. if (isNonManual && string.IsNullOrWhiteSpace(capacityMachineCode))
  394. {
  395. await InsertScheduleExceptionAsync(tenantId, wo, domain, "无设备可用",
  396. $"工单{wo.WorkOrd}工序{routing.Op}无设备可用");
  397. }
  398. var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
  399. int? capacityDeviceAllocation = null;
  400. decimal? capacityAssignedPersonnel = null;
  401. // 设备占用数兜底(不再用 MachBdnRate 做台数公式——量纲错误已废弃)
  402. {
  403. var desired = routing.MachinestPerOp > 0 ? routing.MachinestPerOp.Value : 1;
  404. capacityDeviceAllocation = equipCount > 0 ? Math.Min(desired, equipCount) : desired;
  405. }
  406. // enableCapacityConstraint:产线产能模式下以当日剩余产能为准(已在 Allocate 中体现)
  407. _ = enableCapacityConstraint;
  408. var recId = (int)NextPeriodRecId();
  409. var ordQty = effectiveQty ?? wo.QtyOrded ?? 0;
  410. await InsertScheduleRowAsync(
  411. recId, woDomain, routing, wo, planDate, seq, account, now, tenantId,
  412. capacityQty: effectiveQty);
  413. // 始终写入 ScheduleResultOpMaster(基础排产数据 + 可选产能分配数据)
  414. var resultRecId = NextScheduleResultOpRecId();
  415. await InsertScheduleResultOpAsync(
  416. resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
  417. capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel,
  418. periodDetRecId: recId);
  419. var line = routing.ProdLine?.Trim() ?? string.Empty;
  420. if (string.IsNullOrWhiteSpace(line))
  421. skipped.Add($"{wo.WorkOrd}(工序{routing.Op}无产线,跳过日计划回写)");
  422. else if (ordQty <= 0)
  423. skipped.Add($"{wo.WorkOrd}(工序{routing.Op}计划数为0,跳过日计划回写)");
  424. else
  425. {
  426. pendingPush.Add(new PendingPsdPushRow
  427. {
  428. Domain = woDomain.Length > 8 ? woDomain[..8] : woDomain,
  429. Line = line,
  430. ItemNum = wo.ItemNum ?? string.Empty,
  431. PlanDate = planDate.Date,
  432. Period = _defaultPeriod,
  433. Op = routing.Op,
  434. WorkOrds = wo.WorkOrd,
  435. Sequence = seq,
  436. OrdQty = ordQty
  437. });
  438. }
  439. seq++;
  440. rowCount++;
  441. }
  442. scheduledCount++;
  443. woSw.Stop();
  444. otherMs += woSw.ElapsedMilliseconds - kitSw.ElapsedMilliseconds;
  445. // 每5个工单输出一次诊断日志
  446. if (woIndex % 5 == 0 || woIndex == workOrders.Count)
  447. {
  448. Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
  449. $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
  450. $"本工单[{wo.WorkOrd}]={woSw.ElapsedMilliseconds}ms");
  451. }
  452. }
  453. // P-031 K4:推日计划前先入队工单三人组(WOM/WOD/WOR),保证 MES APP 取数有 MilestoneOp
  454. var mesPushWorkOrds = pendingPush
  455. .Select(x => x.WorkOrds)
  456. .Where(x => !string.IsNullOrWhiteSpace(x))
  457. .Distinct(StringComparer.OrdinalIgnoreCase)
  458. .ToList();
  459. foreach (var wo in mesPushWorkOrds)
  460. await _workOrdMesPush.TryEnqueueSnapshotSafeAsync(tenantId, wo, skipped);
  461. var pushEnqueued = 0;
  462. try
  463. {
  464. pushEnqueued = await EnqueuePsdUpsertsAsync(tenantId, account, now, pendingPush);
  465. }
  466. catch (Exception ex)
  467. {
  468. skipped.Add($"日计划回写入队失败: {ex.Message}");
  469. }
  470. // 全量重排成功后清理本租户旧版本占用(保留本 run 与 S1 当前有效版本)
  471. // 含「全部冻结仅结转产能占用」的情况,否则旧 run 的占用会堆积
  472. if (isFullRun && (scheduledCount > 0 || frozenWorkOrderCount > 0))
  473. await CleanupStaleOccupyAsync(tenantId, scheduleBangId);
  474. // P-029 I3/I4:与 165 对账,补发孤儿生效行的作废;并汇总告警指标
  475. var workOrdKeys = workOrders
  476. .Select(x => x.WorkOrd)
  477. .Where(x => !string.IsNullOrWhiteSpace(x))
  478. .Distinct(StringComparer.OrdinalIgnoreCase)
  479. .ToList();
  480. var reconcile = await ReconcileStale165PsdAsync(
  481. tenantId, account, now, scheduleBangId, workOrdKeys);
  482. if (reconcile.Enqueued > 0)
  483. pushEnqueued += reconcile.Enqueued;
  484. try
  485. {
  486. await CleanupStalePsdDeactOutboxAsync();
  487. }
  488. catch
  489. {
  490. // 清理失败不影响排程结果
  491. }
  492. var outboxDeadCount = await CountOutboxDeadAsync(tenantId);
  493. totalSw.Stop();
  494. Console.WriteLine($"[排程诊断] 完成: {workOrders.Count}工单/{rowCount}条, " +
  495. $"齐套检查={kittingMs}ms 其他={otherMs}ms 总计={totalSw.ElapsedMilliseconds}ms, 日计划入队={pushEnqueued}, " +
  496. $"165对账差={reconcile.MismatchCount} 重复组={reconcile.DuplicateActiveCount} 自动作废={reconcile.DuplicateAutoCount} 人工={reconcile.DuplicateManualCount} 兜底作废={reconcile.Enqueued}" +
  497. $" 缺产线={reconcile.MesLineMissingCount} 缺工序={reconcile.MesRoutingMissingCount}");
  498. if (reconcile.DuplicateManualCount > 0)
  499. skipped.Add($"165 有 {reconcile.DuplicateManualCount} 组重复日计划含已投产行,已告警未自动作废");
  500. if (reconcile.MesLineMissingCount > 0)
  501. skipped.Add($"165 产线主数据缺失 {reconcile.MesLineMissingCount} 行,MES APP 无法显示这些产线的计划");
  502. if (reconcile.MesRoutingMissingCount > 0)
  503. skipped.Add($"165 工单工序缺失 {reconcile.MesRoutingMissingCount} 行,MES APP「投产」列表不会出现这些工单,请检查工单三人组推送");
  504. var message = scheduledCount > 0
  505. ? $"已为 {scheduledCount} 个工单生成 {rowCount} 条工序排程,已入队 {pushEnqueued} 条日计划回写(runId={scheduleBangId},冻结工单 {frozenWorkOrderCount}/工序 {frozenOpCount})"
  506. : frozenWorkOrderCount > 0
  507. ? $"未生成新排程:{frozenWorkOrderCount} 个工单处于冻结期"
  508. : "未生成排程,请确认工单已同步工艺路线";
  509. return new ScheduleGenerationResult
  510. {
  511. WorkOrderCount = scheduledCount,
  512. ScheduleRowCount = rowCount,
  513. PushEnqueuedCount = pushEnqueued,
  514. FrozenWorkOrderCount = frozenWorkOrderCount,
  515. FrozenOpCount = frozenOpCount,
  516. SkippedWorkOrders = skipped,
  517. UsedWorkCenterCalendar = usedCalendar || useLineCapacity,
  518. RunId = scheduleBangId,
  519. PsdMismatchCount = reconcile.MismatchCount,
  520. PsdDuplicateActiveCount = reconcile.DuplicateActiveCount,
  521. PsdDuplicateAutoCount = reconcile.DuplicateAutoCount,
  522. PsdDuplicateManualCount = reconcile.DuplicateManualCount,
  523. OutboxDeadCount = outboxDeadCount,
  524. MesLineMissingCount = reconcile.MesLineMissingCount,
  525. MesRoutingMissingCount = reconcile.MesRoutingMissingCount,
  526. Message = message
  527. };
  528. }
  529. private async Task InsertScheduleRowAsync(
  530. int recId,
  531. string woDomain,
  532. RoutingRow routing,
  533. PendingWorkOrderRow wo,
  534. DateTime planDate,
  535. int seq,
  536. string account,
  537. DateTime now,
  538. long tenantId,
  539. decimal? capacityQty = null)
  540. {
  541. var ordQty = capacityQty ?? wo.QtyOrded ?? 0;
  542. await _db.Ado.ExecuteCommandAsync(
  543. """
  544. INSERT INTO PeriodSequenceDet (
  545. RecID, `Domain`, Site, ItemNum, Line, Op, WorkCtr, ProdDate, PlanDate,
  546. Period, Sequence, OrdQty, CompQty, WorkOrds, Status, Employee,
  547. CreateUser, CreateTime, UpdateUser, UpdateTime,
  548. IsActive, IsConfirm, BusinessID, tenant_id
  549. ) VALUES (
  550. @RecId, @Domain, @Site, @ItemNum, @Line, @Op, @WorkCtr, @ProdDate, @PlanDate,
  551. @Period, @Sequence, @OrdQty, 0, @WorkOrd, '', '',
  552. @User, @Now, @User, @Now,
  553. 1, 0, @BusinessId, @TenantId
  554. )
  555. """,
  556. new SugarParameter("@RecId", recId),
  557. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  558. new SugarParameter("@Site", routing.Site ?? (object)DBNull.Value),
  559. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  560. new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
  561. new SugarParameter("@Op", routing.Op),
  562. new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
  563. new SugarParameter("@ProdDate", planDate),
  564. new SugarParameter("@PlanDate", planDate),
  565. new SugarParameter("@Period", _defaultPeriod),
  566. new SugarParameter("@Sequence", seq),
  567. new SugarParameter("@OrdQty", ordQty),
  568. new SugarParameter("@WorkOrd", wo.WorkOrd),
  569. new SugarParameter("@BusinessId", wo.RecId),
  570. new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
  571. new SugarParameter("@Now", now),
  572. new SugarParameter("@TenantId", tenantId));
  573. }
  574. private static DateTime ResolveNextAvailablePlanDate(
  575. DateTime start,
  576. string? workCtr,
  577. Dictionary<string, HashSet<int>> calendar,
  578. HashSet<string> occupiedSlots)
  579. {
  580. var date = start.Date;
  581. for (var guard = 0; guard < 366; guard++)
  582. {
  583. if (IsWorkDay(date, workCtr, calendar))
  584. {
  585. var key = BuildOccupancyKey(workCtr, date);
  586. // WorkCtr 为空时不参与占用判定,只看工作日
  587. if (key is null || !occupiedSlots.Contains(key))
  588. return date;
  589. }
  590. date = date.AddDays(1);
  591. }
  592. return start.Date;
  593. }
  594. private static bool IsWorkDay(DateTime date, string? workCtr, Dictionary<string, HashSet<int>> calendar)
  595. {
  596. if (calendar.Count == 0)
  597. return true;
  598. // 优先按具体工作中心;无工作中心时回退到通配日历(ProdLine 维度日历播种后 WorkCtr 为空)
  599. var key = string.IsNullOrWhiteSpace(workCtr) ? "*" : workCtr.Trim();
  600. if (!calendar.TryGetValue(key, out var days) || days.Count == 0)
  601. {
  602. if (key != "*" && calendar.TryGetValue("*", out var wildcard) && wildcard.Count > 0)
  603. days = wildcard;
  604. else
  605. return date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday;
  606. }
  607. var weekDay = (int)date.DayOfWeek;
  608. return days.Contains(weekDay);
  609. }
  610. /// <summary>
  611. /// 构建产能占用键。WorkCtr 为空时返回 null(不参与占用),避免归一化为 "*" 退化为全厂单槽。
  612. /// </summary>
  613. private static string? BuildOccupancyKey(string? workCtr, DateTime date)
  614. {
  615. if (string.IsNullOrWhiteSpace(workCtr))
  616. return null;
  617. return $"{workCtr.Trim()}|{date:yyyy-MM-dd}";
  618. }
  619. private static string NormalizeWorkCtr(string? workCtr) =>
  620. string.IsNullOrWhiteSpace(workCtr) ? "*" : workCtr.Trim();
  621. /// <summary>
  622. /// 加载工作中心日历。
  623. /// 注意:ShopCalendarWorkCtr 同时被 S1(按 ProdLine + ShiftsHours1)与 S2(按 WorkCtr + IsWorkDay)读取;
  624. /// 改动该表前须同时评估两侧。WorkCtr 为空的行记入通配键 "*",仅供工作日判定,不参与占用键。
  625. /// </summary>
  626. private async Task<Dictionary<string, HashSet<int>>> LoadWorkCenterCalendarAsync(long tenantId)
  627. {
  628. var rows = await _db.Ado.SqlQueryAsync<CalendarRow>(
  629. """
  630. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, WeekDay
  631. FROM ShopCalendarWorkCtr
  632. WHERE tenant_id = @TenantId
  633. AND IFNULL(IsActive, 0) = 1
  634. AND IFNULL(IsWorkDay, 0) = 1
  635. AND WeekDay IS NOT NULL
  636. """,
  637. new SugarParameter("@TenantId", tenantId));
  638. var map = new Dictionary<string, HashSet<int>>(StringComparer.OrdinalIgnoreCase);
  639. foreach (var row in rows)
  640. {
  641. if (row.WeekDay is null) continue;
  642. var key = NormalizeWorkCtr(row.WorkCtr);
  643. if (!map.TryGetValue(key, out var set))
  644. {
  645. set = new HashSet<int>();
  646. map[key] = set;
  647. }
  648. set.Add(row.WeekDay.Value);
  649. }
  650. return map;
  651. }
  652. private async Task<HashSet<string>> LoadOccupiedSlotsAsync(long tenantId)
  653. {
  654. var rows = await _db.Ado.SqlQueryAsync<OccupiedSlotRow>(
  655. """
  656. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, DATE(PlanDate) AS PlanDate
  657. FROM PeriodSequenceDet
  658. WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL
  659. AND TRIM(IFNULL(WorkCtr,'')) <> ''
  660. """,
  661. new SugarParameter("@TenantId", tenantId));
  662. return rows
  663. .Where(x => x.PlanDate.HasValue)
  664. .Select(x => BuildOccupancyKey(x.WorkCtr, x.PlanDate!.Value))
  665. .Where(x => x != null)
  666. .Cast<string>()
  667. .ToHashSet(StringComparer.OrdinalIgnoreCase);
  668. }
  669. private async Task<List<PendingWorkOrderRow>> LoadPendingWorkOrdersAsync(long tenantId)
  670. {
  671. return await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
  672. """
  673. SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent,
  674. IFNULL(Status, '') AS Status
  675. FROM WorkOrdMaster
  676. WHERE tenant_id = @TenantId
  677. AND LOWER(TRIM(IFNULL(Status,''))) IN ('w', 's', 'r', 'p')
  678. ORDER BY
  679. CASE LOWER(TRIM(IFNULL(Status,'')))
  680. WHEN 'w' THEN 1
  681. WHEN 's' THEN 1
  682. WHEN 'r' THEN 2
  683. WHEN 'p' THEN 3
  684. ELSE 4
  685. END,
  686. IFNULL(Urgent, 0) DESC,
  687. IFNULL(Priority, 0) ASC,
  688. DueDate, WorkOrd
  689. """,
  690. new SugarParameter("@TenantId", tenantId));
  691. }
  692. private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd, string? itemNum = null)
  693. {
  694. var routings = await _db.Ado.SqlQueryAsync<RoutingRow>(
  695. """
  696. SELECT r.OP AS Op,
  697. TRIM(CASE WHEN IFNULL(r.ProdLine, '') = '' THEN IFNULL(pd.Line, '') ELSE r.ProdLine END) AS ProdLine,
  698. r.WorkCtr,
  699. CASE WHEN IFNULL(r.WorkCtr, '') = '' THEN IFNULL(pd.Site, '') ELSE r.WorkCtr END AS Site,
  700. IFNULL(r.StdOp, '') AS StdOp,
  701. IFNULL(r.Machine, '') AS Machine,
  702. IFNULL(r.Engineer, '') AS Engineer,
  703. IFNULL(r.RunCrew, 0) AS RunCrew,
  704. IFNULL(r.MachBdnRate, 0) AS MachBdnRate,
  705. IFNULL(r.MachinesperOp, 0) AS MachinestPerOp,
  706. IFNULL(r.RunTime, 0) AS RunTime,
  707. IFNULL(r.StdRunTime, 0) AS StdRunTime,
  708. IFNULL(pd.Rate, 0) AS ProdLineRate,
  709. IFNULL(r.WorkCode, '') AS WorkCode,
  710. IFNULL(pd.OpType, '') AS OpType,
  711. IFNULL(r.Descr, '') AS OpDescr,
  712. IFNULL(r.StdSetupTime, 0) AS SetupTime
  713. FROM WorkOrdRouting r
  714. LEFT JOIN ProdLineDetail pd ON pd.Part = r.ItemNum AND pd.Op = r.OP AND pd.tenant_id = r.tenant_id
  715. WHERE r.tenant_id = @TenantId AND r.WorkOrd = @WorkOrd AND IFNULL(r.IsActive, 0) = 1 AND IFNULL(r.MilestoneOp, 0) = 1
  716. ORDER BY (r.OP + 0), r.OP
  717. """,
  718. new SugarParameter("@TenantId", tenantId),
  719. new SugarParameter("@WorkOrd", workOrd));
  720. // ── C# 层补充回退:若 SQL JOIN 未命中,用 WorkOrdMaster.ItemNum 直接查 ProdLineDetail ──
  721. var needFill = routings.Any(r => string.IsNullOrWhiteSpace(r.ProdLine)
  722. || string.IsNullOrWhiteSpace(r.Site) || string.IsNullOrWhiteSpace(r.OpType));
  723. if (needFill && !string.IsNullOrWhiteSpace(itemNum))
  724. {
  725. var fallback = await _db.Ado.SqlQueryAsync<ProdLineFallbackRow>(
  726. """
  727. SELECT Op, Line, Site, OpType
  728. FROM ProdLineDetail
  729. WHERE Part = @Part
  730. """,
  731. new SugarParameter("@Part", itemNum));
  732. var fbMap = fallback.ToDictionary(f => f.Op, f => f);
  733. foreach (var r in routings)
  734. {
  735. if (fbMap.TryGetValue(r.Op, out var fb))
  736. {
  737. if (string.IsNullOrWhiteSpace(r.ProdLine) && !string.IsNullOrWhiteSpace(fb.Line))
  738. r.ProdLine = fb.Line;
  739. if (string.IsNullOrWhiteSpace(r.Site) && !string.IsNullOrWhiteSpace(fb.Site))
  740. r.Site = fb.Site;
  741. if (string.IsNullOrWhiteSpace(r.OpType) && !string.IsNullOrWhiteSpace(fb.OpType))
  742. r.OpType = fb.OpType;
  743. }
  744. }
  745. }
  746. return routings;
  747. }
  748. private sealed class ProdLineFallbackRow
  749. {
  750. public int Op { get; set; }
  751. public string? Line { get; set; }
  752. public string? Site { get; set; }
  753. public string? OpType { get; set; }
  754. }
  755. public async Task DeactivateExistingScheduleAsync(
  756. long tenantId,
  757. string workOrd,
  758. string? account = null,
  759. string? domain = null,
  760. HashSet<string>? occupiedSlots = null,
  761. HashSet<string>? startedOpKeys = null)
  762. {
  763. var now = DateTime.Now;
  764. var user = TruncUser(account);
  765. // 先取出将要作废的槽位(含 WorkCtr),以便从内存占用集合中释放,避免计划逐次后漂
  766. var activeSlots = await _db.Ado.SqlQueryAsync<OccupiedSlotRow>(
  767. """
  768. SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, DATE(PlanDate) AS PlanDate
  769. FROM PeriodSequenceDet
  770. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd
  771. AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL
  772. """,
  773. new SugarParameter("@TenantId", tenantId),
  774. new SugarParameter("@WorkOrd", workOrd));
  775. var existing = await _db.Ado.SqlQueryAsync<PendingPsdPushRow>(
  776. """
  777. SELECT TRIM(IFNULL(`Domain`,'')) AS `Domain`,
  778. TRIM(IFNULL(Line,'')) AS Line,
  779. TRIM(IFNULL(ItemNum,'')) AS ItemNum,
  780. DATE(PlanDate) AS PlanDate,
  781. IFNULL(Period, 0) AS Period,
  782. Op,
  783. TRIM(IFNULL(WorkOrds,'')) AS WorkOrds,
  784. IFNULL(Sequence, 0) AS Sequence,
  785. IFNULL(OrdQty, 0) AS OrdQty
  786. FROM PeriodSequenceDet
  787. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND IFNULL(IsActive, 0) = 1
  788. """,
  789. new SugarParameter("@TenantId", tenantId),
  790. new SugarParameter("@WorkOrd", workOrd));
  791. var startedKeys = startedOpKeys ?? await LoadStartedPsdKeysFrom165Async(workOrd);
  792. var deactivateCandidates = new List<PendingPsdPushRow>();
  793. var frozenRecKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  794. foreach (var row in existing)
  795. {
  796. if (string.IsNullOrWhiteSpace(row.Line) || string.IsNullOrWhiteSpace(row.WorkOrds))
  797. continue;
  798. var key = BuildPsdBizKey(row);
  799. if (startedKeys.Contains(key))
  800. {
  801. frozenRecKeys.Add($"{row.Op}|{row.PlanDate:yyyy-MM-dd}|{row.Period}");
  802. try
  803. {
  804. await InsertScheduleExceptionAsync(
  805. tenantId,
  806. new PendingWorkOrderRow
  807. {
  808. WorkOrd = row.WorkOrds,
  809. ItemNum = row.ItemNum,
  810. Domain = row.Domain
  811. },
  812. domain ?? row.Domain,
  813. "现场已投产",
  814. $"工单{row.WorkOrds}工序{row.Op}产线{row.Line}在165已投产,跳过日计划作废回写");
  815. }
  816. catch
  817. {
  818. // 异常表写入失败不影响作废本库排程
  819. }
  820. continue;
  821. }
  822. deactivateCandidates.Add(row);
  823. }
  824. for (var i = 0; i < deactivateCandidates.Count; i++)
  825. {
  826. try
  827. {
  828. await EnqueuePsdDeactivateAsync(
  829. tenantId,
  830. user,
  831. now,
  832. deactivateCandidates[i],
  833. pulse: i == deactivateCandidates.Count - 1);
  834. }
  835. catch
  836. {
  837. // Outbox 失败不影响本库作废
  838. }
  839. }
  840. // 仅作废非冻结工序;已开工工序保持 IsActive=1
  841. if (frozenRecKeys.Count == 0)
  842. {
  843. await _db.Ado.ExecuteCommandAsync(
  844. """
  845. UPDATE PeriodSequenceDet
  846. SET IsActive = 0, UpdateTime = @Now
  847. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND IFNULL(IsActive, 0) = 1
  848. """,
  849. new SugarParameter("@Now", now),
  850. new SugarParameter("@TenantId", tenantId),
  851. new SugarParameter("@WorkOrd", workOrd));
  852. }
  853. else
  854. {
  855. foreach (var row in deactivateCandidates)
  856. {
  857. await _db.Ado.ExecuteCommandAsync(
  858. """
  859. UPDATE PeriodSequenceDet
  860. SET IsActive = 0, UpdateTime = @Now
  861. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND Op = @Op
  862. AND DATE(PlanDate) = @PlanDate AND Period = @Period AND IFNULL(IsActive, 0) = 1
  863. """,
  864. new SugarParameter("@Now", now),
  865. new SugarParameter("@TenantId", tenantId),
  866. new SugarParameter("@WorkOrd", workOrd),
  867. new SugarParameter("@Op", row.Op),
  868. new SugarParameter("@PlanDate", row.PlanDate.Date),
  869. new SugarParameter("@Period", row.Period));
  870. }
  871. }
  872. // 从内存占用集合释放本工单「非冻结」旧槽位
  873. if (occupiedSlots != null)
  874. {
  875. foreach (var slot in activeSlots)
  876. {
  877. if (!slot.PlanDate.HasValue) continue;
  878. var key = BuildOccupancyKey(slot.WorkCtr, slot.PlanDate.Value);
  879. if (key != null)
  880. occupiedSlots.Remove(key);
  881. }
  882. }
  883. // 清理旧的排产结果数据(冻结工单整体跳过本方法调用)
  884. await _db.Ado.ExecuteCommandAsync(
  885. """
  886. DELETE FROM ScheduleResultOpMaster
  887. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  888. """,
  889. new SugarParameter("@TenantId", tenantId),
  890. new SugarParameter("@WorkOrd", workOrd));
  891. }
  892. private async Task<bool> IsWorkOrderFullyFrozenAsync(long tenantId, string workOrd)
  893. {
  894. // 条件1:领料单已全部发完
  895. var allIssued = await _db.Ado.GetIntAsync(
  896. """
  897. SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
  898. FROM NbrMaster nm
  899. WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd
  900. AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1
  901. AND NOT EXISTS (
  902. SELECT 1 FROM NbrDetail nd
  903. WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr
  904. AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1
  905. AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0)
  906. )
  907. """,
  908. new SugarParameter("@TenantId", tenantId),
  909. new SugarParameter("@WorkOrd", workOrd));
  910. // 全发料 → 整单冻结。165 已开工仅冻结对应工序(见循环内 IsOpStarted),不整单跳过。
  911. return allIssued > 0;
  912. }
  913. private async Task SeedConsumedStockFromFrozenAsync(
  914. long tenantId,
  915. HashSet<string> frozenWorkOrds,
  916. Dictionary<string, decimal> consumedStock)
  917. {
  918. foreach (var wo in frozenWorkOrds)
  919. {
  920. var rows = await _db.Ado.SqlQueryAsync<OccupyRow>(
  921. """
  922. SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
  923. FROM ic_item_stockoccupy
  924. WHERE tenant_id = @TenantId AND morder_mo = @WorkOrd AND IsDeleted = 0
  925. GROUP BY icitem_number
  926. """,
  927. new SugarParameter("@TenantId", tenantId),
  928. new SugarParameter("@WorkOrd", wo));
  929. foreach (var r in rows)
  930. {
  931. if (string.IsNullOrWhiteSpace(r.ItemNumber)) continue;
  932. if (!consumedStock.ContainsKey(r.ItemNumber))
  933. consumedStock[r.ItemNumber] = 0m;
  934. consumedStock[r.ItemNumber] += r.TotalOccupied;
  935. }
  936. }
  937. }
  938. private async Task<int> CountActiveOpsAsync(long tenantId, string workOrd)
  939. {
  940. return await _db.Ado.GetIntAsync(
  941. """
  942. SELECT COUNT(*) FROM PeriodSequenceDet
  943. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND IFNULL(IsActive, 0) = 1
  944. """,
  945. new SugarParameter("@TenantId", tenantId),
  946. new SugarParameter("@WorkOrd", workOrd));
  947. }
  948. private static bool IsOpStarted(
  949. HashSet<string> startedKeys,
  950. string woDomain,
  951. RoutingRow routing,
  952. PendingWorkOrderRow wo)
  953. {
  954. // startedKeys 的业务键含 PlanDate/Period,此处按「同工单同工序」宽松匹配(Domain|Line|Item|...|Op|WorkOrds)
  955. var line = routing.ProdLine?.Trim() ?? "";
  956. var item = wo.ItemNum ?? "";
  957. var domain = woDomain.Length > 8 ? woDomain[..8] : woDomain;
  958. var suffix = $"|{routing.Op}|{wo.WorkOrd}";
  959. foreach (var key in startedKeys)
  960. {
  961. if (key.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)
  962. && key.StartsWith($"{domain}|{line}|{item}|", StringComparison.OrdinalIgnoreCase))
  963. return true;
  964. }
  965. return false;
  966. }
  967. private static DateTime MaxDate(DateTime a, DateTime b) => a >= b ? a : b;
  968. private sealed class OccupyRow
  969. {
  970. public string ItemNumber { get; set; } = string.Empty;
  971. public decimal TotalOccupied { get; set; }
  972. }
  973. /// <summary>产线产能上下文:日历日产能 + 当日剩余可排数量。</summary>
  974. private sealed class ProdLineCapacityContext
  975. {
  976. public bool Enabled { get; set; }
  977. /// <summary>ProdLine → WeekDay → (IsWorkDay, ShiftsHours1)</summary>
  978. public Dictionary<string, Dictionary<int, (bool IsWorkDay, decimal Hours)>> Calendars { get; set; } = new(StringComparer.OrdinalIgnoreCase);
  979. /// <summary>ProdLine → 休息分钟合计</summary>
  980. public Dictionary<string, decimal> RestMinutes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
  981. /// <summary>ProdLine|yyyy-MM-dd → 已占用件数(含 setup 折合件数,仅内存)</summary>
  982. public Dictionary<string, decimal> UsedQty { get; set; } = new(StringComparer.OrdinalIgnoreCase);
  983. }
  984. /// <summary>产线产能分配结果(开工首日 + 完工日 + 逐日明细)。</summary>
  985. private sealed class CapacityAllocation
  986. {
  987. public DateTime StartDate { get; init; }
  988. public DateTime EndDate { get; init; }
  989. public List<(DateTime Day, decimal Qty)> Daily { get; init; } = new();
  990. public bool Exhausted { get; init; }
  991. public bool NoCapacityCalendar { get; init; }
  992. public decimal RemainingUnallocated { get; init; }
  993. }
  994. private async Task<ProdLineCapacityContext> LoadProdLineCapacityContextAsync(
  995. long tenantId,
  996. long? baseRunId,
  997. HashSet<string> excludeWorkOrds)
  998. {
  999. var ctx = new ProdLineCapacityContext();
  1000. var calRows = await _db.Ado.SqlQueryAsync<ProdLineCalendarRow>(
  1001. """
  1002. SELECT TRIM(IFNULL(ProdLine,'')) AS ProdLine, WeekDay,
  1003. IFNULL(IsWorkDay, 0) AS IsWorkDay, IFNULL(ShiftsHours1, 0) AS ShiftsHours1
  1004. FROM ShopCalendarWorkCtr
  1005. WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1
  1006. AND TRIM(IFNULL(ProdLine,'')) <> '' AND WeekDay IS NOT NULL
  1007. """,
  1008. new SugarParameter("@TenantId", tenantId));
  1009. foreach (var row in calRows)
  1010. {
  1011. if (string.IsNullOrWhiteSpace(row.ProdLine) || row.WeekDay is null) continue;
  1012. if (!ctx.Calendars.TryGetValue(row.ProdLine, out var days))
  1013. {
  1014. days = new Dictionary<int, (bool, decimal)>();
  1015. ctx.Calendars[row.ProdLine] = days;
  1016. }
  1017. days[row.WeekDay.Value] = (row.IsWorkDay == 1, row.ShiftsHours1);
  1018. }
  1019. ctx.Enabled = ctx.Calendars.Values.Any(d => d.Count == 7);
  1020. if (!ctx.Enabled)
  1021. return ctx;
  1022. var restRows = await _db.Ado.SqlQueryAsync<ProdLineRestRow>(
  1023. """
  1024. SELECT TRIM(IFNULL(ProdLine,'')) AS ProdLine, IFNULL(SUM(RestTime), 0) AS RestTime
  1025. FROM QualityLineWorkDetail
  1026. WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1
  1027. AND TRIM(IFNULL(ProdLine,'')) <> ''
  1028. GROUP BY TRIM(IFNULL(ProdLine,''))
  1029. """,
  1030. new SugarParameter("@TenantId", tenantId));
  1031. foreach (var r in restRows)
  1032. ctx.RestMinutes[r.ProdLine] = r.RestTime;
  1033. // H1:占用从独立表按 run_id 加载(首轮空表视为全空闲,跑完一轮即自愈)
  1034. if (baseRunId is > 0)
  1035. {
  1036. var pars = new List<SugarParameter>
  1037. {
  1038. new("@TenantId", tenantId),
  1039. new("@BaseRunId", baseRunId.Value)
  1040. };
  1041. var sql = """
  1042. SELECT prod_line AS ProdLine, plan_date AS PlanDate,
  1043. IFNULL(SUM(qty), 0) AS UsedQty
  1044. FROM aidop_s2_line_capacity_occupy
  1045. WHERE tenant_id = @TenantId AND run_id = @BaseRunId
  1046. """;
  1047. if (excludeWorkOrds.Count > 0)
  1048. {
  1049. var names = new List<string>();
  1050. var idx = 0;
  1051. foreach (var wo in excludeWorkOrds)
  1052. {
  1053. var p = $"@Ex{idx++}";
  1054. names.Add(p);
  1055. pars.Add(new SugarParameter(p, wo));
  1056. }
  1057. sql += $" AND work_ord NOT IN ({string.Join(",", names)})";
  1058. }
  1059. sql += " GROUP BY prod_line, plan_date";
  1060. var usedRows = await _db.Ado.SqlQueryAsync<ProdLineUsedRow>(sql, pars);
  1061. foreach (var u in usedRows)
  1062. {
  1063. if (!u.PlanDate.HasValue || string.IsNullOrWhiteSpace(u.ProdLine)) continue;
  1064. var key = $"{u.ProdLine}|{u.PlanDate:yyyy-MM-dd}";
  1065. if (!ctx.UsedQty.ContainsKey(key))
  1066. ctx.UsedQty[key] = 0m;
  1067. ctx.UsedQty[key] += u.UsedQty;
  1068. }
  1069. }
  1070. return ctx;
  1071. }
  1072. private async Task CarryOverFrozenCapacityOccupyAsync(
  1073. long tenantId,
  1074. long baseRunId,
  1075. long newRunId,
  1076. HashSet<string> frozenWorkOrds)
  1077. {
  1078. if (frozenWorkOrds.Count == 0 || baseRunId == newRunId)
  1079. return;
  1080. var pars = new List<SugarParameter>
  1081. {
  1082. new("@TenantId", tenantId),
  1083. new("@BaseRunId", baseRunId),
  1084. new("@NewRunId", newRunId)
  1085. };
  1086. var names = new List<string>();
  1087. var idx = 0;
  1088. foreach (var wo in frozenWorkOrds)
  1089. {
  1090. var p = $"@Fw{idx++}";
  1091. names.Add(p);
  1092. pars.Add(new SugarParameter(p, wo));
  1093. }
  1094. await _db.Ado.ExecuteCommandAsync(
  1095. $"""
  1096. INSERT INTO aidop_s2_line_capacity_occupy
  1097. (tenant_id, run_id, prod_line, plan_date, work_ord, op, qty)
  1098. SELECT tenant_id, @NewRunId, prod_line, plan_date, work_ord, op, qty
  1099. FROM aidop_s2_line_capacity_occupy
  1100. WHERE tenant_id = @TenantId AND run_id = @BaseRunId
  1101. AND work_ord IN ({string.Join(",", names)})
  1102. """,
  1103. pars);
  1104. }
  1105. private async Task InsertCapacityOccupyBatchAsync(
  1106. long tenantId,
  1107. long runId,
  1108. string prodLine,
  1109. string workOrd,
  1110. int op,
  1111. List<(DateTime Day, decimal Qty)> daily)
  1112. {
  1113. if (daily.Count == 0) return;
  1114. const int chunkSize = 100;
  1115. for (var offset = 0; offset < daily.Count; offset += chunkSize)
  1116. {
  1117. var chunk = daily.Skip(offset).Take(chunkSize).ToList();
  1118. var pars = new List<SugarParameter>
  1119. {
  1120. new("@TenantId", tenantId),
  1121. new("@RunId", runId),
  1122. new("@ProdLine", prodLine),
  1123. new("@WorkOrd", workOrd),
  1124. new("@Op", op)
  1125. };
  1126. var values = new List<string>();
  1127. for (var j = 0; j < chunk.Count; j++)
  1128. {
  1129. values.Add($"(@TenantId, @RunId, @ProdLine, @PlanDate{j}, @WorkOrd, @Op, @Qty{j})");
  1130. pars.Add(new SugarParameter($"@PlanDate{j}", chunk[j].Day.Date));
  1131. pars.Add(new SugarParameter($"@Qty{j}", chunk[j].Qty));
  1132. }
  1133. await _db.Ado.ExecuteCommandAsync(
  1134. $"""
  1135. INSERT INTO aidop_s2_line_capacity_occupy
  1136. (tenant_id, run_id, prod_line, plan_date, work_ord, op, qty)
  1137. VALUES {string.Join(",\n", values)}
  1138. """,
  1139. pars);
  1140. }
  1141. }
  1142. private async Task<Dictionary<int, DateTime>> LoadActivePlanDatesByOpAsync(long tenantId, string workOrd)
  1143. {
  1144. var rows = await _db.Ado.SqlQueryAsync<ActivePlanDateRow>(
  1145. """
  1146. SELECT Op, DATE(PlanDate) AS PlanDate
  1147. FROM PeriodSequenceDet
  1148. WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd
  1149. AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL
  1150. """,
  1151. new SugarParameter("@TenantId", tenantId),
  1152. new SugarParameter("@WorkOrd", workOrd));
  1153. var map = new Dictionary<int, DateTime>();
  1154. foreach (var r in rows)
  1155. {
  1156. if (!r.PlanDate.HasValue) continue;
  1157. // 同工序多行时取最晚计划日作为完工约束
  1158. if (!map.TryGetValue(r.Op, out var existing) || r.PlanDate.Value > existing)
  1159. map[r.Op] = r.PlanDate.Value.Date;
  1160. }
  1161. return map;
  1162. }
  1163. private static decimal CalcDailyCapacityPieces(ProdLineCapacityContext ctx, string prodLine, DateTime date, decimal rate)
  1164. {
  1165. if (rate <= 0) return 0;
  1166. if (!ctx.Calendars.TryGetValue(prodLine, out var days))
  1167. return 0;
  1168. var weekDay = (int)date.DayOfWeek;
  1169. if (!days.TryGetValue(weekDay, out var cal) || !cal.IsWorkDay)
  1170. return 0;
  1171. var rest = ctx.RestMinutes.TryGetValue(prodLine, out var rm) ? rm : 0m;
  1172. var workMinutes = cal.Hours * 60m - rest;
  1173. if (workMinutes <= 0) return 0;
  1174. // H6:小数累计,跨日自然结转(不再 Math.Ceiling)
  1175. return workMinutes / 60m * rate;
  1176. }
  1177. private static CapacityAllocation AllocateByProdLineCapacity(
  1178. DateTime start,
  1179. string prodLine,
  1180. decimal qtyNeed,
  1181. decimal rate,
  1182. decimal setupHours,
  1183. ProdLineCapacityContext ctx)
  1184. {
  1185. var startDate = start.Date < DateTime.Today ? DateTime.Today : start.Date;
  1186. if (!ctx.Calendars.TryGetValue(prodLine, out var days) || !days.Values.Any(d => d.IsWorkDay))
  1187. {
  1188. return new CapacityAllocation
  1189. {
  1190. StartDate = startDate,
  1191. EndDate = startDate,
  1192. NoCapacityCalendar = true,
  1193. RemainingUnallocated = qtyNeed
  1194. };
  1195. }
  1196. if (qtyNeed <= 0)
  1197. {
  1198. return new CapacityAllocation
  1199. {
  1200. StartDate = startDate,
  1201. EndDate = startDate
  1202. };
  1203. }
  1204. var date = startDate;
  1205. var remaining = qtyNeed;
  1206. DateTime? firstDay = null;
  1207. DateTime? lastDay = null;
  1208. var daily = new List<(DateTime Day, decimal Qty)>();
  1209. var setupApplied = false;
  1210. for (var guard = 0; guard < ScheduleHorizonDays && remaining > 0; guard++)
  1211. {
  1212. var dayCap = CalcDailyCapacityPieces(ctx, prodLine, date, rate);
  1213. if (dayCap <= 0)
  1214. {
  1215. date = date.AddDays(1);
  1216. continue;
  1217. }
  1218. var key = $"{prodLine}|{date:yyyy-MM-dd}";
  1219. var alreadyUsed = ctx.UsedQty.TryGetValue(key, out var used) ? used : 0m;
  1220. var setupCost = (!setupApplied && setupHours > 0) ? setupHours * rate : 0m;
  1221. var remainingCap = dayCap - alreadyUsed - setupCost;
  1222. if (remainingCap <= 0)
  1223. {
  1224. date = date.AddDays(1);
  1225. continue;
  1226. }
  1227. var take = Math.Min(remaining, remainingCap);
  1228. // 内存占用含 setup 折合,避免后续工单误抢;落库只记业务件数
  1229. ctx.UsedQty[key] = alreadyUsed + take + setupCost;
  1230. if (!setupApplied && setupCost > 0)
  1231. setupApplied = true;
  1232. remaining -= take;
  1233. firstDay ??= date;
  1234. lastDay = date;
  1235. daily.Add((date, take));
  1236. if (remaining > 0)
  1237. date = date.AddDays(1);
  1238. }
  1239. return new CapacityAllocation
  1240. {
  1241. StartDate = firstDay ?? startDate,
  1242. EndDate = lastDay ?? (firstDay ?? startDate),
  1243. Daily = daily,
  1244. Exhausted = remaining > 0,
  1245. RemainingUnallocated = remaining
  1246. };
  1247. }
  1248. private async Task<decimal> ResolveProdLineRateAsync(long tenantId, string? itemNum, int op, RoutingRow routing)
  1249. {
  1250. if (routing.ProdLineRate > 0)
  1251. return routing.ProdLineRate;
  1252. if (routing.StdRunTime is > 0)
  1253. return 1m / routing.StdRunTime.Value;
  1254. if (routing.MachBdnRate is > 0)
  1255. return routing.MachBdnRate.Value;
  1256. if (string.IsNullOrWhiteSpace(itemNum))
  1257. return 0;
  1258. return await _db.Ado.GetDecimalAsync(
  1259. """
  1260. SELECT IFNULL(Rate, 0) FROM ProdLineDetail
  1261. WHERE tenant_id = @TenantId AND Part = @Part AND Op = @Op
  1262. LIMIT 1
  1263. """,
  1264. new List<SugarParameter>
  1265. {
  1266. new("@TenantId", tenantId),
  1267. new("@Part", itemNum),
  1268. new("@Op", op)
  1269. });
  1270. }
  1271. private sealed class ProdLineCalendarRow
  1272. {
  1273. public string ProdLine { get; set; } = string.Empty;
  1274. public int? WeekDay { get; set; }
  1275. public int IsWorkDay { get; set; }
  1276. public decimal ShiftsHours1 { get; set; }
  1277. }
  1278. private sealed class ProdLineRestRow
  1279. {
  1280. public string ProdLine { get; set; } = string.Empty;
  1281. public decimal RestTime { get; set; }
  1282. }
  1283. private sealed class ProdLineUsedRow
  1284. {
  1285. public string ProdLine { get; set; } = string.Empty;
  1286. public DateTime? PlanDate { get; set; }
  1287. public decimal UsedQty { get; set; }
  1288. }
  1289. private sealed class ActivePlanDateRow
  1290. {
  1291. public int Op { get; set; }
  1292. public DateTime? PlanDate { get; set; }
  1293. }
  1294. private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
  1295. {
  1296. if (!string.IsNullOrWhiteSpace(woDomain))
  1297. return woDomain.Trim();
  1298. if (!string.IsNullOrWhiteSpace(requestDomain))
  1299. return requestDomain.Trim();
  1300. return tenantId.ToString();
  1301. }
  1302. private long _periodRecIdSeq;
  1303. private long _exceptionRecIdSeq;
  1304. private long _resultOpRecIdSeq;
  1305. private long NextPeriodRecId() => ++_periodRecIdSeq;
  1306. private long NextScheduleExceptionRecId() => ++_exceptionRecIdSeq;
  1307. private long NextScheduleResultOpRecId() => ++_resultOpRecIdSeq;
  1308. /// <summary>
  1309. /// 清理本租户非当前 S2 版本、且非当前 S1 版本的占用行。崩溃时旧版本仍在,切换后才清。
  1310. /// </summary>
  1311. private async Task CleanupStaleOccupyAsync(long tenantId, long currentS2BangId)
  1312. {
  1313. var s1BangId = await _db.Ado.GetLongAsync(
  1314. """
  1315. SELECT id FROM aidop_action_run_log
  1316. WHERE tenant_id = @TenantId
  1317. AND action_code IN ('S1_ORDER_REVIEW', 'S1_ORDER_REFRESH_PLAN', 'S1_DELIVERY_CONFIRM')
  1318. AND status = 'SUCCESS'
  1319. ORDER BY start_time DESC, id DESC
  1320. LIMIT 1
  1321. """,
  1322. new List<SugarParameter> { new("@TenantId", tenantId) });
  1323. // 兼容尚未切换到 run_id 的旧常量批次
  1324. const long legacyS1BangId = 2;
  1325. await _db.Ado.ExecuteCommandAsync(
  1326. """
  1327. DELETE FROM ic_item_stockoccupy
  1328. WHERE tenant_id = @TenantId
  1329. AND bang_id <> @S2BangId
  1330. AND bang_id <> @S1BangId
  1331. AND bang_id <> @LegacyS1
  1332. """,
  1333. new SugarParameter("@TenantId", tenantId),
  1334. new SugarParameter("@S2BangId", currentS2BangId),
  1335. new SugarParameter("@S1BangId", s1BangId > 0 ? s1BangId : -1L),
  1336. new SugarParameter("@LegacyS1", legacyS1BangId));
  1337. await _db.Ado.ExecuteCommandAsync(
  1338. """
  1339. DELETE FROM srm_po_occupy
  1340. WHERE tenant_id = @TenantId
  1341. AND bang_id <> @S2BangId
  1342. AND bang_id <> @S1BangId
  1343. AND bang_id <> @LegacyS1
  1344. """,
  1345. new SugarParameter("@TenantId", tenantId),
  1346. new SugarParameter("@S2BangId", currentS2BangId),
  1347. new SugarParameter("@S1BangId", s1BangId > 0 ? s1BangId : -1L),
  1348. new SugarParameter("@LegacyS1", legacyS1BangId));
  1349. // H1:产能占用表仅有 S2 版本,切换成功后清理非当前 run
  1350. await _db.Ado.ExecuteCommandAsync(
  1351. """
  1352. DELETE FROM aidop_s2_line_capacity_occupy
  1353. WHERE tenant_id = @TenantId AND run_id <> @S2BangId
  1354. """,
  1355. new SugarParameter("@TenantId", tenantId),
  1356. new SugarParameter("@S2BangId", currentS2BangId));
  1357. }
  1358. /// <summary>
  1359. /// 查询可用设备数量(仅排产设备)。
  1360. /// 支持复合编码:"/" 分隔表示任一可用(SUM),"+" 分隔表示需同时使用(MIN)。
  1361. /// </summary>
  1362. private async Task<int> LoadAvailableEquipmentCountAsync(long tenantId, string? machineCode)
  1363. {
  1364. if (string.IsNullOrWhiteSpace(machineCode)) return 0;
  1365. var code = machineCode.Trim();
  1366. // "+" 分隔:需要所有设备同时使用 → 取 MIN
  1367. if (code.Contains('+'))
  1368. {
  1369. var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
  1370. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  1371. if (parts.Length == 0) return 0;
  1372. var counts = new List<int>();
  1373. foreach (var part in parts)
  1374. {
  1375. var c = await CountSingleEquipmentAsync(part);
  1376. counts.Add(c);
  1377. }
  1378. return counts.Count > 0 ? counts.Min() : 0;
  1379. }
  1380. // "/" 分隔:任一设备可用 → 取 SUM
  1381. if (code.Contains('/'))
  1382. {
  1383. var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
  1384. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  1385. if (parts.Length == 0) return 0;
  1386. var total = 0;
  1387. foreach (var part in parts)
  1388. {
  1389. total += await CountSingleEquipmentAsync(part);
  1390. }
  1391. return total;
  1392. }
  1393. // 单设备编码
  1394. return await CountSingleEquipmentAsync(code);
  1395. }
  1396. /// <summary>查询单个设备编码的可用设备数量。</summary>
  1397. private async Task<int> CountSingleEquipmentAsync(string code)
  1398. {
  1399. return await _db.Ado.GetIntAsync(
  1400. """
  1401. SELECT COUNT(*) FROM EquipmentList
  1402. WHERE InternalEquipmentCode = @Code AND IFNULL(IsSchedulable, 0) = 1
  1403. """,
  1404. new SugarParameter("@Code", code));
  1405. }
  1406. /// <summary>
  1407. /// 查询可用人员数量(持有指定技能的人员)。
  1408. /// 支持复合编码:"/" 分隔表示任一技能可用(SUM),"+" 分隔表示需同时持有(MIN)。
  1409. /// </summary>
  1410. private async Task<int> LoadAvailablePersonnelCountAsync(long tenantId, string? skillNo)
  1411. {
  1412. if (string.IsNullOrWhiteSpace(skillNo)) return 0;
  1413. var code = skillNo.Trim();
  1414. // "+" 分隔:需要同时持有所有技能 → 取 MIN
  1415. if (code.Contains('+'))
  1416. {
  1417. var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
  1418. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  1419. if (parts.Length == 0) return 0;
  1420. var counts = new List<int>();
  1421. foreach (var part in parts)
  1422. {
  1423. var c = await CountSingleSkillAsync(tenantId, part);
  1424. counts.Add(c);
  1425. }
  1426. return counts.Count > 0 ? counts.Min() : 0;
  1427. }
  1428. // "/" 分隔:任一技能可用 → 取 SUM
  1429. if (code.Contains('/'))
  1430. {
  1431. var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
  1432. .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
  1433. if (parts.Length == 0) return 0;
  1434. var total = 0;
  1435. foreach (var part in parts)
  1436. {
  1437. total += await CountSingleSkillAsync(tenantId, part);
  1438. }
  1439. return total;
  1440. }
  1441. // 单技能编码
  1442. return await CountSingleSkillAsync(tenantId, code);
  1443. }
  1444. /// <summary>查询持有单个技能编码的人员数量。</summary>
  1445. private async Task<int> CountSingleSkillAsync(long tenantId, string skillNo)
  1446. {
  1447. return await _db.Ado.GetIntAsync(
  1448. "SELECT COUNT(*) FROM EmpSkills WHERE SkillNo = @SkillNo AND tenant_id = @TenantId",
  1449. new SugarParameter("@SkillNo", skillNo),
  1450. new SugarParameter("@TenantId", tenantId));
  1451. }
  1452. /// <summary>解析设备编码:WorkOrdRouting.Machine 为空时回退查 ProdLineDetail。</summary>
  1453. private async Task<string?> ResolveMachineCodeAsync(long tenantId, string? machine, string? itemNum, int op)
  1454. {
  1455. if (!string.IsNullOrWhiteSpace(machine)) return machine.Trim();
  1456. if (string.IsNullOrWhiteSpace(itemNum)) return null;
  1457. return await _db.Ado.GetStringAsync(
  1458. """
  1459. SELECT InternalEquipmentCode FROM ProdLineDetail
  1460. WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
  1461. """,
  1462. new SugarParameter("@ItemNum", itemNum),
  1463. new SugarParameter("@Op", op),
  1464. new SugarParameter("@TenantId", tenantId));
  1465. }
  1466. /// <summary>解析技能编码:WorkOrdRouting.Engineer 为空时回退查 ProdLineDetail。</summary>
  1467. private async Task<string?> ResolveSkillNoAsync(long tenantId, string? engineer, string? itemNum, int op)
  1468. {
  1469. if (!string.IsNullOrWhiteSpace(engineer)) return engineer.Trim();
  1470. if (string.IsNullOrWhiteSpace(itemNum)) return null;
  1471. return await _db.Ado.GetStringAsync(
  1472. """
  1473. SELECT SkillNo FROM ProdLineDetail
  1474. WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
  1475. """,
  1476. new SugarParameter("@ItemNum", itemNum),
  1477. new SugarParameter("@Op", op),
  1478. new SugarParameter("@TenantId", tenantId));
  1479. }
  1480. private async Task InsertScheduleExceptionAsync(
  1481. long tenantId,
  1482. PendingWorkOrderRow wo,
  1483. string? domain,
  1484. string type,
  1485. string remark)
  1486. {
  1487. var recId = NextScheduleExceptionRecId();
  1488. var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
  1489. await _db.Ado.ExecuteCommandAsync(
  1490. """
  1491. INSERT INTO ScheduleExceptionMaster (
  1492. RecID, `Domain`, WorkOrd, ItemNum, CreateTime, Remark, Type, OptTime, tenant_id
  1493. ) VALUES (
  1494. @RecId, @Domain, @WorkOrd, @ItemNum, @CreateTime, @Remark, @Type, @OptTime, @TenantId
  1495. )
  1496. """,
  1497. new SugarParameter("@RecId", recId),
  1498. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  1499. new SugarParameter("@WorkOrd", wo.WorkOrd),
  1500. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  1501. new SugarParameter("@CreateTime", DateTime.Now),
  1502. new SugarParameter("@Remark", remark),
  1503. new SugarParameter("@Type", type),
  1504. new SugarParameter("@OptTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm")),
  1505. new SugarParameter("@TenantId", tenantId));
  1506. }
  1507. /// <summary>写入排产结果(ScheduleResultOpMaster),包含设备/人员产能分配数据。</summary>
  1508. private async Task InsertScheduleResultOpAsync(
  1509. long recId,
  1510. string woDomain,
  1511. RoutingRow routing,
  1512. PendingWorkOrderRow wo,
  1513. DateTime planDate,
  1514. int seq,
  1515. DateTime now,
  1516. long tenantId,
  1517. string? machineCode,
  1518. int? deviceAllocationCount,
  1519. string? skillNo,
  1520. decimal? assignedPersonnelCount,
  1521. long? periodDetRecId)
  1522. {
  1523. await _db.Ado.ExecuteCommandAsync(
  1524. """
  1525. INSERT INTO ScheduleResultOpMaster (
  1526. RecID, `Domain`, WorkOrd, WorkCtr, Line, ItemNum, Op,
  1527. WorkDate, WorkQty, WorkSort, OpDescr, SetupTime, WorkActivateTime,
  1528. InternalEquipmentCode, DeviceAllocationCount, MoldAllocationCount,
  1529. SkillNo, AssignedPersonnelCount,
  1530. PeriodDetRecId,
  1531. Remark, CreateTime, tenant_id
  1532. ) VALUES (
  1533. @RecId, @Domain, @WorkOrd, @WorkCtr, @Line, @ItemNum, @Op,
  1534. @WorkDate, @WorkQty, @WorkSort, @OpDescr, @SetupTime, @WorkActivateTime,
  1535. @InternalEquipmentCode, @DeviceAllocationCount, @MoldAllocationCount,
  1536. @SkillNo, @AssignedPersonnelCount,
  1537. @PeriodDetRecId,
  1538. @Remark, @CreateTime, @TenantId
  1539. )
  1540. """,
  1541. new SugarParameter("@RecId", recId),
  1542. new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
  1543. new SugarParameter("@WorkOrd", wo.WorkOrd),
  1544. new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
  1545. new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
  1546. new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
  1547. new SugarParameter("@Op", routing.Op),
  1548. new SugarParameter("@WorkDate", planDate),
  1549. new SugarParameter("@WorkQty", wo.QtyOrded ?? 0),
  1550. new SugarParameter("@WorkSort", seq),
  1551. new SugarParameter("@OpDescr", string.IsNullOrWhiteSpace(routing.OpDescr) ? (object)DBNull.Value : routing.OpDescr.Trim()),
  1552. new SugarParameter("@SetupTime", routing.SetupTime ?? (object)DBNull.Value),
  1553. new SugarParameter("@WorkActivateTime", now),
  1554. new SugarParameter("@InternalEquipmentCode", string.IsNullOrWhiteSpace(machineCode) ? (object)DBNull.Value : machineCode.Trim()),
  1555. new SugarParameter("@DeviceAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
  1556. new SugarParameter("@MoldAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
  1557. new SugarParameter("@SkillNo", string.IsNullOrWhiteSpace(skillNo) ? (object)DBNull.Value : skillNo.Trim()),
  1558. new SugarParameter("@AssignedPersonnelCount", assignedPersonnelCount ?? (object)DBNull.Value),
  1559. new SugarParameter("@PeriodDetRecId", periodDetRecId ?? (object)DBNull.Value),
  1560. new SugarParameter("@Remark", string.Empty),
  1561. new SugarParameter("@CreateTime", now),
  1562. new SugarParameter("@TenantId", tenantId));
  1563. }
  1564. public sealed class ScheduleGenerationResult
  1565. {
  1566. public int WorkOrderCount { get; set; }
  1567. public int ScheduleRowCount { get; set; }
  1568. public int PushEnqueuedCount { get; set; }
  1569. public int FrozenWorkOrderCount { get; set; }
  1570. public int FrozenOpCount { get; set; }
  1571. public bool UsedWorkCenterCalendar { get; set; }
  1572. public long RunId { get; set; }
  1573. /// <summary>165 生效行数 − 本库生效行数(本批工单范围)。</summary>
  1574. public int PsdMismatchCount { get; set; }
  1575. /// <summary>165 上同 (Domain,工单,工序,计划日,产线) 生效行 &gt; 1 的组数。</summary>
  1576. public int PsdDuplicateActiveCount { get; set; }
  1577. /// <summary>P-036:自动作废的 165 重复生效行数。</summary>
  1578. public int PsdDuplicateAutoCount { get; set; }
  1579. /// <summary>P-036:含已投产状态、仅告警不作废的重复组数。</summary>
  1580. public int PsdDuplicateManualCount { get; set; }
  1581. /// <summary>本轮结束时该租户 outbox status=2 条数。</summary>
  1582. public int OutboxDeadCount { get; set; }
  1583. /// <summary>P-031:本批日计划中 165 产线缺失或未激活的行数。</summary>
  1584. public int MesLineMissingCount { get; set; }
  1585. /// <summary>P-031:本批日计划中 165 缺 MilestoneOp=1 工序的行数。</summary>
  1586. public int MesRoutingMissingCount { get; set; }
  1587. public List<string> SkippedWorkOrders { get; set; } = new();
  1588. public string Message { get; set; } = string.Empty;
  1589. }
  1590. private sealed class PendingPsdPushRow
  1591. {
  1592. public string Domain { get; set; } = string.Empty;
  1593. public string Line { get; set; } = string.Empty;
  1594. public string ItemNum { get; set; } = string.Empty;
  1595. public DateTime PlanDate { get; set; }
  1596. public short Period { get; set; }
  1597. public int Op { get; set; }
  1598. public string WorkOrds { get; set; } = string.Empty;
  1599. public int Sequence { get; set; }
  1600. public decimal OrdQty { get; set; }
  1601. public long RecId { get; set; }
  1602. }
  1603. private async Task<int> EnqueuePsdUpsertsAsync(
  1604. long tenantId,
  1605. string account,
  1606. DateTime now,
  1607. List<PendingPsdPushRow> rows)
  1608. {
  1609. if (rows.Count == 0) return 0;
  1610. var user = TruncUser(account);
  1611. var nowText = now.ToString("yyyy-MM-dd HH:mm:ss");
  1612. var enqueued = 0;
  1613. for (var i = 0; i < rows.Count; i++)
  1614. {
  1615. var row = rows[i];
  1616. var planDateText = row.PlanDate.ToString("yyyy-MM-dd");
  1617. var domain = Trunc(row.Domain, 8);
  1618. var keys = new Dictionary<string, object?>
  1619. {
  1620. ["Domain"] = domain,
  1621. ["Line"] = row.Line,
  1622. ["ItemNum"] = row.ItemNum,
  1623. ["PlanDate"] = planDateText,
  1624. ["Period"] = row.Period,
  1625. ["Op"] = row.Op,
  1626. ["WorkOrds"] = row.WorkOrds
  1627. };
  1628. var insert = new Dictionary<string, object?>
  1629. {
  1630. ["Site"] = domain,
  1631. ["Domain"] = domain,
  1632. ["Line"] = row.Line,
  1633. ["ItemNum"] = row.ItemNum,
  1634. ["Op"] = row.Op,
  1635. ["WorkOrds"] = row.WorkOrds,
  1636. ["PlanDate"] = planDateText,
  1637. ["ProdDate"] = planDateText,
  1638. ["Period"] = row.Period,
  1639. ["Sequence"] = row.Sequence,
  1640. ["OrdQty"] = row.OrdQty,
  1641. ["ProdQty"] = row.OrdQty,
  1642. ["CompQty"] = 0,
  1643. ["RejectQty"] = 0,
  1644. ["ScrapQty"] = 0,
  1645. ["BusinessID"] = 0,
  1646. ["Status"] = "",
  1647. ["Employee"] = "",
  1648. ["IsActive"] = 1,
  1649. ["IsConfirm"] = 0,
  1650. ["CreateUser"] = user,
  1651. ["UpdateUser"] = user,
  1652. ["CreateTime"] = nowText,
  1653. ["UpdateTime"] = nowText,
  1654. ["EffTime"] = nowText
  1655. };
  1656. var update = new Dictionary<string, object?>
  1657. {
  1658. ["OrdQty"] = row.OrdQty,
  1659. ["ProdQty"] = row.OrdQty,
  1660. ["Sequence"] = row.Sequence,
  1661. ["PlanDate"] = planDateText,
  1662. ["ProdDate"] = planDateText,
  1663. ["IsActive"] = 1,
  1664. ["Status"] = "", // P-031 K3:复活时复位作废态 'X'
  1665. ["UpdateUser"] = user,
  1666. ["UpdateTime"] = nowText
  1667. };
  1668. var idem = Trunc(
  1669. $"psd|{domain}|{row.WorkOrds}|{row.Op}|{row.PlanDate:yyyyMMdd}|{row.Period}",
  1670. 200);
  1671. var pulse = i == rows.Count - 1;
  1672. if (await EnqueueOutboxAsync(
  1673. tenantId,
  1674. idem,
  1675. "S2_PSD_UPSERT",
  1676. new
  1677. {
  1678. op = "UPSERT",
  1679. table = "PeriodSequenceDet",
  1680. keys,
  1681. insert,
  1682. update,
  1683. // P-031 K3:允许覆盖空状态或已作废 'X'(依赖 K1 expect IN)
  1684. expect = new Dictionary<string, object?> { ["Status"] = new[] { "", "X" } }
  1685. },
  1686. pulse))
  1687. enqueued++;
  1688. }
  1689. return enqueued;
  1690. }
  1691. private async Task EnqueuePsdDeactivateAsync(
  1692. long tenantId,
  1693. string user,
  1694. DateTime now,
  1695. PendingPsdPushRow row,
  1696. bool pulse,
  1697. long? runId = null,
  1698. long recId = 0)
  1699. {
  1700. var planDateText = row.PlanDate.ToString("yyyy-MM-dd");
  1701. var domain = Trunc(row.Domain, 8);
  1702. var period = row.Period > 0 ? row.Period : _defaultPeriod;
  1703. var recSuffix = recId > 0 ? $"|rec{recId}" : "";
  1704. // 对账兜底带 runId 后缀,避开历史死信 idem_key;常规作废不加,以便 Refresh 复活同键死信
  1705. var idem = runId is > 0
  1706. ? Trunc($"psd-deact|{domain}|{row.WorkOrds}|{row.Op}|{row.PlanDate:yyyyMMdd}|{period}{recSuffix}|r{runId}", 200)
  1707. : Trunc($"psd-deact|{domain}|{row.WorkOrds}|{row.Op}|{row.PlanDate:yyyyMMdd}|{period}{recSuffix}", 200);
  1708. var keys = new Dictionary<string, object?>
  1709. {
  1710. ["Domain"] = domain,
  1711. ["Line"] = row.Line,
  1712. ["ItemNum"] = row.ItemNum,
  1713. ["PlanDate"] = planDateText,
  1714. ["Period"] = period,
  1715. ["Op"] = row.Op,
  1716. ["WorkOrds"] = row.WorkOrds
  1717. };
  1718. if (recId > 0)
  1719. keys["RecID"] = recId;
  1720. await EnqueueOutboxAsync(
  1721. tenantId,
  1722. idem,
  1723. "S2_PSD_DEACTIVATE",
  1724. new
  1725. {
  1726. op = "UPDATE",
  1727. table = "PeriodSequenceDet",
  1728. keys,
  1729. update = new Dictionary<string, object?>
  1730. {
  1731. ["IsActive"] = 0,
  1732. ["Status"] = "X", // P-031 K2:MES 取数只认 Status,不认 IsActive
  1733. ["UpdateUser"] = user,
  1734. ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
  1735. },
  1736. expect = new Dictionary<string, object?> { ["Status"] = "" }
  1737. },
  1738. pulse);
  1739. }
  1740. /// <summary>
  1741. /// P-029 I3:本库已无、165 仍生效且未投产的日计划行 → 补发作废(idem 带 runId)。
  1742. /// </summary>
  1743. private async Task<PsdReconcileResult> ReconcileStale165PsdAsync(
  1744. long tenantId,
  1745. string account,
  1746. DateTime now,
  1747. long runId,
  1748. IReadOnlyList<string> workOrds)
  1749. {
  1750. var result = new PsdReconcileResult();
  1751. if (workOrds.Count == 0) return result;
  1752. var inNames = new List<string>();
  1753. var inPars = new List<SugarParameter> { new("@TenantId", tenantId) };
  1754. for (var i = 0; i < workOrds.Count; i++)
  1755. {
  1756. var name = $"@w{i}";
  1757. inNames.Add(name);
  1758. inPars.Add(new SugarParameter(name, workOrds[i]));
  1759. }
  1760. var inClause = string.Join(",", inNames);
  1761. var localRows = await _db.Ado.SqlQueryAsync<PendingPsdPushRow>(
  1762. $"""
  1763. SELECT TRIM(IFNULL(`Domain`,'')) AS `Domain`,
  1764. TRIM(IFNULL(Line,'')) AS Line,
  1765. TRIM(IFNULL(ItemNum,'')) AS ItemNum,
  1766. DATE(PlanDate) AS PlanDate,
  1767. IFNULL(Period, 0) AS Period,
  1768. Op,
  1769. TRIM(IFNULL(WorkOrds,'')) AS WorkOrds,
  1770. IFNULL(Sequence, 0) AS Sequence,
  1771. IFNULL(OrdQty, 0) AS OrdQty
  1772. FROM PeriodSequenceDet
  1773. WHERE tenant_id = @TenantId
  1774. AND IFNULL(IsActive, 0) = 1
  1775. AND WorkOrds IN ({inClause})
  1776. """,
  1777. inPars);
  1778. var localKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  1779. foreach (var r in localRows)
  1780. {
  1781. if (string.IsNullOrWhiteSpace(r.WorkOrds)) continue;
  1782. localKeys.Add(BuildPsdBizKey(r));
  1783. }
  1784. List<StartedPsdRow> remoteRows;
  1785. try
  1786. {
  1787. var ss = await _scopeFactory.GetScopeAsync(CreatePickBillService.TargetSource);
  1788. var remotePars = inPars.Where(p => p.ParameterName != "@TenantId").ToList();
  1789. remoteRows = await ss.Ado.SqlQueryAsync<StartedPsdRow>(
  1790. $"""
  1791. SELECT RecID, Domain, Line, ItemNum, PlanDate, Period, Op, WorkOrds, Status, OrdQty
  1792. FROM PeriodSequenceDet
  1793. WHERE ISNULL(IsActive, 0) = 1
  1794. AND WorkOrds IN ({inClause})
  1795. """,
  1796. remotePars);
  1797. try
  1798. {
  1799. // P-031 K5:体检本库生效日计划在 165 的产线 / MilestoneOp 完备性
  1800. await FillMesReadinessCountsAsync(ss, localRows, result);
  1801. }
  1802. catch
  1803. {
  1804. // 完备性体检失败不影响孤儿作废对账
  1805. }
  1806. }
  1807. catch
  1808. {
  1809. // 165 探测失败时跳过对账,不中断排程
  1810. return result;
  1811. }
  1812. var dupGroups = new Dictionary<string, List<StartedPsdRow>>(StringComparer.OrdinalIgnoreCase);
  1813. var orphans = new List<PendingPsdPushRow>();
  1814. var user = TruncUser(account);
  1815. var remoteSchedulable = 0; // Status='' 的生效行(不含已投产)
  1816. var fieldStartedStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "R", "S", "W" };
  1817. foreach (var r in remoteRows)
  1818. {
  1819. var wo = (r.WorkOrds ?? string.Empty).Trim();
  1820. if (string.IsNullOrWhiteSpace(wo)) continue;
  1821. var status = (r.Status ?? string.Empty).Trim();
  1822. var isStarted = fieldStartedStatuses.Contains(status);
  1823. var isSchedulable = status.Length == 0;
  1824. var row = new PendingPsdPushRow
  1825. {
  1826. Domain = r.Domain ?? string.Empty,
  1827. Line = r.Line ?? string.Empty,
  1828. ItemNum = r.ItemNum ?? string.Empty,
  1829. PlanDate = r.PlanDate?.Date ?? DateTime.MinValue,
  1830. Period = r.Period,
  1831. Op = r.Op,
  1832. WorkOrds = wo,
  1833. RecId = r.RecID,
  1834. OrdQty = r.OrdQty
  1835. };
  1836. if (row.PlanDate == DateTime.MinValue || string.IsNullOrWhiteSpace(row.Line))
  1837. continue;
  1838. var dupKey = $"{Trunc(row.Domain, 8)}|{wo}|{row.Op}|{row.PlanDate:yyyy-MM-dd}|{row.Line}";
  1839. if (!dupGroups.TryGetValue(dupKey, out var group))
  1840. {
  1841. group = new List<StartedPsdRow>();
  1842. dupGroups[dupKey] = group;
  1843. }
  1844. group.Add(r);
  1845. if (isStarted || !isSchedulable)
  1846. continue;
  1847. remoteSchedulable++;
  1848. if (!localKeys.Contains(BuildPsdBizKey(row)))
  1849. orphans.Add(row);
  1850. }
  1851. result.DuplicateActiveCount = dupGroups.Count(kv => kv.Value.Count > 1);
  1852. // 对账口径:仅 Status='' 的可派工行 vs 本库生效行
  1853. result.MismatchCount = remoteSchedulable - localRows.Count;
  1854. if (result.MismatchCount < 0) result.MismatchCount = 0;
  1855. foreach (var kv in dupGroups.Where(x => x.Value.Count > 1))
  1856. {
  1857. var group = kv.Value;
  1858. if (group.Any(x => fieldStartedStatuses.Contains((x.Status ?? string.Empty).Trim())))
  1859. {
  1860. result.DuplicateManualCount++;
  1861. continue;
  1862. }
  1863. var schedulable = group
  1864. .Where(x => string.IsNullOrWhiteSpace(x.Status))
  1865. .OrderBy(x => x.RecID)
  1866. .ToList();
  1867. if (schedulable.Count <= 1)
  1868. continue;
  1869. var keep = PickKeepDuplicate(schedulable, localRows);
  1870. foreach (var extra in schedulable.Where(x => x.RecID != keep.RecID))
  1871. {
  1872. var extraRow = new PendingPsdPushRow
  1873. {
  1874. Domain = extra.Domain ?? string.Empty,
  1875. Line = extra.Line ?? string.Empty,
  1876. ItemNum = extra.ItemNum ?? string.Empty,
  1877. PlanDate = extra.PlanDate?.Date ?? DateTime.MinValue,
  1878. Period = extra.Period,
  1879. Op = extra.Op,
  1880. WorkOrds = (extra.WorkOrds ?? string.Empty).Trim(),
  1881. RecId = extra.RecID,
  1882. OrdQty = extra.OrdQty
  1883. };
  1884. if (extraRow.PlanDate == DateTime.MinValue || string.IsNullOrWhiteSpace(extraRow.Line) || extraRow.RecId <= 0)
  1885. continue;
  1886. try
  1887. {
  1888. await EnqueuePsdDeactivateAsync(
  1889. tenantId, user, now, extraRow,
  1890. pulse: false,
  1891. runId: runId,
  1892. recId: extraRow.RecId);
  1893. result.Enqueued++;
  1894. result.DuplicateAutoCount++;
  1895. }
  1896. catch
  1897. {
  1898. // 单条失败不阻断
  1899. }
  1900. }
  1901. }
  1902. for (var i = 0; i < orphans.Count; i++)
  1903. {
  1904. try
  1905. {
  1906. await EnqueuePsdDeactivateAsync(
  1907. tenantId, user, now, orphans[i],
  1908. pulse: i == orphans.Count - 1,
  1909. runId: runId);
  1910. result.Enqueued++;
  1911. }
  1912. catch
  1913. {
  1914. // 单条失败不阻断
  1915. }
  1916. }
  1917. return result;
  1918. }
  1919. /// <summary>清理成功且过期的 S2_PSD_DEACTIVATE,防止带 runId 的 idem 无限增长。</summary>
  1920. private async Task CleanupStalePsdDeactOutboxAsync()
  1921. {
  1922. var cutoff = DateTime.Now.AddDays(-7);
  1923. await _db.Ado.ExecuteCommandAsync(
  1924. """
  1925. DELETE FROM mdp_outbox
  1926. WHERE action_code = 'S2_PSD_DEACTIVATE'
  1927. AND status = 1
  1928. AND update_time < @Cutoff
  1929. """,
  1930. new SugarParameter("@Cutoff", cutoff));
  1931. }
  1932. private async Task<int> CountOutboxDeadAsync(long tenantId)
  1933. {
  1934. return await _db.Ado.GetIntAsync(
  1935. """
  1936. SELECT COUNT(*) FROM mdp_outbox
  1937. WHERE tenant_id = @TenantId AND status = 2
  1938. """,
  1939. new SugarParameter("@TenantId", tenantId));
  1940. }
  1941. private sealed class PsdReconcileResult
  1942. {
  1943. public int MismatchCount { get; set; }
  1944. public int DuplicateActiveCount { get; set; }
  1945. public int DuplicateAutoCount { get; set; }
  1946. public int DuplicateManualCount { get; set; }
  1947. public int Enqueued { get; set; }
  1948. public int MesLineMissingCount { get; set; }
  1949. public int MesRoutingMissingCount { get; set; }
  1950. }
  1951. private sealed class MesLineRow
  1952. {
  1953. public string? Domain { get; set; }
  1954. public string? Line { get; set; }
  1955. public bool IsActive { get; set; }
  1956. }
  1957. private sealed class MesRoutingKeyRow
  1958. {
  1959. public string? Domain { get; set; }
  1960. public string? WorkOrd { get; set; }
  1961. public int Op { get; set; }
  1962. public string? ItemNum { get; set; }
  1963. }
  1964. /// <summary>
  1965. /// P-031 K5:按 MES 取数口径统计本库日计划中 165 缺产线 / 缺 MilestoneOp 工序的行数。
  1966. /// </summary>
  1967. private static async Task FillMesReadinessCountsAsync(
  1968. ISqlSugarClient ss,
  1969. List<PendingPsdPushRow> localRows,
  1970. PsdReconcileResult result)
  1971. {
  1972. if (localRows.Count == 0) return;
  1973. var domains = localRows
  1974. .Select(r => (r.Domain ?? string.Empty).Trim())
  1975. .Where(d => d.Length > 0)
  1976. .Distinct(StringComparer.OrdinalIgnoreCase)
  1977. .ToList();
  1978. if (domains.Count == 0) return;
  1979. var domainNames = new List<string>();
  1980. var domainPars = new List<SugarParameter>();
  1981. for (var i = 0; i < domains.Count; i++)
  1982. {
  1983. var name = $"@d{i}";
  1984. domainNames.Add(name);
  1985. domainPars.Add(new SugarParameter(name, domains[i]));
  1986. }
  1987. var domainIn = string.Join(",", domainNames);
  1988. var lineRows = await ss.Ado.SqlQueryAsync<MesLineRow>(
  1989. $"""
  1990. SELECT Domain, RTRIM(Line) AS Line, CAST(ISNULL(IsActive, 0) AS bit) AS IsActive
  1991. FROM LineMaster
  1992. WHERE Domain IN ({domainIn})
  1993. """,
  1994. domainPars);
  1995. var activeLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  1996. foreach (var lm in lineRows)
  1997. {
  1998. if (!lm.IsActive) continue;
  1999. var d = (lm.Domain ?? string.Empty).Trim();
  2000. var line = (lm.Line ?? string.Empty).Trim();
  2001. if (d.Length == 0 || line.Length == 0) continue;
  2002. activeLines.Add($"{d}|{line}");
  2003. }
  2004. var woNames = new List<string>();
  2005. var woPars = new List<SugarParameter>();
  2006. var workOrds = localRows
  2007. .Select(r => (r.WorkOrds ?? string.Empty).Trim())
  2008. .Where(w => w.Length > 0)
  2009. .Distinct(StringComparer.OrdinalIgnoreCase)
  2010. .ToList();
  2011. for (var i = 0; i < workOrds.Count; i++)
  2012. {
  2013. var name = $"@w{i}";
  2014. woNames.Add(name);
  2015. woPars.Add(new SugarParameter(name, workOrds[i]));
  2016. }
  2017. var milestoneKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  2018. if (woNames.Count > 0)
  2019. {
  2020. var woIn = string.Join(",", woNames);
  2021. var rtgRows = await ss.Ado.SqlQueryAsync<MesRoutingKeyRow>(
  2022. $"""
  2023. SELECT Domain, WorkOrd, Op, ItemNum
  2024. FROM WorkOrdRouting
  2025. WHERE WorkOrd IN ({woIn})
  2026. AND ISNULL(MilestoneOp, 0) = 1
  2027. """,
  2028. woPars);
  2029. foreach (var r in rtgRows)
  2030. {
  2031. var d = (r.Domain ?? string.Empty).Trim();
  2032. var wo = (r.WorkOrd ?? string.Empty).Trim();
  2033. var item = (r.ItemNum ?? string.Empty).Trim();
  2034. if (d.Length == 0 || wo.Length == 0) continue;
  2035. milestoneKeys.Add($"{d}|{wo}|{r.Op}|{item}");
  2036. }
  2037. }
  2038. var lineMissing = 0;
  2039. var routingMissing = 0;
  2040. foreach (var row in localRows)
  2041. {
  2042. var d = (row.Domain ?? string.Empty).Trim();
  2043. var line = (row.Line ?? string.Empty).Trim();
  2044. var wo = (row.WorkOrds ?? string.Empty).Trim();
  2045. var item = (row.ItemNum ?? string.Empty).Trim();
  2046. if (d.Length == 0 || wo.Length == 0) continue;
  2047. if (!activeLines.Contains($"{d}|{line}"))
  2048. lineMissing++;
  2049. if (!milestoneKeys.Contains($"{d}|{wo}|{row.Op}|{item}"))
  2050. routingMissing++;
  2051. }
  2052. result.MesLineMissingCount = lineMissing;
  2053. result.MesRoutingMissingCount = routingMissing;
  2054. }
  2055. private async Task<bool> EnqueueOutboxAsync(
  2056. long tenantId,
  2057. string idem,
  2058. string action,
  2059. object payload,
  2060. bool pulse)
  2061. {
  2062. var item = new MdpOutbox
  2063. {
  2064. TenantId = tenantId,
  2065. TargetSourceCode = CreatePickBillService.TargetSource,
  2066. ActionCode = action,
  2067. IdemKey = idem,
  2068. PayloadJson = JsonSerializer.Serialize(payload)
  2069. };
  2070. // 重排时同业务键可能已成功推过,须 Refresh 覆盖 payload
  2071. return await _enqueue.TryEnqueueOrRefreshAsync(item, CancellationToken.None, pulse);
  2072. }
  2073. private async Task<HashSet<string>> LoadStartedPsdKeysFrom165Async(string workOrd)
  2074. {
  2075. var started = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  2076. try
  2077. {
  2078. var ss = await _scopeFactory.GetScopeAsync(CreatePickBillService.TargetSource);
  2079. var rows = await ss.Ado.SqlQueryAsync<StartedPsdRow>(
  2080. """
  2081. SELECT Domain, Line, ItemNum, PlanDate, Period, Op, WorkOrds, Status
  2082. FROM PeriodSequenceDet
  2083. WHERE WorkOrds = @w AND ISNULL(IsActive, 0) = 1
  2084. AND ISNULL(Status, '') <> ''
  2085. """,
  2086. new SugarParameter("@w", workOrd));
  2087. foreach (var r in rows)
  2088. {
  2089. started.Add(BuildPsdBizKey(new PendingPsdPushRow
  2090. {
  2091. Domain = r.Domain ?? string.Empty,
  2092. Line = r.Line ?? string.Empty,
  2093. ItemNum = r.ItemNum ?? string.Empty,
  2094. PlanDate = r.PlanDate?.Date ?? DateTime.MinValue,
  2095. Period = r.Period,
  2096. Op = r.Op,
  2097. WorkOrds = r.WorkOrds ?? string.Empty
  2098. }));
  2099. }
  2100. }
  2101. catch
  2102. {
  2103. // 165 探测失败降级放行,由 Outbox expect 保护现场状态
  2104. }
  2105. return started;
  2106. }
  2107. private static string BuildPsdBizKey(PendingPsdPushRow row) =>
  2108. $"{Trunc(row.Domain, 8)}|{row.Line}|{row.ItemNum}|{row.PlanDate:yyyy-MM-dd}|{row.Period}|{row.Op}|{row.WorkOrds}";
  2109. private static string TruncUser(string? account)
  2110. {
  2111. var user = string.IsNullOrWhiteSpace(account) ? "system" : account.Trim();
  2112. return Trunc(user, 24);
  2113. }
  2114. private static string Trunc(string? value, int max)
  2115. {
  2116. var s = value ?? string.Empty;
  2117. return s.Length > max ? s[..max] : s;
  2118. }
  2119. private sealed class StartedPsdRow
  2120. {
  2121. public long RecID { get; set; }
  2122. public string? Domain { get; set; }
  2123. public string? Line { get; set; }
  2124. public string? ItemNum { get; set; }
  2125. public DateTime? PlanDate { get; set; }
  2126. public short Period { get; set; }
  2127. public int Op { get; set; }
  2128. public string? WorkOrds { get; set; }
  2129. public string? Status { get; set; }
  2130. public decimal OrdQty { get; set; }
  2131. }
  2132. private static StartedPsdRow PickKeepDuplicate(
  2133. List<StartedPsdRow> schedulable,
  2134. List<PendingPsdPushRow> localRows)
  2135. {
  2136. foreach (var remote in schedulable)
  2137. {
  2138. var mapped = localRows.Any(local =>
  2139. string.Equals((local.Domain ?? "").Trim(), (remote.Domain ?? "").Trim(), StringComparison.OrdinalIgnoreCase)
  2140. && string.Equals(local.WorkOrds, (remote.WorkOrds ?? "").Trim(), StringComparison.OrdinalIgnoreCase)
  2141. && local.Op == remote.Op
  2142. && local.PlanDate.Date == (remote.PlanDate?.Date ?? DateTime.MinValue)
  2143. && string.Equals((local.Line ?? "").Trim(), (remote.Line ?? "").Trim(), StringComparison.OrdinalIgnoreCase)
  2144. && string.Equals((local.ItemNum ?? "").Trim(), (remote.ItemNum ?? "").Trim(), StringComparison.OrdinalIgnoreCase)
  2145. && local.Period == remote.Period);
  2146. if (mapped)
  2147. return remote;
  2148. }
  2149. return schedulable[0];
  2150. }
  2151. /// <summary>
  2152. /// 计算工单齐套数量:MIN(可用库存 / qty) 得可生产成品数,超过工单需求量则取工单需求量。
  2153. /// 若工单领料单已全部发完料,则齐套数量 = 工单需求量。
  2154. /// consumedStock 跟踪已被高优先级工单占用的库存量,避免重复占用。
  2155. /// </summary>
  2156. private async Task<decimal?> CalcLocationStockAsync(
  2157. long tenantId, string workOrd, decimal qtyOrded,
  2158. Dictionary<string, decimal> consumedStock)
  2159. {
  2160. // 检查领料单是否已全部发完料
  2161. var allIssued = await _db.Ado.GetIntAsync(
  2162. """
  2163. SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
  2164. FROM NbrMaster nm
  2165. WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd
  2166. AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1
  2167. AND NOT EXISTS (
  2168. SELECT 1 FROM NbrDetail nd
  2169. WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr
  2170. AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1
  2171. AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0)
  2172. )
  2173. """,
  2174. new SugarParameter("@TenantId", tenantId),
  2175. new SugarParameter("@WorkOrd", workOrd));
  2176. if (allIssued > 0 && qtyOrded > 0)
  2177. return qtyOrded;
  2178. // 从最新资源检查获取各物料的 use_qty 和 qty
  2179. var materials = await _db.Ado.SqlQueryAsync<LocationStockMaterialRow>(
  2180. """
  2181. SELECT
  2182. bce.item_number AS ItemNumber,
  2183. IFNULL(bce.use_qty, 0) AS UseQty,
  2184. IFNULL(bce.qty, 0) AS Qty
  2185. FROM b_examine_result ber
  2186. INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
  2187. WHERE ber.tenant_id = @TenantId
  2188. AND ber.IsDeleted = 0
  2189. AND ber.morder_no = @WorkOrd
  2190. AND ber.Id = (
  2191. SELECT br.Id FROM b_examine_result br
  2192. WHERE br.tenant_id = @TenantId AND br.morder_no = @WorkOrd AND br.IsDeleted = 0
  2193. ORDER BY br.create_time DESC LIMIT 1
  2194. )
  2195. AND IFNULL(bce.qty, 0) > 0
  2196. AND IFNULL(bce.erp_cls, 3) = 3
  2197. """,
  2198. new SugarParameter("@TenantId", tenantId),
  2199. new SugarParameter("@WorkOrd", workOrd));
  2200. if (materials.Count == 0)
  2201. return null;
  2202. // MIN(可用库存 / qty) = 扣减已占用后的瓶颈物料可生产成品数
  2203. decimal? minProducible = null;
  2204. foreach (var mat in materials)
  2205. {
  2206. if (mat.Qty <= 0) continue;
  2207. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  2208. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  2209. var producible = availableUseQty / mat.Qty;
  2210. if (minProducible is null || producible < minProducible)
  2211. minProducible = producible;
  2212. }
  2213. if (minProducible is null)
  2214. return null;
  2215. // 可生产数 > 工单需求量则取工单需求量
  2216. var result = Math.Ceiling(minProducible.Value);
  2217. if (qtyOrded > 0 && result > qtyOrded)
  2218. result = qtyOrded;
  2219. // 记录本工单占用的库存量
  2220. foreach (var mat in materials)
  2221. {
  2222. if (mat.Qty <= 0) continue;
  2223. var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
  2224. var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
  2225. var consume = Math.Min(availableUseQty, result * mat.Qty);
  2226. if (!consumedStock.ContainsKey(mat.ItemNumber))
  2227. consumedStock[mat.ItemNumber] = 0m;
  2228. consumedStock[mat.ItemNumber] += consume;
  2229. }
  2230. return result;
  2231. }
  2232. private sealed class LocationStockMaterialRow
  2233. {
  2234. public string ItemNumber { get; set; } = string.Empty;
  2235. public decimal UseQty { get; set; }
  2236. public decimal Qty { get; set; }
  2237. }
  2238. private sealed class PendingWorkOrderRow
  2239. {
  2240. public long RecId { get; set; }
  2241. public string WorkOrd { get; set; } = string.Empty;
  2242. public string? ItemNum { get; set; }
  2243. public string? Domain { get; set; }
  2244. public decimal? QtyOrded { get; set; }
  2245. public DateTime? OrdDate { get; set; }
  2246. public DateTime? DueDate { get; set; }
  2247. public decimal? Priority { get; set; }
  2248. public int? Urgent { get; set; }
  2249. public string Status { get; set; } = string.Empty;
  2250. }
  2251. private sealed class RoutingRow
  2252. {
  2253. public int Op { get; set; }
  2254. public string? ProdLine { get; set; }
  2255. public string? WorkCtr { get; set; }
  2256. public string? Site { get; set; }
  2257. public string? StdOp { get; set; }
  2258. public string? Machine { get; set; }
  2259. public string? Engineer { get; set; }
  2260. public decimal RunCrew { get; set; }
  2261. public decimal? MachBdnRate { get; set; }
  2262. public int? MachinestPerOp { get; set; }
  2263. public decimal? RunTime { get; set; }
  2264. public decimal? StdRunTime { get; set; }
  2265. public decimal ProdLineRate { get; set; }
  2266. public string? WorkCode { get; set; }
  2267. public string? OpType { get; set; }
  2268. public string? OpDescr { get; set; }
  2269. public decimal? SetupTime { get; set; }
  2270. }
  2271. private sealed class CalendarRow
  2272. {
  2273. public string? WorkCtr { get; set; }
  2274. public int? WeekDay { get; set; }
  2275. }
  2276. private sealed class OccupiedSlotRow
  2277. {
  2278. public string? WorkCtr { get; set; }
  2279. public DateTime? PlanDate { get; set; }
  2280. }
  2281. }