5e3b0b9ddf
When a scanned label matches an item already in the tenant's inventory, the scanner now opens an inline modal asking the user to add stock to the existing item rather than navigating away or creating a duplicate. - InventoryController.AddStock: new POST endpoint that creates a Purchase transaction, updates QuantityOnHand, and optionally updates UnitCost / LastPurchasePrice when a new cost is provided. Returns new balance as JSON. - InventoryController.ScanLabel: extends the duplicate-detection response to include existingQuantityOnHand and existingUnitOfMeasure so the modal can display current stock level. - _LabelScanModal.cshtml: adds #addStockModal with quantity (+ UOM label), optional unit cost (pre-filled from scan), optional notes, Add Stock CTA, and an escape hatch to create a new entry instead. - inventory-label-scan.js: when scan returns existingInventoryId the JS opens addStockModal instead of a warning banner. Submitting POSTs to /Inventory/AddStock and shows the updated balance in a success bar with a link to the item. The 'new entry instead' path hides the modal and pre-fills the create form with a softer duplicate warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1583 lines
69 KiB
C#
1583 lines
69 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 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 UserManager<ApplicationUser> _userManager;
|
||
|
||
public InventoryController(
|
||
IUnitOfWork unitOfWork,
|
||
IMapper mapper,
|
||
ILogger<InventoryController> logger,
|
||
ITenantContext tenantContext,
|
||
IMeasurementConversionService measurementService,
|
||
IInventoryAiLookupService aiLookupService,
|
||
ISubscriptionService subscriptionService,
|
||
UserManager<ApplicationUser> userManager)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_mapper = mapper;
|
||
_logger = logger;
|
||
_tenantContext = tenantContext;
|
||
_measurementService = measurementService;
|
||
_aiLookupService = aiLookupService;
|
||
_subscriptionService = subscriptionService;
|
||
_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
|
||
};
|
||
|
||
// Load all items once to compute sidebar stats and category list in memory
|
||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||
|
||
// Set ViewBag for sorting and filters
|
||
ViewBag.SearchTerm = searchTerm;
|
||
ViewBag.Category = category;
|
||
ViewBag.LowStockOnly = lowStockOnly;
|
||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||
|
||
return View(pagedResult);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error retrieving inventory items");
|
||
TempData["Error"] = "An error occurred while loading inventory items.";
|
||
return View(new PagedResult<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 });
|
||
|
||
// 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);
|
||
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);
|
||
return Json(result);
|
||
}
|
||
|
||
/// <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.TransferEfficiency ??= full.TransferEfficiency;
|
||
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
|
||
aiResult.ColorName ??= full.ColorName;
|
||
aiResult.ColorCode ??= full.ColorCode;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
|
||
}
|
||
|
||
if (!aiResult.Success)
|
||
return Json(new { success = false, errorMessage = aiResult.ErrorMessage });
|
||
|
||
// Search catalog by SKU first (most precise), then fall back to color name
|
||
var sku = aiResult.ManufacturerPartNumber?.Trim();
|
||
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
|
||
var colorName = aiResult.ColorName?.Trim();
|
||
|
||
PowderCatalogItem? catalogMatch = null;
|
||
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
|
||
{
|
||
var skuLower = sku.ToLower();
|
||
var mfrLower = manufacturer.ToLower();
|
||
var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
|
||
catalogMatch = skuMatches.FirstOrDefault();
|
||
}
|
||
|
||
var wasInCatalog = catalogMatch != null;
|
||
var addedToCatalog = false;
|
||
|
||
// Auto-contribute: insert into platform catalog if we have the minimum viable fields
|
||
// and this SKU isn't already there
|
||
if (!wasInCatalog
|
||
&& !string.IsNullOrEmpty(sku)
|
||
&& !string.IsNullOrEmpty(manufacturer)
|
||
&& !string.IsNullOrEmpty(colorName))
|
||
{
|
||
try
|
||
{
|
||
var newItem = new PowderCatalogItem
|
||
{
|
||
VendorName = manufacturer,
|
||
Sku = sku,
|
||
ColorName = colorName,
|
||
CureTemperatureF = aiResult.CureTemperatureF,
|
||
CureTimeMinutes = aiResult.CureTimeMinutes,
|
||
Finish = aiResult.Finish,
|
||
ColorFamilies = aiResult.ColorFamilies,
|
||
RequiresClearCoat = aiResult.RequiresClearCoat,
|
||
CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
|
||
TransferEfficiency= aiResult.TransferEfficiency,
|
||
ImageUrl = aiResult.ImageUrl,
|
||
ProductUrl = aiResult.SpecPageUrl,
|
||
SdsUrl = aiResult.SdsUrl,
|
||
TdsUrl = aiResult.TdsUrl,
|
||
IsUserContributed = true,
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
await _unitOfWork.PowderCatalog.AddAsync(newItem);
|
||
await _unitOfWork.CompleteAsync();
|
||
addedToCatalog = true;
|
||
_logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Unique constraint violation means another request beat us — not an error
|
||
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
|
||
}
|
||
}
|
||
|
||
// Check if this product already exists in the tenant's inventory.
|
||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
||
// Returns the first active match so the UI can prompt to add stock inline.
|
||
int? existingInventoryId = null;
|
||
string? existingInventoryName = null;
|
||
decimal? existingQuantityOnHand = null;
|
||
string? existingUnitOfMeasure = null;
|
||
|
||
InventoryItem? existingHit = null;
|
||
|
||
if (!string.IsNullOrEmpty(sku))
|
||
{
|
||
var skuLower = sku.ToLower();
|
||
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||
i.ManufacturerPartNumber != null &&
|
||
i.ManufacturerPartNumber.ToLower() == skuLower);
|
||
existingHit = byPart.FirstOrDefault();
|
||
}
|
||
|
||
if (existingHit == null && !string.IsNullOrEmpty(colorName))
|
||
{
|
||
var nameLower = colorName.ToLower();
|
||
var mfrLower = manufacturer?.ToLower() ?? "";
|
||
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
|
||
i.Name.ToLower() == nameLower);
|
||
existingHit = byName.FirstOrDefault(i =>
|
||
string.IsNullOrEmpty(mfrLower) ||
|
||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
|
||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
|
||
}
|
||
|
||
if (existingHit != null)
|
||
{
|
||
existingInventoryId = existingHit.Id;
|
||
existingInventoryName = existingHit.Name;
|
||
existingQuantityOnHand = existingHit.QuantityOnHand;
|
||
existingUnitOfMeasure = existingHit.UnitOfMeasure;
|
||
}
|
||
|
||
return Json(new
|
||
{
|
||
success = true,
|
||
manufacturer = manufacturer,
|
||
manufacturerPartNumber = sku,
|
||
colorName = colorName,
|
||
description = aiResult.Description,
|
||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
|
||
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
|
||
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
|
||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
|
||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
||
tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl,
|
||
vendorName = manufacturer,
|
||
wasInCatalog = wasInCatalog,
|
||
addedToCatalog = addedToCatalog,
|
||
existingInventoryId = existingInventoryId,
|
||
existingInventoryName = existingInventoryName,
|
||
existingQuantityOnHand = existingQuantityOnHand,
|
||
existingUnitOfMeasure = existingUnitOfMeasure,
|
||
reasoning = aiResult.Reasoning,
|
||
});
|
||
}
|
||
|
||
/// <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 and returns
|
||
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
|
||
/// to the AI Lookup, avoiding unnecessary API calls for known products.
|
||
/// </summary>
|
||
[HttpGet]
|
||
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||
return Json(Array.Empty<object>());
|
||
|
||
var term = q.Trim().ToLower();
|
||
var vendorTerm = vendor?.Trim().ToLower();
|
||
|
||
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||
p.Sku.ToLower() == term ||
|
||
p.ColorName.ToLower().Contains(term) ||
|
||
p.Sku.ToLower().Contains(term));
|
||
|
||
// When a vendor hint is provided, prefer records where VendorName matches,
|
||
// then fall back to all results so the user still sees cross-vendor options.
|
||
var results = matches
|
||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
||
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
|
||
.ThenBy(p => p.ColorName)
|
||
.Take(10)
|
||
.Select(p => new
|
||
{
|
||
id = p.Id,
|
||
vendorName = p.VendorName,
|
||
sku = p.Sku,
|
||
colorName = p.ColorName,
|
||
description = p.Description,
|
||
unitPrice = p.UnitPrice,
|
||
imageUrl = p.ImageUrl,
|
||
sdsUrl = p.SdsUrl,
|
||
tdsUrl = p.TdsUrl,
|
||
applicationGuideUrl = p.ApplicationGuideUrl,
|
||
productUrl = p.ProductUrl,
|
||
isDiscontinued = p.IsDiscontinued,
|
||
cureTemperatureF = p.CureTemperatureF,
|
||
cureTimeMinutes = p.CureTimeMinutes,
|
||
finish = p.Finish,
|
||
colorFamilies = p.ColorFamilies,
|
||
requiresClearCoat = p.RequiresClearCoat,
|
||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||
transferEfficiency = p.TransferEfficiency
|
||
});
|
||
|
||
return Json(results);
|
||
}
|
||
|
||
/// <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.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 _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;
|
||
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
|
||
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);
|
||
|
||
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;
|
||
}
|