Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item and quote pricing construction that was duplicated across create, rework copy, and quote-to-job conversion paths - BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by 6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment, Catalog) and BillsController + ExpensesController, removing 8 private copies - PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11 controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs, Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors) - AccountingDropdownHelper: single LoadAsync() call replaces duplicate vendor/account/job queries in BillsController and ExpensesController - JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate through JobTemplatesController snapshot copy and GetTemplatesJson projection, and JobsController template-application path - Test assertions updated for standardized BlobFileHelper error messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,8 @@ public class QuotesController : Controller
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly IJobItemAssemblyService _jobItemAssemblyService;
|
||||
private readonly IQuotePricingAssemblyService _quotePricingAssemblyService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly IQuotePhotoService _photoService;
|
||||
@@ -55,6 +57,8 @@ public class QuotesController : Controller
|
||||
ILookupCacheService lookupCache,
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
IJobItemAssemblyService jobItemAssemblyService,
|
||||
IQuotePricingAssemblyService quotePricingAssemblyService,
|
||||
IConfiguration configuration,
|
||||
IPlatformSettingsService platformSettings,
|
||||
IQuotePhotoService photoService,
|
||||
@@ -76,6 +80,8 @@ public class QuotesController : Controller
|
||||
_lookupCache = lookupCache;
|
||||
_notificationService = notificationService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_jobItemAssemblyService = jobItemAssemblyService;
|
||||
_quotePricingAssemblyService = quotePricingAssemblyService;
|
||||
_configuration = configuration;
|
||||
_platformSettings = platformSettings;
|
||||
_photoService = photoService;
|
||||
@@ -198,14 +204,9 @@ public class QuotesController : Controller
|
||||
.Contains(tagLower)).ToList();
|
||||
}
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<QuoteListDto>
|
||||
{
|
||||
Items = quoteDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count
|
||||
};
|
||||
var pagedResult = PagedResult<QuoteListDto>.From(
|
||||
gridRequest, quoteDtos,
|
||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -914,140 +915,19 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||
|
||||
// Add quote
|
||||
await _unitOfWork.Quotes.AddAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Create quote items with calculated pricing
|
||||
var itemResults = new List<QuoteItem>();
|
||||
foreach (var itemDto in dto.QuoteItems)
|
||||
{
|
||||
var item = _mapper.Map<QuoteItem>(itemDto);
|
||||
item.QuoteId = quote.Id;
|
||||
item.CompanyId = currentUser.CompanyId;
|
||||
|
||||
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Sales/merchandise items: use the manually entered price directly — no coating calculation
|
||||
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Catalog items: if they have coats, calculate with coats; otherwise use default price
|
||||
else if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
// If catalog item has coats, calculate the full price with coat costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use catalog default price
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the pricing service
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
// Flag whether the user overrode the AI's estimates before accepting
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
// Map coats for this item with calculated costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
item.Coats = new List<QuoteItemCoat>();
|
||||
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;
|
||||
|
||||
// Calculate and store the coat costs
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
currentUser.CompanyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
|
||||
item.Coats.Add(coat);
|
||||
}
|
||||
}
|
||||
|
||||
// Map per-item prep services
|
||||
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
|
||||
{
|
||||
item.PrepServices = new List<QuoteItemPrepService>();
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
|
||||
prepService.CompanyId = currentUser.CompanyId;
|
||||
item.PrepServices.Add(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
itemResults.Add(item);
|
||||
}
|
||||
var itemResults = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
|
||||
dto.QuoteItems,
|
||||
quote.Id,
|
||||
currentUser.CompanyId,
|
||||
ovenRateOverride,
|
||||
DateTime.UtcNow);
|
||||
|
||||
foreach (var item in itemResults)
|
||||
{
|
||||
@@ -1444,23 +1324,7 @@ public class QuotesController : Controller
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||
|
||||
// Track changes
|
||||
var changeHistories = new List<QuoteChangeHistory>();
|
||||
@@ -1704,121 +1568,25 @@ public class QuotesController : Controller
|
||||
|
||||
// Create new quote items with calculated pricing
|
||||
var newItemsForComparison = new List<(string Description, decimal Quantity, decimal UnitPrice, decimal TotalPrice, bool Sandblasting, bool Masking, decimal? SurfaceArea, string? Notes)>();
|
||||
foreach (var itemDto in dto.QuoteItems)
|
||||
var assembledItems = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
|
||||
dto.QuoteItems,
|
||||
quote.Id,
|
||||
currentUser.CompanyId,
|
||||
ovenRateOverride,
|
||||
DateTime.UtcNow);
|
||||
|
||||
foreach (var item in assembledItems)
|
||||
{
|
||||
var item = _mapper.Map<QuoteItem>(itemDto);
|
||||
item.QuoteId = quote.Id;
|
||||
item.CompanyId = currentUser.CompanyId;
|
||||
|
||||
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask} (from DTO: Sand={DtoSand}, Mask={DtoMask})",
|
||||
item.Description, item.RequiresSandblasting, item.RequiresMasking,
|
||||
itemDto.RequiresSandblasting, itemDto.RequiresMasking);
|
||||
|
||||
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Sales/merchandise items: use the manually entered price directly — no coating calculation
|
||||
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Catalog items: if they have coats, calculate with coats; otherwise use default price
|
||||
else if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
// If catalog item has coats, calculate the full price with coat costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use catalog default price
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the pricing service
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
// Flag whether the user overrode the AI's estimates before accepting
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask}",
|
||||
item.Description, item.RequiresSandblasting, item.RequiresMasking);
|
||||
|
||||
await _unitOfWork.QuoteItems.AddAsync(item);
|
||||
|
||||
// Map coats for this item with calculated costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
if (item.Coats?.Any() == true)
|
||||
{
|
||||
item.Coats = new List<QuoteItemCoat>();
|
||||
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;
|
||||
|
||||
// Calculate and store the coat costs
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
currentUser.CompanyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
|
||||
item.Coats.Add(coat);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description);
|
||||
}
|
||||
|
||||
// Map per-item prep services
|
||||
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
|
||||
{
|
||||
item.PrepServices = new List<QuoteItemPrepService>();
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
|
||||
prepService.CompanyId = currentUser.CompanyId;
|
||||
item.PrepServices.Add(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
// Track new item for comparison
|
||||
newItemsForComparison.Add((
|
||||
item.Description ?? "",
|
||||
item.Quantity,
|
||||
@@ -3086,108 +2854,32 @@ public class QuotesController : Controller
|
||||
// Create job items from quote items
|
||||
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
||||
{
|
||||
// Get first coat's color information if available
|
||||
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = job.Id,
|
||||
Description = quoteItem.Description,
|
||||
Quantity = quoteItem.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = quoteItem.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = quoteItem.CatalogItemId,
|
||||
IsGenericItem = quoteItem.IsGenericItem,
|
||||
IsLaborItem = quoteItem.IsLaborItem,
|
||||
IsSalesItem = quoteItem.IsSalesItem,
|
||||
Sku = quoteItem.Sku,
|
||||
ManualUnitPrice = quoteItem.ManualUnitPrice,
|
||||
PowderCostOverride = quoteItem.PowderCostOverride,
|
||||
UnitPrice = quoteItem.UnitPrice,
|
||||
TotalPrice = quoteItem.TotalPrice,
|
||||
LaborCost = quoteItem.TotalPrice * 0.4m, // Estimated 40% labor cost
|
||||
RequiresSandblasting = quoteItem.RequiresSandblasting,
|
||||
RequiresMasking = quoteItem.RequiresMasking,
|
||||
EstimatedMinutes = quoteItem.EstimatedMinutes,
|
||||
Notes = quoteItem.Notes,
|
||||
Complexity = quoteItem.Complexity,
|
||||
AiTags = quoteItem.AiTags,
|
||||
AiPredictionId = quoteItem.AiPredictionId, // Share the same prediction record — no duplication
|
||||
// Catalog items are fixed-price — prep services must not add labor cost to them.
|
||||
// Non-catalog items default to true so prep service labor is included in the calculated price.
|
||||
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, job.Id, quote.CompanyId, createdAtUtc);
|
||||
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID
|
||||
|
||||
// Create JobItemCoat records for all coats from quote
|
||||
if (quoteItem.Coats != null && quoteItem.Coats.Any())
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
|
||||
{
|
||||
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
// Get color info from inventory item if available, otherwise use coat fields
|
||||
string colorName = quoteCoat.ColorName;
|
||||
string colorCode = quoteCoat.ColorCode;
|
||||
string finish = quoteCoat.Finish;
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
|
||||
coat.CoatName, coat.Sequence, coat.ColorName ?? "N/A", coat.ColorCode ?? "N/A");
|
||||
}
|
||||
|
||||
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
|
||||
{
|
||||
// Use inventory item information (takes precedence)
|
||||
colorName = quoteCoat.InventoryItem.Name;
|
||||
colorCode = quoteCoat.InventoryItem.ColorCode;
|
||||
finish = quoteCoat.InventoryItem.Finish;
|
||||
}
|
||||
|
||||
// Calculate PowderToOrder if not already stored on the quote coat
|
||||
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
|
||||
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
|
||||
var powderToOrder = (quoteCoat.PowderToOrder > 0)
|
||||
? quoteCoat.PowderToOrder
|
||||
: (quoteItem.SurfaceAreaSqFt > 0
|
||||
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
|
||||
: (decimal?)null);
|
||||
|
||||
var jobCoat = new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = quoteCoat.CoatName,
|
||||
Sequence = quoteCoat.Sequence,
|
||||
InventoryItemId = quoteCoat.InventoryItemId,
|
||||
ColorName = colorName,
|
||||
VendorId = quoteCoat.VendorId,
|
||||
ColorCode = colorCode,
|
||||
Finish = finish,
|
||||
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = quoteCoat.TransferEfficiency,
|
||||
PowderCostPerLb = quoteCoat.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = quoteCoat.Notes,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.JobItemCoats.AddAsync(jobCoat);
|
||||
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
|
||||
jobCoat.CoatName, jobCoat.Sequence, colorName ?? "N/A", colorCode ?? "N/A");
|
||||
}
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Aggregate unique prep services from all quote items and copy to job
|
||||
// Load from DB directly to ensure prep services are available regardless of caller's includes
|
||||
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
|
||||
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
|
||||
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
|
||||
var uniquePrepServiceIds = itemPrepServices
|
||||
// Aggregate unique prep services from the fully-loaded quote items and copy to job
|
||||
var uniquePrepServiceIds = fullItems
|
||||
.SelectMany(qi => qi.PrepServices)
|
||||
.Where(ps => !ps.IsDeleted)
|
||||
.Select(ps => ps.PrepServiceId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -3795,160 +3487,6 @@ public class QuotesController : Controller
|
||||
/// 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.
|
||||
/// This data powers the "AI accuracy" reporting queries.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
// Change is tracked by EF; will be persisted on the next CompleteAsync()
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a benchmark summary comparing the AI's estimate to historical completed jobs of
|
||||
/// similar complexity and surface area (±60% sqft range). The benchmark is displayed
|
||||
|
||||
Reference in New Issue
Block a user