779 lines
30 KiB
C#
779 lines
30 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Application.DTOs.Common;
|
|
using PowderCoating.Application.DTOs.Inventory;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
using PowderCoating.Web.ViewModels.PowderCatalog;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class PowderCatalogController : Controller
|
|
{
|
|
private const decimal DefaultTransferEfficiency = 65m;
|
|
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IInventoryAiLookupService _aiLookupService;
|
|
private readonly ILogger<PowderCatalogController> _logger;
|
|
|
|
public PowderCatalogController(
|
|
IUnitOfWork unitOfWork,
|
|
IInventoryAiLookupService aiLookupService,
|
|
ILogger<PowderCatalogController> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_aiLookupService = aiLookupService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows platform-level catalog stats and the import form.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(
|
|
string? searchTerm,
|
|
string? vendorName,
|
|
string status = "all",
|
|
string source = "all",
|
|
string completeness = "all",
|
|
string? sortColumn = null,
|
|
string sortDirection = "asc",
|
|
int pageNumber = 1,
|
|
int pageSize = 25)
|
|
{
|
|
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
|
|
var list = all.ToList();
|
|
|
|
var stats = BuildStats(list);
|
|
var vendors = list
|
|
.Select(p => p.VendorName)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(v => v)
|
|
.ToList();
|
|
|
|
status = NormalizeFilter(status, "all", "active", "discontinued");
|
|
source = NormalizeFilter(source, "all", "curated", "contributed");
|
|
completeness = NormalizeFilter(completeness, "all", "ready", "missing-specs", "missing-docs", "missing-image");
|
|
sortDirection = sortDirection?.Equals("desc", StringComparison.OrdinalIgnoreCase) == true ? "desc" : "asc";
|
|
sortColumn = NormalizeSortColumn(sortColumn);
|
|
pageNumber = Math.Max(pageNumber, 1);
|
|
pageSize = pageSize is < 1 or > 100 ? 25 : pageSize;
|
|
|
|
IEnumerable<PowderCatalogItem> query = list;
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
var term = searchTerm.Trim();
|
|
query = query.Where(p =>
|
|
ContainsIgnoreCase(p.VendorName, term) ||
|
|
ContainsIgnoreCase(p.Sku, term) ||
|
|
ContainsIgnoreCase(p.ColorName, term) ||
|
|
ContainsIgnoreCase(p.Description, term) ||
|
|
ContainsIgnoreCase(p.Finish, term));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(vendorName))
|
|
query = query.Where(p => string.Equals(p.VendorName, vendorName.Trim(), StringComparison.OrdinalIgnoreCase));
|
|
|
|
query = status switch
|
|
{
|
|
"active" => query.Where(p => !p.IsDiscontinued),
|
|
"discontinued" => query.Where(p => p.IsDiscontinued),
|
|
_ => query
|
|
};
|
|
|
|
query = source switch
|
|
{
|
|
"curated" => query.Where(p => !p.IsUserContributed),
|
|
"contributed" => query.Where(p => p.IsUserContributed),
|
|
_ => query
|
|
};
|
|
|
|
query = completeness switch
|
|
{
|
|
"ready" => query.Where(IsCatalogReady),
|
|
"missing-specs" => query.Where(p => !HasCoreSpecs(p)),
|
|
"missing-docs" => query.Where(p => !HasDocuments(p)),
|
|
"missing-image" => query.Where(p => string.IsNullOrWhiteSpace(p.ImageUrl)),
|
|
_ => query
|
|
};
|
|
|
|
query = ApplySort(query, sortColumn, sortDirection);
|
|
|
|
var totalCount = query.Count();
|
|
var items = query
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(MapListItem)
|
|
.ToList();
|
|
|
|
var vm = new PowderCatalogIndexViewModel
|
|
{
|
|
Stats = stats,
|
|
Vendors = vendors,
|
|
SearchTerm = searchTerm,
|
|
VendorName = vendorName,
|
|
Status = status,
|
|
Source = source,
|
|
Completeness = completeness,
|
|
SortColumn = sortColumn,
|
|
SortDirection = sortDirection,
|
|
Catalog = new PagedResult<PowderCatalogListItemViewModel>
|
|
{
|
|
Items = items,
|
|
PageNumber = pageNumber,
|
|
PageSize = pageSize,
|
|
TotalCount = totalCount,
|
|
SortColumn = sortColumn,
|
|
SortDirection = sortDirection,
|
|
SearchTerm = searchTerm
|
|
}
|
|
};
|
|
|
|
return View(vm);
|
|
}
|
|
|
|
public IActionResult Create()
|
|
{
|
|
return View(new PowderCatalogFormViewModel
|
|
{
|
|
CreatedAt = DateTime.UtcNow,
|
|
TransferEfficiency = DefaultTransferEfficiency
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Create(PowderCatalogFormViewModel model)
|
|
{
|
|
NormalizeModel(model);
|
|
|
|
if (!ModelState.IsValid)
|
|
return View(model);
|
|
|
|
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku))
|
|
{
|
|
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
|
return View(model);
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var entity = new PowderCatalogItem
|
|
{
|
|
VendorName = model.VendorName,
|
|
Sku = model.Sku,
|
|
ColorName = model.ColorName,
|
|
Description = NullIfWhiteSpace(model.Description),
|
|
UnitPrice = model.UnitPrice,
|
|
ImageUrl = NullIfWhiteSpace(model.ImageUrl),
|
|
SdsUrl = NullIfWhiteSpace(model.SdsUrl),
|
|
TdsUrl = NullIfWhiteSpace(model.TdsUrl),
|
|
ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl),
|
|
ProductUrl = NullIfWhiteSpace(model.ProductUrl),
|
|
CureTemperatureF = model.CureTemperatureF,
|
|
CureTimeMinutes = model.CureTimeMinutes,
|
|
Finish = NullIfWhiteSpace(model.Finish),
|
|
ColorFamilies = NullIfWhiteSpace(model.ColorFamilies),
|
|
RequiresClearCoat = model.RequiresClearCoat,
|
|
CoverageSqFtPerLb = model.CoverageSqFtPerLb,
|
|
SpecificGravity = model.SpecificGravity,
|
|
TransferEfficiency = model.TransferEfficiency,
|
|
IsDiscontinued = model.IsDiscontinued,
|
|
IsUserContributed = model.IsUserContributed,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
try
|
|
{
|
|
await _unitOfWork.PowderCatalog.AddAsync(entity);
|
|
await _unitOfWork.CompleteAsync();
|
|
TempData["Success"] = $"Added powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating powder catalog item {VendorName} {Sku}", model.VendorName, model.Sku);
|
|
TempData["Error"] = "An error occurred while creating the powder catalog item.";
|
|
return View(model);
|
|
}
|
|
}
|
|
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
|
if (entity == null)
|
|
return NotFound();
|
|
|
|
return View(MapForm(entity));
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(int id, PowderCatalogFormViewModel model)
|
|
{
|
|
if (id != model.Id)
|
|
return NotFound();
|
|
|
|
NormalizeModel(model);
|
|
|
|
if (!ModelState.IsValid)
|
|
return View(model);
|
|
|
|
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
|
if (entity == null)
|
|
return NotFound();
|
|
|
|
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku, id))
|
|
{
|
|
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
|
return View(model);
|
|
}
|
|
|
|
entity.VendorName = model.VendorName;
|
|
entity.Sku = model.Sku;
|
|
entity.ColorName = model.ColorName;
|
|
entity.Description = NullIfWhiteSpace(model.Description);
|
|
entity.UnitPrice = model.UnitPrice;
|
|
entity.ImageUrl = NullIfWhiteSpace(model.ImageUrl);
|
|
entity.SdsUrl = NullIfWhiteSpace(model.SdsUrl);
|
|
entity.TdsUrl = NullIfWhiteSpace(model.TdsUrl);
|
|
entity.ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl);
|
|
entity.ProductUrl = NullIfWhiteSpace(model.ProductUrl);
|
|
entity.CureTemperatureF = model.CureTemperatureF;
|
|
entity.CureTimeMinutes = model.CureTimeMinutes;
|
|
entity.Finish = NullIfWhiteSpace(model.Finish);
|
|
entity.ColorFamilies = NullIfWhiteSpace(model.ColorFamilies);
|
|
entity.RequiresClearCoat = model.RequiresClearCoat;
|
|
entity.CoverageSqFtPerLb = model.CoverageSqFtPerLb;
|
|
entity.SpecificGravity = model.SpecificGravity;
|
|
entity.TransferEfficiency = model.TransferEfficiency;
|
|
entity.IsDiscontinued = model.IsDiscontinued;
|
|
entity.IsUserContributed = model.IsUserContributed;
|
|
entity.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
|
await _unitOfWork.CompleteAsync();
|
|
TempData["Success"] = $"Updated powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating powder catalog item {Id}", id);
|
|
TempData["Error"] = "An error occurred while updating the powder catalog item.";
|
|
return View(model);
|
|
}
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ToggleDiscontinued(int id)
|
|
{
|
|
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
|
if (entity == null)
|
|
return NotFound();
|
|
|
|
entity.IsDiscontinued = !entity.IsDiscontinued;
|
|
entity.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
|
await _unitOfWork.CompleteAsync();
|
|
TempData["Success"] = entity.IsDiscontinued
|
|
? $"Marked \"{entity.ColorName}\" as discontinued."
|
|
: $"Reactivated \"{entity.ColorName}\".";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error toggling discontinued status for powder catalog item {Id}", id);
|
|
TempData["Error"] = "An error occurred while updating the catalog item status.";
|
|
}
|
|
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AiLookup(
|
|
[FromForm] string? vendorName,
|
|
[FromForm] string? colorName,
|
|
[FromForm] string? sku)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(vendorName)
|
|
&& string.IsNullOrWhiteSpace(colorName)
|
|
&& string.IsNullOrWhiteSpace(sku))
|
|
{
|
|
return Json(new { success = false, errorMessage = "Enter a vendor, color name, or SKU first." });
|
|
}
|
|
|
|
var result = await _aiLookupService.LookupAsync(vendorName, colorName, null, sku);
|
|
if (result.Success)
|
|
await ApplyTdsCureFallbackAsync(result, colorName);
|
|
|
|
return Json(result);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AiAugmentFromUrl(
|
|
[FromForm] string? productUrl,
|
|
[FromForm] string? colorName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(productUrl))
|
|
return Json(new { success = false, errorMessage = "No product URL provided." });
|
|
|
|
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
|
if (result.Success)
|
|
await ApplyTdsCureFallbackAsync(result, colorName);
|
|
|
|
return Json(result);
|
|
}
|
|
|
|
/// <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,
|
|
SpecificGravity = p.SpecificGravity,
|
|
IsDiscontinued = p.IsDiscontinued
|
|
})
|
|
.ToList();
|
|
|
|
return Json(results);
|
|
}
|
|
|
|
// Private helpers
|
|
|
|
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
|
{
|
|
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
|
|
&& !string.IsNullOrWhiteSpace(result.TdsUrl))
|
|
{
|
|
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
|
|
if (tds.Success)
|
|
{
|
|
result.CureTemperatureF ??= tds.CureTemperatureF;
|
|
result.CureTimeMinutes ??= tds.CureTimeMinutes;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static PowderCatalogStatsDto BuildStats(List<PowderCatalogItem> list)
|
|
{
|
|
return 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
|
|
};
|
|
}
|
|
|
|
private static IEnumerable<PowderCatalogItem> ApplySort(IEnumerable<PowderCatalogItem> query, string sortColumn, string sortDirection)
|
|
{
|
|
var descending = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
|
|
|
return (sortColumn, descending) switch
|
|
{
|
|
("Sku", false) => query.OrderBy(p => p.Sku).ThenBy(p => p.ColorName),
|
|
("Sku", true) => query.OrderByDescending(p => p.Sku).ThenBy(p => p.ColorName),
|
|
("ColorName", false) => query.OrderBy(p => p.ColorName).ThenBy(p => p.Sku),
|
|
("ColorName", true) => query.OrderByDescending(p => p.ColorName).ThenBy(p => p.Sku),
|
|
("UnitPrice", false) => query.OrderBy(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
|
("UnitPrice", true) => query.OrderByDescending(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
|
("Finish", false) => query.OrderBy(p => p.Finish).ThenBy(p => p.ColorName),
|
|
("Finish", true) => query.OrderByDescending(p => p.Finish).ThenBy(p => p.ColorName),
|
|
("UpdatedAt", false) => query.OrderBy(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
|
("UpdatedAt", true) => query.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
|
("LastSyncedAt", false) => query.OrderBy(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
|
("LastSyncedAt", true) => query.OrderByDescending(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
|
("VendorName", true) => query.OrderByDescending(p => p.VendorName).ThenBy(p => p.ColorName),
|
|
_ => query.OrderBy(p => p.VendorName).ThenBy(p => p.ColorName)
|
|
};
|
|
}
|
|
|
|
private static PowderCatalogListItemViewModel MapListItem(PowderCatalogItem item)
|
|
{
|
|
return new PowderCatalogListItemViewModel
|
|
{
|
|
Id = item.Id,
|
|
VendorName = item.VendorName,
|
|
Sku = item.Sku,
|
|
ColorName = item.ColorName,
|
|
Finish = item.Finish,
|
|
UnitPrice = item.UnitPrice,
|
|
IsDiscontinued = item.IsDiscontinued,
|
|
IsUserContributed = item.IsUserContributed,
|
|
HasImage = !string.IsNullOrWhiteSpace(item.ImageUrl),
|
|
HasCoreSpecs = HasCoreSpecs(item),
|
|
HasDocuments = HasDocuments(item),
|
|
CreatedAt = item.CreatedAt,
|
|
UpdatedAt = item.UpdatedAt,
|
|
LastSyncedAt = item.LastSyncedAt
|
|
};
|
|
}
|
|
|
|
private static PowderCatalogFormViewModel MapForm(PowderCatalogItem item)
|
|
{
|
|
return new PowderCatalogFormViewModel
|
|
{
|
|
Id = item.Id,
|
|
VendorName = item.VendorName,
|
|
Sku = item.Sku,
|
|
ColorName = item.ColorName,
|
|
Description = item.Description,
|
|
UnitPrice = item.UnitPrice,
|
|
ImageUrl = item.ImageUrl,
|
|
SdsUrl = item.SdsUrl,
|
|
TdsUrl = item.TdsUrl,
|
|
ApplicationGuideUrl = item.ApplicationGuideUrl,
|
|
ProductUrl = item.ProductUrl,
|
|
CureTemperatureF = item.CureTemperatureF,
|
|
CureTimeMinutes = item.CureTimeMinutes,
|
|
Finish = item.Finish,
|
|
ColorFamilies = item.ColorFamilies,
|
|
RequiresClearCoat = item.RequiresClearCoat,
|
|
CoverageSqFtPerLb = item.CoverageSqFtPerLb,
|
|
SpecificGravity = item.SpecificGravity,
|
|
TransferEfficiency = GetEffectiveTransferEfficiency(item.TransferEfficiency),
|
|
IsDiscontinued = item.IsDiscontinued,
|
|
IsUserContributed = item.IsUserContributed,
|
|
CreatedAt = item.CreatedAt,
|
|
UpdatedAt = item.UpdatedAt,
|
|
LastSyncedAt = item.LastSyncedAt
|
|
};
|
|
}
|
|
|
|
private static bool HasCoreSpecs(PowderCatalogItem item)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(item.Finish)
|
|
&& item.CureTemperatureF.HasValue
|
|
&& item.CureTimeMinutes.HasValue
|
|
&& item.CoverageSqFtPerLb.HasValue
|
|
&& GetEffectiveTransferEfficiency(item.TransferEfficiency).HasValue;
|
|
}
|
|
|
|
private static bool HasDocuments(PowderCatalogItem item)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(item.ProductUrl)
|
|
&& !string.IsNullOrWhiteSpace(item.SdsUrl)
|
|
&& !string.IsNullOrWhiteSpace(item.TdsUrl);
|
|
}
|
|
|
|
private static bool IsCatalogReady(PowderCatalogItem item)
|
|
{
|
|
return HasCoreSpecs(item)
|
|
&& HasDocuments(item)
|
|
&& !string.IsNullOrWhiteSpace(item.ImageUrl);
|
|
}
|
|
|
|
private static bool ContainsIgnoreCase(string? value, string searchTerm)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(value)
|
|
&& value.Contains(searchTerm, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string NormalizeFilter(string? value, string fallback, params string[] allowedValues)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return fallback;
|
|
|
|
return allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase)
|
|
? value.ToLowerInvariant()
|
|
: fallback;
|
|
}
|
|
|
|
private static string NormalizeSortColumn(string? sortColumn)
|
|
{
|
|
var allowed = new[]
|
|
{
|
|
"VendorName", "Sku", "ColorName", "Finish", "UnitPrice", "UpdatedAt", "LastSyncedAt"
|
|
};
|
|
|
|
return allowed.Contains(sortColumn, StringComparer.OrdinalIgnoreCase)
|
|
? allowed.First(s => s.Equals(sortColumn, StringComparison.OrdinalIgnoreCase))
|
|
: "VendorName";
|
|
}
|
|
|
|
private async Task<bool> CatalogRecordExistsAsync(string vendorName, string sku, int? excludeId = null)
|
|
{
|
|
var existing = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
|
p.VendorName.ToLower() == vendorName.ToLower() &&
|
|
p.Sku.ToLower() == sku.ToLower());
|
|
|
|
return existing.Any(p => excludeId == null || p.Id != excludeId.Value);
|
|
}
|
|
|
|
private static void NormalizeModel(PowderCatalogFormViewModel model)
|
|
{
|
|
model.VendorName = model.VendorName?.Trim() ?? string.Empty;
|
|
model.Sku = model.Sku?.Trim() ?? string.Empty;
|
|
model.ColorName = model.ColorName?.Trim() ?? string.Empty;
|
|
model.Description = TrimToNull(model.Description);
|
|
model.ImageUrl = TrimToNull(model.ImageUrl);
|
|
model.SdsUrl = TrimToNull(model.SdsUrl);
|
|
model.TdsUrl = TrimToNull(model.TdsUrl);
|
|
model.ApplicationGuideUrl = TrimToNull(model.ApplicationGuideUrl);
|
|
model.ProductUrl = TrimToNull(model.ProductUrl);
|
|
model.Finish = TrimToNull(model.Finish);
|
|
model.ColorFamilies = TrimToNull(model.ColorFamilies);
|
|
model.TransferEfficiency ??= DefaultTransferEfficiency;
|
|
}
|
|
|
|
private static string? TrimToNull(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
private static string? NullIfWhiteSpace(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
|
}
|
|
|
|
private static decimal? GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
|
{
|
|
return transferEfficiency ?? DefaultTransferEfficiency;
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|