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