Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/InventoryController.cs
T
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00

1975 lines
90 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<InventoryController> _logger;
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ISubscriptionService _subscriptionService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountBalanceService _accountBalanceService;
public InventoryController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<InventoryController> logger,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
IInventoryAiLookupService aiLookupService,
ISubscriptionService subscriptionService,
UserManager<ApplicationUser> userManager,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_tenantContext = tenantContext;
_measurementService = measurementService;
_aiLookupService = aiLookupService;
_subscriptionService = subscriptionService;
_userManager = userManager;
_accountBalanceService = accountBalanceService;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<Func<InventoryItem, bool>>? 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<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> 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<List<InventoryListDto>>(items);
var pagedResult = PagedResult<InventoryListDto>.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<InventoryListDto>());
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<InventoryItemDto>(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));
}
}
/// <summary>
/// Renders the inventory item creation form. Calls <see cref="PopulateDropdowns"/> 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.
/// </summary>
public async Task<IActionResult> 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
});
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateInventoryItemDto dto)
{
if (!ModelState.IsValid)
{
await PopulateDropdowns();
return View(dto);
}
try
{
var item = _mapper.Map<InventoryItem>(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);
}
}
/// <summary>
/// Renders the inventory item edit form, pre-populated via AutoMapper. Dropdowns and
/// measurement unit labels are reloaded the same way as <see cref="Create"/> so the
/// form is always consistent regardless of any lookup-table changes since the item
/// was created.
/// </summary>
public async Task<IActionResult> 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<UpdateInventoryItemDto>(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));
}
}
/// <summary>
/// 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 <see cref="Index"/> work correctly.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Renders the delete confirmation page for an inventory item, showing the item
/// summary so staff can confirm before permanently removing access to the record.
/// </summary>
public async Task<IActionResult> 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<InventoryItemDto>(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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<PowderCoating.Core.Entities.Job>());
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<object>(), 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 });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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);
}
/// <summary>
/// Looks up <paramref name="result"/> 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 <paramref name="autoContribute"/> 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 <paramref name="result"/> in place.
/// </summary>
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);
}
}
/// <summary>
/// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
/// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
/// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
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;
}
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the label scanner inline prompt.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> CatalogLookup(string? q, string? vendor, int? currentId = null)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>());
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);
}
/// <summary>
/// Creates a 0-balance inventory item from a PowderCatalogItem record and marks it IsIncoming=true.
/// Called by the item wizard when a staff member needs to quote a powder that has been ordered
/// but not yet received — the inventory record enables QR code printing on the work order.
/// Returns the new item's data in the same shape as the inventoryPowdersData list so the wizard
/// can add it to powderData and select it immediately without a page refresh.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIncomingFromCatalog(int catalogItemId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null)
return Json(new { success = false, error = "Catalog item not found." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.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;
}
/// <summary>
/// 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").
/// </summary>
private static string ToTitleCase(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return value ?? string.Empty;
return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(value.Trim().ToLower());
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// 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.
/// </summary>
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<SelectListItem>
{
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();
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> RepairCategories()
{
var aliases = new Dictionary<string, string>(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));
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<InventoryItemDto>(item));
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<InventoryItemDto>(item);
ViewBag.MyJobs = myJobs;
ViewBag.OtherJobs = otherJobs;
ViewBag.PreselectedJobId = jobId;
var scanError = TempData["ScanError"] as string;
if (scanError != null) ViewBag.ScanError = scanError;
return View();
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
/// This Job" and "Done" options.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// Displays the inventory activity ledger: stock transactions and powder usage logs.
/// Optionally pre-filtered to a single item when arriving from the Details page.
/// </summary>
public async Task<IActionResult> 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<InventoryTransactionType>(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<string, (int Id, string JobNumber)>();
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);
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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
});
}
/// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
/// Quantity and balance are not changed.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
}
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
public class ScanJobOption
{
public int Id { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
}