158 lines
5.8 KiB
C#
158 lines
5.8 KiB
C#
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|