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:
@@ -87,6 +87,67 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
|||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
|
/// 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
|
/// 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
|
/// 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.
|
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> PropagateToLinkedInventoryAsync()
|
private async Task<int> RefreshLinkedInventoryAsync()
|
||||||
{
|
{
|
||||||
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||||
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
|
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
|
||||||
|
|||||||
@@ -120,6 +120,83 @@ public class PowderCatalogPropagationTests
|
|||||||
Assert.Null(refreshed!.CatalogReferencePrice); // stays null -> quoting falls back to UnitCost
|
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<ILogger<PowderCatalogUpsertService>>());
|
||||||
|
|
||||||
|
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<ILogger<PowderCatalogUpsertService>>());
|
||||||
|
|
||||||
|
await service.PropagateToLinkedInventoryAsync();
|
||||||
|
|
||||||
|
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||||
|
Assert.Null(refreshed!.PowderCatalogItemId); // stays unlinked
|
||||||
|
}
|
||||||
|
|
||||||
private static ApplicationDbContext CreateContext()
|
private static ApplicationDbContext CreateContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
|||||||
Reference in New Issue
Block a user