using System.Diagnostics; using System.Text.Json; using GB5Shared.DTO.Framework.Login; using MMDAL.CustomCode.Scheduling; using MMDAL.DTO.Scheduling; namespace MMBLL.Scheduling.Engine { public class SchedulingEngine : ISchedulingEngine { private const int ProgressReportEvery = 10; private readonly ISchedulingDemandDAL _DemandDAL; private readonly IBatchEngine _BatchEngine; private readonly ICandidateGenerator _CandidateGenerator; private readonly ISlotFinder _SlotFinder; private readonly IRCCPService _RCCPService; private readonly ICalendarService _CalendarService; private readonly ISchedulingRunDAL _SchedulingRunDAL; public SchedulingEngine( ISchedulingDemandDAL demandDAL, IBatchEngine batchEngine, ICandidateGenerator candidateGenerator, ISlotFinder slotFinder, IRCCPService rccpService, ICalendarService calendarService, ISchedulingRunDAL schedulingRunDAL) { _DemandDAL = demandDAL; _BatchEngine = batchEngine; _CandidateGenerator = candidateGenerator; _SlotFinder = slotFinder; _RCCPService = rccpService; _CalendarService = calendarService; _SchedulingRunDAL = schedulingRunDAL; } public async Task RunAsync( SchedulingRunTriggerDTO trigger, int schedulingRunId, LoginDTO login, IProgress progress, CancellationToken ct) { var sw = Stopwatch.StartNew(); var context = new SchedulingContext { TraceEnabled = trigger.TraceEnabled }; // ── Step 1: Stream demand ───────────────────────────────────────── progress.Report(new SchedulingProgressReport { Percent = 5, Step = "Loading demand", CurrentItem = string.Empty }); var eus = new List(); await foreach (var eu in _DemandDAL.StreamOpenIndentsAsync(trigger, login, ct).ConfigureAwait(false)) { if (eu.DurationMinutes <= 0) eu.DurationMinutes = 1; // guard against misconfigured routing eus.Add(eu); context.AddTrace(eu.EuId, "DEMAND_LOADED", 1, JsonSerializer.Serialize(new { eu.EuId, DemandType = eu.DemandType.ToString(), IndentId = eu.SourceDocumentId, DetailId = eu.SourceDetailId, eu.ItemId, eu.WorkCenterId, eu.DurationMinutes, DurationSource = eu.DurationSource, eu.Quantity, Deadline = eu.Deadline }), (int)sw.ElapsedMilliseconds); } // Nesting plans → ExecutionUnit (DemandType=NestingPlan) await foreach (var np in _DemandDAL.StreamOpenNestingPlansAsync(trigger, login, ct).ConfigureAwait(false)) { eus.Add(new ExecutionUnit { EuId = eus.Count + 1, DemandType = DemandType.NestingPlan, SourceDocumentId = np.NestingPlanId, SourceDetailId = np.NestingPlanId, // used in BLL to call UpdateNestingPlanScheduleAsync WorkCenterId = np.WorkCenterId, DurationMinutes = np.DurationMinutes, EarliestStart = np.EarliestStart, ComputedEarliestStart = np.EarliestStart, Deadline = np.Deadline, Quantity = 1, PlanNature = 0, ResourceLevel = 2 // Pattern-level for nesting }); } progress.Report(new SchedulingProgressReport { Percent = 10, Step = "Demand loaded", CurrentItem = $"{eus.Count} execution units" }); // ── Step 2: Batch grouping ──────────────────────────────────────── progress.Report(new SchedulingProgressReport { Percent = 15, Step = "Grouping batches", CurrentItem = string.Empty }); var batchGroups = await _BatchEngine.GroupAsync(eus, login, ct).ConfigureAwait(false); // Flatten: use merged EU for batches; keep original EUs for singles var flatEUs = batchGroups.Select(bg => bg.ToMergedEU()).ToList(); foreach (var eu in flatEUs) context.AddTrace(eu.EuId, "BATCH_GROUPED", 2, JsonSerializer.Serialize(new { eu.RootBatchNumber, BatchKey = $"WC={eu.WorkCenterId},Proc={eu.ProcessId},Item={eu.ItemId},Sku={eu.SkuId}", ItemCount = eu.BatchItemCount, TotalQty = eu.Quantity }), (int)sw.ElapsedMilliseconds); // ── Step 3: Topological sort ────────────────────────────────────── progress.Report(new SchedulingProgressReport { Percent = 20, Step = "Building dependency graph", CurrentItem = string.Empty }); IReadOnlyList sortedEUs; try { sortedEUs = DependencyGraph.Sort(flatEUs); } catch (SchedulingEngineException) { throw; // Surface cycle detection failure to ProcessingService → SignalR ReceiveError } // ── Step 4: RCCP check ──────────────────────────────────────────── progress.Report(new SchedulingProgressReport { Percent = 25, Step = "RCCP capacity check", CurrentItem = string.Empty }); var rccpReport = await _RCCPService.CheckAsync( sortedEUs, trigger.HorizonFrom, trigger.HorizonTo, context, login, ct).ConfigureAwait(false); foreach (var wc in rccpReport) { if (wc.IsOverloaded) progress.Report(new SchedulingProgressReport { Percent = 25, Step = "RCCP Warning", CurrentItem = $"WorkCenter {wc.WorkCenterId} at {wc.OverloadPct:F1}% capacity" }); context.AddTrace(null, "RCCP_CHECKED", 4, JsonSerializer.Serialize(new { wc.WorkCenterId, wc.CapacityMinutes, wc.RequiredMinutes, wc.OverloadPct, wc.IsOverloaded }), (int)sw.ElapsedMilliseconds); } // ── Step 4b: Pre-populate calendar cache for all work centers in this run ───── // Single batch DB call loads all shift patterns; eliminates per-day lazy loads during scheduling. var workCenterIds = sortedEUs.Select(e => e.WorkCenterId).Distinct().ToList(); await _CalendarService .PrePopulateCalendarCacheAsync(workCenterIds, trigger.HorizonFrom, trigger.HorizonTo, context, login, ct) .ConfigureAwait(false); // ── Step 4c: Pre-book firm/released/in-progress plan rows from prior runs ── // ISLOCKED=1 rows must not be double-booked; load them before the scheduling loop starts. var preBooked = await _SchedulingRunDAL .GetLockedFutureSlotsAsync(trigger.HorizonFrom, trigger.HorizonTo, login, ct) .ConfigureAwait(false); foreach (var slot in preBooked) { context.BookSlot(slot.MachineId, new SlotProposal( slot.MachineId, slot.PatternId, slot.ScheduledStartDate, slot.ScheduledEndDate, ParallelNumber: 1, PatternAlreadyMounted: true)); // Pre-booked slots occupy a physical mould — claim it so it cannot be assigned to another machine if (slot.PatternId.HasValue) context.ClaimPattern(slot.PatternId.Value, slot.MachineId); } // ── Step 5: Main scheduling loop ────────────────────────────────── int scheduled = 0, failed = 0; int total = sortedEUs.Count; int combineIdBase = schedulingRunId * 10_000_000; for (int i = 0; i < total; i++) { ct.ThrowIfCancellationRequested(); var eu = sortedEUs[i]; if (i % ProgressReportEvery == 0) { byte pct = (byte)(25 + (i * 70 / Math.Max(total, 1))); progress.Report(new SchedulingProgressReport { Percent = pct, Step = "Scheduling", CurrentItem = $"EU {i + 1}/{total} — doc {eu.SourceDocumentId}" }); } // Monitor-only steps: no machine assignment needed (IsMonitorOnly: 0=Yes) if (eu.IsMonitorOnly == 0) { CommitMonitorOnlyRow(eu, schedulingRunId, combineIdBase + i, context, login); context.DecisionLog.Add(BuildBaseLog(eu, schedulingRunId, i, login) with { Decision = 2 }); context.AddTrace(eu.EuId, "EU_COMMITTED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 2 }), (int)sw.ElapsedMilliseconds); scheduled++; continue; } var committed = await ScheduleEUAsync(eu, schedulingRunId, combineIdBase + i, i, context, login, ct).ConfigureAwait(false); if (committed) scheduled++; else failed++; } sw.Stop(); progress.Report(new SchedulingProgressReport { Percent = 98, Step = "Finalising", CurrentItem = string.Empty }); return new SchedulingEngineResult { CommittedPlan = context.CommittedPlan, BatchChainRows = context.BatchChainRows, MountTasks = context.MountTasks, RCCPReport = rccpReport.ToList(), DecisionLog = context.DecisionLog, TraceLog = context.TraceLog, EUsScheduled = scheduled, EUsFailed = failed, DurationMs = sw.ElapsedMilliseconds }; } // ── EU Scheduling ───────────────────────────────────────────────────── private record CandidateEval( int MachineId, double Score, bool SlotFound, string RejectReason, int? PatternId, bool PatternMounted); private async Task ScheduleEUAsync( ExecutionUnit eu, int runId, int combineId, int seqNo, SchedulingContext context, LoginDTO login, CancellationToken ct) { var euSw = Stopwatch.StartNew(); var candidates = await _CandidateGenerator .GetCandidatesAsync(eu, context, login, ct).ConfigureAwait(false); context.AddTrace(eu.EuId, "CANDIDATES_FOUND", 5, JsonSerializer.Serialize(new { eu.EuId, eu.WorkCenterId, Count = candidates.Count, Machines = candidates.Select(c => new { c.MachineId, c.PatternAlreadyMounted }) }), (int)euSw.ElapsedMilliseconds); if (!candidates.Any()) { var noCanReason = $"No machine candidates configured for WorkCenter {eu.WorkCenterId}. Check machine-to-work-center assignments."; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 3, FailureReason = noCanReason }); context.AddTrace(eu.EuId, "EU_FAILED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 3, FailureReason = noCanReason }), (int)euSw.ElapsedMilliseconds); return false; } // Subcontracting: synthetic candidate with no machine if (eu.ExecutionMode == 1) { CommitSubcontractRow(eu, runId, combineId, context, login); context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 1 }); context.AddTrace(eu.EuId, "EU_COMMITTED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 1 }), (int)euSw.ElapsedMilliseconds); return true; } // Build per-item list here — shared by both parallel and single-machine paths var planItems = eu.BatchItemCount > 1 && eu.BatchedSourceDetailIds.Count > 0 ? eu.BatchedSourceDetailIds .Zip(eu.BatchedItemQtys, (id, qty) => (DetailId: id, Qty: qty)) .ToList() : new List<(int DetailId, decimal Qty)> { (eu.SourceDetailId, eu.Quantity) }; // ── Parallel mode: SPLITS > 1 → require N simultaneous machines ────────── if (eu.Splits > 1) { if (candidates.Count < eu.Splits) { var reason = $"Parallel mode requires {eu.Splits} machines but only {candidates.Count} available for WorkCenter {eu.WorkCenterId}."; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 3, FailureReason = reason }); context.AddTrace(eu.EuId, "EU_FAILED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 3, FailureReason = reason }), (int)euSw.ElapsedMilliseconds); return false; } // Take top-N candidates with DISTINCT PatternIds. // A physical mould cannot be on two machines simultaneously — skip any candidate whose // pattern is already reserved by another machine in this parallel group. var topN = new List(eu.Splits); var parallelPatterns = new HashSet(); foreach (var c in candidates) { if (c.PatternId.HasValue && !parallelPatterns.Add(c.PatternId.Value)) continue; // pattern already reserved by another machine in this group topN.Add(c); if (topN.Count >= eu.Splits) break; } if (topN.Count < eu.Splits) { var distinctReason = $"Parallel mode requires {eu.Splits} machines with distinct patterns but only {topN.Count} " + $"machines found with non-conflicting patterns for WorkCenter {eu.WorkCenterId}."; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 3, FailureReason = distinctReason }); context.AddTrace(eu.EuId, "EU_FAILED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 3, FailureReason = distinctReason }), (int)euSw.ElapsedMilliseconds); return false; } var parallelProposals = await _SlotFinder .FindParallelSlotsAsync(eu, topN, context, login, ct) .ConfigureAwait(false); if (!parallelProposals.Any()) { var reason = $"No simultaneous {eu.Splits}-machine slot found within {SlotFinder.MaxLookAheadDays}-day lookahead."; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 4, CandidatesConsidered = (short)topN.Count, FailureReason = reason, CandidatesJson = SerializeCandidates( topN.Select(c => new CandidateEval(c.MachineId, 0.0, false, "NoSimultaneousSlot", c.PatternId, c.PatternAlreadyMounted)).ToList(), winningMachineId: null) }); context.AddTrace(eu.EuId, "EU_FAILED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 4, FailureReason = reason }), (int)euSw.ElapsedMilliseconds); return false; } bool anyChangeover = false; for (int pIdx = 0; pIdx < parallelProposals.Count; pIdx++) { var proposal = parallelProposals[pIdx]; context.BookSlot(proposal.MachineId, proposal); // Claim pattern so subsequent EUs cannot assign this mould to another machine if (proposal.PatternId.HasValue) context.ClaimPattern(proposal.PatternId.Value, proposal.MachineId); // Per-machine changeover — each machine in the parallel set has its own mould if (!proposal.PatternAlreadyMounted && proposal.PatternId.HasValue) { anyChangeover = true; var mountStart = proposal.Start.AddMinutes(-30); int planIdx = context.CommittedPlan.Count; context.MountTasks.Add(new MountTaskDTO { MachineId = proposal.MachineId, PatternId = proposal.PatternId!.Value, CommittedPlanStartIndex = planIdx, MountStart = mountStart, MountEnd = proposal.Start, TenantId = login.ClientId }); context.LastMountedPatternId[proposal.MachineId] = proposal.PatternId; context.AddTrace(eu.EuId, "PATTERN_CHANGEOVER", 7, JsonSerializer.Serialize(new { eu.EuId, MachineId = proposal.MachineId, PatternId = proposal.PatternId, MountStart = mountStart, MountEnd = proposal.Start }), (int)euSw.ElapsedMilliseconds); } bool shelfLife = eu.MaxWaitMinutes > 0 && (proposal.Start - eu.ComputedEarliestStart).TotalMinutes > eu.MaxWaitMinutes; foreach (var (detailId, itemQty) in planItems) { context.CommittedPlan.Add(new ResourcePlanInsertDTO { IndentDetailId = detailId, MachineId = proposal.MachineId, WorkCenterId = eu.WorkCenterId, ProcessId = eu.ProcessId, PlannedQuantity = SplitQty(itemQty, pIdx, parallelProposals.Count, eu.QuantityDecimalPlaces), ScheduledStartDate = proposal.Start, ScheduledEndDate = proposal.End, ResourcePlanCombineId = combineId, ParallelNumber = (short)proposal.ParallelNumber, IsInterruptible = eu.IsInterruptible, SchedulingRunId = runId, IsLocked = 0, Particulars = shelfLife ? "SHELF_LIFE_VIOLATION" : null, PlanNature = eu.PlanNature, ResourceLevel = 1, PatternId = proposal.PatternId, TenantId = login.ClientId, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow }); } } eu.ScheduledStart = parallelProposals[0].Start; eu.ScheduledEnd = parallelProposals[0].End; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 0, MachineId = parallelProposals[0].MachineId, PatternId = parallelProposals[0].PatternId, PatternNewMount = anyChangeover, ScheduledStart = eu.ScheduledStart, ScheduledEnd = eu.ScheduledEnd, SlotWaitMins = (int)(eu.ScheduledStart!.Value - eu.ComputedEarliestStart).TotalMinutes, DeadlineSlackMins = (int)(eu.Deadline - eu.ScheduledEnd!.Value).TotalMinutes, ShelfLifeViolation = parallelProposals.Any(p => eu.MaxWaitMinutes > 0 && (p.Start - eu.ComputedEarliestStart).TotalMinutes > eu.MaxWaitMinutes), WinningScore = null, CandidatesConsidered = (short)topN.Count, CandidatesJson = SerializeCandidates( topN.Select((c, _) => new CandidateEval(c.MachineId, 0.0, true, string.Empty, c.PatternId, c.PatternAlreadyMounted)).ToList(), winningMachineId: null) }); context.AddTrace(eu.EuId, "EU_COMMITTED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 0, Parallel = eu.Splits, Machines = parallelProposals.Select(p => p.MachineId).ToList(), PlanRowCount = planItems.Count * parallelProposals.Count }), (int)euSw.ElapsedMilliseconds); if (!string.IsNullOrEmpty(eu.RootBatchNumber)) { context.BatchChainMap[eu.RootBatchNumber] = parallelProposals.Last(); context.BatchChainRows.Add(new BatchChainInsertDTO { RootBatchNumber = eu.RootBatchNumber, ProcessStep = (short)eu.RoutingVersionDetailId, BatchId = eu.SourceDocumentId, SchedulingRunId = runId, TenantId = login.ClientId }); } return true; } // ── Single-machine mode: scoring loop ───────────────────────────────────── SlotProposal? bestSlot = null; double bestScore = double.MinValue; var bestCandidateDto = candidates[0]; List? bestProposals = null; int horizonMinutes = (int)(eu.Deadline - eu.ComputedEarliestStart).TotalMinutes; var evalLog = new List(candidates.Count); foreach (var candidate in candidates) { var (proposals, rejectReason) = await _SlotFinder .FindSlotAsync(eu, candidate, context, login, ct).ConfigureAwait(false); if (!proposals.Any()) { evalLog.Add(new CandidateEval( candidate.MachineId, 0.0, false, rejectReason, candidate.PatternId, candidate.PatternAlreadyMounted)); context.AddTrace(eu.EuId, "SLOT_EVALUATED", 5, JsonSerializer.Serialize(new { eu.EuId, MachineId = candidate.MachineId, SlotFound = false, RejectReason = rejectReason, SlotStart = (DateTime?)null, SlotEnd = (DateTime?)null, Score = (double?)null }), (int)euSw.ElapsedMilliseconds); continue; } var primary = proposals[0]; double score = ScoringEngine.Score(eu, candidate, primary, eu.Deadline, horizonMinutes); evalLog.Add(new CandidateEval( candidate.MachineId, score, true, string.Empty, candidate.PatternId, candidate.PatternAlreadyMounted)); context.AddTrace(eu.EuId, "SLOT_EVALUATED", 5, JsonSerializer.Serialize(new { eu.EuId, MachineId = candidate.MachineId, SlotFound = true, RejectReason = string.Empty, SlotStart = (DateTime?)primary.Start, SlotEnd = (DateTime?)primary.End, Score = (double?)Math.Round(score, 6) }), (int)euSw.ElapsedMilliseconds); if (score > bestScore) { bestScore = score; bestSlot = primary; bestCandidateDto = candidate; bestProposals = proposals.ToList(); } } if (bestSlot is null || bestProposals is null) { var reasonSummary = evalLog .GroupBy(e => string.IsNullOrEmpty(e.RejectReason) ? "Unknown" : e.RejectReason) .Select(g => $"{g.Count()}×{g.Key}") .ToList(); var reasonText = reasonSummary.Count > 0 ? string.Join(", ", reasonSummary) : "unknown"; var noSlotReason = $"No slot found within {SlotFinder.MaxLookAheadDays}-day lookahead across {evalLog.Count} candidate machine(s). Reasons: {reasonText}."; context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 4, CandidatesConsidered = (short)evalLog.Count, CandidatesJson = SerializeCandidates(evalLog, winningMachineId: null), FailureReason = noSlotReason }); context.AddTrace(eu.EuId, "EU_FAILED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 4, FailureReason = noSlotReason }), (int)euSw.ElapsedMilliseconds); return false; } // Mark non-winning candidates that had a slot as LowerScore for (int k = 0; k < evalLog.Count; k++) { var e = evalLog[k]; if (e.SlotFound && e.MachineId != bestCandidateDto.MachineId) evalLog[k] = e with { RejectReason = "LowerScore" }; } // Pattern changeover detection bool changeover = !bestCandidateDto.PatternAlreadyMounted && bestCandidateDto.PatternId.HasValue; if (changeover) { var mountStart = bestSlot.Start.AddMinutes(-30); context.MountTasks.Add(new MountTaskDTO { MachineId = bestCandidateDto.MachineId, PatternId = bestCandidateDto.PatternId!.Value, ResourcePlanId = 0, // resolved in BLL after bulk insert using CommittedPlanStartIndex CommittedPlanStartIndex = context.CommittedPlan.Count, // first plan row for this EU MountStart = mountStart, MountEnd = bestSlot.Start, TenantId = login.ClientId }); context.LastMountedPatternId[bestCandidateDto.MachineId] = bestCandidateDto.PatternId; context.AddTrace(eu.EuId, "PATTERN_CHANGEOVER", 7, JsonSerializer.Serialize(new { eu.EuId, MachineId = bestCandidateDto.MachineId, PatternId = bestCandidateDto.PatternId, MountStart = mountStart, MountEnd = bestSlot.Start }), (int)euSw.ElapsedMilliseconds); } // Claim pattern regardless of changeover — physical mould is in use by this machine if (bestCandidateDto.PatternId.HasValue) context.ClaimPattern(bestCandidateDto.PatternId.Value, bestCandidateDto.MachineId); // Commit all proposals for this EU for (int pIdx = 0; pIdx < bestProposals.Count; pIdx++) { var proposal = bestProposals[pIdx]; context.BookSlot(proposal.MachineId, proposal); bool shelfLifeViolation = eu.MaxWaitMinutes > 0 && (proposal.Start - eu.ComputedEarliestStart).TotalMinutes > eu.MaxWaitMinutes; foreach (var (detailId, itemQty) in planItems) { context.CommittedPlan.Add(new ResourcePlanInsertDTO { IndentDetailId = detailId, MachineId = proposal.MachineId, WorkCenterId = eu.WorkCenterId, ProcessId = eu.ProcessId, PlannedQuantity = SplitQty(itemQty, pIdx, bestProposals.Count, eu.QuantityDecimalPlaces), ScheduledStartDate = proposal.Start, ScheduledEndDate = proposal.End, ResourcePlanCombineId = combineId, ParallelNumber = (short)proposal.ParallelNumber, IsInterruptible = eu.IsInterruptible, SchedulingRunId = runId, IsLocked = 0, Particulars = shelfLifeViolation ? "SHELF_LIFE_VIOLATION" : null, PlanNature = eu.PlanNature, ResourceLevel = 1, // Machine PatternId = bestCandidateDto.PatternId, TenantId = login.ClientId, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow }); } } // Record scheduled timestamps on EU for successor EarliestStart propagation eu.ScheduledStart = bestProposals.Min(p => p.Start); eu.ScheduledEnd = bestProposals.Max(p => p.End); // Decision log bool anyShelfLife = bestProposals.Any(p => eu.MaxWaitMinutes > 0 && (p.Start - eu.ComputedEarliestStart).TotalMinutes > eu.MaxWaitMinutes); context.DecisionLog.Add(BuildBaseLog(eu, runId, seqNo, login) with { Decision = 0, MachineId = bestCandidateDto.MachineId, PatternId = bestCandidateDto.PatternId, PatternNewMount = changeover, ScheduledStart = eu.ScheduledStart, ScheduledEnd = eu.ScheduledEnd, SlotWaitMins = (int)(eu.ScheduledStart!.Value - eu.ComputedEarliestStart).TotalMinutes, DeadlineSlackMins = (int)(eu.Deadline - eu.ScheduledEnd!.Value).TotalMinutes, ShelfLifeViolation = anyShelfLife, WinningScore = bestScore, CandidatesConsidered = (short)evalLog.Count, CandidatesJson = SerializeCandidates(evalLog, bestCandidateDto.MachineId) }); context.AddTrace(eu.EuId, "EU_COMMITTED", 7, JsonSerializer.Serialize(new { eu.EuId, Decision = 0, MachineId = bestCandidateDto.MachineId, WinningScore = Math.Round(bestScore, 6), SlotWaitMins = (int)(eu.ScheduledStart!.Value - eu.ComputedEarliestStart).TotalMinutes, DeadlineSlackMins = (int)(eu.Deadline - eu.ScheduledEnd!.Value).TotalMinutes, PlanRowCount = planItems.Count * bestProposals.Count }), (int)euSw.ElapsedMilliseconds); // BatchChain tracking if (!string.IsNullOrEmpty(eu.RootBatchNumber)) { context.BatchChainMap[eu.RootBatchNumber] = bestProposals.Last(); context.BatchChainRows.Add(new BatchChainInsertDTO { RootBatchNumber = eu.RootBatchNumber, ProcessStep = (short)eu.RoutingVersionDetailId, BatchId = eu.SourceDocumentId, SchedulingRunId = runId, TenantId = login.ClientId }); } return true; } // ── Qty split helper ───────────────────────────────────────────────── // Splits totalQty across partCount parallel slots with UOM-aware rounding. // The last part absorbs any rounding remainder so the sum always equals totalQty. private static decimal SplitQty(decimal total, int partIndex, int partCount, int decimalPlaces) { if (partCount == 1) return total; decimal part = Math.Round(total / partCount, decimalPlaces, MidpointRounding.AwayFromZero); if (partIndex == partCount - 1) return Math.Round(total - part * (partCount - 1), decimalPlaces); return part; } // ── Decision log helpers ────────────────────────────────────────────── private static EUDecisionLogDTO BuildBaseLog( ExecutionUnit eu, int runId, int seqNo, LoginDTO login) => new() { SchedulingRunId = runId, TenantId = login.ClientId, SeqNo = seqNo, DemandType = (byte)eu.DemandType, SourceDocumentId = eu.SourceDocumentId, SourceDetailId = eu.SourceDetailId, WorkCenterId = eu.WorkCenterId, ProcessId = eu.ProcessId, ItemId = eu.ItemId, SkuId = eu.SkuId, DurationMinutes = eu.DurationMinutes, DurationSource = eu.DurationSource, OriginalQuantity = eu.Quantity, ExecutionMode = eu.ExecutionMode, IsInterruptible = eu.IsInterruptible, MinimumRunMinutes = eu.MinimumRunMinutes, MaxWaitMinutes = eu.MaxWaitMinutes, EarlyStart = eu.EarliestStart, ComputedEarlyStart = eu.ComputedEarliestStart, Deadline = eu.Deadline, Priority = eu.Priority, RootBatchNumber = eu.RootBatchNumber ?? string.Empty, BatchItemCount = (short)eu.BatchItemCount, BatchedDetailIds = eu.BatchedSourceDetailIds.Count > 0 ? JsonSerializer.Serialize(eu.BatchedSourceDetailIds) : null, TotalBatchQty = eu.Quantity }; private static string? SerializeCandidates( List evals, int? winningMachineId) { if (evals.Count == 0) return null; var items = evals.Select(e => new { e.MachineId, Score = Math.Round(e.Score, 6), e.SlotFound, RejectReason = e.MachineId == winningMachineId ? string.Empty : e.RejectReason, e.PatternId, PatternMounted = e.PatternMounted }); return JsonSerializer.Serialize(items); } private static void CommitMonitorOnlyRow( ExecutionUnit eu, int runId, int combineId, SchedulingContext context, LoginDTO login) { context.CommittedPlan.Add(new ResourcePlanInsertDTO { IndentDetailId = eu.SourceDetailId, MachineId = 0, WorkCenterId = eu.WorkCenterId, ProcessId = eu.ProcessId, PlannedQuantity = eu.Quantity, ScheduledStartDate = eu.ComputedEarliestStart.AddMinutes(eu.MaxWaitMinutes), ScheduledEndDate = eu.ComputedEarliestStart.AddMinutes(eu.MaxWaitMinutes + eu.DurationMinutes), ResourcePlanCombineId = combineId, ParallelNumber = 1, SchedulingRunId = runId, PlanNature = eu.PlanNature, ResourceLevel = 0, // Monitor-only Particulars = eu.MonitorGroup, TenantId = login.ClientId, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow }); } private static void CommitSubcontractRow( ExecutionUnit eu, int runId, int combineId, SchedulingContext context, LoginDTO login) { // eu.DurationMinutes is already SubcontractLeadTimeDays × 480 — computed in SchedulingDemandQB CASE expression context.CommittedPlan.Add(new ResourcePlanInsertDTO { IndentDetailId = eu.SourceDetailId, MachineId = 0, WorkCenterId = eu.WorkCenterId, ProcessId = eu.ProcessId, PlannedQuantity = eu.Quantity, ScheduledStartDate = eu.ComputedEarliestStart, ScheduledEndDate = eu.ComputedEarliestStart.AddMinutes(eu.DurationMinutes), ResourcePlanCombineId = combineId, ParallelNumber = 1, SchedulingRunId = runId, PlanNature = eu.PlanNature, ResourceLevel = 6, // Subcontract TenantId = login.ClientId, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow }); eu.ScheduledStart = eu.ComputedEarliestStart; eu.ScheduledEnd = eu.ComputedEarliestStart.AddMinutes(eu.DurationMinutes); } } }