From 9192ece040e26e866b108f7f8d229730f805a4b8 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:36:49 +0000 Subject: [PATCH] CUB-38: implement low filament alert logic with configurable threshold --- .../API/Controllers/FilamentsController.cs | 57 +++++++++-- backend/API/DTOs/Filaments/FilamentDtos.cs | 13 +++ .../Domain/Interfaces/ILowStockDetector.cs | 39 ++++++++ .../Services/LowStockDetector.cs | 95 +++++++++++++++++++ backend/Program.cs | 3 + backend/appsettings.json | 3 + 6 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 backend/Domain/Interfaces/ILowStockDetector.cs create mode 100644 backend/Infrastructure/Services/LowStockDetector.cs diff --git a/backend/API/Controllers/FilamentsController.cs b/backend/API/Controllers/FilamentsController.cs index f5c1c9c..3a2558f 100644 --- a/backend/API/Controllers/FilamentsController.cs +++ b/backend/API/Controllers/FilamentsController.cs @@ -1,6 +1,7 @@ using Extrudex.API.DTOs; using Extrudex.API.DTOs.Filaments; using Extrudex.Domain.Entities; +using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -17,16 +18,22 @@ namespace Extrudex.API.Controllers; public class FilamentsController : ControllerBase { private readonly ExtrudexDbContext _dbContext; + private readonly ILowStockDetector _lowStockDetector; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The database context for data access. + /// The low-stock detection service for filament alerts. /// The logger for diagnostic output. - public FilamentsController(ExtrudexDbContext dbContext, ILogger logger) + public FilamentsController( + ExtrudexDbContext dbContext, + ILowStockDetector lowStockDetector, + ILogger logger) { _dbContext = dbContext; + _lowStockDetector = lowStockDetector; _logger = logger; } @@ -95,7 +102,7 @@ public class FilamentsController : ControllerBase .OrderByDescending(s => s.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) - .Select(s => MapToFilamentResponse(s)) + .Select(s => MapToFilamentResponse(s, _lowStockDetector)) .ToListAsync(); var response = new PagedResponse @@ -136,7 +143,7 @@ public class FilamentsController : ControllerBase return NotFound(new { error = $"Filament with ID '{id}' not found." }); } - return Ok(MapToFilamentResponse(spool)); + return Ok(MapToFilamentResponse(spool, _lowStockDetector)); } /// @@ -211,7 +218,7 @@ public class FilamentsController : ControllerBase if (entity.MaterialModifierId.HasValue) 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); } @@ -292,7 +299,37 @@ public class FilamentsController : ControllerBase if (entity.MaterialModifierId.HasValue) await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync(); - return Ok(MapToFilamentResponse(entity)); + return Ok(MapToFilamentResponse(entity, _lowStockDetector)); + } + + /// + /// 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. + /// + /// A list of low-stock filament spools with alert metadata. + /// Returns the list of low-stock filament spools. + [HttpGet("low-stock")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> 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); } /// @@ -361,10 +398,12 @@ public class FilamentsController : ControllerBase /// 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. + /// Calculates low-stock status and remaining weight percentage. /// /// The spool entity to map. - /// A FilamentResponse DTO with denormalized material names and QR code URL. - private static FilamentResponse MapToFilamentResponse(Spool s) => new() + /// The low-stock detection service for computing alert flags. + /// A FilamentResponse DTO with denormalized material names, QR code URL, and low-stock metadata. + private static FilamentResponse MapToFilamentResponse(Spool s, ILowStockDetector lowStockDetector) => new() { Id = s.Id, MaterialBaseId = s.MaterialBaseId, @@ -387,6 +426,8 @@ public class FilamentsController : ControllerBase StorageLocation = s.StorageLocation, CreatedAt = s.CreatedAt, 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) }; } \ No newline at end of file diff --git a/backend/API/DTOs/Filaments/FilamentDtos.cs b/backend/API/DTOs/Filaments/FilamentDtos.cs index 75f9247..4143709 100644 --- a/backend/API/DTOs/Filaments/FilamentDtos.cs +++ b/backend/API/DTOs/Filaments/FilamentDtos.cs @@ -76,6 +76,19 @@ public class FilamentResponse /// Encodes a deep link to the spool's detail page. /// public string QrCodeUrl { get; set; } = string.Empty; + + /// + /// 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. + /// + public bool IsLowStock { get; set; } + + /// + /// Remaining filament weight as a percentage of total weight (0–100). + /// Rounded to one decimal place. Returns 0 if total weight is zero. + /// + public decimal RemainingWeightPercent { get; set; } } /// diff --git a/backend/Domain/Interfaces/ILowStockDetector.cs b/backend/Domain/Interfaces/ILowStockDetector.cs new file mode 100644 index 0000000..9ba5c3b --- /dev/null +++ b/backend/Domain/Interfaces/ILowStockDetector.cs @@ -0,0 +1,39 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// 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. +/// +public interface ILowStockDetector +{ + /// + /// Determines whether a spool is considered low stock based on its remaining + /// weight relative to its total weight and the configured threshold percentage. + /// + /// The current remaining weight in grams. + /// The total spool weight in grams when full. + /// + /// true if the remaining weight percentage is at or below the configured + /// low-stock threshold; false otherwise. Returns false for spools + /// with zero total weight to avoid division-by-zero. + /// + bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams); + + /// + /// Calculates the remaining weight as a percentage of total weight. + /// + /// The current remaining weight in grams. + /// The total spool weight in grams when full. + /// + /// A value between 0 and 100 representing the percentage of filament remaining. + /// Returns 0 if total weight is zero to avoid division-by-zero. + /// + decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams); + + /// + /// Gets the currently configured low-stock threshold percentage. + /// Useful for API responses so clients know what threshold is in effect. + /// + decimal LowStockThresholdPercent { get; } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/LowStockDetector.cs b/backend/Infrastructure/Services/LowStockDetector.cs new file mode 100644 index 0000000..8dcd4b0 --- /dev/null +++ b/backend/Infrastructure/Services/LowStockDetector.cs @@ -0,0 +1,95 @@ +using Extrudex.Domain.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// 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) +/// +public class LowStockDetector : ILowStockDetector +{ + private readonly ILogger _logger; + + /// + /// 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. + /// + public decimal LowStockThresholdPercent { get; } + + /// + /// Initializes a new instance of the class. + /// Reads the low-stock threshold from configuration with env var override support. + /// + /// Application configuration for threshold settings. + /// Logger for diagnostic output. + public LowStockDetector(IConfiguration configuration, ILogger logger) + { + _logger = logger; + + // Priority: env var > appsettings > default (20%) + var envThreshold = Environment.GetEnvironmentVariable("EXTRUDEX_LOW_STOCK_THRESHOLD"); + var configThreshold = configuration.GetValue("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); + } + } + + /// + 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; + } + + /// + public decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams) + { + if (weightTotalGrams <= 0m) + return 0m; + + return Math.Round( + (weightRemainingGrams / weightTotalGrams) * 100m, + 1, + MidpointRounding.AwayFromZero); + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index c3ac2a1..ad24693 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -55,6 +55,9 @@ builder.Services.AddSingleton(); // ── Cost Per Print Calculation ───────────────────────────── builder.Services.AddScoped(); +// ── Low Stock Detection ──────────────────────────────────── +builder.Services.AddSingleton(); + // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); diff --git a/backend/appsettings.json b/backend/appsettings.json index d35bd81..a7c2fa0 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -20,5 +20,8 @@ "RequestTimeout": "00:00:15", "Enabled": true, "HistoryBatchSize": 25 + }, + "FilamentAlerts": { + "LowStockThresholdPercent": 20 } } \ No newline at end of file