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:
2026-05-15 15:03:06 -04:00
parent 226a6237a6
commit 6721de91e4
13 changed files with 11657 additions and 128 deletions
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
public decimal SubtotalBeforeDiscount { get; set; } public decimal SubtotalBeforeDiscount { get; set; }
public decimal PricingTierDiscountAmount { get; set; }
public decimal PricingTierDiscountPercent { get; set; }
public decimal QuoteDiscountAmount { get; set; }
public decimal QuoteDiscountPercent { get; set; }
public decimal DiscountAmount { get; set; } public decimal DiscountAmount { get; set; }
public decimal DiscountPercent { get; set; } public decimal DiscountPercent { get; set; }
@@ -27,7 +27,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostOverride = source.PowderCostOverride, PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice, UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice, TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m, LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting, RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking, RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes, EstimatedMinutes = source.EstimatedMinutes,
@@ -113,7 +113,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostOverride = source.PowderCostOverride, PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice, UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice, TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m, LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting, RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking, RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes, EstimatedMinutes = source.EstimatedMinutes,
@@ -35,6 +35,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.EquipmentCosts = pricingResult.EquipmentCosts; quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal; quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost; quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount; quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent; quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts; quote.OverheadAmount = pricingResult.OverheadCosts;
@@ -42,8 +44,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.ProfitMargin = pricingResult.ProfitMargin; quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent; quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount; quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
quote.DiscountPercent = pricingResult.DiscountPercent; quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount; quote.DiscountAmount = pricingResult.DiscountAmount;
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
quote.RushFee = pricingResult.RushFee; quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount; quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total; quote.Total = pricingResult.Total;
+4
View File
@@ -66,6 +66,10 @@ public class Job : BaseEntity
// Used to detect when the quote was subsequently edited so the job details page can warn the user. // Used to detect when the quote was subsequently edited so the job details page can warn the user.
public DateTime? QuoteSnapshotUpdatedAt { get; set; } public DateTime? QuoteSnapshotUpdatedAt { get; set; }
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
// the breakdown that was actually calculated, not a re-run against current operating costs.
public string? PricingBreakdownJson { get; set; }
// Rework tracking // Rework tracking
public bool IsReworkJob { get; set; } public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework public int? OriginalJobId { get; set; } // Set when this job was created as a rework
+14 -7
View File
@@ -45,21 +45,28 @@ public class Quote : BaseEntity
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
public decimal OverheadPercent { get; set; } // Overhead percentage used public decimal OverheadPercent { get; set; } // Legacy overhead percent
public decimal ProfitMargin { get; set; } // Profit margin dollar amount public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
public decimal ProfitPercent { get; set; } // Profit margin percentage used public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies) public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
// Discount Information // Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None; public DiscountType DiscountType { get; set; } = DiscountType.None;
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount) public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied public string? DiscountReason { get; set; } // Why discount was applied
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
public decimal TaxPercent { get; set; } public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; } public decimal TaxAmount { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobPricingSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PricingBreakdownJson",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PricingBreakdownJson",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
}
}
}
@@ -4217,6 +4217,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("OvenCycleMinutes") b.Property<int?>("OvenCycleMinutes")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -6711,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464), CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6722,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473), CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6733,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474), CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -1,9 +1,11 @@
using System.Text.Json;
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
dto.InvoiceItems.Add(new CreateInvoiceItemDto dto.InvoiceItems.Add(new CreateInvoiceItemDto
{ {
SourceJobItemId = item.Id, SourceJobItemId = item.Id,
CatalogItemId = item.CatalogItemId,
Description = item.Description ?? "Powder Coating", Description = item.Description ?? "Powder Coating",
Quantity = item.Quantity > 0 ? item.Quantity : 1, Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.UnitPrice, UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice, TotalPrice = item.TotalPrice,
ColorName = item.ColorName, ColorName = item.ColorName,
Notes = item.Notes,
DisplayOrder = order++, DisplayOrder = order++,
RevenueAccountId = revenueAccountId 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. // because FinalPrice is recalculated on every item edit and can drift from the original quote.
if (sourceQuote != null) 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 var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount + sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee; + sourceQuote.RushFee;
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
} }
else if (hadJobItems) else if (hadJobItems)
{ {
// Direct job — no source quote. Use the stored job-level fees rather than // Direct job — no source quote. Read all charges from the pricing snapshot so the
// recalculating, so the invoice always matches the total shown on the job page. // invoice always matches the total shown on the job's Pricing Summary card.
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with QuotePricingBreakdownDto? jobBreakdown = null;
// OvenCostId) when job items are created or updated. if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m) if (job.OvenBatchCost > 0.01m)
{ {
dto.InvoiceItems.Add(new CreateInvoiceItemDto dto.InvoiceItems.Add(new CreateInvoiceItemDto
{ {
Description = $"Oven Processing Fee", Description = "Oven Processing Fee",
Quantity = 1, Quantity = 1,
UnitPrice = Math.Round(job.OvenBatchCost, 2), UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = 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) if (job.ShopSuppliesAmount > 0.01m)
{ {
var suppliesDesc = job.ShopSuppliesPercent > 0 var suppliesDesc = job.ShopSuppliesPercent > 0
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
Quantity = 1, Quantity = 1,
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2), UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
TotalPrice = 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, DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id RevenueAccountId = defaultRevenueAccount?.Id
}); });
@@ -424,70 +424,22 @@ public class JobsController : Controller
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m; ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
// Internal pricing breakdown (not printed — mirrors quote details breakdown) // Display the pricing snapshot stored when items were last saved.
var breakdownItems = job.JobItems // Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
.Where(ji => !ji.IsDeleted) if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
.Select(ji => new CreateQuoteItemDto
{ {
Description = ji.Description, ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
Quantity = ji.Quantity, }
SurfaceAreaSqFt = ji.SurfaceAreaSqFt, else if (job.FinalPrice > 0)
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
PowderCostOverride = ji.PowderCostOverride,
IncludePrepCost = ji.IncludePrepCost,
Complexity = ji.Complexity,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{ {
CoverageSqFtPerLb = c.CoverageSqFtPerLb, // Legacy job created before snapshot was introduced — show what we have stored
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes
}).ToList()
}).ToList();
if (breakdownItems.Any())
{
var pr = await _pricingService.CalculateQuoteTotalsAsync(
breakdownItems, job.CompanyId, job.CustomerId,
wizardCosts?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{ {
MaterialCosts = pr.MaterialCosts, OvenBatchCost = job.OvenBatchCost,
LaborCosts = pr.LaborCosts, OvenBatches = job.OvenBatches,
EquipmentCosts = pr.EquipmentCosts, ShopSuppliesAmount = job.ShopSuppliesAmount,
ItemsSubtotal = pr.ItemsSubtotal, ShopSuppliesPercent = job.ShopSuppliesPercent,
OvenBatchCost = pr.OvenBatchCost, Total = job.FinalPrice
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
}; };
} }
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
@@ -1169,15 +1121,23 @@ public class JobsController : Controller
// Recalculate total from wizard items // Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId); var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
if (dto.OvenCostId.HasValue)
{
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
if (createOven != null && createOven.CompanyId == companyId)
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m, createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
@@ -1629,14 +1589,22 @@ public class JobsController : Controller
if (dto.JobItems.Any()) if (dto.JobItems.Any())
{ {
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId); var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
if (job.OvenCostId.HasValue)
{
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (editOven != null && editOven.CompanyId == companyId)
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m, editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
} }
// Save change history records // Save change history records
@@ -3044,15 +3012,24 @@ public class JobsController : Controller
} }
} }
// Calculate full total (overhead, margins, tax) to match what the wizard displays // Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
{
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (oven != null && oven.CompanyId == currentUser.CompanyId)
ovenRateOverride = oven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId, model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes); model.TaxPercent, job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName; job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3108,31 +3085,47 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem, IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
ManualUnitPrice = ji.ManualUnitPrice, ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{ {
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb, CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb PowderCostPerLb = c.PowderCostPerLb
}).ToList() }).ToList()
}).ToList(); }).ToList();
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
if (remainingDtos.Any()) if (remainingDtos.Any())
{ {
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); decimal? deleteOvenRate = null;
if (job.OvenCostId.HasValue)
{
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
deleteOvenRate = deleteOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId, remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null); costs?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
} }
else else
{ {
job.FinalPrice = 0; job.FinalPrice = 0;
job.OvenBatchCost = 0;
job.ShopSuppliesAmount = 0; job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0; job.ShopSuppliesPercent = 0;
job.PricingBreakdownJson = null;
} }
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
@@ -3242,6 +3235,42 @@ public class JobsController : Controller
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}"; return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
} }
/// <summary>
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
/// </summary>
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes,
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
QuoteDiscountAmount = pr.QuoteDiscountAmount,
QuoteDiscountPercent = pr.QuoteDiscountPercent,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
};
#endregion #endregion
#region Item Pricing (AJAX) #region Item Pricing (AJAX)
@@ -1,4 +1,5 @@
using AutoMapper; using System.Text.Json;
using AutoMapper;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -2847,8 +2848,39 @@ public class QuotesController : Controller
JobPriorityId = selectedPriority?.Id ?? 1, JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total, QuotedPrice = quote.Total,
FinalPrice = quote.Total, FinalPrice = quote.Total,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount, ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent, ShopSuppliesPercent = quote.ShopSuppliesPercent,
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
QuoteDiscountAmount = quote.QuoteDiscountAmount,
QuoteDiscountPercent = quote.QuoteDiscountPercent,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
RushFee = quote.RushFee,
TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent,
Total = quote.Total
}),
CustomerPO = quote.CustomerPO, CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true, IsCustomerApproved = true,
@@ -59,7 +59,8 @@ public class JobItemAssemblyServiceTests
var pricing = new QuoteItemPricingResult var pricing = new QuoteItemPricingResult
{ {
UnitPrice = 29.99m, UnitPrice = 29.99m,
TotalPrice = 59.98m TotalPrice = 59.98m,
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
}; };
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc); var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
@@ -0,0 +1,576 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
/// <summary>
/// Verifies that quantities, prices, overrides, and charges move correctly through all three
/// pricing stages: Quote → Job → Invoice. Each test targets one transition or cross-cutting concern.
/// </summary>
public class PricingStageFlowTests
{
// ─── Stage 1: QuotePricingAssemblyService.ApplyPricingSnapshot ───────────────
[Fact]
public void ApplyPricingSnapshot_StoresAllNewBreakdownFields()
{
// FacilityOverheadCost, FacilityOverheadRatePerHour, PricingTierDiscount, QuoteDiscount,
// and SubtotalAfterDiscount were added in a recent migration. Verify they are all stored.
var service = CreateAssemblyService(CreateContext());
var quote = new Quote();
var pricing = new QuotePricingResult
{
FacilityOverheadCost = 12.50m,
FacilityOverheadRatePerHour = 25m,
PricingTierDiscountAmount = 5m,
PricingTierDiscountPercent = 2m,
QuoteDiscountAmount = 10m,
QuoteDiscountPercent = 4m,
DiscountAmount = 15m,
DiscountPercent = 6m,
SubtotalAfterDiscount = 235m,
RushFee = 20m,
TaxAmount = 23.5m,
Total = 278.50m,
SubtotalBeforeDiscount = 250m,
ItemsSubtotal = 200m,
OvenBatchCost = 18m,
ShopSuppliesAmount = 8m,
ShopSuppliesPercent = 4m
};
service.ApplyPricingSnapshot(quote, pricing);
Assert.Equal(12.50m, quote.FacilityOverheadCost, precision: 2);
Assert.Equal(25m, quote.FacilityOverheadRatePerHour, precision: 2);
Assert.Equal(5m, quote.PricingTierDiscountAmount, precision: 2);
Assert.Equal(2m, quote.PricingTierDiscountPercent, precision: 2);
Assert.Equal(10m, quote.QuoteDiscountAmount, precision: 2);
Assert.Equal(4m, quote.QuoteDiscountPercent, precision: 2);
Assert.Equal(15m, quote.DiscountAmount, precision: 2);
Assert.Equal(6m, quote.DiscountPercent, precision: 2);
Assert.Equal(235m, quote.SubtotalAfterDiscount, precision: 2);
Assert.Equal(20m, quote.RushFee, precision: 2);
Assert.Equal(23.5m, quote.TaxAmount, precision: 2);
Assert.Equal(278.50m, quote.Total, precision: 2);
}
// ─── Stage 2: Quote → Job (QuotesController.UpdateQuoteStatus) ────────────────
[Fact]
public async Task QuoteToJob_PricingSnapshotCarriesAllCharges()
{
// Verifies that OvenBatchCost, FacilityOverheadCost, ShopSuppliesAmount, RushFee,
// and all discount fields from the approved quote land in Job.PricingBreakdownJson.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
{
QuoteId = 1,
StatusId = approvedStatusId
});
Assert.IsType<JsonResult>(result);
var job = await context.Jobs.SingleAsync();
Assert.NotNull(job.PricingBreakdownJson);
var breakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson!);
Assert.NotNull(breakdown);
Assert.Equal(150m, breakdown.ItemsSubtotal, precision: 2);
Assert.Equal(18m, breakdown.OvenBatchCost, precision: 2);
Assert.Equal(12m, breakdown.FacilityOverheadCost, precision: 2);
Assert.Equal(6m, breakdown.ShopSuppliesAmount, precision: 2);
Assert.Equal(25m, breakdown.RushFee, precision: 2);
Assert.Equal(15m, breakdown.DiscountAmount, precision: 2);
Assert.Equal(211m, breakdown.Total, precision: 2);
}
[Fact]
public async Task QuoteToJob_ItemPricesAndOverridesTransfer()
{
// Verifies that UnitPrice, TotalPrice, ManualUnitPrice, PowderCostOverride,
// CatalogItemId, and Notes all survive the quote→job item conversion.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
var jobItem = await context.JobItems.SingleAsync();
Assert.Equal(75m, jobItem.UnitPrice, precision: 2);
Assert.Equal(150m, jobItem.TotalPrice, precision: 2);
Assert.Equal(69m, jobItem.ManualUnitPrice);
Assert.Equal(8.50m, jobItem.PowderCostOverride);
Assert.Equal(99, jobItem.CatalogItemId);
Assert.Equal("Handle carefully — thin walls", jobItem.Notes);
}
[Fact]
public async Task QuoteToJob_CoatInventoryIdAndPowderToOrderTransfer()
{
// InventoryItemId on coats gates the powder charging logic in PricingCalculationService.
// PowderToOrder is the purchase quantity — both must survive quote→job conversion.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
var coat = await context.JobItemCoats.SingleAsync();
Assert.Equal(50, coat.InventoryItemId);
Assert.Equal(2.0m, coat.PowderToOrder);
Assert.Equal(4.50m, coat.PowderCostPerLb);
}
// ─── Stage 3: Job → Invoice (InvoicesController.Create GET with jobId) ──────────
[Fact]
public async Task JobToInvoice_ItemFieldsPopulateCorrectly()
{
// Notes and CatalogItemId on JobItem must reach InvoiceItem.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: false);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var item = dto.InvoiceItems.First(i => i.SourceJobItemId.HasValue);
Assert.Equal(3m, item.Quantity);
Assert.Equal(45m, item.UnitPrice, precision: 2);
Assert.Equal(135m, item.TotalPrice, precision: 2);
Assert.Equal("Gloss Black", item.ColorName);
Assert.Equal(99, item.CatalogItemId);
Assert.Equal("Watch corners — mask before blasting", item.Notes);
}
[Fact]
public async Task JobToInvoice_DirectJob_AddsOvenShopSuppliesRushFeeLines()
{
// A job created directly (no source quote) must invoice all three processing charges
// separately, reading RushFee and FacilityOverheadCost from PricingBreakdownJson.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: false);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var descriptions = dto.InvoiceItems.Select(i => i.Description).ToList();
Assert.Contains("Oven Processing Fee", descriptions);
Assert.Contains("Facility Overhead", descriptions);
Assert.Contains("Shop Supplies (4%)", descriptions);
Assert.Contains("Rush Fee", descriptions);
var oven = dto.InvoiceItems.Single(i => i.Description == "Oven Processing Fee");
var overhead = dto.InvoiceItems.Single(i => i.Description == "Facility Overhead");
var shop = dto.InvoiceItems.Single(i => i.Description == "Shop Supplies (4%)");
var rush = dto.InvoiceItems.Single(i => i.Description == "Rush Fee");
Assert.Equal(18m, oven.TotalPrice, precision: 2);
Assert.Equal(12m, overhead.TotalPrice, precision: 2);
Assert.Equal(6m, shop.TotalPrice, precision: 2);
Assert.Equal(25m, rush.TotalPrice, precision: 2);
}
[Fact]
public async Task JobToInvoice_FromQuote_BundlesAllProcessingFeesIncludingFacilityOverhead()
{
// When a job came from a quote, all processing charges must be bundled as one line,
// including FacilityOverheadCost which was previously missing.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: true);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var processingLine = dto.InvoiceItems.SingleOrDefault(i => i.Description == "Oven & Shop Processing Fees");
Assert.NotNull(processingLine);
// OvenBatchCost(18) + FacilityOverheadCost(12) + ShopSuppliesAmount(6) + RushFee(25) = 61
Assert.Equal(61m, processingLine!.TotalPrice, precision: 2);
}
[Fact]
public async Task JobToInvoice_TaxAndDiscountFromQuoteNotRecomputed()
{
// Invoice must carry the agreed quote TaxPercent and DiscountAmount,
// not re-derive from current company defaults.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: true);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
Assert.Equal(8.5m, dto.TaxPercent, precision: 2);
Assert.Equal(15m, dto.DiscountAmount, precision: 2);
}
// ─── JobItemAssemblyService: Notes field ──────────────────────────────────────
[Fact]
public void CreateJobItem_FromDto_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var dto = new CreateQuoteItemDto { Description = "Part", Notes = "Fragile — no drop" };
var pricing = new QuoteItemPricingResult { UnitPrice = 10m, TotalPrice = 10m };
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
Assert.Equal("Fragile — no drop", item.Notes);
}
[Fact]
public void CreateJobItem_FromQuoteItem_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var quoteItem = new QuoteItem { Description = "Part", Notes = "Do not sandblast" };
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
Assert.Equal("Do not sandblast", item.Notes);
}
[Fact]
public void CreateJobItem_FromJobItem_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var source = new JobItem { Description = "Part", Notes = "Carry-over note", LaborCost = 0m };
var item = svc.CreateJobItem(source, jobId: 2, companyId: 1, DateTime.UtcNow);
Assert.Equal("Carry-over note", item.Notes);
}
// ─── LaborCost: must come from pricing engine, not a hardcoded multiplier ─────
[Fact]
public void CreateJobItem_FromDto_UsesLaborCostFromPricingResult()
{
var svc = new JobItemAssemblyService();
var dto = new CreateQuoteItemDto { Description = "Rail" };
var pricing = new QuoteItemPricingResult { UnitPrice = 100m, TotalPrice = 200m, LaborCost = 55m };
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
Assert.Equal(55m, item.LaborCost, precision: 2);
}
[Fact]
public void CreateJobItem_FromQuoteItem_UsesStoredItemLaborCost()
{
var svc = new JobItemAssemblyService();
var quoteItem = new QuoteItem
{
Description = "Rail",
UnitPrice = 100m,
TotalPrice = 200m,
ItemLaborCost = 62m
};
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
Assert.Equal(62m, item.LaborCost, precision: 2);
}
// ─── Seed helpers ─────────────────────────────────────────────────────────────
private static void SeedQuoteWithFullPricing(ApplicationDbContext context)
{
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
context.InventoryItems.Add(new InventoryItem
{
Id = 50, CompanyId = 1, SKU = "BLK-1", Name = "Gloss Black",
ColorCode = "RAL9005", Finish = "Gloss", Category = "Powder", UnitOfMeasure = "lbs"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
context.JobStatusLookups.Add(new JobStatusLookup
{ Id = 10, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" });
context.JobPriorityLookups.AddRange(
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
context.PrepServices.Add(new PrepService
{ Id = 5, CompanyId = 1, ServiceName = "Sandblast", DisplayOrder = 1, IsActive = true });
context.Quotes.Add(new Quote
{
Id = 1, CompanyId = 1, QuoteNumber = "Q-2601-0001", CustomerId = 1, QuoteStatusId = 1,
IsRushJob = true,
ItemsSubtotal = 150m,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
DiscountAmount = 15m,
DiscountPercent = 6m,
SubtotalAfterDiscount = 196m,
TaxPercent = 8.5m,
TaxAmount = 16.66m,
Total = 211m
});
context.QuoteItems.Add(new QuoteItem
{
Id = 100, QuoteId = 1, CompanyId = 1,
Description = "Powder coat rail",
Quantity = 2m,
SurfaceAreaSqFt = 20m,
CatalogItemId = 99,
IsSalesItem = false,
ManualUnitPrice = 69m,
PowderCostOverride = 8.50m,
UnitPrice = 75m,
TotalPrice = 150m,
ItemLaborCost = 40m,
Notes = "Handle carefully — thin walls",
IncludePrepCost = true,
EstimatedMinutes = 30
});
context.QuoteItemCoats.Add(new QuoteItemCoat
{
Id = 101, QuoteItemId = 100, CompanyId = 1,
CoatName = "Base Coat", Sequence = 1,
InventoryItemId = 50,
ColorName = "Old Name",
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
PowderCostPerLb = 4.50m,
PowderToOrder = 2.0m
});
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
{ Id = 102, QuoteItemId = 100, CompanyId = 1, PrepServiceId = 5, EstimatedMinutes = 10 });
}
private static void SeedJobForInvoicing(ApplicationDbContext context, bool hasSourceQuote)
{
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
context.JobStatusLookups.Add(new JobStatusLookup
{ Id = 1, CompanyId = 1, StatusCode = "COMPLETED", DisplayName = "Completed" });
context.JobPriorityLookups.Add(new JobPriorityLookup
{ Id = 1, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" });
// Serialized breakdown carrying FacilityOverheadCost and RushFee
var breakdown = new QuotePricingBreakdownDto
{
ItemsSubtotal = 135m,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
TaxPercent = 8.5m,
Total = 211m
};
Quote? quote = null;
if (hasSourceQuote)
{
quote = new Quote
{
Id = 1, CompanyId = 1, QuoteNumber = "Q-TEST", CustomerId = 1,
QuoteStatusId = 1,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
DiscountAmount = 15m,
TaxPercent = 8.5m,
Total = 211m
};
context.QuoteStatusLookups.Add(new QuoteStatusLookup
{ Id = 1, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
context.Quotes.Add(quote);
}
context.Jobs.Add(new Job
{
Id = 1, CompanyId = 1, JobNumber = "JOB-TEST", CustomerId = 1,
Description = "Test job",
JobStatusId = 1,
JobPriorityId = 1,
QuoteId = hasSourceQuote ? 1 : null,
OvenBatchCost = 18m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
IsRushJob = true,
FinalPrice = 211m,
PricingBreakdownJson = JsonSerializer.Serialize(breakdown)
});
context.JobItems.Add(new JobItem
{
Id = 10, JobId = 1, CompanyId = 1,
Description = "Powder coat wheel",
Quantity = 3m,
UnitPrice = 45m,
TotalPrice = 135m,
ColorName = "Gloss Black",
CatalogItemId = 99,
Notes = "Watch corners — mask before blasting",
EstimatedMinutes = 20,
LaborCost = 30m
});
}
// ─── Controller / service factory helpers ────────────────────────────────────
private static QuotePricingAssemblyService CreateAssemblyService(ApplicationDbContext context) =>
new(new UnitOfWork(context),
Mock.Of<IPricingCalculationService>(),
Mock.Of<IInventoryAiLookupService>(),
Mock.Of<ILogger<QuotePricingAssemblyService>>());
private static QuotesController CreateQuotesController(ApplicationDbContext context)
{
var lookupCache = new Mock<ILookupCacheService>();
lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny<int>()))
.ReturnsAsync(() => context.QuoteStatusLookups.ToList());
return new QuotesController(
new UnitOfWork(context),
Mock.Of<AutoMapper.IMapper>(),
Mock.Of<IPricingCalculationService>(),
CreateUserManager().Object,
Mock.Of<ILogger<QuotesController>>(),
Mock.Of<IPdfService>(),
CreateTenantContext().Object,
Mock.Of<IMeasurementConversionService>(),
lookupCache.Object,
Mock.Of<INotificationService>(),
Mock.Of<ISubscriptionService>(),
new JobItemAssemblyService(),
Mock.Of<IQuotePricingAssemblyService>(),
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<IPlatformSettingsService>(),
Mock.Of<IQuotePhotoService>(),
Mock.Of<IAiQuoteService>(),
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IJobPhotoService>(),
Mock.Of<IAiUsageLogger>(),
Mock.Of<ICompanyLogoService>(),
Mock.Of<IInventoryAiLookupService>());
}
private static InvoicesController CreateInvoicesController(ApplicationDbContext context)
{
var controller = new InvoicesController(
new UnitOfWork(context),
Mock.Of<AutoMapper.IMapper>(),
CreateUserManager().Object,
Mock.Of<ILogger<InvoicesController>>(),
Mock.Of<IPdfService>(),
CreateTenantContext().Object,
Mock.Of<INotificationService>(),
Mock.Of<IAccountBalanceService>(),
Mock.Of<ICompanyLogoService>());
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
var principal = new ClaimsPrincipal(identity);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = principal }
};
return controller;
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
var mgr = new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
mgr.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync(new ApplicationUser
{
Id = "user-1",
CompanyId = 1,
UserName = "testuser",
Email = "test@test.com"
});
return mgr;
}
private static Mock<ITenantContext> CreateTenantContext()
{
var tc = new Mock<ITenantContext>();
tc.Setup(x => x.GetCurrentCompanyId()).Returns(1);
tc.Setup(x => x.IsSuperAdmin()).Returns(true);
tc.Setup(x => x.IsPlatformAdmin()).Returns(true);
return tc;
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}