|
|
|
@@ -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.
|
|
|
|
|