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