Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests

- Store complete PricingBreakdownJson snapshot on Job at every save point so
  the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
  SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:03:06 -04:00
parent 226a6237a6
commit 6721de91e4
13 changed files with 11657 additions and 128 deletions
+4
View File
@@ -66,6 +66,10 @@ public class Job : BaseEntity
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
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
+23 -16
View File
@@ -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; }