diff --git a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs index 217c4be..10e8c75 100644 --- a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs +++ b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs @@ -54,10 +54,11 @@ public class ColumbiaProduct /// /// Quantity-break pricing. POLYMORPHIC on the wire: an object /// ({type, minimum_quantity, tiers:[...]}) on simple products, but an empty ARRAY - /// ([]) on variable products. Captured as a raw so - /// deserialization never throws on the type mismatch; the mapper interprets it. + /// ([]) on variable products. Captured as a nullable raw so + /// deserialization never throws on the type mismatch and an absent value is null (not an + /// invalid Undefined element); the mapper interprets it. /// - public JsonElement TieredPricing { get; set; } + public JsonElement? TieredPricing { get; set; } /// Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes). /// Each variant has its own SKU and price. Empty for simple products. @@ -108,8 +109,8 @@ public class ColumbiaVariationPricing public string RegularPrice { get; set; } = string.Empty; public string SalePrice { get; set; } = string.Empty; - /// Same polymorphic object-or-array shape as the parent; captured raw. - public JsonElement TieredPricing { get; set; } + /// Same polymorphic object-or-array shape as the parent; captured raw (nullable). + public JsonElement? TieredPricing { get; set; } } /// An image object — featured or gallery. diff --git a/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs b/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs new file mode 100644 index 0000000..1e319bd --- /dev/null +++ b/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs @@ -0,0 +1,41 @@ +namespace PowderCoating.Application.Interfaces; + +/// +/// Orchestrates a full Columbia Coatings catalog sync: pull every product, map and upsert it, then +/// (only on a complete pull) reconcile discontinuations. Used by both the scheduled background job +/// and the manual "Sync now" admin action. +/// +public interface IColumbiaCatalogSyncService +{ + /// + /// Runs one full sync. Assumes the caller has already decided it should run (enabled / due). + /// Returns a result describing the outcome; never throws for an expected failure (not + /// configured, partial pull, HTTP error) — those are reported on the result instead. + /// + Task RunSyncAsync(CancellationToken cancellationToken = default); +} + +/// Outcome of a Columbia catalog sync run. +public class ColumbiaSyncResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + + public int TotalFetched { get; set; } + public int Inserted { get; set; } + public int Updated { get; set; } + public int Unchanged { get; set; } + public int Skipped { get; set; } + public int Discontinued { get; set; } + public int Reactivated { get; set; } + + public DateTime StartedAt { get; set; } + public TimeSpan Duration { get; set; } + + /// One-line summary suitable for storing in the last-result platform setting / UI. + public string Summary => + Success + ? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " + + $"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)" + : $"Failed: {ErrorMessage}"; +} diff --git a/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs b/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs new file mode 100644 index 0000000..709f1d0 --- /dev/null +++ b/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs @@ -0,0 +1,32 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Application.Interfaces; + +/// +/// Shared upsert for the platform powder catalog: matches incoming records to existing rows by +/// (VendorName, SKU), inserts new ones, and updates changed ones in place. Used by BOTH the manual +/// JSON file import and the Columbia API sync so there is a single upsert path, only the mapping +/// differs. Does NOT handle discontinuation — that is a sync-specific concern. +/// +public interface IPowderCatalogUpsertService +{ + /// + /// Applies mapped catalog items. Only fields sourced from the feed + /// are copied on update; enrichment fields (specific gravity, coverage, transfer efficiency, + /// finish) are preserved so they are not wiped by a feed that never carries them. Changed and + /// inserted rows get stamped on LastSyncedAt/UpdatedAt. + /// + Task UpsertAsync( + IReadOnlyList incoming, + DateTime runTimestamp, + CancellationToken cancellationToken = default); +} + +/// Counts from an upsert run. +public class PowderCatalogUpsertResult +{ + public int Inserted { get; set; } + public int Updated { get; set; } + public int Unchanged { get; set; } + public int Skipped { get; set; } +} diff --git a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs new file mode 100644 index 0000000..5c82d26 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs @@ -0,0 +1,256 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using PowderCoating.Application.Constants; +using PowderCoating.Application.DTOs.Columbia; +using PowderCoating.Core.Entities; + +namespace PowderCoating.Infrastructure.Services.Columbia; + +/// +/// Maps a Columbia Coatings API product onto our platform . +/// Pure, static, side-effect free so the tricky bits (manufacturer derivation, the free-text +/// cure-schedule parser, HTML stripping) can be unit tested directly against captured fixtures. +/// +/// Columbia is a distributor reselling multiple brands, so +/// holds the DERIVED manufacturer (PPG / KP Pigments / Columbia) while +/// records the feed ("Columbia Coatings API") for +/// right-to-delete purges. The vendor's own categories/tags are read here only to derive the +/// manufacturer and additive flag — they are never stored raw. +/// +/// +public static class ColumbiaCatalogMapper +{ + /// A single parsed cure curve — hold at . + public readonly record struct CureCurve(int TempF, int Minutes); + + // Resin chemistries that mean "polyester + TGIC" but arrive formatted three different ways. + private static readonly Regex PolyesterTgic = + new(@"^\s*(polyester\s*[/ ]\s*tgic|tgic\s*[/ ]\s*polyester)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // "10 minutes @ 400°F", "7 minutes at 375 F", "Metal Temperature: 10 minutes at 400°F (204°C)". + // Degree glyph is optional and may be ° (U+00B0), ˚ (U+02DA), or º (U+00BA). + private static readonly Regex CureCurveRegex = + new(@"(\d+)\s*min(?:ute)?s?\.?\s*(?:@|at)\s*(\d{2,3})\s*[°˚º]?\s*F", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex HtmlTag = new("<[^>]+>", RegexOptions.Compiled); + private static readonly Regex WhitespaceRun = new(@"\s{2,}", RegexOptions.Compiled); + + private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false }; + + /// Maps a Columbia product into a fully populated (unsaved) catalog item. + public static PowderCatalogItem Map(ColumbiaProduct p) + { + var curves = ParseCureCurves(p.CureSchedule); + var primary = curves.Count > 0 ? curves[0] : (CureCurve?)null; + + return new PowderCatalogItem + { + VendorName = DeriveManufacturer(p), + Sku = p.Sku.Trim(), + ColorName = p.Name.Trim(), + Source = ColumbiaIntegrationConstants.SourceName, + Category = IsAdditive(p) ? ColumbiaIntegrationConstants.CategoryPowderAdditives : null, + + Description = StripHtml(p.Description), + UnitPrice = ParseBasePrice(p), + PriceTiersJson = BuildPriceTiersJson(p), + + ImageUrl = NullIfBlank(p.FeaturedImage?.Src), + SdsUrl = NullIfBlank(p.SafetyDataSheet), + TdsUrl = NullIfBlank(p.TechnicalDataSheet), + ApplicationGuideUrl = NullIfBlank(FirstNonBlank(p.ProductFlyer, p.ProductBrochure)), + ProductUrl = NullIfBlank(p.Permalink), + + ChemistryType = NormalizeChemistry(p.Type), + MilThickness = NullIfBlank(p.MilThickness), + CureScheduleText = NullIfBlank(p.CureSchedule), + CureCurvesJson = curves.Count > 0 ? JsonSerializer.Serialize(curves, JsonOut) : null, + CureTemperatureF = primary?.TempF, + CureTimeMinutes = primary?.Minutes, + RequiresClearCoat = DetectRequiresClearCoat(p), + + ColorFamilies = BuildColorFamilies(p), + FormulationChanges = NullIfBlank(p.FormulationDateChanges), + + // Coverage / specific gravity / transfer efficiency are not in the API — left null for + // lazy TDS/AI enrichment on first use. IsDiscontinued is handled by the sync sweep. + }; + } + + // ── Manufacturer derivation ─────────────────────────────────────────── + + /// + /// Derives the manufacturer from the product's taxonomy/SKU. Columbia resells PPG powders and + /// KP Pigments additives through the same feed; everything else is Columbia's own brand. + /// + public static string DeriveManufacturer(ColumbiaProduct p) + { + if (IsKpPigments(p)) + return ColumbiaIntegrationConstants.ManufacturerKp; + if (IsPpg(p)) + return ColumbiaIntegrationConstants.ManufacturerPpg; + return ColumbiaIntegrationConstants.ManufacturerColumbia; + } + + private static bool IsKpPigments(ColumbiaProduct p) => + p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase) + || CategoryStartsWith(p, "KP"); + + private static bool IsPpg(ColumbiaProduct p) => + CategoryStartsWith(p, "PPG"); + + /// + /// True for pigments/additives sold by weight (grams) rather than sprayed powders. These get + /// forced into the "Powder Additives" category. Keyed off the broad Additives category and the + /// ADD- SKU prefix, not just the KP brand (there are ~98 non-KP additives). + /// + public static bool IsAdditive(ColumbiaProduct p) => + p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase) + || p.Categories.Any(c => c.Name.Equals("Additives", StringComparison.OrdinalIgnoreCase)) + || CategoryStartsWith(p, "KP"); + + private static bool CategoryStartsWith(ColumbiaProduct p, string prefix) => + p.Categories.Any(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + // ── Pricing ─────────────────────────────────────────────────────────── + + /// + /// Base unit price = the top-level price (falling back to regular_price). For + /// variable products the parent price already carries the lead variant's price, while + /// regular_price is often "0", so price is preferred. + /// + public static decimal ParseBasePrice(ColumbiaProduct p) + { + if (TryParseMoney(p.Price, out var price) && price > 0) + return price; + if (TryParseMoney(p.RegularPrice, out var regular) && regular > 0) + return regular; + + // Variable product with a zero parent price: fall back to the lowest variant price. + var variantPrices = (p.VariationPricing ?? new List()) + .Select(v => TryParseMoney(v.Price, out var vp) ? vp : 0m) + .Where(v => v > 0) + .ToList(); + return variantPrices.Count > 0 ? variantPrices.Min() : 0m; + } + + /// + /// Captures quantity-break / variant pricing as JSON for later use. For variable products this + /// is the per-variant pricing (Bulk vs 1 lb Bags, gram sizes); for simple products it's the + /// tiered_pricing object. Null when neither is present. + /// + public static string? BuildPriceTiersJson(ColumbiaProduct p) + { + if (p.VariationPricing is { Count: > 0 }) + return JsonSerializer.Serialize(p.VariationPricing, JsonOut); + + if (p.TieredPricing is { ValueKind: JsonValueKind.Object } tiered) + { + // Only keep it if it actually carries tiers (avoid storing empty {type,...} shells). + if (tiered.TryGetProperty("tiers", out var tiers) + && tiers.ValueKind == JsonValueKind.Array + && tiers.GetArrayLength() > 0) + { + return tiered.GetRawText(); + } + } + + return null; + } + + private static bool TryParseMoney(string? s, out decimal value) => + decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value); + + // ── Cure schedule parsing ───────────────────────────────────────────── + + /// + /// Extracts every "N minutes at/@ TTT°F" curve from a free-text cure schedule, in document + /// order. The first is treated as the primary/standard curve; the rest are alternate (often + /// lower-temperature) curves preserved for heat-sensitive substrates. Returns an empty list for + /// schedules with no parseable temp/time pair (partial-cure / clear-coat instructions). + /// + public static List ParseCureCurves(string? cureSchedule) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(cureSchedule)) + return result; + + foreach (Match m in CureCurveRegex.Matches(cureSchedule)) + { + if (int.TryParse(m.Groups[1].Value, out var minutes) + && int.TryParse(m.Groups[2].Value, out var tempF) + && tempF is >= 150 and <= 600 // sanity: real cure temps + && minutes is > 0 and <= 120) + { + var curve = new CureCurve(tempF, minutes); + if (!result.Contains(curve)) + result.Add(curve); + } + } + + return result; + } + + /// + /// Flags powders that need a clear coat — explicit "clear coat"/"requires a clear" mentions, or + /// multi-step partial-cure (Illusion) instructions that have no single cure temperature. + /// + public static bool DetectRequiresClearCoat(ColumbiaProduct p) + { + var haystack = $"{p.CureSchedule} {p.Description}"; + return haystack.Contains("clear coat", StringComparison.OrdinalIgnoreCase) + || haystack.Contains("requires a clear", StringComparison.OrdinalIgnoreCase) + || haystack.Contains("Illusion", StringComparison.OrdinalIgnoreCase); + } + + // ── Misc field helpers ──────────────────────────────────────────────── + + /// Joins the color-group taxonomy ({name} entries) into a comma-separated families string. + public static string? BuildColorFamilies(ColumbiaProduct p) + { + var groups = p.PaColorGroup.Select(g => g.Name.Trim()).Where(n => n.Length > 0).Distinct().ToList(); + + if (groups.Count == 0) + { + // Fall back to the "Color Group" attribute options when the taxonomy is empty. + groups = p.Attributes + .Where(a => a.Name.Equals("Color Group", StringComparison.OrdinalIgnoreCase)) + .SelectMany(a => a.Options.Select(o => o.Name.Trim())) + .Where(n => n.Length > 0) + .Distinct() + .ToList(); + } + + return groups.Count > 0 ? string.Join(",", groups) : null; + } + + /// Normalizes resin chemistry — trims, and collapses the three Polyester/TGIC spellings. + public static string? NormalizeChemistry(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + return null; + var trimmed = type.Trim(); + return PolyesterTgic.IsMatch(trimmed) ? "Polyester/TGIC" : trimmed; + } + + /// Strips HTML tags/entities from a description and collapses whitespace to plain text. + public static string? StripHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return null; + + var text = HtmlTag.Replace(html, " "); + text = WebUtility.HtmlDecode(text); + text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); + text = WhitespaceRun.Replace(text, " ").Trim(); + return text.Length > 0 ? text : null; + } + + private static string? NullIfBlank(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + private static string? FirstNonBlank(params string?[] values) => + values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); +} diff --git a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs new file mode 100644 index 0000000..eb4b492 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs @@ -0,0 +1,155 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Logging; +using PowderCoating.Application.Constants; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces; + +namespace PowderCoating.Infrastructure.Services.Columbia; + +/// +/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared +/// , then reconciles discontinuations against the complete +/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any +/// page failure throws from the client) aborts before the sweep so a transient error can never mass +/// flag the catalog as discontinued. +/// +public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService +{ + private readonly IColumbiaCoatingsApiClient _client; + private readonly IPowderCatalogUpsertService _upsert; + private readonly IUnitOfWork _unitOfWork; + private readonly IPlatformSettingsService _settings; + private readonly ILogger _logger; + + public ColumbiaCatalogSyncService( + IColumbiaCoatingsApiClient client, + IPowderCatalogUpsertService upsert, + IUnitOfWork unitOfWork, + IPlatformSettingsService settings, + ILogger logger) + { + _client = client; + _upsert = upsert; + _unitOfWork = unitOfWork; + _settings = settings; + _logger = logger; + } + + /// + public async Task RunSyncAsync(CancellationToken cancellationToken = default) + { + var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow }; + var stopwatch = Stopwatch.StartNew(); + + if (!_client.IsConfigured) + { + result.ErrorMessage = "Columbia API key is not configured."; + await RecordResultAsync(result); + return result; + } + + try + { + // Full pull — throws on any page failure, which we treat as an incomplete sync. + var products = await _client.GetAllProductsAsync(cancellationToken); + result.TotalFetched = products.Count; + + // Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU. + var mapped = products + .Select(ColumbiaCatalogMapper.Map) + .Where(m => !string.IsNullOrWhiteSpace(m.Sku)) + .GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + + var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken); + result.Inserted = upsertResult.Inserted; + result.Updated = upsertResult.Updated; + result.Unchanged = upsertResult.Unchanged; + result.Skipped = upsertResult.Skipped; + + // Complete pull succeeded — safe to reconcile discontinuations. + var incomingKeys = mapped + .Select(m => $"{m.VendorName}|{m.Sku}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + (result.Discontinued, result.Reactivated) = + await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt); + + result.Success = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep."); + result.Success = false; + result.ErrorMessage = ex.Message; + } + + stopwatch.Stop(); + result.Duration = stopwatch.Elapsed; + await RecordResultAsync(result); + return result; + } + + /// + /// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued, + /// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued, + /// reactivated) counts. + /// + private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync( + HashSet incomingKeys, DateTime runTimestamp) + { + var sourced = await _unitOfWork.PowderCatalog.FindAsync( + p => p.Source == ColumbiaIntegrationConstants.SourceName); + + var discontinued = 0; + var reactivated = 0; + + foreach (var item in sourced) + { + var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}"); + + if (!present && !item.IsDiscontinued) + { + item.IsDiscontinued = true; + item.UpdatedAt = runTimestamp; + await _unitOfWork.PowderCatalog.UpdateAsync(item); + discontinued++; + } + else if (present && item.IsDiscontinued) + { + item.IsDiscontinued = false; + item.UpdatedAt = runTimestamp; + await _unitOfWork.PowderCatalog.UpdateAsync(item); + reactivated++; + } + } + + if (discontinued > 0 || reactivated > 0) + await _unitOfWork.CompleteAsync(); + + return (discontinued, reactivated); + } + + /// Persists the run outcome to the last-synced / last-result platform settings. + private async Task RecordResultAsync(ColumbiaSyncResult result) + { + if (result.Success) + { + await _settings.SetAsync( + ColumbiaIntegrationConstants.SettingLastSyncedAt, + result.StartedAt.ToString("O", CultureInfo.InvariantCulture), + updatedBy: "Columbia Sync"); + } + + await _settings.SetAsync( + ColumbiaIntegrationConstants.SettingLastResult, + result.Summary, + updatedBy: "Columbia Sync"); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs new file mode 100644 index 0000000..e874a27 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs @@ -0,0 +1,139 @@ +using Microsoft.Extensions.Logging; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Single upsert path for the platform master list, shared by the +/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive. +/// +public class PowderCatalogUpsertService : IPowderCatalogUpsertService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + /// + public async Task UpsertAsync( + IReadOnlyList 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(); + + 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}"; + + /// + /// Copies feed-sourced fields from onto 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. + /// + 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; + } + + /// Sets a nullable-string property when it differs (ordinal compare); returns whether it changed. + private static bool Set(Func get, Action set, string? newValue) + { + if (!string.Equals(get(), newValue, StringComparison.Ordinal)) + { + set(newValue); + return true; + } + return false; + } + + /// Helper so a value assignment can participate in a boolean OR chain. + private static bool Assign(Action assign) + { + assign(); + return true; + } +} diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index 2c54fbc..2a8db78 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces.Services; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Services; +using PowderCoating.Infrastructure.Services.Columbia; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Application.Configuration; @@ -223,6 +224,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs new file mode 100644 index 0000000..b04da4b --- /dev/null +++ b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using PowderCoating.Application.DTOs.Columbia; +using PowderCoating.Infrastructure.Services.Columbia; + +namespace PowderCoating.UnitTests; + +/// +/// Tests for the Columbia catalog mapper, focused on the fields that are tricky in the real feed: +/// the free-text cure parser (multiple glyphs, multi-curve, partial-cure), manufacturer derivation +/// from a multi-brand distributor, pricing across simple/variable products, and HTML stripping. +/// Cases mirror records captured from the live API. +/// +public class ColumbiaCatalogMapperTests +{ + // ── Cure schedule parsing ───────────────────────────────────────────── + + [Fact] + public void ParseCureCurves_SimpleSchedule_ReturnsSingleCurve() + { + var curves = ColumbiaCatalogMapper.ParseCureCurves("10 minutes @ 400°F"); + Assert.Single(curves); + Assert.Equal(400, curves[0].TempF); + Assert.Equal(10, curves[0].Minutes); + } + + [Fact] + public void ParseCureCurves_MetalTemperaturePrefixWithCelsius_ParsesFahrenheitOnly() + { + var curves = ColumbiaCatalogMapper.ParseCureCurves("Metal Temperature: 10 minutes at 400°F (204°C)"); + Assert.Single(curves); + Assert.Equal(400, curves[0].TempF); + Assert.Equal(10, curves[0].Minutes); + } + + [Theory] + [InlineData("10 minutes @ 400˚F (204˚C)")] // U+02DA ring above + [InlineData("10 minutes @ 400ºF (204ºC)")] // U+00BA masculine ordinal + [InlineData("Metal temperature: 10 minutes @ 400F (204C)")] // no degree glyph at all + public void ParseCureCurves_DegreeGlyphVariants_AllParse(string schedule) + { + var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); + Assert.Single(curves); + Assert.Equal(400, curves[0].TempF); + } + + [Fact] + public void ParseCureCurves_MultiCurve_CapturesAllInOrder_PrimaryIsFirst() + { + // Real record S1790085-55: standard high-temp curve first, low-temp alternates after. + var schedule = "Metal Temperature: 5 minutes at 400°F (204°C) -or- 10 minutes at 360°F (182°C)* 15 minutes at 340°F (171°C)* *Low-Temp cure curve"; + var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); + + Assert.Equal(3, curves.Count); + Assert.Equal(new ColumbiaCatalogMapper.CureCurve(400, 5), curves[0]); // primary + Assert.Equal(new ColumbiaCatalogMapper.CureCurve(360, 10), curves[1]); // alternate + Assert.Equal(new ColumbiaCatalogMapper.CureCurve(340, 15), curves[2]); // alternate + } + + [Fact] + public void ParseCureCurves_PartialCureInstructions_ReturnsEmpty() + { + // Illusion powder F1697027 — multi-step, no single temp/time pair. + var schedule = "(1) - Apply a basecoat and partial cure. (2) - Apply this powder and partial cure (3). - Apply a clear coat and fully cure."; + var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); + Assert.Empty(curves); + } + + [Fact] + public void Map_MultiCurve_SetsPrimaryTempTimeAndStoresAllCurvesJson() + { + var product = new ColumbiaProduct + { + Sku = "S1790085-55", + Name = "Multi Cure", + CureSchedule = "5 minutes at 400°F -or- 10 minutes at 360°F", + }; + + var item = ColumbiaCatalogMapper.Map(product); + + Assert.Equal(400m, item.CureTemperatureF); + Assert.Equal(5, item.CureTimeMinutes); + Assert.False(string.IsNullOrEmpty(item.CureCurvesJson)); + Assert.Equal("5 minutes at 400°F -or- 10 minutes at 360°F", item.CureScheduleText); + + var curves = JsonSerializer.Deserialize>(item.CureCurvesJson!); + Assert.Equal(2, curves!.Count); + } + + // ── Manufacturer derivation ─────────────────────────────────────────── + + [Fact] + public void DeriveManufacturer_AddSkuPrefix_IsKpPigments() + { + var p = new ColumbiaProduct { Sku = "ADD-BRBDS", Name = "Barbados Blue ColorShift Pearl" }; + Assert.Equal("KP Pigments", ColumbiaCatalogMapper.DeriveManufacturer(p)); + Assert.True(ColumbiaCatalogMapper.IsAdditive(p)); + } + + [Fact] + public void DeriveManufacturer_PpgCategory_IsPpg() + { + var p = new ColumbiaProduct + { + Sku = "PCU75139", + Name = "PPG Chrome Shadow", + Categories = { new ColumbiaNamed { Name = "PPG Powders" } }, + }; + Assert.Equal("PPG", ColumbiaCatalogMapper.DeriveManufacturer(p)); + } + + [Fact] + public void DeriveManufacturer_HouseBrand_IsColumbia() + { + var p = new ColumbiaProduct + { + Sku = "X5004124", + Name = "Blue Wrinkle", + Categories = { new ColumbiaNamed { Name = "Powders" }, new ColumbiaNamed { Name = "New Releases" } }, + }; + Assert.Equal("Columbia Coatings", ColumbiaCatalogMapper.DeriveManufacturer(p)); + Assert.False(ColumbiaCatalogMapper.IsAdditive(p)); + } + + // ── Pricing ─────────────────────────────────────────────────────────── + + [Fact] + public void ParseBasePrice_SimpleProduct_UsesTopLevelPrice() + { + var p = new ColumbiaProduct { Price = "18.85", RegularPrice = "18.85" }; + Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p)); + } + + [Fact] + public void ParseBasePrice_VariableProductWithZeroRegular_FallsBackToPriceThenVariants() + { + // Variable parent: price carries the lead variant, regular_price is "0". + var p = new ColumbiaProduct + { + Price = "18.85", + RegularPrice = "0", + VariationPricing = new List + { + new() { Sku = "X-B", Price = "18.85" }, + new() { Sku = "X-P", Price = "18.85" }, + }, + }; + Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p)); + } + + [Fact] + public void BuildPriceTiersJson_VariableProduct_SerializesVariationPricing() + { + var p = new ColumbiaProduct + { + VariationPricing = new List + { + new() { Sku = "X-B", Price = "18.85" }, + }, + }; + var json = ColumbiaCatalogMapper.BuildPriceTiersJson(p); + Assert.NotNull(json); + Assert.Contains("X-B", json); + } + + [Fact] + public void BuildPriceTiersJson_EmptyTieredPricingArray_ReturnsNull() + { + // Variable products carry tiered_pricing as an empty array. + var p = new ColumbiaProduct { TieredPricing = JsonDocument.Parse("[]").RootElement }; + Assert.Null(ColumbiaCatalogMapper.BuildPriceTiersJson(p)); + } + + // ── Chemistry, color, HTML ──────────────────────────────────────────── + + [Theory] + [InlineData("Polyester/TGIC", "Polyester/TGIC")] + [InlineData("Polyester TGIC", "Polyester/TGIC")] + [InlineData("TGIC Polyester", "Polyester/TGIC")] + [InlineData("TGIC", "TGIC")] + [InlineData("", null)] + public void NormalizeChemistry_CollapsesPolyesterTgicVariants(string input, string? expected) + { + Assert.Equal(expected, ColumbiaCatalogMapper.NormalizeChemistry(input)); + } + + [Fact] + public void BuildColorFamilies_JoinsColorGroupNames() + { + var p = new ColumbiaProduct + { + PaColorGroup = { new ColumbiaNamed { Name = "Blue" }, new ColumbiaNamed { Name = "Green" } }, + }; + Assert.Equal("Blue,Green", ColumbiaCatalogMapper.BuildColorFamilies(p)); + } + + [Fact] + public void BuildColorFamilies_FallsBackToColorGroupAttribute() + { + var p = new ColumbiaProduct + { + Attributes = + { + new ColumbiaAttribute + { + Name = "Color Group", + Options = { new ColumbiaNamed { Name = "Black" } }, + }, + }, + }; + Assert.Equal("Black", ColumbiaCatalogMapper.BuildColorFamilies(p)); + } + + [Fact] + public void StripHtml_RemovesTagsEntitiesAndCollapsesWhitespace() + { + var html = "Blue Wrinkle\r\n\r\nThis is a vibrant & bright coating."; + var text = ColumbiaCatalogMapper.StripHtml(html); + Assert.Equal("Blue Wrinkle This is a vibrant & bright coating.", text); + } + + [Fact] + public void DetectRequiresClearCoat_IllusionDescription_IsTrue() + { + var p = new ColumbiaProduct + { + Description = "This Illusion powder requires a clear coat to activate the effect.", + }; + Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); + } + + // ── End-to-end mapping invariants ───────────────────────────────────── + + [Fact] + public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull() + { + var p = new ColumbiaProduct { Sku = "X5004124", Name = "Blue Wrinkle", Price = "18.85" }; + var item = ColumbiaCatalogMapper.Map(p); + + Assert.Equal("Columbia Coatings API", item.Source); + // Enrichment fields are not in the feed and must stay null for lazy TDS/AI enrichment. + Assert.Null(item.SpecificGravity); + Assert.Null(item.CoverageSqFtPerLb); + Assert.Null(item.TransferEfficiency); + } +}