Files
Extrudex/backend/API/Controllers/PrintJobsController.cs

565 lines
25 KiB
C#
Raw Normal View History

2026-04-25 18:51:05 +00:00
using Extrudex.API.DTOs;
using Extrudex.API.DTOs.PrintJobs;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
2026-04-25 18:51:05 +00:00
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 ICostPerPrintService _costService;
2026-04-25 18:51:05 +00:00
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="costService">The cost-per-print calculation service.</param>
2026-04-25 18:51:05 +00:00
/// <param name="logger">The logger for diagnostic output.</param>
public PrintJobsController(
ExtrudexDbContext dbContext,
ICostPerPrintService costService,
ILogger<PrintJobsController> logger)
2026-04-25 18:51:05 +00:00
{
_dbContext = dbContext;
_costService = costService;
2026-04-25 18:51:05 +00:00
_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();
}
// ── POST /api/printjobs/{id}/cost ─────────────────────────────
/// <summary>
/// Calculates the cost of goods sold (COGS) for a specific print job.
/// Uses the spools purchase price and the print jobs derived grams consumed
/// to produce a cost breakdown. Returns warnings instead of errors when
/// cost data is missing or incomplete.
/// </summary>
/// <param name="id">The unique identifier of the print job.</param>
/// <returns>A cost breakdown with warnings if data is incomplete.</returns>
/// <response code="200">Returns the cost breakdown for the print job.</response>
/// <response code="404">If the print job with the given ID is not found.</response>
[HttpPost("{id:guid}/cost")]
[ProducesResponseType(typeof(CostPerPrintResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CostPerPrintResponse>> CalculateCost(Guid id)
{
_logger.LogDebug("Calculating cost for print job {Id}", id);
var result = await _costService.CalculateAsync(id);
// If the job was not found, return 404
if (result.Warnings.Any(w => w.Contains("not found")))
return NotFound(new { error = $"Print job with ID '{id}' not found." });
return Ok(MapCostToResponse(result));
}
2026-04-25 18:51:05 +00:00
// ── 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
};
/// <summary>
/// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO.
/// </summary>
private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new()
{
PrintJobId = r.PrintJobId,
PrintName = r.PrintName,
SpoolId = r.SpoolId,
SpoolSerial = r.SpoolSerial,
MmExtruded = r.MmExtruded,
GramsDerived = r.GramsDerived,
PurchasePrice = r.PurchasePrice,
WeightTotalGrams = r.WeightTotalGrams,
CostPerGram = r.CostPerGram,
CostPerPrint = r.CostPerPrint,
Warnings = r.Warnings
};
2026-04-25 18:51:05 +00:00
}