Add platform powder catalog management UI with full CRUD and AI lookup

- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 00:27:44 -04:00
parent 713efbc2b6
commit 11a1b91be1
15 changed files with 8642 additions and 94 deletions
@@ -20,6 +20,8 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public class InventoryController : Controller
{
private const decimal DefaultTransferEfficiency = 65m;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<InventoryController> _logger;
@@ -745,7 +747,7 @@ public class InventoryController : Controller
if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies;
if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat;
if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb;
if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency;
result.TransferEfficiency ??= GetEffectiveTransferEfficiency(match.TransferEfficiency);
// URL / price fields: fill gaps only — AI may have found something better
result.ImageUrl ??= match.ImageUrl;
result.SpecPageUrl ??= match.ProductUrl;
@@ -775,7 +777,7 @@ public class InventoryController : Controller
ColorFamilies = result.ColorFamilies,
RequiresClearCoat = result.RequiresClearCoat,
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
TransferEfficiency = result.TransferEfficiency,
TransferEfficiency = GetEffectiveTransferEfficiency(result.TransferEfficiency),
ImageUrl = result.ImageUrl,
ProductUrl = result.SpecPageUrl,
SdsUrl = result.SdsUrl,
@@ -873,7 +875,7 @@ public class InventoryController : Controller
aiResult.CureTimeMinutes ??= full.CureTimeMinutes;
aiResult.RequiresClearCoat ??= full.RequiresClearCoat;
aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb;
aiResult.TransferEfficiency ??= full.TransferEfficiency;
aiResult.TransferEfficiency ??= GetEffectiveTransferEfficiency(full.TransferEfficiency);
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
aiResult.ColorName ??= full.ColorName;
aiResult.ColorCode ??= full.ColorCode;
@@ -952,7 +954,7 @@ public class InventoryController : Controller
colorFamilies = aiResult.ColorFamilies,
requiresClearCoat = aiResult.RequiresClearCoat,
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
transferEfficiency = aiResult.TransferEfficiency,
transferEfficiency = aiResult.TransferEfficiency ?? DefaultTransferEfficiency,
unitPrice = aiResult.UnitCostPerLb ?? 0m,
imageUrl = aiResult.ImageUrl,
productUrl = aiResult.SpecPageUrl,
@@ -1104,13 +1106,18 @@ public class InventoryController : Controller
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
})
.ToList();
return Json(results);
}
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{
return transferEfficiency ?? DefaultTransferEfficiency;
}
/// <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
@@ -1,9 +1,12 @@
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;
@@ -12,34 +15,324 @@ 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, 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()
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 = new PowderCatalogStatsDto
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))
{
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
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
};
return View(stats);
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,
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.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>
@@ -75,7 +368,7 @@ public class PowderCatalogController : Controller
}
else
{
TempData["Success"] = $"Import complete {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
TempData["Success"] = $"Import complete - {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
}
return RedirectToAction(nameof(Index));
@@ -126,7 +419,21 @@ public class PowderCatalogController : Controller
return Json(results);
}
// ── Private helpers ────────────────────────────────────────────────────────
// 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)
{
@@ -275,6 +582,184 @@ public class PowderCatalogController : Controller
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,
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>