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