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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:01:55 -04:00
parent eed61a298b
commit 6db055dcf8
4 changed files with 131 additions and 9 deletions
@@ -40,6 +40,27 @@ public static class ColumbiaCatalogMapper
private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false }; private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false };
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary> /// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary>
public static PowderCatalogItem Map(ColumbiaProduct p) public static PowderCatalogItem Map(ColumbiaProduct p)
{ {
@@ -194,16 +215,38 @@ public static class ColumbiaCatalogMapper
return result; 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",
};
/// <summary> /// <summary>
/// Flags powders that need a clear coat — explicit "clear coat"/"requires a clear" mentions, or /// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style)
/// multi-step partial-cure (Illusion) instructions that have no single cure temperature. /// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual
/// "you can clear coat this" mentions are intentionally ignored.
/// </summary> /// </summary>
public static bool DetectRequiresClearCoat(ColumbiaProduct p) public static bool DetectRequiresClearCoat(ColumbiaProduct p)
{ {
var haystack = $"{p.CureSchedule} {p.Description}"; var cure = p.CureSchedule ?? string.Empty;
return haystack.Contains("clear coat", StringComparison.OrdinalIgnoreCase) var name = p.Name ?? string.Empty;
|| haystack.Contains("requires a clear", StringComparison.OrdinalIgnoreCase)
|| haystack.Contains("Illusion", StringComparison.OrdinalIgnoreCase); // 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 ──────────────────────────────────────────────── // ── Misc field helpers ────────────────────────────────────────────────
@@ -57,7 +57,9 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
result.TotalFetched = products.Count; result.TotalFetched = products.Count;
// Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU. // 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 var mapped = products
.Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p))
.Select(ColumbiaCatalogMapper.Map) .Select(ColumbiaCatalogMapper.Map)
.Where(m => !string.IsNullOrWhiteSpace(m.Sku)) .Where(m => !string.IsNullOrWhiteSpace(m.Sku))
.GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase) .GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase)
@@ -70,6 +72,10 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
result.Unchanged = upsertResult.Unchanged; result.Unchanged = upsertResult.Unchanged;
result.Skipped = upsertResult.Skipped; 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. // Complete pull succeeded — safe to reconcile discontinuations.
var incomingKeys = mapped var incomingKeys = mapped
.Select(m => $"{m.VendorName}|{m.Sku}") .Select(m => $"{m.VendorName}|{m.Sku}")
@@ -136,6 +142,32 @@ public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
return (discontinued, reactivated); return (discontinued, reactivated);
} }
/// <summary>
/// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and
/// tester/sample size-variants). Mirrors <see cref="ColumbiaCatalogMapper.IsExcludedProduct"/>
/// 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.
/// </summary>
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);
}
/// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary> /// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary>
private async Task RecordResultAsync(ColumbiaSyncResult result) private async Task RecordResultAsync(ColumbiaSyncResult result)
{ {
@@ -445,7 +445,7 @@ public class PowderCatalogController : Controller
var result = await _columbiaSyncService.RunSyncAsync(cancellationToken); var result = await _columbiaSyncService.RunSyncAsync(cancellationToken);
if (result.Success) if (result.Success)
TempData["Success"] = $"Columbia sync complete &mdash; {result.Summary}"; TempData["Success"] = $"Columbia sync complete - {result.Summary}";
else else
TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}"; TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}";
@@ -121,6 +121,23 @@ public class ColumbiaCatalogMapperTests
Assert.False(ColumbiaCatalogMapper.IsAdditive(p)); 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 ─────────────────────────────────────────────────────────── // ── Pricing ───────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -219,15 +236,45 @@ public class ColumbiaCatalogMapperTests
} }
[Fact] [Fact]
public void DetectRequiresClearCoat_IllusionDescription_IsTrue() public void DetectRequiresClearCoat_ExplicitRequirement_IsTrue()
{ {
var p = new ColumbiaProduct 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)); 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 ───────────────────────────────────── // ── End-to-end mapping invariants ─────────────────────────────────────
// ── Tolerant image deserialization (WordPress returns [] / false when empty) ── // ── Tolerant image deserialization (WordPress returns [] / false when empty) ──