From 6db055dcf8eda9d258e7cbb204f4e313c5b4c2be Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 17 Jun 2026 12:01:55 -0400 Subject: [PATCH] Refine Columbia sync after first live run Fixes from reviewing the first full sync of the real catalog: - Exclude non-powder / size-variant listings: physical swatch cards (-SW / "SWATCH"), 4 oz testers (-04 / "Tester"), and 5 lb sample bags ("Sample ("). These are not standalone powder colors. Filtered before mapping, and a cleanup step deletes any already synced (so they're removed, not flagged discontinued). Sample detection keys off the "Sample (" name, not the bare -S suffix, to avoid catching a real SKU ending in S (verified 0 collisions). - Tighten RequiresClearCoat: was flagging ~53% of the catalog on any casual "clear coat" mention. Now only genuine signals (partial-cure schedules, the Illusion line, explicit "requires a clear" phrasing) trip it. - Fix literal "—" in the sync success banner (TempData is HTML-encoded). Tests cover the exclusion patterns and the tightened clear-coat detection. Co-Authored-By: Claude Opus 4.8 --- .../Columbia/ColumbiaCatalogMapper.cs | 55 +++++++++++++++++-- .../Columbia/ColumbiaCatalogSyncService.cs | 32 +++++++++++ .../Controllers/PowderCatalogController.cs | 2 +- .../ColumbiaCatalogMapperTests.cs | 51 ++++++++++++++++- 4 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs index 5c82d26..3d4a8cb 100644 --- a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs +++ b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogMapper.cs @@ -40,6 +40,27 @@ public static class ColumbiaCatalogMapper private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false }; + /// + /// True for products that should not be in the powder catalog as standalone colors: + /// physical swatch cards (not powder at all), and tester (4 oz) / sample (5 lb) listings that + /// are just smaller SIZES of a parent powder that already exists as its own product. Detected + /// by specific SKU suffixes (-SW / -04) and unambiguous name markers ("SWATCH", "Tester", + /// "Sample ("). The sample-size "-S" SKU suffix is intentionally NOT used on its own — the + /// "Sample (" name marker catches every sample without risking a real SKU that ends in -S. + /// + public static bool IsExcludedProduct(ColumbiaProduct p) + { + var sku = p.Sku ?? string.Empty; + if (sku.EndsWith("-SW", StringComparison.OrdinalIgnoreCase) + || sku.EndsWith("-04", StringComparison.OrdinalIgnoreCase)) + return true; + + var name = p.Name ?? string.Empty; + return name.Contains("SWATCH", StringComparison.OrdinalIgnoreCase) + || name.Contains("Tester", StringComparison.OrdinalIgnoreCase) + || name.Contains("Sample (", StringComparison.OrdinalIgnoreCase); + } + /// Maps a Columbia product into a fully populated (unsaved) catalog item. public static PowderCatalogItem Map(ColumbiaProduct p) { @@ -194,16 +215,38 @@ public static class ColumbiaCatalogMapper return result; } + // Powders that genuinely REQUIRE a clear coat say so explicitly. A casual "apply a clear coat + // for added durability" must NOT trip this — that over-flagged ~half the catalog and would pad + // quotes with unnecessary clear-coat steps. + private static readonly string[] RequiresClearPhrases = + { + "requires a clear", "requires clear", "require a clear", + "must be clear coated", "must be cleared", "needs a clear", + "clear coat is required", "clear coat required", "requires a clearcoat", + "requires a top coat", "clear coat to activate", "clear coat to achieve", + "requires a clear coat", + }; + /// - /// 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. + /// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style) + /// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual + /// "you can clear coat this" mentions are intentionally ignored. /// 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); + var cure = p.CureSchedule ?? string.Empty; + var name = p.Name ?? string.Empty; + + // Partial-cure / multi-step instructions are the "apply this, then clear" case. + if (cure.Contains("partial cure", StringComparison.OrdinalIgnoreCase)) + return true; + + // Columbia's Illusion line needs a clear top coat to develop the effect. + if (name.Contains("Illusion", StringComparison.OrdinalIgnoreCase)) + return true; + + var text = $"{name} {cure} {p.Description}"; + return RequiresClearPhrases.Any(phrase => text.Contains(phrase, StringComparison.OrdinalIgnoreCase)); } // ── Misc field helpers ──────────────────────────────────────────────── diff --git a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs index eb4b492..03a0c2a 100644 --- a/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs +++ b/src/PowderCoating.Infrastructure/Services/Columbia/ColumbiaCatalogSyncService.cs @@ -57,7 +57,9 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService result.TotalFetched = products.Count; // Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU. + // Exclude swatch cards and tester/sample size-variants — not standalone powder colors. var mapped = products + .Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p)) .Select(ColumbiaCatalogMapper.Map) .Where(m => !string.IsNullOrWhiteSpace(m.Sku)) .GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase) @@ -70,6 +72,10 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService result.Unchanged = upsertResult.Unchanged; result.Skipped = upsertResult.Skipped; + // Remove any excluded records (swatches) that were synced before the exclusion existed, + // so they're deleted outright rather than lingering as "discontinued" powders. + await RemoveExcludedRecordsAsync(); + // Complete pull succeeded — safe to reconcile discontinuations. var incomingKeys = mapped .Select(m => $"{m.VendorName}|{m.Sku}") @@ -136,6 +142,32 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService return (discontinued, reactivated); } + /// + /// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and + /// tester/sample size-variants). Mirrors + /// on the stored columns. A no-op once the catalog is clean; guards against records synced + /// before the exclusion rule and ensures excluded items are removed, not flagged discontinued. + /// + private async Task RemoveExcludedRecordsAsync() + { + var excluded = (await _unitOfWork.PowderCatalog.FindAsync(p => + p.Source == ColumbiaIntegrationConstants.SourceName + && (p.Sku.EndsWith("-SW") + || p.Sku.EndsWith("-04") + || p.ColorName.Contains("SWATCH") + || p.ColorName.Contains("Tester") + || p.ColorName.Contains("Sample (")))).ToList(); + + if (excluded.Count == 0) + return; + + foreach (var e in excluded) + await _unitOfWork.PowderCatalog.DeleteAsync(e); + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Columbia sync: removed {Count} excluded record(s) (swatch/tester/sample) from the catalog.", excluded.Count); + } + /// Persists the run outcome to the last-synced / last-result platform settings. private async Task RecordResultAsync(ColumbiaSyncResult result) { diff --git a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs index 32f2148..bea4b68 100644 --- a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs +++ b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs @@ -445,7 +445,7 @@ public class PowderCatalogController : Controller var result = await _columbiaSyncService.RunSyncAsync(cancellationToken); if (result.Success) - TempData["Success"] = $"Columbia sync complete — {result.Summary}"; + TempData["Success"] = $"Columbia sync complete - {result.Summary}"; else TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}"; diff --git a/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs index 7bf96ad..609ae20 100644 --- a/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs +++ b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs @@ -121,6 +121,23 @@ public class ColumbiaCatalogMapperTests Assert.False(ColumbiaCatalogMapper.IsAdditive(p)); } + // ── Excluded products (swatches + tester/sample size variants) ──────── + + [Theory] + [InlineData("T1696049-SW", "**SWATCH** - Copperhead II", true)] // swatch card + [InlineData("T1696049-SW", "Copperhead II", true)] // -SW suffix alone + [InlineData("X1", "**SWATCH** - Something", true)] // name marker alone + [InlineData("T1696049-04", "Copperhead II (4 Ounce Tester)", true)] // tester = size variant + [InlineData("XYZ", "Copperhead II (4 Ounce Tester)", true)] // tester by name + [InlineData("S1760090-S", "Black Beauty Sample (5 lbs)", true)] // 5lb sample = size variant + [InlineData("X5004124", "Blue Wrinkle", false)] // normal powder + [InlineData("S5704126", "Smokey Blue", false)] // normal powder, SKU starts with S + public void IsExcludedProduct_DetectsSwatchesTestersAndSamples(string sku, string name, bool expected) + { + var p = new ColumbiaProduct { Sku = sku, Name = name }; + Assert.Equal(expected, ColumbiaCatalogMapper.IsExcludedProduct(p)); + } + // ── Pricing ─────────────────────────────────────────────────────────── [Fact] @@ -219,15 +236,45 @@ public class ColumbiaCatalogMapperTests } [Fact] - public void DetectRequiresClearCoat_IllusionDescription_IsTrue() + public void DetectRequiresClearCoat_ExplicitRequirement_IsTrue() { var p = new ColumbiaProduct { - Description = "This Illusion powder requires a clear coat to activate the effect.", + Description = "This powder requires a clear coat to activate the effect.", }; Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); } + [Fact] + public void DetectRequiresClearCoat_IllusionLine_IsTrue() + { + var p = new ColumbiaProduct { Name = "Illusion Cherry", Description = "A translucent red." }; + Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); + } + + [Fact] + public void DetectRequiresClearCoat_PartialCureSchedule_IsTrue() + { + var p = new ColumbiaProduct + { + Name = "Some Base", + CureSchedule = "Partial Cure: 15 min total time in oven preheated to 400°F. Then apply clear.", + }; + Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); + } + + [Fact] + public void DetectRequiresClearCoat_CasualMention_IsFalse() + { + // The over-flagging case: a passing mention is not a requirement. + var p = new ColumbiaProduct + { + Name = "Gloss Black", + Description = "Durable gloss black. Apply a clear coat for added protection if desired.", + }; + Assert.False(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); + } + // ── End-to-end mapping invariants ───────────────────────────────────── // ── Tolerant image deserialization (WordPress returns [] / false when empty) ──