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 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; public InventoryController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, ITenantContext tenantContext, IMeasurementConversionService measurementService, IInventoryAiLookupService aiLookupService, ISubscriptionService subscriptionService, UserManager userManager) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _tenantContext = tenantContext; _measurementService = measurementService; _aiLookupService = aiLookupService; _subscriptionService = subscriptionService; _userManager = userManager; } /// /// 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); // Create paged result var pagedResult = new PagedResult { Items = itemDtos, PageNumber = gridRequest.PageNumber, PageSize = gridRequest.PageSize, TotalCount = totalCount }; // Load all items once to compute sidebar stats and category list in memory var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).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(); } 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); 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); return Json(result); } /// /// 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.TransferEfficiency ??= 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 }); // Search catalog by SKU first (most precise), then fall back to color name var sku = aiResult.ManufacturerPartNumber?.Trim(); var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim(); var colorName = aiResult.ColorName?.Trim(); PowderCatalogItem? catalogMatch = null; if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer)) { var skuLower = sku.ToLower(); var mfrLower = manufacturer.ToLower(); var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower)); catalogMatch = skuMatches.FirstOrDefault(); } var wasInCatalog = catalogMatch != null; var addedToCatalog = false; // Auto-contribute: insert into platform catalog if we have the minimum viable fields // and this SKU isn't already there if (!wasInCatalog && !string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(colorName)) { try { var newItem = new PowderCatalogItem { VendorName = manufacturer, Sku = sku, ColorName = colorName, CureTemperatureF = aiResult.CureTemperatureF, CureTimeMinutes = aiResult.CureTimeMinutes, Finish = aiResult.Finish, ColorFamilies = aiResult.ColorFamilies, RequiresClearCoat = aiResult.RequiresClearCoat, CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb, TransferEfficiency= aiResult.TransferEfficiency, ImageUrl = aiResult.ImageUrl, ProductUrl = aiResult.SpecPageUrl, SdsUrl = aiResult.SdsUrl, TdsUrl = aiResult.TdsUrl, IsUserContributed = true, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.PowderCatalog.AddAsync(newItem); await _unitOfWork.CompleteAsync(); addedToCatalog = true; _logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku); } 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); } } // 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 = catalogMatch?.Finish ?? aiResult.Finish, cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF, cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes, colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies, requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat, coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb, transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency, unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m, imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl, productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl, sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl, tdsUrl = catalogMatch?.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 and returns /// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back /// to the AI Lookup, avoiding unnecessary API calls for known products. /// [HttpGet] public async Task CatalogLookup(string? q, string? vendor) { if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Json(Array.Empty()); var term = q.Trim().ToLower(); var vendorTerm = vendor?.Trim().ToLower(); var matches = await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == term || p.ColorName.ToLower().Contains(term) || p.Sku.ToLower().Contains(term)); // When a vendor hint is provided, prefer records where VendorName matches, // then fall back to all results so the user still sees cross-vendor options. var results = matches .OrderBy(p => p.Sku.ToLower() == term ? 0 : 1) .ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1) .ThenBy(p => p.ColorName) .Take(10) .Select(p => new { id = p.Id, vendorName = p.VendorName, sku = p.Sku, colorName = p.ColorName, description = p.Description, unitPrice = p.UnitPrice, imageUrl = p.ImageUrl, sdsUrl = p.SdsUrl, tdsUrl = p.TdsUrl, applicationGuideUrl = p.ApplicationGuideUrl, productUrl = p.ProductUrl, isDiscontinued = p.IsDiscontinued, cureTemperatureF = p.CureTemperatureF, cureTimeMinutes = p.CureTimeMinutes, finish = p.Finish, colorFamilies = p.ColorFamilies, requiresClearCoat = p.RequiresClearCoat, coverageSqFtPerLb = p.CoverageSqFtPerLb, transferEfficiency = p.TransferEfficiency }); return Json(results); } /// /// 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.GetAllAsync(); 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.GetAllAsync(); 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; var txnType = jobId.HasValue ? InventoryTransactionType.JobUsage : (Enum.TryParse(transactionType, out var parsed) ? parsed : InventoryTransactionType.Adjustment); 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(); // 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 allItems = await _unitOfWork.InventoryItems.GetAllAsync(); 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); 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 = 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(), 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); } } /// 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; }