Merge branch 'dev' into agent/dex/CUB-39-filament-usage-sync
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m3s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 4s

This commit is contained in:
2026-04-27 17:25:58 -04:00
12 changed files with 1030 additions and 3 deletions

View File

@@ -0,0 +1,158 @@
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// 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.
/// </summary>
public class CostPerPrintService : ICostPerPrintService
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<CostPerPrintService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CostPerPrintService"/> class.
/// </summary>
/// <param name="dbContext">The database context for data access.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public CostPerPrintService(ExtrudexDbContext dbContext, ILogger<CostPerPrintService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<CostPerPrintResult> 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<string> { $"Print job with ID '{printJobId}' not found." }
};
}
return BuildResult(job);
}
/// <inheritdoc />
public async Task<IReadOnlyList<CostPerPrintResult>> 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<CostPerPrintResult>();
}
return jobs.Select(BuildResult).ToList();
}
/// <summary>
/// Builds a <see cref="CostPerPrintResult"/> 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.
/// </summary>
/// <param name="job">The print job entity with Spool navigation loaded.</param>
/// <returns>A cost calculation result with breakdown and any warnings.</returns>
private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job)
{
var warnings = new List<string>();
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;
}
}