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 };
|
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 — {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) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user