Self-heal inventory catalog links during sync

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 08:32:33 -04:00
parent a6538d9638
commit 148a3f465e
2 changed files with 139 additions and 1 deletions
@@ -87,6 +87,67 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService
return result;
}
/// <summary>
/// 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.
/// </summary>
public async Task<int> PropagateToLinkedInventoryAsync()
{
var linkedCount = await LinkUnlinkedInventoryAsync();
var refreshedCount = await RefreshLinkedInventoryAsync();
return linkedCount + refreshedCount;
}
/// <summary>
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
/// 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.
/// </summary>
private async Task<int> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<int> PropagateToLinkedInventoryAsync()
private async Task<int> RefreshLinkedInventoryAsync()
{
var linked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();