Refactor: centralize accounting helpers, status constants, and query deduplication
- AccountingDropdownHelper: wired into BillsController and ExpensesController, replacing 35-40 lines of duplicated DB queries per controller - AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all magic status strings across Jobs, Quotes, Appointments, OvenScheduler, AiQuickQuote, QuoteApproval, and AccountingDropdownHelper - AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure helper; removed duplicate private method from AccountBalanceService and LedgerService (~50 lines deleted) - AccountDataExportController: extracted 9 Fetch*Async methods (superset of includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each entity is queried once regardless of whether XLSX or CSV format is requested - BillsController.Create and ExpensesController.Create wrapped in ExecuteInTransactionAsync; blob uploads moved after commit to keep financial data atomic and prevent orphaned blobs from rolling back - Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table GetAllAsync to prefix-filtered FindAsync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -543,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) ||
|
||||
@@ -618,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"
|
||||
};
|
||||
}
|
||||
@@ -745,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);
|
||||
|
||||
@@ -869,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>
|
||||
|
||||
@@ -321,84 +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)
|
||||
{
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (receiptValid)
|
||||
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||||
else
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -943,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>
|
||||
|
||||
@@ -164,28 +164,34 @@ public class ExpensesController : Controller
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -125,18 +125,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))
|
||||
@@ -577,7 +577,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 ?? "");
|
||||
}
|
||||
|
||||
@@ -691,7 +691,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
|
||||
@@ -870,10 +870,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;
|
||||
@@ -927,10 +927,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;
|
||||
@@ -1095,7 +1095,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
|
||||
{
|
||||
@@ -1427,11 +1427,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;
|
||||
}
|
||||
@@ -1916,9 +1916,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
|
||||
@@ -1935,8 +1935,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();
|
||||
|
||||
@@ -2024,8 +2024,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);
|
||||
@@ -2164,7 +2164,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
|
||||
@@ -2655,7 +2655,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)
|
||||
{
|
||||
@@ -3410,7 +3410,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();
|
||||
|
||||
@@ -3564,7 +3564,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);
|
||||
@@ -3642,7 +3642,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.";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -98,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,
|
||||
@@ -231,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;
|
||||
@@ -892,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);
|
||||
@@ -1835,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 });
|
||||
@@ -1925,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;
|
||||
@@ -2249,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 });
|
||||
@@ -2263,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)
|
||||
{
|
||||
@@ -2750,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;
|
||||
}
|
||||
@@ -2760,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
|
||||
@@ -2816,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");
|
||||
|
||||
@@ -2906,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();
|
||||
@@ -3126,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))
|
||||
{
|
||||
@@ -3509,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));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
@@ -21,9 +22,9 @@ internal static class AccountingDropdownHelper
|
||||
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 != "COMPLETED" &&
|
||||
j.JobStatus.StatusCode != "CANCELLED" &&
|
||||
j.JobStatus.StatusCode != "DELIVERED");
|
||||
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}";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user