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
@@ -225,6 +225,12 @@ public class CreateInventoryItemDto
[Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; }
/// <summary>
/// Existing inventory record the user explicitly chose to bypass when creating a separate
/// powder lot or location. SKU duplicates can never be bypassed.
/// </summary>
public int? DuplicateOverrideInventoryItemId { get; set; }
}
public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -0,0 +1,91 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
public enum InventoryDuplicateMatchType
{
Sku,
ManufacturerPartNumber,
ManufacturerColor
}
public sealed record InventoryDuplicateMatch(
InventoryItem Item,
InventoryDuplicateMatchType MatchType);
/// <summary>
/// Shared inventory duplicate rules used by manual creation and powder-label scanning.
/// Callers are responsible for supplying inventory already restricted to the current tenant.
/// </summary>
public static class InventoryDuplicateMatcher
{
public static InventoryDuplicateMatch? Find(
IEnumerable<InventoryItem> inventoryItems,
int companyId,
string? sku,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
bool isCoating,
int? excludeId = null)
{
var candidates = inventoryItems
.Where(i => i.CompanyId == companyId && i.Id != excludeId)
.ToList();
var normalizedSku = Normalize(sku);
if (normalizedSku.Length > 0)
{
var skuMatch = candidates.FirstOrDefault(i => Normalize(i.SKU) == normalizedSku);
if (skuMatch != null)
return new InventoryDuplicateMatch(skuMatch, InventoryDuplicateMatchType.Sku);
}
if (!isCoating)
return null;
var coatingCandidates = candidates
.Where(i => i.InventoryCategory?.IsCoating == true)
.ToList();
var normalizedManufacturer = Normalize(manufacturer);
var normalizedPartNumber = Normalize(manufacturerPartNumber);
if (normalizedPartNumber.Length > 0)
{
var partNumberMatch = coatingCandidates.FirstOrDefault(i =>
Normalize(i.ManufacturerPartNumber) == normalizedPartNumber &&
(normalizedManufacturer.Length == 0 ||
Normalize(i.Manufacturer) == normalizedManufacturer));
if (partNumberMatch != null)
return new InventoryDuplicateMatch(
partNumberMatch,
InventoryDuplicateMatchType.ManufacturerPartNumber);
}
var normalizedColorName = Normalize(colorName);
if (normalizedManufacturer.Length == 0 || normalizedColorName.Length == 0)
return null;
var manufacturerColorMatch = coatingCandidates.FirstOrDefault(i =>
Normalize(i.Manufacturer) == normalizedManufacturer &&
Normalize(i.ColorName ?? i.Name) == normalizedColorName);
return manufacturerColorMatch == null
? null
: new InventoryDuplicateMatch(
manufacturerColorMatch,
InventoryDuplicateMatchType.ManufacturerColor);
}
private static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
return string.Join(
' ',
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
}
@@ -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;
}
// 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;
@@ -17,8 +17,10 @@
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post">
<form asp-action="Create" method="post" id="inventory-create-form">
<input type="hidden" asp-for="DuplicateOverrideInventoryItemId" id="duplicate-override-id" />
<partial name="_ValidationSummary" />
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
<!-- Basic Information -->
<div class="mb-4">
@@ -428,16 +430,14 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
<partial name="_LabelScanModal" />
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script>
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
<script src="~/js/inventory-duplicate-check.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
@@ -448,15 +448,13 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
<partial name="_LabelScanModal" />
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
<script src="~/js/inventory-duplicate-check.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
@@ -208,6 +208,8 @@
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
const discontinuedNote = item.isDiscontinued
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
@@ -0,0 +1,263 @@
/**
* Shared inventory duplicate UI.
*
* Owns the "already in inventory" modal and the manual Create-form preflight check.
* Label scanning calls window.inventoryDuplicateUi.show(...) so both entry paths use
* the same prompt and add-stock implementation.
*/
(function () {
'use strict';
const modalEl = document.getElementById('addStockModal');
const modal = modalEl ? bootstrap.Modal.getOrCreateInstance(modalEl) : null;
const itemNameEl = document.getElementById('add-stock-item-name');
const currentQtyEl = document.getElementById('add-stock-current-qty');
const uomEl = document.getElementById('add-stock-uom-label');
const qtyEl = document.getElementById('add-stock-qty');
const costEl = document.getElementById('add-stock-cost');
const notesEl = document.getElementById('add-stock-notes');
const modalStatusEl = document.getElementById('add-stock-status');
const addButton = document.getElementById('add-stock-confirm-btn');
const createSeparateButton = document.getElementById('add-stock-new-btn');
let activeData = null;
let activeOptions = {};
function show(data, options) {
if (!modal || !data?.existingInventoryId) return;
activeData = data;
activeOptions = options || {};
const uom = data.existingUnitOfMeasure || 'lbs';
itemNameEl.textContent = data.existingInventoryName || data.colorName || 'This product';
currentQtyEl.textContent = `${Number(data.existingQuantityOnHand || 0).toFixed(2)} ${uom}`;
uomEl.textContent = uom;
qtyEl.value = '';
costEl.value = Number(data.unitPrice || 0) > 0 ? data.unitPrice : '';
notesEl.value = '';
modalStatusEl.className = 'd-none';
modalStatusEl.textContent = '';
addButton.disabled = false;
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
createSeparateButton.classList.toggle('d-none', data.isBlocking === true);
modal.show();
}
async function addStock() {
const quantity = Number(qtyEl.value);
if (!quantity || quantity <= 0) {
showModalStatus('danger', 'Enter a quantity greater than zero.');
return;
}
addButton.disabled = true;
addButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: activeData.existingInventoryId,
quantity: quantity.toString()
});
const unitCost = Number(costEl.value);
if (unitCost > 0) params.set('unitCost', unitCost.toString());
if (notesEl.value.trim()) params.set('notes', notesEl.value.trim());
const response = await fetch(`/Inventory/AddStock?${params}`, { method: 'POST' });
if (!response.ok) throw new Error(`Server error ${response.status}`);
const result = await response.json();
if (!result.success) {
showModalStatus('danger', result.errorMessage || 'Failed to add stock.');
return;
}
modal.hide();
if (typeof activeOptions.onAdded === 'function') {
activeOptions.onAdded(result, quantity, activeData);
} else {
showFormMessage(
'success',
`Added <strong>${quantity.toFixed(2)} ${escapeHtml(result.unitOfMeasure)}</strong> to ` +
`<strong>${escapeHtml(result.itemName)}</strong>. New stock: ` +
`${Number(result.newQuantityOnHand || 0).toFixed(2)} ${escapeHtml(result.unitOfMeasure)}.`
);
}
} catch (error) {
showModalStatus('danger', error.message);
} finally {
addButton.disabled = false;
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
addButton?.addEventListener('click', addStock);
createSeparateButton?.addEventListener('click', function () {
modal?.hide();
if (typeof activeOptions.onCreateSeparate === 'function') {
activeOptions.onCreateSeparate(activeData);
}
});
window.inventoryDuplicateUi = { show };
const form = document.getElementById('inventory-create-form');
const statusEl = document.getElementById('inventory-duplicate-status');
const overrideEl = document.getElementById('duplicate-override-id');
if (!form || !statusEl || !overrideEl) return;
const fields = {
sku: document.getElementById('field-sku'),
categoryId: document.getElementById('field-category'),
manufacturer: document.getElementById('field-manufacturer'),
manufacturerPartNumber: document.getElementById('field-partnumber'),
colorName: document.getElementById('field-colorname')
};
let timer = null;
let requestVersion = 0;
let latestDuplicate = null;
let acknowledgedSignature = null;
function signature() {
return Object.values(fields)
.map(field => field?.value?.trim().toUpperCase() || '')
.join('|');
}
function scheduleCheck() {
overrideEl.value = '';
acknowledgedSignature = null;
clearTimeout(timer);
timer = setTimeout(() => checkDuplicate(false), 400);
}
Object.values(fields).forEach(field => {
field?.addEventListener('input', scheduleCheck);
field?.addEventListener('change', scheduleCheck);
field?.addEventListener('blur', () => checkDuplicate(false));
});
document.addEventListener('inventory:identity-changed', scheduleCheck);
async function checkDuplicate(showChecking) {
const version = ++requestVersion;
const params = new URLSearchParams();
Object.entries(fields).forEach(([key, field]) => {
if (field?.value?.trim()) params.set(key, field.value.trim());
});
if (!params.has('sku') &&
!(params.has('manufacturer') &&
(params.has('manufacturerPartNumber') || params.has('colorName')))) {
latestDuplicate = null;
hideStatus();
return null;
}
if (showChecking) showFormMessage('info', 'Checking existing inventory…');
try {
const response = await fetch(`/Inventory/CheckDuplicate?${params}`);
if (!response.ok) throw new Error(`Server error ${response.status}`);
const data = await response.json();
if (version !== requestVersion) return latestDuplicate;
latestDuplicate = data.hasDuplicate ? data : null;
if (!latestDuplicate) {
hideStatus();
return null;
}
renderDuplicate(latestDuplicate);
return latestDuplicate;
} catch {
if (showChecking) {
showFormMessage(
'warning',
'Inventory could not be checked right now. The item will be checked again when saved.'
);
}
return latestDuplicate;
}
}
function renderDuplicate(data) {
const quantity = Number(data.existingQuantityOnHand || 0).toFixed(2);
const uom = escapeHtml(data.existingUnitOfMeasure || 'lbs');
const viewLink = `/Inventory/Details/${data.existingInventoryId}`;
const overrideButton = data.isBlocking
? ''
: '<button type="button" class="btn btn-sm btn-outline-secondary duplicate-create-separate">Create separate entry</button>';
statusEl.className = 'alert alert-warning mb-3';
statusEl.innerHTML = `
<div class="d-flex gap-2 align-items-start">
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Already in inventory</div>
<div class="small">${escapeHtml(data.message)}</div>
<div class="small text-muted mt-1">Current stock: ${quantity} ${uom}</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<a class="btn btn-sm btn-outline-secondary" href="${viewLink}">View existing item</a>
<button type="button" class="btn btn-sm btn-primary duplicate-add-stock">Add stock</button>
${overrideButton}
</div>
</div>
</div>`;
statusEl.querySelector('.duplicate-add-stock')?.addEventListener('click', () => show(data));
statusEl.querySelector('.duplicate-create-separate')?.addEventListener('click', () => {
overrideEl.value = data.existingInventoryId;
acknowledgedSignature = signature();
statusEl.className = 'alert alert-warning mb-3';
statusEl.innerHTML =
`<strong>Separate entry confirmed.</strong> ${escapeHtml(data.message)} ` +
`<a class="alert-link" href="${viewLink}">View existing item</a>`;
});
}
form.addEventListener('submit', async function (event) {
if (form.dataset.duplicateValidated === 'true') {
form.dataset.duplicateValidated = '';
return;
}
event.preventDefault();
const duplicate = await checkDuplicate(true);
const isAcknowledged = duplicate &&
!duplicate.isBlocking &&
Number(overrideEl.value) === Number(duplicate.existingInventoryId) &&
acknowledgedSignature === signature();
if (!duplicate || isAcknowledged) {
form.dataset.duplicateValidated = 'true';
form.requestSubmit(event.submitter);
return;
}
statusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
statusEl.querySelector('button, a')?.focus({ preventScroll: true });
});
function showModalStatus(type, message) {
modalStatusEl.className = `alert alert-${type} py-2 small`;
modalStatusEl.textContent = message;
}
function showFormMessage(type, message) {
statusEl.className = `alert alert-${type} mb-3`;
statusEl.innerHTML = message;
}
function hideStatus() {
statusEl.className = 'd-none mb-3';
statusEl.innerHTML = '';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
@@ -46,19 +46,6 @@
const processingEl = document.getElementById('scan-processing');
const processingMsgEl= document.getElementById('scan-processing-msg');
// Add-stock modal elements
const addStockModalEl = document.getElementById('addStockModal');
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
const addStockItemName = document.getElementById('add-stock-item-name');
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
const addStockUomLabel = document.getElementById('add-stock-uom-label');
const addStockQtyInput = document.getElementById('add-stock-qty');
const addStockCostInput = document.getElementById('add-stock-cost');
const addStockNotesInput= document.getElementById('add-stock-notes');
const addStockStatusEl = document.getElementById('add-stock-status');
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
let _addStockItemId = null;
let _lastScanData = null;
if (!modalEl || !videoEl || !canvasEl) return;
@@ -66,13 +53,7 @@
scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
const addStockNewBtn = document.getElementById('add-stock-new-btn');
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
bsAddStockModal?.hide();
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
});
window.addEventListener('beforeunload', releaseCamera);
// Pre-warm camera if browser has already granted permission (no prompt risk)
@@ -327,8 +308,9 @@
if (data.existingInventoryId) {
// Product already in inventory — show inline add-stock prompt
_lastScanData = data;
_addStockItemId = data.existingInventoryId;
openAddStockModal(data);
window.inventoryDuplicateUi?.show(data, {
onCreateSeparate: () => fillFromScan(_lastScanData, true)
});
} else {
fillFromScan(data);
}
@@ -339,79 +321,6 @@
}
}
// ── Add-stock modal ───────────────────────────────────────────────────
function openAddStockModal(data) {
if (!bsAddStockModal) { fillFromScan(data); return; }
const uom = data.existingUnitOfMeasure || 'lbs';
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
if (addStockUomLabel) addStockUomLabel.textContent = uom;
if (addStockQtyInput) addStockQtyInput.value = '';
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
if (addStockNotesInput) addStockNotesInput.value = '';
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
bsAddStockModal.show();
}
async function submitAddStock() {
const qty = parseFloat(addStockQtyInput?.value);
if (!qty || qty <= 0) {
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
return;
}
addStockConfirmBtn.disabled = true;
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: _addStockItemId,
quantity: qty,
});
const cost = parseFloat(addStockCostInput?.value);
if (cost > 0) params.append('unitCost', cost);
const notes = addStockNotesInput?.value?.trim();
if (notes) params.append('notes', notes);
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
return;
}
// Success — close modal and show confirmation on the form
bsAddStockModal.hide();
showFormStatus('success',
`<i class="bi bi-check-circle-fill me-1"></i>` +
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
);
} catch (err) {
showAddStockStatus('danger', 'Error: ' + err.message);
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
function showAddStockStatus(type, msg) {
if (!addStockStatusEl) return;
addStockStatusEl.className = `alert alert-${type} py-2 small`;
addStockStatusEl.textContent = msg;
}
// ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data, skipDuplicatePrompt = false) {
const filled = [];
@@ -506,6 +415,8 @@
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
: '';
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
if (data.existingInventoryId && !skipDuplicatePrompt) {
// Duplicate handled by add-stock modal — don't show a banner here
} else if (data.existingInventoryId && skipDuplicatePrompt) {
@@ -0,0 +1,167 @@
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
namespace PowderCoating.UnitTests;
public class InventoryDuplicateMatcherTests
{
[Fact]
public void Find_SkuMatch_IsRestrictedToRequestedCompany()
{
var otherTenantItem = Item(
id: 1,
companyId: 22,
sku: "POW-001",
manufacturer: "Prismatic Powders",
colorName: "Illusion Malbec");
var result = InventoryDuplicateMatcher.Find(
new[] { otherTenantItem },
companyId: 11,
sku: "POW-001",
manufacturer: null,
manufacturerPartNumber: null,
colorName: null,
isCoating: false);
Assert.Null(result);
}
[Fact]
public void Find_SkuMatch_IsCaseAndWhitespaceInsensitive()
{
var item = Item(1, 11, "POW-001", null, null);
var result = InventoryDuplicateMatcher.Find(
new[] { item },
companyId: 11,
sku: " pow-001 ",
manufacturer: null,
manufacturerPartNumber: null,
colorName: null,
isCoating: false);
Assert.NotNull(result);
Assert.Equal(InventoryDuplicateMatchType.Sku, result!.MatchType);
Assert.Same(item, result.Item);
}
[Fact]
public void Find_PowderManufacturerAndColor_NormalizesSpacingAndCase()
{
var item = Item(
id: 1,
companyId: 11,
sku: "POW-001",
manufacturer: "Prismatic Powders",
colorName: "Illusion Malbec");
var result = InventoryDuplicateMatcher.Find(
new[] { item },
companyId: 11,
sku: null,
manufacturer: " prismatic powders ",
manufacturerPartNumber: null,
colorName: "illusion malbec",
isCoating: true);
Assert.NotNull(result);
Assert.Equal(InventoryDuplicateMatchType.ManufacturerColor, result!.MatchType);
}
[Fact]
public void Find_SameColorFromDifferentManufacturer_IsNotDuplicate()
{
var item = Item(
id: 1,
companyId: 11,
sku: "POW-001",
manufacturer: "Prismatic Powders",
colorName: "Gloss Black");
var result = InventoryDuplicateMatcher.Find(
new[] { item },
companyId: 11,
sku: null,
manufacturer: "Columbia Coatings",
manufacturerPartNumber: null,
colorName: "Gloss Black",
isCoating: true);
Assert.Null(result);
}
[Fact]
public void Find_ManufacturerPartNumber_UsesSharedPowderRule()
{
var item = Item(
id: 1,
companyId: 11,
sku: "POW-001",
manufacturer: "Columbia Coatings",
colorName: "Smokey Blue",
manufacturerPartNumber: "S5704126");
var result = InventoryDuplicateMatcher.Find(
new[] { item },
companyId: 11,
sku: null,
manufacturer: "columbia coatings",
manufacturerPartNumber: " s5704126 ",
colorName: null,
isCoating: true);
Assert.NotNull(result);
Assert.Equal(InventoryDuplicateMatchType.ManufacturerPartNumber, result!.MatchType);
}
[Fact]
public void Find_NonCoating_DoesNotUsePowderIdentityRules()
{
var item = Item(
id: 1,
companyId: 11,
sku: "SUP-001",
manufacturer: "Acme",
colorName: "Blue");
var result = InventoryDuplicateMatcher.Find(
new[] { item },
companyId: 11,
sku: null,
manufacturer: "Acme",
manufacturerPartNumber: null,
colorName: "Blue",
isCoating: false);
Assert.Null(result);
}
private static InventoryItem Item(
int id,
int companyId,
string sku,
string? manufacturer,
string? colorName,
string? manufacturerPartNumber = null)
{
return new InventoryItem
{
Id = id,
CompanyId = companyId,
SKU = sku,
Name = colorName ?? sku,
Manufacturer = manufacturer,
ManufacturerPartNumber = manufacturerPartNumber,
ColorName = colorName,
InventoryCategory = new InventoryCategoryLookup
{
Id = id,
CompanyId = companyId,
DisplayName = "Powder",
CategoryCode = "POWDER",
IsCoating = true
}
};
}
}