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..5c244c3 100644
--- a/backend/API/Controllers/PrintJobsController.cs
+++ b/backend/API/Controllers/PrintJobsController.cs
@@ -2,6 +2,7 @@ using Extrudex.API.DTOs;
using Extrudex.API.DTOs.PrintJobs;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
+using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -19,16 +20,22 @@ namespace Extrudex.API.Controllers;
public class PrintJobsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
+ private readonly ICostPerPrintService _costService;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// The database context for data access.
+ /// The cost-per-print calculation service.
/// The logger for diagnostic output.
- public PrintJobsController(ExtrudexDbContext dbContext, ILogger logger)
+ public PrintJobsController(
+ ExtrudexDbContext dbContext,
+ ICostPerPrintService costService,
+ ILogger logger)
{
_dbContext = dbContext;
+ _costService = costService;
_logger = logger;
}
@@ -413,6 +420,34 @@ public class PrintJobsController : ControllerBase
return NoContent();
}
+ // ── POST /api/printjobs/{id}/cost ─────────────────────────────
+
+ ///
+ /// Calculates the cost of goods sold (COGS) for a specific print job.
+ /// Uses the spool’s purchase price and the print job’s derived grams consumed
+ /// to produce a cost breakdown. Returns warnings instead of errors when
+ /// cost data is missing or incomplete.
+ ///
+ /// The unique identifier of the print job.
+ /// A cost breakdown with warnings if data is incomplete.
+ /// Returns the cost breakdown for the print job.
+ /// If the print job with the given ID is not found.
+ [HttpPost("{id:guid}/cost")]
+ [ProducesResponseType(typeof(CostPerPrintResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> CalculateCost(Guid id)
+ {
+ _logger.LogDebug("Calculating cost for print job {Id}", id);
+
+ var result = await _costService.CalculateAsync(id);
+
+ // If the job was not found, return 404
+ if (result.Warnings.Any(w => w.Contains("not found")))
+ return NotFound(new { error = $"Print job with ID '{id}' not found." });
+
+ return Ok(MapCostToResponse(result));
+ }
+
// ── Gram Derivation Formula ────────────────────────────────────
///
@@ -509,4 +544,22 @@ public class PrintJobsController : ControllerBase
CreatedAt = j.CreatedAt,
UpdatedAt = j.UpdatedAt
};
+
+ ///
+ /// 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/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/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/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/Program.cs b/backend/Program.cs
index adcb0d6..8a17991 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -50,6 +50,9 @@ builder.Services.AddSwaggerGen(c =>
// ── QR Code Generation ──────────────────────────────────────
builder.Services.AddSingleton();
+// ── Cost Per Print Calculation ─────────────────────────────
+builder.Services.AddScoped();
+
// ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());