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
@@ -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);
}
/// <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>
private async Task RecordResultAsync(ColumbiaSyncResult result)
{