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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user