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) ──