Compare commits
7 Commits
61866e1d1e
...
1229081436
| Author | SHA1 | Date | |
|---|---|---|---|
| 1229081436 | |||
| cf9dcfb4c1 | |||
| a33687f7bd | |||
| 0afb474c3e | |||
| 7e1676cfd7 | |||
| 379b0de885 | |||
| edd7389d7d |
@@ -2,6 +2,72 @@ using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Accounting;
|
||||
|
||||
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
public List<ApAgingVendorDto> Vendors { get; set; } = new();
|
||||
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingVendorDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public List<ApAgingBillDto> Bills { get; set; } = new();
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingBillDto
|
||||
{
|
||||
public int BillId { get; set; }
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class TrialBalanceDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<TrialBalanceLine> Lines { get; set; } = new();
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
|
||||
}
|
||||
|
||||
public class TrialBalanceLine
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
public decimal DebitBalance { get; set; }
|
||||
public decimal CreditBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class ProfitAndLossDto
|
||||
@@ -9,6 +75,7 @@ public class ProfitAndLossDto
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
||||
public decimal TotalRevenue { get; set; }
|
||||
@@ -40,6 +107,7 @@ public class BalanceSheetDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
// Assets
|
||||
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
||||
|
||||
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public IEnumerable<T> Items { get; set; } = new List<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
|
||||
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
|
||||
/// are copied from the grid so the model carries full state for view binding.
|
||||
/// </summary>
|
||||
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = grid.PageNumber,
|
||||
PageSize = grid.PageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = grid.SortColumn,
|
||||
SortDirection = grid.SortDirection,
|
||||
SearchTerm = grid.SearchTerm
|
||||
};
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? TimeZone { get; set; }
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
public bool HasLogo { get; set; }
|
||||
|
||||
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
|
||||
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
|
||||
/// Read-only service for financial aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// The <paramref name="method"/> parameter overrides the company's stored preference when
|
||||
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
|
||||
/// </summary>
|
||||
public interface IFinancialReportService
|
||||
{
|
||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
|
||||
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
|
||||
@@ -23,4 +26,13 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
|
||||
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
|
||||
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IJobItemAssemblyService
|
||||
{
|
||||
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
}
|
||||
@@ -42,6 +42,8 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IQuotePricingAssemblyService
|
||||
{
|
||||
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
|
||||
|
||||
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared file validation and content-type resolution used across all blob storage services.
|
||||
/// </summary>
|
||||
public static class BlobFileHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates an uploaded file against an extension allowlist and a maximum size.
|
||||
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
|
||||
/// </summary>
|
||||
public static (bool IsValid, string Extension, string Error) ValidateUpload(
|
||||
IFormFile? file,
|
||||
string[] allowedExtensions,
|
||||
long maxBytes)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > maxBytes)
|
||||
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
|
||||
|
||||
return (true, extension, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a file extension to its MIME content type, covering common image formats and
|
||||
/// document types. Falls back to <c>application/octet-stream</c>.
|
||||
/// </summary>
|
||||
public static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
|
||||
/// them with underscores to produce a safe blob path segment.
|
||||
/// </summary>
|
||||
public static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
var blobId = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// The correct content type is required so that browsers render the image
|
||||
/// inline rather than triggering a download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Sanitize filename — replace OS-invalid characters with underscores to
|
||||
// prevent path traversal and blob naming errors in Azure.
|
||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
fileName = "manual";
|
||||
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||||
|
||||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||||
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Correct MIME types are required so browsers open PDFs inline and
|
||||
/// Word documents prompt a compatible application rather than a raw download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(pricing);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var firstCoat = source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.FirstOrDefault();
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = source.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c =>
|
||||
{
|
||||
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
|
||||
return BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = appearance.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = appearance.ColorCode,
|
||||
Finish = appearance.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = source.ColorName,
|
||||
ColorCode = source.ColorCode,
|
||||
Finish = source.Finish,
|
||||
SurfaceArea = source.SurfaceArea,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
{
|
||||
JobId = jobId,
|
||||
Description = seed.Description,
|
||||
Quantity = seed.Quantity,
|
||||
ColorName = seed.ColorName,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
SurfaceArea = seed.SurfaceArea,
|
||||
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
|
||||
CatalogItemId = seed.CatalogItemId,
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
UnitPrice = seed.UnitPrice,
|
||||
TotalPrice = seed.TotalPrice,
|
||||
LaborCost = seed.LaborCost,
|
||||
RequiresSandblasting = seed.RequiresSandblasting,
|
||||
RequiresMasking = seed.RequiresMasking,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
Notes = seed.Notes,
|
||||
IncludePrepCost = seed.IncludePrepCost,
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
CoatName = seed.CoatName,
|
||||
Sequence = seed.Sequence,
|
||||
InventoryItemId = seed.InventoryItemId,
|
||||
ColorName = seed.ColorName,
|
||||
VendorId = seed.VendorId,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
|
||||
TransferEfficiency = seed.TransferEfficiency,
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
.Select(seed => new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
PrepServiceId = seed.PrepServiceId,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
BlastSetupId = seed.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
return storedPowderToOrder;
|
||||
|
||||
if (surfaceAreaSqFt <= 0)
|
||||
return null;
|
||||
|
||||
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
|
||||
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
string? finish,
|
||||
InventoryItem? inventoryItem)
|
||||
{
|
||||
if (inventoryItem == null)
|
||||
return (colorName, colorCode, finish);
|
||||
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public decimal Quantity { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal? SurfaceArea { get; init; }
|
||||
public decimal SurfaceAreaSqFt { get; init; }
|
||||
public int? CatalogItemId { get; init; }
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
public decimal TotalPrice { get; init; }
|
||||
public decimal LaborCost { get; init; }
|
||||
public bool RequiresSandblasting { get; init; }
|
||||
public bool RequiresMasking { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool IncludePrepCost { get; init; }
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
public int Sequence { get; init; }
|
||||
public int? InventoryItemId { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public int? VendorId { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal CoverageSqFtPerLb { get; init; }
|
||||
public decimal TransferEfficiency { get; init; }
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public int? BlastSetupId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
|
||||
string? caption = null,
|
||||
JobPhotoType photoType = JobPhotoType.Progress)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
||||
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2357,4 +2357,240 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
|
||||
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
|
||||
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#b91c1c";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||||
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
|
||||
});
|
||||
|
||||
if (!dto.Vendors.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("All bills are paid — no outstanding balances.")
|
||||
.FontSize(11).FontColor("#16a34a");
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
|
||||
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
|
||||
{
|
||||
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
|
||||
|
||||
vendCol.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
|
||||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||||
{
|
||||
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
|
||||
: bill.DaysOverdue <= 30 ? "#ca8a04"
|
||||
: bill.DaysOverdue <= 60 ? "#ea580c"
|
||||
: bill.DaysOverdue <= 90 ? "#dc2626"
|
||||
: "#7f1d1d";
|
||||
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||||
|
||||
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
|
||||
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||||
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
|
||||
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||||
table.Cell().Background("#f1f5f9");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
|
||||
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
|
||||
/// totals and a balanced/unbalanced indicator.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#1a56db";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
|
||||
dto.IsBalanced ? "#16a34a" : "#dc2626");
|
||||
});
|
||||
|
||||
if (!dto.Lines.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("No active accounts with balances found.")
|
||||
.FontSize(11).FontColor(Colors.Grey.Darken1);
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.ConstantColumn(70);
|
||||
cols.RelativeColumn(4);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var line in dto.Lines)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
string userId,
|
||||
int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old photos for this user with different extensions
|
||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||
|
||||
// Blob path mirrors former filesystem path
|
||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
||||
IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var tempId = Guid.NewGuid().ToString("N");
|
||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||
var contentType = GetContentType(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
||||
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
return (false, string.Empty, "Failed to read temp photo.");
|
||||
|
||||
using var ms = new MemoryStream(download.Content);
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
|
||||
if (!upload.Success)
|
||||
return (false, string.Empty, "Failed to save permanent photo.");
|
||||
|
||||
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetContentType(string ext) => ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<QuotePricingAssemblyService> _logger;
|
||||
|
||||
public QuotePricingAssemblyService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPricingCalculationService pricingService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<QuotePricingAssemblyService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_pricingService = pricingService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
|
||||
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, itemPricing);
|
||||
return;
|
||||
}
|
||||
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
return [];
|
||||
|
||||
var coats = new List<QuoteItemCoat>();
|
||||
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
companyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
coats.Add(coat);
|
||||
}
|
||||
|
||||
return coats;
|
||||
}
|
||||
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
return [];
|
||||
|
||||
return itemDto.PrepServices
|
||||
.Select(ps => new QuoteItemPrepService
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes,
|
||||
BlastSetupId = ps.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
IsSalesItem = itemDto.IsSalesItem,
|
||||
Sku = itemDto.Sku,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Notes = itemDto.Notes,
|
||||
Complexity = itemDto.Complexity,
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
{
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
item.TotalPrice = pricing.TotalPrice;
|
||||
item.ItemMaterialCost = pricing.MaterialCost;
|
||||
item.ItemLaborCost = pricing.LaborCost;
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
coatDto.PowderCostPerLb = null;
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -150,9 +154,134 @@ public class Expense : BaseEntity
|
||||
public string? Memo { get; set; }
|
||||
public string? ReceiptFilePath { get; set; }
|
||||
|
||||
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||
public virtual Account PaymentAccount { get; set; } = null!;
|
||||
public virtual Job? Job { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
|
||||
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
|
||||
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
|
||||
/// </summary>
|
||||
public class JournalEntry : BaseEntity
|
||||
{
|
||||
public string EntryNumber { get; set; } = string.Empty;
|
||||
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Reference { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
|
||||
|
||||
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
|
||||
public bool IsReversal { get; set; } = false;
|
||||
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
|
||||
public int? ReversalOfId { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public string? PostedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
|
||||
public virtual JournalEntry? ReversalOf { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
|
||||
/// should be non-zero per line (not both). LineOrder controls display sequence.
|
||||
/// </summary>
|
||||
public class JournalEntryLine : BaseEntity
|
||||
{
|
||||
public int JournalEntryId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
public decimal DebitAmount { get; set; }
|
||||
public decimal CreditAmount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int LineOrder { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bank reconciliation session for a single bank/cash account against a statement.
|
||||
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
|
||||
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
|
||||
/// </summary>
|
||||
public class BankReconciliation : BaseEntity
|
||||
{
|
||||
/// <summary>Must be a bank/cash subtype account.</summary>
|
||||
public int AccountId { get; set; }
|
||||
public DateTime StatementDate { get; set; }
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal EndingBalance { get; set; }
|
||||
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
|
||||
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
|
||||
/// Numbering: VC-YYMM-####
|
||||
/// </summary>
|
||||
public class VendorCredit : BaseEntity
|
||||
{
|
||||
public string CreditNumber { get; set; } = string.Empty;
|
||||
public int VendorId { get; set; }
|
||||
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
|
||||
public int APAccountId { get; set; }
|
||||
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
|
||||
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
|
||||
public decimal Total { get; set; }
|
||||
public decimal RemainingAmount { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
public virtual Account APAccount { get; set; } = null!;
|
||||
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
|
||||
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
|
||||
/// </summary>
|
||||
public class VendorCreditLineItem : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
/// <summary>Expense/COGS account being reversed by this line.</summary>
|
||||
public int? AccountId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Account? Account { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the application of a vendor credit against a specific vendor bill.
|
||||
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
|
||||
/// </summary>
|
||||
public class VendorCreditApplication : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
public int BillId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,13 @@ public class Company : BaseEntity
|
||||
public bool MarketingEmailOptOut { get; set; } = false;
|
||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether financial reports (P&L, Balance Sheet, Cash Flow) use
|
||||
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
|
||||
/// re-posting occurs. Default is Accrual (standard for most businesses).
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
|
||||
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
|
||||
public int? CatalogItemId { get; set; }
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public decimal? ManualUnitPrice { get; set; }
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
|
||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
||||
/// </summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Invoice Invoice { get; set; } = null!;
|
||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||
|
||||
@@ -66,3 +66,41 @@ public enum BillStatus
|
||||
Paid = 3,
|
||||
Voided = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Company-level accounting method preference. Affects how financial reports
|
||||
/// (P&L, Balance Sheet, Cash Flow) query and present data. Switching this
|
||||
/// setting never re-posts historical GL entries — it is a report-time choice only.
|
||||
/// </summary>
|
||||
public enum AccountingMethod
|
||||
{
|
||||
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
|
||||
Cash = 0,
|
||||
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
|
||||
Accrual = 1
|
||||
}
|
||||
|
||||
public enum BankReconciliationStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Completed = 1
|
||||
}
|
||||
|
||||
public enum VendorCreditStatus
|
||||
{
|
||||
Open = 0,
|
||||
PartiallyApplied = 1,
|
||||
Applied = 2,
|
||||
Voided = 3
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||
public enum JournalEntryStatus
|
||||
{
|
||||
/// <summary>Not yet posted — can still be edited or deleted.</summary>
|
||||
Draft = 0,
|
||||
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
|
||||
Posted = 1,
|
||||
/// <summary>A reversal JE has been created and posted for this entry.</summary>
|
||||
Reversed = 2
|
||||
}
|
||||
|
||||
@@ -91,6 +91,18 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<BillPayment> BillPayments { get; }
|
||||
IRepository<Expense> Expenses { get; }
|
||||
|
||||
// Manual Journal Entries
|
||||
IRepository<JournalEntry> JournalEntries { get; }
|
||||
IRepository<JournalEntryLine> JournalEntryLines { get; }
|
||||
|
||||
// Vendor Credits
|
||||
IRepository<VendorCredit> VendorCredits { get; }
|
||||
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
|
||||
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
|
||||
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
@@ -324,6 +324,21 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Expense> Expenses { get; set; }
|
||||
|
||||
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<JournalEntry> JournalEntries { get; set; }
|
||||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||||
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
|
||||
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||||
@@ -614,6 +629,21 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
|
||||
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Bank Reconciliation: tenant-filtered
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -633,6 +663,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(a => a.ParentAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// JournalEntry self-referencing reversal link
|
||||
modelBuilder.Entity<JournalEntry>()
|
||||
.HasOne(je => je.ReversalOf)
|
||||
.WithMany()
|
||||
.HasForeignKey(je => je.ReversalOfId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BankReconciliation → Account (no cascade)
|
||||
modelBuilder.Entity<BankReconciliation>()
|
||||
.HasOne(br => br.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(br => br.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCredit → APAccount (no cascade)
|
||||
modelBuilder.Entity<VendorCredit>()
|
||||
.HasOne(vc => vc.APAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(vc => vc.APAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCreditLineItem → Account (nullable, no cascade)
|
||||
modelBuilder.Entity<VendorCreditLineItem>()
|
||||
.HasOne(li => li.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(li => li.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor → DefaultExpenseAccount (no cascade)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasOne(s => s.DefaultExpenseAccount)
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260510011252_AddJobTemplateItemSalesFields.Designer.cs
Generated
+9552
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobTemplateItemSalesFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountingMethod : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9715
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJournalEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
EntryNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
EntryDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Reference = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
IsReversal = table.Column<bool>(type: "bit", nullable: false),
|
||||
ReversalOfId = table.Column<int>(type: "int", nullable: true),
|
||||
PostedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
PostedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JournalEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntries_JournalEntries_ReversalOfId",
|
||||
column: x => x.ReversalOfId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntryLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
DebitAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CreditAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LineOrder = table.Column<int>(type: "int", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JournalEntryLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntries_ReversalOfId",
|
||||
table: "JournalEntries",
|
||||
column: "ReversalOfId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_AccountId",
|
||||
table: "JournalEntryLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_JournalEntryId",
|
||||
table: "JournalEntryLines",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntryLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntries");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9951
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCredits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCredits",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CreditNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
APAccountId = table.Column<int>(type: "int", nullable: false),
|
||||
CreditDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
RemainingAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Memo = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCredits", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Accounts_APAccountId",
|
||||
column: x => x.APAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
BillId = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
AppliedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCreditApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditLineItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCreditLineItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_AccountId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_VendorCreditId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_APAccountId",
|
||||
table: "VendorCredits",
|
||||
column: "APAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_VendorId",
|
||||
table: "VendorCredits",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditLineItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCredits");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBankReconciliation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Payments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Payments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Expenses",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BankReconciliations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BankReconciliations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BankReconciliations_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BankReconciliations_AccountId",
|
||||
table: "BankReconciliations",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BankReconciliations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,6 +936,69 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BankReconciliation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("BeginningBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CompletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("EndingBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("StatementDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("BankReconciliations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BannedIp", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1149,6 +1212,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("CheckNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1164,6 +1230,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -1546,6 +1615,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountingMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("AccountingOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -2851,6 +2923,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -2876,6 +2951,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -4833,6 +4911,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsLaborItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsSalesItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("JobTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4851,6 +4932,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("RequiresSandblasting")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("SurfaceAreaSqFt")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -5064,6 +5148,132 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("JobTimeEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EntryDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EntryNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsReversal")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("PostedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("PostedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ReversalOfId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReversalOfId");
|
||||
|
||||
b.ToTable("JournalEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntryLine", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("CreditAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("DebitAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("JournalEntryId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("LineOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("JournalEntryId");
|
||||
|
||||
b.ToTable("JournalEntryLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -5640,6 +5850,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5661,6 +5874,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("InvoiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -6071,7 +6287,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6082,7 +6298,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6093,7 +6309,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7711,6 +7927,179 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCredit", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("APAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreditDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreditNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("RemainingAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("APAccountId");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorCredits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCreditApplication", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("AppliedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("BillId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorCreditId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BillId");
|
||||
|
||||
b.HasIndex("VendorCreditId");
|
||||
|
||||
b.ToTable("VendorCreditApplications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCreditLineItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorCreditId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("VendorCreditId");
|
||||
|
||||
b.ToTable("VendorCreditLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
@@ -7843,6 +8232,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Job");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BankReconciliation", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Bill", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "APAccount")
|
||||
@@ -8753,6 +9153,35 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Worker");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "ReversalOf")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReversalOfId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ReversalOf");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntryLine", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("JournalEntryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||
@@ -9287,6 +9716,62 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("DefaultExpenseAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCredit", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "APAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("APAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor")
|
||||
.WithMany()
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("APAccount");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCreditApplication", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
|
||||
.WithMany()
|
||||
.HasForeignKey("BillId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
||||
.WithMany("Applications")
|
||||
.HasForeignKey("VendorCreditId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Bill");
|
||||
|
||||
b.Navigation("VendorCredit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCreditLineItem", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
||||
.WithMany("LineItems")
|
||||
.HasForeignKey("VendorCreditId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("VendorCredit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("BillLineItems");
|
||||
@@ -9473,6 +9958,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("PrepServices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatch", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
@@ -9537,6 +10027,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("InventoryItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.VendorCredit", b =>
|
||||
{
|
||||
b.Navigation("Applications");
|
||||
|
||||
b.Navigation("LineItems");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,18 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<BillPayment>? _billPayments;
|
||||
private IRepository<Expense>? _expenses;
|
||||
|
||||
// Manual Journal Entries
|
||||
private IRepository<JournalEntry>? _journalEntries;
|
||||
private IRepository<JournalEntryLine>? _journalEntryLines;
|
||||
|
||||
// Vendor Credits
|
||||
private IRepository<VendorCredit>? _vendorCredits;
|
||||
private IRepository<VendorCreditLineItem>? _vendorCreditLineItems;
|
||||
private IRepository<VendorCreditApplication>? _vendorCreditApplications;
|
||||
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||
/// The context is shared across all repositories created by this instance so that
|
||||
@@ -513,6 +525,33 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<Expense> Expenses =>
|
||||
_expenses ??= new Repository<Expense>(_context);
|
||||
|
||||
// Manual Journal Entries
|
||||
/// <summary>Repository for <see cref="JournalEntry"/> double-entry manual journal entries; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JournalEntry> JournalEntries =>
|
||||
_journalEntries ??= new Repository<JournalEntry>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JournalEntryLine"/> individual debit/credit lines within a journal entry.</summary>
|
||||
public IRepository<JournalEntryLine> JournalEntryLines =>
|
||||
_journalEntryLines ??= new Repository<JournalEntryLine>(_context);
|
||||
|
||||
// Vendor Credits
|
||||
/// <summary>Repository for <see cref="VendorCredit"/> credit notes received from vendors; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<VendorCredit> VendorCredits =>
|
||||
_vendorCredits ??= new Repository<VendorCredit>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="VendorCreditLineItem"/> expense-reversal lines on a vendor credit.</summary>
|
||||
public IRepository<VendorCreditLineItem> VendorCreditLineItems =>
|
||||
_vendorCreditLineItems ??= new Repository<VendorCreditLineItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="VendorCreditApplication"/> records linking a vendor credit to a specific bill.</summary>
|
||||
public IRepository<VendorCreditApplication> VendorCreditApplications =>
|
||||
_vendorCreditApplications ??= new Repository<VendorCreditApplication>(_context);
|
||||
|
||||
// Bank Reconciliation
|
||||
/// <summary>Repository for <see cref="BankReconciliation"/> sessions reconciling a bank account against a statement.</summary>
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
|
||||
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
|
||||
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
|
||||
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
|
||||
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
|
||||
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
|
||||
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
|
||||
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
|
||||
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
|
||||
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
|
||||
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||
}
|
||||
|
||||
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
|
||||
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
|
||||
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
|
||||
/// decide the direction of the balance adjustment.
|
||||
/// </summary>
|
||||
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for double-entry sign conventions shared by
|
||||
/// <see cref="AccountBalanceService"/> and <see cref="LedgerService"/>.
|
||||
/// Centralised here so that adding a new AccountSubType only requires
|
||||
/// one edit rather than two independently maintained switch expressions.
|
||||
/// </summary>
|
||||
internal static class AccountingRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for sub-types whose normal balance is a debit
|
||||
/// (Assets, COGS, Expenses). Sub-type is used rather than AccountType
|
||||
/// because it is constrained to a known enum set and cannot be
|
||||
/// misconfigured by a user. Expense enum values are ≥ 50 by convention,
|
||||
/// allowing a catch-all range match for any future expense sub-types.
|
||||
/// </summary>
|
||||
internal static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
// Asset subtypes → normal debit balance
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
// COGS → normal debit balance
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -25,68 +25,108 @@ public class FinancialReportService : IFinancialReportService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
|
||||
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// Revenue: InvoiceItems posted to revenue accounts
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId);
|
||||
var isCash = accountingMethod == AccountingMethod.Cash;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber)
|
||||
.ToList();
|
||||
var revenueLines = new List<FinancialReportLine>();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
if (isCash)
|
||||
{
|
||||
// Cash basis: total payments received in period (not split by revenue account)
|
||||
var cashRevenue = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
if (cashRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Accrual basis: revenue = invoice item amounts by invoice date
|
||||
var accrualRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
// COGS & Expenses: direct Expenses + BillLineItems merged per account
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
revenueLines.AddRange(accrualRevenue
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber));
|
||||
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
}
|
||||
|
||||
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
if (isCash)
|
||||
{
|
||||
var cashExpenses = await _context.Expenses
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
foreach (var e in cashExpenses)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
|
||||
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
|
||||
var paidBillLines = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
|
||||
.ToListAsync();
|
||||
foreach (var bp in paidBillLines)
|
||||
{
|
||||
var fraction = bp.Bill.Total == 0 ? 1m : bp.Amount / bp.Bill.Total;
|
||||
foreach (var li in bp.Bill.LineItems.Where(li => li.AccountId != null))
|
||||
expenseAmounts[li.AccountId!.Value] = expenseAmounts.GetValueOrDefault(li.AccountId!.Value) + li.Amount * fraction;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var accrualExpenses = await _context.Expenses
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
foreach (var e in accrualExpenses)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
|
||||
var accrualBillLines = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
foreach (var b in accrualBillLines)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
}
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
@@ -105,23 +145,26 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
return new ProfitAndLossDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
CompanyName = companyName,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
From = from,
|
||||
To = to,
|
||||
CompanyName = companyName,
|
||||
AccountingMethod = accountingMethod,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
|
||||
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId);
|
||||
var isCash = accountingMethod == AccountingMethod.Cash;
|
||||
|
||||
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
||||
|
||||
@@ -232,10 +275,17 @@ public class FinancialReportService : IFinancialReportService
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
|
||||
// Cash basis: AR and AP have no meaning (no receivables/payables concept)
|
||||
var currentAssets = assetAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset
|
||||
|| (!isCash && a.AccountSubType == AccountSubType.AccountsReceivable))
|
||||
.Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability
|
||||
|| (!isCash && a.AccountSubType == AccountSubType.AccountsPayable))
|
||||
.Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
@@ -247,6 +297,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
AccountingMethod = accountingMethod,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
@@ -518,6 +569,150 @@ public class FinancialReportService : IFinancialReportService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var openBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => b.CompanyId == companyId
|
||||
&& b.Status != BillStatus.Draft
|
||||
&& b.Status != BillStatus.Voided
|
||||
&& b.Status != BillStatus.Paid
|
||||
&& b.BillDate <= asOfEnd
|
||||
&& b.BalanceDue > 0)
|
||||
.OrderBy(b => b.Vendor!.CompanyName)
|
||||
.ThenBy(b => b.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
static string AgingBucket(int d) => d switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var vendorDtos = new List<ApAgingVendorDto>();
|
||||
|
||||
foreach (var grp in openBills.GroupBy(b => new { b.VendorId, b.Vendor!.CompanyName }))
|
||||
{
|
||||
var vendDto = new ApAgingVendorDto
|
||||
{
|
||||
VendorId = grp.Key.VendorId,
|
||||
VendorName = grp.Key.CompanyName
|
||||
};
|
||||
|
||||
foreach (var bill in grp)
|
||||
{
|
||||
var balance = bill.BalanceDue;
|
||||
var daysOverdue = bill.DueDate.HasValue
|
||||
? (int)(asOf - bill.DueDate.Value.Date).TotalDays
|
||||
: 0;
|
||||
|
||||
vendDto.Bills.Add(new ApAgingBillDto
|
||||
{
|
||||
BillId = bill.Id,
|
||||
BillNumber = bill.BillNumber,
|
||||
BillDate = bill.BillDate,
|
||||
DueDate = bill.DueDate,
|
||||
BalanceDue = balance,
|
||||
DaysOverdue = daysOverdue
|
||||
});
|
||||
|
||||
switch (AgingBucket(daysOverdue))
|
||||
{
|
||||
case "current": vendDto.TotalCurrent += balance; break;
|
||||
case "1-30": vendDto.Total1to30 += balance; break;
|
||||
case "31-60": vendDto.Total31to60 += balance; break;
|
||||
case "61-90": vendDto.Total61to90 += balance; break;
|
||||
default: vendDto.TotalOver90 += balance; break;
|
||||
}
|
||||
}
|
||||
|
||||
vendorDtos.Add(vendDto);
|
||||
}
|
||||
|
||||
var sorted = vendorDtos.OrderByDescending(v => v.TotalBalance).ToList();
|
||||
|
||||
return new ApAgingReportDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
Vendors = sorted,
|
||||
TotalCurrent = sorted.Sum(v => v.TotalCurrent),
|
||||
Total1to30 = sorted.Sum(v => v.Total1to30),
|
||||
Total31to60 = sorted.Sum(v => v.Total31to60),
|
||||
Total61to90 = sorted.Sum(v => v.Total61to90),
|
||||
TotalOver90 = sorted.Sum(v => v.TotalOver90),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
var lines = new List<TrialBalanceLine>();
|
||||
|
||||
foreach (var acct in accounts)
|
||||
{
|
||||
if (acct.CurrentBalance == 0) continue;
|
||||
|
||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||
var line = new TrialBalanceLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
AccountNumber = acct.AccountNumber,
|
||||
AccountName = acct.Name,
|
||||
AccountType = acct.AccountType
|
||||
};
|
||||
|
||||
if (isDebitNormal)
|
||||
{
|
||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
||||
else line.CreditBalance = -acct.CurrentBalance;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
||||
else line.DebitBalance = -acct.CurrentBalance;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return new TrialBalanceDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
Lines = lines,
|
||||
TotalDebits = lines.Sum(l => l.DebitBalance),
|
||||
TotalCredits = lines.Sum(l => l.CreditBalance),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId)
|
||||
{
|
||||
if (companyId <= 0) return AccountingMethod.Accrual;
|
||||
var method = await _context.Companies
|
||||
.Where(c => c.Id == companyId)
|
||||
.Select(c => (AccountingMethod?)c.AccountingMethod)
|
||||
.FirstOrDefaultAsync();
|
||||
return method ?? AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||
/// Falls back to "Your Company" if the record is not found.
|
||||
|
||||
@@ -298,6 +298,28 @@ public class LedgerService : ILedgerService
|
||||
});
|
||||
}
|
||||
|
||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||
var jeLines = await _context.JournalEntryLines
|
||||
.Include(l => l.JournalEntry)
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate >= fromDate
|
||||
&& l.JournalEntry.EntryDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var line in jeLines)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = line.JournalEntry.EntryDate,
|
||||
Reference = line.JournalEntry.EntryNumber,
|
||||
Source = "Journal Entry",
|
||||
Description = line.Description ?? line.JournalEntry.Description,
|
||||
Debit = line.DebitAmount,
|
||||
Credit = line.CreditAmount,
|
||||
LinkController = "JournalEntries",
|
||||
LinkId = line.JournalEntry.Id
|
||||
});
|
||||
|
||||
// ── Sort and compute running balance ──────────────────────────────────
|
||||
entries = entries
|
||||
.OrderBy(e => e.Date)
|
||||
@@ -306,7 +328,7 @@ public class LedgerService : ILedgerService
|
||||
|
||||
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
|
||||
// since users could misconfigure AccountType while SubType is picked from a constrained list).
|
||||
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType);
|
||||
bool normalDebitBalance = AccountingRules.IsNormalDebitBalance(account.AccountSubType);
|
||||
|
||||
// Compute the balance before the selected period
|
||||
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
|
||||
@@ -338,36 +360,6 @@ public class LedgerService : ILedgerService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
|
||||
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
|
||||
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
|
||||
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
|
||||
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
|
||||
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
|
||||
/// </summary>
|
||||
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
// Asset subtypes → normal debit balance
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
// COGS → normal debit balance
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
|
||||
/// by summing all activity prior to that date across every transaction source and adding
|
||||
@@ -375,7 +367,7 @@ public class LedgerService : ILedgerService
|
||||
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
|
||||
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
|
||||
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
|
||||
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
|
||||
/// The sign convention follows <see cref="AccountingRules.IsNormalDebitBalance"/>: debits increase debit-normal
|
||||
/// accounts and credits increase credit-normal accounts.
|
||||
/// </summary>
|
||||
private async Task<decimal> ComputePriorBalanceAsync(
|
||||
@@ -459,6 +451,19 @@ public class LedgerService : ILedgerService
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 10. Posted journal entry lines touching this account (prior to period)
|
||||
debits += await _context.JournalEntryLines
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate < beforeDate)
|
||||
.SumAsync(l => (decimal?)l.DebitAmount) ?? 0;
|
||||
|
||||
credits += await _context.JournalEntryLines
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate < beforeDate)
|
||||
.SumAsync(l => (decimal?)l.CreditAmount) ?? 0;
|
||||
|
||||
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
|
||||
|
||||
// Apply the opening balance if it was established on or before the end of the viewed period.
|
||||
|
||||
@@ -134,4 +134,42 @@ public static class AppConstants
|
||||
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
|
||||
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String codes stored in the JobStatusLookup and QuoteStatusLookup tables.
|
||||
/// Using constants here means a DB code rename only requires one code change,
|
||||
/// not a grep-and-replace across every controller.
|
||||
/// </summary>
|
||||
public static class StatusCodes
|
||||
{
|
||||
public static class Job
|
||||
{
|
||||
public const string Pending = "PENDING";
|
||||
public const string Quoted = "QUOTED";
|
||||
public const string Approved = "APPROVED";
|
||||
public const string InPreparation = "IN_PREPARATION";
|
||||
public const string Sandblasting = "SANDBLASTING";
|
||||
public const string MaskingTaping = "MASKING_TAPING";
|
||||
public const string Cleaning = "CLEANING";
|
||||
public const string InOven = "IN_OVEN";
|
||||
public const string Coating = "COATING";
|
||||
public const string Curing = "CURING";
|
||||
public const string QualityCheck = "QUALITY_CHECK";
|
||||
public const string Completed = "COMPLETED";
|
||||
public const string ReadyForPickup = "READY_FOR_PICKUP";
|
||||
public const string Delivered = "DELIVERED";
|
||||
public const string OnHold = "ON_HOLD";
|
||||
public const string Cancelled = "CANCELLED";
|
||||
}
|
||||
|
||||
public static class Quote
|
||||
{
|
||||
public const string Draft = "DRAFT";
|
||||
public const string Sent = "SENT";
|
||||
public const string Approved = "APPROVED";
|
||||
public const string Rejected = "REJECTED";
|
||||
public const string Converted = "CONVERTED";
|
||||
public const string Expired = "EXPIRED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OfficeOpenXml;
|
||||
using OfficeOpenXml.Style;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Drawing;
|
||||
@@ -207,6 +208,81 @@ public class AccountDataExportController : Controller
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
// ── Data fetchers (single query per entity, superset of includes for both XLSX + CSV) ─────
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted customers for the company, including <c>PricingTier</c> (needed by
|
||||
/// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
|
||||
/// <c>IgnoreQueryFilters</c> because <c>ITenantContext</c> may be null for expired accounts.
|
||||
/// </summary>
|
||||
private Task<List<Customer>> FetchCustomersAsync(int companyId) =>
|
||||
_db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(c => c.PricingTier)
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.</summary>
|
||||
private Task<List<Job>> FetchJobsAsync(int companyId) =>
|
||||
_db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted quotes with Customer and QuoteStatus included.
|
||||
/// The XLSX path only needs QuoteStatus; the CSV path also uses Customer — the superset here
|
||||
/// avoids a second query when both formats include this sheet in the same request.
|
||||
/// </summary>
|
||||
private Task<List<Quote>> FetchQuotesAsync(int companyId) =>
|
||||
_db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(q => q.Customer).Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted invoices with Customer included.</summary>
|
||||
private Task<List<Invoice>> FetchInvoicesAsync(int companyId) =>
|
||||
_db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted inventory items with PrimaryVendor and InventoryCategory included.
|
||||
/// Only the CSV path uses these navigations; XLSX reads only scalar fields but the join is cheap.
|
||||
/// </summary>
|
||||
private Task<List<InventoryItem>> FetchInventoryAsync(int companyId) =>
|
||||
_db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.PrimaryVendor).Include(i => i.InventoryCategory)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.OrderBy(i => i.Name).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted equipment records for the company.</summary>
|
||||
private Task<List<Equipment>> FetchEquipmentAsync(int companyId) =>
|
||||
_db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
|
||||
.OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted vendors for the company.</summary>
|
||||
private Task<List<Vendor>> FetchVendorsAsync(int companyId) =>
|
||||
_db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||
/// </summary>
|
||||
private Task<List<ApplicationUser>> FetchUsersAsync(int companyId) =>
|
||||
_db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId)
|
||||
.OrderBy(u => u.LastName).ToListAsync();
|
||||
|
||||
// ── Sheet builders ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -226,16 +302,9 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
|
||||
/// authenticated user's company. <c>IgnoreQueryFilters()</c> bypasses the global EF
|
||||
/// multi-tenancy filter (which relies on <c>ITenantContext</c>) in favour of the explicit
|
||||
/// <c>CompanyId == companyId</c> predicate, making the filter independent of middleware state.
|
||||
/// </summary>
|
||||
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Customers");
|
||||
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
|
||||
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
|
||||
@@ -256,18 +325,13 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Jobs" worksheet with one row per non-deleted job belonging to the company.
|
||||
/// Job status and priority are lookup-table entities (not enums) stored in
|
||||
/// <c>JobStatusLookup</c> and a parallel priority table; they are eagerly loaded so their
|
||||
/// <c>DisplayName</c> property is available without additional queries.
|
||||
/// If a lookup navigation is null (data anomaly), the raw FK integer is written as a fallback.
|
||||
/// Adds a "Jobs" worksheet. Job status and priority are lookup-table entities (not enums);
|
||||
/// they are eagerly loaded by <see cref="FetchJobsAsync"/> so their <c>DisplayName</c> is
|
||||
/// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
|
||||
/// </summary>
|
||||
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Jobs");
|
||||
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
|
||||
"Description", "Due Date", "Final Price", "Created At" };
|
||||
@@ -289,16 +353,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Quotes" worksheet with one row per non-deleted quote belonging to the company.
|
||||
/// Prospect-only quotes (before they are linked to a customer record) show
|
||||
/// <c>ProspectCompanyName</c>; fully linked quotes fall back to the customer FK integer when
|
||||
/// the navigation cannot be resolved — ensuring no row has a blank identifier column.
|
||||
/// Adds a "Quotes" worksheet. Prospect-only quotes show <c>ProspectCompanyName</c>;
|
||||
/// fully linked quotes fall back to <c>Customer #{id}</c> when the navigation is null.
|
||||
/// </summary>
|
||||
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Quotes");
|
||||
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
|
||||
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
|
||||
@@ -317,16 +377,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company.
|
||||
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) reflecting partial
|
||||
/// payment state without an additional aggregation query.
|
||||
/// Eagerly loads <c>Customer</c> so the customer name is available for the display column.
|
||||
/// Adds an "Invoices" worksheet. <c>BalanceDue</c> is a computed property on the entity
|
||||
/// (<c>Total - AmountPaid</c>) so no extra aggregation query is needed.
|
||||
/// </summary>
|
||||
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Invoices");
|
||||
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
|
||||
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
@@ -348,15 +404,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the company.
|
||||
/// Items are ordered alphabetically so the exported list matches the order users typically
|
||||
/// see in the application's inventory index view.
|
||||
/// </summary>
|
||||
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
|
||||
var data = await FetchInventoryAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Inventory");
|
||||
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
|
||||
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
|
||||
@@ -373,14 +423,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the company.
|
||||
/// Equipment status is stored as an enum and serialised via <c>ToString()</c> for a readable label.
|
||||
/// </summary>
|
||||
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
var data = await FetchEquipmentAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Equipment");
|
||||
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
|
||||
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
|
||||
@@ -398,13 +443,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
|
||||
/// </summary>
|
||||
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
|
||||
var data = await FetchVendorsAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Vendors");
|
||||
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -421,13 +462,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
|
||||
/// </summary>
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -443,16 +480,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Users" worksheet with one row per user belonging to the company.
|
||||
/// The <c>IsDeleted</c> predicate is intentionally omitted because ASP.NET Identity users
|
||||
/// use <c>IsActive = false</c> as their soft-deletion mechanism, not the base-entity
|
||||
/// <c>IsDeleted</c> flag. All users (active and inactive) are included so the export
|
||||
/// provides a complete workforce record for compliance and audit purposes.
|
||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||
/// </summary>
|
||||
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
|
||||
var data = await FetchUsersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Users");
|
||||
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -472,15 +505,12 @@ public class AccountDataExportController : Controller
|
||||
// ── CSV builders ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds the customers CSV string for the company.
|
||||
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be re-imported
|
||||
/// Column names match <c>CustomerImportDto</c> exactly so the file can be re-imported
|
||||
/// via Tools → Bulk Import without any manual header editing.
|
||||
/// </summary>
|
||||
private async Task<string> BuildCustomersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(c => c.PricingTier)
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
|
||||
foreach (var c in data)
|
||||
@@ -492,16 +522,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the jobs CSV string for the company.
|
||||
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported.
|
||||
/// CustomerEmail is included (not the display name) because the importer resolves the customer FK by email.
|
||||
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
|
||||
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
|
||||
/// </summary>
|
||||
private async Task<string> BuildJobsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
foreach (var j in data)
|
||||
@@ -514,15 +540,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the quotes CSV string for the company.
|
||||
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>QuoteImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildQuotesCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
foreach (var q in data)
|
||||
@@ -536,15 +557,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the invoices CSV string for the company, ordered newest-first.
|
||||
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with
|
||||
/// first+last name concatenation as the fallback for non-commercial customers.
|
||||
/// </summary>
|
||||
private async Task<string> BuildInvoicesCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
foreach (var inv in data)
|
||||
@@ -557,16 +575,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inventory CSV string for the company.
|
||||
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>InventoryItemImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildInventoryCsv(int companyId)
|
||||
{
|
||||
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.PrimaryVendor)
|
||||
.Include(i => i.InventoryCategory)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
|
||||
var data = await FetchInventoryAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
|
||||
foreach (var i in data)
|
||||
@@ -577,14 +589,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the equipment CSV string for the company.
|
||||
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>EquipmentImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildEquipmentCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
var data = await FetchEquipmentAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
|
||||
foreach (var e in data)
|
||||
@@ -592,14 +600,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the vendors CSV string for the company.
|
||||
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>VendorImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildVendorsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
|
||||
var data = await FetchVendorsAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
|
||||
foreach (var s in data)
|
||||
@@ -607,14 +611,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shop workers CSV string for the company.
|
||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
@@ -623,15 +623,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the users CSV string for the company.
|
||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> predicate is omitted because
|
||||
/// Identity users use <c>IsActive</c> for soft-deletion; all users are exported for
|
||||
/// completeness and compliance.
|
||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||
/// </summary>
|
||||
private async Task<string> BuildUsersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
|
||||
var data = await FetchUsersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
|
||||
foreach (var u in data)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -102,7 +102,7 @@ public class AiQuickQuoteController : Controller
|
||||
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
|
||||
|
||||
// Draft status — nullable FK, gracefully absent if lookup not seeded
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -117,14 +117,7 @@ public class AppointmentsController : Controller
|
||||
// Map to DTOs
|
||||
var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<AppointmentListDto>
|
||||
{
|
||||
Items = appointmentDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<AppointmentListDto>.From(gridRequest, appointmentDtos, totalCount);
|
||||
|
||||
// Set ViewBag
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -550,7 +543,7 @@ public class AppointmentsController : Controller
|
||||
j => j.Customer,
|
||||
j => j.JobStatus);
|
||||
|
||||
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
|
||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||
var jobsInRange = allJobs.Where(j =>
|
||||
!terminalCodes.Contains(j.JobStatus.StatusCode) &&
|
||||
((j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date >= start.Date && j.ScheduledDate.Value.Date <= end.Date) ||
|
||||
@@ -625,16 +618,16 @@ public class AppointmentsController : Controller
|
||||
|
||||
return statusCode switch
|
||||
{
|
||||
"PENDING" or "QUOTED" => "#6c757d", // Gray
|
||||
AppConstants.StatusCodes.Job.Pending or "QUOTED" => "#6c757d", // Gray
|
||||
"APPROVED" => "#0dcaf0", // Cyan
|
||||
"IN_PREPARATION" or "SANDBLASTING" or
|
||||
"MASKING_TAPING" or "CLEANING" => "#0d6efd", // Blue
|
||||
"IN_OVEN" or "CURING" => "#fd7e14", // Orange
|
||||
"COATING" => "#6610f2", // Indigo
|
||||
"QUALITY_CHECK" => "#20c997", // Teal
|
||||
"COMPLETED" or "DELIVERED" or "READY_FOR_PICKUP" => "#198754", // Green
|
||||
"ON_HOLD" => "#ffc107", // Yellow
|
||||
"CANCELLED" => "#adb5bd", // Light gray
|
||||
AppConstants.StatusCodes.Job.InPreparation or AppConstants.StatusCodes.Job.Sandblasting or
|
||||
AppConstants.StatusCodes.Job.MaskingTaping or AppConstants.StatusCodes.Job.Cleaning => "#0d6efd", // Blue
|
||||
AppConstants.StatusCodes.Job.InOven or AppConstants.StatusCodes.Job.Curing => "#fd7e14", // Orange
|
||||
AppConstants.StatusCodes.Job.Coating => "#6610f2", // Indigo
|
||||
AppConstants.StatusCodes.Job.QualityCheck => "#20c997", // Teal
|
||||
AppConstants.StatusCodes.Job.Completed or AppConstants.StatusCodes.Job.Delivered or AppConstants.StatusCodes.Job.ReadyForPickup => "#198754", // Green
|
||||
AppConstants.StatusCodes.Job.OnHold => "#ffc107", // Yellow
|
||||
AppConstants.StatusCodes.Job.Cancelled => "#adb5bd", // Light gray
|
||||
_ => "#0d6efd"
|
||||
};
|
||||
}
|
||||
@@ -752,7 +745,7 @@ public class AppointmentsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
|
||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
||||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||||
|
||||
@@ -876,27 +869,18 @@ public class AppointmentsController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateAppointmentNumberAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var prefix = $"APT-{now:yyMM}-";
|
||||
|
||||
// Get all appointments for current month (including soft-deleted)
|
||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(ignoreQueryFilters: true);
|
||||
|
||||
var monthAppointments = allAppointments
|
||||
.Where(a => a.AppointmentNumber.StartsWith(prefix))
|
||||
var prefix = $"APT-{DateTime.UtcNow:yyMM}-";
|
||||
var last = (await _unitOfWork.Appointments.FindAsync(
|
||||
a => a.AppointmentNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(a => a.AppointmentNumber)
|
||||
.ToList();
|
||||
.Select(a => a.AppointmentNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
var lastNumber = 0;
|
||||
if (monthAppointments.Any())
|
||||
{
|
||||
var lastAppointmentNumber = monthAppointments.First().AppointmentNumber;
|
||||
var numberPart = lastAppointmentNumber.Split('-').Last();
|
||||
int.TryParse(numberPart, out lastNumber);
|
||||
}
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
next = num + 1;
|
||||
|
||||
var newNumber = lastNumber + 1;
|
||||
return $"{prefix}{newNumber:D4}";
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||
public class BankReconciliationsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public BankReconciliationsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
private bool AllowAccounting() =>
|
||||
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists all reconciliation sessions for the company, newest first.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var all = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId,
|
||||
false,
|
||||
br => br.Account))
|
||||
.OrderByDescending(br => br.StatementDate)
|
||||
.ThenByDescending(br => br.Id)
|
||||
.ToList();
|
||||
|
||||
return View(all);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
await PopulateAccountDropdownAsync();
|
||||
return View(new BankReconciliation { StatementDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BankReconciliation model)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Set beginning balance from last completed reconciliation for this account, or 0
|
||||
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId
|
||||
&& br.AccountId == model.AccountId
|
||||
&& br.Status == BankReconciliationStatus.Completed))
|
||||
.OrderByDescending(br => br.StatementDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
model.BeginningBalance = lastCompleted?.EndingBalance ?? 0;
|
||||
model.Status = BankReconciliationStatus.InProgress;
|
||||
|
||||
await _unitOfWork.BankReconciliations.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Reconciliation started.";
|
||||
return RedirectToAction(nameof(Reconcile), new { id = model.Id });
|
||||
}
|
||||
|
||||
// ── Reconcile (Working View) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Main working view. Shows all uncleared transactions for the account up to StatementDate
|
||||
/// in two sections (deposits/credits and payments/debits) with checkboxes.
|
||||
/// Running cleared balance and difference update via JS as the user checks items.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Reconcile(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == id,
|
||||
false,
|
||||
br => br.Account))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (recon == null) return NotFound();
|
||||
if (recon.Status == BankReconciliationStatus.Completed)
|
||||
return RedirectToAction(nameof(Report), new { id });
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
var statementDate = recon.StatementDate;
|
||||
|
||||
// Customer payments deposited to this account
|
||||
var deposits = (await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate))
|
||||
.Select(p => new ReconciliationItem
|
||||
{
|
||||
EntityType = "Payment",
|
||||
EntityId = p.Id,
|
||||
Date = p.PaymentDate,
|
||||
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||
Description = $"Payment #{p.InvoiceId}",
|
||||
Amount = p.Amount,
|
||||
IsCleared = p.IsCleared
|
||||
}).ToList();
|
||||
|
||||
// Bill payments out of this account (debits — shown as negative in deposits)
|
||||
var billPayments = (await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate))
|
||||
.Select(bp => new ReconciliationItem
|
||||
{
|
||||
EntityType = "BillPayment",
|
||||
EntityId = bp.Id,
|
||||
Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber,
|
||||
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||
Amount = bp.Amount,
|
||||
IsCleared = bp.IsCleared
|
||||
}).ToList();
|
||||
|
||||
// Direct expenses out of this account
|
||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.Date <= statementDate))
|
||||
.Select(e => new ReconciliationItem
|
||||
{
|
||||
EntityType = "Expense",
|
||||
EntityId = e.Id,
|
||||
Date = e.Date,
|
||||
Reference = e.ExpenseNumber,
|
||||
Description = e.Memo ?? string.Empty,
|
||||
Amount = e.Amount,
|
||||
IsCleared = e.IsCleared
|
||||
}).ToList();
|
||||
|
||||
ViewBag.Recon = recon;
|
||||
ViewBag.Deposits = deposits;
|
||||
ViewBag.Payments = billPayments.Concat(expenses).OrderBy(p => p.Date).ToList();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
// ── ToggleCleared (AJAX) ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint. Marks a Payment, BillPayment, or Expense as cleared/uncleared.
|
||||
/// Returns updated running totals as JSON.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleCleared(
|
||||
int reconId, string entityType, int entityId, bool isCleared)
|
||||
{
|
||||
if (!AllowAccounting()) return Forbid();
|
||||
|
||||
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(reconId);
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var now = isCleared ? DateTime.UtcNow : (DateTime?)null;
|
||||
|
||||
switch (entityType)
|
||||
{
|
||||
case "Payment":
|
||||
var payment = await _unitOfWork.Payments.GetByIdAsync(entityId);
|
||||
if (payment != null) { payment.IsCleared = isCleared; payment.ClearedDate = now; }
|
||||
break;
|
||||
case "BillPayment":
|
||||
var bp = await _unitOfWork.BillPayments.GetByIdAsync(entityId);
|
||||
if (bp != null) { bp.IsCleared = isCleared; bp.ClearedDate = now; }
|
||||
break;
|
||||
case "Expense":
|
||||
var exp = await _unitOfWork.Expenses.GetByIdAsync(entityId);
|
||||
if (exp != null) { exp.IsCleared = isCleared; exp.ClearedDate = now; }
|
||||
break;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// ── Complete ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Complete(int id, decimal difference)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
if (Math.Abs(difference) > 0.005m)
|
||||
{
|
||||
TempData["Error"] = $"Cannot complete: difference is {difference:C}. Must be $0.00.";
|
||||
return RedirectToAction(nameof(Reconcile), new { id });
|
||||
}
|
||||
|
||||
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(id);
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
recon.Status = BankReconciliationStatus.Completed;
|
||||
recon.CompletedAt = DateTime.UtcNow;
|
||||
recon.CompletedBy = User.Identity?.Name;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Reconciliation completed.";
|
||||
return RedirectToAction(nameof(Report), new { id });
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Printable view of a completed reconciliation.</summary>
|
||||
public async Task<IActionResult> Report(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == id,
|
||||
false,
|
||||
br => br.Account))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
|
||||
var clearedDeposits = (await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.IsCleared && p.PaymentDate <= recon.StatementDate))
|
||||
.ToList();
|
||||
|
||||
var clearedPayments = new List<ReconciliationItem>();
|
||||
(await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.IsCleared && bp.PaymentDate <= recon.StatementDate))
|
||||
.ToList()
|
||||
.ForEach(bp => clearedPayments.Add(new ReconciliationItem
|
||||
{
|
||||
EntityType = "BillPayment", EntityId = bp.Id, Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber, Amount = bp.Amount, IsCleared = true
|
||||
}));
|
||||
(await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.IsCleared && e.Date <= recon.StatementDate))
|
||||
.ToList()
|
||||
.ForEach(e => clearedPayments.Add(new ReconciliationItem
|
||||
{
|
||||
EntityType = "Expense", EntityId = e.Id, Date = e.Date,
|
||||
Reference = e.ExpenseNumber, Amount = e.Amount, IsCleared = true
|
||||
}));
|
||||
|
||||
ViewBag.ClearedDeposits = clearedDeposits;
|
||||
ViewBag.ClearedPayments = clearedPayments.OrderBy(p => p.Date).ToList();
|
||||
|
||||
return View(recon);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive
|
||||
&& (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Savings
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
|
||||
ViewBag.AccountSelectList = accounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
{
|
||||
Value = a.Id.ToString(),
|
||||
Text = $"{a.AccountNumber} – {a.Name}"
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>View model for a single reconcileable transaction row.</summary>
|
||||
public class ReconciliationItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsCleared { get; set; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -15,6 +16,7 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -319,83 +321,90 @@ public class BillsController : Controller
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
Bill? bill = null;
|
||||
|
||||
var bill = _mapper.Map<Bill>(dto);
|
||||
bill.BillNumber = await GenerateBillNumberAsync();
|
||||
bill.Status = BillStatus.Open;
|
||||
bill.CompanyId = currentUser!.CompanyId;
|
||||
bill.CreatedBy = currentUser.Email;
|
||||
|
||||
// Calculate financials
|
||||
int order = 0;
|
||||
foreach (var li in bill.LineItems)
|
||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||
// atomically so a payNow failure cannot leave a bill with no payment record.
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
|
||||
li.DisplayOrder = order++;
|
||||
li.CompanyId = currentUser.CompanyId;
|
||||
}
|
||||
bill = _mapper.Map<Bill>(dto);
|
||||
bill.BillNumber = await GenerateBillNumberAsync();
|
||||
bill.Status = BillStatus.Open;
|
||||
bill.CompanyId = currentUser!.CompanyId;
|
||||
bill.CreatedBy = currentUser.Email;
|
||||
|
||||
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
// Calculate financials
|
||||
int order = 0;
|
||||
foreach (var li in bill.LineItems)
|
||||
{
|
||||
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
|
||||
li.DisplayOrder = order++;
|
||||
li.CompanyId = currentUser.CompanyId;
|
||||
}
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
// Attach receipt file if provided
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get bill.Id
|
||||
|
||||
// Link bill back to source PO
|
||||
if (dto.PurchaseOrderId > 0)
|
||||
{
|
||||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||||
if (po != null)
|
||||
{
|
||||
po.BillId = bill.Id;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Record payment immediately if "already paid" was checked
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
|
||||
{
|
||||
var payment = new BillPayment
|
||||
{
|
||||
BillId = bill.Id,
|
||||
VendorId = bill.VendorId,
|
||||
PaymentNumber = await GeneratePaymentNumberAsync(),
|
||||
PaymentDate = paymentDate ?? DateTime.Today,
|
||||
Amount = bill.Total,
|
||||
PaymentMethod = (PaymentMethod)paymentMethod.Value,
|
||||
BankAccountId = bankAccountId.Value,
|
||||
CheckNumber = checkNumber,
|
||||
Memo = paymentMemo,
|
||||
CompanyId = bill.CompanyId,
|
||||
CreatedBy = currentUser.Email
|
||||
};
|
||||
|
||||
bill.AmountPaid = payment.Amount;
|
||||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
// Receipt upload after the transaction commits — bill.Id is set and core data
|
||||
// is secure. A blob failure here leaves the bill intact without an attachment.
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
if (IsValidReceiptFile(receiptFile, out var fileError))
|
||||
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||||
else
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// Link bill back to source PO if created from one
|
||||
if (dto.PurchaseOrderId > 0)
|
||||
{
|
||||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||||
if (po != null)
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (receiptValid)
|
||||
{
|
||||
po.BillId = bill.Id;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
else
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||||
}
|
||||
|
||||
// Record payment immediately if "already paid" was checked
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
|
||||
{
|
||||
var payment = new BillPayment
|
||||
{
|
||||
BillId = bill.Id,
|
||||
VendorId = bill.VendorId,
|
||||
PaymentNumber = await GeneratePaymentNumberAsync(),
|
||||
PaymentDate = paymentDate ?? DateTime.Today,
|
||||
Amount = bill.Total,
|
||||
PaymentMethod = (PaymentMethod)paymentMethod.Value,
|
||||
BankAccountId = bankAccountId.Value,
|
||||
CheckNumber = checkNumber,
|
||||
Memo = paymentMemo,
|
||||
CompanyId = bill.CompanyId,
|
||||
CreatedBy = currentUser.Email
|
||||
};
|
||||
|
||||
bill.AmountPaid = payment.Amount;
|
||||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||||
|
||||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} created.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = bill.Id });
|
||||
TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
|
||||
? $"Bill {bill!.BillNumber} saved and marked as paid."
|
||||
: $"Bill {bill!.BillNumber} created.";
|
||||
return RedirectToAction(nameof(Details), new { id = bill!.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -571,7 +580,8 @@ public class BillsController : Controller
|
||||
// Handle receipt file replacement
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
if (IsValidReceiptFile(receiptFile, out var fileError))
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (receiptValid)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
|
||||
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
|
||||
@@ -579,7 +589,7 @@ public class BillsController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,48 +937,13 @@ public class BillsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.IsActive);
|
||||
ViewBag.Vendors = vendors
|
||||
.OrderBy(s => s.CompanyName)
|
||||
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
ViewBag.APAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.ExpenseAccounts = allAccounts
|
||||
.Where(a => 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();
|
||||
|
||||
ViewBag.BankAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
|
||||
j.JobStatus.StatusCode != "COMPLETED" &&
|
||||
j.JobStatus.StatusCode != "CANCELLED" &&
|
||||
j.JobStatus.StatusCode != "DELIVERED"))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new SelectListItem($"{j.JobNumber} – {j.Description ?? "No description"}", j.Id.ToString()))
|
||||
.ToList();
|
||||
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
|
||||
ViewBag.Vendors = dd.Vendors;
|
||||
ViewBag.APAccounts = dd.ApAccounts;
|
||||
ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
|
||||
ViewBag.BankAccounts = dd.BankAccounts;
|
||||
ViewBag.PaymentMethods = dd.PaymentMethods;
|
||||
ViewBag.Jobs = dd.ActiveJobs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1023,7 +998,7 @@ public class BillsController : Controller
|
||||
if (!result.Success) return NotFound();
|
||||
|
||||
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
|
||||
var contentType = MimeFromExt(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
var fileName = $"receipt-{bill.BillNumber}{ext}";
|
||||
return File(result.Content, contentType, fileName);
|
||||
}
|
||||
@@ -1161,41 +1136,8 @@ public class BillsController : Controller
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
|
||||
var contentType = MimeFromExt(ext);
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
|
||||
return result.Success ? blobName : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a receipt file upload against the allowed extension list and the 10 MB size cap.
|
||||
/// Returns <c>false</c> and populates <paramref name="error"/> with a user-friendly message
|
||||
/// when the file fails either check; returns <c>true</c> and sets <paramref name="error"/> to
|
||||
/// an empty string when the file is acceptable.
|
||||
/// </summary>
|
||||
private static bool IsValidReceiptFile(IFormFile file, out string error)
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedReceiptTypes.Contains(ext))
|
||||
{
|
||||
error = $"File type '{ext}' is not allowed. Accepted: {string.Join(", ", AllowedReceiptTypes)}";
|
||||
return false;
|
||||
}
|
||||
if (file.Length > MaxReceiptBytes)
|
||||
{
|
||||
error = "Receipt file must be 10 MB or smaller.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string MimeFromExt(string ext) => ext switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,14 +174,7 @@ public class CompanyUsersController : Controller
|
||||
LastLoginDate = u.LastLoginDate
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<CompanyUserListDto>
|
||||
{
|
||||
Items = userDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<CompanyUserListDto>.From(gridRequest, userDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
|
||||
@@ -123,14 +123,7 @@ public class CustomersController : Controller
|
||||
LastContactDate = c.LastContactDate
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<CustomerListDto>
|
||||
{
|
||||
Items = customerDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -950,13 +943,18 @@ public class CustomersController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateCreditMemoNumberAsync()
|
||||
{
|
||||
var allMemos = await _unitOfWork.CreditMemos.GetAllAsync(true);
|
||||
var prefix = $"CM-{DateTime.Now:yyMM}-";
|
||||
var maxNum = allMemos
|
||||
.Where(m => m.MemoNumber.StartsWith(prefix))
|
||||
.Select(m => { int.TryParse(m.MemoNumber.Replace(prefix, ""), out int n); return n; })
|
||||
.DefaultIfEmpty(0).Max();
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
var last = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(m => m.MemoNumber)
|
||||
.Select(m => m.MemoNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
next = num + 1;
|
||||
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -121,14 +121,7 @@ public class EquipmentController : Controller
|
||||
// Map to DTOs
|
||||
var equipmentDtos = _mapper.Map<List<EquipmentListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<EquipmentListDto>
|
||||
{
|
||||
Items = equipmentDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<EquipmentListDto>.From(gridRequest, equipmentDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
|
||||
@@ -8,12 +8,14 @@ using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -148,38 +150,48 @@ public class ExpensesController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
|
||||
if (receiptFile != null)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, fileError);
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (!receiptValid)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, receiptError);
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
Expense? expense = null;
|
||||
|
||||
var expense = _mapper.Map<Expense>(dto);
|
||||
expense.ExpenseNumber = await GenerateExpenseNumberAsync();
|
||||
expense.CompanyId = currentUser!.CompanyId;
|
||||
expense.CreatedBy = currentUser.Email;
|
||||
// Expense entity + account balance mutations in one atomic transaction so
|
||||
// neither can commit without the other.
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
expense = _mapper.Map<Expense>(dto);
|
||||
expense.ExpenseNumber = await GenerateExpenseNumberAsync();
|
||||
expense.CompanyId = currentUser!.CompanyId;
|
||||
expense.CreatedBy = currentUser.Email;
|
||||
|
||||
await _unitOfWork.Expenses.AddAsync(expense);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
await _unitOfWork.Expenses.AddAsync(expense);
|
||||
await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount);
|
||||
await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
// Receipt upload runs after the transaction commits so expense.Id is available
|
||||
// and the core financial record is already secured. A blob failure here leaves
|
||||
// the expense intact with correct balances — just no receipt attachment.
|
||||
if (receiptFile != null)
|
||||
expense.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser.CompanyId);
|
||||
|
||||
// Update account balances: debit expense account, credit payment account
|
||||
await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount);
|
||||
await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount);
|
||||
|
||||
if (expense.ReceiptFilePath != null)
|
||||
{
|
||||
expense!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser.CompanyId);
|
||||
await _unitOfWork.Expenses.UpdateAsync(expense);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Expense {expense.ExpenseNumber} recorded.";
|
||||
TempData["Success"] = $"Expense {expense!.ExpenseNumber} recorded.";
|
||||
return RedirectToAction(nameof(Details), new { id = expense.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -228,11 +240,15 @@ public class ExpensesController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
|
||||
if (receiptFile != null)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, fileError);
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (!receiptValid)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, receiptError);
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
@@ -345,7 +361,7 @@ public class ExpensesController : Controller
|
||||
|
||||
// Inline for images so the browser previews them; attachment for PDFs triggers download
|
||||
var ext = Path.GetExtension(expense.ReceiptFilePath).ToLowerInvariant();
|
||||
var contentType = result.ContentType.Length > 0 ? result.ContentType : MimeFromExt(ext);
|
||||
var contentType = result.ContentType.Length > 0 ? result.ContentType : BlobFileHelper.GetContentType(ext);
|
||||
var filename = $"Receipt-{expense.ExpenseNumber}{ext}";
|
||||
|
||||
Response.Headers["Content-Disposition"] = ext == ".pdf"
|
||||
@@ -392,39 +408,12 @@ public class ExpensesController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
ViewBag.ExpenseAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.Vendors = (await _unitOfWork.Vendors.FindAsync(s => s.IsActive))
|
||||
.OrderBy(s => s.CompanyName)
|
||||
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
|
||||
j.JobStatus.StatusCode != "COMPLETED" &&
|
||||
j.JobStatus.StatusCode != "CANCELLED" &&
|
||||
j.JobStatus.StatusCode != "DELIVERED"))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new SelectListItem($"{j.JobNumber} – {j.Description ?? "No description"}", j.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
|
||||
.ToList();
|
||||
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
|
||||
ViewBag.ExpenseAccounts = dd.ExpenseAccounts;
|
||||
ViewBag.PaymentAccounts = dd.BankAccounts;
|
||||
ViewBag.Vendors = dd.Vendors;
|
||||
ViewBag.Jobs = dd.ActiveJobs;
|
||||
ViewBag.PaymentMethods = dd.PaymentMethods;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -458,10 +447,8 @@ public class ExpensesController : Controller
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
var blobName = $"{companyId}/expense-receipts/{expenseId}{ext}";
|
||||
var contentType = MimeFromExt(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogError("Receipt upload failed for expense {Id}: {Error}", expenseId, result.ErrorMessage);
|
||||
@@ -470,35 +457,7 @@ public class ExpensesController : Controller
|
||||
return blobName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a receipt file against the allowed extension whitelist and the 10 MB size cap.
|
||||
/// Returns <c>false</c> and sets <paramref name="error"/> when validation fails.
|
||||
/// </summary>
|
||||
private static bool IsValidReceiptFile(IFormFile file, out string error)
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedReceiptTypes.Contains(ext))
|
||||
{
|
||||
error = $"File type '{ext}' is not allowed. Accepted types: {string.Join(", ", AllowedReceiptTypes)}";
|
||||
return false;
|
||||
}
|
||||
if (file.Length > MaxReceiptBytes)
|
||||
{
|
||||
error = "Receipt file must be 10 MB or smaller.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string MimeFromExt(string ext) => ext switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
|
||||
// ── AI: Account Suggestion ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public class InventoryController : Controller
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public InventoryController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -39,7 +40,8 @@ public class InventoryController : Controller
|
||||
IMeasurementConversionService measurementService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ISubscriptionService subscriptionService,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -49,6 +51,7 @@ public class InventoryController : Controller
|
||||
_aiLookupService = aiLookupService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_userManager = userManager;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -154,14 +157,7 @@ public class InventoryController : Controller
|
||||
// Map to DTOs using AutoMapper
|
||||
var itemDtos = _mapper.Map<List<InventoryListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<InventoryListDto>
|
||||
{
|
||||
Items = itemDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
||||
|
||||
// Load all items once to compute sidebar stats and category list in memory
|
||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
||||
@@ -1559,6 +1555,14 @@ public class InventoryController : Controller
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
|
||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||
{
|
||||
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||
}
|
||||
|
||||
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
|
||||
// doesn't have that context, so we rely on the InventoryTransaction alone
|
||||
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
|
||||
|
||||
@@ -208,16 +208,8 @@ public class InvoicesController : Controller
|
||||
|
||||
var dtos = _mapper.Map<List<InvoiceListDto>>(items);
|
||||
|
||||
var pagedResult = new PagedResult<InvoiceListDto>
|
||||
{
|
||||
Items = dtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = gridRequest.SortColumn,
|
||||
SortDirection = gridRequest.SortDirection,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
var pagedResult = PagedResult<InvoiceListDto>.From(gridRequest, dtos, totalCount);
|
||||
pagedResult.SearchTerm = searchTerm;
|
||||
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.StatusFilter = statusFilter;
|
||||
|
||||
@@ -159,6 +159,8 @@ public class JobTemplatesController : Controller
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
IsGenericItem = item.IsGenericItem,
|
||||
IsLaborItem = item.IsLaborItem,
|
||||
IsSalesItem = item.IsSalesItem,
|
||||
Sku = item.Sku,
|
||||
ManualUnitPrice = item.ManualUnitPrice,
|
||||
RequiresSandblasting = item.RequiresSandblasting,
|
||||
RequiresMasking = item.RequiresMasking,
|
||||
@@ -248,6 +250,8 @@ public class JobTemplatesController : Controller
|
||||
catalogItemId = i.CatalogItemId,
|
||||
isGenericItem = i.IsGenericItem,
|
||||
isLaborItem = i.IsLaborItem,
|
||||
isSalesItem = i.IsSalesItem,
|
||||
sku = i.Sku,
|
||||
manualUnitPrice = i.ManualUnitPrice,
|
||||
requiresSandblasting = i.RequiresSandblasting,
|
||||
requiresMasking = i.RequiresMasking,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -34,8 +34,10 @@ public class JobsController : Controller
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly IJobItemAssemblyService _jobItemAssemblyService;
|
||||
private readonly IHubContext<NotificationHub> _hub;
|
||||
private readonly IHubContext<ShopHub> _shopHub;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public JobsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -49,8 +51,10 @@ public class JobsController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
IPricingCalculationService pricingService,
|
||||
IJobItemAssemblyService jobItemAssemblyService,
|
||||
IHubContext<NotificationHub> hub,
|
||||
IHubContext<ShopHub> shopHub)
|
||||
IHubContext<ShopHub> shopHub,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -63,8 +67,10 @@ public class JobsController : Controller
|
||||
_notificationService = notificationService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_pricingService = pricingService;
|
||||
_jobItemAssemblyService = jobItemAssemblyService;
|
||||
_hub = hub;
|
||||
_shopHub = shopHub;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -122,18 +128,18 @@ public class JobsController : Controller
|
||||
var todayDate = DateTime.Today;
|
||||
if (statusGroup == "active")
|
||||
{
|
||||
filter = j => j.JobStatus.StatusCode != "COMPLETED"
|
||||
&& j.JobStatus.StatusCode != "READY_FOR_PICKUP"
|
||||
&& j.JobStatus.StatusCode != "DELIVERED"
|
||||
&& j.JobStatus.StatusCode != "CANCELLED";
|
||||
filter = j => j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||
}
|
||||
else if (statusGroup == "overdue")
|
||||
{
|
||||
filter = j => j.DueDate < todayDate
|
||||
&& j.JobStatus.StatusCode != "COMPLETED"
|
||||
&& j.JobStatus.StatusCode != "READY_FOR_PICKUP"
|
||||
&& j.JobStatus.StatusCode != "DELIVERED"
|
||||
&& j.JobStatus.StatusCode != "CANCELLED";
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
@@ -185,14 +191,9 @@ public class JobsController : Controller
|
||||
.Contains(tagLower)).ToList();
|
||||
}
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<JobListDto>
|
||||
{
|
||||
Items = jobDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count
|
||||
};
|
||||
var pagedResult = PagedResult<JobListDto>.From(
|
||||
gridRequest, jobDtos,
|
||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -579,7 +580,7 @@ public class JobsController : Controller
|
||||
ViewBag.SourceQuoteId = job.QuoteId;
|
||||
ViewBag.SourceQuoteNumber = job.Quote.QuoteNumber;
|
||||
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "PENDING", "QUOTED", "APPROVED" };
|
||||
{ AppConstants.StatusCodes.Job.Pending, AppConstants.StatusCodes.Job.Quoted, AppConstants.StatusCodes.Job.Approved };
|
||||
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
|
||||
}
|
||||
|
||||
@@ -693,7 +694,7 @@ public class JobsController : Controller
|
||||
var oldStatusId = job.JobStatusId;
|
||||
job.JobStatusId = newStatusId;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
|
||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Job.Completed) job.CompletedDate = DateTime.UtcNow;
|
||||
|
||||
var userName = User.Identity?.Name ?? "Shop Floor";
|
||||
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
|
||||
@@ -872,10 +873,10 @@ public class JobsController : Controller
|
||||
jobToUpdate.UpdatedAt = now;
|
||||
|
||||
// Optionally advance status to In Preparation
|
||||
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != "IN_PREPARATION")
|
||||
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
|
||||
{
|
||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == "IN_PREPARATION");
|
||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||
if (inPrepStatus != null)
|
||||
{
|
||||
var oldStatusId = jobToUpdate.JobStatusId;
|
||||
@@ -929,10 +930,10 @@ public class JobsController : Controller
|
||||
job.IntakeCheckedByUserId = userId;
|
||||
job.UpdatedAt = now;
|
||||
|
||||
if (advanceToInPreparation && job.JobStatus.StatusCode != "IN_PREPARATION" && !job.JobStatus.IsTerminalStatus)
|
||||
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
|
||||
{
|
||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == "IN_PREPARATION");
|
||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||
if (inPrepStatus != null)
|
||||
{
|
||||
var oldStatusId = job.JobStatusId;
|
||||
@@ -1026,8 +1027,8 @@ public class JobsController : Controller
|
||||
catalogItemId = i.CatalogItemId,
|
||||
isGenericItem = i.IsGenericItem,
|
||||
isLaborItem = i.IsLaborItem,
|
||||
isSalesItem = false, // JobTemplateItem doesn't have IsSalesItem — default false
|
||||
sku = (string?)null,
|
||||
isSalesItem = i.IsSalesItem,
|
||||
sku = i.Sku,
|
||||
manualUnitPrice = i.ManualUnitPrice,
|
||||
requiresSandblasting = i.RequiresSandblasting,
|
||||
requiresMasking = i.RequiresMasking,
|
||||
@@ -1097,7 +1098,7 @@ public class JobsController : Controller
|
||||
{
|
||||
// Get default "Pending" status (cached)
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING");
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
@@ -1147,82 +1148,20 @@ public class JobsController : Controller
|
||||
{
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
|
||||
itemDto, companyId, null);
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = job.Id,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
IsSalesItem = itemDto.IsSalesItem,
|
||||
Sku = itemDto.Sku,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
Notes = itemDto.Notes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Complexity = itemDto.Complexity,
|
||||
UnitPrice = itemPricing.UnitPrice,
|
||||
TotalPrice = itemPricing.TotalPrice,
|
||||
LaborCost = itemPricing.TotalPrice * 0.4m,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, companyId, itemPricing, createdAtUtc);
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
if (itemDto.Coats?.Any() == true)
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
decimal? powderToOrder = coatDto.PowderToOrder;
|
||||
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
|
||||
{
|
||||
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
|
||||
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
|
||||
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
|
||||
}
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
if (itemDto.PrepServices?.Any() == true)
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
PrepServiceId = psDto.PrepServiceId,
|
||||
EstimatedMinutes = psDto.EstimatedMinutes,
|
||||
BlastSetupId = psDto.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1416,79 +1355,20 @@ public class JobsController : Controller
|
||||
{
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
|
||||
itemDto, companyId, null);
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = id,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
Notes = itemDto.Notes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Complexity = itemDto.Complexity,
|
||||
UnitPrice = itemPricing.UnitPrice,
|
||||
TotalPrice = itemPricing.TotalPrice,
|
||||
LaborCost = itemPricing.TotalPrice * 0.4m,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, id, companyId, itemPricing, createdAtUtc);
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
if (itemDto.Coats?.Any() == true)
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
decimal? powderToOrder = coatDto.PowderToOrder;
|
||||
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
|
||||
{
|
||||
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
|
||||
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
|
||||
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
|
||||
}
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
if (itemDto.PrepServices?.Any() == true)
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
PrepServiceId = psDto.PrepServiceId,
|
||||
EstimatedMinutes = psDto.EstimatedMinutes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1550,11 +1430,11 @@ public class JobsController : Controller
|
||||
// Update status-related dates
|
||||
if (oldStatusId != dto.JobStatusId && newStatus != null)
|
||||
{
|
||||
if (newStatus.StatusCode == "IN_PREPARATION" && job.StartedDate == null)
|
||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Job.InPreparation && job.StartedDate == null)
|
||||
{
|
||||
job.StartedDate = DateTime.UtcNow;
|
||||
}
|
||||
else if (newStatus.StatusCode == "COMPLETED" && job.CompletedDate == null)
|
||||
else if (newStatus.StatusCode == AppConstants.StatusCodes.Job.Completed && job.CompletedDate == null)
|
||||
{
|
||||
job.CompletedDate = DateTime.UtcNow;
|
||||
}
|
||||
@@ -2039,9 +1919,9 @@ public class JobsController : Controller
|
||||
|
||||
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED"
|
||||
&& s.StatusCode != "PENDING" && s.StatusCode != "APPROVED");
|
||||
s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
// Get all jobs scheduled for today with related data including items and coats
|
||||
@@ -2058,8 +1938,8 @@ public class JobsController : Controller
|
||||
{
|
||||
var nextStatus = allStatuses
|
||||
.Where(s => s.DisplayOrder > j.JobStatus.DisplayOrder
|
||||
&& s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED")
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered)
|
||||
.OrderBy(s => s.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
@@ -2147,8 +2027,8 @@ public class JobsController : Controller
|
||||
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
!s.IsTerminalStatus
|
||||
&& s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED");
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.GetActiveJobsForMobileAsync(companyId.Value, workerId);
|
||||
@@ -2287,7 +2167,7 @@ public class JobsController : Controller
|
||||
var oldStatusId = job.JobStatusId;
|
||||
job.JobStatusId = request.NewStatusId;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
|
||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Job.Completed) job.CompletedDate = DateTime.UtcNow;
|
||||
|
||||
// Log status history
|
||||
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
|
||||
@@ -2778,7 +2658,7 @@ public class JobsController : Controller
|
||||
|
||||
// Find the "Completed" status
|
||||
var completedStatus = await _unitOfWork.JobStatusLookups
|
||||
.FirstOrDefaultAsync(s => s.StatusCode == "COMPLETED" && s.CompanyId == job.CompanyId);
|
||||
.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.Completed && s.CompanyId == job.CompanyId);
|
||||
|
||||
if (completedStatus != null)
|
||||
{
|
||||
@@ -2849,6 +2729,14 @@ public class JobsController : Controller
|
||||
inventoryItem.QuantityOnHand -= deductNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
|
||||
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
|
||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||
{
|
||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
||||
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
||||
@@ -3130,86 +3018,20 @@ public class JobsController : Controller
|
||||
{
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
|
||||
itemDto, currentUser.CompanyId, null);
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = job.Id,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
Notes = itemDto.Notes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Complexity = itemDto.Complexity,
|
||||
UnitPrice = itemPricing.UnitPrice,
|
||||
TotalPrice = itemPricing.TotalPrice,
|
||||
LaborCost = itemPricing.TotalPrice * 0.4m,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, currentUser.CompanyId, itemPricing, createdAtUtc);
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Coats
|
||||
if (itemDto.Coats?.Any() == true)
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc))
|
||||
{
|
||||
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
// Calculate PowderToOrder if not supplied by the client
|
||||
decimal? powderToOrder = coatDto.PowderToOrder;
|
||||
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
|
||||
{
|
||||
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
|
||||
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
|
||||
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
|
||||
}
|
||||
|
||||
var coat = new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
// Prep services
|
||||
if (itemDto.PrepServices?.Any() == true)
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc))
|
||||
{
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
var ps = new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
PrepServiceId = psDto.PrepServiceId,
|
||||
EstimatedMinutes = psDto.EstimatedMinutes,
|
||||
BlastSetupId = psDto.BlastSetupId,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(ps);
|
||||
}
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3599,7 +3421,7 @@ public class JobsController : Controller
|
||||
|
||||
// Generate rework job number
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING");
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||
|
||||
@@ -3638,60 +3460,20 @@ public class JobsController : Controller
|
||||
|
||||
foreach (var item in itemsToCopy)
|
||||
{
|
||||
var newItem = new JobItem
|
||||
{
|
||||
JobId = reworkJob.Id,
|
||||
Description = item.Description,
|
||||
Quantity = item.Quantity,
|
||||
SurfaceAreaSqFt = item.SurfaceAreaSqFt,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
IsGenericItem = item.IsGenericItem,
|
||||
IsLaborItem = item.IsLaborItem,
|
||||
ManualUnitPrice = item.ManualUnitPrice,
|
||||
RequiresSandblasting = item.RequiresSandblasting,
|
||||
RequiresMasking = item.RequiresMasking,
|
||||
IncludePrepCost = item.IncludePrepCost,
|
||||
EstimatedMinutes = item.EstimatedMinutes,
|
||||
Complexity = item.Complexity,
|
||||
Notes = item.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
CoatName = coat.CoatName,
|
||||
Sequence = coat.Sequence,
|
||||
InventoryItemId = coat.InventoryItemId,
|
||||
ColorName = coat.ColorName,
|
||||
VendorId = coat.VendorId,
|
||||
ColorCode = coat.ColorCode,
|
||||
Finish = coat.Finish,
|
||||
CoverageSqFtPerLb = coat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coat.TransferEfficiency,
|
||||
PowderCostPerLb = coat.PowderCostPerLb,
|
||||
Notes = coat.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
foreach (var prep in item.PrepServices)
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
PrepServiceId = prep.PrepServiceId,
|
||||
EstimatedMinutes = prep.EstimatedMinutes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3793,7 +3575,7 @@ public class JobsController : Controller
|
||||
|
||||
// Load status lookups to find Pending status
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == "PENDING");
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
|
||||
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
@@ -3871,7 +3653,7 @@ public class JobsController : Controller
|
||||
|
||||
// Guard: only allow re-sync while job is pre-production
|
||||
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "PENDING", "QUOTED", "APPROVED" };
|
||||
{ AppConstants.StatusCodes.Job.Pending, AppConstants.StatusCodes.Job.Quoted, AppConstants.StatusCodes.Job.Approved };
|
||||
if (!preProductionCodes.Contains(job.JobStatus?.StatusCode ?? ""))
|
||||
{
|
||||
TempData["Error"] = "Re-sync is only available before shop work has started.";
|
||||
@@ -3910,95 +3692,31 @@ public class JobsController : Controller
|
||||
|
||||
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
||||
{
|
||||
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = id,
|
||||
Description = quoteItem.Description,
|
||||
Quantity = quoteItem.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = quoteItem.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = quoteItem.CatalogItemId,
|
||||
IsGenericItem = quoteItem.IsGenericItem,
|
||||
IsLaborItem = quoteItem.IsLaborItem,
|
||||
IsSalesItem = quoteItem.IsSalesItem,
|
||||
Sku = quoteItem.Sku,
|
||||
ManualUnitPrice = quoteItem.ManualUnitPrice,
|
||||
PowderCostOverride = quoteItem.PowderCostOverride,
|
||||
UnitPrice = quoteItem.UnitPrice,
|
||||
TotalPrice = quoteItem.TotalPrice,
|
||||
LaborCost = quoteItem.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = quoteItem.RequiresSandblasting,
|
||||
RequiresMasking = quoteItem.RequiresMasking,
|
||||
EstimatedMinutes = quoteItem.EstimatedMinutes,
|
||||
Notes = quoteItem.Notes,
|
||||
Complexity = quoteItem.Complexity,
|
||||
AiTags = quoteItem.AiTags,
|
||||
AiPredictionId = quoteItem.AiPredictionId,
|
||||
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
|
||||
CompanyId = job.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, id, job.CompanyId, createdAtUtc);
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
if (quoteItem.Coats != null)
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc))
|
||||
{
|
||||
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
string colorName = quoteCoat.ColorName;
|
||||
string colorCode = quoteCoat.ColorCode;
|
||||
string finish = quoteCoat.Finish;
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
|
||||
{
|
||||
colorName = quoteCoat.InventoryItem.Name;
|
||||
colorCode = quoteCoat.InventoryItem.ColorCode;
|
||||
finish = quoteCoat.InventoryItem.Finish;
|
||||
}
|
||||
|
||||
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
|
||||
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
|
||||
var powderToOrder = (quoteCoat.PowderToOrder > 0)
|
||||
? quoteCoat.PowderToOrder
|
||||
: (quoteItem.SurfaceAreaSqFt > 0
|
||||
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
|
||||
: (decimal?)null);
|
||||
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = quoteCoat.CoatName,
|
||||
Sequence = quoteCoat.Sequence,
|
||||
InventoryItemId = quoteCoat.InventoryItemId,
|
||||
ColorName = colorName,
|
||||
VendorId = quoteCoat.VendorId,
|
||||
ColorCode = colorCode,
|
||||
Finish = finish,
|
||||
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = quoteCoat.TransferEfficiency,
|
||||
PowderCostPerLb = quoteCoat.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = quoteCoat.Notes,
|
||||
CompanyId = job.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Aggregate prep services from all quote items and copy to job
|
||||
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
|
||||
var itemPrepServices = await _unitOfWork.QuoteItemPrepServices.FindAsync(
|
||||
ps => quoteItemIds.Contains(ps.QuoteItemId));
|
||||
foreach (var prepServiceId in itemPrepServices.Select(ps => ps.PrepServiceId).Distinct())
|
||||
// Aggregate prep services from the fully-loaded quote items and copy to job
|
||||
foreach (var prepServiceId in fullItems
|
||||
.SelectMany(qi => qi.PrepServices)
|
||||
.Where(ps => !ps.IsDeleted)
|
||||
.Select(ps => ps.PrepServiceId)
|
||||
.Distinct())
|
||||
{
|
||||
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
|
||||
{
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||
public class JournalEntriesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public JournalEntriesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
private bool AllowAccounting() =>
|
||||
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<IActionResult> Index(string? status)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var all = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId))
|
||||
.OrderByDescending(je => je.EntryDate)
|
||||
.ThenByDescending(je => je.Id)
|
||||
.ToList();
|
||||
|
||||
var displayed = status switch
|
||||
{
|
||||
"Draft" => all.Where(je => je.Status == JournalEntryStatus.Draft).ToList(),
|
||||
"Posted" => all.Where(je => je.Status == JournalEntryStatus.Posted).ToList(),
|
||||
_ => all
|
||||
};
|
||||
|
||||
ViewBag.StatusFilter = status ?? "All";
|
||||
ViewBag.TotalCount = all.Count;
|
||||
ViewBag.DraftCount = all.Count(je => je.Status == JournalEntryStatus.Draft);
|
||||
ViewBag.PostedCount = all.Count(je => je.Status == JournalEntryStatus.Posted);
|
||||
|
||||
return View(displayed);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
await PopulateAccountDropdownAsync();
|
||||
return View(new JournalEntry { EntryDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(
|
||||
JournalEntry model,
|
||||
int[] lineAccountIds,
|
||||
decimal[] lineDebits,
|
||||
decimal[] lineCreditAmounts,
|
||||
string?[] lineDescriptions,
|
||||
int[] lineOrders)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var lines = BuildLines(lineAccountIds, lineDebits, lineCreditAmounts, lineDescriptions, lineOrders);
|
||||
|
||||
if (!ValidateLines(lines, out string? error))
|
||||
{
|
||||
TempData["Error"] = error;
|
||||
await PopulateAccountDropdownAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
model.EntryNumber = await GenerateEntryNumberAsync(companyId);
|
||||
model.Status = JournalEntryStatus.Draft;
|
||||
model.Lines = lines;
|
||||
|
||||
await _unitOfWork.JournalEntries.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Journal entry {model.EntryNumber} created as draft.";
|
||||
return RedirectToAction(nameof(Details), new { id = model.Id });
|
||||
}
|
||||
|
||||
// ── Details ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var je = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
e => e.Id == id,
|
||||
false,
|
||||
e => e.Lines))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (je == null) return NotFound();
|
||||
|
||||
// Load account names for lines
|
||||
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||
|
||||
// Reversal metadata
|
||||
if (je.ReversalOfId.HasValue)
|
||||
{
|
||||
var original = await _unitOfWork.JournalEntries.GetByIdAsync(je.ReversalOfId.Value);
|
||||
ViewBag.ReversalOfNumber = original?.EntryNumber;
|
||||
}
|
||||
|
||||
var reversal = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
r => r.ReversalOfId == je.Id && r.Status == JournalEntryStatus.Posted))
|
||||
.FirstOrDefault();
|
||||
ViewBag.ReversalEntryNumber = reversal?.EntryNumber;
|
||||
ViewBag.ReversalEntryId = reversal?.Id;
|
||||
|
||||
return View(je);
|
||||
}
|
||||
|
||||
// ── Post ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Post(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var entry = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.Id == id,
|
||||
false,
|
||||
je => je.Lines))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (entry == null) return NotFound();
|
||||
if (entry.Status != JournalEntryStatus.Draft)
|
||||
{
|
||||
TempData["Error"] = "Only draft entries can be posted.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var totalDebits = entry.Lines.Sum(l => l.DebitAmount);
|
||||
var totalCredits = entry.Lines.Sum(l => l.CreditAmount);
|
||||
if (totalDebits != totalCredits)
|
||||
{
|
||||
TempData["Error"] = $"Entry does not balance — debits {totalDebits:C} ≠ credits {totalCredits:C}.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
entry.Status = JournalEntryStatus.Posted;
|
||||
entry.PostedAt = DateTime.UtcNow;
|
||||
entry.PostedBy = User.Identity?.Name;
|
||||
|
||||
foreach (var line in entry.Lines)
|
||||
{
|
||||
if (line.DebitAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount);
|
||||
if (line.CreditAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Journal entry {entry.EntryNumber} posted successfully.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Reverse ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Reverse(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var original = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.Id == id,
|
||||
false,
|
||||
je => je.Lines))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (original == null) return NotFound();
|
||||
if (original.Status != JournalEntryStatus.Posted)
|
||||
{
|
||||
TempData["Error"] = "Only posted entries can be reversed.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var existingReversal = (await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.ReversalOfId == id))
|
||||
.FirstOrDefault();
|
||||
if (existingReversal != null)
|
||||
{
|
||||
TempData["Error"] = $"This entry was already reversed by {existingReversal.EntryNumber}.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
int newEntryId = 0;
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var reversal = new JournalEntry
|
||||
{
|
||||
EntryNumber = await GenerateEntryNumberAsync(companyId),
|
||||
EntryDate = DateTime.Today,
|
||||
Reference = $"Reversal of {original.EntryNumber}",
|
||||
Description = $"Reversal of {original.EntryNumber}: {original.Description}",
|
||||
Status = JournalEntryStatus.Posted,
|
||||
IsReversal = true,
|
||||
ReversalOfId = original.Id,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
PostedBy = User.Identity?.Name,
|
||||
Lines = original.Lines.Select((l, i) => new JournalEntryLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
DebitAmount = l.CreditAmount,
|
||||
CreditAmount = l.DebitAmount,
|
||||
Description = l.Description,
|
||||
LineOrder = l.LineOrder
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntries.AddAsync(reversal);
|
||||
original.Status = JournalEntryStatus.Reversed;
|
||||
|
||||
foreach (var line in reversal.Lines)
|
||||
{
|
||||
if (line.DebitAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount);
|
||||
if (line.CreditAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
newEntryId = reversal.Id;
|
||||
});
|
||||
|
||||
TempData["Success"] = "Reversal entry created and posted.";
|
||||
return RedirectToAction(nameof(Details), new { id = newEntryId });
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var entry = await _unitOfWork.JournalEntries.GetByIdAsync(id);
|
||||
if (entry == null) return NotFound();
|
||||
|
||||
if (entry.Status != JournalEntryStatus.Draft)
|
||||
{
|
||||
TempData["Error"] = "Only draft entries can be deleted. Posted entries must be reversed.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.JournalEntries.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Journal entry {entry.EntryNumber} deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<JournalEntryLine> BuildLines(
|
||||
int[] accountIds, decimal[] debits, decimal[] credits,
|
||||
string?[] descriptions, int[] orders)
|
||||
{
|
||||
var lines = new List<JournalEntryLine>();
|
||||
for (int i = 0; i < accountIds.Length; i++)
|
||||
{
|
||||
if (accountIds[i] == 0) continue;
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = accountIds[i],
|
||||
DebitAmount = i < debits.Length ? debits[i] : 0,
|
||||
CreditAmount = i < credits.Length ? credits[i] : 0,
|
||||
Description = i < descriptions.Length ? descriptions[i] : null,
|
||||
LineOrder = i < orders.Length ? orders[i] : i
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static bool ValidateLines(List<JournalEntryLine> lines, out string? error)
|
||||
{
|
||||
if (lines.Count < 2)
|
||||
{
|
||||
error = "A journal entry must have at least two lines.";
|
||||
return false;
|
||||
}
|
||||
var totalDebits = lines.Sum(l => l.DebitAmount);
|
||||
var totalCredits = lines.Sum(l => l.CreditAmount);
|
||||
if (totalDebits == 0 && totalCredits == 0)
|
||||
{
|
||||
error = "At least one debit or credit amount must be non-zero.";
|
||||
return false;
|
||||
}
|
||||
if (totalDebits != totalCredits)
|
||||
{
|
||||
error = $"Debits ({totalDebits:C}) must equal credits ({totalCredits:C}) before saving.";
|
||||
return false;
|
||||
}
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential entry number in the format JE-YYMM-####.
|
||||
/// Queries across soft-deleted entries to prevent number reuse after deletion.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateEntryNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"JE-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
int next = 1;
|
||||
if (all.Any())
|
||||
{
|
||||
var nums = all
|
||||
.Select(je => je.EntryNumber[prefix.Length..])
|
||||
.Select(s => int.TryParse(s, out int n) ? n : 0);
|
||||
next = nums.Max() + 1;
|
||||
}
|
||||
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
ViewBag.AccountSelectList = accounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
{
|
||||
Value = a.Id.ToString(),
|
||||
Text = $"{a.AccountNumber} – {a.Name}"
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -156,14 +156,7 @@ public class MaintenanceController : Controller
|
||||
// Map to DTOs
|
||||
var maintenanceDtos = _mapper.Map<List<MaintenanceListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<MaintenanceListDto>
|
||||
{
|
||||
Items = maintenanceDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<MaintenanceListDto>.From(gridRequest, maintenanceDtos, totalCount);
|
||||
|
||||
// Get equipment name if filtering by equipment
|
||||
if (equipmentId.HasValue)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PowderCoating.Application.DTOs.Scheduling;
|
||||
@@ -6,6 +6,7 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -27,7 +28,7 @@ public class OvenSchedulerController : Controller
|
||||
/// </summary>
|
||||
private static readonly string[] QueueableStatuses =
|
||||
{
|
||||
"IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "COATING"
|
||||
AppConstants.StatusCodes.Job.InPreparation, AppConstants.StatusCodes.Job.Sandblasting, AppConstants.StatusCodes.Job.MaskingTaping, AppConstants.StatusCodes.Job.Cleaning, AppConstants.StatusCodes.Job.Coating
|
||||
};
|
||||
|
||||
public OvenSchedulerController(
|
||||
@@ -646,7 +647,7 @@ public class OvenSchedulerController : Controller
|
||||
foreach (var batchItem in batch.Items.Where(i => i.Status == OvenBatchItemStatus.Pending))
|
||||
batchItem.Status = OvenBatchItemStatus.InOven;
|
||||
|
||||
var inOvenStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "IN_OVEN");
|
||||
var inOvenStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.InOven);
|
||||
if (inOvenStatus != null)
|
||||
{
|
||||
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
|
||||
@@ -693,8 +694,8 @@ public class OvenSchedulerController : Controller
|
||||
foreach (var batchItem in batch.Items.Where(i => i.Status == OvenBatchItemStatus.InOven))
|
||||
batchItem.Status = OvenBatchItemStatus.Completed;
|
||||
|
||||
var curingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "CURING");
|
||||
var coatingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "COATING");
|
||||
var curingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.Curing);
|
||||
var coatingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Job.Coating);
|
||||
|
||||
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToList();
|
||||
var allBatchedItems = await _unitOfWork.OvenBatchItems.GetAllAsync(false, i => i.Batch);
|
||||
@@ -771,15 +772,18 @@ public class OvenSchedulerController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateBatchNumberAsync()
|
||||
{
|
||||
var yearMonth = DateTime.Now.ToString("yyMM");
|
||||
var all = await _unitOfWork.OvenBatches.GetAllAsync(ignoreQueryFilters: true);
|
||||
var prefix = $"OVN-{yearMonth}-";
|
||||
var maxSeq = all
|
||||
.Where(b => b.BatchNumber.StartsWith(prefix))
|
||||
.Select(b => int.TryParse(b.BatchNumber[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return $"{prefix}{(maxSeq + 1):D4}";
|
||||
var prefix = $"OVN-{DateTime.Now:yyMM}-";
|
||||
var last = (await _unitOfWork.OvenBatches.FindAsync(
|
||||
b => b.BatchNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(b => b.BatchNumber)
|
||||
.Select(b => b.BatchNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
next = num + 1;
|
||||
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -170,14 +170,7 @@ public class PlatformUsersController : Controller
|
||||
totalCount = userDtos.Count; // Recalculate total for SuperAdmins
|
||||
}
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<PlatformUserListDto>
|
||||
{
|
||||
Items = userDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<PlatformUserListDto>.From(gridRequest, userDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.CurrentFilter = filter;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -263,7 +263,7 @@ public class QuoteApprovalController : Controller
|
||||
s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted,
|
||||
ignoreQueryFilters: true)
|
||||
?? await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted,
|
||||
s => s.CompanyId == quote!.CompanyId && s.StatusCode == AppConstants.StatusCodes.Quote.Rejected && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -33,6 +33,8 @@ public class QuotesController : Controller
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly IJobItemAssemblyService _jobItemAssemblyService;
|
||||
private readonly IQuotePricingAssemblyService _quotePricingAssemblyService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly IQuotePhotoService _photoService;
|
||||
@@ -55,6 +57,8 @@ public class QuotesController : Controller
|
||||
ILookupCacheService lookupCache,
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
IJobItemAssemblyService jobItemAssemblyService,
|
||||
IQuotePricingAssemblyService quotePricingAssemblyService,
|
||||
IConfiguration configuration,
|
||||
IPlatformSettingsService platformSettings,
|
||||
IQuotePhotoService photoService,
|
||||
@@ -76,6 +80,8 @@ public class QuotesController : Controller
|
||||
_lookupCache = lookupCache;
|
||||
_notificationService = notificationService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_jobItemAssemblyService = jobItemAssemblyService;
|
||||
_quotePricingAssemblyService = quotePricingAssemblyService;
|
||||
_configuration = configuration;
|
||||
_platformSettings = platformSettings;
|
||||
_photoService = photoService;
|
||||
@@ -92,7 +98,7 @@ public class QuotesController : Controller
|
||||
/// Supports filtering by free-text search and/or status. Tag filtering is applied post-query
|
||||
/// because Tags is a comma-separated string column that can't be efficiently queried server-side.
|
||||
/// The <paramref name="statusCode"/> parameter lets dashboard links deep-link into a specific
|
||||
/// status bucket by code name (e.g. "DRAFT") without knowing the database ID.
|
||||
/// status bucket by code name (e.g. AppConstants.StatusCodes.Quote.Draft) without knowing the database ID.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
@@ -198,14 +204,9 @@ public class QuotesController : Controller
|
||||
.Contains(tagLower)).ToList();
|
||||
}
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<QuoteListDto>
|
||||
{
|
||||
Items = quoteDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count
|
||||
};
|
||||
var pagedResult = PagedResult<QuoteListDto>.From(
|
||||
gridRequest, quoteDtos,
|
||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -230,10 +231,10 @@ public class QuotesController : Controller
|
||||
// Aggregate stats — computed over ALL quotes (not just current page) so stat
|
||||
// cards always reflect the full dataset regardless of current page or page size.
|
||||
var draftSentIds = quoteStatuses
|
||||
.Where(s => s.StatusCode == "DRAFT" || s.StatusCode == "SENT")
|
||||
.Where(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft || s.StatusCode == AppConstants.StatusCodes.Quote.Sent)
|
||||
.Select(s => s.Id).ToList();
|
||||
var approvedConvertedIds = quoteStatuses
|
||||
.Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED")
|
||||
.Where(s => s.StatusCode == AppConstants.StatusCodes.Quote.Approved || s.StatusCode == AppConstants.StatusCodes.Quote.Converted)
|
||||
.Select(s => s.Id).ToList();
|
||||
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
|
||||
ViewBag.StatOpenCount = indexStats.OpenCount;
|
||||
@@ -891,8 +892,8 @@ public class QuotesController : Controller
|
||||
// Get status lookups (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == "SENT");
|
||||
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == "DRAFT");
|
||||
var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
|
||||
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
|
||||
// Create quote entity
|
||||
var quote = _mapper.Map<Quote>(dto);
|
||||
@@ -914,140 +915,19 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||
|
||||
// Add quote
|
||||
await _unitOfWork.Quotes.AddAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Create quote items with calculated pricing
|
||||
var itemResults = new List<QuoteItem>();
|
||||
foreach (var itemDto in dto.QuoteItems)
|
||||
{
|
||||
var item = _mapper.Map<QuoteItem>(itemDto);
|
||||
item.QuoteId = quote.Id;
|
||||
item.CompanyId = currentUser.CompanyId;
|
||||
|
||||
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Sales/merchandise items: use the manually entered price directly — no coating calculation
|
||||
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Catalog items: if they have coats, calculate with coats; otherwise use default price
|
||||
else if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
// If catalog item has coats, calculate the full price with coat costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use catalog default price
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the pricing service
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
// Flag whether the user overrode the AI's estimates before accepting
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
// Map coats for this item with calculated costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
item.Coats = new List<QuoteItemCoat>();
|
||||
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
// If "Add to inventory as Incoming" was checked on the custom tab,
|
||||
// create a 0-balance inventory record so QR codes work on the work order.
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
|
||||
|
||||
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
|
||||
coat.CompanyId = currentUser.CompanyId;
|
||||
|
||||
// Calculate and store the coat costs
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
currentUser.CompanyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
|
||||
item.Coats.Add(coat);
|
||||
}
|
||||
}
|
||||
|
||||
// Map per-item prep services
|
||||
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
|
||||
{
|
||||
item.PrepServices = new List<QuoteItemPrepService>();
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
|
||||
prepService.CompanyId = currentUser.CompanyId;
|
||||
item.PrepServices.Add(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
itemResults.Add(item);
|
||||
}
|
||||
var itemResults = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
|
||||
dto.QuoteItems,
|
||||
quote.Id,
|
||||
currentUser.CompanyId,
|
||||
ovenRateOverride,
|
||||
DateTime.UtcNow);
|
||||
|
||||
foreach (var item in itemResults)
|
||||
{
|
||||
@@ -1444,23 +1324,7 @@ public class QuotesController : Controller
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||
|
||||
// Track changes
|
||||
var changeHistories = new List<QuoteChangeHistory>();
|
||||
@@ -1704,121 +1568,25 @@ public class QuotesController : Controller
|
||||
|
||||
// Create new quote items with calculated pricing
|
||||
var newItemsForComparison = new List<(string Description, decimal Quantity, decimal UnitPrice, decimal TotalPrice, bool Sandblasting, bool Masking, decimal? SurfaceArea, string? Notes)>();
|
||||
foreach (var itemDto in dto.QuoteItems)
|
||||
var assembledItems = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
|
||||
dto.QuoteItems,
|
||||
quote.Id,
|
||||
currentUser.CompanyId,
|
||||
ovenRateOverride,
|
||||
DateTime.UtcNow);
|
||||
|
||||
foreach (var item in assembledItems)
|
||||
{
|
||||
var item = _mapper.Map<QuoteItem>(itemDto);
|
||||
item.QuoteId = quote.Id;
|
||||
item.CompanyId = currentUser.CompanyId;
|
||||
|
||||
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask} (from DTO: Sand={DtoSand}, Mask={DtoMask})",
|
||||
item.Description, item.RequiresSandblasting, item.RequiresMasking,
|
||||
itemDto.RequiresSandblasting, itemDto.RequiresMasking);
|
||||
|
||||
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Sales/merchandise items: use the manually entered price directly — no coating calculation
|
||||
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
// Catalog items: if they have coats, calculate with coats; otherwise use default price
|
||||
else if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
// If catalog item has coats, calculate the full price with coat costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use catalog default price
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the pricing service
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
|
||||
item.UnitPrice = itemPricing.UnitPrice;
|
||||
item.TotalPrice = itemPricing.TotalPrice;
|
||||
item.ItemMaterialCost = itemPricing.MaterialCost;
|
||||
item.ItemLaborCost = itemPricing.LaborCost;
|
||||
item.ItemEquipmentCost = itemPricing.EquipmentCost;
|
||||
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
// Flag whether the user overrode the AI's estimates before accepting
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask}",
|
||||
item.Description, item.RequiresSandblasting, item.RequiresMasking);
|
||||
|
||||
await _unitOfWork.QuoteItems.AddAsync(item);
|
||||
|
||||
// Map coats for this item with calculated costs
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
if (item.Coats?.Any() == true)
|
||||
{
|
||||
item.Coats = new List<QuoteItemCoat>();
|
||||
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
|
||||
|
||||
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
|
||||
coat.CompanyId = currentUser.CompanyId;
|
||||
|
||||
// Calculate and store the coat costs
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
currentUser.CompanyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
|
||||
item.Coats.Add(coat);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description);
|
||||
}
|
||||
|
||||
// Map per-item prep services
|
||||
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
|
||||
{
|
||||
item.PrepServices = new List<QuoteItemPrepService>();
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
|
||||
prepService.CompanyId = currentUser.CompanyId;
|
||||
item.PrepServices.Add(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
// Track new item for comparison
|
||||
newItemsForComparison.Add((
|
||||
item.Description ?? "",
|
||||
item.Quantity,
|
||||
@@ -2067,7 +1835,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Check if quote is approved
|
||||
if (quote.QuoteStatus.StatusCode != "APPROVED")
|
||||
if (quote.QuoteStatus.StatusCode != AppConstants.StatusCodes.Quote.Approved)
|
||||
{
|
||||
TempData["Error"] = "Only approved quotes can be converted to customers.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
@@ -2157,7 +1925,7 @@ public class QuotesController : Controller
|
||||
// Get "Converted" status (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == "CONVERTED");
|
||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
||||
|
||||
// Update quote to link to new customer
|
||||
quote.CustomerId = customer.Id;
|
||||
@@ -2481,7 +2249,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Check if already approved
|
||||
if (quote.QuoteStatus.StatusCode == "APPROVED")
|
||||
if (quote.QuoteStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved)
|
||||
{
|
||||
TempData["Info"] = $"Quote {quote.QuoteNumber} is already approved.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
@@ -2495,7 +2263,7 @@ public class QuotesController : Controller
|
||||
|
||||
// Find the Approved status for this company
|
||||
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
|
||||
s => s.StatusCode == AppConstants.StatusCodes.Quote.Approved && s.CompanyId == currentUser!.CompanyId);
|
||||
|
||||
if (approvedStatus == null)
|
||||
{
|
||||
@@ -2982,7 +2750,7 @@ public class QuotesController : Controller
|
||||
quote.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Set approved date when status changes to Approved
|
||||
if (newStatus.StatusCode == "APPROVED" && oldStatusCode != "APPROVED")
|
||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
|
||||
{
|
||||
quote.ApprovedDate = DateTime.UtcNow;
|
||||
}
|
||||
@@ -2992,7 +2760,7 @@ public class QuotesController : Controller
|
||||
|
||||
// Auto-create job when quote is approved — guard against double-conversion
|
||||
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
|
||||
if (newStatus.StatusCode == "APPROVED" && oldStatusCode != "APPROVED"
|
||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
|
||||
&& !quote.ConvertedToJobId.HasValue)
|
||||
{
|
||||
try
|
||||
@@ -3048,7 +2816,7 @@ public class QuotesController : Controller
|
||||
// Get default job statuses and priorities
|
||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == "APPROVED");
|
||||
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
|
||||
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
|
||||
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
|
||||
|
||||
@@ -3086,108 +2854,32 @@ public class QuotesController : Controller
|
||||
// Create job items from quote items
|
||||
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
||||
{
|
||||
// Get first coat's color information if available
|
||||
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
|
||||
|
||||
var jobItem = new JobItem
|
||||
{
|
||||
JobId = job.Id,
|
||||
Description = quoteItem.Description,
|
||||
Quantity = quoteItem.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = quoteItem.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = quoteItem.CatalogItemId,
|
||||
IsGenericItem = quoteItem.IsGenericItem,
|
||||
IsLaborItem = quoteItem.IsLaborItem,
|
||||
IsSalesItem = quoteItem.IsSalesItem,
|
||||
Sku = quoteItem.Sku,
|
||||
ManualUnitPrice = quoteItem.ManualUnitPrice,
|
||||
PowderCostOverride = quoteItem.PowderCostOverride,
|
||||
UnitPrice = quoteItem.UnitPrice,
|
||||
TotalPrice = quoteItem.TotalPrice,
|
||||
LaborCost = quoteItem.TotalPrice * 0.4m, // Estimated 40% labor cost
|
||||
RequiresSandblasting = quoteItem.RequiresSandblasting,
|
||||
RequiresMasking = quoteItem.RequiresMasking,
|
||||
EstimatedMinutes = quoteItem.EstimatedMinutes,
|
||||
Notes = quoteItem.Notes,
|
||||
Complexity = quoteItem.Complexity,
|
||||
AiTags = quoteItem.AiTags,
|
||||
AiPredictionId = quoteItem.AiPredictionId, // Share the same prediction record — no duplication
|
||||
// Catalog items are fixed-price — prep services must not add labor cost to them.
|
||||
// Non-catalog items default to true so prep service labor is included in the calculated price.
|
||||
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, job.Id, quote.CompanyId, createdAtUtc);
|
||||
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(jobItem);
|
||||
await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID
|
||||
|
||||
// Create JobItemCoat records for all coats from quote
|
||||
if (quoteItem.Coats != null && quoteItem.Coats.Any())
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
|
||||
{
|
||||
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
// Get color info from inventory item if available, otherwise use coat fields
|
||||
string colorName = quoteCoat.ColorName;
|
||||
string colorCode = quoteCoat.ColorCode;
|
||||
string finish = quoteCoat.Finish;
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
|
||||
coat.CoatName, coat.Sequence, coat.ColorName ?? "N/A", coat.ColorCode ?? "N/A");
|
||||
}
|
||||
|
||||
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
|
||||
{
|
||||
// Use inventory item information (takes precedence)
|
||||
colorName = quoteCoat.InventoryItem.Name;
|
||||
colorCode = quoteCoat.InventoryItem.ColorCode;
|
||||
finish = quoteCoat.InventoryItem.Finish;
|
||||
}
|
||||
|
||||
// Calculate PowderToOrder if not already stored on the quote coat
|
||||
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
|
||||
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
|
||||
var powderToOrder = (quoteCoat.PowderToOrder > 0)
|
||||
? quoteCoat.PowderToOrder
|
||||
: (quoteItem.SurfaceAreaSqFt > 0
|
||||
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
|
||||
: (decimal?)null);
|
||||
|
||||
var jobCoat = new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
CoatName = quoteCoat.CoatName,
|
||||
Sequence = quoteCoat.Sequence,
|
||||
InventoryItemId = quoteCoat.InventoryItemId,
|
||||
ColorName = colorName,
|
||||
VendorId = quoteCoat.VendorId,
|
||||
ColorCode = colorCode,
|
||||
Finish = finish,
|
||||
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = quoteCoat.TransferEfficiency,
|
||||
PowderCostPerLb = quoteCoat.PowderCostPerLb,
|
||||
PowderToOrder = powderToOrder,
|
||||
Notes = quoteCoat.Notes,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.JobItemCoats.AddAsync(jobCoat);
|
||||
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
|
||||
jobCoat.CoatName, jobCoat.Sequence, colorName ?? "N/A", colorCode ?? "N/A");
|
||||
}
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Aggregate unique prep services from all quote items and copy to job
|
||||
// Load from DB directly to ensure prep services are available regardless of caller's includes
|
||||
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
|
||||
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
|
||||
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
|
||||
var uniquePrepServiceIds = itemPrepServices
|
||||
// Aggregate unique prep services from the fully-loaded quote items and copy to job
|
||||
var uniquePrepServiceIds = fullItems
|
||||
.SelectMany(qi => qi.PrepServices)
|
||||
.Where(ps => !ps.IsDeleted)
|
||||
.Select(ps => ps.PrepServiceId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -3214,7 +2906,7 @@ public class QuotesController : Controller
|
||||
quote.ConvertedDate = DateTime.UtcNow;
|
||||
var companyIdForStatus = quote.CompanyId;
|
||||
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForStatus);
|
||||
var convertedQuoteStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == "CONVERTED");
|
||||
var convertedQuoteStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
||||
if (convertedQuoteStatus != null)
|
||||
quote.QuoteStatusId = convertedQuoteStatus.Id;
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -3434,8 +3126,8 @@ public class QuotesController : Controller
|
||||
// Advance quote to Sent status when it is still in Draft — mirrors what the email send path does.
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == "SENT");
|
||||
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == "DRAFT");
|
||||
var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
|
||||
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
|
||||
if (sentStatus != null && quote.QuoteStatusId == (draftStatus?.Id ?? 0))
|
||||
{
|
||||
@@ -3795,160 +3487,6 @@ public class QuotesController : Controller
|
||||
/// Returns the new inventory item ID, or null if creation fails (non-fatal — the coat
|
||||
/// falls back to custom-powder pricing without an inventory link).
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Match catalog vendor name to a company vendor record
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
// InventoryCategoryId is nullable — degrade gracefully rather than aborting if the
|
||||
// company has not yet set up inventory categories (e.g., pre-seed).
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
// Start with everything the catalog already has, then augment any null
|
||||
// spec fields by fetching the product URL through the AI lookup service.
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new PowderCoating.Core.Entities.InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Also update the coat DTO so pricing uses the inventory unit cost
|
||||
coatDto.PowderCostPerLb = null; // clear manual price; pricing service reads from inventory
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After pricing is determined for an AI item, update the prediction record to flag whether
|
||||
/// the user changed the AI's estimated surface area or unit price before accepting.
|
||||
/// This data powers the "AI accuracy" reporting queries.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
// Change is tracked by EF; will be persisted on the next CompleteAsync()
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a benchmark summary comparing the AI's estimate to historical completed jobs of
|
||||
/// similar complexity and surface area (±60% sqft range). The benchmark is displayed
|
||||
@@ -3971,7 +3509,7 @@ public class QuotesController : Controller
|
||||
|
||||
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
|
||||
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
|
||||
s => s.StatusCode == "COMPLETED" || s.StatusCode == "DELIVERED"))
|
||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered))
|
||||
.Select(s => s.Id).ToHashSet();
|
||||
var completedJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
|
||||
|
||||
@@ -1192,6 +1192,69 @@ public class ReportsController : Controller
|
||||
return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accounts Payable Aging report — mirrors the AR Aging but groups open bills by vendor
|
||||
/// and buckets them by days past due date. Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/ApAging
|
||||
public async Task<IActionResult> ApAging(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF export of the AP Aging report. Same inline/attachment pattern as other PDF actions.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/ApAgingPdf
|
||||
public async Task<IActionResult> ApAgingPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateApAgingPdfAsync(dto);
|
||||
return inline
|
||||
? File(pdfBytes, "application/pdf")
|
||||
: File(pdfBytes, "application/pdf", $"AP-Aging-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trial Balance report — lists all active accounts with debit and credit balances using
|
||||
/// <c>Account.CurrentBalance</c> (live, not point-in-time). Validates that debits equal
|
||||
/// credits. Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/TrialBalance
|
||||
public async Task<IActionResult> TrialBalance(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/TrialBalancePdf
|
||||
public async Task<IActionResult> TrialBalancePdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateTrialBalancePdfAsync(dto);
|
||||
return inline
|
||||
? File(pdfBytes, "application/pdf")
|
||||
: File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||
public class VendorCreditsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public VendorCreditsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
private bool AllowAccounting() =>
|
||||
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists vendor credits grouped by status with unapplied balance summary.</summary>
|
||||
public async Task<IActionResult> Index(string? status)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var all = (await _unitOfWork.VendorCredits.FindAsync(
|
||||
vc => vc.CompanyId == companyId,
|
||||
false,
|
||||
vc => vc.Vendor))
|
||||
.OrderByDescending(vc => vc.CreditDate)
|
||||
.ThenByDescending(vc => vc.Id)
|
||||
.ToList();
|
||||
|
||||
var displayed = status switch
|
||||
{
|
||||
"Open" => all.Where(vc => vc.Status == VendorCreditStatus.Open).ToList(),
|
||||
"Partial" => all.Where(vc => vc.Status == VendorCreditStatus.PartiallyApplied).ToList(),
|
||||
"Applied" => all.Where(vc => vc.Status == VendorCreditStatus.Applied).ToList(),
|
||||
"Voided" => all.Where(vc => vc.Status == VendorCreditStatus.Voided).ToList(),
|
||||
_ => all
|
||||
};
|
||||
|
||||
ViewBag.StatusFilter = status ?? "All";
|
||||
ViewBag.TotalCount = all.Count;
|
||||
ViewBag.OpenCount = all.Count(vc => vc.Status == VendorCreditStatus.Open);
|
||||
ViewBag.PartialCount = all.Count(vc => vc.Status == VendorCreditStatus.PartiallyApplied);
|
||||
ViewBag.TotalUnapplied = all
|
||||
.Where(vc => vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied)
|
||||
.Sum(vc => vc.RemainingAmount);
|
||||
|
||||
return View(displayed);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
await PopulateDropdownsAsync();
|
||||
return View(new VendorCredit { CreditDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(
|
||||
VendorCredit model,
|
||||
int[] lineAccountIds,
|
||||
string[] lineDescriptions,
|
||||
decimal[] lineAmounts)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var lines = BuildLines(lineAccountIds, lineDescriptions, lineAmounts);
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
TempData["Error"] = "At least one line item is required.";
|
||||
await PopulateDropdownsAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
model.CreditNumber = await GenerateCreditNumberAsync(companyId);
|
||||
model.Status = VendorCreditStatus.Open;
|
||||
model.Total = lines.Sum(l => l.Amount);
|
||||
model.RemainingAmount = model.Total;
|
||||
model.LineItems = lines;
|
||||
|
||||
await _unitOfWork.VendorCredits.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Vendor credit {model.CreditNumber} created.";
|
||||
return RedirectToAction(nameof(Details), new { id = model.Id });
|
||||
}
|
||||
|
||||
// ── Details ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var vc = (await _unitOfWork.VendorCredits.FindAsync(
|
||||
v => v.Id == id,
|
||||
false,
|
||||
v => v.Vendor,
|
||||
v => v.APAccount,
|
||||
v => v.LineItems,
|
||||
v => v.Applications))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (vc == null) return NotFound();
|
||||
|
||||
// Load account names for lines
|
||||
var accountIds = vc.LineItems
|
||||
.Where(l => l.AccountId.HasValue)
|
||||
.Select(l => l.AccountId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||
|
||||
// Load bills referenced by applications
|
||||
if (vc.Applications.Any())
|
||||
{
|
||||
var billIds = vc.Applications.Select(a => a.BillId).ToList();
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => billIds.Contains(b.Id));
|
||||
ViewBag.BillMap = bills.ToDictionary(b => b.Id, b => b.BillNumber);
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewBag.BillMap = new Dictionary<int, string>();
|
||||
}
|
||||
|
||||
// Unapplied bills from the same vendor for the "Apply" panel
|
||||
if (vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied)
|
||||
{
|
||||
var openBills = await _unitOfWork.Bills.FindAsync(
|
||||
b => b.VendorId == vc.VendorId
|
||||
&& (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid));
|
||||
ViewBag.OpenBills = openBills.OrderBy(b => b.DueDate).ToList();
|
||||
}
|
||||
|
||||
return View(vc);
|
||||
}
|
||||
|
||||
// ── Post ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Posts a vendor credit to the GL:
|
||||
/// DR Accounts Payable (APAccountId) — vendor owes us, reduces AP
|
||||
/// CR Expense/COGS accounts (each line) — reverses the original expense
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Post(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var vc = (await _unitOfWork.VendorCredits.FindAsync(
|
||||
v => v.Id == id,
|
||||
false,
|
||||
v => v.LineItems))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (vc == null) return NotFound();
|
||||
if (vc.Status != VendorCreditStatus.Open)
|
||||
{
|
||||
TempData["Error"] = "Only open (unposted) credits can be posted.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// DR AP (reduces what we owe the vendor)
|
||||
await _accountBalanceService.DebitAsync(vc.APAccountId, vc.Total);
|
||||
|
||||
// CR each expense account (reverses original expense)
|
||||
foreach (var line in vc.LineItems)
|
||||
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
|
||||
|
||||
// Status stays Open — the credit is now in the GL but not yet applied to a bill
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Vendor credit {vc.CreditNumber} posted to GL.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Apply ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Applies a vendor credit against a vendor bill. Reduces Bill.AmountPaid (increasing balance)
|
||||
/// and VendorCredit.RemainingAmount. No additional GL posting — AP was already adjusted when
|
||||
/// the credit was posted.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Apply(int id, int billId, decimal amount)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
|
||||
var bill = await _unitOfWork.Bills.GetByIdAsync(billId);
|
||||
|
||||
if (vc == null || bill == null) return NotFound();
|
||||
if (amount <= 0 || amount > vc.RemainingAmount || amount > bill.BalanceDue)
|
||||
{
|
||||
TempData["Error"] = "Invalid application amount.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var application = new VendorCreditApplication
|
||||
{
|
||||
VendorCreditId = vc.Id,
|
||||
BillId = bill.Id,
|
||||
Amount = amount,
|
||||
AppliedDate = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.VendorCreditApplications.AddAsync(application);
|
||||
|
||||
// Update bill — treated as a payment against the balance
|
||||
bill.AmountPaid += amount;
|
||||
if (bill.AmountPaid >= bill.Total)
|
||||
bill.Status = BillStatus.Paid;
|
||||
else if (bill.AmountPaid > 0)
|
||||
bill.Status = BillStatus.PartiallyPaid;
|
||||
|
||||
// Update credit
|
||||
vc.RemainingAmount -= amount;
|
||||
vc.Status = vc.RemainingAmount <= 0
|
||||
? VendorCreditStatus.Applied
|
||||
: VendorCreditStatus.PartiallyApplied;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Applied {amount:C} of credit {vc.CreditNumber} to bill {bill.BillNumber}.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Void ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Void(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
|
||||
if (vc == null) return NotFound();
|
||||
|
||||
if (vc.Status == VendorCreditStatus.Applied)
|
||||
{
|
||||
TempData["Error"] = "Fully applied credits cannot be voided.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
vc.Status = VendorCreditStatus.Voided;
|
||||
vc.RemainingAmount = 0;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<VendorCreditLineItem> BuildLines(
|
||||
int[] accountIds, string[] descriptions, decimal[] amounts)
|
||||
{
|
||||
var lines = new List<VendorCreditLineItem>();
|
||||
for (int i = 0; i < accountIds.Length; i++)
|
||||
{
|
||||
if (i < amounts.Length && amounts[i] > 0)
|
||||
lines.Add(new VendorCreditLineItem
|
||||
{
|
||||
AccountId = accountIds[i] > 0 ? accountIds[i] : null,
|
||||
Description = i < descriptions.Length ? descriptions[i] : string.Empty,
|
||||
Amount = amounts[i]
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit number in the format VC-YYMM-####.
|
||||
/// Uses ignoreQueryFilters so voided/deleted records are included and numbers are never reused.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateCreditNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"VC-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.VendorCredits.FindAsync(
|
||||
vc => vc.CompanyId == companyId && vc.CreditNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
int next = 1;
|
||||
if (all.Any())
|
||||
{
|
||||
var nums = all
|
||||
.Select(vc => vc.CreditNumber[prefix.Length..])
|
||||
.Select(s => int.TryParse(s, out int n) ? n : 0);
|
||||
next = nums.Max() + 1;
|
||||
}
|
||||
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
ViewBag.VendorList = vendors
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new SelectListItem { Value = v.Id.ToString(), Text = v.CompanyName })
|
||||
.ToList();
|
||||
|
||||
ViewBag.APAccountList = accounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
{
|
||||
Value = a.Id.ToString(),
|
||||
Text = $"{a.AccountNumber} – {a.Name}"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ViewBag.ExpenseAccountList = accounts
|
||||
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
{
|
||||
Value = a.Id.ToString(),
|
||||
Text = $"{a.AccountNumber} – {a.Name}"
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,7 @@ public class VendorsController : Controller
|
||||
InventoryItemCount = s.InventoryItems.Count(i => !i.IsDeleted)
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<VendorListDto>
|
||||
{
|
||||
Items = vendorDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<VendorListDto>.From(gridRequest, vendorDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Centralizes the repeated DB queries and SelectListItem projections used by the accounting
|
||||
/// controllers (Bills, Expenses). Each controller assigns only the properties it needs to ViewBag,
|
||||
/// so the naming mismatch between controllers (BankAccounts vs PaymentAccounts) is harmless.
|
||||
/// </summary>
|
||||
internal static class AccountingDropdownHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads vendors, accounts, payment methods, and active jobs in a single call.
|
||||
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
|
||||
/// LINQ-to-SelectListItem transform.
|
||||
/// </summary>
|
||||
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
|
||||
{
|
||||
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
||||
var allAccounts = await unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
var jobs = await unitOfWork.Jobs.FindAsync(j =>
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed &&
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled &&
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered);
|
||||
|
||||
var accountLabel = (Core.Entities.Account a) => $"{a.AccountNumber} – {a.Name}";
|
||||
|
||||
return new AccountingDropdowns
|
||||
{
|
||||
Vendors = vendors
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
ExpenseAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
ExpenseAndAssetAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
ApAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
BankAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
|
||||
.ToList(),
|
||||
|
||||
ActiveJobs = jobs
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new SelectListItem(
|
||||
$"{j.JobNumber} – {j.Description ?? "No description"}",
|
||||
j.Id.ToString()))
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AccountingDropdowns
|
||||
{
|
||||
public IReadOnlyList<SelectListItem> Vendors { get; init; } = [];
|
||||
|
||||
/// <summary>Expense + Cost of Goods accounts (used by Expenses controller).</summary>
|
||||
public IReadOnlyList<SelectListItem> ExpenseAccounts { get; init; } = [];
|
||||
|
||||
/// <summary>Expense + Cost of Goods + Asset accounts (used by Bills controller).</summary>
|
||||
public IReadOnlyList<SelectListItem> ExpenseAndAssetAccounts { get; init; } = [];
|
||||
|
||||
/// <summary>Accounts Payable accounts (used by Bills controller).</summary>
|
||||
public IReadOnlyList<SelectListItem> ApAccounts { get; init; } = [];
|
||||
|
||||
/// <summary>Cash, Checking, Savings, and Credit Card accounts.</summary>
|
||||
public IReadOnlyList<SelectListItem> BankAccounts { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<SelectListItem> PaymentMethods { get; init; } = [];
|
||||
public IReadOnlyList<SelectListItem> ActiveJobs { get; init; } = [];
|
||||
}
|
||||
@@ -211,6 +211,8 @@ builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
||||
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
||||
builder.Services.AddScoped<IJobItemAssemblyService, JobItemAssemblyService>();
|
||||
builder.Services.AddScoped<IQuotePricingAssemblyService, QuotePricingAssemblyService>();
|
||||
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
|
||||
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
||||
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
@model PowderCoating.Core.Entities.BankReconciliation
|
||||
@{
|
||||
ViewData["Title"] = "Start Bank Reconciliation";
|
||||
var accounts = ViewBag.AccountSelectList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Start Bank Reconciliation</h4>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm" style="max-width:600px">
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Bank Account <span class="text-danger">*</span></label>
|
||||
<select asp-for="AccountId" asp-items="accounts" class="form-select" required>
|
||||
<option value="">— select account —</option>
|
||||
</select>
|
||||
<div class="form-text">Only Checking, Savings, and Cash accounts are listed.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statement Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="StatementDate" type="date" class="form-control"
|
||||
value="@Model.StatementDate.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statement Ending Balance <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="EndingBalance" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-text">Enter the closing balance from your bank statement.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Start Reconciliation</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
@model IEnumerable<PowderCoating.Core.Entities.BankReconciliation>
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Bank Reconciliation";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<h4 class="mb-0 fw-semibold">Bank Reconciliation</h4>
|
||||
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
|
||||
<i class="bi bi-plus-lg me-1"></i>Start New Reconciliation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Statement Date</th>
|
||||
<th class="text-end">Beginning Balance</th>
|
||||
<th class="text-end">Ending Balance</th>
|
||||
<th>Status</th>
|
||||
<th>Completed By</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">
|
||||
No reconciliations yet.
|
||||
<a asp-action="Create">Start your first one.</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var br in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-semibold">@br.Account?.Name</td>
|
||||
<td>@br.StatementDate.ToString("MMM d, yyyy")</td>
|
||||
<td class="text-end">@br.BeginningBalance.ToString("C")</td>
|
||||
<td class="text-end">@br.EndingBalance.ToString("C")</td>
|
||||
<td>
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<span class="badge bg-success">Completed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@if (br.CompletedAt.HasValue)
|
||||
{
|
||||
@($"{br.CompletedBy} on {br.CompletedAt.Value.ToLocalTime():MMM d, yyyy}")
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Report
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check2-square me-1"></i>Continue
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,189 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@{
|
||||
ViewData["Title"] = "Reconcile";
|
||||
var recon = ViewBag.Recon as PowderCoating.Core.Entities.BankReconciliation;
|
||||
var deposits = ViewBag.Deposits as List<ReconciliationItem> ?? new();
|
||||
var payments = ViewBag.Payments as List<ReconciliationItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>All Reconciliations
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Reconcile: @recon?.Account?.Name</h4>
|
||||
<span class="text-muted small">Statement date: @recon?.StatementDate.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5">@recon?.BeginningBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Beginning Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5">@recon?.EndingBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Statement Ending Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5" id="clearedBalance">@recon?.BeginningBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Cleared Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5" id="difference">—</div>
|
||||
<div class="text-muted small">Difference</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Deposits / Credits</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!deposits.Any())
|
||||
{
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No deposits found.</td></tr>
|
||||
}
|
||||
@foreach (var item in deposits.OrderBy(d => d.Date))
|
||||
{
|
||||
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
|
||||
data-amount="@item.Amount.ToString("F2")" data-direction="deposit">
|
||||
<td class="small">@item.Date.ToString("MMM d")</td>
|
||||
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
|
||||
<td class="text-end">@item.Amount.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input cleared-checkbox"
|
||||
@(item.IsCleared ? "checked" : "") />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Payments / Debits</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!payments.Any())
|
||||
{
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No payments found.</td></tr>
|
||||
}
|
||||
@foreach (var item in payments)
|
||||
{
|
||||
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
|
||||
data-amount="@item.Amount.ToString("F2")" data-direction="payment">
|
||||
<td class="small">@item.Date.ToString("MMM d")</td>
|
||||
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
|
||||
<td class="text-end">@item.Amount.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input cleared-checkbox"
|
||||
@(item.IsCleared ? "checked" : "") />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form asp-action="Complete" method="post" id="completeForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@recon?.Id" />
|
||||
<input type="hidden" name="difference" id="differenceHidden" value="9999" />
|
||||
<button type="submit" class="btn btn-success" id="completeBtn" disabled>
|
||||
<i class="bi bi-check-circle me-1"></i>Complete Reconciliation
|
||||
</button>
|
||||
<span class="ms-2 text-muted small">Complete is enabled only when difference is $0.00</span>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function() {
|
||||
const reconId = @recon?.Id;
|
||||
const beginning = @recon?.BeginningBalance.ToString("F2");
|
||||
const ending = @recon?.EndingBalance.ToString("F2");
|
||||
let token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
function recalculate() {
|
||||
let clearedDeposits = 0, clearedPayments = 0;
|
||||
document.querySelectorAll('.recon-row').forEach(row => {
|
||||
const cb = row.querySelector('.cleared-checkbox');
|
||||
const amt = parseFloat(row.dataset.amount);
|
||||
if (!cb.checked) return;
|
||||
if (row.dataset.direction === 'deposit') clearedDeposits += amt;
|
||||
else clearedPayments += amt;
|
||||
});
|
||||
|
||||
const cleared = beginning + clearedDeposits - clearedPayments;
|
||||
const difference = ending - cleared;
|
||||
|
||||
document.getElementById('clearedBalance').textContent =
|
||||
cleared.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const diffEl = document.getElementById('difference');
|
||||
diffEl.textContent = difference.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
diffEl.className = Math.abs(difference) < 0.005 ? 'fw-bold fs-5 text-success' : 'fw-bold fs-5 text-danger';
|
||||
|
||||
document.getElementById('differenceHidden').value = difference.toFixed(2);
|
||||
document.getElementById('completeBtn').disabled = Math.abs(difference) >= 0.005;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.cleared-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', async function() {
|
||||
const row = this.closest('.recon-row');
|
||||
const type = row.dataset.type;
|
||||
const id = row.dataset.id;
|
||||
const cleared = this.checked;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/BankReconciliations/ToggleCleared', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
reconId, entityType: type, entityId: id, isCleared: cleared
|
||||
})
|
||||
});
|
||||
if (!resp.ok) this.checked = !cleared; // revert on error
|
||||
} catch {
|
||||
this.checked = !cleared; // revert on network error
|
||||
}
|
||||
recalculate();
|
||||
});
|
||||
});
|
||||
|
||||
recalculate();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@model PowderCoating.Core.Entities.BankReconciliation
|
||||
@using PowderCoating.Web.Controllers
|
||||
@{
|
||||
ViewData["Title"] = $"Reconciliation Report – {Model.Account?.Name}";
|
||||
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
|
||||
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Reconciliation Report</h4>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-auto" onclick="window.print()">
|
||||
<i class="bi bi-printer me-1"></i>Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5 class="fw-semibold">@Model.Account?.Name</h5>
|
||||
<p class="text-muted mb-0">Statement Date: @Model.StatementDate.ToString("MMMM d, yyyy")</p>
|
||||
@if (Model.CompletedAt.HasValue)
|
||||
{
|
||||
<p class="text-muted small">Completed by @Model.CompletedBy on @Model.CompletedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</p>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<table class="table table-sm table-borderless mb-0 ms-auto" style="width:auto">
|
||||
<tr>
|
||||
<td class="text-muted">Beginning Balance:</td>
|
||||
<td class="fw-semibold text-end">@Model.BeginningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">+ Cleared Deposits:</td>
|
||||
<td class="fw-semibold text-end text-success">@clearedDeposits.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">– Cleared Payments:</td>
|
||||
<td class="fw-semibold text-end text-danger">@clearedPayments.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr class="border-top">
|
||||
<td class="fw-semibold">Statement Ending Balance:</td>
|
||||
<td class="fw-bold text-end">@Model.EndingBalance.ToString("C")</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Cleared Deposits (@clearedDeposits.Count())</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var p in clearedDeposits.OrderBy(p => p.PaymentDate))
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@p.PaymentDate.ToString("MMM d")</td>
|
||||
<td class="small">@p.Reference</td>
|
||||
<td class="text-end">@p.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedDeposits.Sum(p=>p.Amount).ToString("C")</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Cleared Payments (@clearedPayments.Count)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var p in clearedPayments)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@p.Date.ToString("MMM d")</td>
|
||||
<td class="small">@p.Reference</td>
|
||||
<td class="text-end">@p.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedPayments.Sum(p=>p.Amount).ToString("C")</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Notes))
|
||||
{
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header fw-semibold">Notes</div>
|
||||
<div class="card-body">@Model.Notes</div>
|
||||
</div>
|
||||
}
|
||||
@@ -199,6 +199,16 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="accountingMethod" class="form-label">Accounting Method</label>
|
||||
<select class="form-select" id="accountingMethod" name="AccountingMethod">
|
||||
<option value="1" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Accrual ? "selected" : null)">Accrual (default)</option>
|
||||
<option value="0" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash ? "selected" : null)">Cash Basis</option>
|
||||
</select>
|
||||
<div class="form-text">Affects how financial reports (P&L, Balance Sheet, Cash Flow) present data. Switching does not re-post historical transactions.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -2166,7 +2176,8 @@
|
||||
City: $('#city').val(),
|
||||
State: $('#state').val(),
|
||||
ZipCode: $('#zipCode').val(),
|
||||
TimeZone: $('#timeZone').val()
|
||||
TimeZone: $('#timeZone').val(),
|
||||
AccountingMethod: parseInt($('#accountingMethod').val())
|
||||
};
|
||||
|
||||
const btn = $('#btnSaveCompanyInfo');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
@model PowderCoating.Core.Entities.JournalEntry
|
||||
@{
|
||||
ViewData["Title"] = "New Journal Entry";
|
||||
var accounts = ViewBag.AccountSelectList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>
|
||||
?? new List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">New Journal Entry</h4>
|
||||
</div>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form asp-action="Create" method="post" id="jeForm">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Entry Details</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="EntryDate" type="date" class="form-control"
|
||||
value="@Model.EntryDate.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Reference</label>
|
||||
<input asp-for="Reference" class="form-control" placeholder="e.g., Invoice #, memo" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Description</label>
|
||||
<input asp-for="Description" class="form-control" placeholder="Brief purpose of this entry" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="fw-semibold">Lines</span>
|
||||
<div class="ms-auto d-flex gap-3 align-items-center small text-muted">
|
||||
<span>Total Debits: <strong id="totalDebits" class="text-dark">$0.00</strong></span>
|
||||
<span>Total Credits: <strong id="totalCredits" class="text-dark">$0.00</strong></span>
|
||||
<span id="balanceStatus" class="badge bg-secondary">Unbalanced</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" id="linesTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40%">Account</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:130px">Debit</th>
|
||||
<th class="text-end" style="width:130px">Credit</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="linesBody">
|
||||
<!-- rows injected by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="addLineBtn">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Line
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" id="saveBtn">Save as Draft</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/journal-entry-create.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
JournalEntry.init(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(
|
||||
accounts.Select(a => new { value = a.Value, text = a.Text }))));
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
@model PowderCoating.Core.Entities.JournalEntry
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = $"Journal Entry {Model.EntryNumber}";
|
||||
var accountMap = ViewBag.AccountMap as Dictionary<int, string> ?? new Dictionary<int, string>();
|
||||
bool isPosted = Model.Status == JournalEntryStatus.Posted;
|
||||
bool isDraft = Model.Status == JournalEntryStatus.Draft;
|
||||
bool isReversed = Model.Status == JournalEntryStatus.Reversed;
|
||||
bool alreadyReversed = ViewBag.ReversalEntryNumber != null;
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">@Model.EntryNumber</h4>
|
||||
|
||||
@if (isDraft)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1 fs-6">Draft</span>
|
||||
}
|
||||
else if (isPosted)
|
||||
{
|
||||
<span class="badge bg-success ms-1 fs-6">Posted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ms-1 fs-6">Reversed</span>
|
||||
}
|
||||
|
||||
@if (Model.IsReversal)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Reversal of @ViewBag.ReversalOfNumber</span>
|
||||
}
|
||||
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
@if (isDraft)
|
||||
{
|
||||
<form asp-action="Post" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Post this journal entry to the GL? This cannot be undone.')">
|
||||
<i class="bi bi-check-circle me-1"></i>Post to GL
|
||||
</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this draft entry?')">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (isPosted && !alreadyReversed)
|
||||
{
|
||||
<form asp-action="Reverse" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning"
|
||||
onclick="return confirm('Create a reversal entry for @Model.EntryNumber? This will immediately post the reversal.')">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Reverse
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (alreadyReversed)
|
||||
{
|
||||
<a asp-action="Details" asp-route-id="@ViewBag.ReversalEntryId" class="btn btn-sm btn-outline-secondary">
|
||||
View Reversal (@ViewBag.ReversalEntryNumber)
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4 text-muted">Entry Number</dt>
|
||||
<dd class="col-8 fw-semibold">@Model.EntryNumber</dd>
|
||||
|
||||
<dt class="col-4 text-muted">Date</dt>
|
||||
<dd class="col-8">@Model.EntryDate.ToString("MMMM d, yyyy")</dd>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Reference))
|
||||
{
|
||||
<dt class="col-4 text-muted">Reference</dt>
|
||||
<dd class="col-8">@Model.Reference</dd>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Description))
|
||||
{
|
||||
<dt class="col-4 text-muted">Description</dt>
|
||||
<dd class="col-8">@Model.Description</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
@if (isPosted && Model.PostedAt.HasValue)
|
||||
{
|
||||
<dt class="col-5 text-muted">Posted</dt>
|
||||
<dd class="col-7 small">
|
||||
@Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br />
|
||||
<span class="text-muted">by @(Model.PostedBy ?? "—")</span>
|
||||
</dd>
|
||||
}
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7 small">@Model.CreatedAt.ToLocalTime().ToString("MMM d, yyyy")</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="fw-semibold">Lines</span>
|
||||
@{
|
||||
var totalDebits = Model.Lines.Sum(l => l.DebitAmount);
|
||||
var totalCredits = Model.Lines.Sum(l => l.CreditAmount);
|
||||
bool balanced = totalDebits == totalCredits;
|
||||
}
|
||||
<div class="ms-auto small">
|
||||
<span class="me-3">Debits: <strong>@totalDebits.ToString("C")</strong></span>
|
||||
<span class="me-3">Credits: <strong>@totalCredits.ToString("C")</strong></span>
|
||||
@if (balanced)
|
||||
{
|
||||
<span class="badge bg-success">Balanced</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Out of Balance</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end">Debit</th>
|
||||
<th class="text-end">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var line in Model.Lines.OrderBy(l => l.LineOrder))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (accountMap.TryGetValue(line.AccountId, out var accountName))
|
||||
{
|
||||
@accountName
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">#@line.AccountId</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">@line.Description</td>
|
||||
<td class="text-end">
|
||||
@if (line.DebitAmount > 0)
|
||||
{
|
||||
@line.DebitAmount.ToString("C")
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (line.CreditAmount > 0)
|
||||
{
|
||||
@line.CreditAmount.ToString("C")
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="2" class="text-end">Totals</td>
|
||||
<td class="text-end">@totalDebits.ToString("C")</td>
|
||||
<td class="text-end">@totalCredits.ToString("C")</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
@model IEnumerable<PowderCoating.Core.Entities.JournalEntry>
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Journal Entries";
|
||||
var statusFilter = ViewBag.StatusFilter as string ?? "All";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<h4 class="mb-0 fw-semibold">Journal Entries</h4>
|
||||
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Entry
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "All" ? "active" : "")" asp-action="Index">
|
||||
All <span class="badge bg-secondary ms-1">@ViewBag.TotalCount</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "Draft" ? "active" : "")" asp-action="Index" asp-route-status="Draft">
|
||||
Draft <span class="badge bg-warning text-dark ms-1">@ViewBag.DraftCount</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "Posted" ? "active" : "")" asp-action="Index" asp-route-status="Posted">
|
||||
Posted <span class="badge bg-success ms-1">@ViewBag.PostedCount</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Entry #</th>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Reference</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
No journal entries found.
|
||||
<a asp-action="Create">Create the first one.</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var je in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@je.Id" class="fw-semibold text-decoration-none">
|
||||
@je.EntryNumber
|
||||
</a>
|
||||
@if (je.IsReversal)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1" title="Reversal entry">REV</span>
|
||||
}
|
||||
</td>
|
||||
<td>@je.EntryDate.ToString("MMM d, yyyy")</td>
|
||||
<td class="text-truncate" style="max-width:280px">@je.Description</td>
|
||||
<td class="text-muted small">@je.Reference</td>
|
||||
<td>
|
||||
@if (je.Status == JournalEntryStatus.Draft)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Draft</span>
|
||||
}
|
||||
else if (je.Status == JournalEntryStatus.Posted)
|
||||
{
|
||||
<span class="badge bg-success">Posted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Reversed</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,242 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.ApAgingReportDto
|
||||
@{
|
||||
ViewData["Title"] = "AP Aging";
|
||||
ViewData["PageIcon"] = "bi-hourglass-split";
|
||||
var today = DateTime.Today;
|
||||
}
|
||||
|
||||
<style>
|
||||
@@media print {
|
||||
.no-print { display: none !important; }
|
||||
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
|
||||
body { font-size: 11px; }
|
||||
.table { font-size: 11px; }
|
||||
}
|
||||
.aging-current { color: #198754; }
|
||||
.aging-1-30 { color: #fd7e14; }
|
||||
.aging-31-60 { color: #dc6c02; }
|
||||
.aging-61-90 { color: #dc3545; }
|
||||
.aging-over90 { color: #842029; font-weight: 700; }
|
||||
.vendor-row { background: #f8f9fa; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy") · @Model.Vendors.Sum(v => v.Bills.Count) open bills</p>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("ApAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-danger no-print" target="_blank">
|
||||
<i class="bi bi-file-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a href="@Url.Action("ApAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
|
||||
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
|
||||
<i class="bi bi-printer me-1"></i>Print
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date filter -->
|
||||
<div class="card shadow-sm mb-4 no-print">
|
||||
<div class="card-body py-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">As of Date</label>
|
||||
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("ApAging", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
|
||||
<a href="@Url.Action("ApAging", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print header -->
|
||||
<div class="text-center mb-4 d-none d-print-block">
|
||||
<h4 class="fw-bold">@Model.CompanyName</h4>
|
||||
<h5>Accounts Payable Aging</h5>
|
||||
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
|
||||
</div>
|
||||
|
||||
<!-- Aging summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 text-success mb-1">@Model.TotalCurrent.ToString("C0")</div>
|
||||
<div class="text-muted small">Current</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 aging-1-30 mb-1">@Model.Total1to30.ToString("C0")</div>
|
||||
<div class="text-muted small">1–30 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 aging-31-60 mb-1">@Model.Total31to60.ToString("C0")</div>
|
||||
<div class="text-muted small">31–60 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 aging-61-90 mb-1">@Model.Total61to90.ToString("C0")</div>
|
||||
<div class="text-muted small">61–90 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 aging-over90 mb-1">@Model.TotalOver90.ToString("C0")</div>
|
||||
<div class="text-muted small">Over 90 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card shadow-sm text-center h-100 border-danger border-opacity-25">
|
||||
<div class="card-body py-3">
|
||||
<div class="h6 text-danger fw-bold mb-1">@Model.TotalOutstanding.ToString("C0")</div>
|
||||
<div class="text-muted small">Total Owed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.Vendors.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-check-circle text-success fs-1 d-block mb-2"></i>
|
||||
<p class="mb-0 fw-semibold">All bills are paid!</p>
|
||||
<p class="small mb-0">No outstanding balances as of @Model.AsOf.ToString("MMMM d, yyyy").</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Summary table -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold">
|
||||
<i class="bi bi-table me-1"></i>Aging Summary by Vendor
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Vendor</th>
|
||||
<th class="text-end">Current</th>
|
||||
<th class="text-end">1–30 Days</th>
|
||||
<th class="text-end">31–60 Days</th>
|
||||
<th class="text-end">61–90 Days</th>
|
||||
<th class="text-end">Over 90</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var vend in Model.Vendors)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-controller="Vendors" asp-action="Details" asp-route-id="@vend.VendorId" class="text-decoration-none fw-medium">
|
||||
@vend.VendorName
|
||||
</a>
|
||||
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
|
||||
</td>
|
||||
<td class="text-end aging-current">@(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—")</td>
|
||||
<td class="text-end aging-1-30">@(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—")</td>
|
||||
<td class="text-end aging-31-60">@(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—")</td>
|
||||
<td class="text-end aging-61-90">@(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—")</td>
|
||||
<td class="text-end aging-over90">@(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—")</td>
|
||||
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-bold">
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="text-end aging-current">@Model.TotalCurrent.ToString("C")</td>
|
||||
<td class="text-end aging-1-30">@Model.Total1to30.ToString("C")</td>
|
||||
<td class="text-end aging-31-60">@Model.Total31to60.ToString("C")</td>
|
||||
<td class="text-end aging-61-90">@Model.Total61to90.ToString("C")</td>
|
||||
<td class="text-end aging-over90">@Model.TotalOver90.ToString("C")</td>
|
||||
<td class="text-end">@Model.TotalOutstanding.ToString("C")</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail by vendor -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">
|
||||
<i class="bi bi-list-ul me-1"></i>Bill Detail
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Bill #</th>
|
||||
<th>Bill Date</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Balance Due</th>
|
||||
<th class="text-end">Age</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var vend in Model.Vendors)
|
||||
{
|
||||
<tr class="vendor-row">
|
||||
<td colspan="6" class="py-2">@vend.VendorName</td>
|
||||
</tr>
|
||||
@foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||||
{
|
||||
string ageBadge = bill.DaysOverdue <= 0 ? "bg-success-subtle text-success"
|
||||
: bill.DaysOverdue <= 30 ? "bg-warning-subtle text-warning"
|
||||
: bill.DaysOverdue <= 60 ? "bg-orange-subtle text-warning"
|
||||
: bill.DaysOverdue <= 90 ? "bg-danger-subtle text-danger"
|
||||
: "bg-danger text-white";
|
||||
string ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.BillId" class="text-decoration-none fw-medium">
|
||||
@bill.BillNumber
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
|
||||
<td class="text-muted small">@(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
|
||||
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="table-light">
|
||||
<td colspan="3" class="ps-4 fw-semibold text-end small">@vend.VendorName subtotal</td>
|
||||
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2 no-print">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open bills (excluding Draft and Voided). Age calculated from due date.
|
||||
</div>
|
||||
@@ -22,6 +22,14 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
|
||||
@if (Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Cash Basis</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-info text-dark">Accrual Basis</span>
|
||||
}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("BalanceSheetPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-danger no-print" target="_blank">
|
||||
|
||||
@@ -188,6 +188,22 @@
|
||||
<p>Snapshot of assets, liabilities, and equity as of any date.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="ApAging" class="report-card">
|
||||
<div class="report-card-icon" style="background:#fff1f2;color:#b91c1c;">
|
||||
<i class="bi bi-hourglass-split"></i>
|
||||
</div>
|
||||
<h5>AP Aging</h5>
|
||||
<p>Outstanding vendor bills by age — current, 30, 60, and 90+ days past due. Exportable to PDF.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="TrialBalance" class="report-card">
|
||||
<div class="report-card-icon" style="background:#eef2ff;color:#4338ca;">
|
||||
<i class="bi bi-list-columns-reverse"></i>
|
||||
</div>
|
||||
<h5>Trial Balance</h5>
|
||||
<p>All active accounts with debit and credit balances — validates that your books are in balance.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">Income Statement — @Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
@if (Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Cash Basis</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-info text-dark">Accrual Basis</span>
|
||||
}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("ProfitAndLossPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-danger no-print" target="_blank">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.TrialBalanceDto
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Trial Balance";
|
||||
ViewData["PageIcon"] = "bi-list-columns-reverse";
|
||||
var today = DateTime.Today;
|
||||
var grouped = Model.Lines.GroupBy(l => l.AccountType).OrderBy(g => g.Key.ToString());
|
||||
}
|
||||
|
||||
<style>
|
||||
@@media print {
|
||||
.no-print { display: none !important; }
|
||||
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
|
||||
body { font-size: 11px; }
|
||||
.table { font-size: 11px; }
|
||||
}
|
||||
.type-header { background: #f1f5f9; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">
|
||||
As of @Model.AsOf.ToString("MMMM d, yyyy") ·
|
||||
@if (Model.IsBalanced)
|
||||
{
|
||||
<span class="text-success fw-semibold"><i class="bi bi-check-circle me-1"></i>Balanced</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger fw-semibold"><i class="bi bi-exclamation-triangle me-1"></i>Out of Balance by @((Model.TotalDebits - Model.TotalCredits).ToString("C"))</span>
|
||||
}
|
||||
</p>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("TrialBalancePdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-danger no-print" target="_blank">
|
||||
<i class="bi bi-file-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a href="@Url.Action("TrialBalancePdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
|
||||
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
|
||||
<i class="bi bi-printer me-1"></i>Print
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date filter -->
|
||||
<div class="card shadow-sm mb-4 no-print">
|
||||
<div class="card-body py-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">As of Date</label>
|
||||
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("TrialBalance", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
|
||||
<a href="@Url.Action("TrialBalance", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print header -->
|
||||
<div class="text-center mb-4 d-none d-print-block">
|
||||
<h4 class="fw-bold">@Model.CompanyName</h4>
|
||||
<h5>Trial Balance</h5>
|
||||
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 text-primary fw-bold mb-1">@Model.TotalDebits.ToString("C0")</div>
|
||||
<div class="text-muted small">Total Debits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 text-primary fw-bold mb-1">@Model.TotalCredits.ToString("C0")</div>
|
||||
<div class="text-muted small">Total Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center h-100 @(Model.IsBalanced ? "border-success border-opacity-50" : "border-danger border-opacity-50")">
|
||||
<div class="card-body py-3">
|
||||
@if (Model.IsBalanced)
|
||||
{
|
||||
<div class="h5 text-success fw-bold mb-1"><i class="bi bi-check-circle me-1"></i>Balanced</div>
|
||||
<div class="text-muted small">Debits = Credits</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="h5 text-danger fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Unbalanced</div>
|
||||
<div class="text-muted small">Difference: @((Model.TotalDebits - Model.TotalCredits).ToString("C"))</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.Lines.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-journal-x fs-1 d-block mb-2"></i>
|
||||
<p class="mb-0 fw-semibold">No active accounts with balances found.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">
|
||||
<i class="bi bi-list-columns-reverse me-1"></i>Account Balances
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:90px">Acct #</th>
|
||||
<th>Account Name</th>
|
||||
<th class="text-end" style="width:140px">Debit</th>
|
||||
<th class="text-end" style="width:140px">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var grp in grouped)
|
||||
{
|
||||
<tr class="type-header">
|
||||
<td colspan="4" class="py-2 text-uppercase small tracking-wide">@grp.Key</td>
|
||||
</tr>
|
||||
@foreach (var line in grp.OrderBy(l => l.AccountNumber))
|
||||
{
|
||||
<tr>
|
||||
<td class="ps-4 text-muted small">@line.AccountNumber</td>
|
||||
<td>@line.AccountName</td>
|
||||
<td class="text-end @(line.DebitBalance > 0 ? "fw-medium" : "text-muted")">
|
||||
@(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "")
|
||||
</td>
|
||||
<td class="text-end @(line.CreditBalance > 0 ? "fw-medium" : "text-muted")">
|
||||
@(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="table-light">
|
||||
<td colspan="2" class="text-end pe-3 small fw-semibold text-muted">@grp.Key subtotal</td>
|
||||
<td class="text-end fw-semibold">@grp.Sum(l => l.DebitBalance).ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@grp.Sum(l => l.CreditBalance).ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-dark fw-bold">
|
||||
<tr>
|
||||
<td colspan="2" class="text-end pe-3">Total</td>
|
||||
<td class="text-end">@Model.TotalDebits.ToString("C")</td>
|
||||
<td class="text-end">@Model.TotalCredits.ToString("C")</td>
|
||||
</tr>
|
||||
@if (!Model.IsBalanced)
|
||||
{
|
||||
<tr class="table-danger">
|
||||
<td colspan="2" class="text-end pe-3 text-danger">Difference (out of balance)</td>
|
||||
<td class="text-end text-danger" colspan="2">@((Model.TotalDebits - Model.TotalCredits).ToString("C"))</td>
|
||||
</tr>
|
||||
}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2 no-print">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Uses current account balances (live, not point-in-time). Accounts with zero balance are excluded.
|
||||
</div>
|
||||
@@ -1130,6 +1130,18 @@
|
||||
<i class="bi bi-journal-bookmark"></i>
|
||||
<span>Chart of Accounts</span>
|
||||
</a>
|
||||
<a asp-controller="JournalEntries" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Journal Entries</span>
|
||||
</a>
|
||||
<a asp-controller="VendorCredits" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span>Vendor Credits</span>
|
||||
</a>
|
||||
<a asp-controller="BankReconciliations" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-bank2"></i>
|
||||
<span>Bank Reconciliation</span>
|
||||
</a>
|
||||
if (hasReports)
|
||||
{
|
||||
<a asp-controller="AccountingExport" asp-action="Index" class="nav-link">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
@model PowderCoating.Core.Entities.VendorCredit
|
||||
@{
|
||||
ViewData["Title"] = "New Vendor Credit";
|
||||
var apAccounts = ViewBag.APAccountList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
var expAccounts = ViewBag.ExpenseAccountList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
var vendors = ViewBag.VendorList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">New Vendor Credit</h4>
|
||||
</div>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Credit Details</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="vendors" class="form-select" required>
|
||||
<option value="">— select vendor —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Credit Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="CreditDate" type="date" class="form-control"
|
||||
value="@Model.CreditDate.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-semibold">AP Account <span class="text-danger">*</span></label>
|
||||
<select asp-for="APAccountId" asp-items="apAccounts" class="form-select" required>
|
||||
<option value="">— select account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Memo</label>
|
||||
<input asp-for="Memo" class="form-control" placeholder="Reason for the credit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Line Items</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" id="linesTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40%">Expense / COGS Account</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:130px">Amount</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="linesBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="addLineBtn">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Line
|
||||
</button>
|
||||
<span class="fw-semibold">
|
||||
Total: <span id="lineTotal">$0.00</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Create Vendor Credit</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function() {
|
||||
const expenseAccounts = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(
|
||||
expAccounts.Select(a => new { value = a.Value, text = a.Text })));
|
||||
let idx = 0;
|
||||
|
||||
function addLine() {
|
||||
const tbody = document.getElementById('linesBody');
|
||||
const i = idx++;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<select name="lineAccountIds" class="form-select form-select-sm">
|
||||
<option value="">— optional —</option>
|
||||
${expenseAccounts.map(a => `<option value="${a.value}">${escHtml(a.text)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="lineDescriptions" type="text" class="form-control form-control-sm" placeholder="what was credited" /></td>
|
||||
<td>
|
||||
<input name="lineAmounts" type="number" step="0.01" min="0.01" class="form-control form-control-sm text-end line-amount" placeholder="0.00" required />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.closest('tr').remove(); updateTotal();">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</td>`;
|
||||
tr.querySelector('.line-amount').addEventListener('input', updateTotal);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
let total = 0;
|
||||
document.querySelectorAll('.line-amount').forEach(el => total += parseFloat(el.value) || 0);
|
||||
document.getElementById('lineTotal').textContent = total.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.getElementById('addLineBtn').addEventListener('click', addLine);
|
||||
addLine();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
@model PowderCoating.Core.Entities.VendorCredit
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = $"Vendor Credit {Model.CreditNumber}";
|
||||
var accountMap = ViewBag.AccountMap as Dictionary<int, string> ?? new();
|
||||
var billMap = ViewBag.BillMap as Dictionary<int, string> ?? new();
|
||||
var openBills = ViewBag.OpenBills as List<PowderCoating.Core.Entities.Bill> ?? new();
|
||||
bool canApply = Model.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied;
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">@Model.CreditNumber</h4>
|
||||
@{
|
||||
var (badgeClass, label) = Model.Status switch
|
||||
{
|
||||
VendorCreditStatus.Open => ("bg-success", "Open"),
|
||||
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partially Applied"),
|
||||
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
|
||||
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", Model.Status.ToString())
|
||||
};
|
||||
}
|
||||
<span class="badge @badgeClass ms-1 fs-6">@label</span>
|
||||
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
@if (Model.Status == VendorCreditStatus.Open)
|
||||
{
|
||||
<form asp-action="Post" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Post this credit to the GL? Accounts Payable will be debited and expense accounts credited.')">
|
||||
<i class="bi bi-check-circle me-1"></i>Post to GL
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (Model.Status != VendorCreditStatus.Voided && Model.Status != VendorCreditStatus.Applied)
|
||||
{
|
||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Void this vendor credit?')">
|
||||
<i class="bi bi-x-circle me-1"></i>Void
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4 text-muted">Credit Number</dt>
|
||||
<dd class="col-8 fw-semibold">@Model.CreditNumber</dd>
|
||||
|
||||
<dt class="col-4 text-muted">Vendor</dt>
|
||||
<dd class="col-8">@Model.Vendor?.CompanyName</dd>
|
||||
|
||||
<dt class="col-4 text-muted">Date</dt>
|
||||
<dd class="col-8">@Model.CreditDate.ToString("MMMM d, yyyy")</dd>
|
||||
|
||||
<dt class="col-4 text-muted">AP Account</dt>
|
||||
<dd class="col-8">@Model.APAccount?.AccountNumber – @Model.APAccount?.Name</dd>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Memo))
|
||||
{
|
||||
<dt class="col-4 text-muted">Memo</dt>
|
||||
<dd class="col-8">@Model.Memo</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="fs-5 fw-bold">@Model.Total.ToString("C")</div>
|
||||
<div class="text-muted small mb-3">Total Credit</div>
|
||||
@if (Model.RemainingAmount > 0)
|
||||
{
|
||||
<div class="fs-4 fw-bold text-success">@Model.RemainingAmount.ToString("C")</div>
|
||||
<div class="text-muted small">Remaining</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted">Fully Applied</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Line Items</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var line in Model.LineItems)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">
|
||||
@if (line.AccountId.HasValue && accountMap.TryGetValue(line.AccountId.Value, out var acct))
|
||||
{ @acct }
|
||||
else
|
||||
{ <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-muted small">@line.Description</td>
|
||||
<td class="text-end">@line.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="2" class="text-end">Total</td>
|
||||
<td class="text-end">@Model.Total.ToString("C")</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Applications.Any())
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Applied To</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Bill</th>
|
||||
<th>Applied Date</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var app in Model.Applications.OrderByDescending(a => a.AppliedDate))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (billMap.TryGetValue(app.BillId, out var billNum))
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@app.BillId">@billNum</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">#@app.BillId</span>
|
||||
}
|
||||
</td>
|
||||
<td>@app.AppliedDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
||||
<td class="text-end">@app.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canApply && openBills.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Apply to a Bill</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Bill</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Balance Due</th>
|
||||
<th class="text-end">Apply Amount</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var bill in openBills)
|
||||
{
|
||||
var maxApply = Math.Min(Model.RemainingAmount, bill.BalanceDue);
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.Id">@bill.BillNumber</a>
|
||||
</td>
|
||||
<td class="text-muted small">@(bill.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||
<td class="text-end">@bill.BalanceDue.ToString("C")</td>
|
||||
<td class="text-end" style="width:150px">
|
||||
<form asp-action="Apply" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@Model.Id" />
|
||||
<input type="hidden" name="billId" value="@bill.Id" />
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input name="amount" type="number" step="0.01" min="0.01"
|
||||
max="@maxApply"
|
||||
value="@maxApply.ToString("F2")"
|
||||
class="form-control text-end" />
|
||||
<button type="submit" class="btn btn-sm btn-success">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (canApply)
|
||||
{
|
||||
<div class="alert alert-info alert-permanent">
|
||||
No open bills found for this vendor to apply the credit against.
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
@model IEnumerable<PowderCoating.Core.Entities.VendorCredit>
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Vendor Credits";
|
||||
var statusFilter = ViewBag.StatusFilter as string ?? "All";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<h4 class="mb-0 fw-semibold">Vendor Credits</h4>
|
||||
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Credit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold text-success">@((ViewBag.TotalUnapplied as decimal? ?? 0).ToString("C"))</div>
|
||||
<div class="small text-muted">Total Unapplied Credit</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">@ViewBag.OpenCount</div>
|
||||
<div class="small text-muted">Open Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">@ViewBag.PartialCount</div>
|
||||
<div class="small text-muted">Partially Applied</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "All" ? "active" : "")" asp-action="Index">
|
||||
All <span class="badge bg-secondary ms-1">@ViewBag.TotalCount</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "Open" ? "active" : "")" asp-action="Index" asp-route-status="Open">
|
||||
Open <span class="badge bg-success ms-1">@ViewBag.OpenCount</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(statusFilter == "Partial" ? "active" : "")" asp-action="Index" asp-route-status="Partial">
|
||||
Partially Applied <span class="badge bg-warning text-dark ms-1">@ViewBag.PartialCount</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Credit #</th>
|
||||
<th>Date</th>
|
||||
<th>Vendor</th>
|
||||
<th>Memo</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">No vendor credits found.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var vc in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@vc.Id" class="fw-semibold text-decoration-none">
|
||||
@vc.CreditNumber
|
||||
</a>
|
||||
</td>
|
||||
<td>@vc.CreditDate.ToString("MMM d, yyyy")</td>
|
||||
<td>@vc.Vendor?.CompanyName</td>
|
||||
<td class="text-muted small text-truncate" style="max-width:180px">@vc.Memo</td>
|
||||
<td class="text-end">@vc.Total.ToString("C")</td>
|
||||
<td class="text-end">
|
||||
@if (vc.RemainingAmount > 0)
|
||||
{
|
||||
<span class="text-success fw-semibold">@vc.RemainingAmount.ToString("C")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
var (badgeClass, label) = vc.Status switch
|
||||
{
|
||||
VendorCreditStatus.Open => ("bg-success", "Open"),
|
||||
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partial"),
|
||||
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
|
||||
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", vc.Status.ToString())
|
||||
};
|
||||
}
|
||||
<span class="badge @badgeClass">@label</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Details" asp-route-id="@vc.Id" class="btn btn-sm btn-outline-secondary">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
const JournalEntry = (() => {
|
||||
let accounts = [];
|
||||
let lineIndex = 0;
|
||||
|
||||
function init(accountList) {
|
||||
accounts = accountList;
|
||||
document.getElementById('addLineBtn').addEventListener('click', addLine);
|
||||
addLine();
|
||||
addLine();
|
||||
}
|
||||
|
||||
function buildAccountOptions(selectedId) {
|
||||
return accounts.map(a =>
|
||||
`<option value="${a.value}" ${a.value == selectedId ? 'selected' : ''}>${escHtml(a.text)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addLine(accountId, debit, credit, desc) {
|
||||
const tbody = document.getElementById('linesBody');
|
||||
const idx = lineIndex++;
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.idx = idx;
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<select name="lineAccountIds" class="form-select form-select-sm line-account" required>
|
||||
<option value="">— select account —</option>
|
||||
${buildAccountOptions(accountId)}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="lineDescriptions" type="text" class="form-control form-control-sm" placeholder="optional" value="${escHtml(desc || '')}" /></td>
|
||||
<td>
|
||||
<input name="lineDebits" type="number" step="0.01" min="0" class="form-control form-control-sm text-end line-debit" placeholder="0.00" value="${debit || ''}" />
|
||||
</td>
|
||||
<td>
|
||||
<input name="lineCreditAmounts" type="number" step="0.01" min="0" class="form-control form-control-sm text-end line-credit" placeholder="0.00" value="${credit || ''}" />
|
||||
</td>
|
||||
<td>
|
||||
<input name="lineOrders" type="hidden" value="${idx}" />
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0 remove-line-btn" title="Remove line">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</td>`;
|
||||
tr.querySelector('.remove-line-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
updateTotals();
|
||||
});
|
||||
tr.querySelector('.line-debit').addEventListener('input', updateTotals);
|
||||
tr.querySelector('.line-credit').addEventListener('input', updateTotals);
|
||||
tbody.appendChild(tr);
|
||||
updateTotals();
|
||||
}
|
||||
|
||||
function updateTotals() {
|
||||
let debits = 0, credits = 0;
|
||||
document.querySelectorAll('.line-debit').forEach(el => {
|
||||
debits += parseFloat(el.value) || 0;
|
||||
});
|
||||
document.querySelectorAll('.line-credit').forEach(el => {
|
||||
credits += parseFloat(el.value) || 0;
|
||||
});
|
||||
document.getElementById('totalDebits').textContent = fmtCurrency(debits);
|
||||
document.getElementById('totalCredits').textContent = fmtCurrency(credits);
|
||||
const badge = document.getElementById('balanceStatus');
|
||||
const balanced = Math.abs(debits - credits) < 0.001 && debits > 0;
|
||||
badge.textContent = balanced ? 'Balanced' : 'Unbalanced';
|
||||
badge.className = 'badge ' + (balanced ? 'bg-success' : 'bg-secondary');
|
||||
}
|
||||
|
||||
function fmtCurrency(n) {
|
||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
@@ -0,0 +1,277 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class JobItemAssemblyServiceTests
|
||||
{
|
||||
private static readonly DateTime CreatedAtUtc = new(2026, 5, 9, 14, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly IJobItemAssemblyService _service = new JobItemAssemblyService();
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromWizardDto_PreservesSalesFieldsAndCalculatedChildren()
|
||||
{
|
||||
var source = new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Powder coated tumbler",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 12m,
|
||||
EstimatedMinutes = 18,
|
||||
CatalogItemId = 44,
|
||||
IsSalesItem = true,
|
||||
Sku = "TMB-RED-20",
|
||||
ManualUnitPrice = 29.99m,
|
||||
PowderCostOverride = 7.25m,
|
||||
RequiresSandblasting = true,
|
||||
RequiresMasking = true,
|
||||
Notes = "Merch item",
|
||||
IncludePrepCost = false,
|
||||
Complexity = "Moderate",
|
||||
AiTags = "merch,tumbler",
|
||||
AiPredictionId = 91,
|
||||
Coats =
|
||||
[
|
||||
new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoatName = "Base",
|
||||
Sequence = 1,
|
||||
ColorName = "Signal Red",
|
||||
ColorCode = "RAL3001",
|
||||
Finish = "Gloss",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 50m
|
||||
}
|
||||
],
|
||||
PrepServices =
|
||||
[
|
||||
new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = 7,
|
||||
EstimatedMinutes = 12,
|
||||
BlastSetupId = 88
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var pricing = new QuoteItemPricingResult
|
||||
{
|
||||
UnitPrice = 29.99m,
|
||||
TotalPrice = 59.98m
|
||||
};
|
||||
|
||||
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
|
||||
var coats = _service.CreateJobItemCoats(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
|
||||
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
|
||||
|
||||
Assert.Equal(10, jobItem.JobId);
|
||||
Assert.Equal("Powder coated tumbler", jobItem.Description);
|
||||
Assert.True(jobItem.IsSalesItem);
|
||||
Assert.Equal("TMB-RED-20", jobItem.Sku);
|
||||
Assert.False(jobItem.IncludePrepCost);
|
||||
Assert.Equal(91, jobItem.AiPredictionId);
|
||||
Assert.Equal("merch,tumbler", jobItem.AiTags);
|
||||
Assert.Equal(59.98m, jobItem.TotalPrice);
|
||||
Assert.Equal(23.992m, jobItem.LaborCost);
|
||||
Assert.Equal(CreatedAtUtc, jobItem.CreatedAt);
|
||||
|
||||
var coat = Assert.Single(coats);
|
||||
Assert.Equal(25, coat.JobItemId);
|
||||
Assert.Equal(1.6m, coat.PowderToOrder);
|
||||
Assert.Equal("Signal Red", coat.ColorName);
|
||||
Assert.Equal(CreatedAtUtc, coat.CreatedAt);
|
||||
|
||||
var prepService = Assert.Single(prepServices);
|
||||
Assert.Equal(88, prepService.BlastSetupId);
|
||||
Assert.Equal(12, prepService.EstimatedMinutes);
|
||||
Assert.Equal(CreatedAtUtc, prepService.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_PreservesQuoteShapeAndPrepCostFlag()
|
||||
{
|
||||
var quoteItem = new QuoteItem
|
||||
{
|
||||
Description = "Bracket set",
|
||||
Quantity = 3m,
|
||||
SurfaceAreaSqFt = 10m,
|
||||
CatalogItemId = 14,
|
||||
IsGenericItem = false,
|
||||
IsLaborItem = false,
|
||||
IsSalesItem = true,
|
||||
Sku = "BRK-SET",
|
||||
ManualUnitPrice = 18m,
|
||||
PowderCostOverride = 6m,
|
||||
UnitPrice = 42m,
|
||||
TotalPrice = 126m,
|
||||
RequiresSandblasting = true,
|
||||
RequiresMasking = false,
|
||||
EstimatedMinutes = 25,
|
||||
Notes = "Use existing hang points",
|
||||
Complexity = "Complex",
|
||||
IncludePrepCost = true,
|
||||
AiTags = "bracket,steel",
|
||||
AiPredictionId = 55,
|
||||
Coats =
|
||||
[
|
||||
new QuoteItemCoat
|
||||
{
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
InventoryItemId = 12,
|
||||
ColorName = "Stale Name",
|
||||
ColorCode = "STALE",
|
||||
Finish = "Stale",
|
||||
CoverageSqFtPerLb = 20m,
|
||||
TransferEfficiency = 80m,
|
||||
PowderCostPerLb = 5m,
|
||||
Notes = "Resolved from inventory",
|
||||
InventoryItem = new InventoryItem
|
||||
{
|
||||
Id = 12,
|
||||
Name = "Gloss Black",
|
||||
ColorCode = "RAL9005",
|
||||
Finish = "Gloss"
|
||||
}
|
||||
}
|
||||
],
|
||||
PrepServices =
|
||||
[
|
||||
new QuoteItemPrepService
|
||||
{
|
||||
PrepServiceId = 4,
|
||||
EstimatedMinutes = 9,
|
||||
BlastSetupId = 41
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var jobItem = _service.CreateJobItem(quoteItem, jobId: 99, companyId: 6, createdAtUtc: CreatedAtUtc);
|
||||
var coats = _service.CreateJobItemCoats(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
|
||||
var prepServices = _service.CreateJobItemPrepServices(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
|
||||
|
||||
Assert.Equal(99, jobItem.JobId);
|
||||
Assert.True(jobItem.IsSalesItem);
|
||||
Assert.Equal("BRK-SET", jobItem.Sku);
|
||||
Assert.True(jobItem.IncludePrepCost);
|
||||
Assert.Equal(55, jobItem.AiPredictionId);
|
||||
Assert.Equal("bracket,steel", jobItem.AiTags);
|
||||
|
||||
var coat = Assert.Single(coats);
|
||||
Assert.Equal("Gloss Black", coat.ColorName);
|
||||
Assert.Equal("RAL9005", coat.ColorCode);
|
||||
Assert.Equal("Gloss", coat.Finish);
|
||||
Assert.Equal(1.88m, coat.PowderToOrder);
|
||||
|
||||
var prepService = Assert.Single(prepServices);
|
||||
Assert.Equal(41, prepService.BlastSetupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_UsesStoredPowderToOrderWhenPresent()
|
||||
{
|
||||
var quoteItem = new QuoteItem
|
||||
{
|
||||
Description = "Wheel",
|
||||
Quantity = 4m,
|
||||
SurfaceAreaSqFt = 15m,
|
||||
Coats =
|
||||
[
|
||||
new QuoteItemCoat
|
||||
{
|
||||
CoatName = "Primer",
|
||||
Sequence = 1,
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
PowderToOrder = 9.5m
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var coat = Assert.Single(_service.CreateJobItemCoats(quoteItem, jobItemId: 5, companyId: 1, CreatedAtUtc));
|
||||
|
||||
Assert.Equal(9.5m, coat.PowderToOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromExistingJobItem_PreservesTransferableShapeForRework()
|
||||
{
|
||||
var source = new JobItem
|
||||
{
|
||||
Description = "Gate panel",
|
||||
Quantity = 1m,
|
||||
ColorName = "Bronze",
|
||||
ColorCode = "BZ-22",
|
||||
Finish = "Textured",
|
||||
SurfaceArea = 22m,
|
||||
SurfaceAreaSqFt = 22m,
|
||||
CatalogItemId = 8,
|
||||
IsGenericItem = false,
|
||||
IsLaborItem = false,
|
||||
IsSalesItem = true,
|
||||
Sku = "GATE-BRZ",
|
||||
ManualUnitPrice = 140m,
|
||||
PowderCostOverride = 9m,
|
||||
UnitPrice = 140m,
|
||||
TotalPrice = 140m,
|
||||
LaborCost = 56m,
|
||||
RequiresSandblasting = true,
|
||||
RequiresMasking = true,
|
||||
EstimatedMinutes = 90,
|
||||
Notes = "Rework copy",
|
||||
IncludePrepCost = false,
|
||||
Complexity = "Extreme",
|
||||
AiTags = "gate,outdoor",
|
||||
AiPredictionId = 12,
|
||||
Coats =
|
||||
[
|
||||
new JobItemCoat
|
||||
{
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
InventoryItemId = 21,
|
||||
ColorName = "Bronze",
|
||||
VendorId = 13,
|
||||
ColorCode = "BZ-22",
|
||||
Finish = "Textured",
|
||||
CoverageSqFtPerLb = 26m,
|
||||
TransferEfficiency = 70m,
|
||||
PowderCostPerLb = 8m,
|
||||
PowderToOrder = 2.75m,
|
||||
Notes = "Keep order qty"
|
||||
}
|
||||
],
|
||||
PrepServices =
|
||||
[
|
||||
new JobItemPrepService
|
||||
{
|
||||
PrepServiceId = 3,
|
||||
EstimatedMinutes = 45,
|
||||
BlastSetupId = 77
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var jobItem = _service.CreateJobItem(source, jobId: 222, companyId: 9, createdAtUtc: CreatedAtUtc);
|
||||
var coats = _service.CreateJobItemCoats(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
|
||||
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
|
||||
|
||||
Assert.Equal(222, jobItem.JobId);
|
||||
Assert.Equal("Bronze", jobItem.ColorName);
|
||||
Assert.True(jobItem.IsSalesItem);
|
||||
Assert.Equal("GATE-BRZ", jobItem.Sku);
|
||||
Assert.False(jobItem.IncludePrepCost);
|
||||
Assert.Equal(56m, jobItem.LaborCost);
|
||||
Assert.Equal(12, jobItem.AiPredictionId);
|
||||
|
||||
var coat = Assert.Single(coats);
|
||||
Assert.Equal(2.75m, coat.PowderToOrder);
|
||||
Assert.Equal("Bronze", coat.ColorName);
|
||||
|
||||
var prepService = Assert.Single(prepServices);
|
||||
Assert.Equal(77, prepService.BlastSetupId);
|
||||
Assert.Equal(45, prepService.EstimatedMinutes);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(null!, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("No file was uploaded.", result.ErrorMessage);
|
||||
Assert.Equal("No file provided.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -31,7 +31,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(file, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Photo must be smaller than 10 MB.", result.ErrorMessage);
|
||||
Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,7 +43,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(file, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Only JPG, PNG, GIF, and WebP images are allowed.", result.ErrorMessage);
|
||||
Assert.Equal("File type not allowed. Allowed: .jpg, .jpeg, .png, .gif, .webp.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Job;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class QuoteAndReworkControllerFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateQuoteStatus_ApprovedQuote_CopiesItemLevelFieldsIntoCreatedJob()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteConversionData(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var quoteStatuses = await context.QuoteStatusLookups.OrderBy(s => s.Id).ToListAsync();
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache
|
||||
.Setup(x => x.GetQuoteStatusLookupsAsync(1))
|
||||
.ReturnsAsync(quoteStatuses);
|
||||
var tenantContext = CreateTenantContext();
|
||||
|
||||
var controller = new QuotesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<QuotesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
tenantContext.Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IQuotePricingAssemblyService>(),
|
||||
new ConfigurationBuilder().Build(),
|
||||
Mock.Of<IPlatformSettingsService>(),
|
||||
Mock.Of<IQuotePhotoService>(),
|
||||
Mock.Of<IAiQuoteService>(),
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
Mock.Of<IAiUsageLogger>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
Mock.Of<IInventoryAiLookupService>());
|
||||
|
||||
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
|
||||
{
|
||||
QuoteId = 1,
|
||||
StatusId = 2
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var quote = await context.Quotes.SingleAsync();
|
||||
Assert.Equal(3, quote.QuoteStatusId);
|
||||
Assert.True(quote.ConvertedToJobId.HasValue);
|
||||
|
||||
var job = await context.Jobs.SingleAsync();
|
||||
Assert.Equal(quote.Id, job.QuoteId);
|
||||
|
||||
var jobItem = await context.JobItems.SingleAsync();
|
||||
Assert.True(jobItem.IsSalesItem);
|
||||
Assert.Equal("MUG-01", jobItem.Sku);
|
||||
Assert.False(jobItem.IncludePrepCost);
|
||||
|
||||
var jobCoat = await context.JobItemCoats.SingleAsync();
|
||||
Assert.Equal(1.5m, jobCoat.PowderToOrder);
|
||||
Assert.Equal(50, jobCoat.InventoryItemId);
|
||||
|
||||
var jobItemPrep = await context.JobItemPrepServices.SingleAsync();
|
||||
Assert.Equal(77, jobItemPrep.BlastSetupId);
|
||||
|
||||
var jobPrep = await context.JobPrepServices.SingleAsync();
|
||||
Assert.Equal(5, jobPrep.PrepServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddReworkRecord_CopiesFullItemShapeToReworkJob()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedReworkData(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache
|
||||
.Setup(x => x.GetJobStatusLookupsAsync(1))
|
||||
.ReturnsAsync(await context.JobStatusLookups.ToListAsync());
|
||||
lookupCache
|
||||
.Setup(x => x.GetJobPriorityLookupsAsync(1))
|
||||
.ReturnsAsync(await context.JobPriorityLookups.ToListAsync());
|
||||
var tenantContext = CreateTenantContext();
|
||||
|
||||
var mapper = new Mock<AutoMapper.IMapper>();
|
||||
mapper
|
||||
.Setup(x => x.Map<ReworkRecordDto>(It.IsAny<object>()))
|
||||
.Returns<object>(source =>
|
||||
{
|
||||
var record = Assert.IsType<ReworkRecord>(source);
|
||||
return new ReworkRecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
JobId = record.JobId,
|
||||
JobItemId = record.JobItemId,
|
||||
ReworkJobId = record.ReworkJobId
|
||||
};
|
||||
});
|
||||
|
||||
var controller = new JobsController(
|
||||
new UnitOfWork(context),
|
||||
mapper.Object,
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<JobsController>>(),
|
||||
tenantContext.Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IHubContext<NotificationHub>>(),
|
||||
Mock.Of<IHubContext<ShopHub>>(),
|
||||
Mock.Of<IAccountBalanceService>());
|
||||
|
||||
var result = await controller.AddReworkRecord(new CreateReworkRecordDto
|
||||
{
|
||||
JobId = 1,
|
||||
JobItemId = 10,
|
||||
ReworkType = ReworkType.InternalDefect,
|
||||
Reason = ReworkReason.InsufficientCoverage,
|
||||
DefectDescription = "Thin coverage on one edge",
|
||||
DiscoveredBy = ReworkDiscoveredBy.Internal,
|
||||
DiscoveredDate = new DateTime(2026, 5, 9),
|
||||
EstimatedReworkCost = 65m
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
|
||||
Assert.Equal(1, reworkJob.OriginalJobId);
|
||||
|
||||
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
|
||||
Assert.True(reworkItem.IsSalesItem);
|
||||
Assert.Equal("GATE-BRZ", reworkItem.Sku);
|
||||
Assert.False(reworkItem.IncludePrepCost);
|
||||
Assert.Equal(140m, reworkItem.UnitPrice);
|
||||
Assert.Equal(140m, reworkItem.TotalPrice);
|
||||
|
||||
var reworkCoat = await context.JobItemCoats.SingleAsync(c => c.JobItemId == reworkItem.Id);
|
||||
Assert.Equal(2.75m, reworkCoat.PowderToOrder);
|
||||
|
||||
var reworkPrep = await context.JobItemPrepServices.SingleAsync(p => p.JobItemId == reworkItem.Id);
|
||||
Assert.Equal(88, reworkPrep.BlastSetupId);
|
||||
}
|
||||
|
||||
private static void SeedQuoteConversionData(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
CompanyName = "Acme Fabrication"
|
||||
});
|
||||
|
||||
context.InventoryItems.Add(new InventoryItem
|
||||
{
|
||||
Id = 50,
|
||||
CompanyId = 1,
|
||||
SKU = "POW-1",
|
||||
Name = "Gloss Black",
|
||||
ColorCode = "RAL9005",
|
||||
Finish = "Gloss",
|
||||
Category = "Powder",
|
||||
UnitOfMeasure = "lbs"
|
||||
});
|
||||
|
||||
context.QuoteStatusLookups.AddRange(
|
||||
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
|
||||
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
|
||||
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{
|
||||
Id = 10,
|
||||
CompanyId = 1,
|
||||
StatusCode = "APPROVED",
|
||||
DisplayName = "Approved"
|
||||
});
|
||||
|
||||
context.JobPriorityLookups.AddRange(
|
||||
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
|
||||
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
|
||||
|
||||
context.PrepServices.Add(new PrepService
|
||||
{
|
||||
Id = 5,
|
||||
CompanyId = 1,
|
||||
ServiceName = "Sandblasting",
|
||||
DisplayOrder = 1,
|
||||
IsActive = true,
|
||||
RequiresBlastSetup = true
|
||||
});
|
||||
|
||||
context.Quotes.Add(new Quote
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
QuoteNumber = "Q-1001",
|
||||
CustomerId = 1,
|
||||
QuoteStatusId = 1,
|
||||
Total = 50m,
|
||||
ShopSuppliesAmount = 2m,
|
||||
ShopSuppliesPercent = 4m
|
||||
});
|
||||
|
||||
context.QuoteItems.Add(new QuoteItem
|
||||
{
|
||||
Id = 100,
|
||||
QuoteId = 1,
|
||||
CompanyId = 1,
|
||||
Description = "Merch mug",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 10m,
|
||||
IsSalesItem = true,
|
||||
Sku = "MUG-01",
|
||||
UnitPrice = 25m,
|
||||
TotalPrice = 50m,
|
||||
IncludePrepCost = false,
|
||||
EstimatedMinutes = 12
|
||||
});
|
||||
|
||||
context.QuoteItemCoats.Add(new QuoteItemCoat
|
||||
{
|
||||
Id = 101,
|
||||
QuoteItemId = 100,
|
||||
CompanyId = 1,
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
InventoryItemId = 50,
|
||||
ColorName = "Old Name",
|
||||
ColorCode = "OLD",
|
||||
Finish = "Old",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
PowderToOrder = 1.5m
|
||||
});
|
||||
|
||||
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
|
||||
{
|
||||
Id = 102,
|
||||
QuoteItemId = 100,
|
||||
CompanyId = 1,
|
||||
PrepServiceId = 5,
|
||||
EstimatedMinutes = 9,
|
||||
BlastSetupId = 77
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedReworkData(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
CompanyName = "Acme Fabrication"
|
||||
});
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
StatusCode = "PENDING",
|
||||
DisplayName = "Pending"
|
||||
});
|
||||
|
||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 1,
|
||||
PriorityCode = "NORMAL",
|
||||
DisplayName = "Normal"
|
||||
});
|
||||
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
JobNumber = "JOB-2605-0001",
|
||||
Description = "Original gate job",
|
||||
CustomerId = 1,
|
||||
JobStatusId = 1,
|
||||
JobPriorityId = 2
|
||||
});
|
||||
|
||||
context.JobItems.Add(new JobItem
|
||||
{
|
||||
Id = 10,
|
||||
JobId = 1,
|
||||
CompanyId = 1,
|
||||
Description = "Gate panel",
|
||||
Quantity = 1m,
|
||||
SurfaceArea = 22m,
|
||||
SurfaceAreaSqFt = 22m,
|
||||
CatalogItemId = 8,
|
||||
IsSalesItem = true,
|
||||
Sku = "GATE-BRZ",
|
||||
UnitPrice = 140m,
|
||||
TotalPrice = 140m,
|
||||
LaborCost = 56m,
|
||||
IncludePrepCost = false,
|
||||
EstimatedMinutes = 90,
|
||||
ColorName = "Bronze",
|
||||
ColorCode = "BZ-22",
|
||||
Finish = "Textured"
|
||||
});
|
||||
|
||||
context.JobItemCoats.Add(new JobItemCoat
|
||||
{
|
||||
Id = 11,
|
||||
JobItemId = 10,
|
||||
CompanyId = 1,
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
CoverageSqFtPerLb = 26m,
|
||||
TransferEfficiency = 70m,
|
||||
PowderToOrder = 2.75m
|
||||
});
|
||||
|
||||
context.JobItemPrepServices.Add(new JobItemPrepService
|
||||
{
|
||||
Id = 12,
|
||||
JobItemId = 10,
|
||||
CompanyId = 1,
|
||||
PrepServiceId = 3,
|
||||
EstimatedMinutes = 45,
|
||||
BlastSetupId = 88
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private static Mock<ITenantContext> CreateTenantContext()
|
||||
{
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.GetCurrentCompanyId()).Returns(1);
|
||||
tenantContext.Setup(x => x.IsSuperAdmin()).Returns(true);
|
||||
tenantContext.Setup(x => x.IsPlatformAdmin()).Returns(true);
|
||||
return tenantContext;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, "SuperAdmin")],
|
||||
"Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class QuotePhotoServiceTests
|
||||
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("File type '.bmp' is not allowed.", result.ErrorMessage);
|
||||
Assert.Equal("File type not allowed. Allowed: .jpg, .jpeg, .png, .gif, .webp.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class QuotePricingAssemblyServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyPricingSnapshot_CopiesAllTotalsToQuote()
|
||||
{
|
||||
var service = CreateService(CreateContext(), Mock.Of<IPricingCalculationService>());
|
||||
var quote = new Quote();
|
||||
var pricing = new QuotePricingResult
|
||||
{
|
||||
MaterialCosts = 10m,
|
||||
LaborCosts = 20m,
|
||||
EquipmentCosts = 30m,
|
||||
ItemsSubtotal = 40m,
|
||||
OvenBatchCost = 50m,
|
||||
ShopSuppliesAmount = 60m,
|
||||
ShopSuppliesPercent = 7m,
|
||||
OverheadCosts = 80m,
|
||||
OverheadPercent = 9m,
|
||||
ProfitMargin = 100m,
|
||||
ProfitPercent = 11m,
|
||||
SubtotalBeforeDiscount = 120m,
|
||||
DiscountPercent = 13m,
|
||||
DiscountAmount = 14m,
|
||||
RushFee = 15m,
|
||||
TaxAmount = 16m,
|
||||
Total = 17m
|
||||
};
|
||||
|
||||
service.ApplyPricingSnapshot(quote, pricing);
|
||||
|
||||
Assert.Equal(10m, quote.MaterialCosts);
|
||||
Assert.Equal(20m, quote.LaborCosts);
|
||||
Assert.Equal(30m, quote.EquipmentCosts);
|
||||
Assert.Equal(40m, quote.ItemsSubtotal);
|
||||
Assert.Equal(50m, quote.OvenBatchCost);
|
||||
Assert.Equal(60m, quote.ShopSuppliesAmount);
|
||||
Assert.Equal(7m, quote.ShopSuppliesPercent);
|
||||
Assert.Equal(80m, quote.OverheadAmount);
|
||||
Assert.Equal(9m, quote.OverheadPercent);
|
||||
Assert.Equal(100m, quote.ProfitMargin);
|
||||
Assert.Equal(11m, quote.ProfitPercent);
|
||||
Assert.Equal(120m, quote.SubTotal);
|
||||
Assert.Equal(13m, quote.DiscountPercent);
|
||||
Assert.Equal(14m, quote.DiscountAmount);
|
||||
Assert.Equal(15m, quote.RushFee);
|
||||
Assert.Equal(16m, quote.TaxAmount);
|
||||
Assert.Equal(17m, quote.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateQuoteItemsAsync_PreservesManualAndCalculatedPricingPaths()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.AiItemPredictions.Add(new AiItemPrediction
|
||||
{
|
||||
Id = 91,
|
||||
CompanyId = 1,
|
||||
PredictedSurfaceAreaSqFt = 4m,
|
||||
PredictedUnitPrice = 100m,
|
||||
PredictedMinutes = 15,
|
||||
PredictedComplexity = "Moderate",
|
||||
Confidence = "High"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var pricingService = new Mock<IPricingCalculationService>();
|
||||
pricingService
|
||||
.Setup(x => x.CalculateQuoteItemPriceAsync(
|
||||
It.Is<CreateQuoteItemDto>(i => i.Description == "Custom frame"),
|
||||
1,
|
||||
null))
|
||||
.ReturnsAsync(new QuoteItemPricingResult
|
||||
{
|
||||
UnitPrice = 77m,
|
||||
TotalPrice = 154m,
|
||||
MaterialCost = 22m,
|
||||
LaborCost = 33m,
|
||||
EquipmentCost = 11m
|
||||
});
|
||||
pricingService
|
||||
.Setup(x => x.CalculateCoatPriceAsync(
|
||||
It.IsAny<CreateQuoteItemCoatDto>(),
|
||||
12m,
|
||||
2m,
|
||||
0,
|
||||
25,
|
||||
1))
|
||||
.ReturnsAsync(new QuoteItemCoatPricingResult
|
||||
{
|
||||
CoatMaterialCost = 5m,
|
||||
CoatLaborCost = 6m,
|
||||
CoatTotalCost = 11m
|
||||
});
|
||||
|
||||
var service = CreateService(context, pricingService.Object);
|
||||
|
||||
var items = await service.CreateQuoteItemsAsync(
|
||||
[
|
||||
new CreateQuoteItemDto
|
||||
{
|
||||
Description = "AI wheel",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 5m,
|
||||
EstimatedMinutes = 20,
|
||||
IsAiItem = true,
|
||||
ManualUnitPrice = 123m,
|
||||
AiPredictionId = 91
|
||||
},
|
||||
new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Shop tumbler",
|
||||
Quantity = 3m,
|
||||
IsSalesItem = true,
|
||||
Sku = "TMB-20",
|
||||
ManualUnitPrice = 18m,
|
||||
IncludePrepCost = false
|
||||
},
|
||||
new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Custom frame",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 12m,
|
||||
EstimatedMinutes = 25,
|
||||
RequiresSandblasting = true,
|
||||
Notes = "Calculated path",
|
||||
Coats =
|
||||
[
|
||||
new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
ColorName = "Black",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m
|
||||
}
|
||||
],
|
||||
PrepServices =
|
||||
[
|
||||
new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = 7,
|
||||
EstimatedMinutes = 15,
|
||||
BlastSetupId = 44
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
quoteId: 55,
|
||||
companyId: 1,
|
||||
ovenRateOverride: null,
|
||||
createdAtUtc: new DateTime(2026, 5, 9, 15, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(3, items.Count);
|
||||
|
||||
var aiItem = items.Single(i => i.Description == "AI wheel");
|
||||
Assert.Equal(123m, aiItem.UnitPrice);
|
||||
Assert.Equal(246m, aiItem.TotalPrice);
|
||||
|
||||
var salesItem = items.Single(i => i.Description == "Shop tumbler");
|
||||
Assert.True(salesItem.IsSalesItem);
|
||||
Assert.Equal("TMB-20", salesItem.Sku);
|
||||
Assert.False(salesItem.IncludePrepCost);
|
||||
Assert.Equal(18m, salesItem.UnitPrice);
|
||||
Assert.Equal(54m, salesItem.TotalPrice);
|
||||
|
||||
var customItem = items.Single(i => i.Description == "Custom frame");
|
||||
Assert.Equal(77m, customItem.UnitPrice);
|
||||
Assert.Equal(154m, customItem.TotalPrice);
|
||||
Assert.Equal(22m, customItem.ItemMaterialCost);
|
||||
Assert.Equal(33m, customItem.ItemLaborCost);
|
||||
Assert.Equal(11m, customItem.ItemEquipmentCost);
|
||||
var customPrep = Assert.Single(customItem.PrepServices);
|
||||
Assert.Equal(44, customPrep.BlastSetupId);
|
||||
var customCoat = Assert.Single(customItem.Coats);
|
||||
Assert.Equal(11m, customCoat.CoatTotalCost);
|
||||
|
||||
var prediction = await context.AiItemPredictions.SingleAsync();
|
||||
Assert.True(prediction.UserOverrodeEstimate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateQuoteItemsAsync_CatalogItemWithoutCoats_UsesCatalogDefaultPrice()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.CatalogItems.Add(new CatalogItem
|
||||
{
|
||||
Id = 22,
|
||||
CompanyId = 1,
|
||||
Name = "Gate Hinge",
|
||||
DefaultPrice = 42.5m
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context, Mock.Of<IPricingCalculationService>());
|
||||
|
||||
var item = Assert.Single(await service.CreateQuoteItemsAsync(
|
||||
[
|
||||
new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Catalog hinge",
|
||||
Quantity = 4m,
|
||||
CatalogItemId = 22
|
||||
}
|
||||
],
|
||||
quoteId: 1,
|
||||
companyId: 1,
|
||||
ovenRateOverride: null,
|
||||
createdAtUtc: DateTime.UtcNow));
|
||||
|
||||
Assert.Equal(42.5m, item.UnitPrice);
|
||||
Assert.Equal(170m, item.TotalPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateQuoteItemsAsync_AddAsIncoming_CreatesInventoryItemAndLinksCoat()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
|
||||
{
|
||||
Id = 5,
|
||||
VendorName = "Prismatic Powders",
|
||||
Sku = "P-1001",
|
||||
ColorName = "Candy Red",
|
||||
UnitPrice = 19.5m,
|
||||
CoverageSqFtPerLb = 85m,
|
||||
TransferEfficiency = 70m
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var pricingService = new Mock<IPricingCalculationService>();
|
||||
pricingService
|
||||
.Setup(x => x.CalculateQuoteItemPriceAsync(It.IsAny<CreateQuoteItemDto>(), 1, null))
|
||||
.ReturnsAsync(new QuoteItemPricingResult
|
||||
{
|
||||
UnitPrice = 50m,
|
||||
TotalPrice = 50m,
|
||||
MaterialCost = 10m,
|
||||
LaborCost = 20m,
|
||||
EquipmentCost = 5m
|
||||
});
|
||||
pricingService
|
||||
.Setup(x => x.CalculateCoatPriceAsync(It.IsAny<CreateQuoteItemCoatDto>(), 6m, 1m, 0, 10, 1))
|
||||
.ReturnsAsync(new QuoteItemCoatPricingResult
|
||||
{
|
||||
CoatMaterialCost = 3m,
|
||||
CoatLaborCost = 4m,
|
||||
CoatTotalCost = 7m
|
||||
});
|
||||
|
||||
var service = CreateService(context, pricingService.Object);
|
||||
var dto = new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Incoming powder item",
|
||||
Quantity = 1m,
|
||||
SurfaceAreaSqFt = 6m,
|
||||
EstimatedMinutes = 10,
|
||||
Coats =
|
||||
[
|
||||
new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoatName = "Base",
|
||||
Sequence = 1,
|
||||
CatalogItemId = 5,
|
||||
AddAsIncoming = true,
|
||||
PowderCostPerLb = 22m
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var item = Assert.Single(await service.CreateQuoteItemsAsync(
|
||||
[dto],
|
||||
quoteId: 9,
|
||||
companyId: 1,
|
||||
ovenRateOverride: null,
|
||||
createdAtUtc: DateTime.UtcNow));
|
||||
|
||||
var inventoryItem = await context.InventoryItems.SingleAsync();
|
||||
var coat = Assert.Single(item.Coats);
|
||||
Assert.Equal(inventoryItem.Id, coat.InventoryItemId);
|
||||
Assert.True(inventoryItem.IsIncoming);
|
||||
Assert.Null(dto.Coats[0].PowderCostPerLb);
|
||||
}
|
||||
|
||||
private static QuotePricingAssemblyService CreateService(ApplicationDbContext context, IPricingCalculationService pricingService)
|
||||
{
|
||||
return new QuotePricingAssemblyService(
|
||||
new UnitOfWork(context),
|
||||
pricingService,
|
||||
Mock.Of<IInventoryAiLookupService>(),
|
||||
Mock.Of<ILogger<QuotePricingAssemblyService>>());
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, "SuperAdmin")],
|
||||
"Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user