diff --git a/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs b/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs
index 527d8ff..b8f2b75 100644
--- a/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs
+++ b/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs
@@ -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();
}
- ///
- /// Returns true for account sub-types whose normal balance is a debit
- /// (Assets, COGS, Expenses). This mirrors the identical helper in
- /// and is the single source of truth for how and
- /// decide the direction of the balance adjustment.
- ///
- 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
- };
}
diff --git a/src/PowderCoating.Infrastructure/Services/AccountingRules.cs b/src/PowderCoating.Infrastructure/Services/AccountingRules.cs
new file mode 100644
index 0000000..12d8364
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/AccountingRules.cs
@@ -0,0 +1,41 @@
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Single source of truth for double-entry sign conventions shared by
+/// and .
+/// Centralised here so that adding a new AccountSubType only requires
+/// one edit rather than two independently maintained switch expressions.
+///
+internal static class AccountingRules
+{
+ ///
+ /// Returns true 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.
+ ///
+ 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
+ };
+}
diff --git a/src/PowderCoating.Infrastructure/Services/LedgerService.cs b/src/PowderCoating.Infrastructure/Services/LedgerService.cs
index 5ae07af..aefc6d3 100644
--- a/src/PowderCoating.Infrastructure/Services/LedgerService.cs
+++ b/src/PowderCoating.Infrastructure/Services/LedgerService.cs
@@ -306,7 +306,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 +338,6 @@ public class LedgerService : ILedgerService
};
}
- ///
- /// Returns true if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
- /// false for normal credit balance (Liabilities, Equity, Revenue).
- /// is used rather than
- /// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
- /// whereas AccountType is a broader category that a user might set incorrectly.
- /// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
- ///
- 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
- };
-
///
/// Computes the account balance on the day immediately before
/// by summing all activity prior to that date across every transaction source and adding
@@ -375,7 +345,7 @@ public class LedgerService : ILedgerService
/// date is on or before — a future-dated opening balance (e.g.
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
/// A null OpeningBalanceDate means the balance predates all transactions and always applies.
- /// The sign convention follows : debits increase debit-normal
+ /// The sign convention follows : debits increase debit-normal
/// accounts and credits increase credit-normal accounts.
///
private async Task ComputePriorBalanceAsync(
diff --git a/src/PowderCoating.Shared/Constants/AppConstants.cs b/src/PowderCoating.Shared/Constants/AppConstants.cs
index ab94f7b..c50915a 100644
--- a/src/PowderCoating.Shared/Constants/AppConstants.cs
+++ b/src/PowderCoating.Shared/Constants/AppConstants.cs
@@ -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
}
+
+ ///
+ /// 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.
+ ///
+ 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";
+ }
+ }
}
diff --git a/src/PowderCoating.Web/Controllers/AccountDataExportController.cs b/src/PowderCoating.Web/Controllers/AccountDataExportController.cs
index 8466e2e..880d2c4 100644
--- a/src/PowderCoating.Web/Controllers/AccountDataExportController.cs
+++ b/src/PowderCoating.Web/Controllers/AccountDataExportController.cs
@@ -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) ─────
+
+ ///
+ /// Fetches all non-deleted customers for the company, including PricingTier (needed by
+ /// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
+ /// IgnoreQueryFilters because ITenantContext may be null for expired accounts.
+ ///
+ private Task> FetchCustomersAsync(int companyId) =>
+ _db.Customers.AsNoTracking().IgnoreQueryFilters()
+ .Include(c => c.PricingTier)
+ .Where(c => c.CompanyId == companyId && !c.IsDeleted)
+ .OrderBy(c => c.CompanyName).ToListAsync();
+
+ /// Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.
+ private Task> 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();
+
+ ///
+ /// 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.
+ ///
+ private Task> 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();
+
+ /// Fetches all non-deleted invoices with Customer included.
+ private Task> FetchInvoicesAsync(int companyId) =>
+ _db.Invoices.AsNoTracking().IgnoreQueryFilters()
+ .Include(i => i.Customer)
+ .Where(i => i.CompanyId == companyId && !i.IsDeleted)
+ .OrderByDescending(i => i.InvoiceDate).ToListAsync();
+
+ ///
+ /// 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.
+ ///
+ private Task> 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();
+
+ /// Fetches all non-deleted equipment records for the company.
+ private Task> FetchEquipmentAsync(int companyId) =>
+ _db.Equipment.AsNoTracking().IgnoreQueryFilters()
+ .Where(e => e.CompanyId == companyId && !e.IsDeleted)
+ .OrderBy(e => e.EquipmentName).ToListAsync();
+
+ /// Fetches all non-deleted vendors for the company.
+ private Task> FetchVendorsAsync(int companyId) =>
+ _db.Vendors.AsNoTracking().IgnoreQueryFilters()
+ .Where(s => s.CompanyId == companyId && !s.IsDeleted)
+ .OrderBy(s => s.CompanyName).ToListAsync();
+
+ /// Fetches all non-deleted shop workers for the company.
+ private Task> FetchShopWorkersAsync(int companyId) =>
+ _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
+ .Where(w => w.CompanyId == companyId && !w.IsDeleted)
+ .OrderBy(w => w.Name).ToListAsync();
+
+ ///
+ /// Fetches all users for the company. IsDeleted is intentionally omitted because
+ /// Identity users use IsActive = false for soft-deletion, not the base-entity flag.
+ ///
+ private Task> FetchUsersAsync(int companyId) =>
+ _db.Users.AsNoTracking().IgnoreQueryFilters()
+ .Where(u => u.CompanyId == companyId)
+ .OrderBy(u => u.LastName).ToListAsync();
+
// ── Sheet builders ───────────────────────────────────────────────────────
///
@@ -226,16 +302,9 @@ public class AccountDataExportController : Controller
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
}
- ///
- /// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
- /// authenticated user's company. IgnoreQueryFilters() bypasses the global EF
- /// multi-tenancy filter (which relies on ITenantContext) in favour of the explicit
- /// CompanyId == companyId predicate, making the filter independent of middleware state.
- ///
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
}
///
- /// 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
- /// JobStatusLookup and a parallel priority table; they are eagerly loaded so their
- /// DisplayName 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 so their DisplayName is
+ /// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
///
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
}
///
- /// 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
- /// ProspectCompanyName; 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 ProspectCompanyName;
+ /// fully linked quotes fall back to Customer #{id} when the navigation is null.
///
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
}
///
- /// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company.
- /// BalanceDue is a computed property (Total - AmountPaid) reflecting partial
- /// payment state without an additional aggregation query.
- /// Eagerly loads Customer so the customer name is available for the display column.
+ /// Adds an "Invoices" worksheet. BalanceDue is a computed property on the entity
+ /// (Total - AmountPaid) so no extra aggregation query is needed.
///
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);
}
- ///
- /// 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.
- ///
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);
}
- ///
- /// 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 ToString() for a readable label.
- ///
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);
}
- ///
- /// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
- ///
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);
}
- ///
- /// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
- ///
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
}
///
- /// Adds a "Users" worksheet with one row per user belonging to the company.
- /// The IsDeleted predicate is intentionally omitted because ASP.NET Identity users
- /// use IsActive = false as their soft-deletion mechanism, not the base-entity
- /// IsDeleted 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 IsActive = false for soft-deletion; IsDeleted is not applicable here.
///
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 ─────────────────────────────────────────────────────────
///
- /// Builds the customers CSV string for the company.
- /// Column names match exactly so the file can be re-imported
+ /// Column names match CustomerImportDto exactly so the file can be re-imported
/// via Tools → Bulk Import without any manual header editing.
///
private async Task 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
}
///
- /// Builds the jobs CSV string for the company.
- /// Column names match 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 JobImportDto exactly so the file can be re-imported.
+ /// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
///
private async Task 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();
}
- ///
- /// Builds the quotes CSV string for the company.
- /// Column names match exactly so the file can be re-imported.
- ///
+ /// Column names match QuoteImportDto exactly so the file can be re-imported.
private async Task 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
}
///
- /// 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.
///
private async Task 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();
}
- ///
- /// Builds the inventory CSV string for the company.
- /// Column names match exactly so the file can be re-imported.
- ///
+ /// Column names match InventoryItemImportDto exactly so the file can be re-imported.
private async Task 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();
}
- ///
- /// Builds the equipment CSV string for the company.
- /// Column names match exactly so the file can be re-imported.
- ///
+ /// Column names match EquipmentImportDto exactly so the file can be re-imported.
private async Task 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();
}
- ///
- /// Builds the vendors CSV string for the company.
- /// Column names match exactly so the file can be re-imported.
- ///
+ /// Column names match VendorImportDto exactly so the file can be re-imported.
private async Task 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();
}
- ///
- /// Builds the shop workers CSV string for the company.
- /// Column names match exactly so the file can be re-imported.
- ///
+ /// Column names match ShopWorkerImportDto exactly so the file can be re-imported.
private async Task 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
}
///
- /// Builds the users CSV string for the company.
- /// Like , the IsDeleted predicate is omitted because
- /// Identity users use IsActive 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 and .
///
private async Task 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)
diff --git a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
index 465ae4e..e0b42c9 100644
--- a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
+++ b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
@@ -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;
diff --git a/src/PowderCoating.Web/Controllers/AppointmentsController.cs b/src/PowderCoating.Web/Controllers/AppointmentsController.cs
index fddcb6e..7dc0181 100644
--- a/src/PowderCoating.Web/Controllers/AppointmentsController.cs
+++ b/src/PowderCoating.Web/Controllers/AppointmentsController.cs
@@ -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
///
private async Task 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}";
}
///
diff --git a/src/PowderCoating.Web/Controllers/BillsController.cs b/src/PowderCoating.Web/Controllers/BillsController.cs
index 5464c9c..98fcb43 100644
--- a/src/PowderCoating.Web/Controllers/BillsController.cs
+++ b/src/PowderCoating.Web/Controllers/BillsController.cs
@@ -321,84 +321,90 @@ public class BillsController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
+ Bill? bill = null;
- var bill = _mapper.Map(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(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)
{
diff --git a/src/PowderCoating.Web/Controllers/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs
index c4b6daf..48d9c63 100644
--- a/src/PowderCoating.Web/Controllers/CustomersController.cs
+++ b/src/PowderCoating.Web/Controllers/CustomersController.cs
@@ -943,13 +943,18 @@ public class CustomersController : Controller
///
private async Task 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}";
}
///
diff --git a/src/PowderCoating.Web/Controllers/ExpensesController.cs b/src/PowderCoating.Web/Controllers/ExpensesController.cs
index 8fb04a2..2499f51 100644
--- a/src/PowderCoating.Web/Controllers/ExpensesController.cs
+++ b/src/PowderCoating.Web/Controllers/ExpensesController.cs
@@ -164,28 +164,34 @@ public class ExpensesController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
+ Expense? expense = null;
- var expense = _mapper.Map(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(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)
diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs
index 22cd5f1..c837a88 100644
--- a/src/PowderCoating.Web/Controllers/JobsController.cs
+++ b/src/PowderCoating.Web/Controllers/JobsController.cs
@@ -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(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(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.";
diff --git a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs
index c2947b5..8e8c873 100644
--- a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs
+++ b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs
@@ -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
///
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
///
private async Task 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}";
}
///
diff --git a/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs b/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs
index 7c314b1..21f9398 100644
--- a/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs
+++ b/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs
@@ -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";
diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs
index 32b7105..e334360 100644
--- a/src/PowderCoating.Web/Controllers/QuotesController.cs
+++ b/src/PowderCoating.Web/Controllers/QuotesController.cs
@@ -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 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.
///
public async Task 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(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));
diff --git a/src/PowderCoating.Web/Helpers/AccountingDropdownHelper.cs b/src/PowderCoating.Web/Helpers/AccountingDropdownHelper.cs
index 392a980..34faa96 100644
--- a/src/PowderCoating.Web/Helpers/AccountingDropdownHelper.cs
+++ b/src/PowderCoating.Web/Helpers/AccountingDropdownHelper.cs
@@ -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}";