Add IsIncoming inventory flag and catalog-to-incoming powder flow in item wizard

- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code
  printing on work orders while the shipment is in transit
- InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory
  record from a PowderCatalogItem and returns it in wizard-compatible shape
- item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback;
  catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs
  to server and selects the new item without a page refresh
- QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side
  incoming-item creation during quote save
- Inventory Create/Edit/Index views: IsIncoming badge and field
- IInventoryAiLookupService: minor interface update
- Migration: AddInventoryIsIncoming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:47:19 -04:00
parent f40d58ac2e
commit fc35fd123c
10 changed files with 10038 additions and 22 deletions
@@ -708,7 +708,6 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
return Json(result);
}
@@ -801,17 +800,15 @@ public class InventoryController : Controller
}
/// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
/// can always return the result even if the TDS fetch does not help.
/// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
/// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
/// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success)
{
@@ -1118,6 +1115,109 @@ public class InventoryController : Controller
return Json(results);
}
/// <summary>
/// Creates a 0-balance inventory item from a PowderCatalogItem record and marks it IsIncoming=true.
/// Called by the item wizard when a staff member needs to quote a powder that has been ordered
/// but not yet received — the inventory record enables QR code printing on the work order.
/// Returns the new item's data in the same shape as the inventoryPowdersData list so the wizard
/// can add it to powderData and select it immediately without a page refresh.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIncomingFromCatalog(int catalogItemId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null)
return Json(new { success = false, error = "Catalog item not found." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
// Generate a unique SKU following the same pattern as GenerateSku: {CODE}-{YYMM}-{####}
var code = coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.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();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var item = new InventoryItem
{
SKU = sku,
Name = ToTitleCase($"{catalogItem.VendorName} {catalogItem.ColorName}"),
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber= catalogItem.Sku,
Finish = catalogItem.Finish,
ColorFamilies = catalogItem.ColorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = catalogItem.CoverageSqFtPerLb ?? 30m,
TransferEfficiency = GetEffectiveTransferEfficiency(catalogItem.TransferEfficiency),
CureTemperatureF = catalogItem.CureTemperatureF,
CureTimeMinutes = catalogItem.CureTimeMinutes,
SpecificGravity = catalogItem.SpecificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = catalogItem.ImageUrl,
SdsUrl = catalogItem.SdsUrl,
TdsUrl = catalogItem.TdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Created incoming inventory item {ItemId} ({ItemName}) from catalog item {CatalogId} for company {CompanyId}",
item.Id, item.Name, catalogItemId, companyId);
return Json(new
{
success = true,
value = item.Id.ToString(),
text = $"[INCOMING] {coatingCategory.DisplayName} - {item.Manufacturer ?? "Generic"} - {item.ColorName ?? item.Name} - {item.ManufacturerPartNumber ?? "N/A"} ({item.UnitCost:C4}/unit)",
coverage = item.CoverageSqFtPerLb ?? 30m,
efficiency = item.TransferEfficiency ?? 65m,
unitOfMeasure= item.UnitOfMeasure,
categoryName = coatingCategory.DisplayName,
costPerLb = item.UnitCost,
colorName = item.ColorName ?? item.Name,
colorCode = "",
isIncoming = true
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create incoming inventory item from catalog {CatalogItemId}", catalogItemId);
return Json(new { success = false, error = "Failed to create inventory item. Please try again." });
}
}
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{
return transferEfficiency ?? DefaultTransferEfficiency;