using AutoMapper; using PowderCoating.Shared.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Inventory; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using Microsoft.AspNetCore.Identity; using QRCoder; using System.Drawing; using System.Drawing.Imaging; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public class InventoryController : Controller { private const decimal DefaultTransferEfficiency = 65m; private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly ILogger _logger; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly IInventoryAiLookupService _aiLookupService; private readonly ISubscriptionService _subscriptionService; private readonly UserManager _userManager; private readonly IAccountBalanceService _accountBalanceService; public InventoryController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, ITenantContext tenantContext, IMeasurementConversionService measurementService, IInventoryAiLookupService aiLookupService, ISubscriptionService subscriptionService, UserManager userManager, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _tenantContext = tenantContext; _measurementService = measurementService; _aiLookupService = aiLookupService; _subscriptionService = subscriptionService; _userManager = userManager; _accountBalanceService = accountBalanceService; } /// /// Displays the paginated inventory list with optional keyword search, category filter, /// and a low-stock quick-filter. When lowStockOnly is active the default sort switches /// to QuantityOnHand ascending so the most depleted items surface immediately. Stats /// (total value, active count, low-stock count) are computed directly on the DbSet /// using aggregate SQL to avoid loading all rows into memory. /// public async Task Index( string? searchTerm, string? category, string? sortColumn, string sortDirection = "asc", bool lowStockOnly = false, int pageNumber = 1, int pageSize = 25) { try { // Default sort to QuantityOnHand asc when showing low-stock filter var defaultSort = lowStockOnly ? "QuantityOnHand" : "Name"; var defaultDir = lowStockOnly ? "asc" : sortDirection; // Create and validate grid request var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? defaultSort, SortDirection = sortColumn == null ? defaultDir : sortDirection, SearchTerm = searchTerm }; gridRequest.Validate(); // Build search and category filter System.Linq.Expressions.Expression>? filter = null; if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm)) { var search = searchTerm.ToLower(); filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint && (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search) || (i.ColorName != null && i.ColorName.ToLower().Contains(search)) || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search))); } else if (lowStockOnly) { filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint; } else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category)) { // Both search and category filter var search = searchTerm.ToLower(); var cat = category; filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search) || (i.Description != null && i.Description.ToLower().Contains(search)) || (i.ColorName != null && i.ColorName.ToLower().Contains(search)) || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search))) && i.Category.ToLower() == cat.ToLower(); } else if (!string.IsNullOrWhiteSpace(searchTerm)) { // Search only var search = searchTerm.ToLower(); filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search) || (i.Description != null && i.Description.ToLower().Contains(search)) || (i.ColorName != null && i.ColorName.ToLower().Contains(search)) || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)); } else if (!string.IsNullOrWhiteSpace(category)) { // Category filter only var cat = category; filter = i => i.Category.ToLower() == cat.ToLower(); } // Build orderBy function Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { "SKU" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.SKU) : q.OrderByDescending(i => i.SKU), "Name" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Name) : q.OrderByDescending(i => i.Name), "Category" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Category) : q.OrderByDescending(i => i.Category), "ColorName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.ColorName) : q.OrderByDescending(i => i.ColorName), "QuantityOnHand" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.QuantityOnHand) : q.OrderByDescending(i => i.QuantityOnHand), "UnitCost" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.UnitCost) : q.OrderByDescending(i => i.UnitCost), "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.IsActive) : q.OrderByDescending(i => i.IsActive), _ => q => q.OrderBy(i => i.Name) }; // Get paged data with PrimaryVendor and InventoryCategory eager loading var (items, totalCount) = await _unitOfWork.InventoryItems.GetPagedAsync( gridRequest.PageNumber, gridRequest.PageSize, filter, orderBy, i => i.PrimaryVendor, i => i.InventoryCategory); // Map to DTOs using AutoMapper var itemDtos = _mapper.Map>(items); var pagedResult = PagedResult.From(gridRequest, itemDtos, totalCount); // Load all items once to compute sidebar stats and category list in memory var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList(); ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m; // Set ViewBag for sorting and filters ViewBag.SearchTerm = searchTerm; ViewBag.Category = category; ViewBag.LowStockOnly = lowStockOnly; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving inventory items"); TempData["Error"] = "An error occurred while loading inventory items."; return View(new PagedResult()); } } /// /// Renders the inventory item detail page. The primary vendor name is looked up /// separately because the repository does not eager-load Vendor by default, avoiding /// the join on list queries where the vendor name is not displayed. The measurement /// unit label (sqft vs m²) is resolved from the company's metric preference so the /// coverage field is always displayed in the unit the shop recognizes. /// public async Task Details(int? id) { if (id == null) { return NotFound(); } try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value, false, i => i.InventoryCategory); if (item == null) { return NotFound(); } var itemDto = _mapper.Map(item); // Get vendor name if exists if (item.PrimaryVendorId.HasValue) { var vendor = await _unitOfWork.Vendors.GetByIdAsync(item.PrimaryVendorId.Value); itemDto.PrimaryVendorName = vendor?.CompanyName; } // Derive IsCoating from the category (not stored on the item) ViewBag.IsCoating = item.InventoryCategory?.IsCoating ?? false; // Set measurement units for view var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); return View(itemDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving inventory item {ItemId}", id); TempData["Error"] = "An error occurred while loading the inventory item."; return RedirectToAction(nameof(Index)); } } /// /// Renders the inventory item creation form. Calls to /// load vendor, category, unit-of-measure, and chart-of-accounts options, and exposes /// the company's metric preference so the view can label coverage fields correctly. /// public async Task Create() { await PopulateDropdowns(); // Set measurement unit labels based on company preference var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); return View(new CreateInventoryItemDto { CoverageSqFtPerLb = 30, TransferEfficiency = 65 }); } /// /// Persists a new inventory item. The item name is normalized to title-case on save /// so the list view is consistently formatted regardless of how staff typed it. The /// legacy string Category field is populated from the selected InventoryCategoryLookup /// to keep older code paths that read Category by string working without migration. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateInventoryItemDto dto) { if (!ModelState.IsValid) { await PopulateDropdowns(); return View(dto); } try { var item = _mapper.Map(dto); item.CreatedAt = DateTime.UtcNow; item.IsActive = true; item.Name = ToTitleCase(item.Name); // Populate legacy Category field from lookup table if (item.InventoryCategoryId.HasValue) { var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value); if (category != null) item.Category = category.DisplayName; } await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.SaveChangesAsync(); // Record opening stock as an Initial transaction if (item.QuantityOnHand > 0) { var txn = new InventoryTransaction { InventoryItemId = item.Id, TransactionType = InventoryTransactionType.Initial, Quantity = item.QuantityOnHand, UnitCost = item.UnitCost, TotalCost = item.QuantityOnHand * item.UnitCost, TransactionDate = DateTime.UtcNow, BalanceAfter = item.QuantityOnHand, Notes = "Opening stock on item creation" }; await _unitOfWork.InventoryTransactions.AddAsync(txn); await _unitOfWork.SaveChangesAsync(); } // Contribute/sync to the platform powder catalog if we have enough identity data. // Runs silently — a failure here never blocks the inventory save. if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber)) { var catalogResult = new InventoryAiLookupResult { Manufacturer = dto.Manufacturer, ManufacturerPartNumber = dto.ManufacturerPartNumber, ColorName = dto.ColorName ?? item.Name, Finish = dto.Finish, CureTemperatureF = dto.CureTemperatureF, CureTimeMinutes = dto.CureTimeMinutes, ColorFamilies = dto.ColorFamilies, RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null, CoverageSqFtPerLb = dto.CoverageSqFtPerLb, SpecificGravity = dto.SpecificGravity, TransferEfficiency = dto.TransferEfficiency, UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null, SpecPageUrl = dto.SpecPageUrl, ImageUrl = dto.ImageUrl, SdsUrl = dto.SdsUrl, TdsUrl = dto.TdsUrl, }; await EnrichFromCatalogAsync(catalogResult, autoContribute: true); } TempData["Success"] = "Inventory item created successfully."; return RedirectToAction(nameof(Details), new { id = item.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating inventory item"); TempData["Error"] = "An error occurred while creating the inventory item."; await PopulateDropdowns(); return View(dto); } } /// /// Renders the inventory item edit form, pre-populated via AutoMapper. Dropdowns and /// measurement unit labels are reloaded the same way as so the /// form is always consistent regardless of any lookup-table changes since the item /// was created. /// public async Task Edit(int? id) { if (id == null) { return NotFound(); } try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); if (item == null) { return NotFound(); } var dto = _mapper.Map(item); dto.CoverageSqFtPerLb ??= 30; dto.TransferEfficiency ??= 65; await PopulateDropdowns(); // Set measurement unit labels based on company preference var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving inventory item {ItemId} for edit", id); TempData["Error"] = "An error occurred while loading the inventory item."; return RedirectToAction(nameof(Index)); } } /// /// Persists edits to an existing inventory item. Mirrors the Create POST behavior for /// name normalization and legacy Category field population. If the user clears the /// category selection, Category is set to an empty string rather than left stale, so /// category-based filters in work correctly. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdateInventoryItemDto dto) { if (id != dto.Id) { return NotFound(); } if (!ModelState.IsValid) { await PopulateDropdowns(); return View(dto); } try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id); if (item == null) { return NotFound(); } var previousQty = item.QuantityOnHand; _mapper.Map(dto, item); item.UpdatedAt = DateTime.UtcNow; item.Name = ToTitleCase(item.Name); // Populate legacy Category field from lookup table if (item.InventoryCategoryId.HasValue) { var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value); if (category != null) item.Category = category.DisplayName; else item.Category = string.Empty; } else { item.Category = string.Empty; } await _unitOfWork.InventoryItems.UpdateAsync(item); await _unitOfWork.SaveChangesAsync(); // Record an Adjustment transaction when quantity on hand changes var qtyDelta = item.QuantityOnHand - previousQty; if (qtyDelta != 0) { var txn = new InventoryTransaction { InventoryItemId = item.Id, TransactionType = InventoryTransactionType.Adjustment, Quantity = qtyDelta, UnitCost = item.UnitCost, TotalCost = Math.Abs(qtyDelta) * item.UnitCost, TransactionDate = DateTime.UtcNow, BalanceAfter = item.QuantityOnHand, Notes = "Manual quantity adjustment via edit" }; await _unitOfWork.InventoryTransactions.AddAsync(txn); await _unitOfWork.SaveChangesAsync(); } TempData["Success"] = "Inventory item updated successfully."; return RedirectToAction(nameof(Details), new { id = item.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating inventory item {ItemId}", id); TempData["Error"] = "An error occurred while updating the inventory item."; await PopulateDropdowns(); return View(dto); } } /// /// Renders the delete confirmation page for an inventory item, showing the item /// summary so staff can confirm before permanently removing access to the record. /// public async Task Delete(int? id) { if (id == null) { return NotFound(); } try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); if (item == null) { return NotFound(); } var itemDto = _mapper.Map(item); return View(itemDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving inventory item {ItemId} for delete", id); TempData["Error"] = "An error occurred while loading the inventory item."; return RedirectToAction(nameof(Index)); } } /// /// Soft-deletes the inventory item, preserving its transaction history and any job /// references. Because powder items are linked to JobItem coats and invoice line items, /// a hard delete would break historical cost reporting; soft delete keeps the data /// intact and invisible to normal queries. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task DeleteConfirmed(int id) { try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id); if (item == null) { return NotFound(); } await _unitOfWork.InventoryItems.SoftDeleteAsync(item); await _unitOfWork.SaveChangesAsync(); TempData["Success"] = $"Inventory item {item.Name} deleted successfully."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting inventory item {ItemId}", id); TempData["Error"] = "An error occurred while deleting the inventory item."; return RedirectToAction(nameof(Index)); } } /// /// Returns a partial view listing jobs that use the specified inventory item in any /// coat. This is rendered as an AJAX partial inside the Details view so the main page /// loads quickly and the job list is fetched on demand. Capped at 500 jobs to avoid /// unbounded result sets on heavily-used powder colors. /// [HttpGet] public async Task JobsUsing(int id) { try { var (jobs, _) = await _unitOfWork.Jobs.GetPagedAsync( pageNumber: 1, pageSize: 500, filter: j => j.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)), orderBy: q => q.OrderByDescending(j => j.CreatedAt), j => j.Customer, j => j.Photos, j => j.JobStatus); return PartialView("_JobsUsingPowder", jobs.ToList()); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving jobs using inventory item {ItemId}", id); return PartialView("_JobsUsingPowder", new List()); } } /// /// Returns a paginated JSON list of job photos whose tag set contains the inventory /// item's color name or product name. Two-pass matching is used: the first pass /// queries the DB with a LIKE to narrow candidates, then an in-memory exact-token /// comparison rejects false positives (e.g. "Black" matching "Gloss Black"). This /// avoids tagging a photo with the wrong powder when similar names overlap. /// [HttpGet] public async Task TaggedPhotos(int id, int page = 1, int pageSize = 12) { try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id); if (item == null) return Json(new { success = false }); // Build SQL-level pre-filter using the item's identifiers var colorName = item.ColorName?.Trim().ToLower(); var name = item.Name?.Trim().ToLower(); if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name)) return Json(new { success = true, photos = Array.Empty(), totalCount = 0, page, pageSize }); // Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss") var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name); var searchTerms = new[] { colorName, name } .Where(s => !string.IsNullOrEmpty(s)) .Distinct() .ToArray(); var matched = allMatches.Where(p => p.Tags!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(t => searchTerms.Any(term => t.Equals(term, StringComparison.OrdinalIgnoreCase)))) .ToList(); var totalCount = matched.Count; var photos = matched .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new { id = p.Id, jobId = p.JobId, jobNumber = p.Job?.JobNumber ?? "", customerName = p.Job?.Customer?.CompanyName ?? "", caption = p.Caption ?? "", tags = p.Tags ?? "", uploadedDate = p.UploadedDate.ToString("MMM dd, yyyy") }) .ToList(); return Json(new { success = true, photos, totalCount, page, pageSize }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving tagged photos for inventory item {ItemId}", id); return Json(new { success = false }); } } /// /// Returns a paginated JSON list of job photos from jobs that use this inventory item /// in any coat — matched via the direct InventoryItemId FK on JobItemCoat. This is /// more reliable than TaggedPhotos which relies on photo tags being manually set. /// [HttpGet] public async Task PhotosByPowder(int id, int page = 1, int pageSize = 20) { try { var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id); var totalCount = photos.Count; var paged = photos .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new { id = p.Id, jobId = p.JobId, jobNumber = p.Job?.JobNumber ?? "", customerName = p.Job?.Customer?.CompanyName ?? p.Job?.Customer?.ContactFirstName ?? "", caption = p.Caption ?? "", uploadedDate = p.UploadedDate.ToString("MMM dd, yyyy") }) .ToList(); return Json(new { success = true, photos = paged, totalCount, page, pageSize }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving photos by powder for inventory item {ItemId}", id); return Json(new { success = false }); } } /// /// Generates the next available SKU for a given category in the format /// CODE-YYMM-#### (e.g. POWD-2604-0003). The category code is truncated or /// padded to exactly 4 characters. IgnoreQueryFilters is used when scanning /// existing SKUs so that deleted items do not leave gaps that could cause number /// reuse — a reused SKU on a purchase order would create accounting confusion. /// [HttpGet] public async Task GenerateSku(int categoryId) { var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId); if (category == null) return Json(new { sku = "" }); // Build prefix from category code: first 4 chars, uppercase, e.g. "POWD", "PRIM", "MASK" 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 Json(new { sku = $"{prefix}{(maxSeq + 1):D4}" }); } /// /// Invokes the AI Inventory Assist feature to look up powder coverage, chemistry, and /// pricing data for a product based on manufacturer/color/part-number hints. The /// feature is gated behind a subscription flag so only plans that include AI Inventory /// Assist can call the underlying Anthropic API, preventing unexpected billing on /// lower-tier plans. /// [HttpPost] public async Task AiLookup( [FromForm] string? manufacturer, [FromForm] string? colorName, [FromForm] string? colorCode, [FromForm] string? partNumber) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId)) return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." }); var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber); if (result.Success) { await EnrichFromCatalogAsync(result, autoContribute: true); await ApplyTdsCureFallbackAsync(result, colorName); } return Json(result); } /// /// Augments a catalog fill with cure specs, color families, and finish by fetching the /// product's known URL and running it through Claude. Skips Serper — the URL is already /// known from the catalog record so no search step is needed. Gated behind the same /// AI Inventory Assist subscription flag as AiLookup. /// [HttpPost] public async Task AiAugmentFromUrl( [FromForm] string? productUrl, [FromForm] string? colorName) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId)) return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." }); if (string.IsNullOrWhiteSpace(productUrl)) return Json(new { success = false, errorMessage = "No product URL provided." }); var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName); if (result.Success) await EnrichFromCatalogAsync(result, autoContribute: true); return Json(result); } /// /// Looks up in the platform powder catalog by SKU + manufacturer. /// If a match is found, catalog values overwrite Claude-inferred ones for spec fields /// (catalog is the authoritative source) and fill gaps for URL/price fields. /// If no match and is true, inserts a new catalog entry /// so future lookups resolve instantly without an API call. /// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges. /// Mutates in place. /// private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync( InventoryAiLookupResult result, bool autoContribute) { var sku = result.ManufacturerPartNumber?.Trim(); var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim(); var colorName = result.ColorName?.Trim(); PowderCatalogItem? match = null; if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer)) { var skuLower = sku.ToLower(); var mfrLower = manufacturer.ToLower(); var hits = await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower)); match = hits.FirstOrDefault(); } if (match != null) { // Catalog is authoritative for spec fields — overwrite AI inference if (match.Finish != null) result.Finish = match.Finish; if (match.CureTemperatureF != null) result.CureTemperatureF = match.CureTemperatureF; if (match.CureTimeMinutes != null) result.CureTimeMinutes = match.CureTimeMinutes; if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies; if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat; if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb; if (match.SpecificGravity != null) result.SpecificGravity = match.SpecificGravity; result.TransferEfficiency ??= GetEffectiveTransferEfficiency(match.TransferEfficiency); // URL / price fields: fill gaps only — AI may have found something better result.ImageUrl ??= match.ImageUrl; result.SpecPageUrl ??= match.ProductUrl; result.SdsUrl ??= match.SdsUrl; result.TdsUrl ??= match.TdsUrl; if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice; // Back-sync: fill NULL catalog fields from the incoming result so the catalog // gets richer over time without overwriting anything already stored. bool catalogDirty = false; if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; } if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; } if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; } if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; } if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; } if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; } if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; } if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; } if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; } if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; } if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; } if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; } if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; } if (catalogDirty) { match.UpdatedAt = DateTime.UtcNow; try { await _unitOfWork.PowderCatalog.UpdateAsync(match); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id); } } return (true, false); } if (!autoContribute || string.IsNullOrEmpty(sku) || string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(colorName)) return (false, false); // Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly try { var newItem = new PowderCatalogItem { VendorName = manufacturer, Sku = sku, ColorName = colorName, UnitPrice = result.UnitCostPerLb ?? 0m, CureTemperatureF = result.CureTemperatureF, CureTimeMinutes = result.CureTimeMinutes, Finish = result.Finish, ColorFamilies = result.ColorFamilies, RequiresClearCoat = result.RequiresClearCoat, CoverageSqFtPerLb = result.CoverageSqFtPerLb, SpecificGravity = result.SpecificGravity, TransferEfficiency = GetEffectiveTransferEfficiency(result.TransferEfficiency), ImageUrl = result.ImageUrl, ProductUrl = result.SpecPageUrl, SdsUrl = result.SdsUrl, TdsUrl = result.TdsUrl, IsUserContributed = true, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.PowderCatalog.AddAsync(newItem); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku); return (false, true); } catch (Exception ex) { // Unique constraint violation means another request beat us — not an error _logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message); return (false, false); } } /// /// 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. /// private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName) { if ((result.CureTemperatureF == null || result.CureTimeMinutes == null) && !string.IsNullOrEmpty(result.TdsUrl)) { var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName); if (tds.Success) { if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF; if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes; } } } /// /// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner, /// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform /// catalog, and — when the product is not yet in the catalog and enough data was extracted /// — inserts it automatically as a user-contributed entry so future scans resolve instantly. /// [HttpPost] public async Task ScanLabel( [FromForm] string? imageBase64, [FromForm] string? mediaType, [FromForm] string? qrUrl) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId)) return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." }); InventoryAiLookupResult aiResult; if (!string.IsNullOrWhiteSpace(qrUrl)) { // QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null); if (aiResult.Success && aiResult.SpecPageUrl == null) aiResult.SpecPageUrl = qrUrl; } else if (!string.IsNullOrWhiteSpace(imageBase64)) { // Vision path: Claude reads what's printed on the label (limited to visible text) aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg"); // Follow-up web lookup so we get SDS/TDS URLs, product page, image, description, // and any specs not printed on the label. Label values are kept as-is (authoritative); // the full lookup only fills fields that are still null. if (aiResult.Success) { var mfr = aiResult.Manufacturer ?? aiResult.VendorName; if (!string.IsNullOrWhiteSpace(mfr) && (!string.IsNullOrWhiteSpace(aiResult.ColorName) || !string.IsNullOrWhiteSpace(aiResult.ManufacturerPartNumber))) { var full = await _aiLookupService.LookupAsync( mfr, aiResult.ColorName, aiResult.ColorCode, aiResult.ManufacturerPartNumber); if (full.Success) { aiResult.Description ??= full.Description; aiResult.SdsUrl ??= full.SdsUrl; aiResult.TdsUrl ??= full.TdsUrl; aiResult.ImageUrl ??= full.ImageUrl; aiResult.SpecPageUrl ??= full.SpecPageUrl; aiResult.UnitCostPerLb ??= full.UnitCostPerLb; aiResult.VendorName ??= full.VendorName; aiResult.ColorFamilies ??= full.ColorFamilies; aiResult.Finish ??= full.Finish; aiResult.CureTemperatureF ??= full.CureTemperatureF; aiResult.CureTimeMinutes ??= full.CureTimeMinutes; aiResult.RequiresClearCoat ??= full.RequiresClearCoat; aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb; aiResult.SpecificGravity ??= full.SpecificGravity; aiResult.TransferEfficiency ??= GetEffectiveTransferEfficiency(full.TransferEfficiency); aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber; aiResult.ColorName ??= full.ColorName; aiResult.ColorCode ??= full.ColorCode; } } } } else { return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." }); } if (!aiResult.Success) return Json(new { success = false, errorMessage = aiResult.ErrorMessage }); var sku = aiResult.ManufacturerPartNumber?.Trim(); var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim(); var colorName = aiResult.ColorName?.Trim(); // Catalog lookup, merge, and auto-contribute — same logic as AiLookup button var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true); // TDS cure fallback — same logic as AiLookup button await ApplyTdsCureFallbackAsync(aiResult, colorName); // Check if this product already exists in the tenant's inventory. // Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer. // Returns the first active match so the UI can prompt to add stock inline. int? existingInventoryId = null; string? existingInventoryName = null; decimal? existingQuantityOnHand = null; string? existingUnitOfMeasure = null; InventoryItem? existingHit = null; if (!string.IsNullOrEmpty(sku)) { var skuLower = sku.ToLower(); var byPart = await _unitOfWork.InventoryItems.FindAsync(i => i.ManufacturerPartNumber != null && i.ManufacturerPartNumber.ToLower() == skuLower); existingHit = byPart.FirstOrDefault(); } if (existingHit == null && !string.IsNullOrEmpty(colorName)) { var nameLower = colorName.ToLower(); var mfrLower = manufacturer?.ToLower() ?? ""; var byName = await _unitOfWork.InventoryItems.FindAsync(i => (i.ColorName != null && i.ColorName.ToLower() == nameLower) || i.Name.ToLower() == nameLower); existingHit = byName.FirstOrDefault(i => string.IsNullOrEmpty(mfrLower) || (i.Manufacturer ?? "").ToLower().Contains(mfrLower) || mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim())); } if (existingHit != null) { existingInventoryId = existingHit.Id; existingInventoryName = existingHit.Name; existingQuantityOnHand = existingHit.QuantityOnHand; existingUnitOfMeasure = existingHit.UnitOfMeasure; } return Json(new { success = true, manufacturer = manufacturer, manufacturerPartNumber = sku, colorName = colorName, description = aiResult.Description, finish = aiResult.Finish, cureTemperatureF = aiResult.CureTemperatureF, cureTimeMinutes = aiResult.CureTimeMinutes, colorFamilies = aiResult.ColorFamilies, requiresClearCoat = aiResult.RequiresClearCoat, coverageSqFtPerLb = aiResult.CoverageSqFtPerLb, specificGravity = aiResult.SpecificGravity, transferEfficiency = aiResult.TransferEfficiency ?? DefaultTransferEfficiency, unitPrice = aiResult.UnitCostPerLb ?? 0m, imageUrl = aiResult.ImageUrl, productUrl = aiResult.SpecPageUrl, sdsUrl = aiResult.SdsUrl, tdsUrl = aiResult.TdsUrl, vendorName = manufacturer, wasInCatalog = wasInCatalog, addedToCatalog = addedToCatalog, existingInventoryId = existingInventoryId, existingInventoryName = existingInventoryName, existingQuantityOnHand = existingQuantityOnHand, existingUnitOfMeasure = existingUnitOfMeasure, reasoning = aiResult.Reasoning, }); } /// /// Adds stock to an existing inventory item from the label scanner inline prompt. /// Creates a Purchase transaction and updates QuantityOnHand without navigating away. /// [HttpPost] public async Task AddStock(int inventoryItemId, decimal quantity, decimal? unitCost, string? notes) { try { if (quantity <= 0) return Json(new { success = false, errorMessage = "Quantity must be greater than zero." }); var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId); if (item == null) return Json(new { success = false, errorMessage = "Item not found." }); var cost = (unitCost.HasValue && unitCost.Value > 0) ? unitCost.Value : item.UnitCost; item.QuantityOnHand += quantity; item.LastPurchaseDate = DateTime.UtcNow; if (unitCost.HasValue && unitCost.Value > 0) { item.LastPurchasePrice = unitCost.Value; item.UnitCost = unitCost.Value; } item.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(item); var txn = new InventoryTransaction { InventoryItemId = item.Id, TransactionType = InventoryTransactionType.Purchase, Quantity = quantity, UnitCost = cost, TotalCost = quantity * cost, TransactionDate = DateTime.UtcNow, BalanceAfter = item.QuantityOnHand, Notes = !string.IsNullOrWhiteSpace(notes) ? notes.Trim() : "Added via label scan", }; await _unitOfWork.InventoryTransactions.AddAsync(txn); await _unitOfWork.SaveChangesAsync(); _logger.LogInformation("Label scan added {Qty} {UOM} to inventory item {Id} ({Name})", quantity, item.UnitOfMeasure, item.Id, item.Name); return Json(new { success = true, newQuantityOnHand = item.QuantityOnHand, unitOfMeasure = item.UnitOfMeasure, itemName = item.Name, }); } catch (Exception ex) { _logger.LogError(ex, "Error adding stock via label scan to inventory item {ItemId}", inventoryItemId); return Json(new { success = false, errorMessage = "An error occurred. Please try again." }); } } /// /// Searches the platform-level PowderCatalogItems table by SKU or color name. /// Excludes catalog entries already present in the company's inventory (by ManufacturerPartNumber). /// Pass currentId when editing an existing item so its own catalog entry is not filtered out. /// Called by the inventory Create/Edit form before falling back to AI Lookup. /// [HttpGet] public async Task CatalogLookup(string? q, string? vendor, int? currentId = null) { if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Json(Array.Empty()); var term = q.Trim().ToLower(); var vendorTerm = vendor?.Trim().ToLower(); // Build a set of SKUs already in this company's inventory so we can exclude them. // When editing, the current item's own SKU is re-included so its catalog entry still appears. var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId); var existingSkus = existingItems .Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0)) .Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) .ToHashSet(); // Single query — all partial color/SKU matches across all vendors. // Results are ranked: exact vendor + exact color (isExact=true) sorts first and // triggers auto-fill in the JS. Everything else goes to the picker modal. // This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill // only when that exact product is in the catalog; otherwise they see a ranked modal // with same-vendor results at the top and a "Not Listed — Search Online" escape hatch. var matches = await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower().Contains(term) || p.Sku.ToLower() == term || p.Sku.ToLower().Contains(term)); var results = matches .Where(p => !existingSkus.Contains(p.Sku.ToLower())) .Select(p => { var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm); var colorExact = p.ColorName.ToLower() == term; return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact); }) .OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3) .ThenBy(x => x.p.ColorName) .Select(x => new { id = x.p.Id, vendorName = x.p.VendorName, sku = x.p.Sku, colorName = x.p.ColorName, description = x.p.Description, unitPrice = x.p.UnitPrice, imageUrl = x.p.ImageUrl, sdsUrl = x.p.SdsUrl, tdsUrl = x.p.TdsUrl, applicationGuideUrl = x.p.ApplicationGuideUrl, productUrl = x.p.ProductUrl, isDiscontinued = x.p.IsDiscontinued, isExact = x.isExact, cureTemperatureF = x.p.CureTemperatureF, cureTimeMinutes = x.p.CureTimeMinutes, finish = x.p.Finish, colorFamilies = x.p.ColorFamilies, requiresClearCoat = x.p.RequiresClearCoat, coverageSqFtPerLb = x.p.CoverageSqFtPerLb, specificGravity = x.p.SpecificGravity, transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency) }) .ToList(); return Json(results); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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.FindAsync(c => c.CompanyId == companyId); 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; } /// /// Normalizes a string to title-case using the current culture's TextInfo. Applied to /// inventory item names on create and edit so the list view is consistently formatted /// regardless of how staff typed the entry (e.g. "gloss black" → "Gloss Black"). /// private static string ToTitleCase(string? value) { if (string.IsNullOrWhiteSpace(value)) return value ?? string.Empty; return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(value.Trim().ToLower()); } /// /// Shows all IsCoating inventory items with two tabs: "On Hand" (HasSamplePanel=true) /// and "Need to Order" (HasSamplePanel=false). Optionally filtered by manufacturer. /// Only coating items are included regardless of active/inactive status, so discontinued /// colors that are still on the wall remain visible. /// public async Task SamplePanels(string? manufacturer, string? tab) { try { var allCoatings = (await _unitOfWork.InventoryItems.FindAsync( i => i.InventoryCategory != null && i.InventoryCategory.IsCoating, false, i => i.InventoryCategory)) .OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name) .ToList(); // Distinct manufacturer list for filter dropdown ViewBag.Manufacturers = allCoatings .Where(i => !string.IsNullOrWhiteSpace(i.Manufacturer)) .Select(i => i.Manufacturer!) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(m => m) .ToList(); var filtered = string.IsNullOrWhiteSpace(manufacturer) ? allCoatings : allCoatings.Where(i => string.Equals(i.Manufacturer, manufacturer, StringComparison.OrdinalIgnoreCase)).ToList(); ViewBag.SelectedManufacturer = manufacturer; ViewBag.ActiveTab = tab ?? "need"; ViewBag.OnHandItems = filtered.Where(i => i.HasSamplePanel).ToList(); ViewBag.NeedToOrderItems = filtered.Where(i => !i.HasSamplePanel).ToList(); ViewBag.TotalCoatings = allCoatings.Count; ViewBag.TotalOnHand = allCoatings.Count(i => i.HasSamplePanel); ViewBag.TotalNeedOrder = allCoatings.Count(i => !i.HasSamplePanel); return View(); } catch (Exception ex) { _logger.LogError(ex, "Error loading sample panels view"); TempData["Error"] = "An error occurred while loading sample panels."; return RedirectToAction(nameof(Index)); } } /// /// AJAX POST that toggles HasSamplePanel on a coating inventory item. /// Returns JSON so the SamplePanels view can update the UI without a full reload. /// Only valid for IsCoating items; returns 400 for non-coating items to prevent misuse. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ToggleSamplePanel(int id, bool hasPanel) { try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id, false, i => i.InventoryCategory); if (item == null) return Json(new { success = false, message = "Item not found." }); if (item.InventoryCategory?.IsCoating != true) return Json(new { success = false, message = "Not a coating item." }); item.HasSamplePanel = hasPanel; item.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(item); await _unitOfWork.SaveChangesAsync(); return Json(new { success = true, hasPanel = item.HasSamplePanel }); } catch (Exception ex) { _logger.LogError(ex, "Error toggling sample panel for item {ItemId}", id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Populates all ViewBag dropdowns needed by the Create and Edit forms: vendors, /// category lookup (with a JSON map of categoryId → isCoating so JS can show/hide /// coating-specific fields), standard units of measure, and the chart-of-accounts /// selects for inventory asset and COGS accounts. Also sets AiInventoryAssistEnabled /// so the view can conditionally render the AI lookup button. /// private async Task PopulateDropdowns() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName"); // Load categories from lookup table var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var categories = allCategories .Where(c => c.IsActive) .OrderBy(c => c.DisplayOrder) .ToList(); ViewBag.Categories = new SelectList(categories, "Id", "DisplayName"); // JSON map of categoryId → isCoating (used by JS to show/hide coating fields) ViewBag.CategoryIsCoatingJson = System.Text.Json.JsonSerializer.Serialize( categories.ToDictionary(c => c.Id.ToString(), c => c.IsCoating)); ViewBag.UnitsOfMeasure = new List { new SelectListItem { Value = "lbs", Text = "Pounds (lbs)" }, new SelectListItem { Value = "kg", Text = "Kilograms (kg)" }, new SelectListItem { Value = "oz", Text = "Ounces (oz)" }, new SelectListItem { Value = "gallons", Text = "Gallons" }, new SelectListItem { Value = "liters", Text = "Liters" }, new SelectListItem { Value = "units", Text = "Units" }, new SelectListItem { Value = "boxes", Text = "Boxes" }, new SelectListItem { Value = "rolls", Text = "Rolls" } }; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); ViewBag.InventoryAccounts = accounts .Where(a => a.AccountType == AccountType.Asset && a.AccountSubType == AccountSubType.Inventory) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); ViewBag.CogsAccounts = accounts .Where(a => a.AccountType == AccountType.CostOfGoods) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); } /// /// One-time SuperAdmin repair action that links inventory items which have a Category /// string but no InventoryCategoryId — typically items seeded or imported via CSV /// before the lookup-table migration was applied. Matches by display name first, then /// falls back to a built-in alias map (e.g. "Powder Coatings" → "POWDER"). Uses /// ignoreQueryFilters throughout so items from all companies are repaired in a single /// pass. Safe to run multiple times; already-linked items are simply skipped. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task RepairCategories() { var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Powder Coatings", "POWDER" }, { "Powder Coating", "POWDER" }, { "Powders", "POWDER" }, { "Primers", "PRIMER" }, { "Cleaners", "CLEANER" }, { "Masking", "MASKING" }, { "Masking Tape", "MASKING" }, { "Abrasive", "ABRASIVE" }, { "Abrasives", "ABRASIVE" }, { "Blast Media", "ABRASIVE" }, { "Chemicals", "CHEMICAL" }, { "Consumable", "CONSUMABLE" }, { "Tools & Equipment", "TOOL" }, { "Equipment", "TOOL" }, { "General", "OTHER" }, }; // Load all categories grouped by company so we match the right company's records var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(ignoreQueryFilters: true); var categoriesByCompany = allCategories .Where(c => !c.IsDeleted) .GroupBy(c => c.CompanyId) .ToDictionary(g => g.Key, g => g.ToList()); // Get items with no category link (ignore tenant filter to fix all companies) var unlinked = (await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true)) .Where(i => !i.IsDeleted && i.InventoryCategoryId == null) .ToList(); int repaired = 0; foreach (var item in unlinked) { if (string.IsNullOrWhiteSpace(item.Category)) continue; if (!categoriesByCompany.TryGetValue(item.CompanyId, out var companyCats)) continue; var byName = companyCats.ToDictionary(c => c.DisplayName, c => c, StringComparer.OrdinalIgnoreCase); var byCode = companyCats.ToDictionary(c => c.CategoryCode, c => c, StringComparer.OrdinalIgnoreCase); InventoryCategoryLookup? cat = null; byName.TryGetValue(item.Category, out cat); if (cat == null && aliases.TryGetValue(item.Category, out var code)) byCode.TryGetValue(code, out cat); if (cat != null) { item.InventoryCategoryId = cat.Id; item.Category = cat.DisplayName; item.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(item); repaired++; } } await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Category repair complete: {repaired} of {unlinked.Count} unlinked items updated."; return RedirectToAction(nameof(Index)); } /// /// Returns a QR code PNG image encoding the scan URL for the given inventory item. /// Used by the label print page and the item Details page. /// [HttpGet] [AllowAnonymous] public IActionResult QrCode(int id, int size = 10) { var scanUrl = Url.Action("Scan", "Inventory", new { id }, Request.Scheme)!; using var qrGenerator = new QRCodeGenerator(); var qrData = qrGenerator.CreateQrCode(scanUrl, QRCodeGenerator.ECCLevel.M); using var qrCode = new PngByteQRCode(qrData); var pngBytes = qrCode.GetGraphic(size); return File(pngBytes, "image/png"); } /// /// Renders a print-optimised label for the inventory item containing the QR code, /// item name, SKU, and colour. Designed to be printed directly from the browser. /// public async Task Label(int? id) { if (id == null) return NotFound(); var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); if (item == null) return NotFound(); return View(_mapper.Map(item)); } /// /// Mobile-friendly scan landing page. Shows item details, a job picker pre-filtered /// to the current user's active jobs, and a usage entry form. /// [HttpGet] public async Task Scan(int? id, int? jobId) { if (id == null) return NotFound(); var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); if (item == null) return NotFound(); var userId = _userManager.GetUserId(User); var myJobs = (await _unitOfWork.Jobs.FindAsync( j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId, false, j => j.Customer, j => j.JobStatus)) .OrderBy(j => j.JobNumber) .Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" }) .ToList(); var myJobIds = myJobs.Select(j => j.Id).ToHashSet(); var otherJobs = (await _unitOfWork.Jobs.FindAsync( j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id), false, j => j.Customer, j => j.JobStatus)) .OrderByDescending(j => j.CreatedAt) .Take(100) .Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" }) .ToList(); ViewBag.ItemDto = _mapper.Map(item); ViewBag.MyJobs = myJobs; ViewBag.OtherJobs = otherJobs; ViewBag.PreselectedJobId = jobId; var scanError = TempData["ScanError"] as string; if (scanError != null) ViewBag.ScanError = scanError; return View(); } /// /// Records powder usage logged via the mobile scan page. Creates a JobUsage /// InventoryTransaction (and PowderUsageLog) when a job is selected, or an /// Adjustment transaction when logging without a job. Updates QuantityOnHand. /// [HttpPost] [ValidateAntiForgeryToken] public async Task LogUsage(int inventoryItemId, int? jobId, decimal quantity, string transactionType, string? notes) { try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId); if (item == null) return NotFound(); if (quantity <= 0) { TempData["ScanError"] = "Quantity must be greater than zero."; return RedirectToAction(nameof(Scan), new { id = inventoryItemId }); } var userId = _userManager.GetUserId(User) ?? string.Empty; // Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only var txnType = InventoryTransactionType.JobUsage; item.QuantityOnHand -= quantity; item.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(item); var txn = new InventoryTransaction { InventoryItemId = item.Id, TransactionType = txnType, Quantity = -quantity, UnitCost = item.UnitCost, TotalCost = quantity * item.UnitCost, TransactionDate = DateTime.UtcNow, BalanceAfter = item.QuantityOnHand, JobId = jobId, Reference = jobId.HasValue ? $"Job #{jobId}" : null, Notes = notes?.Trim() }; await _unitOfWork.InventoryTransactions.AddAsync(txn); await _unitOfWork.SaveChangesAsync(); // GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue) { var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost); await _accountBalanceService.DebitAsync(item.CogsAccountId, cost); await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost); } // PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging // doesn't have that context, so we rely on the InventoryTransaction alone // for the audit trail. Coat-level PowderUsageLogs are created by the job workflow. TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}."; TempData["ScanItemId"] = inventoryItemId.ToString(); TempData["ScanJobId"] = jobId?.ToString(); TempData["ScanItemName"] = item.Name; return RedirectToAction(nameof(ScanSuccess)); } catch (Exception ex) { _logger.LogError(ex, "Error logging usage for inventory item {ItemId}", inventoryItemId); TempData["ScanError"] = "An error occurred while saving. Please try again."; return RedirectToAction(nameof(Scan), new { id = inventoryItemId }); } } /// /// Success screen shown after a usage log is saved. Offers "Log Another Item for /// This Job" and "Done" options. /// [HttpGet] public IActionResult ScanSuccess() { ViewBag.Message = TempData["ScanSuccess"] as string; ViewBag.ItemId = TempData["ScanItemId"]; ViewBag.JobId = TempData["ScanJobId"]; ViewBag.ItemName = TempData["ScanItemName"] as string; return View(); } /// /// Applies a manual stock adjustment submitted from the Details page modal. /// Supports Add, Remove, and Set (exact count) modes and always records an /// InventoryTransaction so the change appears in the activity ledger. /// [HttpPost] [ValidateAntiForgeryToken] public async Task StockAdjustment(int id, string adjustmentType, decimal quantity, string reason, string? notes) { try { var item = await _unitOfWork.InventoryItems.GetByIdAsync(id); if (item == null) return NotFound(); if (quantity <= 0 && adjustmentType != "Set") { TempData["Error"] = "Quantity must be greater than zero."; return RedirectToAction(nameof(Details), new { id }); } if (string.IsNullOrWhiteSpace(reason)) { TempData["Error"] = "A reason is required for stock adjustments."; return RedirectToAction(nameof(Details), new { id }); } var previousQty = item.QuantityOnHand; decimal delta; switch (adjustmentType) { case "Add": delta = quantity; item.QuantityOnHand += quantity; break; case "Remove": delta = -quantity; item.QuantityOnHand -= quantity; break; case "Set": delta = quantity - item.QuantityOnHand; item.QuantityOnHand = quantity; break; default: TempData["Error"] = "Invalid adjustment type."; return RedirectToAction(nameof(Details), new { id }); } item.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(item); var fullNotes = string.IsNullOrWhiteSpace(notes) ? reason : $"{reason} — {notes.Trim()}"; var txn = new InventoryTransaction { InventoryItemId = item.Id, TransactionType = InventoryTransactionType.Adjustment, Quantity = delta, UnitCost = item.UnitCost, TotalCost = Math.Abs(delta) * item.UnitCost, TransactionDate = DateTime.UtcNow, BalanceAfter = item.QuantityOnHand, Reference = $"Manual adjustment ({adjustmentType})", Notes = fullNotes }; await _unitOfWork.InventoryTransactions.AddAsync(txn); await _unitOfWork.SaveChangesAsync(); var direction = delta > 0 ? $"+{delta:N2}" : $"{delta:N2}"; TempData["Success"] = $"Stock adjusted {direction} {item.UnitOfMeasure}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error applying stock adjustment for item {ItemId}", id); TempData["Error"] = "An error occurred while saving the adjustment."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Displays the inventory activity ledger: stock transactions and powder usage logs. /// Optionally pre-filtered to a single item when arriving from the Details page. /// public async Task Ledger( int? inventoryItemId, DateTime? dateFrom, DateTime? dateTo, string? typeFilter) { var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId); var itemList = allItems .Where(i => i.IsActive || i.QuantityOnHand > 0) .OrderBy(i => i.Name) .Select(i => new InventoryListDto { Id = i.Id, SKU = i.SKU, Name = i.Name, QuantityOnHand = i.QuantityOnHand, UnitOfMeasure = i.UnitOfMeasure, IsActive = i.IsActive }) .ToList(); // Build transactions query InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse(typeFilter, out var pt) ? pt : null; var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType); // Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId var unresolvedRefs = transactions .Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.JobId == null && !string.IsNullOrEmpty(t.Reference)) .Select(t => t.Reference!) .Distinct() .ToList(); var jobRefLookup = new Dictionary(); if (unresolvedRefs.Any()) { var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber)); jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber)); } // Powder usage logs with dynamic date + item filters var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo); InventoryItem? selectedItem = null; if (inventoryItemId.HasValue) selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value); // Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog var linkedTxIds = usageLogs .Where(u => u.InventoryTransactionId.HasValue) .Select(u => u.InventoryTransactionId!.Value) .ToHashSet(); var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto { Id = u.Id, JobId = u.JobId, JobNumber = u.Job?.JobNumber ?? string.Empty, CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(), InventoryItemId = u.InventoryItemId, ItemName = u.InventoryItem?.Name, SKU = u.InventoryItem?.SKU, CoatColor = u.JobItemCoat?.ColorName, ActualLbsUsed = u.ActualLbsUsed, EstimatedLbs = u.EstimatedLbs, VarianceLbs = u.VarianceLbs, RecordedAt = u.RecordedAt, Notes = u.Notes }).ToList(); // Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record; // surface them in the "Powder Usage By Job" tab so they aren't invisible. powderUsageDtos.AddRange(transactions .Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !linkedTxIds.Contains(t.Id) && (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference)))) .Select(t => { var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0); var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty); var cust = t.Job?.Customer; var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim(); return new PowderUsageLogDto { Id = 0, SourceTransactionId = t.Id, JobId = jobId, JobNumber = jobNumber, CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName, InventoryItemId = t.InventoryItemId, ItemName = t.InventoryItem?.Name, SKU = t.InventoryItem?.SKU, CoatColor = null, ActualLbsUsed = Math.Abs(t.Quantity), EstimatedLbs = 0, VarianceLbs = 0, RecordedAt = t.TransactionDate, Notes = t.Notes }; })); powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)]; var vm = new InventoryLedgerViewModel { InventoryItemId = inventoryItemId, SelectedItemName = selectedItem?.Name, SelectedItemSku = selectedItem?.SKU, DateFrom = dateFrom, DateTo = dateTo, TypeFilter = typeFilter, AllItems = itemList, Transactions = transactions.Select(t => new InventoryTransactionDto { Id = t.Id, InventoryItemId = t.InventoryItemId, ItemName = t.InventoryItem?.Name ?? string.Empty, SKU = t.InventoryItem?.SKU ?? string.Empty, TransactionType = t.TransactionType.ToString(), Quantity = t.Quantity, UnitCost = t.UnitCost, TotalCost = t.TotalCost, TransactionDate = t.TransactionDate, Reference = t.Reference, Notes = t.Notes, BalanceAfter = t.BalanceAfter, PurchaseOrderId = t.PurchaseOrderId, PurchaseOrderNumber = t.PurchaseOrder?.PoNumber, JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null), JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null) }).ToList(), PowderUsageLogs = powderUsageDtos, TotalPurchased = transactions .Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial) .Sum(t => t.Quantity), TotalUsed = transactions .Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Sale) .Sum(t => Math.Abs(t.Quantity)), TotalAdjusted = transactions .Where(t => t.TransactionType == InventoryTransactionType.Adjustment) .Sum(t => t.Quantity) }; return View(vm); } /// /// Returns the current values of a JobUsage InventoryTransaction plus a list of active /// jobs so the edit modal can be pre-populated without a full page reload. /// [HttpGet] public async Task GetUsageForEdit(int id) { var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.Job, t => t.InventoryItem); if (txn == null) return NotFound(); if (txn.TransactionType != InventoryTransactionType.JobUsage && txn.TransactionType != InventoryTransactionType.Adjustment) return BadRequest("Only usage transactions can be edited here."); var allJobs = await _unitOfWork.Jobs.FindAsync( j => !j.JobStatus.IsTerminalStatus, false, j => j.Customer, j => j.JobStatus); var jobs = allJobs .OrderByDescending(j => j.CreatedAt) .Take(200) .Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim()) : "No Customer" }) .ToList(); return Json(new { transactionId = txn.Id, jobId = txn.JobId, notes = txn.Notes, transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"), itemName = txn.InventoryItem?.Name, jobs }); } /// /// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date. /// Quantity and balance are not changed. /// [HttpPost] [ValidateAntiForgeryToken] public async Task EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate) { var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id); if (txn == null) return NotFound(); if (txn.TransactionType != InventoryTransactionType.JobUsage && txn.TransactionType != InventoryTransactionType.Adjustment) return BadRequest(); if (jobId.HasValue) { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value); txn.JobId = jobId.Value; txn.Reference = job?.JobNumber; } else { txn.JobId = null; txn.Reference = null; } // Promote Adjustment→JobUsage when a job is assigned so it shows in Powder Usage By Job tab if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment) txn.TransactionType = InventoryTransactionType.JobUsage; txn.Notes = notes?.Trim(); txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc ? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc); txn.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryTransactions.UpdateAsync(txn); await _unitOfWork.SaveChangesAsync(); return Json(new { success = true }); } } /// Helper projection used by the Scan action for job picker data. public class ScanJobOption { public int Id { get; set; } public string JobNumber { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty; }