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; /// /// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared /// , 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. /// public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService { private readonly IColumbiaCoatingsApiClient _client; private readonly IPowderCatalogUpsertService _upsert; private readonly IUnitOfWork _unitOfWork; private readonly IPlatformSettingsService _settings; private readonly ILogger _logger; public ColumbiaCatalogSyncService( IColumbiaCoatingsApiClient client, IPowderCatalogUpsertService upsert, IUnitOfWork unitOfWork, IPlatformSettingsService settings, ILogger logger) { _client = client; _upsert = upsert; _unitOfWork = unitOfWork; _settings = settings; _logger = logger; } /// public async Task 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; } /// /// 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. /// private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync( HashSet 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); } /// /// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and /// tester/sample size-variants). Mirrors /// 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. /// 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); } /// Persists the run outcome to the last-synced / last-result platform settings. 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"); } }