Add Columbia catalog mapper, shared upsert, and sync service
Phase 2: the mapping and sync core. - ColumbiaCatalogMapper (pure/static, unit-tested): maps an API product to a PowderCatalogItem. Derives manufacturer (PPG/KP Pigments/Columbia) from taxonomy+SKU; flags additives into the Powder Additives category; takes base price from the top-level price with variant fallback; captures variation / tiered pricing as JSON; parses the free-text cure schedule into all curves (three degree glyphs, @/at, multi-curve in order, partial-cure -> none) with the first as the primary temp/time; strips HTML descriptions; joins color groups; normalizes chemistry; flags clear-coat powders. - PowderCatalogUpsertService (IPowderCatalogUpsertService): single upsert path matching on (VendorName, SKU). Copies only feed-sourced fields and leaves enrichment fields (specific gravity, coverage, transfer efficiency, finish) untouched so syncs never wipe lazily-enriched TDS/AI data. - ColumbiaCatalogSyncService (IColumbiaCatalogSyncService): pulls the full catalog, maps + de-dupes, upserts, then reconciles discontinuations ONLY on a complete pull (a partial pull throws and aborts before the sweep). Reactivates reappearing items; records last-synced/last-result platform settings. - 25 mapper unit tests covering the cure parser, manufacturer derivation, simple/variable pricing, chemistry, color, and HTML cases from real records. Full suite green (261 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
|
||||
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
|
||||
/// </summary>
|
||||
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<PowderCatalogUpsertService> _logger;
|
||||
|
||||
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PowderCatalogUpsertResult> UpsertAsync(
|
||||
IReadOnlyList<PowderCatalogItem> incoming,
|
||||
DateTime runTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new PowderCatalogUpsertResult();
|
||||
|
||||
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
|
||||
var vendorNames = incoming
|
||||
.Select(i => i.VendorName)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
|
||||
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var toAdd = new List<PowderCatalogItem>();
|
||||
|
||||
foreach (var item in incoming)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
|
||||
{
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.TryGetValue(KeyOf(item), out var record))
|
||||
{
|
||||
if (ApplyFeedFields(record, item))
|
||||
{
|
||||
record.UpdatedAt = runTimestamp;
|
||||
record.LastSyncedAt = runTimestamp;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
||||
result.Updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Unchanged++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
item.CreatedAt = runTimestamp;
|
||||
item.LastSyncedAt = runTimestamp;
|
||||
toAdd.Add(item);
|
||||
result.Inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0)
|
||||
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped.",
|
||||
result.Inserted, result.Updated, result.Unchanged, result.Skipped);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
|
||||
|
||||
/// <summary>
|
||||
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
|
||||
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
|
||||
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
|
||||
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
|
||||
/// </summary>
|
||||
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
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 |= 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);
|
||||
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
|
||||
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
|
||||
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
|
||||
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
|
||||
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 |= 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);
|
||||
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Sets a nullable-string property when it differs (ordinal compare); returns whether it changed.</summary>
|
||||
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
|
||||
{
|
||||
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
|
||||
{
|
||||
set(newValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
|
||||
private static bool Assign(Action assign)
|
||||
{
|
||||
assign();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user