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}";