Merge remote-tracking branch 'origin/dev' into fix-pr-17
# Conflicts: # backend/Program.cs # backend/appsettings.json
This commit is contained in:
108
backend/API/Controllers/CostAnalysisController.cs
Normal file
108
backend/API/Controllers/CostAnalysisController.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Extrudex.API.DTOs.PrintJobs;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for cost analysis endpoints. Provides spool-level
|
||||
/// cost breakdowns and aggregated COGS reporting.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/cost-analysis")]
|
||||
public class CostAnalysisController : ControllerBase
|
||||
{
|
||||
private readonly ICostPerPrintService _costService;
|
||||
private readonly ILogger<CostAnalysisController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CostAnalysisController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="costService">The cost-per-print calculation service.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public CostAnalysisController(
|
||||
ICostPerPrintService costService,
|
||||
ILogger<CostAnalysisController> logger)
|
||||
{
|
||||
_costService = costService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── POST /api/cost-analysis/spool ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="request">The request containing the spool identifier.</param>
|
||||
/// <returns>A spool-level cost summary with per-job breakdowns.</returns>
|
||||
/// <response code="200">Returns the spool cost breakdown with per-job details.</response>
|
||||
/// <response code="404">If the spool has no print jobs.</response>
|
||||
[HttpPost("spool")]
|
||||
[ProducesResponseType(typeof(SpoolCostResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SpoolCostResponse>> 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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -413,6 +413,92 @@ public class PrintJobsController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── GET /api/printjobs/{id}/cost-summary ──────────────────────────
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the print job.</param>
|
||||
/// <returns>A cost summary with breakdown and any warnings about missing data.</returns>
|
||||
/// <response code="200">Returns the cost summary. Warnings field lists any missing data.</response>
|
||||
/// <response code="404">If the print job with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}/cost-summary")]
|
||||
[ProducesResponseType(typeof(CostSummaryResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CostSummaryResponse>> 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<string>();
|
||||
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 ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user