d3a5d827f9
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>
430 lines
17 KiB
C#
430 lines
17 KiB
C#
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;
|
||
}
|
||
}
|