Auto-receive catalog powders and fix soft-deleted SKU collision

Two fixes to the "Got It" powder receive flow:

1. Skip the modal when the powder is in the master catalog. Clicking "Got It"
   now first calls ReceivePowderFromCatalog, which — if the powder resolves in
   the catalog — creates a fully populated inventory record (specs, cure, SDS/
   TDS, image, pricing) and marks the coat received, no modal. Only when the
   powder isn't in the catalog does it fall back to the manual entry modal.
   The catalog match/apply and the receive finalize (opening txn, mark received,
   sibling-coat linking) are extracted into shared helpers used by both the
   modal save and the auto-receive path.

2. Fix a crash re-receiving a previously-deleted powder. The unique index
   IX_InventoryItems_CompanyId_SKU had no filter, so a soft-deleted item still
   reserved its SKU; re-creating it generated the same SKU and violated the
   constraint. The index is now filtered on IsDeleted = 0, matching the app's
   soft-delete semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:42:29 -04:00
parent 99b22d2ad2
commit 115ccf7d5e
6 changed files with 11702 additions and 75 deletions
@@ -1511,6 +1511,9 @@ modelBuilder.Entity<Job>()
modelBuilder.Entity<InventoryItem>() modelBuilder.Entity<InventoryItem>()
.HasIndex(i => new { i.CompanyId, i.SKU }) .HasIndex(i => new { i.CompanyId, i.SKU })
.IsUnique() .IsUnique()
// Filter on IsDeleted so soft-deleted items don't reserve their SKU and block a new
// (or re-created) item from reusing it — matching the app's soft-delete semantics.
.HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); .HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
modelBuilder.Entity<Company>() modelBuilder.Entity<Company>()
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FilterInventorySkuUniqueIndexOnSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true);
}
}
}
@@ -4244,7 +4244,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId", "SKU") b.HasIndex("CompanyId", "SKU")
.IsUnique() .IsUnique()
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); .HasDatabaseName("IX_InventoryItems_CompanyId_SKU")
.HasFilter("[IsDeleted] = 0");
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint") b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
@@ -7234,7 +7235,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997), CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7245,7 +7246,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002), CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7256,7 +7257,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003), CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -771,59 +771,9 @@ public class DashboardController : Controller
// coat's colorCode), preferring the same manufacturer; fall back to color name. // coat's colorCode), preferring the same manufacturer; fall back to color name.
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer); await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
await _unitOfWork.InventoryItems.AddAsync(inventoryItem); var linkedCount = await FinalizeReceivedPowderAsync(
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
jobItem?.Job?.JobNumber);
// Opening stock transaction
var transaction = new InventoryTransaction
{
CompanyId = companyId,
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = unitCost ?? 0,
TotalCost = lbsReceived * (unitCost ?? 0),
TransactionDate = DateTime.UtcNow,
Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}",
BalanceAfter = lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
// Scan for sibling coats with the same custom powder and link them to the new item
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0;
foreach (var other in candidateCoats)
{
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
if (!colorMatch) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
other.InventoryItemId = inventoryItem.Id;
linkedCount++;
}
if (linkedCount > 0)
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount }); return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
} }
@@ -835,40 +785,45 @@ public class DashboardController : Controller
} }
/// <summary> /// <summary>
/// Fills blank spec/document fields on a newly received custom-powder inventory item from the /// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
/// matching platform powder catalog row — cure schedule, coverage, specific gravity, transfer /// (stored as the coat's color code), preferring the same manufacturer, then by color name.
/// efficiency, SDS/TDS links, sample image, color families, product page — so the tenant gets a /// Returns null when no match is found.
/// complete record instead of just the color code/name carried on the quote. Matches by catalog
/// SKU (stored as the coat's color code), preferring the same manufacturer, then by color name.
/// Only fills gaps (never overwrites form-entered values) and links the item to the catalog row.
/// </summary> /// </summary>
private async Task EnrichInventoryFromCatalogAsync( private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
InventoryItem item, string? colorCode, string? colorName, string? manufacturer) string? colorCode, string? colorName, string? manufacturer)
{ {
PowderCatalogItem? catalog = null;
var code = colorCode?.Trim(); var code = colorCode?.Trim();
if (!string.IsNullOrWhiteSpace(code)) if (!string.IsNullOrWhiteSpace(code))
{ {
var codeLower = code.ToLower(); var codeLower = code.ToLower();
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList(); var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
var mfr = manufacturer?.Trim().ToLower(); var mfr = manufacturer?.Trim().ToLower();
catalog = (!string.IsNullOrWhiteSpace(mfr) var match = (!string.IsNullOrWhiteSpace(mfr)
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr)) ? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
: null) : null)
?? hits.FirstOrDefault(); ?? hits.FirstOrDefault();
if (match != null)
return match;
} }
if (catalog == null && !string.IsNullOrWhiteSpace(colorName)) if (!string.IsNullOrWhiteSpace(colorName))
{ {
var nameLower = colorName.Trim().ToLower(); var nameLower = colorName.Trim().ToLower();
catalog = (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower)) return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
.FirstOrDefault(); .FirstOrDefault();
} }
if (catalog == null) return null;
return; }
/// <summary>
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
/// value already set (e.g. entered on the receive form) is preserved.
/// </summary>
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
{
item.PowderCatalogItemId = catalog.Id; item.PowderCatalogItemId = catalog.Id;
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku; if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
@@ -897,6 +852,185 @@ public class DashboardController : Controller
} }
} }
/// <summary>
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
/// </summary>
private async Task EnrichInventoryFromCatalogAsync(
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
{
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
if (catalog != null)
ApplyCatalogToInventory(item, catalog);
}
/// <summary>
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
/// same color. Returns the number of additional coats linked. Used by both the manual modal
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
/// (<see cref="ReceivePowderFromCatalog"/>).
/// </summary>
private async Task<int> FinalizeReceivedPowderAsync(
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
{
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
var transaction = new InventoryTransaction
{
CompanyId = companyId,
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = inventoryItem.UnitCost,
TotalCost = lbsReceived * inventoryItem.UnitCost,
TransactionDate = DateTime.UtcNow,
Notes = $"Initial stock — received from powder order for job {jobNumber}",
BalanceAfter = lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId);
var linkedCount = 0;
foreach (var other in candidateCoats)
{
var colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
if (!colorMatch) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
other.InventoryItemId = inventoryItem.Id;
linkedCount++;
}
if (linkedCount > 0)
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _unitOfWork.CompleteAsync();
return linkedCount;
}
/// <summary>
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
/// adding catalog-sourced powders.
/// </summary>
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
{
var code = category.CategoryCode.Length >= 4
? category.CategoryCode[..4].ToUpperInvariant()
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
var yearMonth = DateTime.Now.ToString("yyMM");
var prefix = $"{code}-{yearMonth}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
return $"{prefix}{(maxSeq + 1):D4}";
}
/// <summary>
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
/// powder is already in the master catalog — the new record is fully populated from the catalog
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
/// fall back to the manual entry modal.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
{
try
{
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
// opens the manual modal.
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
if (catalog == null)
return Json(new { success = false, needsDetails = true });
// Resolve the company's POWDER (coating) inventory category.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, needsDetails = true });
var sku = await GeneratePowderSkuAsync(coatingCategory);
var inventoryItem = new InventoryItem
{
CompanyId = companyId,
SKU = sku,
Name = catalog.ColorName,
ColorName = catalog.ColorName,
ColorCode = coat.ColorCode,
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
QuantityOnHand = lbsReceived,
UnitOfMeasure = "lbs",
UnitCost = catalog.UnitPrice,
LastPurchasePrice = catalog.UnitPrice,
LastPurchaseDate = DateTime.UtcNow,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
ApplyCatalogToInventory(inventoryItem, catalog);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
jobItem?.Job?.JobNumber);
return Json(new
{
success = true,
fromCatalog = true,
itemName = inventoryItem.Name,
sku = inventoryItem.SKU,
linkedCount
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." });
}
}
/// <summary> /// <summary>
/// Platform-level dashboard visible only to SuperAdmins who are not impersonating a tenant. /// Platform-level dashboard visible only to SuperAdmins who are not impersonating a tenant.
/// Displays a cross-company overview: total/active/inactive company counts, user count, /// Displays a cross-company overview: total/active/inactive company counts, user count,
@@ -1041,6 +1041,37 @@
// Custom powder (no inventory item) â†' open modal to add to inventory // Custom powder (no inventory item) â†' open modal to add to inventory
if (!hasInv) { if (!hasInv) {
// If the powder is already in the master catalog, receive it straight to inventory
// with all its specs/docs — no modal. Only fall back to the modal when it isn't.
const tokenAuto = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true; qtyInput.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const autoResp = await fetch('@Url.Action("ReceivePowderFromCatalog", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tokenAuto },
body: `coatId=${coatId}&lbsReceived=${lbs}`
});
const autoData = await autoResp.json();
if (autoData.success) {
fadePlacedRow(row);
showInventoryToast('Added "' + (autoData.itemName || 'powder') + '" to inventory from the catalog.');
return;
}
if (!autoData.needsDetails) {
alert(autoData.message || 'Could not record receipt. Please try again.');
this.disabled = false; qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
return;
}
// Not in catalog — fall through to the manual entry modal.
} catch {
// Network error — fall back to the manual entry modal.
}
this.disabled = false; qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
const modal = document.getElementById('addPowderModal'); const modal = document.getElementById('addPowderModal');
// Pre-fill hidden + text fields // Pre-fill hidden + text fields
modal.querySelector('#apm-coatId').value = coatId; modal.querySelector('#apm-coatId').value = coatId;