Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/VendorsController.cs
T
spouliot d3a5d827f9 Phase F: Customer/Vendor Statements, Payment Terms Parser, Tax Rates
F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
    StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
    CustomersController + VendorsController; Statement views + PDF download via
    StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.

F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
    EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
    endpoint on InvoicesController auto-populates Terms + due date on customer select;
    early payment discount notice on Invoice Create.

F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
    IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
    (Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
    AJAX endpoint; Tax Rates in Settings gear menu.

Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:55:22 -04:00

430 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using AutoMapper;
using PowderCoating.Shared.Constants;
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.Vendor;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages vendor (supplier) records: company details, payment terms, preferred-vendor flag, and the
/// default expense account used when AP bills are created for this vendor. Vendors link to inventory
/// items (indicating their source supplier) and purchase orders. Deletion is blocked when live
/// inventory items reference the vendor, protecting referential integrity without resorting to a
/// cascade delete that would silently break historical records.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageVendors)]
public class VendorsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<VendorsController> _logger;
private readonly IFinancialReportService _financialReports;
private readonly ITenantContext _tenantContext;
public VendorsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<VendorsController> logger,
IFinancialReportService financialReports,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_financialReports = financialReports;
_tenantContext = tenantContext;
}
/// <summary>
/// Displays a paginated, sortable, searchable vendor list. Search covers company name, contact
/// name, email, phone, and city so a user can locate a vendor by any of its common identifiers.
/// Inventory items are eagerly loaded solely to compute the per-vendor item count badge shown in
/// the grid; the count explicitly excludes soft-deleted items so only live references are shown.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CompanyName",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search filter
System.Linq.Expressions.Expression<Func<Vendor, bool>>? filter = null;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
filter = s => s.CompanyName.ToLower().Contains(search)
|| (s.ContactName != null && s.ContactName.ToLower().Contains(search))
|| (s.Email != null && s.Email.ToLower().Contains(search))
|| (s.Phone != null && s.Phone.ToLower().Contains(search))
|| (s.City != null && s.City.ToLower().Contains(search));
}
// Build orderBy function
Func<IQueryable<Vendor>, IOrderedQueryable<Vendor>> orderBy = gridRequest.SortColumn switch
{
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.CompanyName) : q.OrderByDescending(s => s.CompanyName),
"ContactName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.ContactName) : q.OrderByDescending(s => s.ContactName),
"Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Phone) : q.OrderByDescending(s => s.Phone),
"Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Email) : q.OrderByDescending(s => s.Email),
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsActive) : q.OrderByDescending(s => s.IsActive),
"IsPreferred" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsPreferred) : q.OrderByDescending(s => s.IsPreferred),
_ => q => q.OrderBy(s => s.CompanyName)
};
// Get paged data with inventory items eager loading for count
var (items, totalCount) = await _unitOfWork.Vendors.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy,
s => s.InventoryItems);
// Map to DTOs
var vendorDtos = items.Select(s => new VendorListDto
{
Id = s.Id,
CompanyName = s.CompanyName,
ContactName = s.ContactName,
Phone = s.Phone,
Email = s.Email,
IsActive = s.IsActive,
IsPreferred = s.IsPreferred,
InventoryItemCount = s.InventoryItems.Count(i => !i.IsDeleted)
}).ToList();
var pagedResult = PagedResult<VendorListDto>.From(gridRequest, vendorDtos, totalCount);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving vendors");
TempData["Error"] = "An error occurred while loading vendors.";
return View(new PagedResult<VendorListDto>());
}
}
/// <summary>
/// Shows full vendor detail including linked inventory items and the human-readable default expense
/// account name. The account name is resolved via a direct <c>_context.Accounts.FindAsync</c>
/// call rather than an eager-load because the Account entity lives outside the standard
/// <c>IUnitOfWork</c> pattern and is only needed for display here.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var vendorDto = _mapper.Map<VendorDto>(vendor);
if (vendor.DefaultExpenseAccountId.HasValue)
{
var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value);
vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} {acct.Name}" : null;
}
return View(vendorDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving vendor {VendorId}", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the Create form with the expense account dropdown pre-populated.
/// When <paramref name="inline"/> is true the layout is stripped so the HTML can be injected
/// into a modal; the form will POST back with the same flag and return JSON instead of a redirect.
/// </summary>
public async Task<IActionResult> Create(bool inline = false)
{
await PopulateExpenseAccountsAsync();
if (inline)
return PartialView(new CreateVendorDto());
return View(new CreateVendorDto());
}
/// <summary>
/// Persists a new vendor, stamping it with the current user's <c>CompanyId</c> so multi-tenant
/// isolation is enforced at creation rather than relying solely on query filters. Redirects to
/// Details on success so the user can immediately verify the saved record.
/// When <paramref name="inline"/> is true (quick-add modal path) returns JSON {success, id, name}
/// instead of a redirect so the caller can add the new option to the originating select element.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateVendorDto dto, bool inline = false)
{
if (!ModelState.IsValid)
{
if (inline)
{
var errors = ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage);
return Json(new { success = false, errors });
}
await PopulateExpenseAccountsAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId;
await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync();
if (inline)
return Json(new { success = true, id = vendor.Id, name = vendor.CompanyName });
TempData["Success"] = $"Vendor '{vendor.CompanyName}' created successfully.";
return RedirectToAction(nameof(Details), new { id = vendor.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating vendor");
if (inline)
return Json(new { success = false, errors = new[] { "An error occurred while saving." } });
TempData["Error"] = "An error occurred while creating the vendor.";
return View(dto);
}
}
/// <summary>
/// Renders the Edit form for an existing vendor.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
if (vendor == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateVendorDto>(vendor);
await PopulateExpenseAccountsAsync();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading vendor {VendorId} for edit", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Applies edits to an existing vendor. Uses AutoMapper's map-onto-existing-entity overload so
/// EF Core's change tracker only marks modified columns dirty, preserving audit timestamps and
/// <c>CompanyId</c> that are not present in the DTO.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateVendorDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
await PopulateExpenseAccountsAsync();
return View(dto);
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
if (vendor == null)
{
return NotFound();
}
_mapper.Map(dto, vendor);
await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Vendor '{vendor.CompanyName}' updated successfully.";
return RedirectToAction(nameof(Details), new { id = vendor.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating vendor {VendorId}", id);
TempData["Error"] = "An error occurred while updating the vendor.";
return View(dto);
}
}
/// <summary>
/// Shows the Delete confirmation page with the vendor's active inventory item count, so the user
/// understands the impact before confirming. The count is surfaced here to avoid a surprise
/// block error on the POST — surfacing the constraint on the GET gives the user an opportunity to
/// reassign items first.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var vendorDto = _mapper.Map<VendorDto>(vendor);
ViewBag.InventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted);
return View(vendorDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading vendor {VendorId} for deletion", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes a vendor after a business-rule guard: deletion is blocked when the vendor is
/// still referenced by one or more active (non-soft-deleted) inventory items. The block is
/// enforced in application code rather than via a DB foreign key constraint because soft deletes
/// make constraint-based blocking unreliable — a DB constraint would fire even on logically
/// deleted items. Soft delete is used so purchase order and AP bill history that references this
/// vendor remains intact.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var inventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted);
if (inventoryItemCount > 0)
{
TempData["Error"] = $"Cannot delete vendor '{vendor.CompanyName}' because it is currently used by {inventoryItemCount} inventory item(s). Please reassign or remove those items first.";
return RedirectToAction(nameof(Delete), new { id });
}
var vendorName = vendor.CompanyName;
await _unitOfWork.Vendors.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Vendor '{vendorName}' has been deleted.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting vendor {VendorId}", id);
TempData["Error"] = "An error occurred while deleting the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
/// <summary>
/// Displays or downloads a dated activity statement for a vendor.
/// </summary>
[HttpGet]
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var toDate = to ?? DateTime.Today;
var dto = await _financialReports.GetVendorStatementAsync(companyId, id, fromDate, toDate);
if (pdf)
{
var bytes = StatementPdfHelper.Generate(
dto.VendorName, dto.CompanyName, null,
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: true);
return File(bytes, "application/pdf", $"Statement-{dto.VendorName}-{toDate:yyyyMMdd}.pdf");
}
return View(dto);
}
/// for the vendor's default expense account dropdown. All three account types are included because
/// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
/// the field is optional — not every vendor needs a default account pre-set.
/// </summary>
private async Task PopulateExpenseAccountsAsync()
{
var accounts = (await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)))
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
accounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.ExpenseAccounts = accounts;
}
}