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:
@@ -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>()
|
||||||
|
|||||||
+11376
File diff suppressed because it is too large
Load Diff
+82
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user