Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user