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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Generated
+10757
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user