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.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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 17:09:08 +00:00
|
|
|
|
// ── GET /api/printjobs/{id}/cost-summary ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// <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.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="id">The unique identifier of the print job.</param>
|
|
|
|
|
|
/// <returns>A cost summary with breakdown and any warnings about missing data.</returns>
|
|
|
|
|
|
/// <response code="200">Returns the cost summary. Warnings field lists any missing data.</response>
|
|
|
|
|
|
/// <response code="404">If the print job with the given ID is not found.</response>
|
|
|
|
|
|
[HttpGet("{id:guid}/cost-summary")]
|
|
|
|
|
|
[ProducesResponseType(typeof(CostSummaryResponse), StatusCodes.Status200OK)]
|
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
|
public async Task<ActionResult<CostSummaryResponse>> 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<string>();
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|