Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController) - Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js) - Fix quote preview not recalculating on customer change (Create.cshtml) - Add AddQuotePricingSnapshotFields migration (missing from prior session) - Fix intake button rendering ✓ as literal text (Html.Raw fix) - Clean up corrupted Unicode box-drawing chars in Job Details view 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 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 DiscountPercent { get; set; }
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -113,7 +113,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
|
||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
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.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
||||
public DateTime? ApprovedDate { get; set; }
|
||||
|
||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor 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 OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor 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 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 ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
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 DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
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 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 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotePricingSnapshotFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes");
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4217,6 +4217,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("OvenCycleMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -6711,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6722,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6733,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6980,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("ExpirationDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("HideDiscountFromCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7025,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ProfitMargin")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7064,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("QuoteDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("QuoteNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -7089,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("SubTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("SubtotalAfterDiscount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
});
|
||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
|
||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Oven Processing Fee",
|
||||
Description = "Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||
if (facilityOverhead > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Facility Overhead",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||
if (rushFee > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Rush Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(rushFee, 2),
|
||||
TotalPrice = Math.Round(rushFee, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
|
||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
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,
|
||||
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())
|
||||
// Display the pricing snapshot stored when items were last saved.
|
||||
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
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 = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
}
|
||||
else if (job.FinalPrice > 0)
|
||||
{
|
||||
// Legacy job created before snapshot was introduced — show what we have stored
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
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
|
||||
OvenBatchCost = job.OvenBatchCost,
|
||||
OvenBatches = job.OvenBatches,
|
||||
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||
Total = job.FinalPrice
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
@@ -1169,15 +1121,23 @@ public class JobsController : Controller
|
||||
|
||||
// Recalculate total from wizard items
|
||||
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(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1629,14 +1589,22 @@ public class JobsController : Controller
|
||||
if (dto.JobItems.Any())
|
||||
{
|
||||
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(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2962,7 +2930,7 @@ public class JobsController : Controller
|
||||
JobId = job.Id,
|
||||
JobNumber = job.JobNumber,
|
||||
CustomerId = job.CustomerId,
|
||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
||||
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
OvenCostId = job.OvenCostId,
|
||||
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||
@@ -2999,7 +2967,7 @@ public class JobsController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one job item.");
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||
@@ -3044,15 +3012,26 @@ 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 updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3066,7 +3045,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||
TempData["Error"] = "An error occurred while saving job items.";
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
return View("EditItems", model);
|
||||
}
|
||||
@@ -3108,31 +3087,47 @@ public class JobsController : Controller
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem,
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice,
|
||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
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(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.OvenBatchCost = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.PricingBreakdownJson = null;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3242,6 +3237,57 @@ public class JobsController : Controller
|
||||
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>
|
||||
/// <summary>
|
||||
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||
/// are never charged tax when a job is saved or recalculated.
|
||||
/// </summary>
|
||||
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||
{
|
||||
if (customerId is > 0)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||
if (customer?.IsTaxExempt == true) return 0m;
|
||||
}
|
||||
return companyDefaultRate;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
#region Item Pricing (AJAX)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutoMapper;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -2847,8 +2848,39 @@ public class QuotesController : Controller
|
||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
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,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
@* ── Catalog Products ── *@
|
||||
@* -- Catalog Products -- *@
|
||||
@if (catalogItems.Any())
|
||||
{
|
||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||
@@ -414,7 +414,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Custom Work ── *@
|
||||
@* -- Custom Work -- *@
|
||||
@if (customItems.Any())
|
||||
{
|
||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||
@@ -565,7 +565,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Labor ── *@
|
||||
@* -- Labor -- *@
|
||||
@if (laborItems.Any())
|
||||
{
|
||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||
@@ -616,7 +616,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Mobile cards ── *@
|
||||
@* -- Mobile cards -- *@
|
||||
<div class="d-lg-none mt-2">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
@@ -1310,7 +1310,7 @@
|
||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||
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")">
|
||||
<i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||
</a>
|
||||
}
|
||||
@{
|
||||
@@ -2332,7 +2332,7 @@
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Inline date editing ──────────────────────────────────────────────
|
||||
// -- Inline date editing ----------------------------------------------
|
||||
const jobId = @Model.Id;
|
||||
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
@@ -2433,7 +2433,7 @@
|
||||
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
|
||||
|
||||
|
||||
// ── Auto-submit after wizard saves an item ────────────────────────
|
||||
// -- Auto-submit after wizard saves an item ------------------------
|
||||
let itemsModified = false;
|
||||
|
||||
// Wrap wizardSave to set a flag before the modal hides
|
||||
@@ -2451,7 +2451,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete confirmation modal ─────────────────────────────────────
|
||||
// -- Delete confirmation modal -------------------------------------
|
||||
let pendingDeleteItemId = -1;
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
@@ -2489,7 +2489,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
|
||||
<!-- -- Rework / Warranty ---------------------------------------------- -->
|
||||
<script>
|
||||
const rework = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2645,7 +2645,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
|
||||
<!-- -- Job Costing ---------------------------------------------------- -->
|
||||
<script>
|
||||
const costing = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2754,7 +2754,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
|
||||
<!-- -- Time Tracking --------------------------------------------------- -->
|
||||
<script>
|
||||
const timeTracking = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2762,7 +2762,7 @@
|
||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||
let entries = [];
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────
|
||||
// -- Load ----------------------------------------------------------
|
||||
async function load() {
|
||||
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
||||
entries = await r.json();
|
||||
@@ -2810,7 +2810,7 @@
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
// ── Modal helpers ─────────────────────────────────────────────────
|
||||
// -- Modal helpers -------------------------------------------------
|
||||
function openAdd() {
|
||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||
document.getElementById('teEntryId').value = '0';
|
||||
@@ -2917,7 +2917,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Deposits ─────────────────────────────────────────────────────────────
|
||||
// -- Deposits -------------------------------------------------------------
|
||||
// Note: antiForgeryToken() is already defined above in this script block
|
||||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@@ -2973,7 +2973,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collapsible sections ──────────────────────────────────────────────────
|
||||
// -- Collapsible sections --------------------------------------------------
|
||||
(function () {
|
||||
const storageKey = 'jobDetailCollapse_@Model.Id';
|
||||
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
||||
@@ -3012,7 +3012,7 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Part Intake Modal ─────────────────────────────────────────────────────
|
||||
// -- Part Intake Modal --------------------------------------------------
|
||||
(function () {
|
||||
const expectedCount = @intakeExpectedCount;
|
||||
const partCountInput = document.getElementById('intakePartCount');
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
if (taxField) {
|
||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||
}
|
||||
// Recalculate the live preview so it reflects the updated tax rate immediately
|
||||
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
|
||||
|
||||
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
||||
const emailSection = document.getElementById('emailNotifySection');
|
||||
|
||||
@@ -2904,7 +2904,8 @@ async function runAutoPricing() {
|
||||
try {
|
||||
// Collect current form meta
|
||||
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
|
||||
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
|
||||
const _taxField = document.querySelector('[name="TaxPercent"]');
|
||||
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
|
||||
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
|
||||
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
|
||||
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
|
||||
|
||||
@@ -59,7 +59,8 @@ public class JobItemAssemblyServiceTests
|
||||
var pricing = new QuoteItemPricingResult
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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