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
};
}