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()