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
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
@@ -88,8 +89,8 @@ public class AccountsController : Controller
dto.AccountSubType = preSubType.Value;
dto.AccountType = preSubType.Value switch
{
AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable
or AccountSubType.Inventory or AccountSubType.FixedAsset
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
AccountSubType.AccountsPayable or AccountSubType.CreditCard
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
@@ -445,11 +446,11 @@ public class AccountsController : Controller
.ToList();
ViewBag.AccountTypes = Enum.GetValues<AccountType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
.ToList();
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
.ToList();
}
}
@@ -424,7 +424,8 @@ public class BillsController : Controller
// Payment form defaults
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.AccountSubType == AccountSubType.Checking ||
a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber)
@@ -949,7 +950,8 @@ public class BillsController : Controller
.ToList();
ViewBag.BankAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Checking ||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
@@ -401,7 +401,8 @@ public class ExpensesController : Controller
.ToList();
ViewBag.PaymentAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Checking ||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
@@ -41,6 +41,7 @@ public class QuotesController : Controller
private readonly IJobPhotoService _jobPhotoService;
private readonly IAiUsageLogger _usageLogger;
private readonly ICompanyLogoService _logoService;
private readonly IInventoryAiLookupService _aiLookupService;
public QuotesController(
IUnitOfWork unitOfWork,
@@ -61,7 +62,8 @@ public class QuotesController : Controller
IWebHostEnvironment env,
IJobPhotoService jobPhotoService,
IAiUsageLogger usageLogger,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IInventoryAiLookupService aiLookupService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -82,6 +84,7 @@ public class QuotesController : Controller
_jobPhotoService = jobPhotoService;
_usageLogger = usageLogger;
_logoService = logoService;
_aiLookupService = aiLookupService;
}
/// <summary>
@@ -487,6 +490,8 @@ public class QuotesController : Controller
quote.ProspectCity = null;
quote.ProspectState = null;
quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
await _unitOfWork.CompleteAsync();
@@ -894,6 +899,8 @@ public class QuotesController : Controller
quote.QuoteNumber = await GenerateQuoteNumberAsync();
quote.PreparedById = currentUser.Id;
quote.CompanyId = currentUser.CompanyId;
if (dto.ProspectSmsConsent)
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
if (dto.SendEmailToCustomer)
{
@@ -1001,6 +1008,12 @@ public class QuotesController : Controller
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
// If "Add to inventory as Incoming" was checked on the custom tab,
// create a 0-balance inventory record so QR codes work on the work order.
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
@@ -1424,6 +1437,12 @@ public class QuotesController : Controller
// Update quote entity
_mapper.Map(dto, quote);
// Manage SMS consent timestamp: stamp when first consented, clear when revoked
if (dto.ProspectSmsConsent && !quote.ProspectSmsConsentedAt.HasValue)
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
else if (!dto.ProspectSmsConsent)
quote.ProspectSmsConsentedAt = null;
// Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
@@ -1761,6 +1780,10 @@ public class QuotesController : Controller
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
@@ -2116,9 +2139,21 @@ public class QuotesController : Controller
var customer = _mapper.Map<Customer>(dto);
customer.CompanyId = currentUser!.CompanyId;
// Carry over SMS consent if staff confirmed it on this form (TCPA compliance)
if (dto.SmsConsent)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = dto.ProspectSmsConsentedAt ?? DateTime.UtcNow;
customer.SmsConsentMethod = "verbal";
}
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync();
// Send the TCPA-compliant welcome/opt-in confirmation SMS when consent was granted
if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
@@ -2136,6 +2171,8 @@ public class QuotesController : Controller
quote.ProspectCity = null;
quote.ProspectState = null;
quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
@@ -2284,6 +2321,8 @@ public class QuotesController : Controller
quote.ProspectCity = null;
quote.ProspectState = null;
quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
@@ -2651,22 +2690,25 @@ public class QuotesController : Controller
ViewBag.CompanyTaxPercent = costs.FirstOrDefault()?.TaxPercent ?? 0;
}
// Inventory coatings
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
.Select(i => new
{
value = i.Id.ToString(),
text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
text = i.IsIncoming
? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)"
: $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
coverage = i.CoverageSqFtPerLb ?? 30m,
efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory.DisplayName,
categoryName = i.InventoryCategory!.DisplayName,
costPerLb = i.UnitCost,
colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? ""
colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming
}).ToList();
// Vendors
@@ -3022,18 +3064,20 @@ public class QuotesController : Controller
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
IsRushJob = quote.IsRushJob,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
IsRushJob = quote.IsRushJob,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
};
await _unitOfWork.Jobs.AddAsync(job);
@@ -3276,7 +3320,7 @@ public class QuotesController : Controller
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResendQuote(int id)
public async Task<IActionResult> ResendQuote(int id, string? overrideEmail = null)
{
try
{
@@ -3284,10 +3328,12 @@ public class QuotesController : Controller
if (quote == null)
return Json(new { success = false, message = "Quote not found." });
var trimmedOverride = overrideEmail?.Trim();
// Determine recipient for feedback message
string? recipientEmail = quote.CustomerId.HasValue
? quote.Customer?.Email
: quote.ProspectEmail;
string? recipientEmail = !string.IsNullOrWhiteSpace(trimmedOverride)
? trimmedOverride
: (quote.CustomerId.HasValue ? quote.Customer?.Email : quote.ProspectEmail);
string recipientName = quote.CustomerId.HasValue && quote.Customer != null
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
@@ -3324,7 +3370,7 @@ public class QuotesController : Controller
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename);
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
// Check the most recent log entry to get actual send status
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
@@ -3743,6 +3789,147 @@ public class QuotesController : Controller
}
}
/// <summary>
/// Creates a 0-balance IsIncoming inventory item from a powder catalog entry so that
/// QR codes can be printed on work orders while the powder is still in transit.
/// Returns the new inventory item ID, or null if creation fails (non-fatal — the coat
/// falls back to custom-powder pricing without an inventory link).
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
// Match catalog vendor name to a company vendor record
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
var matchedVendor = vendors.FirstOrDefault(v =>
v.CompanyName.ToLower().Contains(vendorNameLower) ||
vendorNameLower.Contains(v.CompanyName.ToLower()));
// InventoryCategoryId is nullable — degrade gracefully rather than aborting if the
// company has not yet set up inventory categories (e.g., pre-seed).
var code = coatingCategory != null
? (coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
: "POWD";
var prefix = $"{code}-{DateTime.Now:yyMM}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
// Start with everything the catalog already has, then augment any null
// spec fields by fetching the product URL through the AI lookup service.
var description = catalogItem.Description;
var finish = catalogItem.Finish;
var colorFamilies = catalogItem.ColorFamilies;
var cureTemp = catalogItem.CureTemperatureF;
var cureTime = catalogItem.CureTimeMinutes;
var coverage = catalogItem.CoverageSqFtPerLb;
var transferEff = catalogItem.TransferEfficiency;
var specificGravity = catalogItem.SpecificGravity;
var imageUrl = catalogItem.ImageUrl;
var sdsUrl = catalogItem.SdsUrl;
var tdsUrl = catalogItem.TdsUrl;
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
(string.IsNullOrWhiteSpace(description) ||
string.IsNullOrWhiteSpace(colorFamilies) ||
cureTemp == null || cureTime == null);
if (needsAugment)
{
try
{
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
if (augmented.Success)
{
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
cureTemp ??= augmented.CureTemperatureF;
cureTime ??= augmented.CureTimeMinutes;
coverage ??= augmented.CoverageSqFtPerLb;
transferEff ??= augmented.TransferEfficiency;
specificGravity ??= augmented.SpecificGravity;
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
}
}
var item = new PowderCoating.Core.Entities.InventoryItem
{
SKU = sku,
Name = name,
Description = description,
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber = catalogItem.Sku,
Finish = finish,
ColorFamilies = colorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = coverage ?? 30m,
TransferEfficiency = transferEff ?? 65m,
CureTemperatureF = cureTemp,
CureTimeMinutes = cureTime,
SpecificGravity = specificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = imageUrl,
SdsUrl = sdsUrl,
TdsUrl = tdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
PrimaryVendorId = matchedVendor?.Id,
InventoryCategoryId = coatingCategory?.Id,
Category = coatingCategory?.DisplayName ?? "Powder Coating",
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
// Also update the coat DTO so pricing uses the inventory unit cost
coatDto.PowderCostPerLb = null; // clear manual price; pricing service reads from inventory
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
return null;
}
}
/// <summary>
/// After pricing is determined for an AI item, update the prediction record to flag whether
/// the user changed the AI's estimated surface area or unit price before accepting.