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:
@@ -225,6 +225,12 @@ public class CreateInventoryItemDto
|
|||||||
[Display(Name = "Incoming / On Order")]
|
[Display(Name = "Incoming / On Order")]
|
||||||
public bool IsIncoming { get; set; }
|
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
|
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);
|
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
|
try
|
||||||
{
|
{
|
||||||
var item = _mapper.Map<InventoryItem>(dto);
|
var item = _mapper.Map<InventoryItem>(dto);
|
||||||
@@ -306,12 +327,8 @@ public class InventoryController : Controller
|
|||||||
item.Name = ToTitleCase(item.Name);
|
item.Name = ToTitleCase(item.Name);
|
||||||
|
|
||||||
// Populate legacy Category field from lookup table
|
// Populate legacy Category field from lookup table
|
||||||
if (item.InventoryCategoryId.HasValue)
|
if (category != null)
|
||||||
{
|
item.Category = category.DisplayName;
|
||||||
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
|
// 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
|
// 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
|
// TDS cure fallback — same logic as AiLookup button
|
||||||
await ApplyTdsCureFallbackAsync(aiResult, colorName);
|
await ApplyTdsCureFallbackAsync(aiResult, colorName);
|
||||||
|
|
||||||
// Check if this product already exists in the tenant's inventory.
|
var duplicate = await FindInventoryDuplicateAsync(
|
||||||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
null,
|
||||||
// Returns the first active match so the UI can prompt to add stock inline.
|
manufacturer,
|
||||||
int? existingInventoryId = null;
|
sku,
|
||||||
string? existingInventoryName = null;
|
colorName,
|
||||||
decimal? existingQuantityOnHand = null;
|
isCoating: true);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
@@ -1105,16 +1089,61 @@ public class InventoryController : Controller
|
|||||||
vendorName = manufacturer,
|
vendorName = manufacturer,
|
||||||
wasInCatalog = wasInCatalog,
|
wasInCatalog = wasInCatalog,
|
||||||
addedToCatalog = addedToCatalog,
|
addedToCatalog = addedToCatalog,
|
||||||
existingInventoryId = existingInventoryId,
|
existingInventoryId = duplicate?.Item.Id,
|
||||||
existingInventoryName = existingInventoryName,
|
existingInventoryName = duplicate?.Item.Name,
|
||||||
existingQuantityOnHand = existingQuantityOnHand,
|
existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
|
||||||
existingUnitOfMeasure = existingUnitOfMeasure,
|
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
|
||||||
|
duplicateMatchType = duplicate?.MatchType.ToString(),
|
||||||
reasoning = aiResult.Reasoning,
|
reasoning = aiResult.Reasoning,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[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)
|
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||||
{
|
{
|
||||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-4">
|
<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" />
|
<partial name="_ValidationSummary" />
|
||||||
|
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
|
||||||
|
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -428,16 +430,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
<partial name="_LabelScanModal" />
|
||||||
{
|
|
||||||
<partial name="_LabelScanModal" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
<script>const inventoryFormIsCreate = true;</script>
|
<script>const inventoryFormIsCreate = true;</script>
|
||||||
<partial name="_InventoryColorFamilyScripts" />
|
<partial name="_InventoryColorFamilyScripts" />
|
||||||
<script src="~/js/inventory-catalog-lookup.js"></script>
|
<script src="~/js/inventory-catalog-lookup.js"></script>
|
||||||
|
<script src="~/js/inventory-duplicate-check.js"></script>
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||||
{
|
{
|
||||||
<script src="~/js/inventory-label-scan.js"></script>
|
<script src="~/js/inventory-label-scan.js"></script>
|
||||||
|
|||||||
@@ -448,15 +448,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
<partial name="_LabelScanModal" />
|
||||||
{
|
|
||||||
<partial name="_LabelScanModal" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
<partial name="_InventoryColorFamilyScripts" />
|
<partial name="_InventoryColorFamilyScripts" />
|
||||||
<script src="~/js/inventory-catalog-lookup.js"></script>
|
<script src="~/js/inventory-catalog-lookup.js"></script>
|
||||||
|
<script src="~/js/inventory-duplicate-check.js"></script>
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||||
{
|
{
|
||||||
<script src="~/js/inventory-label-scan.js"></script>
|
<script src="~/js/inventory-label-scan.js"></script>
|
||||||
|
|||||||
@@ -208,6 +208,8 @@
|
|||||||
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
|
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
|
||||||
|
|
||||||
const discontinuedNote = item.isDiscontinued
|
const discontinuedNote = item.isDiscontinued
|
||||||
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
|
? ' <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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -46,33 +46,14 @@
|
|||||||
const processingEl = document.getElementById('scan-processing');
|
const processingEl = document.getElementById('scan-processing');
|
||||||
const processingMsgEl= document.getElementById('scan-processing-msg');
|
const processingMsgEl= document.getElementById('scan-processing-msg');
|
||||||
|
|
||||||
// Add-stock modal elements
|
let _lastScanData = null;
|
||||||
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;
|
if (!modalEl || !videoEl || !canvasEl) return;
|
||||||
|
|
||||||
scanBtn.addEventListener('click', openScanner);
|
scanBtn.addEventListener('click', openScanner);
|
||||||
modalEl.addEventListener('hide.bs.modal', onModalClose);
|
modalEl.addEventListener('hide.bs.modal', onModalClose);
|
||||||
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
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);
|
window.addEventListener('beforeunload', releaseCamera);
|
||||||
|
|
||||||
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
||||||
@@ -326,9 +307,10 @@
|
|||||||
|
|
||||||
if (data.existingInventoryId) {
|
if (data.existingInventoryId) {
|
||||||
// Product already in inventory — show inline add-stock prompt
|
// Product already in inventory — show inline add-stock prompt
|
||||||
_lastScanData = data;
|
_lastScanData = data;
|
||||||
_addStockItemId = data.existingInventoryId;
|
window.inventoryDuplicateUi?.show(data, {
|
||||||
openAddStockModal(data);
|
onCreateSeparate: () => fillFromScan(_lastScanData, true)
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
fillFromScan(data);
|
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) {
|
function fillFromScan(data, skipDuplicatePrompt = false) {
|
||||||
const filled = [];
|
const filled = [];
|
||||||
|
|
||||||
@@ -506,6 +415,8 @@
|
|||||||
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
|
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
|
||||||
|
|
||||||
if (data.existingInventoryId && !skipDuplicatePrompt) {
|
if (data.existingInventoryId && !skipDuplicatePrompt) {
|
||||||
// Duplicate handled by add-stock modal — don't show a banner here
|
// Duplicate handled by add-stock modal — don't show a banner here
|
||||||
} else if (data.existingInventoryId && skipDuplicatePrompt) {
|
} 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user