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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user