All checks were successful
Dev Build / build-test (pull_request) Successful in 2m7s
392 lines
18 KiB
C#
392 lines
18 KiB
C#
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)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
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}, " +
|
|
"includeArchived={IncludeArchived}, storageLocation={StorageLocation}",
|
|
query.PageNumber, query.PageSize, query.MaterialBaseId,
|
|
query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive,
|
|
query.IncludeArchived, query.StorageLocation);
|
|
|
|
// 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);
|
|
|
|
// Exclude archived spools by default; include when explicitly requested
|
|
if (query.IncludeArchived != true)
|
|
spoolQuery = spoolQuery.Where(s => !s.IsArchived);
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.StorageLocation))
|
|
spoolQuery = spoolQuery.Where(s =>
|
|
s.StorageLocation != null &&
|
|
s.StorageLocation.ToLower().Contains(query.StorageLocation.ToLower()));
|
|
|
|
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,
|
|
IsArchived = request.IsArchived,
|
|
StorageLocation = request.StorageLocation
|
|
};
|
|
|
|
_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;
|
|
entity.IsArchived = request.IsArchived;
|
|
entity.StorageLocation = request.StorageLocation;
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a filament spool by its unique identifier.
|
|
/// If the spool has associated print jobs, the deletion is rejected with a 409 Conflict
|
|
/// to preserve COGS and print history — the caller should archive the spool instead.
|
|
/// Associated filament usage records are removed before the spool is deleted.
|
|
/// AMS slots referencing this spool will have their SpoolId set to null by the database.
|
|
/// </summary>
|
|
/// <param name="id">The unique identifier of the filament spool to delete.</param>
|
|
/// <returns>No content on successful deletion.</returns>
|
|
/// <response code="204">The filament spool was successfully deleted.</response>
|
|
/// <response code="404">If the filament spool with the given ID is not found.</response>
|
|
/// <response code="409">If the spool has associated print jobs and cannot be deleted.</response>
|
|
[HttpDelete("{id:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
public async Task<IActionResult> DeleteFilament(Guid id)
|
|
{
|
|
_logger.LogInformation("Deleting filament {Id}", id);
|
|
|
|
var entity = await _dbContext.Spools.FindAsync(id);
|
|
if (entity is null)
|
|
{
|
|
_logger.LogWarning("Filament {Id} not found for deletion", id);
|
|
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
|
}
|
|
|
|
// Check for associated print jobs — these cannot be orphaned
|
|
var hasPrintJobs = await _dbContext.PrintJobs.AnyAsync(pj => pj.SpoolId == id);
|
|
if (hasPrintJobs)
|
|
{
|
|
_logger.LogWarning(
|
|
"Cannot delete filament {Id}: associated print jobs exist. Suggest archiving instead.", id);
|
|
return Conflict(new
|
|
{
|
|
error = $"Cannot delete filament '{id}' because it has associated print jobs. " +
|
|
"Archive the filament instead to preserve print history and COGS data."
|
|
});
|
|
}
|
|
|
|
// Remove associated filament usage records (usage tracking data for this spool)
|
|
var usageRecords = await _dbContext.FilamentUsages
|
|
.Where(fu => fu.SpoolId == id)
|
|
.ToListAsync();
|
|
|
|
if (usageRecords.Count > 0)
|
|
{
|
|
_logger.LogInformation(
|
|
"Removing {Count} filament usage records for spool {Id}",
|
|
usageRecords.Count, id);
|
|
_dbContext.FilamentUsages.RemoveRange(usageRecords);
|
|
}
|
|
|
|
_dbContext.Spools.Remove(entity);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Filament {Id} deleted successfully", id);
|
|
return NoContent();
|
|
}
|
|
|
|
// ── 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,
|
|
IsArchived = s.IsArchived,
|
|
StorageLocation = s.StorageLocation,
|
|
CreatedAt = s.CreatedAt,
|
|
UpdatedAt = s.UpdatedAt,
|
|
QrCodeUrl = $"/api/qr/spool/{s.Id}"
|
|
};
|
|
} |