a8fb56e8ec
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>
866 lines
37 KiB
C#
866 lines
37 KiB
C#
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);
|
||
}
|
||
}
|