1306 lines
56 KiB
C#
1306 lines
56 KiB
C#
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 PowderCoating.Infrastructure.Data;
|
||
using Microsoft.EntityFrameworkCore;
|
||
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<InventoryController> _logger;
|
||
private readonly ITenantContext _tenantContext;
|
||
private readonly IMeasurementConversionService _measurementService;
|
||
private readonly IInventoryAiLookupService _aiLookupService;
|
||
private readonly ISubscriptionService _subscriptionService;
|
||
private readonly ApplicationDbContext _context;
|
||
private readonly UserManager<ApplicationUser> _userManager;
|
||
|
||
public InventoryController(
|
||
IUnitOfWork unitOfWork,
|
||
IMapper mapper,
|
||
ILogger<InventoryController> logger,
|
||
ITenantContext tenantContext,
|
||
IMeasurementConversionService measurementService,
|
||
IInventoryAiLookupService aiLookupService,
|
||
ISubscriptionService subscriptionService,
|
||
ApplicationDbContext context,
|
||
UserManager<ApplicationUser> userManager)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_mapper = mapper;
|
||
_logger = logger;
|
||
_tenantContext = tenantContext;
|
||
_measurementService = measurementService;
|
||
_aiLookupService = aiLookupService;
|
||
_subscriptionService = subscriptionService;
|
||
_context = context;
|
||
_userManager = userManager;
|
||
}
|
||
|
||
/// <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);
|
||
|
||
// Create paged result
|
||
var pagedResult = new PagedResult<InventoryListDto>
|
||
{
|
||
Items = itemDtos,
|
||
PageNumber = gridRequest.PageNumber,
|
||
PageSize = gridRequest.PageSize,
|
||
TotalCount = totalCount
|
||
};
|
||
|
||
// Push stats and category list to the database rather than loading all rows
|
||
var statsBase = _context.InventoryItems;
|
||
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
|
||
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
|
||
ViewBag.StatsTotalValue = await statsBase.SumAsync(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();
|
||
}
|
||
|
||
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 });
|
||
|
||
IQueryable<JobPhoto> query = _context.JobPhotos
|
||
.AsNoTracking()
|
||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
|
||
|
||
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
|
||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
|
||
else if (!string.IsNullOrEmpty(colorName))
|
||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
|
||
else
|
||
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
|
||
|
||
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
|
||
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
|
||
|
||
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 _context.JobPhotos
|
||
.AsNoTracking()
|
||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
|
||
.Where(p => !p.IsDeleted &&
|
||
p.Job != null &&
|
||
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
|
||
.OrderByDescending(p => p.UploadedDate)
|
||
.ToListAsync();
|
||
|
||
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);
|
||
return Json(result);
|
||
}
|
||
|
||
/// <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 _context.InventoryItems
|
||
.AsNoTracking()
|
||
.Include(i => i.InventoryCategory)
|
||
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
|
||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||
.ToListAsync();
|
||
|
||
// 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.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<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 _context.Jobs
|
||
.AsNoTracking()
|
||
.Include(j => j.Customer)
|
||
.Include(j => j.JobStatus)
|
||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
|
||
.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" })
|
||
.ToListAsync();
|
||
|
||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||
var otherJobs = await _context.Jobs
|
||
.AsNoTracking()
|
||
.Include(j => j.Customer)
|
||
.Include(j => j.JobStatus)
|
||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
|
||
.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" })
|
||
.ToListAsync();
|
||
|
||
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;
|
||
var txnType = jobId.HasValue ? InventoryTransactionType.JobUsage
|
||
: (Enum.TryParse<InventoryTransactionType>(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 });
|
||
}
|
||
}
|
||
|
||
/// <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 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
|
||
var txnQuery = _context.InventoryTransactions
|
||
.AsNoTracking()
|
||
.Include(t => t.InventoryItem)
|
||
.Include(t => t.PurchaseOrder)
|
||
.Include(t => t.Job)
|
||
.Where(t => !t.IsDeleted);
|
||
|
||
if (inventoryItemId.HasValue)
|
||
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
|
||
|
||
if (dateFrom.HasValue)
|
||
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
|
||
|
||
if (dateTo.HasValue)
|
||
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
|
||
|
||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
|
||
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
|
||
|
||
var transactions = await txnQuery
|
||
.OrderByDescending(t => t.TransactionDate)
|
||
.Take(500)
|
||
.ToListAsync();
|
||
|
||
// 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 _context.Jobs
|
||
.AsNoTracking()
|
||
.Where(j => unresolvedRefs.Contains(j.JobNumber))
|
||
.Select(j => new { j.Id, j.JobNumber })
|
||
.ToListAsync();
|
||
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
|
||
}
|
||
|
||
// Build powder usage logs query
|
||
var usageQuery = _context.PowderUsageLogs
|
||
.AsNoTracking()
|
||
.Include(u => u.Job).ThenInclude(j => j.Customer)
|
||
.Include(u => u.InventoryItem)
|
||
.Include(u => u.JobItemCoat)
|
||
.Where(u => !u.IsDeleted);
|
||
|
||
if (inventoryItemId.HasValue)
|
||
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
|
||
|
||
if (dateFrom.HasValue)
|
||
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
|
||
|
||
if (dateTo.HasValue)
|
||
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
|
||
|
||
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
|
||
var usageLogs = await usageQuery
|
||
.OrderByDescending(u => u.RecordedAt)
|
||
.Take(500)
|
||
.ToListAsync();
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
}
|