From 8401bd77e806fc921ec4eff363c54514cb722668 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 17 Jun 2026 16:01:10 -0400 Subject: [PATCH] Route Prismatic file import through the shared upsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual JSON file import had its own insert/update loop; it now maps to PowderCatalogItem and calls IPowderCatalogUpsertService.UpsertAsync — the same path the Columbia API sync uses — so there is a single upsert/diff implementation (and the file import now gets inventory propagation for free). Items are tagged Source = "Manual JSON Import". Also makes the shared upsert merge-not-wipe: it only overwrites a field when the incoming feed provides a value (non-blank string, price > 0, nullable HasValue), so a partial feed like the Prismatic scrape (no cure/chemistry) can't null out data another source or enrichment populated. Co-Authored-By: Claude Opus 4.8 --- .../Services/PowderCatalogUpsertService.cs | 16 ++-- .../Controllers/PowderCatalogController.cs | 83 ++++++------------- 2 files changed, 38 insertions(+), 61 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs index ebf722a..77c8797 100644 --- a/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs +++ b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs @@ -194,7 +194,7 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName); changed |= Set(() => dest.Description, v => dest.Description = v, src.Description); - changed |= dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice); + changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice); changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson); changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl); changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl); @@ -205,9 +205,9 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness); changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText); changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson); - changed |= dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF); - changed |= dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes); - changed |= dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat); + changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF); + changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes); + changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat); changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies); changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges); changed |= Set(() => dest.Category, v => dest.Category = v, src.Category); @@ -216,9 +216,15 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService return changed; } - /// Sets a nullable-string property when it differs (ordinal compare); returns whether it changed. + /// + /// Sets a nullable-string property when the feed provides a non-blank value that differs. + /// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic + /// file import, which omits cure/chemistry) never nulls out existing data. + /// private static bool Set(Func get, Action set, string? newValue) { + if (string.IsNullOrWhiteSpace(newValue)) + return false; if (!string.Equals(get(), newValue, StringComparison.Ordinal)) { set(newValue); diff --git a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs index bea4b68..47f2e38 100644 --- a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs +++ b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs @@ -18,9 +18,12 @@ public class PowderCatalogController : Controller { private const decimal DefaultTransferEfficiency = 65m; + private const string JsonImportSource = "Manual JSON Import"; + private readonly IUnitOfWork _unitOfWork; private readonly IInventoryAiLookupService _aiLookupService; private readonly IColumbiaCatalogSyncService _columbiaSyncService; + private readonly IPowderCatalogUpsertService _upsertService; private readonly IPlatformSettingsService _platformSettings; private readonly ILogger _logger; @@ -28,12 +31,14 @@ public class PowderCatalogController : Controller IUnitOfWork unitOfWork, IInventoryAiLookupService aiLookupService, IColumbiaCatalogSyncService columbiaSyncService, + IPowderCatalogUpsertService upsertService, IPlatformSettingsService platformSettings, ILogger logger) { _unitOfWork = unitOfWork; _aiLookupService = aiLookupService; _columbiaSyncService = columbiaSyncService; + _upsertService = upsertService; _platformSettings = platformSettings; _logger = logger; } @@ -533,13 +538,10 @@ public class PowderCatalogController : Controller 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(); + // Map the scrape format to catalog items, then hand off to the shared upsert path (same + // one the Columbia API sync uses) so there is a single insert/update/diff implementation. + var mapped = new List(); + int skipped = 0, errors = 0; foreach (var item in resultsEl.EnumerateArray()) { @@ -553,49 +555,21 @@ public class PowderCatalogController : Controller 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)) + mapped.Add(new PowderCatalogItem { - 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++; - } + VendorName = vendorName, + Source = JsonImportSource, + Sku = sku, + ColorName = colorName, + Description = StripBoilerplate(item.GetStringOrNull("description")), + UnitPrice = ExtractBasePrice(item), + PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null, + 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"), + }); } catch (Exception ex) { @@ -604,17 +578,14 @@ public class PowderCatalogController : Controller } } - if (toAdd.Any()) - await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd); - - await _unitOfWork.CompleteAsync(); + var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow); return new PowderCatalogImportResult { Success = true, - Inserted = inserted, - Updated = updated, - Skipped = skipped, + Inserted = upsert.Inserted, + Updated = upsert.Updated, + Skipped = skipped + upsert.Skipped, Errors = errors }; }