Route Prismatic file import through the shared upsert

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:01:10 -04:00
parent 0f6eef5370
commit 8401bd77e8
2 changed files with 38 additions and 61 deletions
@@ -194,7 +194,7 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName); changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description); 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.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl); changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl); 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.MilThickness, v => dest.MilThickness = v, src.MilThickness);
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText); changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson); changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
changed |= dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF); changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
changed |= dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes); changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
changed |= dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat); 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.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges); changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category); changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
@@ -216,9 +216,15 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService
return changed; return changed;
} }
/// <summary>Sets a nullable-string property when it differs (ordinal compare); returns whether it changed.</summary> /// <summary>
/// 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.
/// </summary>
private static bool Set(Func<string?> get, Action<string?> set, string? newValue) private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
{ {
if (string.IsNullOrWhiteSpace(newValue))
return false;
if (!string.Equals(get(), newValue, StringComparison.Ordinal)) if (!string.Equals(get(), newValue, StringComparison.Ordinal))
{ {
set(newValue); set(newValue);
@@ -18,9 +18,12 @@ public class PowderCatalogController : Controller
{ {
private const decimal DefaultTransferEfficiency = 65m; private const decimal DefaultTransferEfficiency = 65m;
private const string JsonImportSource = "Manual JSON Import";
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IInventoryAiLookupService _aiLookupService; private readonly IInventoryAiLookupService _aiLookupService;
private readonly IColumbiaCatalogSyncService _columbiaSyncService; private readonly IColumbiaCatalogSyncService _columbiaSyncService;
private readonly IPowderCatalogUpsertService _upsertService;
private readonly IPlatformSettingsService _platformSettings; private readonly IPlatformSettingsService _platformSettings;
private readonly ILogger<PowderCatalogController> _logger; private readonly ILogger<PowderCatalogController> _logger;
@@ -28,12 +31,14 @@ public class PowderCatalogController : Controller
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IInventoryAiLookupService aiLookupService, IInventoryAiLookupService aiLookupService,
IColumbiaCatalogSyncService columbiaSyncService, IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings, IPlatformSettingsService platformSettings,
ILogger<PowderCatalogController> logger) ILogger<PowderCatalogController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_aiLookupService = aiLookupService; _aiLookupService = aiLookupService;
_columbiaSyncService = columbiaSyncService; _columbiaSyncService = columbiaSyncService;
_upsertService = upsertService;
_platformSettings = platformSettings; _platformSettings = platformSettings;
_logger = logger; _logger = logger;
} }
@@ -533,13 +538,10 @@ public class PowderCatalogController : Controller
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' 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 // Map the scrape format to catalog items, then hand off to the shared upsert path (same
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName)) // one the Columbia API sync uses) so there is a single insert/update/diff implementation.
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase); var mapped = new List<PowderCatalogItem>();
int skipped = 0, errors = 0;
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray()) foreach (var item in resultsEl.EnumerateArray())
{ {
@@ -553,49 +555,21 @@ public class PowderCatalogController : Controller
continue; continue;
} }
var rawDesc = item.GetStringOrNull("description"); mapped.Add(new PowderCatalogItem
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; VendorName = vendorName,
record.Description = cleanDesc; Source = JsonImportSource,
record.UnitPrice = unitPrice; Sku = sku,
record.PriceTiersJson = priceTiersJson; ColorName = colorName,
record.ImageUrl = item.GetStringOrNull("sample_image_url"); Description = StripBoilerplate(item.GetStringOrNull("description")),
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url"); UnitPrice = ExtractBasePrice(item),
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url"); PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null,
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"); ImageUrl = item.GetStringOrNull("sample_image_url"),
record.ProductUrl = item.GetStringOrNull("product_url"); SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
record.UpdatedAt = now; TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
record.LastSyncedAt = now; ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
await _unitOfWork.PowderCatalog.UpdateAsync(record); ProductUrl = item.GetStringOrNull("product_url"),
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) catch (Exception ex)
{ {
@@ -604,17 +578,14 @@ public class PowderCatalogController : Controller
} }
} }
if (toAdd.Any()) var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow);
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult return new PowderCatalogImportResult
{ {
Success = true, Success = true,
Inserted = inserted, Inserted = upsert.Inserted,
Updated = updated, Updated = upsert.Updated,
Skipped = skipped, Skipped = skipped + upsert.Skipped,
Errors = errors Errors = errors
}; };
} }