Extract shared catalog enrichment into EnrichFromCatalogAsync helper

AiLookup and ScanLabel were running separate catalog lookup + auto-contribute
code paths. Both now go through EnrichFromCatalogAsync so any future change
to catalog logic only needs to be made once.

- EnrichFromCatalogAsync: private helper that finds a matching PowderCatalogItem
  by SKU + manufacturer, overwrites AI-inferred spec fields with catalog values
  (catalog is authoritative), fills gaps for URL/price fields with ??=, and
  optionally auto-contributes new entries to the platform catalog. Returns
  (wasInCatalog, addedToCatalog) for callers that show UI badges.
- AiLookup: now calls EnrichFromCatalogAsync then ApplyTdsCureFallbackAsync
  before returning — same enrichment pipeline as ScanLabel.
- ScanLabel: replaced ~50-line inline catalog block with two helper calls.
  Return statement simplified from catalogMatch?.X ?? aiResult.X to just
  aiResult.X since EnrichFromCatalogAsync already merged catalog values in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:27:28 -04:00
parent 145da7b5c4
commit 7de65910e3
@@ -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);
}
/// <summary>
/// Looks up <paramref name="result"/> 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 <paramref name="autoContribute"/> 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 <paramref name="result"/> in place.
/// </summary>
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);
}
}
/// <summary>
/// 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,