598 lines
27 KiB
C#
598 lines
27 KiB
C#
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();
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
|
||
// ── 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
|
||
};
|
||
} |