8acbc8605d
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>
1021 lines
50 KiB
C#
1021 lines
50 KiB
C#
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; }
|
||
}
|