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 _logger; public PowderCatalogController( IUnitOfWork unitOfWork, IInventoryAiLookupService aiLookupService, ILogger logger) { _unitOfWork = unitOfWork; _aiLookupService = aiLookupService; _logger = logger; } /// /// Shows platform-level catalog stats and the import form. /// public async Task 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 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 { 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 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 Edit(int id) { var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id); if (entity == null) return NotFound(); return View(MapForm(entity)); } [HttpPost] [ValidateAntiForgeryToken] public async Task 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 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 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 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); } /// /// 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. /// [HttpPost] [RequestSizeLimit(50 * 1024 * 1024)] // 50 MB public async Task 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)); } /// /// 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. /// [AllowAnonymous] [Authorize] public async Task Lookup(string? q) { if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Json(Array.Empty()); 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 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(); 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 }; } /// /// Strips page-scrape boilerplate that starts at "PRODUCT SUPPORT" or "CUSTOMER SERVICE". /// Returns a trimmed first-paragraph description suitable for display. /// 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; } /// /// Extracts the base (lowest-quantity) unit price from the price_tiers array. /// Falls back to 0 if the array is missing or malformed. /// 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 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 ApplySort(IEnumerable 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 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; } } /// Extension helpers for reading nullable strings from JsonElement. 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; } }