CUB-38: implement low filament alert logic with configurable threshold
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m12s

This commit is contained in:
2026-04-27 17:36:49 +00:00
committed by otto-bot
parent fa4a4c21b3
commit 9192ece040
6 changed files with 202 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
using Extrudex.API.DTOs; using Extrudex.API.DTOs;
using Extrudex.API.DTOs.Filaments; using Extrudex.API.DTOs.Filaments;
using Extrudex.Domain.Entities; using Extrudex.Domain.Entities;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,16 +18,22 @@ namespace Extrudex.API.Controllers;
public class FilamentsController : ControllerBase public class FilamentsController : ControllerBase
{ {
private readonly ExtrudexDbContext _dbContext; private readonly ExtrudexDbContext _dbContext;
private readonly ILowStockDetector _lowStockDetector;
private readonly ILogger<FilamentsController> _logger; private readonly ILogger<FilamentsController> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="FilamentsController"/> class. /// Initializes a new instance of the <see cref="FilamentsController"/> class.
/// </summary> /// </summary>
/// <param name="dbContext">The database context for data access.</param> /// <param name="dbContext">The database context for data access.</param>
/// <param name="lowStockDetector">The low-stock detection service for filament alerts.</param>
/// <param name="logger">The logger for diagnostic output.</param> /// <param name="logger">The logger for diagnostic output.</param>
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger) public FilamentsController(
ExtrudexDbContext dbContext,
ILowStockDetector lowStockDetector,
ILogger<FilamentsController> logger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_lowStockDetector = lowStockDetector;
_logger = logger; _logger = logger;
} }
@@ -95,7 +102,7 @@ public class FilamentsController : ControllerBase
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.Skip((pageNumber - 1) * pageSize) .Skip((pageNumber - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(s => MapToFilamentResponse(s)) .Select(s => MapToFilamentResponse(s, _lowStockDetector))
.ToListAsync(); .ToListAsync();
var response = new PagedResponse<FilamentResponse> var response = new PagedResponse<FilamentResponse>
@@ -136,7 +143,7 @@ public class FilamentsController : ControllerBase
return NotFound(new { error = $"Filament with ID '{id}' not found." }); return NotFound(new { error = $"Filament with ID '{id}' not found." });
} }
return Ok(MapToFilamentResponse(spool)); return Ok(MapToFilamentResponse(spool, _lowStockDetector));
} }
/// <summary> /// <summary>
@@ -211,7 +218,7 @@ public class FilamentsController : ControllerBase
if (entity.MaterialModifierId.HasValue) if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
var response = MapToFilamentResponse(entity); var response = MapToFilamentResponse(entity, _lowStockDetector);
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response); return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
} }
@@ -292,7 +299,37 @@ public class FilamentsController : ControllerBase
if (entity.MaterialModifierId.HasValue) if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
return Ok(MapToFilamentResponse(entity)); return Ok(MapToFilamentResponse(entity, _lowStockDetector));
}
/// <summary>
/// Gets only the filament spools that are flagged as low stock.
/// A spool is considered low stock when its remaining weight percentage
/// is at or below the configured threshold.
/// </summary>
/// <returns>A list of low-stock filament spools with alert metadata.</returns>
/// <response code="200">Returns the list of low-stock filament spools.</response>
[HttpGet("low-stock")]
[ProducesResponseType(typeof(List<FilamentResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<FilamentResponse>>> GetLowStockFilaments()
{
_logger.LogDebug("Getting low-stock filaments (threshold: {Threshold}%)",
_lowStockDetector.LowStockThresholdPercent);
var spools = await _dbContext.Spools
.Include(s => s.MaterialBase)
.Include(s => s.MaterialFinish)
.Include(s => s.MaterialModifier)
.Where(s => s.IsActive)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
var lowStockItems = spools
.Where(s => _lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams))
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
.ToList();
return Ok(lowStockItems);
} }
/// <summary> /// <summary>
@@ -361,10 +398,12 @@ public class FilamentsController : ControllerBase
/// Maps a Spool domain entity to a FilamentResponse DTO. /// Maps a Spool domain entity to a FilamentResponse DTO.
/// Denormalizes material names for display convenience. /// Denormalizes material names for display convenience.
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code. /// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
/// Calculates low-stock status and remaining weight percentage.
/// </summary> /// </summary>
/// <param name="s">The spool entity to map.</param> /// <param name="s">The spool entity to map.</param>
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns> /// <param name="lowStockDetector">The low-stock detection service for computing alert flags.</param>
private static FilamentResponse MapToFilamentResponse(Spool s) => new() /// <returns>A FilamentResponse DTO with denormalized material names, QR code URL, and low-stock metadata.</returns>
private static FilamentResponse MapToFilamentResponse(Spool s, ILowStockDetector lowStockDetector) => new()
{ {
Id = s.Id, Id = s.Id,
MaterialBaseId = s.MaterialBaseId, MaterialBaseId = s.MaterialBaseId,
@@ -387,6 +426,8 @@ public class FilamentsController : ControllerBase
StorageLocation = s.StorageLocation, StorageLocation = s.StorageLocation,
CreatedAt = s.CreatedAt, CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt, UpdatedAt = s.UpdatedAt,
QrCodeUrl = $"/api/qr/spool/{s.Id}" QrCodeUrl = $"/api/qr/spool/{s.Id}",
IsLowStock = lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams),
RemainingWeightPercent = lowStockDetector.GetRemainingWeightPercent(s.WeightRemainingGrams, s.WeightTotalGrams)
}; };
} }

View File

@@ -76,6 +76,19 @@ public class FilamentResponse
/// Encodes a deep link to the spool's detail page. /// Encodes a deep link to the spool's detail page.
/// </summary> /// </summary>
public string QrCodeUrl { get; set; } = string.Empty; public string QrCodeUrl { get; set; } = string.Empty;
/// <summary>
/// Whether this spool is flagged as low stock — remaining weight is at or
/// below the configured low-stock threshold percentage.
/// Useful for UI alerts and inventory dashboards.
/// </summary>
public bool IsLowStock { get; set; }
/// <summary>
/// Remaining filament weight as a percentage of total weight (0100).
/// Rounded to one decimal place. Returns 0 if total weight is zero.
/// </summary>
public decimal RemainingWeightPercent { get; set; }
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,39 @@
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Detects low-stock filament spools based on configurable weight thresholds.
/// Determines whether a spool's remaining filament falls below a critical level
/// so that alerts and API flags can be surfaced to the user.
/// </summary>
public interface ILowStockDetector
{
/// <summary>
/// Determines whether a spool is considered low stock based on its remaining
/// weight relative to its total weight and the configured threshold percentage.
/// </summary>
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
/// <returns>
/// <c>true</c> if the remaining weight percentage is at or below the configured
/// low-stock threshold; <c>false</c> otherwise. Returns <c>false</c> for spools
/// with zero total weight to avoid division-by-zero.
/// </returns>
bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams);
/// <summary>
/// Calculates the remaining weight as a percentage of total weight.
/// </summary>
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
/// <returns>
/// A value between 0 and 100 representing the percentage of filament remaining.
/// Returns 0 if total weight is zero to avoid division-by-zero.
/// </returns>
decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams);
/// <summary>
/// Gets the currently configured low-stock threshold percentage.
/// Useful for API responses so clients know what threshold is in effect.
/// </summary>
decimal LowStockThresholdPercent { get; }
}

View File

@@ -0,0 +1,95 @@
using Extrudex.Domain.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Detects low-stock filament spools by comparing the remaining weight percentage
/// against a configurable threshold. The threshold can be set via:
/// 1. EXTRUDEX_LOW_STOCK_THRESHOLD env var (highest priority, e.g. "25")
/// 2. FilamentAlerts:LowStockThresholdPercent in appsettings.json
/// 3. Default: 20% (a standard spool is "low" when ≤20% remains)
/// </summary>
public class LowStockDetector : ILowStockDetector
{
private readonly ILogger<LowStockDetector> _logger;
/// <summary>
/// The percentage threshold below which a spool is considered low stock.
/// For example, 20 means a spool is "low" when ≤20% of its filament remains.
/// </summary>
public decimal LowStockThresholdPercent { get; }
/// <summary>
/// Initializes a new instance of the <see cref="LowStockDetector"/> class.
/// Reads the low-stock threshold from configuration with env var override support.
/// </summary>
/// <param name="configuration">Application configuration for threshold settings.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public LowStockDetector(IConfiguration configuration, ILogger<LowStockDetector> logger)
{
_logger = logger;
// Priority: env var > appsettings > default (20%)
var envThreshold = Environment.GetEnvironmentVariable("EXTRUDEX_LOW_STOCK_THRESHOLD");
var configThreshold = configuration.GetValue<decimal?>("FilamentAlerts:LowStockThresholdPercent");
if (!string.IsNullOrEmpty(envThreshold) && decimal.TryParse(envThreshold, out var parsedEnv))
{
LowStockThresholdPercent = Math.Clamp(parsedEnv, 0m, 100m);
_logger.LogInformation(
"Low-stock threshold set from env var EXTRUDEX_LOW_STOCK_THRESHOLD: {Threshold}%",
LowStockThresholdPercent);
}
else if (configThreshold.HasValue)
{
LowStockThresholdPercent = Math.Clamp(configThreshold.Value, 0m, 100m);
_logger.LogInformation(
"Low-stock threshold set from config FilamentAlerts:LowStockThresholdPercent: {Threshold}%",
LowStockThresholdPercent);
}
else
{
LowStockThresholdPercent = 20m;
_logger.LogInformation(
"Low-stock threshold using default: {Threshold}%", LowStockThresholdPercent);
}
}
/// <inheritdoc />
public bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams)
{
if (weightTotalGrams <= 0m)
{
_logger.LogDebug(
"Spool with total weight {Total}g cannot be evaluated for low stock — treating as not low",
weightTotalGrams);
return false;
}
var remainingPercent = GetRemainingWeightPercent(weightRemainingGrams, weightTotalGrams);
var isLow = remainingPercent <= LowStockThresholdPercent;
if (isLow)
{
_logger.LogDebug(
"Spool is LOW STOCK: {Remaining}g / {Total}g = {Percent:F1}% (threshold: {Threshold}%)",
weightRemainingGrams, weightTotalGrams, remainingPercent, LowStockThresholdPercent);
}
return isLow;
}
/// <inheritdoc />
public decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams)
{
if (weightTotalGrams <= 0m)
return 0m;
return Math.Round(
(weightRemainingGrams / weightTotalGrams) * 100m,
1,
MidpointRounding.AwayFromZero);
}
}

View File

@@ -55,6 +55,9 @@ builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
// ── Cost Per Print Calculation ───────────────────────────── // ── Cost Per Print Calculation ─────────────────────────────
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>(); builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
// ── Low Stock Detection ────────────────────────────────────
builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
// ── FluentValidation ────────────────────────────────────── // ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI. // Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

View File

@@ -20,5 +20,8 @@
"RequestTimeout": "00:00:15", "RequestTimeout": "00:00:15",
"Enabled": true, "Enabled": true,
"HistoryBatchSize": 25 "HistoryBatchSize": 25
},
"FilamentAlerts": {
"LowStockThresholdPercent": 20
} }
} }