initial commit
This commit is contained in:
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