Files
Extrudex/backend/API/Controllers/PrintJobsController.cs
cubecraft-agents[bot] 230c3b295d initial commit
2026-04-25 18:51:05 +00:00

512 lines
23 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 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;
/// <summary>
/// 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).
/// </summary>
[ApiController]
[Route("api/printjobs")]
public class PrintJobsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<PrintJobsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PrintJobsController"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public PrintJobsController(ExtrudexDbContext dbContext, ILogger<PrintJobsController> logger)
{
_dbContext = dbContext;
_logger = logger;
}
// ── GET /api/printjobs ────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
/// <param name="query">Optional query parameters for pagination and filtering.</param>
/// <returns>A paginated list of print jobs matching the filter criteria.</returns>
/// <response code="200">Returns the paginated list of print jobs.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PrintJobResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PrintJobResponse>>> 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<JobStatus>(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<PrintJobResponse>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(response);
}
// ── GET /api/printjobs/{id} ───────────────────────────────────
/// <summary>
/// Gets a specific print job by its unique identifier.
/// Includes denormalized printer name and spool serial for display.
/// </summary>
/// <param name="id">The unique identifier of the print job.</param>
/// <returns>The print job details.</returns>
/// <response code="200">Returns the print job details.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PrintJobResponse>> 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 ───────────────────────────────────────
/// <summary>
/// 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).
/// </summary>
/// <param name="request">The print job creation request with all required fields.</param>
/// <returns>The newly created print job with generated ID and timestamps.</returns>
/// <response code="201">Returns the created print job with location header.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPost]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> 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<DataSource>(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} ────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
/// <param name="id">The unique identifier of the print job to update.</param>
/// <param name="request">The print job update request with all required fields.</param>
/// <returns>The updated print job with current timestamps.</returns>
/// <response code="200">Returns the updated print job.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> 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 ──────────────────────────
/// <summary>
/// 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)
/// </summary>
/// <param name="id">The unique identifier of the print job.</param>
/// <param name="request">The status update request containing the new status.</param>
/// <returns>The updated print job with the new status.</returns>
/// <response code="200">Returns the print job with updated status.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
/// <response code="400">If the status transition is invalid.</response>
[HttpPatch("{id:guid}/status")]
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PrintJobResponse>> 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<JobStatus>(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} ─────────────────────────────────
/// <summary>
/// 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.
/// </summary>
/// <param name="id">The unique identifier of the print job to soft-delete.</param>
/// <returns>No content on success.</returns>
/// <response code="204">The print job was successfully soft-deleted.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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();
}
// ── Gram Derivation Formula ────────────────────────────────────
/// <summary>
/// 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³.
/// </summary>
/// <param name="mmExtruded">Total millimeters of filament extruded.</param>
/// <param name="filamentDiameterMm">Filament diameter in mm (typically 1.75mm).</param>
/// <param name="densityGPerCm3">Material density in g/cm³ (e.g., 1.24 for PLA).</param>
/// <returns>Derived grams consumed, rounded to 2 decimal places.</returns>
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 ───────────────────────────────
/// <summary>
/// Validates that a status transition is allowed by business rules.
/// Returns null if the transition is valid, or an error message if not.
/// </summary>
/// <param name="currentStatus">The current status of the print job.</param>
/// <param name="newStatus">The desired new status.</param>
/// <returns>null if valid; an error message string if invalid.</returns>
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
};
}