initial commit
This commit is contained in:
314
backend/API/Controllers/FilamentsController.cs
Normal file
314
backend/API/Controllers/FilamentsController.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using Extrudex.API.DTOs;
|
||||
using Extrudex.API.DTOs.Filaments;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing filament spools — the core inventory unit of Extrudex.
|
||||
/// Exposes spool data under the user-facing "filaments" route while mapping
|
||||
/// to the Spool domain entity internally.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/filaments")]
|
||||
public class FilamentsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<FilamentsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a paginated list of filament spools with optional filtering by material,
|
||||
/// brand, and active status. Results are ordered by creation date (newest first)
|
||||
/// and include denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="query">Optional query parameters for pagination and filtering.</param>
|
||||
/// <returns>A paginated list of filament spools matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the paginated list of filament spools.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<FilamentResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<FilamentResponse>>> GetFilaments(
|
||||
[FromQuery] FilamentQueryParameters query)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " +
|
||||
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " +
|
||||
"materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}",
|
||||
query.PageNumber, query.PageSize, query.MaterialBaseId,
|
||||
query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive);
|
||||
|
||||
// Clamp pagination values
|
||||
var pageNumber = Math.Max(1, query.PageNumber);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var spoolQuery = _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (query.MaterialBaseId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value);
|
||||
|
||||
if (query.MaterialFinishId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value);
|
||||
|
||||
if (query.MaterialModifierId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialModifierId == query.MaterialModifierId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Brand))
|
||||
spoolQuery = spoolQuery.Where(s =>
|
||||
s.Brand.ToLower().Contains(query.Brand.ToLower()));
|
||||
|
||||
if (query.IsActive.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
|
||||
|
||||
var totalCount = await spoolQuery.CountAsync();
|
||||
|
||||
var items = await spoolQuery
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => MapToFilamentResponse(s))
|
||||
.ToListAsync();
|
||||
|
||||
var response = new PagedResponse<FilamentResponse>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific filament spool by its unique identifier.
|
||||
/// Includes denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the filament spool.</param>
|
||||
/// <returns>The filament spool details.</returns>
|
||||
/// <response code="200">Returns the filament spool details.</response>
|
||||
/// <response code="404">If the filament spool with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<FilamentResponse>> GetFilament(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting filament {Id}", id);
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (spool is null)
|
||||
{
|
||||
_logger.LogWarning("Filament {Id} not found", id);
|
||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToFilamentResponse(spool));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new filament spool. Validates that all foreign keys reference
|
||||
/// existing material entities and that the SpoolSerial is unique.
|
||||
/// WeightRemainingGrams must not exceed WeightTotalGrams.
|
||||
/// </summary>
|
||||
/// <param name="request">The filament creation request with all required fields.</param>
|
||||
/// <returns>The newly created filament spool with generated ID and timestamps.</returns>
|
||||
/// <response code="201">Returns the created filament spool with location header.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FilamentResponse>> CreateFilament(
|
||||
[FromBody] CreateFilamentRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating filament: {Serial} - {Brand} {Color}",
|
||||
request.SpoolSerial, request.Brand, request.ColorName);
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Validate serial uniqueness
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
var entity = new Spool
|
||||
{
|
||||
MaterialBaseId = request.MaterialBaseId,
|
||||
MaterialFinishId = request.MaterialFinishId,
|
||||
MaterialModifierId = request.MaterialModifierId,
|
||||
Brand = request.Brand,
|
||||
ColorName = request.ColorName,
|
||||
ColorHex = request.ColorHex,
|
||||
WeightTotalGrams = request.WeightTotalGrams,
|
||||
WeightRemainingGrams = request.WeightRemainingGrams,
|
||||
FilamentDiameterMm = request.FilamentDiameterMm,
|
||||
SpoolSerial = request.SpoolSerial,
|
||||
PurchasePrice = request.PurchasePrice,
|
||||
PurchaseDate = request.PurchaseDate,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_dbContext.Spools.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
var response = MapToFilamentResponse(entity);
|
||||
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing filament spool. Validates foreign keys and serial uniqueness
|
||||
/// (excluding the current spool from the uniqueness check).
|
||||
/// WeightRemainingGrams must not exceed WeightTotalGrams.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the filament spool to update.</param>
|
||||
/// <param name="request">The filament update request with all required fields.</param>
|
||||
/// <returns>The updated filament spool with current timestamps.</returns>
|
||||
/// <response code="200">Returns the updated filament spool.</response>
|
||||
/// <response code="404">If the filament spool 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(FilamentResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FilamentResponse>> UpdateFilament(
|
||||
Guid id, [FromBody] UpdateFilamentRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating filament {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Filament {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Check serial uniqueness (excluding current spool)
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
entity.MaterialFinishId = request.MaterialFinishId;
|
||||
entity.MaterialModifierId = request.MaterialModifierId;
|
||||
entity.Brand = request.Brand;
|
||||
entity.ColorName = request.ColorName;
|
||||
entity.ColorHex = request.ColorHex;
|
||||
entity.WeightTotalGrams = request.WeightTotalGrams;
|
||||
entity.WeightRemainingGrams = request.WeightRemainingGrams;
|
||||
entity.FilamentDiameterMm = request.FilamentDiameterMm;
|
||||
entity.SpoolSerial = request.SpoolSerial;
|
||||
entity.PurchasePrice = request.PurchasePrice;
|
||||
entity.PurchaseDate = request.PurchaseDate;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
return Ok(MapToFilamentResponse(entity));
|
||||
}
|
||||
|
||||
// ── Mapping helper ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Maps a Spool domain entity to a FilamentResponse DTO.
|
||||
/// Denormalizes material names for display convenience.
|
||||
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
|
||||
/// </summary>
|
||||
/// <param name="s">The spool entity to map.</param>
|
||||
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
|
||||
private static FilamentResponse MapToFilamentResponse(Spool s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
MaterialBaseId = s.MaterialBaseId,
|
||||
MaterialBaseName = s.MaterialBase?.Name ?? string.Empty,
|
||||
MaterialFinishId = s.MaterialFinishId,
|
||||
MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty,
|
||||
MaterialModifierId = s.MaterialModifierId,
|
||||
MaterialModifierName = s.MaterialModifier?.Name,
|
||||
Brand = s.Brand,
|
||||
ColorName = s.ColorName,
|
||||
ColorHex = s.ColorHex,
|
||||
WeightTotalGrams = s.WeightTotalGrams,
|
||||
WeightRemainingGrams = s.WeightRemainingGrams,
|
||||
FilamentDiameterMm = s.FilamentDiameterMm,
|
||||
SpoolSerial = s.SpoolSerial,
|
||||
PurchasePrice = s.PurchasePrice,
|
||||
PurchaseDate = s.PurchaseDate,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt,
|
||||
QrCodeUrl = $"/api/qr/spool/{s.Id}"
|
||||
};
|
||||
}
|
||||
158
backend/API/Controllers/MaterialBasesController.cs
Normal file
158
backend/API/Controllers/MaterialBasesController.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material bases — the core polymer/material type
|
||||
/// (e.g., PLA, PETG, ABS). Enforces consistent material naming across all spools.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-bases")]
|
||||
public class MaterialBasesController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialBasesController> _logger;
|
||||
|
||||
public MaterialBasesController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialBasesController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material bases ordered by name.
|
||||
/// </summary>
|
||||
/// <returns>A list of all material bases with their densities.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
|
||||
{
|
||||
_logger.LogDebug("Getting all material bases");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => MapToResponse(mb))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material base by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base.</param>
|
||||
/// <returns>The material base details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material base {Id}", id);
|
||||
|
||||
var mb = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (mb is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mb));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material base.
|
||||
/// </summary>
|
||||
/// <param name="request">The material base creation request.</param>
|
||||
/// <returns>The created material base.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
|
||||
[FromBody] CreateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material base: {Name}", request.Name);
|
||||
|
||||
var entity = new MaterialBase
|
||||
{
|
||||
Name = request.Name,
|
||||
DensityGperCm3 = request.DensityGperCm3
|
||||
};
|
||||
|
||||
_dbContext.MaterialBases.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to update.</param>
|
||||
/// <param name="request">The material base update request.</param>
|
||||
/// <returns>The updated material base.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
|
||||
Guid id, [FromBody] UpdateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.DensityGperCm3 = request.DensityGperCm3;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialBases.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialBaseResponse MapToResponse(MaterialBase mb) => new()
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
};
|
||||
}
|
||||
191
backend/API/Controllers/MaterialFinishesController.cs
Normal file
191
backend/API/Controllers/MaterialFinishesController.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material finishes — the surface finish descriptor
|
||||
/// for a material. This is REQUIRED on every spool record. Default: "Basic".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-finishes")]
|
||||
public class MaterialFinishesController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialFinishesController> _logger;
|
||||
|
||||
public MaterialFinishesController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialFinishesController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material finishes, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
|
||||
/// <returns>A list of material finishes ordered by base material name, then finish name.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
|
||||
|
||||
var finishes = await query
|
||||
.OrderBy(mf => mf.MaterialBase.Name)
|
||||
.ThenBy(mf => mf.Name)
|
||||
.Select(mf => MapToResponse(mf))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(finishes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material finish by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish.</param>
|
||||
/// <returns>The material finish details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material finish {Id}", id);
|
||||
|
||||
var mf = await _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.FirstOrDefaultAsync(mf => mf.Id == id);
|
||||
|
||||
if (mf is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mf));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material finish.
|
||||
/// </summary>
|
||||
/// <param name="request">The material finish creation request.</param>
|
||||
/// <returns>The created material finish.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
|
||||
[FromBody] CreateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialFinish
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialFinishes.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to update.</param>
|
||||
/// <param name="request">The material finish update request.</param>
|
||||
/// <returns>The updated material finish.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
|
||||
Guid id, [FromBody] UpdateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialFinishes.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialFinishResponse MapToResponse(MaterialFinish mf) => new()
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = mf.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
};
|
||||
}
|
||||
533
backend/API/Controllers/MaterialLookupsController.cs
Normal file
533
backend/API/Controllers/MaterialLookupsController.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.API.Validators;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for querying material metadata — bases, finishes, and modifiers.
|
||||
/// Provides lookup/reference data for the spool inventory system.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/materials")]
|
||||
public class MaterialLookupsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialLookupsController> _logger;
|
||||
|
||||
public MaterialLookupsController(ExtrudexDbContext dbContext, ILogger<MaterialLookupsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── MaterialBase ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material bases (PLA, PETG, ABS, etc.).
|
||||
/// </summary>
|
||||
/// <returns>A list of all material bases with their densities.</returns>
|
||||
[HttpGet("bases")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
|
||||
{
|
||||
_logger.LogDebug("Getting all material bases");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => new MaterialBaseResponse
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material base by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base.</param>
|
||||
/// <returns>The material base details.</returns>
|
||||
[HttpGet("bases/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material base {Id}", id);
|
||||
|
||||
var mb = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (mb is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialBaseResponse(mb));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material base.
|
||||
/// </summary>
|
||||
/// <param name="request">The material base creation request.</param>
|
||||
/// <returns>The created material base.</returns>
|
||||
[HttpPost("bases")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
|
||||
[FromBody] CreateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material base: {Name}", request.Name);
|
||||
|
||||
var entity = new MaterialBase
|
||||
{
|
||||
Name = request.Name,
|
||||
DensityGperCm3 = request.DensityGperCm3
|
||||
};
|
||||
|
||||
_dbContext.MaterialBases.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialBaseResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to update.</param>
|
||||
/// <param name="request">The material base update request.</param>
|
||||
/// <returns>The updated material base.</returns>
|
||||
[HttpPut("bases/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
|
||||
Guid id, [FromBody] UpdateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.DensityGperCm3 = request.DensityGperCm3;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialBaseResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to delete.</param>
|
||||
[HttpDelete("bases/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialBases.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── MaterialFinish ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material finishes, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
|
||||
/// <returns>A list of material finishes.</returns>
|
||||
[HttpGet("finishes")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
{
|
||||
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
|
||||
}
|
||||
|
||||
var finishes = await query
|
||||
.OrderBy(mf => mf.MaterialBase.Name)
|
||||
.ThenBy(mf => mf.Name)
|
||||
.Select(mf => new MaterialFinishResponse
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = mf.MaterialBase.Name,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(finishes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material finish by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish.</param>
|
||||
/// <returns>The material finish details.</returns>
|
||||
[HttpGet("finishes/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material finish {Id}", id);
|
||||
|
||||
var mf = await _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.FirstOrDefaultAsync(mf => mf.Id == id);
|
||||
|
||||
if (mf is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialFinishResponse(mf));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material finish.
|
||||
/// </summary>
|
||||
/// <param name="request">The material finish creation request.</param>
|
||||
/// <returns>The created material finish.</returns>
|
||||
[HttpPost("finishes")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
|
||||
[FromBody] CreateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialFinish
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialFinishes.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialFinishResponse(entity, materialBase.Name);
|
||||
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to update.</param>
|
||||
/// <param name="request">The material finish update request.</param>
|
||||
/// <returns>The updated material finish.</returns>
|
||||
[HttpPut("finishes/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
|
||||
Guid id, [FromBody] UpdateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialFinishResponse(entity, materialBase.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to delete.</param>
|
||||
[HttpDelete("finishes/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialFinishes.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── MaterialModifier ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material modifiers, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
|
||||
/// <returns>A list of material modifiers.</returns>
|
||||
[HttpGet("modifiers")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
{
|
||||
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
|
||||
}
|
||||
|
||||
var modifiers = await query
|
||||
.OrderBy(mm => mm.MaterialBase.Name)
|
||||
.ThenBy(mm => mm.Name)
|
||||
.Select(mm => new MaterialModifierResponse
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = mm.MaterialBase.Name,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(modifiers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material modifier by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier.</param>
|
||||
/// <returns>The material modifier details.</returns>
|
||||
[HttpGet("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifier {Id}", id);
|
||||
|
||||
var mm = await _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.FirstOrDefaultAsync(mm => mm.Id == id);
|
||||
|
||||
if (mm is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialModifierResponse(mm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material modifier.
|
||||
/// </summary>
|
||||
/// <param name="request">The material modifier creation request.</param>
|
||||
/// <returns>The created material modifier.</returns>
|
||||
[HttpPost("modifiers")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
|
||||
[FromBody] CreateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialModifier
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialModifiers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialModifierResponse(entity, materialBase.Name);
|
||||
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to update.</param>
|
||||
/// <param name="request">The material modifier update request.</param>
|
||||
/// <returns>The updated material modifier.</returns>
|
||||
[HttpPut("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
|
||||
Guid id, [FromBody] UpdateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialModifierResponse(entity, materialBase.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to delete.</param>
|
||||
[HttpDelete("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialModifiers.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Density convenience endpoint ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material density data (shorthand for bases with density info).
|
||||
/// </summary>
|
||||
/// <returns>A list of material bases with their density values.</returns>
|
||||
[HttpGet("densities")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialDensities()
|
||||
{
|
||||
_logger.LogDebug("Getting material densities");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => new MaterialBaseResponse
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
// ── Mapping helpers ───────────────────────────────────────
|
||||
|
||||
private static MaterialBaseResponse MapToMaterialBaseResponse(MaterialBase mb) => new()
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
};
|
||||
|
||||
private static MaterialFinishResponse MapToMaterialFinishResponse(
|
||||
MaterialFinish mf, string? materialBaseName = null) => new()
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = materialBaseName ?? mf.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
};
|
||||
|
||||
private static MaterialModifierResponse MapToMaterialModifierResponse(
|
||||
MaterialModifier mm, string? materialBaseName = null) => new()
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = materialBaseName ?? mm.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
};
|
||||
}
|
||||
192
backend/API/Controllers/MaterialModifiersController.cs
Normal file
192
backend/API/Controllers/MaterialModifiersController.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material modifiers — optional additives or
|
||||
/// fillers for a material (e.g., "Carbon Fiber", "Wood Fill", "Glow-in-the-Dark").
|
||||
/// Not every spool has a modifier.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-modifiers")]
|
||||
public class MaterialModifiersController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialModifiersController> _logger;
|
||||
|
||||
public MaterialModifiersController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialModifiersController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material modifiers, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
|
||||
/// <returns>A list of material modifiers ordered by base material name, then modifier name.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
|
||||
|
||||
var modifiers = await query
|
||||
.OrderBy(mm => mm.MaterialBase.Name)
|
||||
.ThenBy(mm => mm.Name)
|
||||
.Select(mm => MapToResponse(mm))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(modifiers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material modifier by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier.</param>
|
||||
/// <returns>The material modifier details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifier {Id}", id);
|
||||
|
||||
var mm = await _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.FirstOrDefaultAsync(mm => mm.Id == id);
|
||||
|
||||
if (mm is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material modifier.
|
||||
/// </summary>
|
||||
/// <param name="request">The material modifier creation request.</param>
|
||||
/// <returns>The created material modifier.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
|
||||
[FromBody] CreateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialModifier
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialModifiers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to update.</param>
|
||||
/// <param name="request">The material modifier update request.</param>
|
||||
/// <returns>The updated material modifier.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
|
||||
Guid id, [FromBody] UpdateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialModifiers.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialModifierResponse MapToResponse(MaterialModifier mm) => new()
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = mm.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
};
|
||||
}
|
||||
512
backend/API/Controllers/PrintJobsController.cs
Normal file
512
backend/API/Controllers/PrintJobsController.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
297
backend/API/Controllers/PrintersController.cs
Normal file
297
backend/API/Controllers/PrintersController.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using Extrudex.API.DTOs.Printers;
|
||||
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 3D printers in the fleet.
|
||||
/// Provides CRUD operations, a lightweight status endpoint, and soft-delete.
|
||||
/// Real-time printer status is also available via the SignalR PrinterHub.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/printers")]
|
||||
public class PrintersController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<PrintersController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PrintersController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public PrintersController(ExtrudexDbContext dbContext, ILogger<PrintersController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all printers, optionally filtered by active status.
|
||||
/// Results are ordered by printer name alphabetically.
|
||||
/// </summary>
|
||||
/// <param name="isActive">
|
||||
/// Optional filter: <c>true</c> for active printers only,
|
||||
/// <c>false</c> for inactive (soft-deleted) printers,
|
||||
/// omit for all printers.
|
||||
/// </param>
|
||||
/// <returns>A list of printers matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the list of printers.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<PrinterResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<PrinterResponse>>> GetPrinters(
|
||||
[FromQuery] bool? isActive = null)
|
||||
{
|
||||
_logger.LogDebug("Getting printers, isActive filter: {IsActive}", isActive);
|
||||
|
||||
var query = _dbContext.Printers.AsQueryable();
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(p => p.IsActive == isActive.Value);
|
||||
|
||||
var printers = await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => MapToPrinterResponse(p))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(printers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific printer by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer.</param>
|
||||
/// <returns>The full printer details including connection configuration.</returns>
|
||||
/// <response code="200">Returns the printer details.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PrinterResponse>> GetPrinter(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting printer {Id}", id);
|
||||
|
||||
var printer = await _dbContext.Printers.FindAsync(id);
|
||||
if (printer is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToPrinterResponse(printer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the real-time connection/health status of a specific printer.
|
||||
/// This is a lightweight endpoint suitable for polling or dashboard displays.
|
||||
/// For push-based real-time updates, subscribe to the SignalR PrinterHub instead.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer.</param>
|
||||
/// <returns>The printer's current status, last-seen timestamp, and active flag.</returns>
|
||||
/// <response code="200">Returns the printer status.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpGet("{id:guid}/status")]
|
||||
[ProducesResponseType(typeof(PrinterStatusResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PrinterStatusResponse>> GetPrinterStatus(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting status for printer {Id}", id);
|
||||
|
||||
var printer = await _dbContext.Printers.FindAsync(id);
|
||||
if (printer is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for status check", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(new PrinterStatusResponse
|
||||
{
|
||||
Id = printer.Id,
|
||||
Name = printer.Name,
|
||||
Status = printer.Status.ToString(),
|
||||
LastSeenAt = printer.LastSeenAt,
|
||||
IsActive = printer.IsActive
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new printer in the fleet.
|
||||
/// The printer is created with an initial status of Offline.
|
||||
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
|
||||
/// </summary>
|
||||
/// <param name="request">The printer registration request with connection details.</param>
|
||||
/// <returns>The newly created printer.</returns>
|
||||
/// <response code="201">The printer was created successfully.</response>
|
||||
/// <response code="400">The request body is invalid or validation failed.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrinterResponse>> CreatePrinter(
|
||||
[FromBody] CreatePrinterRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
_logger.LogInformation("Creating printer: {Name} ({Manufacturer} {Model})",
|
||||
request.Name, request.Manufacturer, request.Model);
|
||||
|
||||
// Parse enums from string values
|
||||
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
|
||||
}
|
||||
|
||||
// Default port based on connection type
|
||||
var port = request.Port > 0
|
||||
? request.Port
|
||||
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
|
||||
|
||||
var entity = new Printer
|
||||
{
|
||||
Name = request.Name,
|
||||
Manufacturer = request.Manufacturer,
|
||||
Model = request.Model,
|
||||
PrinterType = printerType,
|
||||
ConnectionType = connectionType,
|
||||
HostnameOrIp = request.HostnameOrIp,
|
||||
Port = port,
|
||||
MqttUsername = request.MqttUsername,
|
||||
MqttPassword = request.MqttPassword,
|
||||
MqttUseTls = request.MqttUseTls,
|
||||
ApiKey = request.ApiKey,
|
||||
IsActive = request.IsActive,
|
||||
Status = PrinterStatus.Offline
|
||||
};
|
||||
|
||||
_dbContext.Printers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToPrinterResponse(entity);
|
||||
return CreatedAtAction(nameof(GetPrinter), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing printer's configuration and connection info.
|
||||
/// Uses full replacement semantics — all fields are written.
|
||||
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer to update.</param>
|
||||
/// <param name="request">The printer update request with new values.</param>
|
||||
/// <returns>The updated printer.</returns>
|
||||
/// <response code="200">The printer was updated successfully.</response>
|
||||
/// <response code="400">The request body is invalid or validation failed.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrinterResponse>> UpdatePrinter(
|
||||
Guid id, [FromBody] UpdatePrinterRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
_logger.LogInformation("Updating printer {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Printers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Parse enums from string values
|
||||
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
|
||||
}
|
||||
|
||||
var port = request.Port > 0
|
||||
? request.Port
|
||||
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.Manufacturer = request.Manufacturer;
|
||||
entity.Model = request.Model;
|
||||
entity.PrinterType = printerType;
|
||||
entity.ConnectionType = connectionType;
|
||||
entity.HostnameOrIp = request.HostnameOrIp;
|
||||
entity.Port = port;
|
||||
entity.MqttUsername = request.MqttUsername;
|
||||
entity.MqttPassword = request.MqttPassword;
|
||||
entity.MqttUseTls = request.MqttUseTls;
|
||||
entity.ApiKey = request.ApiKey;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToPrinterResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a printer by setting <c>IsActive</c> to <c>false</c>.
|
||||
/// The printer record is retained for historical and audit purposes.
|
||||
/// Soft-deleted printers can be recovered via PUT with <c>IsActive = true</c>.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer to soft-delete.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
/// <response code="204">The printer was soft-deleted successfully.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeletePrinter(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Soft-deleting printer {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Printers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for soft-delete", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.IsActive = false;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="Printer"/> domain entity to a <see cref="PrinterResponse"/> DTO.
|
||||
/// </summary>
|
||||
/// <param name="p">The printer entity to map.</param>
|
||||
/// <returns>A response DTO suitable for API output.</returns>
|
||||
private static PrinterResponse MapToPrinterResponse(Printer p) => new()
|
||||
{
|
||||
Id = p.Id,
|
||||
Status = p.Status.ToString(),
|
||||
Name = p.Name,
|
||||
Manufacturer = p.Manufacturer,
|
||||
Model = p.Model,
|
||||
PrinterType = p.PrinterType.ToString(),
|
||||
ConnectionType = p.ConnectionType.ToString(),
|
||||
HostnameOrIp = p.HostnameOrIp,
|
||||
Port = p.Port,
|
||||
IsActive = p.IsActive,
|
||||
LastSeenAt = p.LastSeenAt,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt
|
||||
};
|
||||
}
|
||||
101
backend/API/Controllers/QrController.cs
Normal file
101
backend/API/Controllers/QrController.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for generating QR codes for Extrudex resources (spools, printers, locations).
|
||||
/// Returns QR codes as PNG or SVG images optimized for small label printing.
|
||||
/// QR codes encode deep links that resolve to the resource's detail page.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/qr")]
|
||||
public class QrController : ControllerBase
|
||||
{
|
||||
private readonly IQrCodeService _qrCodeService;
|
||||
private readonly ILogger<QrController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QrController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="qrCodeService">The QR code generation service.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public QrController(IQrCodeService qrCodeService, ILogger<QrController> logger)
|
||||
{
|
||||
_qrCodeService = qrCodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a QR code for the specified resource type and ID.
|
||||
/// Returns the QR code as a PNG image by default, or as SVG when
|
||||
/// the <c>format</c> query parameter is set to "svg".
|
||||
/// </summary>
|
||||
/// <param name="resourceType">
|
||||
/// The type of resource: <c>spool</c>, <c>printer</c>, or <c>location</c>.
|
||||
/// </param>
|
||||
/// <param name="id">The unique identifier of the resource.</param>
|
||||
/// <param name="format">
|
||||
/// Optional output format: <c>png</c> (default) or <c>svg</c>.
|
||||
/// SVG is resolution-independent and ideal for printing at any scale.
|
||||
/// </param>
|
||||
/// <param name="size">
|
||||
/// Optional pixel density per QR module for PNG output (default: 20).
|
||||
/// Ignored for SVG output. Range: 4–40.
|
||||
/// </param>
|
||||
/// <returns>The QR code image in the requested format.</returns>
|
||||
/// <response code="200">Returns the QR code image.</response>
|
||||
/// <response code="400">If the resource type is invalid or parameters are out of range.</response>
|
||||
[HttpGet("{resourceType}/{id:guid}")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/png")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/svg+xml")]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult GetQrCode(
|
||||
string resourceType,
|
||||
Guid id,
|
||||
[FromQuery] string format = "png",
|
||||
[FromQuery] int size = 20)
|
||||
{
|
||||
_logger.LogDebug("Generating QR code: resourceType={ResourceType}, id={Id}, format={Format}, size={Size}",
|
||||
resourceType, id, format, size);
|
||||
|
||||
// Parse resource type
|
||||
if (!Enum.TryParse<QrResourceType>(resourceType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Invalid resource type: '{resourceType}'. " +
|
||||
$"Valid values are: spool, printer, location."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate format
|
||||
format = format.ToLowerInvariant();
|
||||
if (format is not ("png" or "svg"))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Invalid format: '{format}'. Valid values are: png, svg."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate size for PNG
|
||||
if (format == "png" && (size < 4 || size > 40))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Size must be between 4 and 40. Received: {size}."
|
||||
});
|
||||
}
|
||||
|
||||
if (format == "svg")
|
||||
{
|
||||
var svg = _qrCodeService.GenerateSvg(parsedType, id);
|
||||
return Content(svg, "image/svg+xml");
|
||||
}
|
||||
|
||||
var pngBytes = _qrCodeService.GeneratePng(parsedType, id, size);
|
||||
return File(pngBytes, "image/png");
|
||||
}
|
||||
}
|
||||
329
backend/API/Controllers/SpoolsController.cs
Normal file
329
backend/API/Controllers/SpoolsController.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using Extrudex.API.DTOs;
|
||||
using Extrudex.API.DTOs.Spools;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing filament spools — the core inventory unit of Extrudex.
|
||||
/// Supports full CRUD with pagination, filtering by material and active status,
|
||||
/// and soft-delete semantics (DELETE sets IsActive = false).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/spools")]
|
||||
public class SpoolsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<SpoolsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SpoolsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public SpoolsController(ExtrudexDbContext dbContext, ILogger<SpoolsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a paginated list of spools with optional filtering by material and active status.
|
||||
/// Results are ordered by creation date (newest first) and include denormalized material names.
|
||||
/// </summary>
|
||||
/// <param name="query">Optional query parameters for pagination and filtering.</param>
|
||||
/// <returns>A paginated list of spools matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the paginated list of spools.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SpoolResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SpoolResponse>>> GetSpools(
|
||||
[FromQuery] SpoolQueryParameters query)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Getting spools: pageNumber={PageNumber}, pageSize={PageSize}, " +
|
||||
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, isActive={IsActive}",
|
||||
query.PageNumber, query.PageSize, query.MaterialBaseId, query.MaterialFinishId, query.IsActive);
|
||||
|
||||
// Clamp pagination values
|
||||
var pageNumber = Math.Max(1, query.PageNumber);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var spoolQuery = _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (query.MaterialBaseId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value);
|
||||
|
||||
if (query.MaterialFinishId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value);
|
||||
|
||||
if (query.IsActive.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
|
||||
|
||||
var totalCount = await spoolQuery.CountAsync();
|
||||
|
||||
var items = await spoolQuery
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => MapToSpoolResponse(s))
|
||||
.ToListAsync();
|
||||
|
||||
var response = new PagedResponse<SpoolResponse>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific spool by its unique identifier.
|
||||
/// Includes denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool.</param>
|
||||
/// <returns>The spool details.</returns>
|
||||
/// <response code="200">Returns the spool details.</response>
|
||||
/// <response code="404">If the spool with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SpoolResponse>> GetSpool(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting spool {Id}", id);
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (spool is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToSpoolResponse(spool));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new spool record. Validates that all foreign keys reference existing entities
|
||||
/// and that the SpoolSerial is unique across all spools.
|
||||
/// </summary>
|
||||
/// <param name="request">The spool creation request with all required fields.</param>
|
||||
/// <returns>The newly created spool with generated ID and timestamps.</returns>
|
||||
/// <response code="201">Returns the created spool with location header.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<SpoolResponse>> CreateSpool([FromBody] CreateSpoolRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating spool: {Serial} - {Brand} {Color}",
|
||||
request.SpoolSerial, request.Brand, request.ColorName);
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Validate serial uniqueness
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
var entity = new Spool
|
||||
{
|
||||
MaterialBaseId = request.MaterialBaseId,
|
||||
MaterialFinishId = request.MaterialFinishId,
|
||||
MaterialModifierId = request.MaterialModifierId,
|
||||
Brand = request.Brand,
|
||||
ColorName = request.ColorName,
|
||||
ColorHex = request.ColorHex,
|
||||
WeightTotalGrams = request.WeightTotalGrams,
|
||||
WeightRemainingGrams = request.WeightRemainingGrams,
|
||||
FilamentDiameterMm = request.FilamentDiameterMm,
|
||||
SpoolSerial = request.SpoolSerial,
|
||||
PurchasePrice = request.PurchasePrice,
|
||||
PurchaseDate = request.PurchaseDate,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_dbContext.Spools.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
var response = MapToSpoolResponse(entity);
|
||||
return CreatedAtAction(nameof(GetSpool), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing spool. Validates foreign keys and serial uniqueness
|
||||
/// (excluding the current spool from the uniqueness check).
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool to update.</param>
|
||||
/// <param name="request">The spool update request with all required fields.</param>
|
||||
/// <returns>The updated spool with current timestamps.</returns>
|
||||
/// <response code="200">Returns the updated spool.</response>
|
||||
/// <response code="404">If the spool 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(SpoolResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<SpoolResponse>> UpdateSpool(
|
||||
Guid id, [FromBody] UpdateSpoolRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating spool {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Check serial uniqueness (excluding current spool)
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
entity.MaterialFinishId = request.MaterialFinishId;
|
||||
entity.MaterialModifierId = request.MaterialModifierId;
|
||||
entity.Brand = request.Brand;
|
||||
entity.ColorName = request.ColorName;
|
||||
entity.ColorHex = request.ColorHex;
|
||||
entity.WeightTotalGrams = request.WeightTotalGrams;
|
||||
entity.WeightRemainingGrams = request.WeightRemainingGrams;
|
||||
entity.FilamentDiameterMm = request.FilamentDiameterMm;
|
||||
entity.SpoolSerial = request.SpoolSerial;
|
||||
entity.PurchasePrice = request.PurchasePrice;
|
||||
entity.PurchaseDate = request.PurchaseDate;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
return Ok(MapToSpoolResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a spool by setting IsActive = false. The spool is retained
|
||||
/// in the database for historical COGS and print job records.
|
||||
/// This is NOT a hard delete — the data is preserved.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool to soft-delete.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
/// <response code="204">The spool was successfully soft-deleted.</response>
|
||||
/// <response code="404">If the spool with the given ID is not found.</response>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSpool(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Soft-deleting spool {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found for soft-delete", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
if (!entity.IsActive)
|
||||
{
|
||||
_logger.LogDebug("Spool {Id} is already inactive — idempotent no-op", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
entity.IsActive = false;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Spool {Id} soft-deleted (IsActive = false)", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ─────────────────────────────────────────
|
||||
|
||||
private static SpoolResponse MapToSpoolResponse(Spool s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
MaterialBaseId = s.MaterialBaseId,
|
||||
MaterialBaseName = s.MaterialBase?.Name ?? string.Empty,
|
||||
MaterialFinishId = s.MaterialFinishId,
|
||||
MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty,
|
||||
MaterialModifierId = s.MaterialModifierId,
|
||||
MaterialModifierName = s.MaterialModifier?.Name,
|
||||
Brand = s.Brand,
|
||||
ColorName = s.ColorName,
|
||||
ColorHex = s.ColorHex,
|
||||
WeightTotalGrams = s.WeightTotalGrams,
|
||||
WeightRemainingGrams = s.WeightRemainingGrams,
|
||||
FilamentDiameterMm = s.FilamentDiameterMm,
|
||||
SpoolSerial = s.SpoolSerial,
|
||||
PurchasePrice = s.PurchasePrice,
|
||||
PurchaseDate = s.PurchaseDate,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user