Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates

- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
  QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
  AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
  (LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:48:00 -04:00
parent 0d980e651a
commit 9a52e7fae5
19 changed files with 480 additions and 63 deletions
@@ -78,6 +78,7 @@ public class QuoteProfile : Profile
// CreateQuoteDto -> Quote
CreateMap<CreateQuoteDto, Quote>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Set by controller on consent
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Generated by controller
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK to Draft status
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
@@ -111,6 +112,7 @@ public class QuoteProfile : Profile
// UpdateQuoteDto -> Quote
CreateMap<UpdateQuoteDto, Quote>()
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Cannot change
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Managed by controller
.ForMember(dest => dest.CustomerId, opt => opt.Ignore()) // Cannot change after creation - preserved in controller
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
@@ -277,6 +279,8 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.ZipCode, opt => opt.MapFrom(src => src.ProspectZipCode))
.ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial))
.ForMember(dest => dest.CreditLimit, opt => opt.MapFrom(src => 0m))
.ForMember(dest => dest.SmsConsent, opt => opt.MapFrom(src => src.ProspectSmsConsent))
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.MapFrom(src => src.ProspectSmsConsentedAt))
.ForMember(dest => dest.PricingTierId, opt => opt.Ignore())
.ForMember(dest => dest.TaxId, opt => opt.Ignore())
.ForMember(dest => dest.PaymentTerms, opt => opt.Ignore())
@@ -98,7 +98,12 @@ public class PdfService : IPdfService
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
page.Content().Layers(layers =>
{
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
if (invoiceDto.Status == InvoiceStatus.Paid)
layers.Layer().Element(c => ComposePaidStamp(c));
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
@@ -153,7 +158,6 @@ public class PdfService : IPdfService
if (invoice.DueDate.HasValue)
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
column.Item().Text($"Status: {invoice.Status}").FontSize(9);
});
});
@@ -161,6 +165,27 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
/// external Skia/SkiaSharp dependency is needed.
/// </summary>
private static void ComposePaidStamp(IContainer container)
{
container
.AlignCenter()
.AlignMiddle()
.Rotate(-45f)
.Border(5)
.BorderColor(Colors.Green.Darken2)
.PaddingVertical(14)
.PaddingHorizontal(28)
.Text("PAID")
.FontSize(80)
.Bold()
.FontColor(Colors.Green.Darken2);
}
/// <summary>
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
@@ -126,9 +126,11 @@ public class PricingCalculationService : IPricingCalculationService
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
// In-stock coats reference an inventory item that already has stock on hand.
// Incoming coats reference an inventory item with IsIncoming=true (ordered, not yet received).
bool isCustomPowder = !coat.InventoryItemId.HasValue
&& coat.PowderCostPerLb.HasValue
&& coat.PowderCostPerLb.Value > 0;
bool isIncomingPowder = false;
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
@@ -143,13 +145,14 @@ public class PricingCalculationService : IPricingCalculationService
}
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
{
// In-stock powder - use inventory cost
// In-stock or incoming powder - use inventory cost
try
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0)
{
costPerLb = inventoryItem.UnitCost;
isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency;
@@ -157,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
}
}
catch (Exception ex)
@@ -172,13 +175,13 @@ public class PricingCalculationService : IPricingCalculationService
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
decimal coatMaterialCost;
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
// Custom or incoming powder must be purchased for this job — charge for the full ordered
// quantity so the shop recovers the actual outlay, not just the calculated usage.
if (batchSurfaceAreaSqFt > 0 && (isCustomPowder || isIncomingPowder) && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Custom powder that must be purchased: charge for the full ordered quantity, not just
// the calculated usage. The shop is spending money on the entire order for this job.
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
_logger.LogInformation("Coat {CoatName}: {PowderKind} powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
}
else if (batchSurfaceAreaSqFt > 0)
{