diff --git a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs index 9804d24..a401fac 100644 --- a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs +++ b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs @@ -225,6 +225,12 @@ public class CreateInventoryItemDto [Display(Name = "Incoming / On Order")] public bool IsIncoming { get; set; } + /// + /// Existing inventory record the user explicitly chose to bypass when creating a separate + /// powder lot or location. SKU duplicates can never be bypassed. + /// + public int? DuplicateOverrideInventoryItemId { get; set; } + } public class UpdateInventoryItemDto : CreateInventoryItemDto diff --git a/src/PowderCoating.Application/Services/InventoryDuplicateMatcher.cs b/src/PowderCoating.Application/Services/InventoryDuplicateMatcher.cs new file mode 100644 index 0000000..9495e2d --- /dev/null +++ b/src/PowderCoating.Application/Services/InventoryDuplicateMatcher.cs @@ -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); + +/// +/// Shared inventory duplicate rules used by manual creation and powder-label scanning. +/// Callers are responsible for supplying inventory already restricted to the current tenant. +/// +public static class InventoryDuplicateMatcher +{ + public static InventoryDuplicateMatch? Find( + IEnumerable 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(); + } +} diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index deb8ceb..10255f6 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -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(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, }); } /// - /// 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. + /// + [HttpGet] + public async Task 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, + }); + } + + /// + /// Adds stock to an existing inventory item from the shared duplicate prompt. /// Creates a Purchase transaction and updates QuantityOnHand without navigating away. /// [HttpPost] @@ -1360,6 +1389,48 @@ public class InventoryController : Controller } } + private async Task 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; diff --git a/src/PowderCoating.Web/Views/Inventory/Create.cshtml b/src/PowderCoating.Web/Views/Inventory/Create.cshtml index b953e49..880c7c5 100644 --- a/src/PowderCoating.Web/Views/Inventory/Create.cshtml +++ b/src/PowderCoating.Web/Views/Inventory/Create.cshtml @@ -17,8 +17,10 @@
-
+ + +
@@ -428,16 +430,14 @@
-@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) -{ - -} + @section Scripts { + @if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) { diff --git a/src/PowderCoating.Web/Views/Inventory/Edit.cshtml b/src/PowderCoating.Web/Views/Inventory/Edit.cshtml index 4b9825a..bdbe6ed 100644 --- a/src/PowderCoating.Web/Views/Inventory/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Inventory/Edit.cshtml @@ -448,15 +448,13 @@
-@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) -{ - -} + @section Scripts { + @if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) { diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js b/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js index c202de6..60c82b9 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js @@ -208,6 +208,8 @@ if (match) { vendorSel.value = match.value; filled.push('Vendor'); } } + document.dispatchEvent(new CustomEvent('inventory:identity-changed')); + const discontinuedNote = item.isDiscontinued ? ' Discontinued' : ''; diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-duplicate-check.js b/src/PowderCoating.Web/wwwroot/js/inventory-duplicate-check.js new file mode 100644 index 0000000..40a236f --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/inventory-duplicate-check.js @@ -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 = '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 = '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 ${quantity.toFixed(2)} ${escapeHtml(result.unitOfMeasure)} to ` + + `${escapeHtml(result.itemName)}. New stock: ` + + `${Number(result.newQuantityOnHand || 0).toFixed(2)} ${escapeHtml(result.unitOfMeasure)}.` + ); + } + } catch (error) { + showModalStatus('danger', error.message); + } finally { + addButton.disabled = false; + addButton.innerHTML = '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 + ? '' + : ''; + + statusEl.className = 'alert alert-warning mb-3'; + statusEl.innerHTML = ` +
+ +
+
Already in inventory
+
${escapeHtml(data.message)}
+
Current stock: ${quantity} ${uom}
+
+ View existing item + + ${overrideButton} +
+
+
`; + + 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 = + `Separate entry confirmed. ${escapeHtml(data.message)} ` + + `View existing item`; + }); + } + + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } +})(); diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js index e41dd14..0086409 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js @@ -46,33 +46,14 @@ 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; + let _lastScanData = null; if (!modalEl || !videoEl || !canvasEl) return; 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) @@ -326,9 +307,10 @@ if (data.existingInventoryId) { // Product already in inventory — show inline add-stock prompt - _lastScanData = data; - _addStockItemId = data.existingInventoryId; - openAddStockModal(data); + _lastScanData = 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 = '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 = 'Add Stock'; - return; - } - - // Success — close modal and show confirmation on the form - bsAddStockModal.hide(); - showFormStatus('success', - `` + - `Added ${qty.toFixed(2)} ${data.unitOfMeasure} to ${data.itemName}. ` + - `New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` + - `View item` - ); - - } catch (err) { - showAddStockStatus('danger', 'Error: ' + err.message); - addStockConfirmBtn.disabled = false; - addStockConfirmBtn.innerHTML = '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 @@ ? ' Added to platform catalog' : ''; + 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) { diff --git a/tests/PowderCoating.UnitTests/InventoryDuplicateMatcherTests.cs b/tests/PowderCoating.UnitTests/InventoryDuplicateMatcherTests.cs new file mode 100644 index 0000000..7667810 --- /dev/null +++ b/tests/PowderCoating.UnitTests/InventoryDuplicateMatcherTests.cs @@ -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 + } + }; + } +}