Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so the Details page reads stored data rather than re-running the pricing engine - Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts, SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot - Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr) - Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads - Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths - Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson) - Fix Notes and CatalogItemId not copied to InvoiceItem - Add 14 unit tests in PricingStageFlowTests covering all three pricing stages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
});
|
||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
|
||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Oven Processing Fee",
|
||||
Description = "Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||
if (facilityOverhead > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Facility Overhead",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||
if (rushFee > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Rush Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(rushFee, 2),
|
||||
TotalPrice = Math.Round(rushFee, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user