Compare commits

...

2 Commits

Author SHA1 Message Date
spouliot 6721de91e4 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>
2026-05-15 15:03:06 -04:00
spouliot 226a6237a6 Fix corrupted Unicode characters in Jobs/Details.cshtml
All � replacement characters replaced with correct HTML entities
(&mdash;, &ndash;, &bull;, &times;, &hellip;) and restored a
corrupted class attribute with missing double quotes on the Intake button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:51:00 -04:00
14 changed files with 11720 additions and 191 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,
+63 -63
View File
@@ -57,7 +57,7 @@
} }
else else
{ {
<span>Shop work has started � review the quote and apply any changes manually.</span> <span>Shop work has started &mdash; review the quote and apply any changes manually.</span>
} }
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
@@ -217,7 +217,7 @@
</button> </button>
</div> </div>
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted"> <div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving� <span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</div> </div>
</div> </div>
</div> </div>
@@ -263,7 +263,7 @@
<i class="bi bi-x-circle me-1"></i><small>Clear date</small> <i class="bi bi-x-circle me-1"></i><small>Clear date</small>
</button> </button>
<div id="dueDate-saving" class="d-none mt-1 small text-muted"> <div id="dueDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving� <span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</div> </div>
</div> </div>
</div> </div>
@@ -273,7 +273,7 @@
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<select id="workerAssignmentSelect" class="form-select form-select-sm" <select id="workerAssignmentSelect" class="form-select form-select-sm"
onchange="updateWorkerAssignment(this)"> onchange="updateWorkerAssignment(this)">
<option value="">� Unassigned �</option> <option value="">&ndash; Unassigned &ndash;</option>
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers) @foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
{ {
if (w.Value == Model.AssignedUserId) if (w.Value == Model.AssignedUserId)
@@ -287,7 +287,7 @@
} }
</select> </select>
<span id="workerSaveIndicator" class="text-muted small d-none"> <span id="workerSaveIndicator" class="text-muted small d-none">
<span class="spinner-border spinner-border-sm me-1"></span>Saving� <span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</span> </span>
<span id="workerSavedTick" class="text-success small d-none"> <span id="workerSavedTick" class="text-success small d-none">
<i class="bi bi-check-circle-fill"></i> <i class="bi bi-check-circle-fill"></i>
@@ -351,10 +351,10 @@
{ {
<br /> <br />
<small class="ms-3"> <small class="ms-3">
� <strong>@coat.CoatName</strong> &bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName)) @if (!string.IsNullOrEmpty(coat.ColorName))
{ {
<text> � @coat.ColorName</text> <text> &ndash; @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName)) @if (!string.IsNullOrEmpty(coat.VendorName))
{ {
<text> (@coat.VendorName)</text> <text> (@coat.VendorName)</text>
@@ -373,7 +373,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span> <span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue) @if (!coat.InventoryItemId.HasValue)
{ {
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span> <span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder &mdash; must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
} }
} }
@if (!string.IsNullOrEmpty(coat.Notes)) @if (!string.IsNullOrEmpty(coat.Notes))
@@ -390,7 +390,7 @@
@foreach (var ps in item.PrepServices) @foreach (var ps in item.PrepServices)
{ {
<br /> <br />
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small> <small class="ms-3">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @ps.EstimatedMinutes min</span></small>
} }
} }
@if (!string.IsNullOrEmpty(item.Notes)) @if (!string.IsNullOrEmpty(item.Notes))
@@ -478,10 +478,10 @@
{ {
<br /> <br />
<small class="ms-3"> <small class="ms-3">
� <strong>@coat.CoatName</strong> &bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName)) @if (!string.IsNullOrEmpty(coat.ColorName))
{ {
<text> � @coat.ColorName</text> <text> &ndash; @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName)) @if (!string.IsNullOrEmpty(coat.VendorName))
{ {
<text> (@coat.VendorName)</text> <text> (@coat.VendorName)</text>
@@ -500,7 +500,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span> <span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue) @if (!coat.InventoryItemId.HasValue)
{ {
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span> <span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder &mdash; must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
} }
} }
@if (!string.IsNullOrEmpty(coat.Notes)) @if (!string.IsNullOrEmpty(coat.Notes))
@@ -517,7 +517,7 @@
@foreach (var ps in item.PrepServices) @foreach (var ps in item.PrepServices)
{ {
<br /> <br />
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small> <small class="ms-3">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @ps.EstimatedMinutes min</span></small>
} }
} }
@if (!string.IsNullOrEmpty(item.Notes)) @if (!string.IsNullOrEmpty(item.Notes))
@@ -532,7 +532,7 @@
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text> <text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
<br /><small class="text-muted">per item</small> <br /><small class="text-muted">per item</small>
} }
else { <span class="text-muted">�</span> } else { <span class="text-muted">&mdash;</span> }
</td> </td>
<td class="text-center"> <td class="text-center">
@if (item.EstimatedMinutes > 0) @if (item.EstimatedMinutes > 0)
@@ -540,7 +540,7 @@
<text>@item.EstimatedMinutes min</text> <text>@item.EstimatedMinutes min</text>
<br /><small class="text-muted">per item</small> <br /><small class="text-muted">per item</small>
} }
else { <span class="text-muted">�</span> } else { <span class="text-muted">&mdash;</span> }
</td> </td>
<td class="text-center"> <td class="text-center">
@if (totalPowderNeeded > 0) @if (totalPowderNeeded > 0)
@@ -548,7 +548,7 @@
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong> <strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
<br /><small class="text-muted">total batch</small> <br /><small class="text-muted">total batch</small>
} }
else { <span class="text-muted">�</span> } else { <span class="text-muted">&mdash;</span> }
</td> </td>
<td class="text-end">@item.UnitPrice.ToString("C")</td> <td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -599,7 +599,7 @@
{ {
<text>@item.EstimatedMinutes min</text> <text>@item.EstimatedMinutes min</text>
} }
else { <span class="text-muted">�</span> } else { <span class="text-muted">&mdash;</span> }
</td> </td>
<td class="text-end">@item.UnitPrice.ToString("C")</td> <td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -653,7 +653,7 @@
<span class="mobile-card-value"> <span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) @foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{ {
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small> <small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }</small>
} }
</span> </span>
</div> </div>
@@ -704,7 +704,7 @@
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i> <i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div> </div>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<span class="text-muted small">Total: <strong id="totalHoursDisplay">�</strong></span> <span class="text-muted small">Total: <strong id="totalHoursDisplay">&mdash;</strong></span>
@{ @{
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0; var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
var estimatedHrs = estimatedMins / 60m; var estimatedHrs = estimatedMins / 60m;
@@ -741,7 +741,7 @@
<tfoot class="table-light fw-semibold"> <tfoot class="table-light fw-semibold">
<tr> <tr>
<td colspan="3">Total</td> <td colspan="3">Total</td>
<td class="text-end" id="timeEntriesTotalHours">�</td> <td class="text-end" id="timeEntriesTotalHours">&mdash;</td>
<td colspan="3"></td> <td colspan="3"></td>
</tr> </tr>
</tfoot> </tfoot>
@@ -1099,7 +1099,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="intakeModalLabel"> <h5 class="modal-title" id="intakeModalLabel">
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake � Check In <i class="bi bi-box-seam me-2 text-info"></i>Part Intake &ndash; Check In
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
@@ -1117,7 +1117,7 @@
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")" value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
placeholder="@intakeExpectedCount" /> placeholder="@intakeExpectedCount" />
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none"> <div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected � note the discrepancy below. <i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected &mdash; note the discrepancy below.
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -1310,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id" <a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")" class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")"> title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake") <i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake &#10003;" : "Intake")
</a> </a>
} }
@{ @{
@@ -1368,7 +1368,7 @@
</div> </div>
</div> </div>
<!-- Pricing Summary (internal � d-print-none) --> <!-- Pricing Summary (internal - d-print-none) -->
@{ @{
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto; var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
} }
@@ -1400,7 +1400,7 @@
@if (jobPb.OvenBatchCost > 0) @if (jobPb.OvenBatchCost > 0)
{ {
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" � {jobPb.OvenCycleMinutes} min" : "")):</span> <span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" &times; {jobPb.OvenCycleMinutes} min" : "")):</span>
<strong>@jobPb.OvenBatchCost.ToString("C")</strong> <strong>@jobPb.OvenBatchCost.ToString("C")</strong>
</div> </div>
} }
@@ -1518,7 +1518,7 @@
} }
else if (allCatalog) else if (allCatalog)
{ {
<div class="text-muted small fst-italic">All items use fixed catalog pricing � no per-category cost split available.</div> <div class="text-muted small fst-italic">All items use fixed catalog pricing &mdash; no per-category cost split available.</div>
} }
else else
{ {
@@ -1547,7 +1547,7 @@
@if (jobPb.FacilityOverheadCost > 0) @if (jobPb.FacilityOverheadCost > 0)
{ {
<div class="d-flex justify-content-between small mb-1"> <div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr � estimated hours)</span> <span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr &times; estimated hours)</span>
<span>@jobPb.FacilityOverheadCost.ToString("C")</span> <span>@jobPb.FacilityOverheadCost.ToString("C")</span>
</div> </div>
} }
@@ -1712,11 +1712,11 @@
<div class="px-3 pt-3 pb-2"> <div class="px-3 pt-3 pb-2">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span> <span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
<span class="fw-semibold" id="costingRevenue">�</span> <span class="fw-semibold" id="costingRevenue">&mdash;</span>
</div> </div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2"> <div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span> <span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
<span id="costingPowder">�</span> <span id="costingPowder">&mdash;</span>
</div> </div>
<div id="powderDetail" style="display:none;" class="ps-3 pb-1"> <div id="powderDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;"> <table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1725,7 +1725,7 @@
</div> </div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2"> <div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span> <span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
<span id="costingLabor">�</span> <span id="costingLabor">&mdash;</span>
</div> </div>
<div id="laborDetail" style="display:none;" class="ps-3 pb-1"> <div id="laborDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;"> <table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1734,12 +1734,12 @@
</div> </div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2"> <div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span> <span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
<span id="costingOven">�</span> <span id="costingOven">&mdash;</span>
</div> </div>
<div id="costingReworkSection" style="display:none;"> <div id="costingReworkSection" style="display:none;">
<div class="d-flex justify-content-between small text-muted mb-1 ps-2"> <div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span> <span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
<span id="costingRework">�</span> <span id="costingRework">&mdash;</span>
</div> </div>
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1"> <div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;"> <table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1748,25 +1748,25 @@
</div> </div>
<div class="d-flex justify-content-between small text-success mb-1 ps-2"> <div class="d-flex justify-content-between small text-success mb-1 ps-2">
<span>Billed to Customer</span> <span>Billed to Customer</span>
<span id="costingReworkBilled">�</span> <span id="costingReworkBilled">&mdash;</span>
</div> </div>
</div> </div>
<hr class="my-2" /> <hr class="my-2" />
<div class="d-flex justify-content-between small mb-1 ps-2"> <div class="d-flex justify-content-between small mb-1 ps-2">
<span class="text-muted">Total Costs</span> <span class="text-muted">Total Costs</span>
<span id="costingTotal" class="text-danger">�</span> <span id="costingTotal" class="text-danger">&mdash;</span>
</div> </div>
<div class="d-flex justify-content-between fw-bold mb-1"> <div class="d-flex justify-content-between fw-bold mb-1">
<span>Gross Profit</span> <span>Gross Profit</span>
<span id="costingProfit">�</span> <span id="costingProfit">&mdash;</span>
</div> </div>
<div class="d-flex justify-content-between small text-muted mb-1"> <div class="d-flex justify-content-between small text-muted mb-1">
<span>Gross Margin</span> <span>Gross Margin</span>
<span id="costingMargin">�</span> <span id="costingMargin">&mdash;</span>
</div> </div>
<div class="d-flex justify-content-between small text-muted"> <div class="d-flex justify-content-between small text-muted">
<span>Margin vs Quote</span> <span>Margin vs Quote</span>
<span id="costingQuotedMargin">�</span> <span id="costingQuotedMargin">&mdash;</span>
</div> </div>
</div> </div>
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div> <div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
@@ -1869,7 +1869,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Tags <label class="form-label">Tags
<small class="text-muted fw-normal ms-1">� colors, finish, or other keywords</small> <small class="text-muted fw-normal ms-1">&ndash; colors, finish, or other keywords</small>
</label> </label>
<input type="hidden" id="photoTagsHidden" name="tags" /> <input type="hidden" id="photoTagsHidden" name="tags" />
<div id="photoTagsContainer"></div> <div id="photoTagsContainer"></div>
@@ -1948,7 +1948,7 @@
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea> <textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
</div> </div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">� colors, finish, keywords</small></label> <label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">&ndash; colors, finish, keywords</small></label>
<input type="hidden" id="editPhotoTagsHidden" /> <input type="hidden" id="editPhotoTagsHidden" />
<div id="editPhotoTagsContainer"></div> <div id="editPhotoTagsContainer"></div>
</div> </div>
@@ -2000,7 +2000,7 @@
<div class="mb-2"> <div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label> <label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5" <textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message�" maxlength="160"></textarea> placeholder="Type your message&hellip;" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1"> <div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none"> <div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically. <i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
@@ -2012,7 +2012,7 @@
</div> </div>
<div class="modal-footer justify-content-between"> <div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn"> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip � don't send Skip &mdash; don't send
</button> </button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn"> <button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS <i class="bi bi-send me-1"></i>Send SMS
@@ -2133,7 +2133,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Specific Item (optional)</label> <label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem"> <select class="form-select" id="rwJobItem">
<option value="">� Whole Job �</option> <option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null) @if (Model.Items != null)
{ {
@foreach (var item in Model.Items) @foreach (var item in Model.Items)
@@ -2195,9 +2195,9 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Resolution</label> <label class="form-label">Resolution</label>
<select class="form-select" id="rwResolution"> <select class="form-select" id="rwResolution">
<option value="">� Pending �</option> <option value="">&ndash; Pending &ndash;</option>
<option value="0">Recoated � No Charge</option> <option value="0">Recoated &mdash; No Charge</option>
<option value="1">Recoated � Billed to Customer</option> <option value="1">Recoated &mdash; Billed to Customer</option>
<option value="2">Customer Credited</option> <option value="2">Customer Credited</option>
<option value="3">Written Off</option> <option value="3">Written Off</option>
<option value="4">No Action Required</option> <option value="4">No Action Required</option>
@@ -2256,7 +2256,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
<select class="form-select" id="teWorkerId"> <select class="form-select" id="teWorkerId">
<option value="">� Select worker �</option> <option value="">&ndash; Select worker &ndash;</option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? [])) @foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{ {
<option value="@w.Id">@w.Name</option> <option value="@w.Id">@w.Name</option>
@@ -2275,7 +2275,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Stage / Task</label> <label class="form-label fw-semibold">Stage / Task</label>
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking�" list="stageOptions" /> <input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking&hellip;" list="stageOptions" />
<datalist id="stageOptions"> <datalist id="stageOptions">
<option value="Sandblasting"></option> <option value="Sandblasting"></option>
<option value="Masking & Taping"></option> <option value="Masking & Taping"></option>
@@ -2290,7 +2290,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes�"></textarea> <textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes&hellip;"></textarea>
</div> </div>
<div class="text-danger small d-none" id="teError"></div> <div class="text-danger small d-none" id="teError"></div>
</div> </div>
@@ -2456,7 +2456,7 @@
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal')); const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value; const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Delegated listener � handles all delete buttons via data attributes // Delegated listener -- handles all delete buttons via data attributes
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-delete-id]'); const btn = e.target.closest('[data-delete-id]');
if (!btn) return; if (!btn) return;
@@ -2534,12 +2534,12 @@
</div> </div>
<div class="small mt-1 text-muted">${r.defectDescription}</div> <div class="small mt-1 text-muted">${r.defectDescription}</div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()} Found: ${r.discoveredByDisplay} &mdash; ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? '� ' + r.reportedByName : ''} ${r.reportedByName ? '&ndash; ' + r.reportedByName : ''}
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''} ${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
</div> </div>
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''} ${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' � $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''} ${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' &mdash; $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
</div>`).join(''); </div>`).join('');
} }
@@ -2685,7 +2685,7 @@
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer); document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
const rBody = document.getElementById('reworkCostLines'); const rBody = document.getElementById('reworkCostLines');
rBody.innerHTML = d.reworkLines.map(l => `<tr> rBody.innerHTML = d.reworkLines.map(l => `<tr>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} � ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td> <td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} &ndash; ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td> <td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join(''); <td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
} else { } else {
@@ -2701,14 +2701,14 @@
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`; document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
document.getElementById('costingQuotedMargin').textContent = document.getElementById('costingQuotedMargin').textContent =
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '�'; d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '';
// Powder detail lines // Powder detail lines
const pBody = document.getElementById('powderLines'); const pBody = document.getElementById('powderLines');
pBody.innerHTML = d.hasPowderData pBody.innerHTML = d.hasPowderData
? d.powderLines.map(l => `<tr> ? d.powderLines.map(l => `<tr>
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td> <td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
<td class="text-end text-nowrap">${l.lbs} lbs � ${fmt(l.costPerLb)}/lb</td> <td class="text-end text-nowrap">${l.lbs} lbs &times; ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('') <td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>'; : '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
@@ -2716,14 +2716,14 @@
const lBody = document.getElementById('laborLines'); const lBody = document.getElementById('laborLines');
lBody.innerHTML = d.hasLaborData lBody.innerHTML = d.hasLaborData
? d.laborLines.map(l => `<tr> ? d.laborLines.map(l => `<tr>
<td class="text-muted">${l.worker}${l.stage ? ' � ' + l.stage : ''}<br/><small>${l.workDate}</small></td> <td class="text-muted">${l.worker}${l.stage ? ' &ndash; ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h � ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td> <td class="text-end text-nowrap">${l.hours}h &times; ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('') <td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>'; : '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
// Notes // Notes
const notes = []; const notes = [];
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items � edit the item and enter a surface area to calculate powder cost.'); if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items &mdash; edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.'); else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.'); if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.'); if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
@@ -2793,7 +2793,7 @@
<td class="fw-semibold">${esc(e.workerName)}</td> <td class="fw-semibold">${esc(e.workerName)}</td>
<td class="small">${d}</td> <td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td> <td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">�</span>'}</td> <td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">&mdash;</span>'}</td>
<td class="small text-muted">${esc(e.notes ?? '')}</td> <td class="small text-muted">${esc(e.notes ?? '')}</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button> <button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
@@ -2805,9 +2805,9 @@
} }
function updateTotals(total) { function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '�'; const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '';
document.getElementById('totalHoursDisplay').textContent = fmt; document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�'; document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '';
} }
// ── Modal helpers ───────────────────────────────────────────────── // ── Modal helpers ─────────────────────────────────────────────────
@@ -2931,7 +2931,7 @@
} }
if (errEl) errEl.classList.add('d-none'); if (errEl) errEl.classList.add('d-none');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving�'; } if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;'; }
const params = new URLSearchParams(new FormData(form)); const params = new URLSearchParams(new FormData(form));
@@ -3105,7 +3105,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input type="text" name="templateName" class="form-control" required maxlength="100" <input type="text" name="templateName" class="form-control" required maxlength="100"
placeholder="e.g. Wheel Refinish � Standard 4pc"> placeholder="e.g. Wheel Refinish &mdash; Standard 4pc">
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -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!);
}
}