using Microsoft.Extensions.Logging; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; namespace PowderCoating.Infrastructure.Services; /// /// Single upsert path for the platform master list, shared by the /// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive. /// public class PowderCatalogUpsertService : IPowderCatalogUpsertService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; } /// public async Task UpsertAsync( IReadOnlyList incoming, DateTime runTimestamp, CancellationToken cancellationToken = default) { var result = new PowderCatalogUpsertResult(); // Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased. var vendorNames = incoming .Select(i => i.VendorName) .Where(v => !string.IsNullOrWhiteSpace(v)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName))) .ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase); var toAdd = new List(); foreach (var item in incoming) { if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName)) { result.Skipped++; continue; } if (existing.TryGetValue(KeyOf(item), out var record)) { if (ApplyFeedFields(record, item)) { record.UpdatedAt = runTimestamp; record.LastSyncedAt = runTimestamp; await _unitOfWork.PowderCatalog.UpdateAsync(record); result.Updated++; } else { result.Unchanged++; } } else { item.CreatedAt = runTimestamp; item.LastSyncedAt = runTimestamp; toAdd.Add(item); result.Inserted++; } } if (toAdd.Count > 0) await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd); await _unitOfWork.CompleteAsync(); // Push current catalog price + product data down to any tenant inventory linked to these // catalog rows, so quotes reflect the current price. var propagated = await PropagateToLinkedInventoryAsync(); _logger.LogInformation( "Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.", result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated); return result; } /// /// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by /// linking any unlinked item to its catalog row by identity, then refreshes every linked item /// with the catalog's current price and product data. Returns the number of items touched. /// public async Task PropagateToLinkedInventoryAsync() { var linkedCount = await LinkUnlinkedInventoryAsync(); var refreshedCount = await RefreshLinkedInventoryAsync(); return linkedCount + refreshedCount; } /// /// Self-heals the catalog link: finds inventory items with no /// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the /// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU, /// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count /// newly linked. This backfills items created before linking existed, on every environment, with /// no manual step. /// private async Task LinkUnlinkedInventoryAsync() { var unlinked = (await _unitOfWork.InventoryItems.FindAsync( i => i.PowderCatalogItemId == null && i.Manufacturer != null && i.Manufacturer != "" && i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "", ignoreQueryFilters: true)).ToList(); if (unlinked.Count == 0) return 0; var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList(); var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku))) .GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); if (bySku.Count == 0) return 0; var linked = 0; foreach (var inv in unlinked) { if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates)) continue; var mfr = inv.Manufacturer!.Trim().ToLower(); var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr)) ?? (candidates.Count == 1 ? candidates[0] : null); if (match == null) continue; inv.PowderCatalogItemId = match.Id; ApplyCatalogToLinkedInventory(inv, match); await _unitOfWork.InventoryItems.UpdateAsync(inv); linked++; } if (linked > 0) await _unitOfWork.CompleteAsync(); return linked; } /// /// Refreshes every tenant inventory item linked to a powder catalog row (across all companies) /// with the catalog's current list price and product data. Sets /// (the QUOTING price) and product spec/doc /// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes, /// image, location, or stock levels — those are tenant-owned. EF persists only items that /// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated. /// private async Task RefreshLinkedInventoryAsync() { var linked = (await _unitOfWork.InventoryItems.FindAsync( i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList(); if (linked.Count == 0) return 0; var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList(); var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id))) .ToDictionary(p => p.Id); var updated = 0; foreach (var inv in linked) { if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat)) continue; if (ApplyCatalogToLinkedInventory(inv, cat)) { await _unitOfWork.InventoryItems.UpdateAsync(inv); updated++; } } if (updated > 0) await _unitOfWork.CompleteAsync(); return updated; } /// /// Applies the catalog's current price and product data onto a linked inventory item, returning /// true if anything changed. Sets the quoting reference price (only when the catalog has a real /// price > 0) and refreshes product/spec fields where the catalog has a value — never erasing /// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or /// stock levels. /// private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat) { var changed = false; // Quoting price (the point of this): keep the current catalog list price, separate from cost. if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice) { inv.CatalogReferencePrice = cat.UnitPrice; inv.CatalogPriceUpdatedAt = DateTime.UtcNow; changed = true; } // Product data — refresh from the catalog where it has a value (catalog is authoritative on // these); do not null out a tenant value the catalog doesn't carry. changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description); changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish); changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies); changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl); changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl); changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl); if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF) { inv.CureTemperatureF = cat.CureTemperatureF; changed = true; } if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes) { inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; } if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb) { inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; } if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity) { inv.SpecificGravity = cat.SpecificGravity; changed = true; } if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency) { inv.TransferEfficiency = cat.TransferEfficiency; changed = true; } if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat) { inv.RequiresClearCoat = true; changed = true; } return changed; } /// Sets a string property from the catalog only when the catalog value is non-blank and differs. private static bool SetStrIfCatalogHas(Func get, Action set, string? catalogValue) { if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal)) { set(catalogValue); return true; } return false; } private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}"; /// /// Copies feed-sourced fields from onto and /// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity, /// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are /// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed. /// private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src) { var changed = false; changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName); changed |= Set(() => dest.Description, v => dest.Description = v, src.Description); changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice); changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson); changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl); changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl); changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl); changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl); changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl); changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType); changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness); changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText); changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson); changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF); changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes); changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat); changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies); changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges); changed |= Set(() => dest.Category, v => dest.Category = v, src.Category); changed |= Set(() => dest.Source, v => dest.Source = v, src.Source); return changed; } /// /// Sets a nullable-string property when the feed provides a non-blank value that differs. /// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic /// file import, which omits cure/chemistry) never nulls out existing data. /// private static bool Set(Func get, Action set, string? newValue) { if (string.IsNullOrWhiteSpace(newValue)) return false; if (!string.Equals(get(), newValue, StringComparison.Ordinal)) { set(newValue); return true; } return false; } /// Helper so a value assignment can participate in a boolean OR chain. private static bool Assign(Action assign) { assign(); return true; } }