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
@@ -0,0 +1,289 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PowderCatalogController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Shows platform-level catalog stats and the import form.
/// </summary>
public async Task<IActionResult> Index()
{
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
var list = all.ToList();
var stats = new PowderCatalogStatsDto
{
TotalProducts = list.Count,
ActiveProducts = list.Count(p => !p.IsDiscontinued),
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
UserContributedProducts = list.Count(p => p.IsUserContributed),
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
};
return View(stats);
}
/// <summary>
/// Accepts a JSON file upload (Prismatic Powders scrape format) and upserts all records
/// into PowderCatalogItems. Strips page-scrape boilerplate from descriptions.
/// Existing records matched by (VendorName, Sku) are updated in-place; new ones are inserted.
/// </summary>
[HttpPost]
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
public async Task<IActionResult> Import(IFormFile file, string vendorName = "Prismatic Powders")
{
if (file == null || file.Length == 0)
{
TempData["Error"] = "Please select a JSON file to import.";
return RedirectToAction(nameof(Index));
}
PowderCatalogImportResult result;
try
{
result = await ImportJsonAsync(file, vendorName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Powder catalog import failed");
TempData["Error"] = $"Import failed: {ex.Message}";
return RedirectToAction(nameof(Index));
}
if (!result.Success)
{
TempData["Error"] = result.ErrorMessage ?? "Import failed.";
}
else
{
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
/// SKU exact matches are ranked first; color name substring matches follow.
/// Returns up to 10 results. Accessible to all authenticated users so the inventory
/// Create/Edit form can call it without a separate policy.
/// </summary>
[AllowAnonymous]
[Authorize]
public async Task<IActionResult> Lookup(string? q)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<PowderCatalogLookupResult>());
var term = q.Trim();
// Exact SKU match first, then color name contains
var all = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term.ToLower() ||
p.ColorName.ToLower().Contains(term.ToLower()) ||
p.Sku.ToLower().Contains(term.ToLower()));
var results = all
.OrderBy(p => p.Sku.ToLower() == term.ToLower() ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new PowderCatalogLookupResult
{
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
})
.ToList();
return Json(results);
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
{
using var stream = file.OpenReadStream();
using var doc = await JsonDocument.ParseAsync(stream);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
resultsEl.ValueKind != JsonValueKind.Array)
{
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
}
// Load existing records for this vendor into a lookup dictionary
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray())
{
try
{
var sku = item.GetStringOrNull("sku");
var colorName = item.GetStringOrNull("color_name");
if (string.IsNullOrWhiteSpace(sku) || string.IsNullOrWhiteSpace(colorName))
{
skipped++;
continue;
}
var rawDesc = item.GetStringOrNull("description");
var cleanDesc = StripBoilerplate(rawDesc);
var unitPrice = ExtractBasePrice(item);
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
? tiersEl.GetRawText()
: null;
if (existing.TryGetValue(sku, out var record))
{
record.ColorName = colorName;
record.Description = cleanDesc;
record.UnitPrice = unitPrice;
record.PriceTiersJson = priceTiersJson;
record.ImageUrl = item.GetStringOrNull("sample_image_url");
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
record.ProductUrl = item.GetStringOrNull("product_url");
record.UpdatedAt = now;
record.LastSyncedAt = now;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
updated++;
}
else
{
toAdd.Add(new PowderCatalogItem
{
VendorName = vendorName,
Sku = sku,
ColorName = colorName,
Description = cleanDesc,
UnitPrice = unitPrice,
PriceTiersJson = priceTiersJson,
ImageUrl = item.GetStringOrNull("sample_image_url"),
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
ProductUrl = item.GetStringOrNull("product_url"),
CreatedAt = now,
LastSyncedAt = now
});
inserted++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipping catalog record due to parse error");
errors++;
}
}
if (toAdd.Any())
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult
{
Success = true,
Inserted = inserted,
Updated = updated,
Skipped = skipped,
Errors = errors
};
}
/// <summary>
/// Strips page-scrape boilerplate that starts at "PRODUCT SUPPORT" or "CUSTOMER SERVICE".
/// Returns a trimmed first-paragraph description suitable for display.
/// </summary>
private static string? StripBoilerplate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var cutpoints = new[] { "PRODUCT SUPPORT", "CUSTOMER SERVICE", "Q&As", "FAQs" };
var cut = raw.Length;
foreach (var cp in cutpoints)
{
var idx = raw.IndexOf(cp, StringComparison.OrdinalIgnoreCase);
if (idx > 0 && idx < cut)
cut = idx;
}
var cleaned = raw[..cut].Trim();
// Collapse multiple whitespace runs
cleaned = Regex.Replace(cleaned, @"\s{3,}", " ").Trim();
return cleaned.Length > 0 ? cleaned : null;
}
/// <summary>
/// Extracts the base (lowest-quantity) unit price from the price_tiers array.
/// Falls back to 0 if the array is missing or malformed.
/// </summary>
private static decimal ExtractBasePrice(JsonElement item)
{
if (!item.TryGetProperty("price_tiers", out var tiers) || tiers.ValueKind != JsonValueKind.Array)
return 0m;
// Find the tier with the lowest min (base price = smallest quantity break)
decimal? price = null;
int lowestMin = int.MaxValue;
foreach (var tier in tiers.EnumerateArray())
{
if (tier.TryGetProperty("min", out var minEl) && minEl.TryGetInt32(out var min) &&
tier.TryGetProperty("price", out var priceEl) && priceEl.TryGetDecimal(out var p))
{
if (min < lowestMin && min >= 1 && p > 0)
{
lowestMin = min;
price = p;
}
}
}
return price ?? 0m;
}
}
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
internal static class JsonElementExtensions
{
internal static string? GetStringOrNull(this JsonElement el, string property)
{
if (el.TryGetProperty(property, out var val) && val.ValueKind == JsonValueKind.String)
return val.GetString();
return null;
}
}