diff --git a/backend/API/Controllers/CostAnalysisController.cs b/backend/API/Controllers/CostAnalysisController.cs new file mode 100644 index 0000000..87e94b0 --- /dev/null +++ b/backend/API/Controllers/CostAnalysisController.cs @@ -0,0 +1,108 @@ +using Extrudex.API.DTOs.PrintJobs; +using Extrudex.Domain.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Extrudex.API.Controllers; + +/// +/// Controller for cost analysis endpoints. Provides spool-level +/// cost breakdowns and aggregated COGS reporting. +/// +[ApiController] +[Route("api/cost-analysis")] +public class CostAnalysisController : ControllerBase +{ + private readonly ICostPerPrintService _costService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The cost-per-print calculation service. + /// The logger for diagnostic output. + public CostAnalysisController( + ICostPerPrintService costService, + ILogger logger) + { + _costService = costService; + _logger = logger; + } + + // ── POST /api/cost-analysis/spool ──────────────────────────── + + /// + /// Calculates cost breakdowns for all print jobs associated with a specific spool. + /// Returns per-job costs plus an aggregated total. Jobs with missing cost data + /// include warnings and null cost fields — the endpoint never throws for missing data. + /// + /// The request containing the spool identifier. + /// A spool-level cost summary with per-job breakdowns. + /// Returns the spool cost breakdown with per-job details. + /// If the spool has no print jobs. + [HttpPost("spool")] + [ProducesResponseType(typeof(SpoolCostResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CalculateSpoolCost([FromBody] SpoolCostRequest request) + { + _logger.LogDebug("Calculating cost breakdown for spool {SpoolId}", request.SpoolId); + + var results = await _costService.CalculateBySpoolAsync(request.SpoolId); + + if (results.Count == 0) + { + return NotFound(new { error = $"No print jobs found for spool with ID '{request.SpoolId}'." }); + } + + // Build the spool-level summary + var firstResult = results[0]; + var jobResponses = results.Select(MapCostToResponse).ToList(); + + // Aggregate total cost and grams — only include jobs that have a valid cost + var calculableJobs = results.Where(r => r.CostPerPrint.HasValue).ToList(); + var totalCost = calculableJobs.Count == results.Count + ? Math.Round(calculableJobs.Sum(r => r.CostPerPrint!.Value), 4) + : (decimal?)null; + + var aggregateWarnings = new List(); + if (calculableJobs.Count < results.Count) + { + aggregateWarnings.Add( + $"{results.Count - calculableJobs.Count} of {results.Count} print jobs have missing cost data. " + + "Total cost reflects only jobs with complete data."); + } + + var response = new SpoolCostResponse + { + SpoolId = request.SpoolId, + SpoolSerial = firstResult.SpoolSerial, + PurchasePrice = firstResult.PurchasePrice, + WeightTotalGrams = firstResult.WeightTotalGrams, + CostPerGram = firstResult.CostPerGram, + TotalGramsConsumed = results.Sum(r => r.GramsDerived), + TotalCost = totalCost, + JobCount = results.Count, + Jobs = jobResponses, + Warnings = aggregateWarnings + }; + + return Ok(response); + } + + /// + /// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO. + /// + private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new() + { + PrintJobId = r.PrintJobId, + PrintName = r.PrintName, + SpoolId = r.SpoolId, + SpoolSerial = r.SpoolSerial, + MmExtruded = r.MmExtruded, + GramsDerived = r.GramsDerived, + PurchasePrice = r.PurchasePrice, + WeightTotalGrams = r.WeightTotalGrams, + CostPerGram = r.CostPerGram, + CostPerPrint = r.CostPerPrint, + Warnings = r.Warnings + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/PrintJobsController.cs b/backend/API/Controllers/PrintJobsController.cs index 226430e..5cb802e 100644 --- a/backend/API/Controllers/PrintJobsController.cs +++ b/backend/API/Controllers/PrintJobsController.cs @@ -413,6 +413,92 @@ public class PrintJobsController : ControllerBase return NoContent(); } + // ── GET /api/printjobs/{id}/cost-summary ────────────────────────── + + /// + /// Gets the material cost summary for a specific print job. + /// Calculates total material cost from filament usage (grams derived) + /// and the spool's purchase price. Returns warnings instead of errors + /// when cost data is unavailable. + /// + /// The unique identifier of the print job. + /// A cost summary with breakdown and any warnings about missing data. + /// Returns the cost summary. Warnings field lists any missing data. + /// If the print job with the given ID is not found. + [HttpGet("{id:guid}/cost-summary")] + [ProducesResponseType(typeof(CostSummaryResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCostSummary(Guid id) + { + _logger.LogDebug("Getting cost summary for print job {Id}", id); + + var job = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .FirstOrDefaultAsync(j => j.Id == id); + + if (job is null) + { + _logger.LogWarning("Print job {Id} not found for cost summary", id); + return NotFound(new { error = $"Print job with ID '{id}' not found." }); + } + + var warnings = new List(); + var spool = job.Spool; + + // Build response with what we have + var response = new CostSummaryResponse + { + PrintJobId = job.Id, + PrintName = job.PrintName, + SpoolId = job.SpoolId, + SpoolSerial = spool?.SpoolSerial ?? string.Empty, + SpoolBrand = spool?.Brand ?? string.Empty, + SpoolColorName = spool?.ColorName ?? string.Empty, + MmExtruded = job.MmExtruded, + GramsDerived = job.GramsDerived, + SpoolPurchasePrice = spool?.PurchasePrice, + SpoolWeightTotalGrams = spool?.WeightTotalGrams, + StoredCostPerPrint = job.CostPerPrint + }; + + // Validate spool data availability + if (spool is null) + { + warnings.Add("Spool data is not available for this print job. Cost cannot be calculated."); + response.Warnings = warnings; + return Ok(response); + } + + // Check if we can calculate cost + if (!spool.PurchasePrice.HasValue) + { + warnings.Add("Spool purchase price is not set. Cost per gram and total material cost cannot be calculated."); + } + + if (spool.WeightTotalGrams <= 0) + { + warnings.Add("Spool total weight is zero or invalid. Cost per gram and total material cost cannot be calculated."); + } + + // If we have enough data, calculate the cost + if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0) + { + var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; + response.PricePerGram = Math.Round(pricePerGram, 4); + response.TotalMaterialCost = Math.Round(job.GramsDerived * pricePerGram, 4); + } + + // Warn if grams derived is zero but mm extruded is non-zero + if (job.GramsDerived == 0 && job.MmExtruded > 0) + { + warnings.Add("GramsDerived is zero despite MmExtruded being non-zero. Cost may be inaccurate. Consider re-deriving grams from filament parameters."); + } + + response.Warnings = warnings; + return Ok(response); + } + // ── Gram Derivation Formula ──────────────────────────────────── /// diff --git a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs new file mode 100644 index 0000000..e82e6b3 --- /dev/null +++ b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs @@ -0,0 +1,99 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// Response DTO for cost-per-print calculation. Contains the full cost +/// breakdown and any warnings about missing or incomplete data. +/// +public class CostPerPrintResponse +{ + /// The print job identifier this result belongs to. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// The spool identifier that provided filament. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Total millimeters of filament extruded. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print. + public decimal GramsDerived { get; set; } + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if purchase price or total weight is missing. + public decimal? CostPerGram { get; set; } + + /// Calculated cost of this print job. Null if cost data is incomplete. + public decimal? CostPerPrint { get; set; } + + /// + /// Warnings about missing or incomplete data. Empty when all data is available + /// and the calculation succeeded. + /// + public List Warnings { get; set; } = new(); +} + +/// +/// Request DTO for batch cost calculation by spool. Returns cost breakdowns +/// for all print jobs associated with the specified spool. +/// +public class SpoolCostRequest +{ + /// The unique identifier of the spool to calculate costs for. + [Required(ErrorMessage = "SpoolId is required.")] + public Guid SpoolId { get; set; } +} + +/// +/// Response DTO for spool-level cost calculation. Contains cost breakdowns +/// for all print jobs on the spool, plus a total cost summary. +/// +public class SpoolCostResponse +{ + /// The spool identifier. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if cost data is incomplete. + public decimal? CostPerGram { get; set; } + + /// Total grams consumed across all print jobs on this spool. + public decimal TotalGramsConsumed { get; set; } + + /// Total calculated cost across all print jobs. Null if any job has missing data. + public decimal? TotalCost { get; set; } + + /// Number of print jobs included in this calculation. + public int JobCount { get; set; } + + /// + /// Individual cost breakdowns per print job. Jobs with missing data + /// will have null cost fields and populated warnings. + /// + public List Jobs { get; set; } = new(); + + /// + /// Aggregate warnings about missing data across all jobs. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs b/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs new file mode 100644 index 0000000..0c2f9ed --- /dev/null +++ b/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs @@ -0,0 +1,55 @@ +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// Response DTO for the cost summary of a print job. +/// Provides a breakdown of material cost based on filament usage +/// and spool pricing data. If cost data is incomplete, warnings +/// are returned instead of throwing an error. +/// +public class CostSummaryResponse +{ + /// Unique identifier of the print job. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// Foreign key to the spool used for this print job. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Brand of the spool. + public string SpoolBrand { get; set; } = string.Empty; + + /// Color name of the spool. + public string SpoolColorName { get; set; } = string.Empty; + + /// Total millimeters of filament extruded during this print. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print job. + public decimal GramsDerived { get; set; } + + /// Purchase price of the full spool, if available. + public decimal? SpoolPurchasePrice { get; set; } + + /// Total weight of the spool in grams when full. + public decimal? SpoolWeightTotalGrams { get; set; } + + /// Calculated price per gram (purchase price / total weight), if available. + public decimal? PricePerGram { get; set; } + + /// Calculated total material cost for this print job, if available. + public decimal? TotalMaterialCost { get; set; } + + /// The CostPerPrint stored on the print job entity, if set. + public decimal? StoredCostPerPrint { get; set; } + + /// + /// Warnings about missing data that prevent cost calculation. + /// Empty if all data is available and cost was calculated successfully. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs new file mode 100644 index 0000000..19c991d --- /dev/null +++ b/backend/API/Jobs/FilamentUsageSyncJob.cs @@ -0,0 +1,79 @@ +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Extrudex.API.Jobs; + +/// +/// Background job that periodically syncs filament usage data from +/// Moonraker printers. Runs as a hosted service and polls all active +/// Moonraker printers on a configurable interval to persist usage +/// data to the Extrudex database. +/// +/// Configuration is bound from the "FilamentUsageSync" section in +/// appsettings.json. Set Enabled=false to disable without removing +/// the service registration. +/// +public class FilamentUsageSyncJob : BackgroundService +{ + private readonly IFilamentUsageSyncService _syncService; + private readonly FilamentUsageSyncOptions _options; + private readonly ILogger _logger; + + /// + /// Creates a new FilamentUsageSyncJob. + /// + /// The service that performs the actual sync logic. + /// Configuration options for polling interval and timeouts. + /// Logger for diagnostic output. + public FilamentUsageSyncJob( + IFilamentUsageSyncService syncService, + IOptions options, + ILogger logger) + { + _syncService = syncService; + _options = options.Value; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _logger.LogInformation("Filament usage sync job is disabled via configuration — exiting"); + return; + } + + _logger.LogInformation( + "Filament usage sync job starting — polling every {Interval}", + _options.PollingInterval); + + // Delay briefly on startup to allow the web host to fully initialize + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var syncedCount = await _syncService.SyncAllAsync(stoppingToken); + + _logger.LogInformation( + "Filament usage sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}", + syncedCount, _options.PollingInterval); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, + "Error during filament usage sync cycle — will retry in {Interval}", + _options.PollingInterval); + } + + await Task.Delay(_options.PollingInterval, stoppingToken); + } + + _logger.LogInformation("Filament usage sync job shutting down"); + } +} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index b604978..23aacef 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,6 +17,9 @@ RUN dotnet publish Extrudex.csproj \ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app +# Install curl for health check (not included in aspnet base image) +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + # Non-root user for security RUN adduser --disabled-password --gecos "" appuser USER appuser diff --git a/backend/Domain/Entities/FilamentUsage.cs b/backend/Domain/Entities/FilamentUsage.cs new file mode 100644 index 0000000..3237bc9 --- /dev/null +++ b/backend/Domain/Entities/FilamentUsage.cs @@ -0,0 +1,73 @@ +using Extrudex.Domain.Base; + +namespace Extrudex.Domain.Entities; + +/// +/// Tracks filament consumption for a specific print job on a specific spool. +/// Each record captures the grams used, which printer consumed it, and when the +/// usage was recorded. This enables granular per-job usage analytics, COGS +/// reconciliation, and spool weight depletion tracking. +/// +/// A single PrintJob may have multiple FilamentUsage records if multiple spools +/// were consumed (e.g., multi-material prints via AMS). +/// +public class FilamentUsage : AuditableEntity +{ + /// + /// Foreign key to the print job that consumed this filament. + /// A usage record is always tied to a print job. + /// + public Guid PrintJobId { get; set; } + + /// + /// Navigation to the print job that consumed this filament. + /// + public PrintJob PrintJob { get; set; } = null!; + + /// + /// Foreign key to the spool (filament) that provided the material. + /// Links usage back to the specific physical spool for inventory tracking. + /// + public Guid SpoolId { get; set; } + + /// + /// Navigation to the spool that provided the material. + /// + public Spool Spool { get; set; } = null!; + + /// + /// Foreign key to the printer that executed the print job. + /// Denormalized from PrintJob for direct querying of per-printer usage. + /// + public Guid PrinterId { get; set; } + + /// + /// Navigation to the printer that executed the print job. + /// + public Printer Printer { get; set; } = null!; + + /// + /// Grams of filament consumed during this print job. + /// Derived from mm_extruded × cross_section_area × material_density, + /// or measured directly from AMS weight delta. + /// + public decimal GramsUsed { get; set; } + + /// + /// Millimeters of filament extruded for this usage record. + /// The primary physical measurement; grams_used is derived from this. + /// + public decimal MmExtruded { get; set; } + + /// + /// Timestamp when this usage record was created (UTC). + /// Represents when the usage was first logged, which may differ from + /// the print job's started_at or completed_at timestamps. + /// + public DateTime RecordedAt { get; set; } = DateTime.UtcNow; + + /// + /// Optional notes about this usage record (e.g., "AMS tray 3", "manual weight check"). + /// + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/Entities/PrintJob.cs b/backend/Domain/Entities/PrintJob.cs index 6483f72..caeb2d1 100644 --- a/backend/Domain/Entities/PrintJob.cs +++ b/backend/Domain/Entities/PrintJob.cs @@ -97,4 +97,10 @@ public class PrintJob : AuditableEntity /// Optional notes about the print job (e.g., "First layer adhesion issues"). /// public string? Notes { get; set; } + + /// + /// Navigation collection of filament usage records for this print job. + /// Enables tracking granular per-spool consumption within a print. + /// + public ICollection FilamentUsages { get; set; } = new List(); } \ No newline at end of file diff --git a/backend/Domain/Entities/Printer.cs b/backend/Domain/Entities/Printer.cs index b28419e..4e48b19 100644 --- a/backend/Domain/Entities/Printer.cs +++ b/backend/Domain/Entities/Printer.cs @@ -94,4 +94,10 @@ public class Printer : AuditableEntity /// Navigation collection of print jobs executed on this printer. /// public ICollection PrintJobs { get; set; } = new List(); + + /// + /// Navigation collection of filament usage records tracking consumption on this printer. + /// Enables querying per-printer filament usage and COGS. + /// + public ICollection FilamentUsages { get; set; } = new List(); } \ No newline at end of file diff --git a/backend/Domain/Entities/Spool.cs b/backend/Domain/Entities/Spool.cs index 0356002..5084a7b 100644 --- a/backend/Domain/Entities/Spool.cs +++ b/backend/Domain/Entities/Spool.cs @@ -102,4 +102,10 @@ public class Spool : AuditableEntity /// Navigation collection of print jobs that consumed filament from this spool. /// public ICollection PrintJobs { get; set; } = new List(); + + /// + /// Navigation collection of filament usage records tracking consumption from this spool. + /// Enables querying how much filament was consumed per print job. + /// + public ICollection FilamentUsages { get; set; } = new List(); } \ No newline at end of file diff --git a/backend/Domain/Interfaces/ICostPerPrintService.cs b/backend/Domain/Interfaces/ICostPerPrintService.cs new file mode 100644 index 0000000..041736a --- /dev/null +++ b/backend/Domain/Interfaces/ICostPerPrintService.cs @@ -0,0 +1,76 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// Service interface for calculating the cost of goods sold (COGS) per print job. +/// Uses the spool's purchase price and the print job's derived grams consumed +/// to produce a cost breakdown. Handles missing cost data gracefully by returning +/// warnings rather than throwing exceptions. +/// +public interface ICostPerPrintService +{ + /// + /// Calculates the cost per print for a specific print job. + /// + /// The unique identifier of the print job. + /// Optional cancellation token. + /// + /// A containing the cost breakdown, + /// or warnings if cost data is missing or incomplete. + /// + Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default); + + /// + /// Calculates cost breakdowns for all print jobs associated with a specific spool. + /// Useful for spool-level COGS reporting. + /// + /// The unique identifier of the spool. + /// Optional cancellation token. + /// + /// A list of for each print job on the spool. + /// Jobs with missing cost data will include warnings. + /// + Task> CalculateBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default); +} + +/// +/// Result of a cost-per-print calculation. Contains the cost breakdown +/// and any warnings about missing or incomplete cost data. +/// +public class CostPerPrintResult +{ + /// The print job identifier this result belongs to. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// The spool identifier that provided filament. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Total millimeters of filament extruded. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print. + public decimal GramsDerived { get; set; } + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if purchase price or total weight is missing. + public decimal? CostPerGram { get; set; } + + /// Calculated cost of this print job. Null if cost data is incomplete. + public decimal? CostPerPrint { get; set; } + + /// + /// Warnings about missing or incomplete data that prevented a full calculation. + /// Empty when all data is available and the calculation succeeded. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs new file mode 100644 index 0000000..951f80d --- /dev/null +++ b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs @@ -0,0 +1,19 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// Service interface for syncing filament usage data from printers +/// into the Extrudex database. Handles querying Moonraker printers, +/// computing derived usage metrics, and persisting updates to spools +/// and print job records. +/// +public interface IFilamentUsageSyncService +{ + /// + /// Performs a single sync cycle: queries all active Moonraker printers, + /// fetches their current filament usage data, and persists updates to + /// the database. + /// + /// Cancellation token for graceful shutdown. + /// The number of printers successfully synced. + Task SyncAllAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs new file mode 100644 index 0000000..d4599f2 --- /dev/null +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -0,0 +1,39 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// Client interface for communicating with Moonraker REST API endpoints +/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// Used to retrieve filament usage data, print job status, and +/// remaining spool weight from the printer. +/// +public interface IMoonrakerClient +{ + /// + /// Fetches the current filament usage data from the Moonraker server. + /// Returns a dictionary of usage metrics reported by the printer. + /// + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// A dictionary of usage metric names to their decimal values. + Task> GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); + + /// + /// Checks whether the Moonraker server is reachable and responding. + /// + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// true if the server responded successfully; otherwise false. + Task IsReachableAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs new file mode 100644 index 0000000..29f95b2 --- /dev/null +++ b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs @@ -0,0 +1,33 @@ +namespace Extrudex.Infrastructure.Configuration; + +/// +/// Configuration options for the FilamentUsageSync background job. +/// Bound from appsettings.json under the "FilamentUsageSync" section. +/// Controls polling interval and per-printer timeout settings. +/// +public class FilamentUsageSyncOptions +{ + /// + /// The section name in appsettings.json where these options are bound. + /// + public const string SectionName = "FilamentUsageSync"; + + /// + /// How often the background job polls printers for usage data. + /// Default: 5 minutes. Minimum recommended: 1 minute. + /// + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Timeout for individual HTTP requests to a Moonraker printer. + /// Default: 30 seconds. + /// + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Whether the sync job is enabled. Set to false to disable + /// the background job without removing its registration. + /// Default: true. + /// + public bool Enabled { get; set; } = true; +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/FilamentUsageConfiguration.cs b/backend/Infrastructure/Data/Configurations/FilamentUsageConfiguration.cs new file mode 100644 index 0000000..8672735 --- /dev/null +++ b/backend/Infrastructure/Data/Configurations/FilamentUsageConfiguration.cs @@ -0,0 +1,83 @@ +using Extrudex.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Extrudex.Infrastructure.Data.Configurations; + +public class FilamentUsageConfiguration : BaseEntityConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(e => e.PrintJobId) + .HasColumnName("print_job_id") + .IsRequired(); + + builder.Property(e => e.SpoolId) + .HasColumnName("spool_id") + .IsRequired(); + + builder.Property(e => e.PrinterId) + .HasColumnName("printer_id") + .IsRequired(); + + builder.Property(e => e.GramsUsed) + .HasColumnName("grams_used") + .HasPrecision(10, 2) + .IsRequired(); + + builder.Property(e => e.MmExtruded) + .HasColumnName("mm_extruded") + .HasPrecision(12, 2) + .IsRequired(); + + builder.Property(e => e.RecordedAt) + .HasColumnName("recorded_at") + .HasDefaultValueSql("now() at time zone 'utc'") + .IsRequired(); + + builder.Property(e => e.Notes) + .HasColumnName("notes") + .HasMaxLength(2000); + + // Index on print_job_id for querying usage by print job + builder.HasIndex(e => e.PrintJobId) + .HasDatabaseName("ix_filament_usages_print_job_id"); + + // Index on spool_id for querying usage by spool (filament) + builder.HasIndex(e => e.SpoolId) + .HasDatabaseName("ix_filament_usages_spool_id"); + + // Index on printer_id for querying usage by printer + builder.HasIndex(e => e.PrinterId) + .HasDatabaseName("ix_filament_usages_printer_id"); + + // Index on recorded_at for time-range queries + builder.HasIndex(e => e.RecordedAt) + .HasDatabaseName("ix_filament_usages_recorded_at"); + + // Composite index for querying usage by spool within a date range + builder.HasIndex(e => new { e.SpoolId, e.RecordedAt }) + .HasDatabaseName("ix_filament_usages_spool_id_recorded_at"); + + // Relationships + builder.HasOne(e => e.PrintJob) + .WithMany(e => e.FilamentUsages) + .HasForeignKey(e => e.PrintJobId) + .HasConstraintName("fk_filament_usages_print_job") + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Spool) + .WithMany(e => e.FilamentUsages) + .HasForeignKey(e => e.SpoolId) + .HasConstraintName("fk_filament_usages_spool") + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(e => e.Printer) + .WithMany(e => e.FilamentUsages) + .HasForeignKey(e => e.PrinterId) + .HasConstraintName("fk_filament_usages_printer") + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Data/ExtrudexDbContext.cs b/backend/Infrastructure/Data/ExtrudexDbContext.cs index f6ec61a..270718c 100644 --- a/backend/Infrastructure/Data/ExtrudexDbContext.cs +++ b/backend/Infrastructure/Data/ExtrudexDbContext.cs @@ -23,6 +23,7 @@ public class ExtrudexDbContext : DbContext public DbSet AmsUnits => Set(); public DbSet AmsSlots => Set(); public DbSet PrintJobs => Set(); + public DbSet FilamentUsages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.Designer.cs b/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.Designer.cs new file mode 100644 index 0000000..393abb9 --- /dev/null +++ b/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.Designer.cs @@ -0,0 +1,1068 @@ +// +using System; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Extrudex.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ExtrudexDbContext))] + [Migration("20260426183433_AddFilamentUsageTrackingModel")] + partial class AddFilamentUsageTrackingModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Extrudex.Domain.Entities.AmsSlot", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AmsUnitId") + .HasColumnType("uuid") + .HasColumnName("ams_unit_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("RemainingWeightG") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("remaining_weight_g"); + + b.Property("SpoolId") + .HasColumnType("uuid") + .HasColumnName("spool_id"); + + b.Property("TrayIndex") + .HasColumnType("integer") + .HasColumnName("tray_index"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("SpoolId") + .HasDatabaseName("ix_ams_slots_spool_id"); + + b.HasIndex("AmsUnitId", "TrayIndex") + .IsUnique() + .HasDatabaseName("ix_ams_slots_ams_unit_id_tray_index"); + + b.ToTable("ams_slots", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.AmsUnit", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PrinterId") + .HasColumnType("uuid") + .HasColumnName("printer_id"); + + b.Property("UnitIndex") + .HasColumnType("integer") + .HasColumnName("unit_index"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("PrinterId", "UnitIndex") + .IsUnique() + .HasDatabaseName("ix_ams_units_printer_id_unit_index"); + + b.ToTable("ams_units", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("GramsUsed") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("grams_used"); + + b.Property("MmExtruded") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("mm_extruded"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("PrintJobId") + .HasColumnType("uuid") + .HasColumnName("print_job_id"); + + b.Property("PrinterId") + .HasColumnType("uuid") + .HasColumnName("printer_id"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("SpoolId") + .HasColumnType("uuid") + .HasColumnName("spool_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("PrintJobId") + .HasDatabaseName("ix_filament_usages_print_job_id"); + + b.HasIndex("PrinterId") + .HasDatabaseName("ix_filament_usages_printer_id"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_filament_usages_recorded_at"); + + b.HasIndex("SpoolId") + .HasDatabaseName("ix_filament_usages_spool_id"); + + b.HasIndex("SpoolId", "RecordedAt") + .HasDatabaseName("ix_filament_usages_spool_id_recorded_at"); + + b.ToTable("filament_usages", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialBase", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("DensityGperCm3") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("density_g_per_cm3"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_material_bases_name"); + + b.ToTable("material_bases", (string)null); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388), + DensityGperCm3 = 1.24m, + Name = "PLA", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388) + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871), + DensityGperCm3 = 1.27m, + Name = "PETG", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871) + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881), + DensityGperCm3 = 1.04m, + Name = "ABS", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881) + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888), + DensityGperCm3 = 1.07m, + Name = "ASA", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888) + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895), + DensityGperCm3 = 1.21m, + Name = "TPU", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895) + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9901), + DensityGperCm3 = 1.14m, + Name = "Nylon", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9902) + }); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialFinish", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("MaterialBaseId") + .HasColumnType("uuid") + .HasColumnName("material_base_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("MaterialBaseId", "Name") + .IsUnique() + .HasDatabaseName("ix_material_finishes_material_base_id_name"); + + b.ToTable("material_finishes", (string)null); + + b.HasData( + new + { + Id = new Guid("20000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Matte", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Silk", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Glitter", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Marble", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Sparkle", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000007"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000008"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), + Name = "Matte", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000009"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(297), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), + Name = "Silk", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(298) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000010"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000011"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), + Name = "Matte", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000012"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(316), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(317) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000013"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), + Name = "Matte", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000014"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000005"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329) + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000015"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), + Name = "Basic", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336) + }); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialModifier", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("MaterialBaseId") + .HasColumnType("uuid") + .HasColumnName("material_base_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("MaterialBaseId", "Name") + .IsUnique() + .HasDatabaseName("ix_material_modifiers_material_base_id_name"); + + b.ToTable("material_modifiers", (string)null); + + b.HasData( + new + { + Id = new Guid("30000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Carbon Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(805), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Glass Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(806) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Wood Fill", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), + Name = "Glow-in-the-Dark", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), + Name = "Carbon Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), + Name = "Glass Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000007"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), + Name = "Carbon Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000008"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), + Name = "Glass Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000009"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), + Name = "Carbon Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000010"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(859), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), + Name = "Carbon Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(860) + }, + new + { + Id = new Guid("30000000-0000-0000-0000-000000000011"), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866), + MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), + Name = "Glass Fiber", + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866) + }); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.PrintJob", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CostPerPrint") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("cost_per_print"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("data_source"); + + b.Property("FilamentDiameterAtPrintMm") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)") + .HasColumnName("filament_diameter_at_print_mm"); + + b.Property("GcodeFilePath") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("gcode_file_path"); + + b.Property("GramsDerived") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("grams_derived"); + + b.Property("MaterialDensityAtPrint") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("material_density_at_print"); + + b.Property("MmExtruded") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("mm_extruded"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("PrintName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("print_name"); + + b.Property("PrinterId") + .HasColumnType("uuid") + .HasColumnName("printer_id"); + + b.Property("SpoolId") + .HasColumnType("uuid") + .HasColumnName("spool_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("Queued") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("DataSource") + .HasDatabaseName("ix_print_jobs_data_source"); + + b.HasIndex("PrinterId") + .HasDatabaseName("ix_print_jobs_printer_id"); + + b.HasIndex("SpoolId") + .HasDatabaseName("ix_print_jobs_spool_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_print_jobs_status"); + + b.ToTable("print_jobs", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.Printer", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("api_key"); + + b.Property("ConnectionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("connection_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("HostnameOrIp") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("hostname_or_ip"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("Manufacturer") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("manufacturer"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("model"); + + b.Property("MqttPassword") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("mqtt_password"); + + b.Property("MqttUseTls") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("mqtt_use_tls"); + + b.Property("MqttUsername") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mqtt_username"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("PrinterType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("printer_type"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("Offline") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("ConnectionType") + .HasDatabaseName("ix_printers_connection_type"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_printers_is_active"); + + b.HasIndex("PrinterType") + .HasDatabaseName("ix_printers_printer_type"); + + b.HasIndex("Status") + .HasDatabaseName("ix_printers_status"); + + b.ToTable("printers", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.Spool", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Brand") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("brand"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color_hex"); + + b.Property("ColorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("color_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("FilamentDiameterMm") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)") + .HasColumnName("filament_diameter_mm"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MaterialBaseId") + .HasColumnType("uuid") + .HasColumnName("material_base_id"); + + b.Property("MaterialFinishId") + .HasColumnType("uuid") + .HasColumnName("material_finish_id"); + + b.Property("MaterialModifierId") + .HasColumnType("uuid") + .HasColumnName("material_modifier_id"); + + b.Property("PurchaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("purchase_date"); + + b.Property("PurchasePrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("purchase_price"); + + b.Property("SpoolSerial") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("spool_serial"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("WeightRemainingGrams") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("weight_remaining_grams"); + + b.Property("WeightTotalGrams") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("weight_total_grams"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_spools_is_active"); + + b.HasIndex("MaterialBaseId") + .HasDatabaseName("ix_spools_material_base_id"); + + b.HasIndex("MaterialFinishId") + .HasDatabaseName("ix_spools_material_finish_id"); + + b.HasIndex("MaterialModifierId") + .HasDatabaseName("ix_spools_material_modifier_id"); + + b.HasIndex("SpoolSerial") + .IsUnique() + .HasDatabaseName("ix_spools_spool_serial"); + + b.ToTable("spools", (string)null); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.AmsSlot", b => + { + b.HasOne("Extrudex.Domain.Entities.AmsUnit", "AmsUnit") + .WithMany("Slots") + .HasForeignKey("AmsUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ams_slots_ams_unit"); + + b.HasOne("Extrudex.Domain.Entities.Spool", "Spool") + .WithMany("AmsSlots") + .HasForeignKey("SpoolId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_ams_slots_spool"); + + b.Navigation("AmsUnit"); + + b.Navigation("Spool"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.AmsUnit", b => + { + b.HasOne("Extrudex.Domain.Entities.Printer", "Printer") + .WithMany("AmsUnits") + .HasForeignKey("PrinterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ams_units_printer"); + + b.Navigation("Printer"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b => + { + b.HasOne("Extrudex.Domain.Entities.PrintJob", "PrintJob") + .WithMany("FilamentUsages") + .HasForeignKey("PrintJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_filament_usages_print_job"); + + b.HasOne("Extrudex.Domain.Entities.Printer", "Printer") + .WithMany("FilamentUsages") + .HasForeignKey("PrinterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_filament_usages_printer"); + + b.HasOne("Extrudex.Domain.Entities.Spool", "Spool") + .WithMany("FilamentUsages") + .HasForeignKey("SpoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_filament_usages_spool"); + + b.Navigation("PrintJob"); + + b.Navigation("Printer"); + + b.Navigation("Spool"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialFinish", b => + { + b.HasOne("Extrudex.Domain.Entities.MaterialBase", "MaterialBase") + .WithMany("Finishes") + .HasForeignKey("MaterialBaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_material_finishes_material_base"); + + b.Navigation("MaterialBase"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialModifier", b => + { + b.HasOne("Extrudex.Domain.Entities.MaterialBase", "MaterialBase") + .WithMany("Modifiers") + .HasForeignKey("MaterialBaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_material_modifiers_material_base"); + + b.Navigation("MaterialBase"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.PrintJob", b => + { + b.HasOne("Extrudex.Domain.Entities.Printer", "Printer") + .WithMany("PrintJobs") + .HasForeignKey("PrinterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_print_jobs_printer"); + + b.HasOne("Extrudex.Domain.Entities.Spool", "Spool") + .WithMany("PrintJobs") + .HasForeignKey("SpoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_print_jobs_spool"); + + b.Navigation("Printer"); + + b.Navigation("Spool"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.Spool", b => + { + b.HasOne("Extrudex.Domain.Entities.MaterialBase", "MaterialBase") + .WithMany("Spools") + .HasForeignKey("MaterialBaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_spools_material_base"); + + b.HasOne("Extrudex.Domain.Entities.MaterialFinish", "MaterialFinish") + .WithMany("Spools") + .HasForeignKey("MaterialFinishId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_spools_material_finish"); + + b.HasOne("Extrudex.Domain.Entities.MaterialModifier", "MaterialModifier") + .WithMany("Spools") + .HasForeignKey("MaterialModifierId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_spools_material_modifier"); + + b.Navigation("MaterialBase"); + + b.Navigation("MaterialFinish"); + + b.Navigation("MaterialModifier"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.AmsUnit", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialBase", b => + { + b.Navigation("Finishes"); + + b.Navigation("Modifiers"); + + b.Navigation("Spools"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialFinish", b => + { + b.Navigation("Spools"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialModifier", b => + { + b.Navigation("Spools"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.PrintJob", b => + { + b.Navigation("FilamentUsages"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.Printer", b => + { + b.Navigation("AmsUnits"); + + b.Navigation("FilamentUsages"); + + b.Navigation("PrintJobs"); + }); + + modelBuilder.Entity("Extrudex.Domain.Entities.Spool", b => + { + b.Navigation("AmsSlots"); + + b.Navigation("FilamentUsages"); + + b.Navigation("PrintJobs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.cs b/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.cs new file mode 100644 index 0000000..0adfc7b --- /dev/null +++ b/backend/Infrastructure/Data/Migrations/20260426183433_AddFilamentUsageTrackingModel.cs @@ -0,0 +1,533 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Extrudex.Infrastructure.Data.Migrations +{ + /// + public partial class AddFilamentUsageTrackingModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "filament_usages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + print_job_id = table.Column(type: "uuid", nullable: false), + spool_id = table.Column(type: "uuid", nullable: false), + printer_id = table.Column(type: "uuid", nullable: false), + grams_used = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false), + mm_extruded = table.Column(type: "numeric(12,2)", precision: 12, scale: 2, nullable: false), + recorded_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + notes = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'") + }, + constraints: table => + { + table.PrimaryKey("PK_filament_usages", x => x.id); + table.ForeignKey( + name: "fk_filament_usages_print_job", + column: x => x.print_job_id, + principalTable: "print_jobs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_filament_usages_printer", + column: x => x.printer_id, + principalTable: "printers", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_filament_usages_spool", + column: x => x.spool_id, + principalTable: "spools", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9901), new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9902) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000007"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000008"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000009"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(297), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(298) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000010"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000011"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000012"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(316), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(317) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000013"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000014"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000015"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(805), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(806) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000007"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000008"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000009"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000010"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(859), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(860) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000011"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866), new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866) }); + + migrationBuilder.CreateIndex( + name: "ix_filament_usages_print_job_id", + table: "filament_usages", + column: "print_job_id"); + + migrationBuilder.CreateIndex( + name: "ix_filament_usages_printer_id", + table: "filament_usages", + column: "printer_id"); + + migrationBuilder.CreateIndex( + name: "ix_filament_usages_recorded_at", + table: "filament_usages", + column: "recorded_at"); + + migrationBuilder.CreateIndex( + name: "ix_filament_usages_spool_id", + table: "filament_usages", + column: "spool_id"); + + migrationBuilder.CreateIndex( + name: "ix_filament_usages_spool_id_recorded_at", + table: "filament_usages", + columns: new[] { "spool_id", "recorded_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "filament_usages"); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645) }); + + migrationBuilder.UpdateData( + table: "material_bases", + keyColumn: "id", + keyValue: new Guid("10000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1651), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1652) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2055), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2056) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000007"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000008"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000009"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000010"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000011"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000012"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000013"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000014"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2132), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2133) }); + + migrationBuilder.UpdateData( + table: "material_finishes", + keyColumn: "id", + keyValue: new Guid("20000000-0000-0000-0000-000000000015"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000001"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000002"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000003"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000004"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2477), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2478) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000005"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000006"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2490), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2491) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000007"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000008"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000009"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000010"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516) }); + + migrationBuilder.UpdateData( + table: "material_modifiers", + keyColumn: "id", + keyValue: new Guid("30000000-0000-0000-0000-000000000011"), + columns: new[] { "created_at", "updated_at" }, + values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2522), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2523) }); + } + } +} diff --git a/backend/Infrastructure/Data/Migrations/ExtrudexDbContextModelSnapshot.cs b/backend/Infrastructure/Data/Migrations/ExtrudexDbContextModelSnapshot.cs index 64647c5..f214506 100644 --- a/backend/Infrastructure/Data/Migrations/ExtrudexDbContextModelSnapshot.cs +++ b/backend/Infrastructure/Data/Migrations/ExtrudexDbContextModelSnapshot.cs @@ -104,6 +104,77 @@ namespace Extrudex.Infrastructure.Data.Migrations b.ToTable("ams_units", (string)null); }); + modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("GramsUsed") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("grams_used"); + + b.Property("MmExtruded") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("mm_extruded"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("PrintJobId") + .HasColumnType("uuid") + .HasColumnName("print_job_id"); + + b.Property("PrinterId") + .HasColumnType("uuid") + .HasColumnName("printer_id"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("SpoolId") + .HasColumnType("uuid") + .HasColumnName("spool_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Id"); + + b.HasIndex("PrintJobId") + .HasDatabaseName("ix_filament_usages_print_job_id"); + + b.HasIndex("PrinterId") + .HasDatabaseName("ix_filament_usages_printer_id"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_filament_usages_recorded_at"); + + b.HasIndex("SpoolId") + .HasDatabaseName("ix_filament_usages_spool_id"); + + b.HasIndex("SpoolId", "RecordedAt") + .HasDatabaseName("ix_filament_usages_spool_id_recorded_at"); + + b.ToTable("filament_usages", (string)null); + }); + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialBase", b => { b.Property("Id") @@ -145,50 +216,50 @@ namespace Extrudex.Infrastructure.Data.Migrations new { Id = new Guid("10000000-0000-0000-0000-000000000001"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388), DensityGperCm3 = 1.24m, Name = "PLA", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388) }, new { Id = new Guid("10000000-0000-0000-0000-000000000002"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871), DensityGperCm3 = 1.27m, Name = "PETG", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871) }, new { Id = new Guid("10000000-0000-0000-0000-000000000003"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881), DensityGperCm3 = 1.04m, Name = "ABS", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881) }, new { Id = new Guid("10000000-0000-0000-0000-000000000004"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888), DensityGperCm3 = 1.07m, Name = "ASA", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888) }, new { Id = new Guid("10000000-0000-0000-0000-000000000005"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895), DensityGperCm3 = 1.21m, Name = "TPU", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895) }, new { Id = new Guid("10000000-0000-0000-0000-000000000006"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1651), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9901), DensityGperCm3 = 1.14m, Name = "Nylon", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1652) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9902) }); }); @@ -232,122 +303,122 @@ namespace Extrudex.Infrastructure.Data.Migrations new { Id = new Guid("20000000-0000-0000-0000-000000000001"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90) }, new { Id = new Guid("20000000-0000-0000-0000-000000000002"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Matte", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251) }, new { Id = new Guid("20000000-0000-0000-0000-000000000003"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Silk", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259) }, new { Id = new Guid("20000000-0000-0000-0000-000000000004"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2055), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Glitter", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2056) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266) }, new { Id = new Guid("20000000-0000-0000-0000-000000000005"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Marble", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272) }, new { Id = new Guid("20000000-0000-0000-0000-000000000006"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Sparkle", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278) }, new { Id = new Guid("20000000-0000-0000-0000-000000000007"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285) }, new { Id = new Guid("20000000-0000-0000-0000-000000000008"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), Name = "Matte", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291) }, new { Id = new Guid("20000000-0000-0000-0000-000000000009"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(297), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), Name = "Silk", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(298) }, new { Id = new Guid("20000000-0000-0000-0000-000000000010"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304) }, new { Id = new Guid("20000000-0000-0000-0000-000000000011"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), Name = "Matte", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310) }, new { Id = new Guid("20000000-0000-0000-0000-000000000012"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(316), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(317) }, new { Id = new Guid("20000000-0000-0000-0000-000000000013"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), Name = "Matte", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323) }, new { Id = new Guid("20000000-0000-0000-0000-000000000014"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2132), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000005"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2133) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329) }, new { Id = new Guid("20000000-0000-0000-0000-000000000015"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), Name = "Basic", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336) }); }); @@ -391,90 +462,90 @@ namespace Extrudex.Infrastructure.Data.Migrations new { Id = new Guid("30000000-0000-0000-0000-000000000001"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Carbon Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482) }, new { Id = new Guid("30000000-0000-0000-0000-000000000002"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(805), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Glass Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(806) }, new { Id = new Guid("30000000-0000-0000-0000-000000000003"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Wood Fill", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815) }, new { Id = new Guid("30000000-0000-0000-0000-000000000004"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2477), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"), Name = "Glow-in-the-Dark", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2478) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821) }, new { Id = new Guid("30000000-0000-0000-0000-000000000005"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), Name = "Carbon Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828) }, new { Id = new Guid("30000000-0000-0000-0000-000000000006"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2490), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"), Name = "Glass Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2491) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834) }, new { Id = new Guid("30000000-0000-0000-0000-000000000007"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), Name = "Carbon Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840) }, new { Id = new Guid("30000000-0000-0000-0000-000000000008"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"), Name = "Glass Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847) }, new { Id = new Guid("30000000-0000-0000-0000-000000000009"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"), Name = "Carbon Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853) }, new { Id = new Guid("30000000-0000-0000-0000-000000000010"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(859), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), Name = "Carbon Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(860) }, new { Id = new Guid("30000000-0000-0000-0000-000000000011"), - CreatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2522), + CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866), MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"), Name = "Glass Fiber", - UpdatedAt = new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2523) + UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866) }); }); @@ -838,6 +909,36 @@ namespace Extrudex.Infrastructure.Data.Migrations b.Navigation("Printer"); }); + modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b => + { + b.HasOne("Extrudex.Domain.Entities.PrintJob", "PrintJob") + .WithMany("FilamentUsages") + .HasForeignKey("PrintJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_filament_usages_print_job"); + + b.HasOne("Extrudex.Domain.Entities.Printer", "Printer") + .WithMany("FilamentUsages") + .HasForeignKey("PrinterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_filament_usages_printer"); + + b.HasOne("Extrudex.Domain.Entities.Spool", "Spool") + .WithMany("FilamentUsages") + .HasForeignKey("SpoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_filament_usages_spool"); + + b.Navigation("PrintJob"); + + b.Navigation("Printer"); + + b.Navigation("Spool"); + }); + modelBuilder.Entity("Extrudex.Domain.Entities.MaterialFinish", b => { b.HasOne("Extrudex.Domain.Entities.MaterialBase", "MaterialBase") @@ -936,10 +1037,17 @@ namespace Extrudex.Infrastructure.Data.Migrations b.Navigation("Spools"); }); + modelBuilder.Entity("Extrudex.Domain.Entities.PrintJob", b => + { + b.Navigation("FilamentUsages"); + }); + modelBuilder.Entity("Extrudex.Domain.Entities.Printer", b => { b.Navigation("AmsUnits"); + b.Navigation("FilamentUsages"); + b.Navigation("PrintJobs"); }); @@ -947,6 +1055,8 @@ namespace Extrudex.Infrastructure.Data.Migrations { b.Navigation("AmsSlots"); + b.Navigation("FilamentUsages"); + b.Navigation("PrintJobs"); }); #pragma warning restore 612, 618 diff --git a/backend/Infrastructure/Services/CostPerPrintService.cs b/backend/Infrastructure/Services/CostPerPrintService.cs new file mode 100644 index 0000000..e4b2593 --- /dev/null +++ b/backend/Infrastructure/Services/CostPerPrintService.cs @@ -0,0 +1,158 @@ +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// Calculates the cost of goods sold (COGS) per print job using the spool's +/// purchase price and the print job's derived grams consumed. +/// +/// Formula: +/// cost_per_gram = purchase_price / weight_total_grams +/// cost_per_print = grams_derived × cost_per_gram +/// +/// Handles missing data gracefully — if the spool has no purchase price or +/// weight recorded, the result includes warnings and null cost fields +/// instead of throwing exceptions. +/// +public class CostPerPrintService : ICostPerPrintService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public CostPerPrintService(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + public async Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Calculating cost per print for job {PrintJobId}", printJobId); + + var job = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .FirstOrDefaultAsync(j => j.Id == printJobId, cancellationToken); + + if (job is null) + { + _logger.LogWarning("Print job {PrintJobId} not found for cost calculation", printJobId); + return new CostPerPrintResult + { + PrintJobId = printJobId, + Warnings = new List { $"Print job with ID '{printJobId}' not found." } + }; + } + + return BuildResult(job); + } + + /// + public async Task> CalculateBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Calculating cost per print for all jobs on spool {SpoolId}", spoolId); + + var jobs = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .Where(j => j.SpoolId == spoolId) + .OrderByDescending(j => j.CreatedAt) + .ToListAsync(cancellationToken); + + if (jobs.Count == 0) + { + _logger.LogDebug("No print jobs found for spool {SpoolId}", spoolId); + return Array.Empty(); + } + + return jobs.Select(BuildResult).ToList(); + } + + /// + /// Builds a from a print job entity. + /// Computes cost_per_gram and cost_per_print when all required data is available. + /// Populates warnings when data is missing or incomplete. + /// + /// The print job entity with Spool navigation loaded. + /// A cost calculation result with breakdown and any warnings. + private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job) + { + var warnings = new List(); + var spool = job.Spool; + + // Map what we always have + var result = new CostPerPrintResult + { + PrintJobId = job.Id, + PrintName = job.PrintName, + SpoolId = job.SpoolId, + SpoolSerial = spool?.SpoolSerial ?? string.Empty, + MmExtruded = job.MmExtruded, + GramsDerived = job.GramsDerived, + }; + + // Guard: spool must be loaded + if (spool is null) + { + warnings.Add("Spool data is not available for this print job."); + result.Warnings = warnings; + return result; + } + + // Capture purchase price + result.PurchasePrice = spool.PurchasePrice; + result.WeightTotalGrams = spool.WeightTotalGrams; + + // Check for missing purchase price + if (!spool.PurchasePrice.HasValue) + { + warnings.Add( + "Spool purchase price is not recorded. Cost calculation requires a purchase price on the spool."); + } + + // Check for zero or negative weight — prevents division by zero + if (spool.WeightTotalGrams <= 0) + { + warnings.Add( + "Spool total weight is zero or not recorded. Cost calculation requires a positive weight_total_grams on the spool."); + } + + // Check for zero grams derived + if (job.GramsDerived <= 0) + { + warnings.Add( + "Derived grams consumed is zero. Ensure mm_extruded, filament diameter, and material density are recorded for this print job."); + } + + // If all data is present and valid, compute the cost + if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0 && job.GramsDerived > 0) + { + var costPerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; + result.CostPerGram = Math.Round(costPerGram, 6); + result.CostPerPrint = Math.Round(job.GramsDerived * costPerGram, 4); + + _logger.LogDebug( + "Cost calculated for job {PrintJobId}: {GramsDerived}g × {CostPerGram:C}/g = {CostPerPrint:C}", + job.Id, job.GramsDerived, result.CostPerGram, result.CostPerPrint); + } + else + { + _logger.LogDebug( + "Cost calculation incomplete for job {PrintJobId}: missing data (warnings: {WarningCount})", + job.Id, warnings.Count); + } + + result.Warnings = warnings; + return result; + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs new file mode 100644 index 0000000..c2e305b --- /dev/null +++ b/backend/Infrastructure/Services/FilamentUsageSyncService.cs @@ -0,0 +1,139 @@ +using Extrudex.Domain.Enums; +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Configuration; + +/// +/// Service that syncs filament usage data from Moonraker printers into the +/// Extrudex database. Queries all active Moonraker printers, fetches their +/// current filament usage metrics, and updates spool remaining weights and +/// print job records. +/// +public class FilamentUsageSyncService : IFilamentUsageSyncService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly IMoonrakerClient _moonrakerClient; + private readonly ILogger _logger; + + /// + /// Creates a new FilamentUsageSyncService. + /// + /// The EF Core database context for persisting updates. + /// The Moonraker HTTP client for fetching printer data. + /// Logger for diagnostic output. + public FilamentUsageSyncService( + ExtrudexDbContext dbContext, + IMoonrakerClient moonrakerClient, + ILogger logger) + { + _dbContext = dbContext; + _moonrakerClient = moonrakerClient; + _logger = logger; + } + + /// + public async Task SyncAllAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Starting filament usage sync cycle"); + + var printers = await _dbContext.Printers + .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) + .Include(p => p.AmsUnits) + .ThenInclude(u => u.Slots) + .ThenInclude(s => s.Spool) + .ToListAsync(cancellationToken); + + if (printers.Count == 0) + { + _logger.LogInformation("No active Moonraker printers found — skipping sync"); + return 0; + } + + _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count); + + var syncedCount = 0; + + foreach (var printer in printers) + { + try + { + var usageData = await _moonrakerClient.GetFilamentUsageAsync( + printer.HostnameOrIp, + printer.Port, + printer.ApiKey, + cancellationToken); + + if (usageData.Count == 0) + { + _logger.LogWarning( + "No usage data returned from printer {PrinterName} ({Host}:{Port})", + printer.Name, printer.HostnameOrIp, printer.Port); + continue; + } + + // Update spool remaining weights from AMS data + UpdateSpoolWeights(printer, usageData); + + // Mark printer as seen and idle (reachable = idle, not printing) + printer.LastSeenAt = DateTime.UtcNow; + printer.Status = PrinterStatus.Idle; + + syncedCount++; + _logger.LogInformation( + "Successfully synced filament usage from printer {PrinterName}", + printer.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error syncing filament usage from printer {PrinterName} ({Host}:{Port})", + printer.Name, printer.HostnameOrIp, printer.Port); + } + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced", + syncedCount, printers.Count); + + return syncedCount; + } + + /// + /// Updates spool remaining weights based on usage data received from Moonraker. + /// For printers with AMS units, updates the remaining weight on each slot's spool. + /// + private void UpdateSpoolWeights( + Domain.Entities.Printer printer, + Dictionary usageData) + { + // Update AMS slot remaining weights if available + foreach (var amsUnit in printer.AmsUnits) + { + foreach (var slot in amsUnit.Slots) + { + if (slot.Spool != null && slot.RemainingWeightG.HasValue) + { + // Sync the AMS-reported remaining weight to the spool + slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value; + + _logger.LogDebug( + "Updated spool {SpoolSerial} remaining weight to {Weight}g", + slot.Spool.SpoolSerial, slot.RemainingWeightG.Value); + } + } + } + + // If usage data contains extruded mm, log it for observability + if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0) + { + _logger.LogInformation( + "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job", + printer.Name, mmExtruded); + } + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs new file mode 100644 index 0000000..1666dcf --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -0,0 +1,130 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Extrudex.Domain.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Extrudex.Infrastructure.Configuration; + +/// +/// HTTP client for communicating with Moonraker REST API endpoints +/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// Retrieves filament usage data and printer status information. +/// +public class MoonrakerClient : IMoonrakerClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Creates a new MoonrakerClient with the configured HTTP client and logger. + /// + /// The HTTP client for making requests to Moonraker endpoints. + /// Logger for diagnostic output. + public MoonrakerClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task> GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var result = new Dictionary(); + + try + { + // Query Moonraker server info endpoint for filament usage data + using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/history/items?limit=1"); + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); + } + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + // Extract filament usage from the response + // Moonraker returns job history with filament_used and similar fields + if (json.TryGetProperty("result", out var resultElement)) + { + if (resultElement.TryGetProperty("items", out var items) && items.GetArrayLength() > 0) + { + var job = items[0]; + + // Moonraker tracks filament_used in millimeters + if (job.TryGetProperty("filament_used", out var filamentUsed)) + { + result["mm_extruded"] = filamentUsed.GetDecimal(); + } + + // Total duration in seconds + if (job.TryGetProperty("print_duration", out var duration)) + { + result["print_duration_seconds"] = duration.GetDecimal(); + } + } + } + + _logger.LogDebug( + "Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics", + hostnameOrIp, port, result.Count); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker response from {Host}:{Port}", + hostnameOrIp, port); + } + + return result; + } + + /// + public async Task IsReachableAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info"); + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); + } + + using var response = await _httpClient.SendAsync(request, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); + return false; + } + } + + /// + /// Builds the base URL for Moonraker API calls from hostname and port. + /// + private static string BuildBaseUrl(string hostnameOrIp, int port) + { + return $"http://{hostnameOrIp}:{port}"; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index 46780df..c2bdcc9 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,7 +1,9 @@ using System.Reflection; using Extrudex.API.Filters; using Extrudex.API.Hubs; +using Extrudex.API.Jobs; using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Configuration; using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Services; using FluentValidation; @@ -50,8 +52,8 @@ builder.Services.AddSwaggerGen(c => // ── QR Code Generation ────────────────────────────────────── builder.Services.AddSingleton(); -// ── Low Stock Detection ──────────────────────────────────── -builder.Services.AddSingleton(); +// ── Cost Per Print Calculation ───────────────────────────── +builder.Services.AddScoped(); // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. @@ -80,6 +82,16 @@ builder.Services.AddCors(options => // ── SignalR (real-time printer updates) ──────────────────── builder.Services.AddSignalR(); +// ── Filament Usage Sync (Background Job) ────────────────── +builder.Services.Configure( + builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName)); +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0"); +}); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // ── Health Checks ─────────────────────────────────────────── builder.Services.AddHealthChecks() .AddNpgSql(connectionString); diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 8edfdd9..06130e3 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -8,5 +8,10 @@ }, "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme" + }, + "FilamentUsageSync": { + "PollingInterval": "00:01:00", + "RequestTimeout": "00:00:30", + "Enabled": true } } \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index 6790af0..e5c747f 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -10,7 +10,9 @@ "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" }, - "FilamentAlerts": { - "LowStockThresholdPercent": 20 + "FilamentUsageSync": { + "PollingInterval": "00:05:00", + "RequestTimeout": "00:00:30", + "Enabled": true } } \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index d17960c..d00c1e6 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,13 +18,14 @@ echo "📦 Building and starting services..." $COMPOSE_CMD -f docker-compose.dev.yml up -d --build echo "⏳ Waiting for services to become healthy..." -sleep 10 +sleep 15 echo "✅ Deployment complete!" echo "" echo "Services running:" +echo " • PostgreSQL: localhost:5433" echo " • Extrudex API: http://localhost:5080" -echo " • Control Center Web: http://localhost:5081" +echo " • Extrudex Web: http://localhost:5081" echo "" echo "To view logs:" echo " $COMPOSE_CMD -f docker-compose.dev.yml logs -f" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2859dff..a0a3d49 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,6 +1,25 @@ -version: '3.8' - services: + extrudex-db: + image: postgres:16-alpine + container_name: extrudex-db + environment: + POSTGRES_USER: extrudex + POSTGRES_PASSWORD: changeme + POSTGRES_DB: extrudex + ports: + - "5433:5432" + volumes: + - extrudex-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U extrudex"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + networks: + - extrudex-network + extrudex-api: build: context: ./backend @@ -11,6 +30,14 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 + - EXTRUDEX_DB_HOST=extrudex-db + - EXTRUDEX_DB_PORT=5432 + - EXTRUDEX_DB_NAME=extrudex + - EXTRUDEX_DB_USER=extrudex + - EXTRUDEX_DB_PASSWORD=changeme + depends_on: + extrudex-db: + condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] @@ -21,11 +48,11 @@ services: networks: - extrudex-network - control-center-web: + extrudex-web: build: - context: ../Control-Center/frontend + context: ./frontend dockerfile: Dockerfile - container_name: control-center-web + container_name: extrudex-web ports: - "5081:80" depends_on: @@ -35,6 +62,9 @@ services: networks: - extrudex-network +volumes: + extrudex-db-data: + networks: extrudex-network: driver: bridge \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6da98e8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.angular +.vscode +*.md +.editorconfig +.prettierrc +src/test.ts +**/*.spec.ts \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4aa493c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +# Stage 1: Build the Angular application +FROM node:22-alpine AS build + +WORKDIR /app + +# Copy package files first for better layer caching +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npx ng build --configuration production + +# Stage 2: Serve static files with nginx +FROM nginx:alpine + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built Angular artifacts from build stage +COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..64b7fa0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 256; + + # Angular SPA — fallback to index.html for client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Proxy API requests to backend + # Uses resolver so nginx doesn't crash if backend isn't available at startup + resolver 127.0.0.11 valid=30s ipv6=off; + set $backend "extrudex-api:8080"; + + location /api/ { + proxy_pass http://$backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "ok"; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.html b/frontend/src/app/components/filament-filter/filament-filter.component.html new file mode 100644 index 0000000..fd93087 --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.html @@ -0,0 +1,76 @@ + + \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.scss b/frontend/src/app/components/filament-filter/filament-filter.component.scss new file mode 100644 index 0000000..8ebbaaf --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.scss @@ -0,0 +1,134 @@ +/** + * Filament Filter Bar Styles + * Responsive filter layout for kiosk and mobile + */ + +$spacing-unit: 8px; + +.filament-filter-bar { + display: flex; + align-items: center; + gap: $spacing-unit * 2; + flex-wrap: wrap; + padding: $spacing-unit * 2 0; + margin-bottom: $spacing-unit * 2; +} + +// Form field sizing +.filter-field { + flex: 0 1 auto; + min-width: 160px; + + &.material-filter { + min-width: 200px; + } + + &.color-filter { + min-width: 180px; + } + + // Reduce vertical spacing inside filter fields + .mat-mdc-form-field-subscript-wrapper { + display: none; // No hint/error text needed for filters + } +} + +// Selected material chips +.selected-chips { + flex-wrap: wrap; + gap: 4px; +} + +.filter-chip { + font-size: 12px !important; + min-height: 24px !important; + + mat-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + } +} + +// Active filter icon +.filter-active-icon { + color: var(--mat-sys-primary); + font-size: 18px !important; + width: 18px !important; + height: 18px !important; +} + +// Checkbox styling +.filter-checkbox { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + user-select: none; + touch-action: manipulation; // Prevent zoom on double-tap + + .checkbox-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: var(--mat-sys-on-surface-variant); + transition: color 0.2s ease; + + &.active { + color: var(--mat-sys-primary); + } + } +} + +// Clear filters button +.clear-filters-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } +} + +// Responsive: stack filters vertically on small screens +@media (max-width: 768px) { + .filament-filter-bar { + flex-direction: column; + align-items: stretch; + gap: $spacing-unit; + } + + .filter-field { + width: 100%; + min-width: unset; + + &.material-filter, + &.color-filter { + min-width: unset; + } + } + + .filter-checkbox { + padding: $spacing-unit 0; + } + + .clear-filters-btn { + align-self: flex-start; + } +} + +// Extra-small screens (phone portrait) +@media (max-width: 480px) { + .filament-filter-bar { + padding: $spacing-unit 0; + margin-bottom: $spacing-unit; + } + + .filter-checkbox { + font-size: 13px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.ts b/frontend/src/app/components/filament-filter/filament-filter.component.ts new file mode 100644 index 0000000..7559afc --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.ts @@ -0,0 +1,158 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + computed, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + Filament, + StockLevel, + classifyStockLevel, +} from '../../models/filament.model'; + +/** Filter state emitted by the filament filter component */ +export interface FilamentFilterState { + /** Selected material base names — empty means all */ + materialBaseNames: string[]; + + /** Color search text — empty string means all */ + colorSearch: string; + + /** Whether to show only low/critical stock */ + lowStockOnly: boolean; + + /** Whether to show only active spools */ + activeOnly: boolean; +} + +/** + * FilamentFilterComponent — Filter bar for the filament inventory list. + * + * Provides: + * - Material type multi-select filter + * - Color name text search + * - Low stock toggle (shows only critical/low spools) + * - Active-only toggle + * - Clear all filters action + */ +@Component({ + selector: 'app-filament-filter', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatCheckboxModule, + MatIconModule, + MatChipsModule, + MatButtonModule, + MatTooltipModule, + ], + templateUrl: './filament-filter.component.html', + styleUrl: './filament-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilamentFilterComponent { + /** Filament data input — used to derive material options */ + @Input() set filaments(value: Filament[]) { + this._filaments.set(value); + const materials = [...new Set(value.map((f) => f.materialBaseName))].sort(); + this.materialOptions.set(materials); + } + get filaments(): Filament[] { + return this._filaments(); + } + private readonly _filaments = signal([]); + + /** Available material base names derived from filament data */ + readonly materialOptions = signal([]); + + /** Selected material base names */ + readonly selectedMaterials = signal([]); + + /** Color search text */ + readonly colorSearch = signal(''); + + /** Low stock only toggle */ + readonly lowStockOnly = signal(false); + + /** Active only toggle */ + readonly activeOnly = signal(false); + + /** Computed: whether any filters are active */ + readonly hasActiveFilters = computed( + () => + this.selectedMaterials().length > 0 || + this.colorSearch().trim().length > 0 || + this.lowStockOnly() || + this.activeOnly() + ); + + /** Emits the current filter state whenever filters change */ + @Output() readonly filterChange = new EventEmitter(); + + /** Handle material selection change */ + onMaterialChange(selected: string[]): void { + this.selectedMaterials.set(selected); + this.emitFilterState(); + } + + /** Handle color search input */ + onColorSearchChange(value: string): void { + this.colorSearch.set(value); + this.emitFilterState(); + } + + /** Handle low stock toggle */ + onLowStockToggle(checked: boolean): void { + this.lowStockOnly.set(checked); + this.emitFilterState(); + } + + /** Handle active-only toggle */ + onActiveOnlyToggle(checked: boolean): void { + this.activeOnly.set(checked); + this.emitFilterState(); + } + + /** Remove a single material chip */ + removeMaterial(material: string): void { + const updated = this.selectedMaterials().filter((m) => m !== material); + this.selectedMaterials.set(updated); + this.emitFilterState(); + } + + /** Clear all filters */ + clearAll(): void { + this.selectedMaterials.set([]); + this.colorSearch.set(''); + this.lowStockOnly.set(false); + this.activeOnly.set(false); + this.emitFilterState(); + } + + /** Emit the current filter state */ + private emitFilterState(): void { + this.filterChange.emit({ + materialBaseNames: this.selectedMaterials(), + colorSearch: this.colorSearch().trim().toLowerCase(), + lowStockOnly: this.lowStockOnly(), + activeOnly: this.activeOnly(), + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 75d798f..5beccd4 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,6 +1,12 @@ - +
+ + + @if (criticalCount() > 0) {