Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,946 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
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.Infrastructure.Data;
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 ApplicationDbContext _context;
private readonly IPdfService _pdfService;
public PurchaseOrdersController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<PurchaseOrdersController> logger,
ApplicationDbContext context,
IPdfService pdfService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_pdfService = pdfService;
}
// -----------------------------------------------------------------------
// 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 query = _context.Set<PurchaseOrder>()
.Include(po => po.Vendor)
.Include(po => po.Items.Where(i => !i.IsDeleted))
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
.AsQueryable();
if (statusFilter.HasValue)
query = query.Where(po => po.Status == statusFilter.Value);
if (vendorId.HasValue)
query = query.Where(po => po.VendorId == vendorId.Value);
if (dateFrom.HasValue)
query = query.Where(po => po.OrderDate >= dateFrom.Value);
if (dateTo.HasValue)
query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1));
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.Trim().ToLower();
query = query.Where(po =>
po.PoNumber.ToLower().Contains(term) ||
po.Vendor.CompanyName.ToLower().Contains(term) ||
(po.Notes != null && po.Notes.ToLower().Contains(term)));
}
query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch
{
("ponumber", "asc") => query.OrderBy(po => po.PoNumber),
("ponumber", _) => query.OrderByDescending(po => po.PoNumber),
("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName),
("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName),
("status", "asc") => query.OrderBy(po => po.Status),
("status", _) => query.OrderByDescending(po => po.Status),
("orderdate", "asc") => query.OrderBy(po => po.OrderDate),
("orderdate", _) => query.OrderByDescending(po => po.OrderDate),
("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate),
("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate),
("total", "asc") => query.OrderBy(po => po.TotalAmount),
("total", _) => query.OrderByDescending(po => po.TotalAmount),
_ => query.OrderByDescending(po => po.OrderDate)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var dtos = _mapper.Map<List<PurchaseOrderListDto>>(items);
var result = new PagedResult<PurchaseOrderListDto>
{
Items = dtos,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
// Stats
var allForStats = await _context.Set<PurchaseOrder>()
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
.Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate })
.ToListAsync();
ViewBag.TotalCount = allForStats.Count;
ViewBag.OpenCount = allForStats.Count(p =>
p.Status == PurchaseOrderStatus.Draft ||
p.Status == PurchaseOrderStatus.Submitted ||
p.Status == PurchaseOrderStatus.PartiallyReceived);
ViewBag.CommittedValue = allForStats
.Where(p => p.Status != PurchaseOrderStatus.Cancelled)
.Sum(p => p.TotalAmount);
ViewBag.OverdueCount = allForStats.Count(p =>
(p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived)
&& p.ExpectedDeliveryDate.HasValue
&& p.ExpectedDeliveryDate.Value.Date < DateTime.UtcNow.Date);
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 _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Bill)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 _context.Set<InventoryTransaction>().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;
await _context.SaveChangesAsync();
}); // end ExecuteInTransactionAsync
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 _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == 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 pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
dto, company?.LogoData, company?.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 _context.Set<InventoryItem>()
.Include(i => i.PrimaryVendor)
.Where(i => !i.IsDeleted
&& i.IsActive
&& i.CompanyId == currentUser.CompanyId
&& i.PrimaryVendorId != null
&& i.QuantityOnHand <= i.ReorderPoint)
.ToListAsync();
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 existing = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix))
.Select(po => po.PoNumber)
.ToListAsync();
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 vendors = await _context.Set<Vendor>()
.Where(v => !v.IsDeleted && v.CompanyId == companyId && v.IsActive)
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
.ToListAsync();
vendors.Insert(0, new SelectListItem("— Select Vendor —", ""));
ViewBag.Vendors = vendors;
var inventoryItems = await _context.Set<InventoryItem>()
.Where(i => !i.IsDeleted && i.CompanyId == companyId && i.IsActive)
.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
})
.ToListAsync();
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 vendors = await _context.Set<Vendor>()
.Where(v => !v.IsDeleted && v.CompanyId == companyId)
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
.ToListAsync();
vendors.Insert(0, new SelectListItem("All Vendors", ""));
ViewBag.VendorList = vendors;
}
}