using Extrudex.API.DTOs; using Extrudex.API.DTOs.PrintJobs; using Extrudex.Domain.Entities; using Extrudex.Domain.Enums; using Extrudex.Infrastructure.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Extrudex.API.Controllers; /// /// Controller for managing print jobs — the core operational record of Extrudex. /// Supports full CRUD with pagination, filtering by printer/spool/status, /// status transition validation, gram derivation from filament parameters, /// and soft-delete (sets Status to Cancelled). /// [ApiController] [Route("api/printjobs")] public class PrintJobsController : ControllerBase { private readonly ExtrudexDbContext _dbContext; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The database context for data access. /// The logger for diagnostic output. public PrintJobsController(ExtrudexDbContext dbContext, ILogger logger) { _dbContext = dbContext; _logger = logger; } // ── GET /api/printjobs ──────────────────────────────────────── /// /// Gets a paginated list of print jobs with optional filtering by /// printer, spool, and status. Results are ordered by creation date /// (newest first) and include denormalized printer name and spool serial. /// /// Optional query parameters for pagination and filtering. /// A paginated list of print jobs matching the filter criteria. /// Returns the paginated list of print jobs. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> GetPrintJobs( [FromQuery] PrintJobQueryParameters query) { _logger.LogDebug( "Getting print jobs: pageNumber={PageNumber}, pageSize={PageSize}, " + "printerId={PrinterId}, spoolId={SpoolId}, status={Status}", query.PageNumber, query.PageSize, query.PrinterId, query.SpoolId, query.Status); // Clamp pagination values var pageNumber = Math.Max(1, query.PageNumber); var pageSize = Math.Clamp(query.PageSize, 1, 100); var jobQuery = _dbContext.PrintJobs .Include(j => j.Printer) .Include(j => j.Spool) .AsQueryable(); // Apply filters if (query.PrinterId.HasValue) jobQuery = jobQuery.Where(j => j.PrinterId == query.PrinterId.Value); if (query.SpoolId.HasValue) jobQuery = jobQuery.Where(j => j.SpoolId == query.SpoolId.Value); if (!string.IsNullOrWhiteSpace(query.Status) && Enum.TryParse(query.Status, ignoreCase: true, out var statusFilter)) { jobQuery = jobQuery.Where(j => j.Status == statusFilter); } var totalCount = await jobQuery.CountAsync(); var items = await jobQuery .OrderByDescending(j => j.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Select(j => MapToResponse(j)) .ToListAsync(); var response = new PagedResponse { Items = items, TotalCount = totalCount, PageNumber = pageNumber, PageSize = pageSize }; return Ok(response); } // ── GET /api/printjobs/{id} ─────────────────────────────────── /// /// Gets a specific print job by its unique identifier. /// Includes denormalized printer name and spool serial for display. /// /// The unique identifier of the print job. /// The print job details. /// Returns the print job details. /// If the print job with the given ID is not found. [HttpGet("{id:guid}")] [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetPrintJob(Guid id) { _logger.LogDebug("Getting print job {Id}", id); var job = await _dbContext.PrintJobs .Include(j => j.Printer) .Include(j => j.Spool) .FirstOrDefaultAsync(j => j.Id == id); if (job is null) { _logger.LogWarning("Print job {Id} not found", id); return NotFound(new { error = $"Print job with ID '{id}' not found." }); } return Ok(MapToResponse(job)); } // ── POST /api/printjobs ─────────────────────────────────────── /// /// Creates a new print job record. Validates that PrinterId and SpoolId /// reference existing entities. When AutoDeriveGrams is true, the gram /// derivation formula is computed from the spool's material data: /// grams = mm_extruded × π × (diameter/2)² × (density/1000). /// /// The print job creation request with all required fields. /// The newly created print job with generated ID and timestamps. /// Returns the created print job with location header. /// If the request is invalid or foreign keys don't exist. [HttpPost] [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> CreatePrintJob([FromBody] CreatePrintJobRequest request) { _logger.LogInformation("Creating print job: {PrintName} on printer {PrinterId} with spool {SpoolId}", request.PrintName, request.PrinterId, request.SpoolId); // Validate foreign keys exist var printer = await _dbContext.Printers.FindAsync(request.PrinterId); if (printer is null) return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." }); var spool = await _dbContext.Spools .Include(s => s.MaterialBase) .FirstOrDefaultAsync(s => s.Id == request.SpoolId); if (spool is null) return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." }); // Resolve gram derivation parameters decimal filamentDiameter = request.FilamentDiameterAtPrintMm; decimal materialDensity = request.MaterialDensityAtPrint; decimal gramsDerived = request.GramsDerived; if (request.AutoDeriveGrams) { filamentDiameter = spool.FilamentDiameterMm; materialDensity = spool.MaterialBase.DensityGperCm3; gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity); } else if (request.MmExtruded > 0 && gramsDerived == 0 && materialDensity > 0) { // Auto-derive if mm_extruded is provided but gramsDerived is zero // and we have enough info to compute it gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity); } // Parse data source enum if (!Enum.TryParse(request.DataSource, ignoreCase: true, out var dataSource)) return BadRequest(new { error = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'." }); var entity = new PrintJob { PrinterId = request.PrinterId, SpoolId = request.SpoolId, PrintName = request.PrintName, GcodeFilePath = request.GcodeFilePath, MmExtruded = request.MmExtruded, GramsDerived = gramsDerived, CostPerPrint = request.CostPerPrint, StartedAt = request.StartedAt, Status = JobStatus.Queued, DataSource = dataSource, FilamentDiameterAtPrintMm = filamentDiameter, MaterialDensityAtPrint = materialDensity, Notes = request.Notes }; _dbContext.PrintJobs.Add(entity); await _dbContext.SaveChangesAsync(); // Reload navigation properties for the response await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync(); await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync(); var response = MapToResponse(entity); return CreatedAtAction(nameof(GetPrintJob), new { id = entity.Id }, response); } // ── PUT /api/printjobs/{id} ──────────────────────────────────── /// /// Updates an existing print job. Validates that PrinterId and SpoolId /// reference existing entities. When AutoDeriveGrams is true, gram /// derivation is recomputed from the spool's current material data. /// /// The unique identifier of the print job to update. /// The print job update request with all required fields. /// The updated print job with current timestamps. /// Returns the updated print job. /// If the print job with the given ID is not found. /// If the request is invalid or foreign keys don't exist. [HttpPut("{id:guid}")] [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> UpdatePrintJob( Guid id, [FromBody] UpdatePrintJobRequest request) { _logger.LogInformation("Updating print job {Id}", id); var entity = await _dbContext.PrintJobs.FindAsync(id); if (entity is null) { _logger.LogWarning("Print job {Id} not found for update", id); return NotFound(new { error = $"Print job with ID '{id}' not found." }); } // Validate foreign keys exist var printer = await _dbContext.Printers.FindAsync(request.PrinterId); if (printer is null) return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." }); var spool = await _dbContext.Spools .Include(s => s.MaterialBase) .FirstOrDefaultAsync(s => s.Id == request.SpoolId); if (spool is null) return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." }); // Resolve gram derivation decimal gramsDerived = request.GramsDerived; decimal filamentDiameter = entity.FilamentDiameterAtPrintMm; decimal materialDensity = entity.MaterialDensityAtPrint; if (request.AutoDeriveGrams) { filamentDiameter = spool.FilamentDiameterMm; materialDensity = spool.MaterialBase.DensityGperCm3; gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity); } // Update entity fields entity.PrinterId = request.PrinterId; entity.SpoolId = request.SpoolId; entity.PrintName = request.PrintName; entity.GcodeFilePath = request.GcodeFilePath; entity.MmExtruded = request.MmExtruded; entity.GramsDerived = gramsDerived; entity.CostPerPrint = request.CostPerPrint; entity.FilamentDiameterAtPrintMm = filamentDiameter; entity.MaterialDensityAtPrint = materialDensity; entity.Notes = request.Notes; // If auto-deriving and the spool changed, also recompute COGS if (request.AutoDeriveGrams && spool.PurchasePrice.HasValue && gramsDerived > 0) { var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; entity.CostPerPrint = Math.Round(gramsDerived * pricePerGram, 4); } await _dbContext.SaveChangesAsync(); // Reload navigation properties await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync(); await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync(); return Ok(MapToResponse(entity)); } // ── PATCH /api/printjobs/{id}/status ────────────────────────── /// /// Updates the status of a print job with business rule validation. /// Allowed transitions: /// - Queued → Printing (job starts) /// - Printing → Completed (job finishes successfully) /// - Any → Cancelled (user cancels) /// - Any → Failed (job fails) /// - Queued → Queued, Printing → Printing (idempotent) /// /// Forbidden transitions: /// - Completed → Queued, Printing (cannot reopen a completed job) /// - Cancelled → Queued, Printing, Completed (cannot reactivate a cancelled job) /// - Failed → Queued, Printing, Completed (cannot reactivate a failed job) /// /// The unique identifier of the print job. /// The status update request containing the new status. /// The updated print job with the new status. /// Returns the print job with updated status. /// If the print job with the given ID is not found. /// If the status transition is invalid. [HttpPatch("{id:guid}/status")] [ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> UpdatePrintJobStatus( Guid id, [FromBody] UpdatePrintJobStatusRequest request) { _logger.LogInformation("Updating print job {Id} status to {NewStatus}", id, request.Status); var entity = await _dbContext.PrintJobs.FindAsync(id); if (entity is null) { _logger.LogWarning("Print job {Id} not found for status update", id); return NotFound(new { error = $"Print job with ID '{id}' not found." }); } // Parse the target status if (!Enum.TryParse(request.Status, ignoreCase: true, out var newStatus)) { return BadRequest(new { error = "Status must be one of: Queued, Printing, Completed, Cancelled, Failed." }); } // Validate the transition var transitionError = ValidateStatusTransition(entity.Status, newStatus); if (transitionError is not null) { _logger.LogWarning( "Invalid status transition for print job {Id}: {CurrentStatus} → {NewStatus}. Reason: {Reason}", id, entity.Status, newStatus, transitionError); return BadRequest(new { error = transitionError }); } // Apply the transition var oldStatus = entity.Status; entity.Status = newStatus; // Set timestamps based on transition if (newStatus == JobStatus.Printing && entity.StartedAt is null) entity.StartedAt = DateTime.UtcNow; if (newStatus is JobStatus.Completed or JobStatus.Failed or JobStatus.Cancelled) entity.CompletedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); _logger.LogInformation( "Print job {Id} status updated: {OldStatus} → {NewStatus}", id, oldStatus, newStatus); // Reload navigation properties for the response await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync(); await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync(); return Ok(MapToResponse(entity)); } // ── DELETE /api/printjobs/{id} ───────────────────────────────── /// /// Soft-deletes a print job by setting its status to Cancelled. /// This preserves the job record for historical COGS reporting and audit trails. /// If the job is already in a terminal state (Cancelled, Failed, Completed), /// the operation is idempotent and returns 204. /// /// The unique identifier of the print job to soft-delete. /// No content on success. /// The print job was successfully soft-deleted. /// If the print job with the given ID is not found. [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeletePrintJob(Guid id) { _logger.LogInformation("Soft-deleting print job {Id}", id); var entity = await _dbContext.PrintJobs.FindAsync(id); if (entity is null) { _logger.LogWarning("Print job {Id} not found for soft-delete", id); return NotFound(new { error = $"Print job with ID '{id}' not found." }); } // If already in a terminal/cancelled state, idempotent no-op if (entity.Status == JobStatus.Cancelled) { _logger.LogDebug("Print job {Id} is already cancelled — idempotent no-op", id); return NoContent(); } // Only cancel if it makes sense — don't override Completed status if (entity.Status == JobStatus.Completed) { _logger.LogWarning( "Print job {Id} is Completed — cannot soft-delete. Use explicit status change instead.", id); return BadRequest(new { error = "Cannot soft-delete a completed print job. Use PATCH /status to change status explicitly." }); } entity.Status = JobStatus.Cancelled; entity.CompletedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); _logger.LogInformation("Print job {Id} soft-deleted (Status set to Cancelled)", id); return NoContent(); } // ── GET /api/printjobs/{id}/cost-summary ────────────────────────── /// /// Gets the material cost summary for a specific print job. /// Calculates total material cost from filament usage (grams derived) /// and the spool's purchase price. Returns warnings instead of errors /// when cost data is unavailable. /// /// The unique identifier of the print job. /// A cost summary with breakdown and any warnings about missing data. /// Returns the cost summary. Warnings field lists any missing data. /// If the print job with the given ID is not found. [HttpGet("{id:guid}/cost-summary")] [ProducesResponseType(typeof(CostSummaryResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetCostSummary(Guid id) { _logger.LogDebug("Getting cost summary for print job {Id}", id); var job = await _dbContext.PrintJobs .Include(j => j.Spool) .ThenInclude(s => s!.MaterialBase) .FirstOrDefaultAsync(j => j.Id == id); if (job is null) { _logger.LogWarning("Print job {Id} not found for cost summary", id); return NotFound(new { error = $"Print job with ID '{id}' not found." }); } var warnings = new List(); var spool = job.Spool; // Build response with what we have var response = new CostSummaryResponse { PrintJobId = job.Id, PrintName = job.PrintName, SpoolId = job.SpoolId, SpoolSerial = spool?.SpoolSerial ?? string.Empty, SpoolBrand = spool?.Brand ?? string.Empty, SpoolColorName = spool?.ColorName ?? string.Empty, MmExtruded = job.MmExtruded, GramsDerived = job.GramsDerived, SpoolPurchasePrice = spool?.PurchasePrice, SpoolWeightTotalGrams = spool?.WeightTotalGrams, StoredCostPerPrint = job.CostPerPrint }; // Validate spool data availability if (spool is null) { warnings.Add("Spool data is not available for this print job. Cost cannot be calculated."); response.Warnings = warnings; return Ok(response); } // Check if we can calculate cost if (!spool.PurchasePrice.HasValue) { warnings.Add("Spool purchase price is not set. Cost per gram and total material cost cannot be calculated."); } if (spool.WeightTotalGrams <= 0) { warnings.Add("Spool total weight is zero or invalid. Cost per gram and total material cost cannot be calculated."); } // If we have enough data, calculate the cost if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0) { var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; response.PricePerGram = Math.Round(pricePerGram, 4); response.TotalMaterialCost = Math.Round(job.GramsDerived * pricePerGram, 4); } // Warn if grams derived is zero but mm extruded is non-zero if (job.GramsDerived == 0 && job.MmExtruded > 0) { warnings.Add("GramsDerived is zero despite MmExtruded being non-zero. Cost may be inaccurate. Consider re-deriving grams from filament parameters."); } response.Warnings = warnings; return Ok(response); } // ── Gram Derivation Formula ──────────────────────────────────── /// /// Derives grams consumed from millimeters extruded, filament diameter, and material density. /// Formula: grams = mm_extruded × cross_section_area_mm² × (density_g_per_cm³ / 1000) /// where cross_section_area = π × (diameter_mm / 2)² /// The density division by 1000 converts g/cm³ to g/mm³. /// /// Total millimeters of filament extruded. /// Filament diameter in mm (typically 1.75mm). /// Material density in g/cm³ (e.g., 1.24 for PLA). /// Derived grams consumed, rounded to 2 decimal places. private static decimal DeriveGrams(decimal mmExtruded, decimal filamentDiameterMm, decimal densityGPerCm3) { // Cross-section area in mm²: π × r² where r = diameter/2 var radiusMm = filamentDiameterMm / 2m; var crossSectionAreaMm2 = Math.Round(Math.PI * (double)(radiusMm * radiusMm), 8); // Convert density from g/cm³ to g/mm³ by dividing by 1000 var densityGMm3 = (double)densityGPerCm3 / 1000.0; // grams = mm × area × density var grams = (double)mmExtruded * crossSectionAreaMm2 * densityGMm3; return Math.Round((decimal)grams, 2); } // ── Status Transition Validation ─────────────────────────────── /// /// Validates that a status transition is allowed by business rules. /// Returns null if the transition is valid, or an error message if not. /// /// The current status of the print job. /// The desired new status. /// null if valid; an error message string if invalid. private static string? ValidateStatusTransition(JobStatus currentStatus, JobStatus newStatus) { // Idempotent transitions are always allowed if (currentStatus == newStatus) return null; // Any state can transition to Cancelled or Failed if (newStatus is JobStatus.Cancelled or JobStatus.Failed) return null; // Queued can transition to Printing if (currentStatus == JobStatus.Queued && newStatus == JobStatus.Printing) return null; // Printing can transition to Completed if (currentStatus == JobStatus.Printing && newStatus == JobStatus.Completed) return null; // All other transitions are forbidden return currentStatus switch { JobStatus.Completed => $"Cannot change status from '{currentStatus}' to '{newStatus}'. Completed jobs cannot be reactivated.", JobStatus.Cancelled => $"Cannot change status from '{currentStatus}' to '{newStatus}'. Cancelled jobs cannot be reactivated.", JobStatus.Failed => $"Cannot change status from '{currentStatus}' to '{newStatus}'. Failed jobs cannot be reactivated.", JobStatus.Printing => $"Cannot change status from '{currentStatus}' to '{newStatus}'. A printing job can only transition to Completed, Cancelled, or Failed.", JobStatus.Queued => $"Cannot change status from '{currentStatus}' to '{newStatus}'. A queued job can only transition to Printing, Cancelled, or Failed.", _ => $"Invalid status transition from '{currentStatus}' to '{newStatus}'." }; } // ── Mapping Helper ───────────────────────────────────────────── private static PrintJobResponse MapToResponse(PrintJob j) => new() { Id = j.Id, PrinterId = j.PrinterId, PrinterName = j.Printer?.Name ?? string.Empty, SpoolId = j.SpoolId, SpoolSerial = j.Spool?.SpoolSerial ?? string.Empty, PrintName = j.PrintName, GcodeFilePath = j.GcodeFilePath, MmExtruded = j.MmExtruded, GramsDerived = j.GramsDerived, CostPerPrint = j.CostPerPrint, StartedAt = j.StartedAt, CompletedAt = j.CompletedAt, Status = j.Status.ToString(), DataSource = j.DataSource.ToString(), FilamentDiameterAtPrintMm = j.FilamentDiameterAtPrintMm, MaterialDensityAtPrint = j.MaterialDensityAtPrint, Notes = j.Notes, CreatedAt = j.CreatedAt, UpdatedAt = j.UpdatedAt }; }