6db055dcf8
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>
188 lines
7.4 KiB
C#
188 lines
7.4 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using Microsoft.Extensions.Logging;
|
|
using PowderCoating.Application.Constants;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
|
|
namespace PowderCoating.Infrastructure.Services.Columbia;
|
|
|
|
/// <summary>
|
|
/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared
|
|
/// <see cref="IPowderCatalogUpsertService"/>, then reconciles discontinuations against the complete
|
|
/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any
|
|
/// page failure throws from the client) aborts before the sweep so a transient error can never mass
|
|
/// flag the catalog as discontinued.
|
|
/// </summary>
|
|
public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
|
|
{
|
|
private readonly IColumbiaCoatingsApiClient _client;
|
|
private readonly IPowderCatalogUpsertService _upsert;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IPlatformSettingsService _settings;
|
|
private readonly ILogger<ColumbiaCatalogSyncService> _logger;
|
|
|
|
public ColumbiaCatalogSyncService(
|
|
IColumbiaCoatingsApiClient client,
|
|
IPowderCatalogUpsertService upsert,
|
|
IUnitOfWork unitOfWork,
|
|
IPlatformSettingsService settings,
|
|
ILogger<ColumbiaCatalogSyncService> logger)
|
|
{
|
|
_client = client;
|
|
_upsert = upsert;
|
|
_unitOfWork = unitOfWork;
|
|
_settings = settings;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow };
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
if (!_client.IsConfigured)
|
|
{
|
|
result.ErrorMessage = "Columbia API key is not configured.";
|
|
await RecordResultAsync(result);
|
|
return result;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Full pull — throws on any page failure, which we treat as an incomplete sync.
|
|
var products = await _client.GetAllProductsAsync(cancellationToken);
|
|
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)
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken);
|
|
result.Inserted = upsertResult.Inserted;
|
|
result.Updated = upsertResult.Updated;
|
|
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}")
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
(result.Discontinued, result.Reactivated) =
|
|
await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt);
|
|
|
|
result.Success = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep.");
|
|
result.Success = false;
|
|
result.ErrorMessage = ex.Message;
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
result.Duration = stopwatch.Elapsed;
|
|
await RecordResultAsync(result);
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued,
|
|
/// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued,
|
|
/// reactivated) counts.
|
|
/// </summary>
|
|
private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync(
|
|
HashSet<string> incomingKeys, DateTime runTimestamp)
|
|
{
|
|
var sourced = await _unitOfWork.PowderCatalog.FindAsync(
|
|
p => p.Source == ColumbiaIntegrationConstants.SourceName);
|
|
|
|
var discontinued = 0;
|
|
var reactivated = 0;
|
|
|
|
foreach (var item in sourced)
|
|
{
|
|
var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}");
|
|
|
|
if (!present && !item.IsDiscontinued)
|
|
{
|
|
item.IsDiscontinued = true;
|
|
item.UpdatedAt = runTimestamp;
|
|
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
|
discontinued++;
|
|
}
|
|
else if (present && item.IsDiscontinued)
|
|
{
|
|
item.IsDiscontinued = false;
|
|
item.UpdatedAt = runTimestamp;
|
|
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
|
reactivated++;
|
|
}
|
|
}
|
|
|
|
if (discontinued > 0 || reactivated > 0)
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
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)
|
|
{
|
|
if (result.Success)
|
|
{
|
|
await _settings.SetAsync(
|
|
ColumbiaIntegrationConstants.SettingLastSyncedAt,
|
|
result.StartedAt.ToString("O", CultureInfo.InvariantCulture),
|
|
updatedBy: "Columbia Sync");
|
|
}
|
|
|
|
await _settings.SetAsync(
|
|
ColumbiaIntegrationConstants.SettingLastResult,
|
|
result.Summary,
|
|
updatedBy: "Columbia Sync");
|
|
}
|
|
}
|