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:
@@ -40,6 +40,27 @@ public static class ColumbiaCatalogMapper
|
||||
|
||||
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>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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}";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user