using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using PowderCoating.Application.DTOs.Scheduling; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Hubs; namespace PowderCoating.Web.Controllers; [Authorize] public class OvenSchedulerController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IAiSchedulingService _aiSchedulingService; private readonly ILogger _logger; private readonly IHubContext _shopHub; private readonly ITenantContext _tenantContext; /// /// Status codes that identify jobs whose coats are eligible to be loaded into the oven scheduler queue. /// These are the shop-floor statuses where physical powder has been applied or surfaces are ready — /// coats at these stages are the ones that actually need oven time. Terminal statuses (Curing, Completed, etc.) /// are intentionally excluded so completed work never re-appears in the queue. /// private static readonly string[] QueueableStatuses = { AppConstants.StatusCodes.Job.InPreparation, AppConstants.StatusCodes.Job.Sandblasting, AppConstants.StatusCodes.Job.MaskingTaping, AppConstants.StatusCodes.Job.Cleaning, AppConstants.StatusCodes.Job.Coating }; public OvenSchedulerController( IUnitOfWork unitOfWork, IAiSchedulingService aiSchedulingService, ILogger logger, IHubContext shopHub, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _aiSchedulingService = aiSchedulingService; _logger = logger; _shopHub = shopHub; _tenantContext = tenantContext; } // ────────────────────────────────────────────────────────────────────────── // INDEX — Main scheduler board // ────────────────────────────────────────────────────────────────────────── /// /// Renders the main oven scheduler board for a given date, showing all named ovens, their /// current batches, and the global job queue of unscheduled coats. /// Data is intentionally loaded in separate, targeted queries rather than one large join so that /// each query can be filtered at the database level — avoiding full-table scans on large datasets. /// The filter ensures only jobs with pending oven work appear in /// the queue; scheduledCoatIds is then used to suppress coats that are already in an /// active batch so the queue always shows true remaining work. /// public async Task Index(DateTime? date, string goal = "maximize_throughput") { var scheduledDate = date?.Date ?? DateTime.Today; // Load active Named Ovens — filter IsActive at database level var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .ToList(); // Load batches for the selected date — filter at database level with includes var scheduledDateEnd = scheduledDate.AddDays(1); var batches = (await _unitOfWork.OvenBatches.FindAsync( b => b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd && b.Status != OvenBatchStatus.Cancelled, false, b => b.OvenCost, b => b.Items)) .ToList(); // Load batch items — filter by batch IDs at database level var batchIds = batches.Select(b => b.Id).ToHashSet(); var allBatchItems = batchIds.Any() ? (await _unitOfWork.OvenBatchItems.FindAsync( i => batchIds.Contains(i.OvenBatchId), false, i => i.Job, i => i.JobItem, i => i.JobItemCoat)) .ToList() : new List(); // Load customer/status/priority for batch item jobs — filter by job IDs at database level var batchJobIds = allBatchItems.Select(i => i.JobId).Distinct().ToHashSet(); var batchJobs = batchJobIds.Any() ? (await _unitOfWork.Jobs.FindAsync( j => batchJobIds.Contains(j.Id), false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) .ToDictionary(j => j.Id) : new Dictionary(); // Load jobs in the queue — filter by status at database level var queueJobs = (await _unitOfWork.Jobs.FindAsync( j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode), false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems)) .ToList(); // Load coats for queue job items — filter by item IDs at database level var queueJobItemIds = queueJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToHashSet(); var queueCoats = queueJobItemIds.Any() ? (await _unitOfWork.JobItemCoats.FindAsync( c => queueJobItemIds.Contains(c.JobItemId), false, c => c.InventoryItem)) .ToList() : new List(); // Map coats back to items var coatsByItem = queueCoats.GroupBy(c => c.JobItemId).ToDictionary(g => g.Key, g => g.ToList()); foreach (var job in queueJobs) { foreach (var item in job.JobItems) { if (coatsByItem.TryGetValue(item.Id, out var coats)) item.Coats = coats; } } // Determine which coats are already scheduled — filter out removed/cancelled at database level var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync( i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled, false, i => i.Batch)) .Select(i => i.JobItemCoatId) .ToHashSet(); // Get company defaults var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true); var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45; // Build the view model var vm = new OvenSchedulerViewModel { ScheduledDate = scheduledDate, OptimizationGoal = goal, DefaultCycleMinutes = defaultCycleMinutes, Ovens = ovenCosts.Select(o => new OvenInfoDto { OvenCostId = o.Id, EquipmentId = 0, Name = o.Label, MaxLoadSqFt = o.MaxLoadSqFt, CycleMinutes = o.DefaultCycleMinutes ?? defaultCycleMinutes, Status = "Operational", IsOperational = true }).ToList(), Batches = BuildBatchDtos(batches, allBatchItems, batchJobs, ovenCosts), QueuedJobs = BuildQueuedJobDtos(queueJobs, scheduledCoatIds) }; return View(vm); } // ────────────────────────────────────────────────────────────────────────── // AI SUGGEST — POST, returns JSON // ────────────────────────────────────────────────────────────────────────── /// /// Calls the AI scheduling service to suggest optimal oven batches for today's queue and returns /// the recommendation as JSON for the client-side scheduler UI. /// All queueable jobs with their coats and surface-area data are packaged into a /// and forwarded to . /// Already-scheduled coat IDs are included so the AI does not re-suggest work that is already /// in a planned batch. The response is rendered client-side — no batches are persisted here; /// the user must call to commit them. /// [HttpPost] public async Task Suggest([FromBody] SuggestRequest req) { var goal = req?.OptimizationGoal ?? "maximize_throughput"; var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId)) .Where(o => o.IsActive) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .ToList(); if (!equipmentList.Any()) return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." }); var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; var queueJobs = (await _unitOfWork.Jobs.FindAsync( j => j.CompanyId == suggestCompanyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems)) .Where(j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode)) .ToList(); if (!queueJobs.Any()) return Json(new { success = false, error = "No jobs in the queue to schedule." }); // Load coats with inventory items var jobItemIds = queueJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToHashSet(); var allCoats = jobItemIds.Any() ? (await _unitOfWork.JobItemCoats.GetAllAsync(false, c => c.InventoryItem)) .Where(c => jobItemIds.Contains(c.JobItemId)) .ToList() : new List(); var coatsByItem = allCoats.GroupBy(c => c.JobItemId).ToDictionary(g => g.Key, g => g.ToList()); foreach (var job in queueJobs) foreach (var item in job.JobItems) if (coatsByItem.TryGetValue(item.Id, out var coats)) item.Coats = coats; var allBatchedItems = await _unitOfWork.OvenBatchItems.GetAllAsync(false, i => i.Batch); var scheduledCoatIds = allBatchedItems .Where(i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled) .Select(i => i.JobItemCoatId) .ToHashSet(); var aiRequest = new BatchSchedulingRequest { ScheduleFromDate = DateTime.Today, OptimizationGoal = goal, Ovens = equipmentList.Select(o => new OvenConfigDto { OvenCostId = o.Id, EquipmentId = 0, Name = o.Label, MaxLoadSqFt = o.MaxLoadSqFt, CycleMinutes = o.DefaultCycleMinutes ?? defaultCycleMinutes, }).ToList(), Jobs = BuildAiJobDtos(queueJobs, scheduledCoatIds) }; var suggestion = await _aiSchedulingService.SuggestBatchesAsync(aiRequest); if (!suggestion.Success) return Json(new { success = false, error = suggestion.ErrorMessage }); return Json(new { success = true, suggestion }); } // ────────────────────────────────────────────────────────────────────────── // ACCEPT SUGGESTION — POST, creates OvenBatch records from AI suggestion // ────────────────────────────────────────────────────────────────────────── /// /// Persists one or more AI-suggested batches as real and /// records, wrapped in a database transaction so that either /// all batches are created or none are (avoiding partial state on failure). /// Each batch receives a unique number via and is /// flagged AiSuggested = true so the UI can distinguish AI-created batches from /// manually created ones. The AI reasoning text is stored as JSON in AiReasoningJson /// for auditability. Start/end times are only set when the AI provides a parseable /// SuggestedStartTime string. /// [HttpPost] public async Task AcceptSuggestion([FromBody] AcceptSuggestionRequest req) { if (req?.Batches == null || !req.Batches.Any()) return Json(new { success = false, error = "No batches provided." }); var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; var createdBatches = new List(); try { await _unitOfWork.ExecuteInTransactionAsync(async () => { createdBatches.Clear(); var scheduledDate = req.ScheduledDate?.Date ?? DateTime.Today; foreach (var suggested in req.Batches) { var batchNumber = await GenerateBatchNumberAsync(); var batch = new OvenBatch { BatchNumber = batchNumber, OvenCostId = suggested.OvenCostId > 0 ? suggested.OvenCostId : (int?)null, Status = OvenBatchStatus.Planned, ScheduledDate = scheduledDate, TotalSurfaceAreaSqFt = suggested.EstimatedSqFt, CureTemperatureF = suggested.CureTemperatureF, CycleMinutes = suggested.EstimatedCycleMinutes > 0 ? suggested.EstimatedCycleMinutes : defaultCycleMinutes, PrimaryColorName = suggested.PrimaryColorName, PrimaryColorCode = suggested.PrimaryColorCode, AiSuggested = true, AiReasoningJson = suggested.Rationale }; if (!string.IsNullOrWhiteSpace(suggested.SuggestedStartTime) && TimeSpan.TryParse(suggested.SuggestedStartTime, out var t)) { batch.ScheduledStartTime = scheduledDate.Date.Add(t); batch.EstimatedEndTime = batch.ScheduledStartTime.Value.AddMinutes(batch.CycleMinutes); } await _unitOfWork.OvenBatches.AddAsync(batch); await _unitOfWork.SaveChangesAsync(); var sortOrder = 0; foreach (var item in suggested.Items) { await _unitOfWork.OvenBatchItems.AddAsync(new OvenBatchItem { OvenBatchId = batch.Id, JobId = item.JobId, JobItemId = item.JobItemId, JobItemCoatId = item.JobItemCoatId, SurfaceAreaContribution = item.SurfaceAreaSqFt, CoatPassNumber = item.CoatPassNumber, SortOrder = sortOrder++, Status = OvenBatchItemStatus.Pending }); } createdBatches.Add(new { batchId = batch.Id, batchNumber }); } }); // end ExecuteInTransactionAsync return Json(new { success = true, batches = createdBatches }); } catch (Exception ex) { _logger.LogError(ex, "Error accepting AI batch suggestion"); return Json(new { success = false, error = "Failed to save batches." }); } } // ────────────────────────────────────────────────────────────────────────── // CREATE BATCH — manually create an empty batch // ────────────────────────────────────────────────────────────────────────── /// /// Creates a new empty for the specified named oven and returns the /// full batch DTO so the client can render it immediately without a page reload. /// The oven's DefaultCycleMinutes is preferred over the company-level default so that /// per-oven timing is honored; the company default acts as a safe fallback when an oven has /// no specific value. AiSuggested is set to false to distinguish manual batches /// from those created via . /// [HttpPost] public async Task CreateBatch([FromBody] CreateBatchRequest req) { if (req.OvenCostId <= 0) return Json(new { success = false, error = "Invalid oven selected." }); var oven = await _unitOfWork.OvenCosts.GetByIdAsync(req.OvenCostId); if (oven == null) return Json(new { success = false, error = "Oven not found." }); var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; var batchNumber = await GenerateBatchNumberAsync(); var scheduledDate = req.ScheduledDate?.Date ?? DateTime.Today; var batch = new OvenBatch { BatchNumber = batchNumber, OvenCostId = req.OvenCostId, Status = OvenBatchStatus.Planned, ScheduledDate = scheduledDate, CycleMinutes = oven.DefaultCycleMinutes ?? defaultCycleMinutes, TotalSurfaceAreaSqFt = 0, AiSuggested = false, Notes = req.Notes }; if (req.ScheduledStartTime.HasValue) { batch.ScheduledStartTime = scheduledDate.Date.Add(req.ScheduledStartTime.Value); batch.EstimatedEndTime = batch.ScheduledStartTime.Value.AddMinutes(batch.CycleMinutes); } await _unitOfWork.OvenBatches.AddAsync(batch); await _unitOfWork.CompleteAsync(); return Json(new { success = true, batch = new { id = batch.Id, batchNumber = batch.BatchNumber, ovenCostId = batch.OvenCostId, equipmentId = 0, ovenName = oven.Label, status = batch.Status.ToString(), statusId = (int)batch.Status, scheduledDate = batch.ScheduledDate, scheduledStartTime = batch.ScheduledStartTime, estimatedEndTime = batch.EstimatedEndTime, cycleMinutes = batch.CycleMinutes, totalSurfaceAreaSqFt = 0m, maxLoadSqFt = oven.MaxLoadSqFt, capacityPct = 0m, aiSuggested = false, items = new List() } }); } // ────────────────────────────────────────────────────────────────────────── // ADD TO BATCH — drag a coat from the queue into a batch // ────────────────────────────────────────────────────────────────────────── /// /// Adds a single coat (identified by JobItemCoatId) from the scheduler queue into /// an existing batch and recalculates the batch's total surface area and capacity percentage. /// A coat-conflict check is performed before insertion: if the same coat is already active in /// a different non-cancelled batch, the request is rejected to prevent scheduling the same /// physical coat twice. Surface area is computed as SurfaceAreaSqFt × Quantity so that /// multi-piece items count their total oven footprint. The primary color is set from the first /// coat added if the batch has no color yet, giving operators a visual cue for batch grouping. /// [HttpPost] public async Task AddToBatch([FromBody] AddToBatchRequest req) { var batch = await _unitOfWork.OvenBatches.GetByIdAsync(req.BatchId, false, b => b.Items, b => b.OvenCost); if (batch == null) return Json(new { success = false, error = "Batch not found." }); if (batch.Status >= OvenBatchStatus.InProgress) return Json(new { success = false, error = "Cannot modify a batch that is already in progress." }); // Check if coat is already in another active batch var existingItems = await _unitOfWork.OvenBatchItems.GetAllAsync(false, i => i.Batch); var conflicting = existingItems.FirstOrDefault(i => i.JobItemCoatId == req.JobItemCoatId && i.Status != OvenBatchItemStatus.Removed && i.OvenBatchId != req.BatchId && i.Batch.Status != OvenBatchStatus.Cancelled); if (conflicting != null) return Json(new { success = false, error = $"This coat is already scheduled in batch {conflicting.Batch.BatchNumber}." }); var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(req.JobItemCoatId, false, c => c.JobItem, c => c.InventoryItem); if (coat == null) return Json(new { success = false, error = "Coat not found." }); var job = await _unitOfWork.Jobs.GetByIdAsync(coat.JobItem.JobId, false, j => j.Customer, j => j.JobPriority); var nextOrder = batch.Items.Any() ? batch.Items.Max(i => i.SortOrder) + 1 : 0; var sqft = coat.JobItem.SurfaceAreaSqFt * coat.JobItem.Quantity; var batchItem = new OvenBatchItem { OvenBatchId = req.BatchId, JobId = coat.JobItem.JobId, JobItemId = coat.JobItemId, JobItemCoatId = req.JobItemCoatId, SurfaceAreaContribution = sqft, CoatPassNumber = coat.Sequence, SortOrder = nextOrder, Status = OvenBatchItemStatus.Pending }; await _unitOfWork.OvenBatchItems.AddAsync(batchItem); batch.TotalSurfaceAreaSqFt = batch.Items.Sum(i => i.SurfaceAreaContribution) + sqft; if (string.IsNullOrEmpty(batch.PrimaryColorName) && !string.IsNullOrEmpty(coat.ColorName)) batch.PrimaryColorName = coat.ColorName; await _unitOfWork.CompleteAsync(); var capacityPct = batch.OvenCost?.MaxLoadSqFt > 0 ? Math.Round(batch.TotalSurfaceAreaSqFt / batch.OvenCost.MaxLoadSqFt.Value * 100, 1) : (decimal?)null; return Json(new { success = true, batchItemId = batchItem.Id, totalSurfaceAreaSqFt = batch.TotalSurfaceAreaSqFt, capacityPct, item = new { id = batchItem.Id, jobId = batchItem.JobId, jobItemId = batchItem.JobItemId, jobItemCoatId = batchItem.JobItemCoatId, jobNumber = job?.JobNumber ?? "", customerName = job?.Customer?.CompanyName ?? $"{job?.Customer?.ContactFirstName} {job?.Customer?.ContactLastName}".Trim(), itemDescription = coat.JobItem.Description, coatName = coat.CoatName, colorName = coat.ColorName, colorCode = coat.ColorCode, surfaceAreaContribution = sqft, coatPassNumber = coat.Sequence, priority = job?.JobPriority?.DisplayName ?? "Normal", dueDate = job?.DueDate } }); } // ────────────────────────────────────────────────────────────────────────── // MOVE TO BATCH — drag a batch item from one batch to another // ────────────────────────────────────────────────────────────────────────── /// /// Moves an existing from its current batch to a different target /// batch, adjusting the surface-area totals on both batches atomically. /// Both the source and target batch are guard-checked for InProgress or later status — /// in-progress batches are already physically loaded into the oven so their item list must not /// change. Surface area is subtracted from the source (floored at zero to avoid negative totals /// from floating-point drift) and added to the target. /// [HttpPost] public async Task MoveToBatch([FromBody] MoveToBatchRequest req) { var item = await _unitOfWork.OvenBatchItems.GetByIdAsync(req.BatchItemId, false, i => i.Batch); if (item == null) return Json(new { success = false, error = "Batch item not found." }); var targetBatch = await _unitOfWork.OvenBatches.GetByIdAsync(req.TargetBatchId, false, b => b.Items, b => b.Equipment); if (targetBatch == null) return Json(new { success = false, error = "Target batch not found." }); if (targetBatch.Status >= OvenBatchStatus.InProgress) return Json(new { success = false, error = "Cannot add items to a batch in progress." }); if (item.Batch.Status >= OvenBatchStatus.InProgress) return Json(new { success = false, error = "Cannot move items from a batch in progress." }); var sourceBatch = await _unitOfWork.OvenBatches.GetByIdAsync(item.OvenBatchId, false, b => b.Items); var oldSqft = item.SurfaceAreaContribution; item.OvenBatchId = req.TargetBatchId; item.SortOrder = targetBatch.Items.Any() ? targetBatch.Items.Max(i => i.SortOrder) + 1 : 0; if (sourceBatch != null) sourceBatch.TotalSurfaceAreaSqFt = Math.Max(0, sourceBatch.TotalSurfaceAreaSqFt - oldSqft); targetBatch.TotalSurfaceAreaSqFt += oldSqft; await _unitOfWork.CompleteAsync(); return Json(new { success = true, sourceBatchTotal = sourceBatch?.TotalSurfaceAreaSqFt ?? 0, targetBatchTotal = targetBatch.TotalSurfaceAreaSqFt }); } // ────────────────────────────────────────────────────────────────────────── // REMOVE FROM BATCH // ────────────────────────────────────────────────────────────────────────── /// /// Marks a batch item as (soft-removal, not a delete) /// and returns enough job/coat data for the JavaScript client to reconstruct the queue card /// without a full page reload. /// Soft-removal is used rather than physical deletion so that historical audit data on the batch /// is preserved. The response includes the full queue item payload — job number, customer, color, /// surface area, etc. — because the client needs to re-insert the coat back into the queue column. /// [HttpPost] public async Task RemoveFromBatch([FromBody] RemoveFromBatchRequest req) { var item = await _unitOfWork.OvenBatchItems.GetByIdAsync(req.BatchItemId, false, i => i.JobItem, i => i.JobItemCoat, i => i.JobItemCoat.InventoryItem); if (item == null) return Json(new { success = false, error = "Item not found." }); var batch = await _unitOfWork.OvenBatches.GetByIdAsync(item.OvenBatchId, false, b => b.Items); if (batch?.Status >= OvenBatchStatus.InProgress) return Json(new { success = false, error = "Cannot remove items from a batch in progress." }); var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId, false, j => j.Customer, j => j.JobPriority, j => j.JobStatus); item.Status = OvenBatchItemStatus.Removed; if (batch != null) batch.TotalSurfaceAreaSqFt = Math.Max(0, batch.TotalSurfaceAreaSqFt - item.SurfaceAreaContribution); await _unitOfWork.CompleteAsync(); return Json(new { success = true, batchTotal = batch?.TotalSurfaceAreaSqFt ?? 0, // Full data needed to rebuild the queue item in JS without a page reload queueItem = new { jobId = item.JobId, jobNumber = job?.JobNumber ?? "", customerName = job?.Customer?.CompanyName ?? $"{job?.Customer?.ContactFirstName} {job?.Customer?.ContactLastName}".Trim(), priority = job?.JobPriority?.DisplayName ?? "Normal", priorityId = job?.JobPriority?.DisplayOrder ?? 1, dueDate = job?.DueDate?.ToString("yyyy-MM-dd"), isOverdue = job?.DueDate.HasValue == true && job.DueDate.Value.Date < DateTime.Today, jobItemCoatId = item.JobItemCoatId, jobItemId = item.JobItemId, coatName = item.JobItemCoat?.CoatName ?? "", coatPassNumber = item.CoatPassNumber, colorName = item.JobItemCoat?.ColorName, colorCode = item.JobItemCoat?.ColorCode, surfaceAreaSqFt = item.SurfaceAreaContribution, itemDescription = item.JobItem?.Description ?? "" } }); } // ────────────────────────────────────────────────────────────────────────── // START BATCH // ────────────────────────────────────────────────────────────────────────── /// /// Transitions a batch from Planned/Loading to InProgress, advances all pending batch items /// to , and pushes the affected jobs to the /// IN_OVEN status in the job lifecycle. /// The job status update is critical for shop-floor visibility: shop workers checking the /// /hubs/shop SignalR feed will see the status change immediately via the /// JobStatusChanged push event sent after the DB save. The estimated end time is /// recalculated from DateTime.UtcNow rather than the scheduled start to reflect the /// actual time the oven run began. /// [HttpPost] public async Task StartBatch([FromBody] BatchActionRequest req) { var batch = await _unitOfWork.OvenBatches.GetByIdAsync(req.BatchId, false, b => b.Items); if (batch == null) return Json(new { success = false, error = "Batch not found." }); if (batch.Status != OvenBatchStatus.Planned && batch.Status != OvenBatchStatus.Loading) return Json(new { success = false, error = "Batch must be Planned or Loading to start." }); batch.Status = OvenBatchStatus.InProgress; batch.ActualStartTime = DateTime.UtcNow; batch.EstimatedEndTime = DateTime.UtcNow.AddMinutes(batch.CycleMinutes); foreach (var batchItem in batch.Items.Where(i => i.Status == OvenBatchItemStatus.Pending)) batchItem.Status = OvenBatchItemStatus.InOven; var inOvenStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.InOven); if (inOvenStatus != null) { var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet(); var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0; var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id)); foreach (var job in jobs) job.JobStatusId = inOvenStatus.Id; } await _unitOfWork.CompleteAsync(); var startBatchCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(startBatchCompanyId)) await _shopHub.Clients.Group($"shop-{startBatchCompanyId}").SendAsync("JobStatusChanged", new { }); return Json(new { success = true, status = "InProgress", startTime = batch.ActualStartTime }); } // ────────────────────────────────────────────────────────────────────────── // COMPLETE BATCH // ────────────────────────────────────────────────────────────────────────── /// /// Marks a batch as Completed, closes all in-oven batch items, and advances each affected job /// to the correct next status based on whether all of that job's coats across all batches are now done. /// The per-job logic is intentionally nuanced: a job with multiple coat passes may span several /// batches. Only when every coat of a job (allJobCoatIds) is represented in a completed /// batch item (completedCoatIds) does the job advance to CURING. Otherwise it moves back /// to COATING, signaling to the shop that additional oven passes remain. A SignalR push is fired /// after the save so real-time shop-floor displays update immediately without polling. /// [HttpPost] public async Task CompleteBatch([FromBody] BatchActionRequest req) { var batch = await _unitOfWork.OvenBatches.GetByIdAsync(req.BatchId, false, b => b.Items); if (batch == null) return Json(new { success = false, error = "Batch not found." }); if (batch.Status != OvenBatchStatus.InProgress) return Json(new { success = false, error = "Batch must be In Progress to complete." }); batch.Status = OvenBatchStatus.Completed; batch.ActualEndTime = DateTime.UtcNow; foreach (var batchItem in batch.Items.Where(i => i.Status == OvenBatchItemStatus.InOven)) batchItem.Status = OvenBatchItemStatus.Completed; var curingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.Curing); var coatingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.Coating); var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToList(); var allBatchedItems = await _unitOfWork.OvenBatchItems.GetAllAsync(false, i => i.Batch); foreach (var jobId in jobIds) { var allJobCoatIds = (await _unitOfWork.JobItemCoats.GetAllAsync(false, c => c.JobItem)) .Where(c => c.JobItem.JobId == jobId) .Select(c => c.Id) .ToHashSet(); var completedCoatIds = allBatchedItems .Where(i => i.JobId == jobId && i.Status == OvenBatchItemStatus.Completed && i.Batch.Status == OvenBatchStatus.Completed) .Select(i => i.JobItemCoatId) .ToHashSet(); var allDone = allJobCoatIds.All(id => completedCoatIds.Contains(id)); var job = await _unitOfWork.Jobs.GetByIdAsync(jobId); if (job != null) { if (allDone && curingStatus != null) job.JobStatusId = curingStatus.Id; else if (!allDone && coatingStatus != null) job.JobStatusId = coatingStatus.Id; } } await _unitOfWork.CompleteAsync(); var completeBatchCompanyId = _tenantContext.GetCurrentCompanyId()?.ToString(); if (!string.IsNullOrEmpty(completeBatchCompanyId)) await _shopHub.Clients.Group($"shop-{completeBatchCompanyId}").SendAsync("JobStatusChanged", new { }); return Json(new { success = true, status = "Completed", endTime = batch.ActualEndTime }); } // ────────────────────────────────────────────────────────────────────────── // DELETE BATCH // ────────────────────────────────────────────────────────────────────────── /// /// Soft-deletes a batch that has not yet started, preventing accidental removal of batches /// that are already physically running or have completed historical records. /// Batches at or beyond are rejected because their /// items have already influenced job statuses; deleting them would leave jobs stuck in /// IN_OVEN with no corresponding batch record. /// [HttpPost] public async Task DeleteBatch([FromBody] BatchActionRequest req) { var batch = await _unitOfWork.OvenBatches.GetByIdAsync(req.BatchId); if (batch == null) return Json(new { success = false, error = "Batch not found." }); if (batch.Status >= OvenBatchStatus.InProgress) return Json(new { success = false, error = "Cannot delete a batch that is in progress or completed." }); await _unitOfWork.OvenBatches.SoftDeleteAsync(req.BatchId); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } // ────────────────────────────────────────────────────────────────────────── // PRIVATE HELPERS // ────────────────────────────────────────────────────────────────────────── /// /// Generates the next sequential batch number in the format OVN-YYMM-####. /// Uses ignoreQueryFilters: true to include soft-deleted batches in the sequence scan — /// this prevents number reuse if a batch was deleted after being created in the same month, /// which is important for financial traceability and shop-floor audit trails. /// private async Task GenerateBatchNumberAsync() { var prefix = $"OVN-{DateTime.Now:yyMM}-"; var last = (await _unitOfWork.OvenBatches.FindAsync( b => b.BatchNumber.StartsWith(prefix), ignoreQueryFilters: true)) .OrderByDescending(b => b.BatchNumber) .Select(b => b.BatchNumber) .FirstOrDefault(); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) next = num + 1; return $"{prefix}{next:D4}"; } /// /// Projects a collection of records into view objects, /// attaching item details and capacity percentage for each batch. /// Capacity percentage is derived from the oven's MaxLoadSqFt defined on the /// (Named Oven); when not set, capacity is shown as zero rather than throwing. /// Items with status are excluded from the output so /// the UI only shows active items in each batch lane. /// The dictionary is consulted as a fallback for batches whose /// OvenCost navigation property wasn't eagerly loaded (e.g., legacy batches created before /// the Named Ovens migration). /// private static List BuildBatchDtos( IEnumerable batches, IEnumerable allBatchItems, Dictionary jobDict, IEnumerable ovenCosts) { var itemsByBatch = allBatchItems.GroupBy(i => i.OvenBatchId).ToDictionary(g => g.Key, g => g.ToList()); var ovenDict = ovenCosts.ToDictionary(o => o.Id); return batches.Select(b => { var items = itemsByBatch.GetValueOrDefault(b.Id) ?? new List(); ovenDict.TryGetValue(b.OvenCostId ?? 0, out var oven); var maxLoad = b.OvenCost?.MaxLoadSqFt ?? oven?.MaxLoadSqFt; var capPct = maxLoad > 0 ? Math.Round(b.TotalSurfaceAreaSqFt / maxLoad.Value * 100, 1) : 0m; return new OvenBatchDto { Id = b.Id, BatchNumber = b.BatchNumber, OvenCostId = b.OvenCostId ?? 0, EquipmentId = b.EquipmentId ?? 0, OvenName = b.OvenCost?.Label ?? oven?.Label ?? "Unknown Oven", Status = b.Status.ToString(), StatusId = (int)b.Status, ScheduledDate = b.ScheduledDate, ScheduledStartTime = b.ScheduledStartTime, EstimatedEndTime = b.EstimatedEndTime, ActualStartTime = b.ActualStartTime, ActualEndTime = b.ActualEndTime, TotalSurfaceAreaSqFt = b.TotalSurfaceAreaSqFt, MaxLoadSqFt = maxLoad, CapacityPct = capPct, CureTemperatureF = b.CureTemperatureF, CycleMinutes = b.CycleMinutes, PrimaryColorName = b.PrimaryColorName, PrimaryColorCode = b.PrimaryColorCode, AiSuggested = b.AiSuggested, AiReasoning = b.AiReasoningJson, Notes = b.Notes, Items = items .Where(i => i.Status != OvenBatchItemStatus.Removed) .Select(i => { jobDict.TryGetValue(i.JobId, out var job); return new BatchItemDto { Id = i.Id, JobId = i.JobId, JobItemId = i.JobItemId, JobItemCoatId = i.JobItemCoatId, JobNumber = job?.JobNumber ?? i.Job?.JobNumber ?? "", CustomerName = job?.Customer?.CompanyName ?? $"{job?.Customer?.ContactFirstName} {job?.Customer?.ContactLastName}".Trim(), ItemDescription = i.JobItem?.Description ?? "", CoatName = i.JobItemCoat?.CoatName ?? "", ColorName = i.JobItemCoat?.ColorName, ColorCode = i.JobItemCoat?.ColorCode, SurfaceAreaContribution = i.SurfaceAreaContribution, CoatPassNumber = i.CoatPassNumber, Priority = job?.JobPriority?.DisplayName ?? "Normal", DueDate = job?.DueDate, ItemStatus = i.Status.ToString() }; }).ToList() }; }).ToList(); } /// /// Builds the sorted, unscheduled coat queue shown in the left-hand column of the scheduler board. /// Only coats whose IDs are absent from are included, /// so each coat appears in the queue exactly once — it disappears when dropped into a batch and /// reappears if removed from the batch via . /// Jobs are ordered by descending priority ID then ascending due date so the most urgent, /// most overdue work surfaces at the top. Jobs with no remaining pending coats are filtered out /// entirely so an otherwise-complete job does not litter the queue. /// private static List BuildQueuedJobDtos( IEnumerable jobs, HashSet scheduledCoatIds) { var today = DateTime.Today; return jobs .Select(j => { var pendingCoats = j.JobItems .SelectMany(item => item.Coats .Where(c => !scheduledCoatIds.Contains(c.Id)) .OrderBy(c => c.Sequence) .Select(c => new QueuedCoatDto { JobItemId = item.Id, JobItemCoatId = c.Id, ItemDescription = item.Description, CoatName = c.CoatName, CoatPassNumber = c.Sequence, ColorName = c.ColorName, ColorCode = c.ColorCode, SurfaceAreaSqFt = item.SurfaceAreaSqFt * item.Quantity, CureTemperatureF = c.InventoryItem?.CureTemperatureF })) .ToList(); var priorityId = j.JobPriority?.DisplayOrder ?? 1; return new QueuedJobDto { JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), Priority = j.JobPriority?.DisplayName ?? "Normal", PriorityId = priorityId, DueDate = j.DueDate, IsOverdue = j.DueDate.HasValue && j.DueDate.Value.Date < today, JobStatus = j.JobStatus?.DisplayName ?? "", TotalSqFt = j.JobItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity), PendingCoats = pendingCoats }; }) .Where(q => q.PendingCoats.Any()) .OrderByDescending(q => q.PriorityId) .ThenBy(q => q.DueDate ?? DateTime.MaxValue) .ToList(); } /// /// Converts queue jobs into the richer shape expected by the AI /// scheduling service, including per-coat cure temperatures and already-baked flags. /// The AlreadyBaked flag on each coat (derived from ) /// allows the AI to understand which passes are still outstanding without requiring it to query /// the database itself — all relevant context is packaged up front. Jobs with no unscheduled /// coats are excluded so the AI payload is minimal and focused. /// private static List BuildAiJobDtos( IEnumerable jobs, HashSet scheduledCoatIds) { return jobs.Select(j => new OvenReadyJobDto { JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), Priority = j.JobPriority?.DisplayName ?? "Normal", DueDate = j.DueDate, Status = j.JobStatus?.StatusCode ?? "", Items = j.JobItems .Where(i => i.Coats.Any(c => !scheduledCoatIds.Contains(c.Id))) .Select(item => new OvenReadyItemDto { JobItemId = item.Id, Description = item.Description, Quantity = item.Quantity, SurfaceAreaSqFt = item.SurfaceAreaSqFt, TotalSqFt = item.SurfaceAreaSqFt * item.Quantity, Coats = item.Coats.OrderBy(c => c.Sequence).Select(c => new OvenReadyCoatDto { JobItemCoatId = c.Id, CoatName = c.CoatName, Sequence = c.Sequence, ColorName = c.ColorName, ColorCode = c.ColorCode, CureTemperatureF = c.InventoryItem?.CureTemperatureF, CureTimeMinutes = c.InventoryItem?.CureTimeMinutes, AlreadyBaked = scheduledCoatIds.Contains(c.Id) }).ToList() }).ToList() }).Where(j => j.Items.Any()).ToList(); } } // ────────────────────────────────────────────────────────────────────────────── // Request DTOs // ────────────────────────────────────────────────────────────────────────────── public class SuggestRequest { public string OptimizationGoal { get; set; } = "maximize_throughput"; } public class AcceptSuggestionRequest { public DateTime? ScheduledDate { get; set; } public List Batches { get; set; } = new(); } public class CreateBatchRequest { public int OvenCostId { get; set; } public int EquipmentId { get; set; } // legacy, kept for backward compatibility public DateTime? ScheduledDate { get; set; } public TimeSpan? ScheduledStartTime { get; set; } public string? Notes { get; set; } } public class AddToBatchRequest { public int BatchId { get; set; } public int JobItemCoatId { get; set; } } public class MoveToBatchRequest { public int BatchItemId { get; set; } public int TargetBatchId { get; set; } } public class RemoveFromBatchRequest { public int BatchItemId { get; set; } } public class BatchActionRequest { public int BatchId { get; set; } }