Add platform powder catalog, catalog-first lookup, and label scanner

- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 16:36:25 -04:00
parent 90f333c8f3
commit 1fc79b77fe
25 changed files with 21279 additions and 23 deletions
@@ -682,6 +682,200 @@ public class InventoryController : Controller
return Json(result);
}
/// <summary>
/// Augments a catalog fill with cure specs, color families, and finish by fetching the
/// product's known URL and running it through Claude. Skips Serper — the URL is already
/// known from the catalog record so no search step is needed. Gated behind the same
/// AI Inventory Assist subscription flag as AiLookup.
/// </summary>
[HttpPost]
public async Task<IActionResult> AiAugmentFromUrl(
[FromForm] string? productUrl,
[FromForm] string? colorName)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." });
if (string.IsNullOrWhiteSpace(productUrl))
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
return Json(result);
}
/// <summary>
/// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
/// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
/// catalog, and — when the product is not yet in the catalog and enough data was extracted
/// — inserts it automatically as a user-contributed entry so future scans resolve instantly.
/// </summary>
[HttpPost]
public async Task<IActionResult> ScanLabel(
[FromForm] string? imageBase64,
[FromForm] string? mediaType,
[FromForm] string? qrUrl)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." });
InventoryAiLookupResult aiResult;
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page and let Claude extract specs from its content
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
// Vision path: Claude reads the label photo directly
aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");
}
else
{
return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
}
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();
}
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,
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);
}
}
return Json(new
{
success = true,
manufacturer = manufacturer,
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 ?? 0m,
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
sdsUrl = catalogMatch?.SdsUrl,
tdsUrl = catalogMatch?.TdsUrl,
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
reasoning = aiResult.Reasoning,
});
}
/// <summary>
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
/// to the AI Lookup, avoiding unnecessary API calls for known products.
/// </summary>
[HttpGet]
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>());
var term = q.Trim().ToLower();
var vendorTerm = vendor?.Trim().ToLower();
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
// When a vendor hint is provided, prefer records where VendorName matches,
// then fall back to all results so the user still sees cross-vendor options.
var results = matches
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new
{
id = p.Id,
vendorName = p.VendorName,
sku = p.Sku,
colorName = p.ColorName,
description = p.Description,
unitPrice = p.UnitPrice,
imageUrl = p.ImageUrl,
sdsUrl = p.SdsUrl,
tdsUrl = p.TdsUrl,
applicationGuideUrl = p.ApplicationGuideUrl,
productUrl = p.ProductUrl,
isDiscontinued = p.IsDiscontinued,
cureTemperatureF = p.CureTemperatureF,
cureTimeMinutes = p.CureTimeMinutes,
finish = p.Finish,
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency
});
return Json(results);
}
/// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted