using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.PurchaseOrder; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Shared.Constants; using PowderCoating.Web.Helpers; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManagePurchaseOrders)] public class PurchaseOrdersController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; private readonly ICompanyLogoService _logoService; public PurchaseOrdersController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, IPdfService pdfService, ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _pdfService = pdfService; _logoService = logoService; } // ----------------------------------------------------------------------- // GET: /PurchaseOrders // ----------------------------------------------------------------------- /// /// Lists purchase orders with server-side filtering, sorting, and pagination. Summary KPI /// cards (total count, open count, committed value, overdue count) are computed from a /// lightweight projection of all non-deleted POs for the company — independent of the current /// filter — so the cards always reflect the global state rather than the filtered subset. /// Cancelled POs are excluded from committed value because they carry no financial obligation. /// public async Task Index( string? searchTerm, PurchaseOrderStatus? statusFilter, int? vendorId, DateTime? dateFrom, DateTime? dateTo, string? sortColumn, string sortDirection = "desc", int pageNumber = 1, int pageSize = 25) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); pageSize = Math.Clamp(pageSize, 10, 100); pageNumber = Math.Max(1, pageNumber); var (items, totalCount) = await _unitOfWork.PurchaseOrders.GetPagedAsync( currentUser.CompanyId, pageNumber, pageSize, statusFilter, vendorId, dateFrom, dateTo, searchTerm, sortColumn, sortDirection); var dtos = _mapper.Map>(items); var result = new PagedResult { Items = dtos, TotalCount = totalCount, PageNumber = pageNumber, PageSize = pageSize }; // Stats (server-side projection — only three columns fetched) var stats = await _unitOfWork.PurchaseOrders.GetStatsAsync(currentUser.CompanyId); ViewBag.TotalCount = stats.TotalCount; ViewBag.OpenCount = stats.OpenCount; ViewBag.CommittedValue = stats.CommittedValue; ViewBag.OverdueCount = stats.OverdueCount; await PopulateVendorFilterDropdownAsync(currentUser.CompanyId); ViewBag.SearchTerm = searchTerm; ViewBag.StatusFilter = statusFilter; ViewBag.VendorId = vendorId; ViewBag.DateFrom = dateFrom?.ToString("yyyy-MM-dd"); ViewBag.DateTo = dateTo?.ToString("yyyy-MM-dd"); ViewBag.SortColumn = sortColumn ?? "orderdate"; ViewBag.SortDirection = sortDirection; return View(result); } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/Details/5 // ----------------------------------------------------------------------- /// /// Displays full PO detail including vendor info, linked bill (if one has been created), and /// all non-deleted line items with their associated inventory items. /// public async Task Details(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); var dto = _mapper.Map(po); return View(dto); } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/Create // ----------------------------------------------------------------------- /// /// Returns the blank PO creation form with today's date pre-filled. Vendors and inventory /// items are loaded via and serialised to JSON so /// the dynamic line-item UI can look up unit-of-measure and last purchase price without /// additional round trips. /// public async Task Create() { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); await PopulateCreateViewBagAsync(currentUser.CompanyId); return View(new CreatePurchaseOrderDto { OrderDate = DateTime.Today }); } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Create // ----------------------------------------------------------------------- /// /// Persists a new purchase order in Draft status. POs start as drafts so purchasing /// staff can review before formally submitting to the vendor via . /// Custom line items (those without a linked inventory item) must have an explicit description /// because the item name cannot be inferred from inventory. LineTotal and PO totals are /// computed server-side from quantity × unit cost to prevent client-side manipulation. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreatePurchaseOrderDto dto) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); if (!dto.Items.Any()) ModelState.AddModelError("", "At least one line item is required."); foreach (var item in dto.Items.Where(i => !i.InventoryItemId.HasValue)) { if (string.IsNullOrWhiteSpace(item.Description)) ModelState.AddModelError("", "Custom line items require a description."); } if (!ModelState.IsValid) { await PopulateCreateViewBagAsync(currentUser.CompanyId); return View(dto); } try { var poNumber = await GeneratePoNumberAsync(currentUser.CompanyId); var po = _mapper.Map(dto); po.PoNumber = poNumber; po.Status = PurchaseOrderStatus.Draft; po.CompanyId = currentUser.CompanyId; foreach (var itemDto in dto.Items) { var item = _mapper.Map(itemDto); item.LineTotal = item.QuantityOrdered * item.UnitCost; item.CompanyId = currentUser.CompanyId; po.Items.Add(item); } po.SubTotal = po.Items.Sum(i => i.LineTotal); po.TotalAmount = po.SubTotal + po.ShippingCost; await _unitOfWork.PurchaseOrders.AddAsync(po); await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Purchase Order {po.PoNumber} created."); return RedirectToAction(nameof(Details), new { id = po.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating purchase order"); ModelState.AddModelError("", "An error occurred while creating the purchase order."); await PopulateCreateViewBagAsync(currentUser.CompanyId); return View(dto); } } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/Edit/5 // ----------------------------------------------------------------------- /// /// Returns the edit form for a draft PO. Only Draft POs are editable; once submitted /// the vendor may have already received and acted on the order so changes would cause /// discrepancies between what the vendor was told and what is recorded internally. /// public async Task Edit(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { TempData["Error"] = "Only Draft purchase orders can be edited."; return RedirectToAction(nameof(Details), new { id }); } var dto = new UpdatePurchaseOrderDto { VendorId = po.VendorId, OrderDate = po.OrderDate, ExpectedDeliveryDate = po.ExpectedDeliveryDate, ShippingCost = po.ShippingCost, Notes = po.Notes, InternalNotes = po.InternalNotes, Items = po.Items.Select(i => new CreatePurchaseOrderItemDto { InventoryItemId = i.InventoryItemId, Description = i.Description, UnitOfMeasure = i.UnitOfMeasure, QuantityOrdered = i.QuantityOrdered, UnitCost = i.UnitCost, Notes = i.Notes }).ToList() }; await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.PoId = id; ViewBag.PoNumber = po.PoNumber; return View(dto); } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Edit/5 // ----------------------------------------------------------------------- /// /// Saves PO edits using a soft-delete + re-insert pattern for line items so that previously /// submitted item records are preserved in the database for audit purposes. Active item /// subtotals and shipping are recomputed after the new items are added to keep /// SubTotal and TotalAmount consistent. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdatePurchaseOrderDto dto) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { TempData["Error"] = "Only Draft purchase orders can be edited."; return RedirectToAction(nameof(Details), new { id }); } if (!dto.Items.Any()) ModelState.AddModelError("", "At least one line item is required."); foreach (var item in dto.Items.Where(i => !i.InventoryItemId.HasValue)) { if (string.IsNullOrWhiteSpace(item.Description)) ModelState.AddModelError("", "Custom line items require a description."); } if (!ModelState.IsValid) { await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.PoId = id; ViewBag.PoNumber = po.PoNumber; return View(dto); } try { _mapper.Map(dto, po); // Soft-delete existing items then add fresh ones foreach (var existing in po.Items) { existing.IsDeleted = true; existing.UpdatedAt = DateTime.UtcNow; } foreach (var itemDto in dto.Items) { var item = _mapper.Map(itemDto); item.LineTotal = item.QuantityOrdered * item.UnitCost; item.CompanyId = currentUser.CompanyId; po.Items.Add(item); } var activeItems = po.Items.Where(i => !i.IsDeleted).ToList(); po.SubTotal = activeItems.Sum(i => i.LineTotal); po.TotalAmount = po.SubTotal + po.ShippingCost; po.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Purchase Order {po.PoNumber} updated."); return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating purchase order {PoId}", id); ModelState.AddModelError("", "An error occurred while updating the purchase order."); await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.PoId = id; ViewBag.PoNumber = po.PoNumber; return View(dto); } } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/Delete/5 // ----------------------------------------------------------------------- /// /// Returns the delete confirmation page for a PO. Only Draft or Cancelled POs /// may be deleted; submitted/received POs have inventory and accounting implications that /// prevent safe removal. /// public async Task Delete(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled) { TempData["Error"] = "Only Draft or Cancelled purchase orders can be deleted."; return RedirectToAction(nameof(Details), new { id }); } return View(_mapper.Map(po)); } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Delete/5 // ----------------------------------------------------------------------- /// /// Soft-deletes a draft or cancelled PO. The PO number is preserved in the database (via /// soft delete rather than physical removal) so the sequence cannot be reused and the number /// gap is explainable in audit logs. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task DeleteConfirmed(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(id); if (po == null || po.CompanyId != currentUser.CompanyId) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled) { TempData["Error"] = "Only Draft or Cancelled purchase orders can be deleted."; return RedirectToAction(nameof(Details), new { id }); } try { await _unitOfWork.PurchaseOrders.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Purchase Order {po.PoNumber} deleted."); return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting purchase order {PoId}", id); TempData["Error"] = "An error occurred while deleting the purchase order."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Submit/5 // ----------------------------------------------------------------------- /// /// Advances a PO from Draft to Submitted, signalling that the order has been /// formally sent to the vendor. A PO with no line items cannot be submitted because the /// vendor would have nothing to fulfil. Submitted POs become read-only; any changes require /// cancelling and creating a new PO (or discussing an amendment with the vendor off-system). /// [HttpPost] [ValidateAntiForgeryToken] public async Task Submit(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { TempData["Error"] = "Only Draft purchase orders can be submitted."; return RedirectToAction(nameof(Details), new { id }); } if (!po.Items.Any()) { TempData["Error"] = "Cannot submit a purchase order with no line items."; return RedirectToAction(nameof(Details), new { id }); } po.Status = PurchaseOrderStatus.Submitted; po.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Purchase Order {po.PoNumber} submitted."); return RedirectToAction(nameof(Details), new { id }); } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Cancel/5 // ----------------------------------------------------------------------- /// /// Cancels a PO at any status except Received. Received POs cannot be cancelled /// because the goods have already entered inventory and a vendor bill may exist; undoing /// receipt would require separate inventory adjustments and bill voids. Cancelled POs are /// excluded from committed-spend reporting but remain in the database for audit purposes. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Cancel(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(id); if (po == null || po.CompanyId != currentUser.CompanyId) return NotFound(); if (po.Status == PurchaseOrderStatus.Received) { TempData["Error"] = "Received purchase orders cannot be cancelled."; return RedirectToAction(nameof(Details), new { id }); } po.Status = PurchaseOrderStatus.Cancelled; po.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Purchase Order {po.PoNumber} cancelled."); return RedirectToAction(nameof(Details), new { id }); } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/Receive/5 // ----------------------------------------------------------------------- /// /// Returns the goods-receipt form pre-filled with each line item's remaining-to-receive /// quantity (ordered minus already received). Only Submitted or /// PartiallyReceived POs can accept receipts — Draft POs have not been sent /// and Received POs are already complete. /// public async Task Receive(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Submitted && po.Status != PurchaseOrderStatus.PartiallyReceived) { TempData["Error"] = "Only Submitted or Partially Received purchase orders can receive goods."; return RedirectToAction(nameof(Details), new { id }); } var dto = new ReceivePurchaseOrderDto { ReceivedDate = DateTime.Today, Items = po.Items.Select(i => new ReceiveItemDto { PurchaseOrderItemId = i.Id, InventoryItemId = i.InventoryItemId, ItemName = i.InventoryItem?.Name ?? i.Description ?? string.Empty, ItemSKU = i.InventoryItem?.SKU ?? string.Empty, UnitOfMeasure = i.InventoryItem?.UnitOfMeasure ?? i.UnitOfMeasure ?? string.Empty, QuantityOrdered = i.QuantityOrdered, QuantityAlreadyReceived = i.QuantityReceived, QuantityRemaining = Math.Max(0, i.QuantityOrdered - i.QuantityReceived), QuantityToReceive = Math.Max(0, i.QuantityOrdered - i.QuantityReceived) }).ToList() }; ViewBag.PoNumber = po.PoNumber; ViewBag.VendorName = po.Vendor?.CompanyName; ViewBag.OrderDate = po.OrderDate; ViewBag.PoId = id; return View(dto); } // ----------------------------------------------------------------------- // POST: /PurchaseOrders/Receive/5 // ----------------------------------------------------------------------- /// /// Records the physical receipt of goods and updates inventory stock levels atomically inside /// a database transaction. For each linked inventory item the weighted-average cost is /// recalculated as (existingQty * existingAvgCost + receivedQty * poUnitCost) / newTotalQty /// so that the running average reflects the blended acquisition cost over multiple purchases. /// An of type Purchase is written for each item /// received to provide a complete stock ledger. Quantities are clamped to the remaining /// receivable amount to prevent over-receipt. PO status advances to PartiallyReceived /// or Received based on whether all items have been fully received. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Receive(int id, ReceivePurchaseOrderDto dto) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Submitted && po.Status != PurchaseOrderStatus.PartiallyReceived) { TempData["Error"] = "Only Submitted or Partially Received purchase orders can receive goods."; return RedirectToAction(nameof(Details), new { id }); } var hasAnyQuantity = dto.Items.Any(i => i.QuantityToReceive > 0); if (!hasAnyQuantity) { TempData["Error"] = "Enter a quantity greater than 0 for at least one item."; ViewBag.PoNumber = po.PoNumber; ViewBag.VendorName = po.Vendor?.CompanyName; ViewBag.OrderDate = po.OrderDate; ViewBag.PoId = id; return View(dto); } var allReceived = false; try { await _unitOfWork.ExecuteInTransactionAsync(async () => { foreach (var receiveDto in dto.Items.Where(i => i.QuantityToReceive > 0)) { var poItem = po.Items.FirstOrDefault(i => i.Id == receiveDto.PurchaseOrderItemId); if (poItem == null) continue; // Clamp to remaining quantity var maxReceivable = poItem.QuantityOrdered - poItem.QuantityReceived; var qtyToReceive = Math.Min(receiveDto.QuantityToReceive, maxReceivable); if (qtyToReceive <= 0) continue; // Only update inventory for linked inventory items if (poItem.InventoryItemId.HasValue) { var inventoryItem = poItem.InventoryItem; if (inventoryItem != null) { // Recalculate weighted average cost var newTotalQty = inventoryItem.QuantityOnHand + qtyToReceive; if (newTotalQty > 0) { inventoryItem.AverageCost = Math.Round( (inventoryItem.QuantityOnHand * inventoryItem.AverageCost + qtyToReceive * poItem.UnitCost) / newTotalQty, 4); } inventoryItem.QuantityOnHand += qtyToReceive; inventoryItem.LastPurchasePrice = poItem.UnitCost; inventoryItem.LastPurchaseDate = dto.ReceivedDate; var transaction = new InventoryTransaction { InventoryItemId = poItem.InventoryItemId.Value, TransactionType = InventoryTransactionType.Purchase, Quantity = qtyToReceive, UnitCost = poItem.UnitCost, TotalCost = qtyToReceive * poItem.UnitCost, TransactionDate = dto.ReceivedDate, Reference = po.PoNumber, Notes = dto.Notes, BalanceAfter = inventoryItem.QuantityOnHand, PurchaseOrderId = po.Id, CompanyId = po.CompanyId }; await _unitOfWork.InventoryTransactions.AddAsync(transaction); } } poItem.QuantityReceived += qtyToReceive; poItem.LineTotal = poItem.QuantityOrdered * poItem.UnitCost; poItem.UpdatedAt = DateTime.UtcNow; } // Recalculate PO totals and status var activeItems = po.Items.Where(i => !i.IsDeleted).ToList(); po.SubTotal = activeItems.Sum(i => i.LineTotal); po.TotalAmount = po.SubTotal + po.ShippingCost; allReceived = activeItems.All(i => i.QuantityReceived >= i.QuantityOrdered); var anyReceived = activeItems.Any(i => i.QuantityReceived > 0); if (allReceived) { po.Status = PurchaseOrderStatus.Received; po.ReceivedDate = dto.ReceivedDate; } else if (anyReceived) { po.Status = PurchaseOrderStatus.PartiallyReceived; } po.UpdatedAt = DateTime.UtcNow; }); // end ExecuteInTransactionAsync — SaveChangesAsync called automatically before commit this.ToastSuccess(allReceived ? $"All items received for {po.PoNumber}." : $"Partial receipt recorded for {po.PoNumber}."); return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error receiving goods for PO {PoId}", id); TempData["Error"] = "An error occurred while recording the receipt."; return RedirectToAction(nameof(Receive), new { id }); } } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/DownloadPdf/5 // ----------------------------------------------------------------------- /// /// Generates and streams a PDF version of the PO suitable for emailing to the vendor. /// Company logo and contact details are embedded in the PDF header using the tenant's /// stored logo bytes and CompanyInfoDto. PDF generation is delegated to /// . /// public async Task DownloadPdf(int id) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); try { var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); var dto = _mapper.Map(po); var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); var companyInfo = new Application.DTOs.Company.CompanyInfoDto { CompanyName = company?.CompanyName ?? string.Empty, Phone = company?.Phone, Address = company?.Address, City = company?.City, State = company?.State, ZipCode = company?.ZipCode, PrimaryContactEmail = company?.PrimaryContactEmail }; var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync( dto, logoData, logoContentType, companyInfo); return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf"); } catch (Exception ex) { _logger.LogError(ex, "Error generating PDF for PO {Id}", id); TempData["Error"] = "An error occurred while generating the PDF."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // GET: /PurchaseOrders/CreateFromLowStock // ----------------------------------------------------------------------- /// /// Shortcut that pre-populates a new PO from the low-stock inventory alert list. Only items /// with a PrimaryVendorId are eligible because the vendor field is required on a PO. /// Items are grouped by vendor so the user can choose which vendor's low-stock items to order /// in a single PO (you cannot mix vendors on one PO). When a vendor is selected the DTO is /// pre-filled with each low-stock item's ReorderQuantity (or 1 if not set) and the /// last purchase price as the default unit cost, saving manual data entry. /// public async Task CreateFromLowStock(int? vendorId) { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); // Find low-stock items that have a primary vendor var lowStockItems = (await _unitOfWork.InventoryItems.FindAsync( i => i.IsActive && i.CompanyId == currentUser.CompanyId && i.PrimaryVendorId != null && i.QuantityOnHand <= i.ReorderPoint, false, i => i.PrimaryVendor!)) .ToList(); if (!lowStockItems.Any()) { TempData["Info"] = "No low-stock items with a primary vendor were found."; return RedirectToAction(nameof(Create)); } // Group by vendor for the selection step var vendorGroups = lowStockItems .GroupBy(i => new { i.PrimaryVendorId, VendorName = i.PrimaryVendor?.CompanyName ?? "Unknown" }) .Select(g => new { VendorId = g.Key.PrimaryVendorId!.Value, VendorName = g.Key.VendorName, ItemCount = g.Count() }) .OrderBy(v => v.VendorName) .ToList(); // If no vendor selected yet, show selection page if (!vendorId.HasValue) { ViewBag.VendorGroups = vendorGroups; return View("SelectLowStockVendor"); } // Pre-populate a Create form for the selected vendor var vendorItems = lowStockItems.Where(i => i.PrimaryVendorId == vendorId.Value).ToList(); if (!vendorItems.Any()) { TempData["Info"] = "No low-stock items found for that vendor."; return RedirectToAction(nameof(CreateFromLowStock)); } var dto = new CreatePurchaseOrderDto { VendorId = vendorId.Value, OrderDate = DateTime.Today, Items = vendorItems.Select(i => new CreatePurchaseOrderItemDto { InventoryItemId = i.Id, QuantityOrdered = i.ReorderQuantity > 0 ? i.ReorderQuantity : 1, UnitCost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost }).ToList() }; await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.FromLowStock = true; return View("Create", dto); } // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- /// /// Generates a sequential PO number in the format PO-YYMM-####. Uses /// IgnoreQueryFilters() to include soft-deleted POs in the scan so numbers are never /// reused. Scoped to the current company so each tenant has its own independent sequence. /// private async Task GeneratePoNumberAsync(int companyId) { var prefix = $"PO-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var existingPos = await _unitOfWork.PurchaseOrders.FindAsync( po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix), ignoreQueryFilters: true); var existing = existingPos.Select(po => po.PoNumber).ToList(); var maxNum = 0; foreach (var num in existing) { var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : ""; if (int.TryParse(suffix, out int n) && n > maxNum) maxNum = n; } return $"{prefix}{(maxNum + 1):D4}"; } /// /// Loads Create/Edit form dropdowns: active vendors and active inventory items. Inventory /// items are serialised to a JSON blob in ViewBag.InventoryItemsJson so the dynamic /// line-item UI can look up unit-of-measure and cost on the client side without extra AJAX /// calls. The cost preference order is last purchase price then catalog unit cost. /// private async Task PopulateCreateViewBagAsync(int companyId) { var vendorEntities = await _unitOfWork.Vendors.FindAsync( v => v.CompanyId == companyId && v.IsActive); var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) .ToList(); vendors.Insert(0, new SelectListItem("— Select Vendor —", "")); ViewBag.Vendors = vendors; var inventoryEntities = await _unitOfWork.InventoryItems.FindAsync( i => i.CompanyId == companyId && i.IsActive); var inventoryItems = inventoryEntities .OrderBy(i => i.Name) .Select(i => new { value = i.Id, text = i.Name + (!string.IsNullOrEmpty(i.SKU) ? $" ({i.SKU})" : ""), uom = i.UnitOfMeasure ?? "units", cost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost }) .ToList(); ViewBag.InventoryItemsJson = System.Text.Json.JsonSerializer.Serialize(inventoryItems); } /// /// Loads the vendor filter dropdown for the Index view. Unlike /// this includes inactive vendors so that historical /// POs for deactivated vendors remain searchable. /// private async Task PopulateVendorFilterDropdownAsync(int companyId) { var vendorEntities = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) .ToList(); vendors.Insert(0, new SelectListItem("All Vendors", "")); ViewBag.VendorList = vendors; } private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) { if (company == null) return (null, null); if (!string.IsNullOrEmpty(company.LogoFilePath)) { var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath); if (ok) return (content, contentType); } return (company.LogoData, company.LogoContentType); } }