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:
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user