From 148a3f465e762efcf315b0be47b73d419d7165fa Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Thu, 18 Jun 2026 08:32:33 -0400 Subject: [PATCH] Self-heal inventory catalog links during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync propagation now also backfills the catalog link: any inventory item with no PowderCatalogItemId that matches a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU) gets linked and picks up 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. This backfills items created before linking existed, automatically, on every environment (dev and prod) with no manual step or one-off script — legacy items link on the next sync, new items still link at create time. Cost basis, quantity, notes, and image remain untouched. Tests: links an unlinked item by manufacturer+part number; leaves it unlinked when the part number has no catalog match. Full suite 278 green. Co-Authored-By: Claude Opus 4.8 --- .../Services/PowderCatalogUpsertService.cs | 63 ++++++++++++++- .../PowderCatalogPropagationTests.cs | 77 +++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs index 77c8797..6c2876f 100644 --- a/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs +++ b/src/PowderCoating.Infrastructure/Services/PowderCatalogUpsertService.cs @@ -87,6 +87,67 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService 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 @@ -95,7 +156,7 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService /// 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. /// - public async Task PropagateToLinkedInventoryAsync() + private async Task RefreshLinkedInventoryAsync() { var linked = (await _unitOfWork.InventoryItems.FindAsync( i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList(); diff --git a/tests/PowderCoating.UnitTests/PowderCatalogPropagationTests.cs b/tests/PowderCoating.UnitTests/PowderCatalogPropagationTests.cs index 4649ea9..61c3a35 100644 --- a/tests/PowderCoating.UnitTests/PowderCatalogPropagationTests.cs +++ b/tests/PowderCoating.UnitTests/PowderCatalogPropagationTests.cs @@ -120,6 +120,83 @@ public class PowderCatalogPropagationTests Assert.Null(refreshed!.CatalogReferencePrice); // stays null -> quoting falls back to UnitCost } + [Fact] + public async Task Propagate_LinksUnlinkedItem_ByManufacturerAndPartNumber() + { + await using var context = CreateContext(); + + var catalog = new PowderCatalogItem + { + VendorName = "Columbia Coatings", + Sku = "CS1693053", + ColorName = "Joker Jewel", + Source = "Columbia Coatings API", + UnitPrice = 28m, + }; + context.PowderCatalogItems.Add(catalog); + await context.SaveChangesAsync(); + + var inv = new InventoryItem + { + CompanyId = 1, + SKU = "POWD-2606-0009", + Name = "Joker Jewel", + Manufacturer = "Columbia Coatings", + ManufacturerPartNumber = "CS1693053", // matches catalog SKU + PowderCatalogItemId = null, // not linked yet (legacy item) + UnitCost = 20m, + }; + context.InventoryItems.Add(inv); + await context.SaveChangesAsync(); + + var service = new PowderCatalogUpsertService( + new UnitOfWork(context), + Mock.Of>()); + + await service.PropagateToLinkedInventoryAsync(); + + var refreshed = await context.InventoryItems.FindAsync(inv.Id); + Assert.Equal(catalog.Id, refreshed!.PowderCatalogItemId); // self-healed link + Assert.Equal(28m, refreshed.CatalogReferencePrice); // and got the price + Assert.Equal(20m, refreshed.UnitCost); // cost untouched + } + + [Fact] + public async Task Propagate_DoesNotLink_WhenPartNumberDoesNotMatch() + { + await using var context = CreateContext(); + + context.PowderCatalogItems.Add(new PowderCatalogItem + { + VendorName = "Columbia Coatings", + Sku = "CS1693053", + ColorName = "Joker Jewel", + UnitPrice = 28m, + }); + await context.SaveChangesAsync(); + + var inv = new InventoryItem + { + CompanyId = 1, + SKU = "POWD-2606-0010", + Name = "Something Else", + Manufacturer = "Columbia Coatings", + ManufacturerPartNumber = "NOPE-999", // no catalog match + PowderCatalogItemId = null, + }; + context.InventoryItems.Add(inv); + await context.SaveChangesAsync(); + + var service = new PowderCatalogUpsertService( + new UnitOfWork(context), + Mock.Of>()); + + await service.PropagateToLinkedInventoryAsync(); + + var refreshed = await context.InventoryItems.FindAsync(inv.Id); + Assert.Null(refreshed!.PowderCatalogItemId); // stays unlinked + } + private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder()