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:
2026-05-09 22:42:39 -04:00
parent edd7389d7d
commit 379b0de885
15 changed files with 394 additions and 359 deletions
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
await _unitOfWork.CompleteAsync();
}
/// <summary>
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
/// decide the direction of the balance adjustment.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
_ => false
};
}
@@ -0,0 +1,41 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Single source of truth for double-entry sign conventions shared by
/// <see cref="AccountBalanceService"/> and <see cref="LedgerService"/>.
/// Centralised here so that adding a new AccountSubType only requires
/// one edit rather than two independently maintained switch expressions.
/// </summary>
internal static class AccountingRules
{
/// <summary>
/// Returns <c>true</c> for sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). Sub-type is used rather than AccountType
/// because it is constrained to a known enum set and cannot be
/// misconfigured by a user. Expense enum values are ≥ 50 by convention,
/// allowing a catch-all range match for any future expense sub-types.
/// </summary>
internal static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
}
@@ -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
};
}
/// <summary>
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
/// <summary>
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
/// by summing all activity prior to that date across every transaction source and adding
@@ -375,7 +345,7 @@ public class LedgerService : ILedgerService
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
/// The sign convention follows <see cref="AccountingRules.IsNormalDebitBalance"/>: debits increase debit-normal
/// accounts and credits increase credit-normal accounts.
/// </summary>
private async Task<decimal> ComputePriorBalanceAsync(
@@ -134,4 +134,42 @@ public static class AppConstants
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
}
/// <summary>
/// String codes stored in the JobStatusLookup and QuoteStatusLookup tables.
/// Using constants here means a DB code rename only requires one code change,
/// not a grep-and-replace across every controller.
/// </summary>
public static class StatusCodes
{
public static class Job
{
public const string Pending = "PENDING";
public const string Quoted = "QUOTED";
public const string Approved = "APPROVED";
public const string InPreparation = "IN_PREPARATION";
public const string Sandblasting = "SANDBLASTING";
public const string MaskingTaping = "MASKING_TAPING";
public const string Cleaning = "CLEANING";
public const string InOven = "IN_OVEN";
public const string Coating = "COATING";
public const string Curing = "CURING";
public const string QualityCheck = "QUALITY_CHECK";
public const string Completed = "COMPLETED";
public const string ReadyForPickup = "READY_FOR_PICKUP";
public const string Delivered = "DELIVERED";
public const string OnHold = "ON_HOLD";
public const string Cancelled = "CANCELLED";
}
public static class Quote
{
public const string Draft = "DRAFT";
public const string Sent = "SENT";
public const string Approved = "APPROVED";
public const string Rejected = "REJECTED";
public const string Converted = "CONVERTED";
public const string Expired = "EXPIRED";
}
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Drawing;
@@ -207,6 +208,81 @@ public class AccountDataExportController : Controller
writer.Write(content);
}
// ── Data fetchers (single query per entity, superset of includes for both XLSX + CSV) ─────
/// <summary>
/// Fetches all non-deleted customers for the company, including <c>PricingTier</c> (needed by
/// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
/// <c>IgnoreQueryFilters</c> because <c>ITenantContext</c> may be null for expired accounts.
/// </summary>
private Task<List<Customer>> FetchCustomersAsync(int companyId) =>
_db.Customers.AsNoTracking().IgnoreQueryFilters()
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.OrderBy(c => c.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.</summary>
private Task<List<Job>> FetchJobsAsync(int companyId) =>
_db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
/// <summary>
/// Fetches all non-deleted quotes with Customer and QuoteStatus included.
/// The XLSX path only needs QuoteStatus; the CSV path also uses Customer — the superset here
/// avoids a second query when both formats include this sheet in the same request.
/// </summary>
private Task<List<Quote>> FetchQuotesAsync(int companyId) =>
_db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Include(q => q.Customer).Include(q => q.QuoteStatus)
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.OrderByDescending(q => q.QuoteDate).ToListAsync();
/// <summary>Fetches all non-deleted invoices with Customer included.</summary>
private Task<List<Invoice>> FetchInvoicesAsync(int companyId) =>
_db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.Customer)
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.OrderByDescending(i => i.InvoiceDate).ToListAsync();
/// <summary>
/// Fetches all non-deleted inventory items with PrimaryVendor and InventoryCategory included.
/// Only the CSV path uses these navigations; XLSX reads only scalar fields but the join is cheap.
/// </summary>
private Task<List<InventoryItem>> FetchInventoryAsync(int companyId) =>
_db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.PrimaryVendor).Include(i => i.InventoryCategory)
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.OrderBy(i => i.Name).ToListAsync();
/// <summary>Fetches all non-deleted equipment records for the company.</summary>
private Task<List<Equipment>> FetchEquipmentAsync(int companyId) =>
_db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
.OrderBy(e => e.EquipmentName).ToListAsync();
/// <summary>Fetches all non-deleted vendors for the company.</summary>
private Task<List<Vendor>> FetchVendorsAsync(int companyId) =>
_db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name).ToListAsync();
/// <summary>
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
/// </summary>
private Task<List<ApplicationUser>> FetchUsersAsync(int companyId) =>
_db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId)
.OrderBy(u => u.LastName).ToListAsync();
// ── Sheet builders ───────────────────────────────────────────────────────
/// <summary>
@@ -226,16 +302,9 @@ public class AccountDataExportController : Controller
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
}
/// <summary>
/// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
/// authenticated user's company. <c>IgnoreQueryFilters()</c> bypasses the global EF
/// multi-tenancy filter (which relies on <c>ITenantContext</c>) in favour of the explicit
/// <c>CompanyId == companyId</c> predicate, making the filter independent of middleware state.
/// </summary>
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var data = await FetchCustomersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
@@ -256,18 +325,13 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Adds a "Jobs" worksheet with one row per non-deleted job belonging to the company.
/// Job status and priority are lookup-table entities (not enums) stored in
/// <c>JobStatusLookup</c> and a parallel priority table; they are eagerly loaded so their
/// <c>DisplayName</c> property is available without additional queries.
/// If a lookup navigation is null (data anomaly), the raw FK integer is written as a fallback.
/// Adds a "Jobs" worksheet. Job status and priority are lookup-table entities (not enums);
/// they are eagerly loaded by <see cref="FetchJobsAsync"/> so their <c>DisplayName</c> is
/// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
/// </summary>
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var data = await FetchJobsAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" };
@@ -289,16 +353,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Adds a "Quotes" worksheet with one row per non-deleted quote belonging to the company.
/// Prospect-only quotes (before they are linked to a customer record) show
/// <c>ProspectCompanyName</c>; fully linked quotes fall back to the customer FK integer when
/// the navigation cannot be resolved — ensuring no row has a blank identifier column.
/// Adds a "Quotes" worksheet. Prospect-only quotes show <c>ProspectCompanyName</c>;
/// fully linked quotes fall back to <c>Customer #{id}</c> when the navigation is null.
/// </summary>
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var data = await FetchQuotesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
@@ -317,16 +377,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company.
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) reflecting partial
/// payment state without an additional aggregation query.
/// Eagerly loads <c>Customer</c> so the customer name is available for the display column.
/// Adds an "Invoices" worksheet. <c>BalanceDue</c> is a computed property on the entity
/// (<c>Total - AmountPaid</c>) so no extra aggregation query is needed.
/// </summary>
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var data = await FetchInvoicesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
@@ -348,15 +404,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the company.
/// Items are ordered alphabetically so the exported list matches the order users typically
/// see in the application's inventory index view.
/// </summary>
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var data = await FetchInventoryAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Inventory");
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
@@ -373,14 +423,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the company.
/// Equipment status is stored as an enum and serialised via <c>ToString()</c> for a readable label.
/// </summary>
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var data = await FetchEquipmentAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Equipment");
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
@@ -398,13 +443,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
/// </summary>
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var data = await FetchVendorsAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Vendors");
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" };
WriteHeader(ws, headers, hdr);
@@ -421,13 +462,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var data = await FetchShopWorkersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
@@ -443,16 +480,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Adds a "Users" worksheet with one row per user belonging to the company.
/// The <c>IsDeleted</c> predicate is intentionally omitted because ASP.NET Identity users
/// use <c>IsActive = false</c> as their soft-deletion mechanism, not the base-entity
/// <c>IsDeleted</c> flag. All users (active and inactive) are included so the export
/// provides a complete workforce record for compliance and audit purposes.
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
/// </summary>
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var data = await FetchUsersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Users");
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" };
WriteHeader(ws, headers, hdr);
@@ -472,15 +505,12 @@ public class AccountDataExportController : Controller
// ── CSV builders ─────────────────────────────────────────────────────────
/// <summary>
/// Builds the customers CSV string for the company.
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be re-imported
/// Column names match <c>CustomerImportDto</c> exactly so the file can be re-imported
/// via Tools → Bulk Import without any manual header editing.
/// </summary>
private async Task<string> BuildCustomersCsv(int companyId)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var data = await FetchCustomersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
foreach (var c in data)
@@ -492,16 +522,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Builds the jobs CSV string for the company.
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported.
/// CustomerEmail is included (not the display name) because the importer resolves the customer FK by email.
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
/// </summary>
private async Task<string> BuildJobsCsv(int companyId)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var data = await FetchJobsAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data)
@@ -514,15 +540,10 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the quotes CSV string for the company.
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
/// </summary>
/// <summary>Column names match <c>QuoteImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildQuotesCsv(int companyId)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var data = await FetchQuotesAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data)
@@ -536,15 +557,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Builds the invoices CSV string for the company, ordered newest-first.
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with
/// first+last name concatenation as the fallback for non-commercial customers.
/// </summary>
private async Task<string> BuildInvoicesCsv(int companyId)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var data = await FetchInvoicesAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data)
@@ -557,16 +575,10 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the inventory CSV string for the company.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
/// </summary>
/// <summary>Column names match <c>InventoryItemImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildInventoryCsv(int companyId)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.PrimaryVendor)
.Include(i => i.InventoryCategory)
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var data = await FetchInventoryAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
foreach (var i in data)
@@ -577,14 +589,10 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the equipment CSV string for the company.
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
/// </summary>
/// <summary>Column names match <c>EquipmentImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildEquipmentCsv(int companyId)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var data = await FetchEquipmentAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
foreach (var e in data)
@@ -592,14 +600,10 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the vendors CSV string for the company.
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
/// </summary>
/// <summary>Column names match <c>VendorImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildVendorsCsv(int companyId)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var data = await FetchVendorsAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
foreach (var s in data)
@@ -607,14 +611,10 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the shop workers CSV string for the company.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var data = await FetchShopWorkersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
@@ -623,15 +623,12 @@ public class AccountDataExportController : Controller
}
/// <summary>
/// Builds the users CSV string for the company.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> predicate is omitted because
/// Identity users use <c>IsActive</c> for soft-deletion; all users are exported for
/// completeness and compliance.
/// All users (active and inactive) are exported for completeness and compliance — mirrors
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
/// </summary>
private async Task<string> BuildUsersCsv(int companyId)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var data = await FetchUsersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
foreach (var u in data)
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -102,7 +102,7 @@ public class AiQuickQuoteController : Controller
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
// Draft status — nullable FK, gracefully absent if lookup not seeded
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var now = DateTime.UtcNow;
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -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}";