ProductionScheduleGenerationService.cs 97 KB

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