Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs
T
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00

1021 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<OvenSchedulerController> _logger;
private readonly IHubContext<ShopHub> _shopHub;
private readonly ITenantContext _tenantContext;
/// <summary>
/// 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.
/// </summary>
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<OvenSchedulerController> logger,
IHubContext<ShopHub> shopHub,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_aiSchedulingService = aiSchedulingService;
_logger = logger;
_shopHub = shopHub;
_tenantContext = tenantContext;
}
// ──────────────────────────────────────────────────────────────────────────
// INDEX — Main scheduler board
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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 <see cref="QueueableStatuses"/> filter ensures only jobs with pending oven work appear in
/// the queue; <c>scheduledCoatIds</c> is then used to suppress coats that are already in an
/// active batch so the queue always shows true remaining work.
/// </summary>
public async Task<IActionResult> 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<OvenBatchItem>();
// 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<int, Job>();
// 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<JobItemCoat>();
// 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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
/// <see cref="BatchSchedulingRequest"/> and forwarded to <see cref="IAiSchedulingService.SuggestBatchesAsync"/>.
/// 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 <see cref="AcceptSuggestion"/> to commit them.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<JobItemCoat>();
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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Persists one or more AI-suggested batches as real <see cref="OvenBatch"/> and
/// <see cref="OvenBatchItem"/> 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 <see cref="GenerateBatchNumberAsync"/> and is
/// flagged <c>AiSuggested = true</c> so the UI can distinguish AI-created batches from
/// manually created ones. The AI reasoning text is stored as JSON in <c>AiReasoningJson</c>
/// for auditability. Start/end times are only set when the AI provides a parseable
/// <c>SuggestedStartTime</c> string.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<object>();
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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Creates a new empty <see cref="OvenBatch"/> 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 <c>DefaultCycleMinutes</c> 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. <c>AiSuggested</c> is set to <c>false</c> to distinguish manual batches
/// from those created via <see cref="AcceptSuggestion"/>.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<object>()
}
});
}
// ──────────────────────────────────────────────────────────────────────────
// ADD TO BATCH — drag a coat from the queue into a batch
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Adds a single coat (identified by <c>JobItemCoatId</c>) 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 <c>SurfaceAreaSqFt × Quantity</c> 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Moves an existing <see cref="OvenBatchItem"/> 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 <c>InProgress</c> 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Marks a batch item as <see cref="OvenBatchItemStatus.Removed"/> (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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Transitions a batch from Planned/Loading to InProgress, advances all pending batch items
/// to <see cref="OvenBatchItemStatus.InOven"/>, and pushes the affected jobs to the
/// <c>IN_OVEN</c> status in the job lifecycle.
/// The job status update is critical for shop-floor visibility: shop workers checking the
/// <c>/hubs/shop</c> SignalR feed will see the status change immediately via the
/// <c>JobStatusChanged</c> push event sent after the DB save. The estimated end time is
/// recalculated from <c>DateTime.UtcNow</c> rather than the scheduled start to reflect the
/// actual time the oven run began.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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 (<c>allJobCoatIds</c>) is represented in a completed
/// batch item (<c>completedCoatIds</c>) 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// 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 <see cref="OvenBatchStatus.InProgress"/> or beyond are rejected because their
/// items have already influenced job statuses; deleting them would leave jobs stuck in
/// <c>IN_OVEN</c> with no corresponding batch record.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
// ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Generates the next sequential batch number in the format <c>OVN-YYMM-####</c>.
/// Uses <c>ignoreQueryFilters: true</c> 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.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// Projects a collection of <see cref="OvenBatch"/> records into <see cref="OvenBatchDto"/> view objects,
/// attaching item details and capacity percentage for each batch.
/// Capacity percentage is derived from the oven's <c>MaxLoadSqFt</c> defined on the
/// <see cref="OvenCost"/> (Named Oven); when not set, capacity is shown as zero rather than throwing.
/// Items with status <see cref="OvenBatchItemStatus.Removed"/> are excluded from the output so
/// the UI only shows active items in each batch lane.
/// The <paramref name="ovenCosts"/> dictionary is consulted as a fallback for batches whose
/// <c>OvenCost</c> navigation property wasn't eagerly loaded (e.g., legacy batches created before
/// the Named Ovens migration).
/// </summary>
private static List<OvenBatchDto> BuildBatchDtos(
IEnumerable<OvenBatch> batches,
IEnumerable<OvenBatchItem> allBatchItems,
Dictionary<int, Job> jobDict,
IEnumerable<OvenCost> 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<OvenBatchItem>();
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();
}
/// <summary>
/// Builds the sorted, unscheduled coat queue shown in the left-hand column of the scheduler board.
/// Only coats whose IDs are absent from <paramref name="scheduledCoatIds"/> 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 <see cref="RemoveFromBatch"/>.
/// 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.
/// </summary>
private static List<QueuedJobDto> BuildQueuedJobDtos(
IEnumerable<Job> jobs,
HashSet<int> 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();
}
/// <summary>
/// Converts queue jobs into the richer <see cref="OvenReadyJobDto"/> shape expected by the AI
/// scheduling service, including per-coat cure temperatures and already-baked flags.
/// The <c>AlreadyBaked</c> flag on each coat (derived from <paramref name="scheduledCoatIds"/>)
/// 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.
/// </summary>
private static List<OvenReadyJobDto> BuildAiJobDtos(
IEnumerable<Job> jobs,
HashSet<int> 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<SuggestedBatch> 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; }
}