Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs
T
spouliot a8fb56e8ec Fix company logo missing from PDFs and add AI photo save logging
When a tenant uploads a logo it is stored in Azure Blob Storage and
LogoData (the legacy DB byte[]) is cleared. All PDF controllers were
still reading the now-null LogoData, so logos never appeared on any
PDF after upload. Fixed by injecting ICompanyLogoService into all six
affected controllers (Quotes, Invoices, Deposits, GiftCertificates,
PurchaseOrders, CatalogItems) and loading the blob-stored logo first
before falling back to the legacy DB field.

Also added structured logging to the AI photo promotion path in
QuotesController Create/Edit POST so upload failures are visible in
production logs instead of silently swallowed.

Added onclick safety net to the Create and Edit quote submit buttons
so dynamically-injected hidden fields (AiPhotoTempIds) are written
before iOS Safari collects the form data on submit.

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

866 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using AutoMapper;
using 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<ApplicationUser> _userManager;
private readonly ILogger<PurchaseOrdersController> _logger;
private readonly IPdfService _pdfService;
private readonly ICompanyLogoService _logoService;
public PurchaseOrdersController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<PurchaseOrdersController> logger,
IPdfService pdfService,
ICompanyLogoService logoService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_pdfService = pdfService;
_logoService = logoService;
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<List<PurchaseOrderListDto>>(items);
var result = new PagedResult<PurchaseOrderListDto>
{
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
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<PurchaseOrderDto>(po);
return View(dto);
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Create
// -----------------------------------------------------------------------
/// <summary>
/// Returns the blank PO creation form with today's date pre-filled. Vendors and inventory
/// items are loaded via <see cref="PopulateCreateViewBagAsync"/> and serialised to JSON so
/// the dynamic line-item UI can look up unit-of-measure and last purchase price without
/// additional round trips.
/// </summary>
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Persists a new purchase order in <c>Draft</c> status. POs start as drafts so purchasing
/// staff can review before formally submitting to the vendor via <see cref="Submit"/>.
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<PurchaseOrder>(dto);
po.PoNumber = poNumber;
po.Status = PurchaseOrderStatus.Draft;
po.CompanyId = currentUser.CompanyId;
foreach (var itemDto in dto.Items)
{
var item = _mapper.Map<PurchaseOrderItem>(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
// -----------------------------------------------------------------------
/// <summary>
/// Returns the edit form for a draft PO. Only <c>Draft</c> 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.
/// </summary>
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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
/// <c>SubTotal</c> and <c>TotalAmount</c> consistent.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<PurchaseOrderItem>(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
// -----------------------------------------------------------------------
/// <summary>
/// Returns the delete confirmation page for a PO. Only <c>Draft</c> or <c>Cancelled</c> POs
/// may be deleted; submitted/received POs have inventory and accounting implications that
/// prevent safe removal.
/// </summary>
public async Task<IActionResult> 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<PurchaseOrderListDto>(po));
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Delete/5
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Advances a PO from <c>Draft</c> to <c>Submitted</c>, 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).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Cancels a PO at any status except <c>Received</c>. 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Returns the goods-receipt form pre-filled with each line item's remaining-to-receive
/// quantity (ordered minus already received). Only <c>Submitted</c> or
/// <c>PartiallyReceived</c> POs can accept receipts — <c>Draft</c> POs have not been sent
/// and <c>Received</c> POs are already complete.
/// </summary>
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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 <c>(existingQty * existingAvgCost + receivedQty * poUnitCost) / newTotalQty</c>
/// so that the running average reflects the blended acquisition cost over multiple purchases.
/// An <see cref="InventoryTransaction"/> of type <c>Purchase</c> 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 <c>PartiallyReceived</c>
/// or <c>Received</c> based on whether all items have been fully received.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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 <c>CompanyInfoDto</c>. PDF generation is delegated to
/// <see cref="IPdfService.GeneratePurchaseOrderPdfAsync"/>.
/// </summary>
public async Task<IActionResult> 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<PurchaseOrderDto>(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
// -----------------------------------------------------------------------
/// <summary>
/// Shortcut that pre-populates a new PO from the low-stock inventory alert list. Only items
/// with a <c>PrimaryVendorId</c> 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 <c>ReorderQuantity</c> (or 1 if not set) and the
/// last purchase price as the default unit cost, saving manual data entry.
/// </summary>
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Generates a sequential PO number in the format <c>PO-YYMM-####</c>. Uses
/// <c>IgnoreQueryFilters()</c> 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.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// Loads Create/Edit form dropdowns: active vendors and active inventory items. Inventory
/// items are serialised to a JSON blob in <c>ViewBag.InventoryItemsJson</c> 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.
/// </summary>
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);
}
/// <summary>
/// Loads the vendor filter dropdown for the Index view. Unlike
/// <see cref="PopulateCreateViewBagAsync"/> this includes inactive vendors so that historical
/// POs for deactivated vendors remain searchable.
/// </summary>
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);
}
}