diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 34305e0..69f8de2 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -679,7 +679,11 @@ public class InventoryController : Controller return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." }); var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber); - if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName); + if (result.Success) + { + await EnrichFromCatalogAsync(result, autoContribute: true); + await ApplyTdsCureFallbackAsync(result, colorName); + } return Json(result); } @@ -706,6 +710,92 @@ public class InventoryController : Controller return Json(result); } + /// + /// Looks up in the platform powder catalog by SKU + manufacturer. + /// If a match is found, catalog values overwrite Claude-inferred ones for spec fields + /// (catalog is the authoritative source) and fill gaps for URL/price fields. + /// If no match and is true, inserts a new catalog entry + /// so future lookups resolve instantly without an API call. + /// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges. + /// Mutates in place. + /// + private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync( + InventoryAiLookupResult result, bool autoContribute) + { + var sku = result.ManufacturerPartNumber?.Trim(); + var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim(); + var colorName = result.ColorName?.Trim(); + + PowderCatalogItem? match = null; + if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer)) + { + var skuLower = sku.ToLower(); + var mfrLower = manufacturer.ToLower(); + var hits = await _unitOfWork.PowderCatalog.FindAsync(p => + p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower)); + match = hits.FirstOrDefault(); + } + + if (match != null) + { + // Catalog is authoritative for spec fields — overwrite AI inference + if (match.Finish != null) result.Finish = match.Finish; + if (match.CureTemperatureF != null) result.CureTemperatureF = match.CureTemperatureF; + if (match.CureTimeMinutes != null) result.CureTimeMinutes = match.CureTimeMinutes; + if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies; + if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat; + if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb; + if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency; + // URL / price fields: fill gaps only — AI may have found something better + result.ImageUrl ??= match.ImageUrl; + result.SpecPageUrl ??= match.ProductUrl; + result.SdsUrl ??= match.SdsUrl; + result.TdsUrl ??= match.TdsUrl; + if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice; + return (true, false); + } + + if (!autoContribute + || string.IsNullOrEmpty(sku) + || string.IsNullOrEmpty(manufacturer) + || string.IsNullOrEmpty(colorName)) + return (false, false); + + // Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly + try + { + var newItem = new PowderCatalogItem + { + VendorName = manufacturer, + Sku = sku, + ColorName = colorName, + CureTemperatureF = result.CureTemperatureF, + CureTimeMinutes = result.CureTimeMinutes, + Finish = result.Finish, + ColorFamilies = result.ColorFamilies, + RequiresClearCoat = result.RequiresClearCoat, + CoverageSqFtPerLb = result.CoverageSqFtPerLb, + TransferEfficiency = result.TransferEfficiency, + ImageUrl = result.ImageUrl, + ProductUrl = result.SpecPageUrl, + SdsUrl = result.SdsUrl, + TdsUrl = result.TdsUrl, + IsUserContributed = true, + CreatedAt = DateTime.UtcNow, + }; + await _unitOfWork.PowderCatalog.AddAsync(newItem); + await _unitOfWork.CompleteAsync(); + _logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku); + return (false, true); + } + catch (Exception ex) + { + // Unique constraint violation means another request beat us — not an error + _logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message); + return (false, false); + } + } + /// /// If cure temperature or cure time is still missing after the primary lookup but a TDS URL /// was returned, fetches that page and asks Claude to extract only the cure schedule. @@ -799,71 +889,14 @@ public class InventoryController : Controller if (!aiResult.Success) return Json(new { success = false, errorMessage = aiResult.ErrorMessage }); - // Search catalog by SKU first (most precise), then fall back to color name var sku = aiResult.ManufacturerPartNumber?.Trim(); var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim(); var colorName = aiResult.ColorName?.Trim(); - PowderCatalogItem? catalogMatch = null; - if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer)) - { - var skuLower = sku.ToLower(); - var mfrLower = manufacturer.ToLower(); - var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p => - p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower)); - catalogMatch = skuMatches.FirstOrDefault(); - } + // Catalog lookup, merge, and auto-contribute — same logic as AiLookup button + var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true); - var wasInCatalog = catalogMatch != null; - var addedToCatalog = false; - - // Auto-contribute: insert into platform catalog if we have the minimum viable fields - // and this SKU isn't already there - if (!wasInCatalog - && !string.IsNullOrEmpty(sku) - && !string.IsNullOrEmpty(manufacturer) - && !string.IsNullOrEmpty(colorName)) - { - try - { - var newItem = new PowderCatalogItem - { - VendorName = manufacturer, - Sku = sku, - ColorName = colorName, - CureTemperatureF = aiResult.CureTemperatureF, - CureTimeMinutes = aiResult.CureTimeMinutes, - Finish = aiResult.Finish, - ColorFamilies = aiResult.ColorFamilies, - RequiresClearCoat = aiResult.RequiresClearCoat, - CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb, - TransferEfficiency= aiResult.TransferEfficiency, - ImageUrl = aiResult.ImageUrl, - ProductUrl = aiResult.SpecPageUrl, - SdsUrl = aiResult.SdsUrl, - TdsUrl = aiResult.TdsUrl, - IsUserContributed = true, - CreatedAt = DateTime.UtcNow, - }; - await _unitOfWork.PowderCatalog.AddAsync(newItem); - await _unitOfWork.CompleteAsync(); - addedToCatalog = true; - _logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku); - } - catch (Exception ex) - { - // Unique constraint violation means another request beat us — not an error - _logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message); - } - } - - // If cure specs are still missing but we have a TDS URL, try reading it. - // Prefer the catalog's TDS URL if present; the catalog record is more reliable. - // Temporarily merge the catalog TDS URL into aiResult so ApplyTdsCureFallbackAsync - // sees the best available URL and writes the result back into aiResult. - if (catalogMatch?.CureTemperatureF != null) aiResult.CureTemperatureF = catalogMatch.CureTemperatureF; - if (catalogMatch?.CureTimeMinutes != null) aiResult.CureTimeMinutes = catalogMatch.CureTimeMinutes; - aiResult.TdsUrl ??= catalogMatch?.TdsUrl; + // TDS cure fallback — same logic as AiLookup button await ApplyTdsCureFallbackAsync(aiResult, colorName); // Check if this product already exists in the tenant's inventory. @@ -913,18 +946,18 @@ public class InventoryController : Controller manufacturerPartNumber = sku, colorName = colorName, description = aiResult.Description, - finish = catalogMatch?.Finish ?? aiResult.Finish, - cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF, - cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes, - colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies, - requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat, - coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb, - transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency, - unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m, - imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl, - productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl, - sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl, - tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl, + finish = aiResult.Finish, + cureTemperatureF = aiResult.CureTemperatureF, + cureTimeMinutes = aiResult.CureTimeMinutes, + colorFamilies = aiResult.ColorFamilies, + requiresClearCoat = aiResult.RequiresClearCoat, + coverageSqFtPerLb = aiResult.CoverageSqFtPerLb, + transferEfficiency = aiResult.TransferEfficiency, + unitPrice = aiResult.UnitCostPerLb ?? 0m, + imageUrl = aiResult.ImageUrl, + productUrl = aiResult.SpecPageUrl, + sdsUrl = aiResult.SdsUrl, + tdsUrl = aiResult.TdsUrl, vendorName = manufacturer, wasInCatalog = wasInCatalog, addedToCatalog = addedToCatalog,