Add inventory duplicate detection on add and label scan

Introduce a shared InventoryDuplicateMatcher (SKU, manufacturer part number,
manufacturer color) used by both manual inventory creation and powder-label
scanning, so the two paths flag duplicates consistently. Surfaces a duplicate
warning in the Create/Edit forms via inventory-duplicate-check.js and the
catalog-lookup / label-scan flows. Callers pass tenant-restricted inventory;
the matcher re-checks CompanyId as defense in depth.

Adds InventoryDuplicateMatcherTests covering the match precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:39:24 -04:00
parent 1005be0c9e
commit 517e452c64
9 changed files with 665 additions and 156 deletions
@@ -298,6 +298,27 @@ public class InventoryController : Controller
return View(dto);
}
var category = dto.InventoryCategoryId.HasValue
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.InventoryCategoryId.Value)
: null;
var duplicate = await FindInventoryDuplicateAsync(
dto.SKU,
dto.Manufacturer,
dto.ManufacturerPartNumber,
dto.ColorName,
category?.IsCoating == true);
if (duplicate != null &&
(duplicate.MatchType == InventoryDuplicateMatchType.Sku ||
dto.DuplicateOverrideInventoryItemId != duplicate.Item.Id))
{
ModelState.AddModelError(
duplicate.MatchType == InventoryDuplicateMatchType.Sku ? nameof(dto.SKU) : string.Empty,
BuildDuplicateMessage(duplicate));
await PopulateDropdowns();
return View(dto);
}
try
{
var item = _mapper.Map<InventoryItem>(dto);
@@ -306,12 +327,8 @@ public class InventoryController : Controller
item.Name = ToTitleCase(item.Name);
// Populate legacy Category field from lookup table
if (item.InventoryCategoryId.HasValue)
{
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
if (category != null)
item.Category = category.DisplayName;
}
if (category != null)
item.Category = category.DisplayName;
// Link to the platform catalog row when this item's identity matches one, so the detail
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
@@ -1042,45 +1059,12 @@ public class InventoryController : Controller
// TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName);
// Check if this product already exists in the tenant's inventory.
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
// Returns the first active match so the UI can prompt to add stock inline.
int? existingInventoryId = null;
string? existingInventoryName = null;
decimal? existingQuantityOnHand = null;
string? existingUnitOfMeasure = null;
InventoryItem? existingHit = null;
if (!string.IsNullOrEmpty(sku))
{
var skuLower = sku.ToLower();
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
i.ManufacturerPartNumber != null &&
i.ManufacturerPartNumber.ToLower() == skuLower);
existingHit = byPart.FirstOrDefault();
}
if (existingHit == null && !string.IsNullOrEmpty(colorName))
{
var nameLower = colorName.ToLower();
var mfrLower = manufacturer?.ToLower() ?? "";
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
i.Name.ToLower() == nameLower);
existingHit = byName.FirstOrDefault(i =>
string.IsNullOrEmpty(mfrLower) ||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
}
if (existingHit != null)
{
existingInventoryId = existingHit.Id;
existingInventoryName = existingHit.Name;
existingQuantityOnHand = existingHit.QuantityOnHand;
existingUnitOfMeasure = existingHit.UnitOfMeasure;
}
var duplicate = await FindInventoryDuplicateAsync(
null,
manufacturer,
sku,
colorName,
isCoating: true);
return Json(new
{
@@ -1105,16 +1089,61 @@ public class InventoryController : Controller
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
existingInventoryId = existingInventoryId,
existingInventoryName = existingInventoryName,
existingQuantityOnHand = existingQuantityOnHand,
existingUnitOfMeasure = existingUnitOfMeasure,
existingInventoryId = duplicate?.Item.Id,
existingInventoryName = duplicate?.Item.Name,
existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
duplicateMatchType = duplicate?.MatchType.ToString(),
reasoning = aiResult.Reasoning,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the label scanner inline prompt.
/// Checks the current tenant's active inventory for an existing SKU or powder identity.
/// Uses the same matcher as label scanning and repeats the tenant boundary explicitly.
/// </summary>
[HttpGet]
public async Task<IActionResult> CheckDuplicate(
string? sku,
int? categoryId,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
int? currentId = null)
{
var category = categoryId.HasValue
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId.Value)
: null;
var duplicate = await FindInventoryDuplicateAsync(
sku,
manufacturer,
manufacturerPartNumber,
colorName,
category?.IsCoating == true,
currentId);
if (duplicate == null)
return Json(new { hasDuplicate = false });
return Json(new
{
hasDuplicate = true,
isBlocking = duplicate.MatchType == InventoryDuplicateMatchType.Sku,
matchType = duplicate.MatchType.ToString(),
message = BuildDuplicateMessage(duplicate),
existingInventoryId = duplicate.Item.Id,
existingInventoryName = duplicate.Item.Name,
existingSku = duplicate.Item.SKU,
existingManufacturer = duplicate.Item.Manufacturer,
existingColorName = duplicate.Item.ColorName,
existingQuantityOnHand = duplicate.Item.QuantityOnHand,
existingUnitOfMeasure = duplicate.Item.UnitOfMeasure,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the shared duplicate prompt.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary>
[HttpPost]
@@ -1360,6 +1389,48 @@ public class InventoryController : Controller
}
}
private async Task<InventoryDuplicateMatch?> FindInventoryDuplicateAsync(
string? sku,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
bool isCoating,
int? excludeId = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (!companyId.HasValue || companyId.Value <= 0)
return null;
// Explicit CompanyId predicate is intentional defense-in-depth on top of the global filter.
var tenantInventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId.Value,
false,
i => i.InventoryCategory!);
return InventoryDuplicateMatcher.Find(
tenantInventory,
companyId.Value,
sku,
manufacturer,
manufacturerPartNumber,
colorName,
isCoating,
excludeId);
}
private static string BuildDuplicateMessage(InventoryDuplicateMatch duplicate)
{
return duplicate.MatchType switch
{
InventoryDuplicateMatchType.Sku =>
$"SKU '{duplicate.Item.SKU}' is already used by '{duplicate.Item.Name}'.",
InventoryDuplicateMatchType.ManufacturerPartNumber =>
$"This manufacturer's part number is already recorded as '{duplicate.Item.Name}' ({duplicate.Item.SKU}).",
_ =>
$"{duplicate.Item.Manufacturer} {duplicate.Item.ColorName ?? duplicate.Item.Name} is already in inventory as '{duplicate.Item.Name}' ({duplicate.Item.SKU})."
};
}
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{
return transferEfficiency ?? DefaultTransferEfficiency;