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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user