From 7735fe3cceb771bb5f696b30d89c1c6c81a2c52e Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Thu, 11 Jun 2026 13:20:04 -0400 Subject: [PATCH] Demo data realism + invoice resend via SMS on any status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed data fixes: - Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added entities — root cause of all "same month" chart issues - Customer seeder: generates 15 customers/month from Jan → current month; keeps 10 commercial anchors in deterministic order for job seeder index map - Invoice seeder: historical range bumped from 2→8 paid invoices/month so P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses) - Month -1 bumped to 7 paid invoices to stay above expenses - Jobs: set UpdatedAt to historical event date so analytics don't need null fallback - Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs - SeedDataService: inject IAccountBalanceService; auto-recalculate account balances after seeding; patch checking/savings opening balances unconditionally on reset - Customer list: sort by CompanyName ?? ContactLastName so individuals and commercial accounts interleave instead of appearing as two blocks Invoice resend: - ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only resend no longer requires an email address on file - Ensures PublicViewToken exists before SMS so the view link is always valid - canResend in Details view now allows Paid invoices (removed != Paid guard) - Resend button shows channel-choice modal when customer has both email + SMS, direct SMS button when SMS only, or email button when email only - New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice - resendInvoice() JS updated to pass sendEmail/sendSms query params Co-Authored-By: Claude Sonnet 4.6 --- .../Data/ApplicationDbContext.cs | 4 +- .../Services/SeedDataService.Accounts.cs | 7 +- .../Services/SeedDataService.Bills.cs | 164 ++++++-- .../Services/SeedDataService.Customers.cs | 238 ++++++++---- .../SeedDataService.InventoryTransactions.cs | 49 ++- .../Services/SeedDataService.Invoices.cs | 32 +- .../Services/SeedDataService.Jobs.cs | 366 +++++++++++------- .../Services/SeedDataService.Quotes.cs | 195 +++++++--- .../Services/SeedDataService.Remove.cs | 329 +++++++++++----- .../Services/SeedDataService.Workers.cs | 27 +- .../Services/SeedDataService.cs | 60 ++- .../Controllers/CustomersController.cs | 6 +- .../Controllers/InvoicesController.cs | 53 ++- .../Controllers/ReportsController.cs | 13 +- .../Controllers/SeedDataController.cs | 2 +- .../Views/Invoices/Details.cshtml | 84 +++- 16 files changed, 1142 insertions(+), 487 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs index 0ff0eb6..18f3991 100644 --- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs @@ -2265,7 +2265,9 @@ modelBuilder.Entity() if (entry.State == EntityState.Added) { - entity.CreatedAt = DateTime.UtcNow; + // Only stamp if not already set — seeders set historical dates and rely on them being preserved. + if (entity.CreatedAt == default) + entity.CreatedAt = DateTime.UtcNow; entity.CreatedBy = currentUser; // Auto-set CompanyId for new entities (if not already set) diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs index 7303018..381913a 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs @@ -44,8 +44,11 @@ public partial class SeedDataService var accounts = new List { // ── ASSETS ──────────────────────────────────────────────────────── - new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now }, - new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now }, + // Opening balances represent accumulated cash before the 12-month seeded history window. + // Without them, 12 months of seeded expenses outpace ~3 months of seeded revenue and + // the checking account shows a large negative — unrealistic for a demo. + new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", OpeningBalance = 75_000m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 75_000m, CompanyId = company.Id, CreatedAt = now }, + new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", OpeningBalance = 14_500m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 14_500m, CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now }, diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Bills.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Bills.cs index ecd85bb..750c89f 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Bills.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Bills.cs @@ -126,6 +126,105 @@ public partial class SeedDataService return bill; } + // ── ELECTRIC UTILITY — months −12 through −4 (paid) ───────────────── + // Monthly bills going back 12 months fill the AP and expense trend charts. + // Amounts reflect seasonal variation (higher summer AC, higher winter heat). + var elecAmounts = new decimal[] { 640m, 620m, 595m, 580m, 570m, 558m, 545m, 562m, 574m }; + for (int m = 12; m >= 4; m--) + { + var bd = now.AddDays(-(m * 30 + 2)); + var dd = bd.AddDays(15); + var pd = dd.AddDays(2); + var amt = elecAmounts[12 - m]; + await AddBill(new Bill + { + VendorInvoiceNumber = $"ELEC-HIST-{m:D2}", + VendorId = fallback.Id, + APAccountId = apAccount.Id, + BillDate = bd, + DueDate = dd, + Status = BillStatus.Paid, + Terms = "Due on Receipt", + Memo = $"Electric — {m} months ago", + SubTotal = amt, + Total = amt, + AmountPaid = amt, + CreatedAt = bd, + LineItems = { new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = amt, Amount = amt, DisplayOrder = 1 } } + }, new BillPayment + { + VendorId = fallback.Id, + BankAccountId = checkingAccount.Id, + PaymentDate = pd, + Amount = amt, + PaymentMethod = PaymentMethod.BankTransferACH, + Memo = $"Electric bill — auto pay (month -{m})" + }); + } + + // ── POWDER ORDERS — months −12, −9, −6 (quarterly history) ─────────── + await AddBill(new Bill + { + VendorInvoiceNumber = "PP-61041", + VendorId = (prismatic ?? fallback).Id, + APAccountId = apAccount.Id, + BillDate = now.AddDays(-365), + DueDate = now.AddDays(-335), + Status = BillStatus.Paid, + Terms = "Net 30", + Memo = "Annual powder restock — month 12", + SubTotal = 1_080.00m, Total = 1_080.00m, AmountPaid = 1_080.00m, + CreatedAt = now.AddDays(-365), + LineItems = + { + new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 }, + new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 160.00m, Amount = 320.00m, DisplayOrder = 2 }, + new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 2, UnitPrice = 152.00m, Amount = 304.00m, DisplayOrder = 3 }, + new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps Assortment", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 4 } + } + }, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-335), Amount = 1_080.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-61041 — paid in full" }); + + await AddBill(new Bill + { + VendorInvoiceNumber = "PP-68820", + VendorId = (columbia ?? fallback).Id, + APAccountId = apAccount.Id, + BillDate = now.AddDays(-275), + DueDate = now.AddDays(-245), + Status = BillStatus.Paid, + Terms = "Net 30", + Memo = "Quarterly specialty colors — month 9", + SubTotal = 940.00m, Total = 940.00m, AmountPaid = 940.00m, + CreatedAt = now.AddDays(-275), + LineItems = + { + new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 }, + new BillLineItem { AccountId = powderAccount?.Id, Description = "Safety Yellow Powder — 25 lbs", Quantity = 2, UnitPrice = 115.00m, Amount = 230.00m, DisplayOrder = 2 }, + new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 275.00m, Amount = 275.00m, DisplayOrder = 3 } + } + }, new BillPayment { VendorId = (columbia ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-245), Amount = 940.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-68820 — paid in full" }); + + await AddBill(new Bill + { + VendorInvoiceNumber = "PP-73110", + VendorId = (prismatic ?? fallback).Id, + APAccountId = apAccount.Id, + BillDate = now.AddDays(-185), + DueDate = now.AddDays(-155), + Status = BillStatus.Paid, + Terms = "Net 30", + Memo = "Quarterly powder restock — month 6", + SubTotal = 1_020.00m, Total = 1_020.00m, AmountPaid = 1_020.00m, + CreatedAt = now.AddDays(-185), + LineItems = + { + new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 4, UnitPrice = 89.00m, Amount = 356.00m, DisplayOrder = 1 }, + new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 3, UnitPrice = 86.50m, Amount = 259.50m, DisplayOrder = 2 }, + new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Bronze Powder — 25 lbs", Quantity = 2, UnitPrice = 138.00m, Amount = 276.00m, DisplayOrder = 3 }, + new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives",Quantity = 1, UnitPrice = 128.50m, Amount = 128.50m, DisplayOrder = 4 } + } + }, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-155), Amount = 1_020.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-73110 — paid in full" }); + // ── POWDER ORDERS ───────────────────────────────────────────────────── // Month -3: Large powder restock — Paid @@ -694,50 +793,55 @@ public partial class SeedDataService seeded++; } - // ── SHOP RENT — 3 months ────────────────────────────────────────────── + // ── SHOP RENT — 12 months ───────────────────────────────────────────── + // Monthly rent payments going back 12 months fill the P&L expense chart. var rentAccount = rentAcct ?? fallbackExpense; - await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago"); - await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago"); - await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month"); - await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month"); + for (int m = 12; m >= 1; m--) + await AddExp(now.AddDays(-(m * 30 + 2)), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, $"Shop rent — {m} month{(m == 1 ? "" : "s")} ago"); + await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month"); - // ── NATURAL GAS — 3 months ──────────────────────────────────────────── + // ── NATURAL GAS — 12 months ─────────────────────────────────────────── + // Gas usage varies seasonally — higher in winter months. var utilAccount = utilitiesAcct ?? fallbackExpense; - await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago"); - await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago"); - await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month"); + decimal[] gasAmts = [310m, 295m, 260m, 218m, 185m, 172m, 168m, 179m, 196m, 225m, 255m, 241m]; + for (int m = 12; m >= 1; m--) + await AddExp(now.AddDays(-(m * 30 - 2)), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, gasAmts[12 - m], $"Natural gas — {m} month{(m == 1 ? "" : "s")} ago"); - // ── INSURANCE ───────────────────────────────────────────────────────── + // ── INSURANCE — quarterly (4 payments over 12 months) ───────────────── var insAccount = insuranceAcct ?? fallbackExpense; - await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1"); - await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2"); + await AddExp(now.AddDays(-365), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1 (prior year)"); + await AddExp(now.AddDays(-274), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2"); + await AddExp(now.AddDays(-182), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q3"); + await AddExp(now.AddDays(-91), null, insAccount, checkingAccount, PaymentMethod.Check, 810.00m, "Business liability insurance — quarterly premium Q4 (rate increase)"); - // ── MARKETING / ADVERTISING ─────────────────────────────────────────── + // ── MARKETING / ADVERTISING — monthly for 12 months ────────────────── var adAccount = advertisingAcct ?? fallbackExpense; - await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign"); - await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign"); - await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign"); - await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly"); + decimal[] adAmts = [120m, 120m, 135m, 150m, 150m, 165m, 150m, 165m, 175m, 175m, 175m, 175m]; + for (int m = 12; m >= 1; m--) + await AddExp(now.AddDays(-(m * 30 + 5)), null, adAccount, cc, PaymentMethod.CreditDebitCard, adAmts[12 - m], "Google Ads — local search campaign"); + await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly"); await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month"); - // ── SOFTWARE SUBSCRIPTIONS ──────────────────────────────────────────── + // ── SOFTWARE SUBSCRIPTIONS — 12 months ─────────────────────────────── var swAccount = officeSuppliesAcct ?? fallbackExpense; - await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); - await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); - await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); - await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); + for (int m = 12; m >= 1; m--) + await AddExp(now.AddDays(-(m * 30)), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); + await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — current month"); - // ── OFFICE SUPPLIES ─────────────────────────────────────────────────── + // ── OFFICE SUPPLIES — quarterly ─────────────────────────────────────── var offAccount = officeSuppliesAcct ?? fallbackExpense; - await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens"); - await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels"); + var firstVendorId = vendors.FirstOrDefault()?.Id; + await AddExp(now.AddDays(-270), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 58.90m, "Office supplies — printer paper, labels, pens"); + await AddExp(now.AddDays(-180), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens"); + await AddExp(now.AddDays(-90), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 71.25m, "Office supplies — printer paper, labels, pens"); + await AddExp(now.AddDays(-8), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels"); - // ── BANK FEES ───────────────────────────────────────────────────────── + // ── BANK FEES — 12 months ───────────────────────────────────────────── var bankAccount = bankChargesAcct ?? fallbackExpense; - await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees"); - await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees"); - await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees"); - await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees"); + decimal[] feeAmts = [24m, 25m, 26m, 28m, 27m, 29m, 28m, 30m, 31m, 29m, 31m, 32m]; + for (int m = 12; m >= 1; m--) + await AddExp(now.AddDays(-(m * 30 - 1)), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, feeAmts[12 - m], "Monthly card processing fees"); + await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees — current month"); return seeded; } diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs index 15b2d1d..7bc8af4 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs @@ -6,38 +6,35 @@ namespace PowderCoating.Infrastructure.Services; public partial class SeedDataService { /// - /// Seeds 27 realistic customers for the demo company: 10 commercial accounts and - /// 17 individual/non-commercial customers, all anchored to the Raleigh-Durham NC area. + /// Seeds customers at a rate of 15 per calendar month from January of the current year + /// through the current month, mimicking a real shop that acquires customers steadily + /// as the year progresses. Seeding in January produces 15 customers; seeding in June + /// produces 90 (15 × 6 months). /// /// /// - /// The NC geographic identity (Carolina Fabrication, Apex Motorsports, Triangle Offroad, - /// Raleigh Architectural Metals, etc.) gives tutorial recordings and marketing screenshots - /// a coherent sense of place rather than a scatter of US cities. + /// The first 27 customers are always the hand-crafted anchor accounts (10 commercial, + /// 17 individual) inserted in a deterministic, index-stable order. + /// maps customer indices 0–9 to specific commercial price profiles, so the commercial + /// anchors must always occupy the lowest database IDs for this company. /// /// - /// Revenue is deliberately top-heavy: Carolina Fabrication (Platinum) and Apex Motorsports - /// (Gold) carry the majority of simulated revenue so the Revenue by Customer report shows - /// a realistic Pareto distribution from day one. + /// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally + /// generated individual customers drawn from NC-area name and city pools, so the + /// New Customers per Month chart shows a consistent 15-bar pattern regardless of + /// the reseed date. /// /// - /// Wake County Fleet Services is seeded as tax-exempt to demonstrate the tax-exempt - /// workflow (0% tax on quotes and invoices, ★ marker in dropdowns). - /// - /// - /// Each customer is inserted individually so a duplicate-email collision on any single - /// record is converted to a warning rather than aborting the entire batch. + /// Idempotency: returns 0 immediately if any non-deleted customers already exist for + /// this company (they are removed by the reset sweep before a full reseed). /// /// /// The tenant company to seed customers for. - /// - /// A tuple of (seededCount, warnings) where warnings list any skipped customers. - /// + /// A tuple of (seededCount, warnings) where warnings list skipped records. private async Task<(int seededCount, List warnings)> SeedCustomersAsync(Company company) { var warnings = new List(); int seededCount = 0; - int skippedCount = 0; var now = DateTime.UtcNow; var existingCount = await _context.Set() @@ -57,101 +54,176 @@ public partial class SeedDataService var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold"); var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum"); - // ── Local helpers ────────────────────────────────────────────────────── + // ── Anchor customer builders (commercial and individual) ────────────── Customer Comm(string co, string fn, string ln, string em, string ph, string city, string st, string zip, string terms, decimal credit, decimal bal, - string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) => + string tax, PricingTier? tier, string notes, bool taxExempt = false) => new Customer { CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph, City = city, State = st, ZipCode = zip, IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal, PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt, - IsActive = true, - LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)), - GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months) + IsActive = true, GeneralNotes = notes, CompanyId = company.Id }; Customer Indiv(string fn, string ln, string em, string ph, - string city, string st, string zip, string notes, int months) => + string city, string st, string zip, string notes) => new Customer { ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph, City = city, State = st, ZipCode = zip, IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true, - LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)), - GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months) + GeneralNotes = notes, CompanyId = company.Id }; - var customers = new List + // ── 27 hand-crafted anchors — ORDER IS CRITICAL ─────────────────────── + // SeedJobsAsync.CustomerProfile() maps indices 0–9 to commercial accounts + // and 10–26 to known individual accounts. Never reorder these. + var anchors = new List { - // ─── Commercial Customers (10) — NC / Triangle Area ──────────────── + // Commercial (ci 0–9) — drive the Revenue by Customer report story + Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly"), + Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components"), + Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers"), + Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly"), + Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates"), + Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing"), + Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames"), + Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels"), + Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures"), + Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", true), - // Top-revenue accounts — drive the Revenue by Customer report story - Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly", 18), - Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components", 15), - Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers", 12), - Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly", 10), - - // Mid-tier commercial - Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates", 13), - Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing", 9), - Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames", 7), - Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels", 8), - - // Specialty accounts - Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures", 11), - Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", 20, true), - - // ─── Individual / Non-Commercial Customers (17) ────────────────── - - Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs", 6), - Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts", 4), - Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers", 7), - Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings", 3), - Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes", 5), - Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases", 8), - Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art", 2), - Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats", 5), - Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC","27526", "Antique fireplace grate and hardware restoration", 3), - Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build", 6), - Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC","27540", "Outdoor furniture set, 6 chairs and table", 2), - Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red", 3), - Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black", 4), - Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel", 2), - Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate", 1), - Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly", 3), - Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black", 2), + // Individual (ci 10–26) — residential work and hobby builds + Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs"), + Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts"), + Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers"), + Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings"), + Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes"), + Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases"), + Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art"), + Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats"), + Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"), + Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"), + Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"), + Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"), + Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"), + Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"), + Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"), + Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"), + Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"), }; - foreach (var customer in customers) + // Spread anchors evenly from Jan 1 of this year through the current month so the + // New Customers chart shows them distributed rather than all in one bar. + int currentMonth = now.Month; + for (int i = 0; i < anchors.Count; i++) + { + int month = 1 + (i * currentMonth / anchors.Count); + month = Math.Clamp(month, 1, currentMonth); + int day = 3 + (i % 20); + anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc); + } + + // Insert anchors in deterministic order (commercial first, then individual). + // This guarantees that job seeder indices 0-9 = commercial, 10-26 = individual. + foreach (var anchor in anchors) { try { - var existingCustomer = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Email == customer.Email - && c.CompanyId == company.Id && !c.IsDeleted); - - if (existingCustomer != null) - { - skippedCount++; - var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}"; - warnings.Add($"Skipped: {name} — email {customer.Email} already exists"); - continue; - } - - await _context.Set().AddAsync(customer); + var dup = await _context.Set().IgnoreQueryFilters() + .FirstOrDefaultAsync(c => c.Email == anchor.Email && c.CompanyId == company.Id && !c.IsDeleted); + if (dup != null) { warnings.Add($"Skipped anchor {anchor.Email} — already exists"); continue; } + await _context.Set().AddAsync(anchor); await _context.SaveChangesAsync(); seededCount++; } catch (Exception ex) { - skippedCount++; - var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}"; - warnings.Add($"Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}"); - if (_context.Entry(customer).State != EntityState.Detached) - _context.Entry(customer).State = EntityState.Detached; + warnings.Add($"Skipped anchor {anchor.Email} — {GetFriendlyErrorMessage(ex, "customer")}"); + if (_context.Entry(anchor).State != Microsoft.EntityFrameworkCore.EntityState.Detached) + _context.Entry(anchor).State = Microsoft.EntityFrameworkCore.EntityState.Detached; + } + } + + // ── Name and location pools for procedural fill customers ───────────── + string[] firstNames = + { + "Liam", "Emma", "Noah", "Olivia", "Oliver", "Charlotte", "Elijah", "Amelia", + "Aiden", "Harper", "Lucas", "Evelyn", "Mason", "Grace", "Ethan", "Sofia", + "Logan", "Chloe", "Caleb", "Victoria", "Ryan", "Zoe", "Nathan", "Lily", + "Carter", "Aria", "Dylan", "Nora", "Brandon", "Hazel", + }; + string[] lastNames = + { + "Mitchell", "Reyes", "Turner", "Phillips", "Campbell", "Parker", + "Evans", "Edwards", "Collins", "Stewart", "Fletcher", "Morris", + "Morgan", "Bell", "Murphy", "Bailey", "Rivera", "Cooper", + "Richardson","Cox", "Howard", "Ward", "Torres", "Peterson", + "Gray", "Ramirez", "James", "Watson", "Brooks", "Kelly", + }; + string[] cities = { "Raleigh", "Durham", "Cary", "Apex", "Wake Forest", "Garner", + "Morrisville", "Fuquay-Varina", "Holly Springs", "Knightdale", + "Wendell", "Clayton", "Zebulon", "Pittsboro", "Lillington" }; + string[] zips = { "27601", "27707", "27511", "27502", "27587", "27529", + "27560", "27526", "27540", "27545", + "27591", "27520", "27597", "27312", "27546" }; + string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" }; + + // Count anchors already assigned to each calendar month + var anchorsPerMonth = new int[currentMonth + 1]; + foreach (var a in anchors) + { + if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth) + anchorsPerMonth[a.CreatedAt.Month]++; + } + + // Fill each month to exactly 15 customers using procedurally generated individuals. + // These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case). + int genIdx = 0; + for (int month = 1; month <= currentMonth; month++) + { + int needed = Math.Max(0, 15 - anchorsPerMonth[month]); + var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc); + + for (int slot = 0; slot < needed; slot++, genIdx++) + { + string fn = firstNames[genIdx % firstNames.Length]; + string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length]; + string city = cities[genIdx % cities.Length]; + string zip = zips[genIdx % zips.Length]; + string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}"; + string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}"; + int day = needed > 1 ? 1 + (slot * 27 / needed) : 14; + + var gen = new Customer + { + ContactFirstName = fn, + ContactLastName = ln, + Email = email, + Phone = phone, + City = city, + State = "NC", + ZipCode = zip, + IsCommercial = false, + PaymentTerms = "Due on receipt", + IsActive = true, + CompanyId = company.Id, + CreatedAt = monthStart.AddDays(day), + }; + + try + { + await _context.Set().AddAsync(gen); + await _context.SaveChangesAsync(); + seededCount++; + } + catch (Exception ex) + { + warnings.Add($"Skipped generated customer {email} — {GetFriendlyErrorMessage(ex, "customer")}"); + if (_context.Entry(gen).State != Microsoft.EntityFrameworkCore.EntityState.Detached) + _context.Entry(gen).State = Microsoft.EntityFrameworkCore.EntityState.Detached; + } } } diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs index 8426e70..ba03d93 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs @@ -54,17 +54,25 @@ public partial class SeedDataService if (items.Count == 0) return 0; - // Load completed/delivered jobs to generate usage transactions against - var completedJobs = await _context.Set() + // Two-query approach: resolve status IDs first to avoid Include() navigation + // returning null when global query filters interact with IgnoreQueryFilters(). + var completedStatusIds = await _context.Set() .IgnoreQueryFilters() - .Where(j => j.CompanyId == company.Id && !j.IsDeleted - && (j.JobStatus.StatusCode == "COMPLETED" - || j.JobStatus.StatusCode == "READY_FOR_PICKUP" - || j.JobStatus.StatusCode == "DELIVERED")) - .Include(j => j.JobStatus) - .OrderBy(j => j.Id) + .Where(s => s.CompanyId == company.Id + && new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode)) + .Select(s => s.Id) .ToListAsync(); + var completedJobs = completedStatusIds.Count == 0 + ? new List() + : await _context.Set() + .IgnoreQueryFilters() + .Where(j => j.CompanyId == company.Id && !j.IsDeleted + && completedStatusIds.Contains(j.JobStatusId)) + .Include(j => j.JobItems) + .OrderBy(j => j.Id) + .ToListAsync(); + var now = DateTime.UtcNow; var seeded = 0; var txns = new List(); @@ -90,14 +98,18 @@ public partial class SeedDataService }); } - // ── Purchase transactions — 3 months of restocks ────────────────────── - // Simulate monthly powder purchases for top items - var powderItems = items.Take(8).ToList(); // focus on powder coat items - - foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] { - (85, 1.2m), (55, 1.0m), (25, 0.9m) }) + // ── Purchase transactions — 12 months of monthly restocks ──────────── + var powderItems = items.Take(8).ToList(); + // Quantities vary slightly month to month to give the inventory chart a natural shape + var purchaseOffsets = new (int daysAgo, decimal mult)[] { - foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle + (365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m), + (245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m), + (125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m) + }; + foreach (var (offset, qtyMult) in purchaseOffsets) + { + foreach (var item in powderItems.Take(4)) { var qty = Math.Round(25m * qtyMult, 0); txns.Add(new InventoryTransaction @@ -144,9 +156,10 @@ public partial class SeedDataService foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i))) { - // Completed date spread: most within the last 60 days - var daysAgo = 10 + (idx % 55); - var usageDate = now.AddDays(-daysAgo); + // Use the job's actual completion date so powder usage history spans the same + // 12-month window as jobs, giving the Powder Usage report non-trivial data in + // every month rather than clustering everything in the last 60 days. + var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date; // Pick a color-matched powder item (or rotate) var firstItem = job.JobItems?.FirstOrDefault(); diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Invoices.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Invoices.cs index 46e2edc..898c1e4 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Invoices.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Invoices.cs @@ -183,6 +183,23 @@ public partial class SeedDataService } } + // ── Months −12 through −4: 8 paid invoices per month ──────────────── + // 8 × avg ~$650 = ~$5,200/month collected revenue, which exceeds the seeded + // ~$4,200/month in operating expenses, so the P&L chart shows a consistent profit. + // Payment methods and tax rates alternate for variety in the ledger. + var histMethods = new[] { PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.CreditDebitCard, PaymentMethod.Cash }; + for (int monthBack = 12; monthBack >= 4; monthBack--) + { + for (int inv = 0; inv < 8; inv++) + { + var daysAgo = monthBack * 30 + 25 - (inv * 3); + var taxPct = (monthBack + inv) % 2 == 0 ? 7.5m : 0m; + var method = histMethods[(monthBack * 8 + inv) % histMethods.Length]; + var chkRef = method == PaymentMethod.Check ? $"CHK-{9000 + monthBack * 8 + inv:D4}" : null; + await Inv(InvoiceStatus.Paid, daysAgo, 30, taxPct, "Net 30", "Thank you for your business!", method, chkRef); + } + } + // ── Month −3 (6 paid) ───────────────────────────────────────────────── await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041"); @@ -201,12 +218,15 @@ public partial class SeedDataService await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053"); await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH); - // ── Month −1 (5 paid + 2 partial + 2 sent) ─────────────────────────── - await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH); - await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056"); - await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash); - await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); - await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard); + // ── Month −1 (7 paid + 2 partial + 2 sent) ─────────────────────────── + // 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses. + await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); + await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054"); + await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); + await Inv(InvoiceStatus.Paid, 24, 14, 7.5m, "Net 14", "Thank you!", PaymentMethod.Cash); + await Inv(InvoiceStatus.Paid, 20, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); + await Inv(InvoiceStatus.Paid, 18, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard); + await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1058"); await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060"); await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days."); diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Jobs.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Jobs.cs index b05b68b..912ddd0 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Jobs.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Jobs.cs @@ -7,29 +7,28 @@ namespace PowderCoating.Infrastructure.Services; public partial class SeedDataService { /// - /// Seeds 50 powder coating jobs distributed across all 16 statuses, with deliberate - /// per-customer revenue targeting so the Revenue by Customer report matches the demo - /// company narrative: Carolina Fabrication largest, then Apex Motorsports, Triangle Offroad, - /// Smith Welding, and so on down to small individual residential jobs. + /// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic + /// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold) + /// and a shuffled visit order so jobs from different customers are interleaved naturally + /// rather than appearing as per-customer blocks. /// /// /// - /// Job counts and price ranges are defined per customer index (0 = Carolina Fabrication, - /// the top-revenue account) via CustomerProfile(ci). This replaces the previous - /// modulo-formula approach that produced uniform pricing regardless of customer tier. + /// Per-customer job counts and price ranges are defined by CustomerProfile(ci) + /// where ci is the customer's position in the Id-ascending list seeded by + /// SeedCustomersAsync (0 = Carolina Fabrication, the largest account). /// /// - /// All 16 statuses are covered: the 16 statuses cycle through the first 16 jobs in - /// sequence, guaranteeing every pipeline stage is populated even with only 50 total jobs. + /// A shuffled visitSchedule (fixed seed 42) drives the outer loop so that + /// Carolina Fabrication, Apex Motorsports, and individual customers appear + /// interleaved in creation-date order rather than in consecutive customer blocks. /// /// - /// Date logic groups jobs into three buckets — completed (60–150 days ago), in-progress - /// (10–50 days ago), and early-stage (2–17 days ago or future-scheduled) — to produce - /// realistic dashboard pipeline and calendar views. - /// - /// - /// The first approved quote available for each customer is linked to their first job - /// when a match exists, demonstrating the quote-to-job conversion workflow. + /// Status pool (fixed seed 99): Delivered ×10, Completed ×8, + /// ReadyForPickup ×5, then decreasing counts for in-progress stages, with + /// exactly one Cancelled and one OnHold. + /// Priority pool (fixed seed 77): Normal 76 %, High 12 %, Urgent 8 %, + /// Rush 4 % — rush is genuinely rare. /// /// private async Task SeedJobsAsync(Company company) @@ -88,11 +87,11 @@ public partial class SeedDataService var seq = maxNum + 1; // ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ───────── - // Indices match the customer order seeded in SeedCustomersAsync: + // Indices match the customer insertion order from SeedCustomersAsync (ascending Id): // 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad, // 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks, // 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet, - // 10–26 = individual residential customers (smaller jobs) + // 10–26 = individual residential customers static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch { 0 => (7, 800m, 2500m), // Carolina Fabrication — largest account @@ -109,9 +108,9 @@ public partial class SeedDataService 11 => (1, 150m, 350m), // Sarah Jenkins 12 => (1, 200m, 400m), // Mike Thompson 13 => (2, 100m, 300m), // Robert Miller - 14 => (0, 0m, 0m), // Jennifer Clark — no jobs yet + 14 => (0, 0m, 0m), // Jennifer Clark — prospect only 15 => (1, 100m, 250m), // David Wilson - 16 => (0, 0m, 0m), // Lisa Anderson — no jobs yet + 16 => (0, 0m, 0m), // Lisa Anderson — prospect only 17 => (1, 150m, 300m), // Thomas Harris 18 => (0, 0m, 0m), // Karen White — no jobs yet 19 => (1, 250m, 500m), // James Taylor @@ -124,29 +123,99 @@ public partial class SeedDataService _ => (0, 0m, 0m), // Patricia Young — no jobs yet }; - // All 16 statuses cycle globally so every pipeline stage is visible. - string[] allStatuses = - [ - "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", - "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", - "QUALITY_CHECK", "COMPLETED", "READY_FOR_PICKUP", "DELIVERED", - "ON_HOLD", "CANCELLED" - ]; - string StatusFor(int jobIdx) => allStatuses[jobIdx % allStatuses.Length]; - - // Priority distribution weighted toward the interesting end for demo visibility. - static string PriorityFor(int i) => (i % 10) switch + // ── Status pool: realistic shop distribution (total = 50) ────────────── + // Delivered and Completed dominate; exactly one Cancelled and one OnHold. + var statusPool = new List(); + foreach (var (code, count) in new (string Code, int Count)[] { - 0 => "RUSH", - 1 => "RUSH", - 2 => "URGENT", - 3 => "URGENT", - 4 => "HIGH", - 5 => "HIGH", - _ => "NORMAL" - }; + ("DELIVERED", 10), + ("COMPLETED", 8), + ("READY_FOR_PICKUP", 5), + ("IN_PREPARATION", 4), + ("SANDBLASTING", 4), + ("COATING", 3), + ("QUALITY_CHECK", 3), + ("CURING", 3), + ("MASKING_TAPING", 2), + ("IN_OVEN", 2), + ("CLEANING", 1), + ("PENDING", 1), + ("QUOTED", 1), + ("APPROVED", 1), + ("ON_HOLD", 1), + ("CANCELLED", 1), + }) + { + for (int k = 0; k < count; k++) statusPool.Add(code); + } + // Fisher-Yates shuffle with a fixed seed so resets produce the same distribution + var statusRng = new Random(99); + for (int k = statusPool.Count - 1; k > 0; k--) + { + var swap = statusRng.Next(k + 1); + (statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]); + } - // Job item descriptions and specs — 15-item pool cycling via (jobIdx*3 + itemIdx) % 15. + // ── Priority pool: realistic distribution (total = 50) ───────────────── + // Rush jobs are genuinely rare; most work is Normal priority. + var priorityPool = new List(); + foreach (var (code, count) in new (string Code, int Count)[] + { + ("NORMAL", 38), + ("HIGH", 6), + ("URGENT", 4), + ("RUSH", 2), + }) + { + for (int k = 0; k < count; k++) priorityPool.Add(code); + } + var priorityRng = new Random(77); + for (int k = priorityPool.Count - 1; k > 0; k--) + { + var swap = priorityRng.Next(k + 1); + (priorityPool[k], priorityPool[swap]) = (priorityPool[swap], priorityPool[k]); + } + + // ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ── + // A plain Fisher-Yates on the full list clusters commercial entries because they + // outnumber individual ones 4:1; splitting into two pools and distributing + // individual jobs evenly throughout ensures the two types never appear in blocks. + var commercialVisits = new List(); + var individualVisits = new List(); + for (int ci = 0; ci < customers.Count; ci++) + { + var (numJobs, _, _) = CustomerProfile(ci); + for (int j = 0; j < numJobs; j++) + (ci < 10 ? commercialVisits : individualVisits).Add(ci); + } + var rngC = new Random(42); + for (int k = commercialVisits.Count - 1; k > 0; k--) + { + var swap = rngC.Next(k + 1); + (commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]); + } + var rngI = new Random(17); + for (int k = individualVisits.Count - 1; k > 0; k--) + { + var swap = rngI.Next(k + 1); + (individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]); + } + // Distribute individual visits at evenly-spaced positions throughout the commercial list + var visitSchedule = new List(commercialVisits.Count + individualVisits.Count); + double indStride = individualVisits.Count > 0 + ? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0) + : double.MaxValue; + int indInsertIdx = 0; + for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++) + { + while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1) + visitSchedule.Add(individualVisits[indInsertIdx++]); + visitSchedule.Add(commercialVisits[comIdx]); + } + while (indInsertIdx < individualVisits.Count) + visitSchedule.Add(individualVisits[indInsertIdx++]); + + // Job item descriptions and specs — 15-item pool cycling via (visitIdx*3 + itemIdx) % 15. static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) => ((i * 3 + j) % 15) switch { @@ -157,128 +226,145 @@ public partial class SeedDataService 4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55), 5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35), 6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50), - 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120), + 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120), 8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180), 9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35), 10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60), 11 => ("Bicycle Frame", "Candy Red", true, true, 60), 12 => ("Compressor Tank", "Safety Orange", true, false, 45), 13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50), - _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40) + _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40) }; - var jobs = new List(); - var quoteIdx = 0; - var jobIdx = 0; + var jobs = new List(); + var quoteIdx = 0; + var jobsByCustomer = new int[customers.Count]; // within-customer job counter per ci + var jobIdx = 0; // global counter for misc modulos + var inProgressCount = 0; // caps "Carried Over" card to 2 jobs + var completedJobCount = 0; // drives linear date spread over 12 months - for (int ci = 0; ci < customers.Count; ci++) + for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++) { + var ci = visitSchedule[visitIdx]; var customer = customers[ci]; - var (numJobs, minVal, maxVal) = CustomerProfile(ci); + var j = jobsByCustomer[ci]++; // within-customer job index + var (_, minVal, maxVal) = CustomerProfile(ci); - for (int j = 0; j < numJobs; j++, jobIdx++, seq++) + var statusCode = statusPool[visitIdx]; + var priorityCode = priorityPool[visitIdx]; + + // Try to link the first available approved quote for this customer + Quote? linkedQuote = null; + for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++) { - var statusCode = StatusFor(jobIdx); - var priorityCode = PriorityFor(jobIdx); - - // Try to link the first available approved quote for this customer - Quote? linkedQuote = null; - for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++) + if (approvedQuotes[qi].CustomerId == customer.Id) { - if (approvedQuotes[qi].CustomerId == customer.Id) - { - linkedQuote = approvedQuotes[qi]; - quoteIdx = qi + 1; - break; - } - // Every 4th job forcibly links any available approved quote - if (quoteIdx % 4 == 0 && qi == quoteIdx) - { - linkedQuote = approvedQuotes[qi]; - quoteIdx++; - break; - } + linkedQuote = approvedQuotes[qi]; + quoteIdx = qi + 1; + break; } + // Every 4th job, forcibly consume the next available approved quote + if (quoteIdx % 4 == 0 && qi == quoteIdx) + { + linkedQuote = approvedQuotes[qi]; + quoteIdx++; + break; + } + } - // Date logic - var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED"; - var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" + // Date logic: completed jobs furthest back, ready-for-pickup recent past, + // in-progress spread forward, pending/quoted in the future. + var isCompleted = statusCode is "COMPLETED" or "DELIVERED" or "CANCELLED"; + var isReadyForPickup = statusCode == "READY_FOR_PICKUP"; + var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK"; - int daysAgo = isCompleted ? 60 + (jobIdx % 90) - : isInProgress ? 10 + (jobIdx % 40) - : 2 + (jobIdx % 15); - var createdDate = now.AddDays(-daysAgo); - var scheduledDate = isCompleted ? createdDate.AddDays(3 + (jobIdx % 5)) - : isInProgress ? now.AddDays(-(jobIdx % 4)) - : now.AddDays(3 + (jobIdx % 12)); - var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7; - var dueDate = scheduledDate.AddDays(rushDays); - var startedDate = !isCompleted && !isInProgress ? (DateTime?)null : scheduledDate; - var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null; + if (isInProgress) inProgressCount++; + if (isCompleted) completedJobCount++; - // Per-customer value targeting: deterministic within the customer's price range - var range = maxVal - minVal; - var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m; - var itemCount = 1 + (jobIdx % 3); - var items = new List(); + // Completed jobs spread linearly ~1–12 months back for chart coverage. + // Ready-for-pickup: job finished recently, just waiting on customer. + // In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future. + int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14 + : isReadyForPickup ? 5 + (visitIdx % 8) + : isInProgress ? 10 + (visitIdx % 40) + : 2 + (visitIdx % 15); + var createdDate = now.AddDays(-daysAgo); - for (int k = 0; k < itemCount; k++) + var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5)) + : isReadyForPickup ? now.AddDays(visitIdx % 5) + : isInProgress ? now.AddDays(inProgressCount <= 3 ? -(4 - inProgressCount) : inProgressCount - 3) + : now.AddDays(3 + (visitIdx % 12)); + var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7; + var dueDate = scheduledDate.AddDays(rushDays); + var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null; + var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null; + + // Per-customer value targeting: deterministic variance within the customer's price range + var range = maxVal - minVal; + var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m; + var itemCount = 1 + (visitIdx % 3); + var items = new List(); + + for (int k = 0; k < itemCount; k++) + { + var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k); + var qty = 1 + (k % 3); + var unitPrice = linkedQuote != null && k == 0 + ? Math.Round(linkedQuote.Total / itemCount, 2) + : Math.Round(targetValue / itemCount / qty, 2); + + items.Add(new JobItem { - var (desc, color, sand, mask, mins) = ItemSpec(jobIdx, k); - var qty = 1 + (k % 3); - var unitPrice = linkedQuote != null && k == 0 - ? Math.Round(linkedQuote.Total / itemCount, 2) - : Math.Round(targetValue / itemCount / qty, 2); - - items.Add(new JobItem - { - Description = desc, - Quantity = qty, - ColorName = color, - SurfaceAreaSqFt = 10m + k * 3.5m, - UnitPrice = unitPrice, - TotalPrice = unitPrice * qty, - LaborCost = Math.Round(unitPrice * qty * 0.35m, 2), - RequiresSandblasting = sand, - RequiresMasking = mask, - EstimatedMinutes = mins, - CompanyId = company.Id, - CreatedAt = createdDate - }); - } - - var finalPrice = items.Sum(it => it.TotalPrice); - var quotedPrice = linkedQuote?.Total ?? Math.Round(finalPrice * 1.05m, 2); - - jobs.Add(new Job - { - JobNumber = $"{prefix}{seq:D4}", - CustomerId = customer.Id, - QuoteId = linkedQuote?.Id, - AssignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null, - Description = linkedQuote?.Description - ?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}", - JobStatusId = jobStatuses[statusCode], - JobPriorityId = jobPriorities[priorityCode], - ScheduledDate = scheduledDate, - StartedDate = startedDate, - CompletedDate = completedDate, - DueDate = dueDate, - QuotedPrice = quotedPrice, - FinalPrice = finalPrice, - IsRushJob = priorityCode == "RUSH", - CustomerPO = customer.IsCommercial && jobIdx % 3 == 0 ? $"PO-{40000 + jobIdx}" : null, - SpecialInstructions = jobIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." : - jobIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null, - InternalNotes = jobIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null, - RequiresCustomerApproval = jobIdx % 5 == 0, - IsCustomerApproved = jobIdx % 5 != 0 || !isInProgress, - JobItems = items, - CompanyId = company.Id, - CreatedAt = createdDate + Description = desc, + Quantity = qty, + ColorName = color, + SurfaceAreaSqFt = 10m + k * 3.5m, + UnitPrice = unitPrice, + TotalPrice = unitPrice * qty, + LaborCost = Math.Round(unitPrice * qty * 0.35m, 2), + RequiresSandblasting = sand, + RequiresMasking = mask, + EstimatedMinutes = mins, + CompanyId = company.Id, + CreatedAt = createdDate }); } + + var finalPrice = items.Sum(it => it.TotalPrice); + var quotedPrice = linkedQuote?.Total ?? Math.Round(finalPrice * 1.05m, 2); + + jobs.Add(new Job + { + JobNumber = $"{prefix}{seq:D4}", + CustomerId = customer.Id, + QuoteId = linkedQuote?.Id, + AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null, + Description = linkedQuote?.Description + ?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}", + JobStatusId = jobStatuses[statusCode], + JobPriorityId = jobPriorities[priorityCode], + ScheduledDate = scheduledDate, + StartedDate = startedDate, + CompletedDate = completedDate, + DueDate = dueDate, + QuotedPrice = quotedPrice, + FinalPrice = finalPrice, + IsRushJob = priorityCode == "RUSH", + CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null, + SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." : + visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null, + InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null, + RequiresCustomerApproval = visitIdx % 5 == 0, + IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress, + JobItems = items, + CompanyId = company.Id, + CreatedAt = createdDate, + // Set UpdatedAt to the historical event date so analytics charts group into the + // correct month. The EF interceptor only stamps UpdatedAt on Modified saves, + // leaving it null for seeded entities, which the analytics filter treats as excluded. + UpdatedAt = completedDate ?? (isInProgress ? scheduledDate : (DateTime?)null) ?? createdDate + }); } await _context.Set().AddRangeAsync(jobs); diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Quotes.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Quotes.cs index 561a8bd..06043ec 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Quotes.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Quotes.cs @@ -7,32 +7,28 @@ public partial class SeedDataService { /// /// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved, - /// Rejected, and Expired — with a deliberate majority of Approved quotes so that - /// SeedJobsAsync has enough approved records to link jobs to. + /// Rejected, and Expired — with Approved as the clear majority so that SeedJobsAsync + /// has enough linked quotes to demonstrate the quote-to-job conversion workflow. /// /// /// - /// All five quote statuses are present so the Quotes Index view demonstrates the full - /// workflow from first contact to customer approval. APPROVED is the majority (16 of 35) - /// because the job seeder links to approved quotes to show the quote-to-job conversion path. + /// Customers are loaded in Id-ascending order (matching the job seeder) and a + /// shuffled visit schedule interleaves commercial and individual customers so that + /// statuses and customer types are naturally mixed rather than appearing in blocks. /// /// - /// Quotes are distributed across both commercial and individual customers, matching the - /// real-world mix where most quotes come from commercial accounts but individuals do - /// occasionally request quotes for larger projects. + /// Status pool (fixed seed 55): Approved ×18, Sent ×8, Draft ×4, + /// Rejected ×3, Expired ×2. Shuffled with Fisher-Yates (fixed seed 55) + /// so every reset produces the same interleaved sequence. /// /// - /// Item descriptions, colours, and specs are drawn from the same 15-item pool used by - /// the job seeder so that tutorial screenshots of quotes and linked jobs look consistent. + /// Per-customer quote counts mirror real business activity: top commercial accounts + /// (Carolina Fabrication, Apex, Triangle) generate the most quotes; prospect customers + /// (Jennifer Clark, Lisa Anderson, Michelle Brown) have quotes but no jobs. /// /// - /// Pricing is deliberately simple (sqft × rate + variance) rather than running through - /// IPricingCalculationService — this avoids a dependency on operating cost config that - /// may not yet be populated when seed runs. - /// - /// - /// Dates spread over 150–180 days back (5–6 months) so the historical charts on the - /// dashboard and reports show a meaningful activity curve. + /// Dates spread over 7–185 days back (roughly 6 months) with a small jitter term so + /// historical charts show a natural activity curve without obviously linear spacing. /// /// private async Task SeedQuotesAsync(Company company) @@ -52,12 +48,11 @@ public partial class SeedDataService if (quoteStatuses.Count == 0) return 0; - // Load all customers — commercial first, then individual + // Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align var allCustomers = await _context.Set() .IgnoreQueryFilters() .Where(c => c.CompanyId == company.Id && !c.IsDeleted) - .OrderByDescending(c => c.IsCommercial) - .ThenBy(c => c.Id) + .OrderBy(c => c.Id) .ToListAsync(); if (allCustomers.Count == 0) @@ -68,7 +63,6 @@ public partial class SeedDataService .FirstOrDefaultAsync(); var now = DateTime.UtcNow; - var prefix = $"QT-{now:yy}{now.Month:D2}-"; var existing = await _context.Set() .IgnoreQueryFilters() @@ -80,19 +74,98 @@ public partial class SeedDataService if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x; var seq = maxNum + 1; - // ── Status distribution: 3 Draft, 6 Sent, 16 Approved, 5 Rejected, 5 Expired ── - // APPROVED is majority so SeedJobsAsync has enough linked quotes. - static string StatusFor(int i) => i switch + // ── Per-customer quote counts (must sum to 35) ───────────────────────── + // Indices match the Id-ascending customer list from SeedCustomersAsync: + // 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad, + // 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks, + // 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet, + // 10–26 = individual residential customers + static int QuotesFor(int ci) => ci switch { - < 3 => "DRAFT", - < 9 => "SENT", - < 25 => "APPROVED", - < 30 => "REJECTED", - _ => "EXPIRED" + 0 => 4, // Carolina Fabrication — most quotes + 1 => 3, // Apex Motorsports + 2 => 3, // Triangle Offroad + 3 => 3, // Smith Welding + 4 => 2, // Raleigh Architectural Metals + 5 => 2, // East Coast Powderworks + 6 => 1, // Piedmont Metal Works + 7 => 1, // Cary Industrial Solutions + 8 => 2, // Durham Tech Equipment + 9 => 3, // Wake County Fleet Services + 10 => 1, // John Davis + 11 => 1, // Sarah Jenkins + 12 => 1, // Mike Thompson + 13 => 1, // Robert Miller + 14 => 1, // Jennifer Clark — prospect (quote only, no job) + 15 => 1, // David Wilson + 16 => 1, // Lisa Anderson — prospect + 17 => 1, // Thomas Harris + 19 => 1, // James Taylor + 20 => 1, // Michelle Brown — prospect + 21 => 1, // Chris Lee + _ => 0, }; + // ── Status pool: realistic distribution (total = 35) ─────────────────── + // Approved is the majority; Rejected and Expired are rare. + var statusPool = new List(); + foreach (var (code, count) in new (string Code, int Count)[] + { + ("APPROVED", 18), + ("SENT", 8), + ("DRAFT", 4), + ("REJECTED", 3), + ("EXPIRED", 2), + }) + { + for (int k = 0; k < count; k++) statusPool.Add(code); + } + // Fisher-Yates shuffle — fixed seed for deterministic resets + var statusRng = new Random(55); + for (int k = statusPool.Count - 1; k > 0; k--) + { + var swap = statusRng.Next(k + 1); + (statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]); + } + + // ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ── + // Same two-pool approach as the job seeder — prevents commercial customers from + // clustering at the top of any list sorted by quote number or date. + var commercialVisits = new List(); + var individualVisits = new List(); + for (int ci = 0; ci < allCustomers.Count; ci++) + { + var count = QuotesFor(ci); + for (int j = 0; j < count; j++) + (ci < 10 ? commercialVisits : individualVisits).Add(ci); + } + var rngC = new Random(42); + for (int k = commercialVisits.Count - 1; k > 0; k--) + { + var swap = rngC.Next(k + 1); + (commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]); + } + var rngI = new Random(17); + for (int k = individualVisits.Count - 1; k > 0; k--) + { + var swap = rngI.Next(k + 1); + (individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]); + } + var visitSchedule = new List(commercialVisits.Count + individualVisits.Count); + double indStride = individualVisits.Count > 0 + ? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0) + : double.MaxValue; + int indInsertIdx = 0; + for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++) + { + while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1) + visitSchedule.Add(individualVisits[indInsertIdx++]); + visitSchedule.Add(commercialVisits[comIdx]); + } + while (indInsertIdx < individualVisits.Count) + visitSchedule.Add(individualVisits[indInsertIdx++]); + // ── Item catalogue — 15 common powder coating jobs ────────────────────── - // Index by (i * 3 + j) % 15 so variety cycles independently of quote index. static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) => (i % 15) switch { @@ -103,39 +176,41 @@ public partial class SeedDataService 4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55, 18.0m), 5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35, 20.0m), 6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50, 22.0m), - 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m), + 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m), 8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180, 35.0m), 9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35, 4.0m), 10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60, 30.0m), 11 => ("Bicycle Frame", "Candy Red", true, true, 60, 6.5m), 12 => ("Compressor Tank", "Safety Orange", true, false, 45, 10.0m), 13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50, 24.0m), - _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m) + _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m) }; - var quotes = new List(); - const int Total = 35; + var quotes = new List(); + var quotesByCustomer = new int[allCustomers.Count]; // within-customer quote counter - for (int i = 0; i < Total; i++) + for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++) { - // Cycle through all customers; top 10 (commercial) appear multiple times - var customer = allCustomers[i % allCustomers.Count]; - var statusCode = StatusFor(i); + var ci = visitSchedule[visitIdx]; + var customer = allCustomers[ci]; + var j = quotesByCustomer[ci]++; // within-customer quote index (unused except for date jitter) + var statusCode = statusPool[visitIdx]; - // Spread dates over 30–180 days ago; newer quotes have lower index - var daysAgo = 180 - (int)(i * 4.5); - var quoteDate = now.AddDays(-daysAgo); + // Dates spread 7–185 days back; small jitter via ci so identical-status quotes + // for the same customer don't land on exactly the same day. + var daysAgo = Math.Max(7, 185 - visitIdx * 5 + (ci % 5)); + var quoteDate = now.AddDays(-daysAgo); var expireDate = quoteDate.AddDays(30); - var itemCount = 1 + (i % 3); + var itemCount = 1 + (visitIdx % 3); var items = new List(); - for (int j = 0; j < itemCount; j++) + for (int k = 0; k < itemCount; k++) { - var (desc, color, sand, mask, mins, sqft) = ItemSpec(i * 3 + j); - var qty = 1 + (j % 4); - var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m); - var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 6) * 5.0m, 2); + var (desc, color, sand, mask, mins, sqft) = ItemSpec(visitIdx * 3 + k); + var qty = 1 + (k % 4); + var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m); + var unitPrice = Math.Round(sqft * 8.50m * tierMult + (visitIdx % 6) * 5.0m, 2); items.Add(new QuoteItem { @@ -147,10 +222,10 @@ public partial class SeedDataService RequiresSandblasting = sand, RequiresMasking = mask, EstimatedMinutes = mins, - Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" }, - Notes = j == 0 && i % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null, - CompanyId = company.Id, - CreatedAt = quoteDate + Complexity = (visitIdx % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" }, + Notes = k == 0 && visitIdx % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null, + CompanyId = company.Id, + CreatedAt = quoteDate }); } @@ -160,7 +235,7 @@ public partial class SeedDataService var afterDiscount = subtotal - discountAmt; var taxPct = customer.IsTaxExempt ? 0m : 7.5m; var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2); - var rushFee = i % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m; + var rushFee = visitIdx % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m; var total = afterDiscount + taxAmt + rushFee; quotes.Add(new Quote @@ -170,11 +245,11 @@ public partial class SeedDataService PreparedById = preparedByUser?.Id, QuoteStatusId = quoteStatuses[statusCode], IsCommercial = customer.IsCommercial, - IsRushJob = i % 10 == 0, + IsRushJob = visitIdx % 10 == 0, QuoteDate = quoteDate, ExpirationDate = expireDate, - SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null, - ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null, + SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null, + ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null, ItemsSubtotal = subtotal, SubTotal = subtotal, DiscountPercent = discountPct, @@ -185,11 +260,11 @@ public partial class SeedDataService Total = total, Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}", Terms = customer.PaymentTerms ?? "Net 30", - Notes = i % 8 == 0 ? "Customer requested color sample before full production run." : - i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null, - CustomerPO = customer.IsCommercial && i % 2 == 0 ? $"PO-{30000 + i}" : null, - RequiresDeposit = i % 4 == 0, - DepositPercent = i % 4 == 0 ? 50m : 0m, + Notes = visitIdx % 8 == 0 ? "Customer requested color sample before full production run." : + visitIdx % 13 == 0 ? "Rush turnaround requested — 3 business days." : null, + CustomerPO = customer.IsCommercial && visitIdx % 2 == 0 ? $"PO-{30000 + visitIdx}" : null, + RequiresDeposit = visitIdx % 4 == 0, + DepositPercent = visitIdx % 4 == 0 ? 50m : 0m, QuoteItems = items, CompanyId = company.Id, CreatedAt = quoteDate, diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs index ca4f69a..bc853c3 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; namespace PowderCoating.Infrastructure.Services; @@ -79,28 +80,25 @@ public partial class SeedDataService /// /// /// - /// All queries use IgnoreQueryFilters() so that records already soft-deleted by users - /// are still found and physically removed — this prevents orphaned data from accumulating - /// in the database after partial cleanup. + /// When ForceRemoveAll = true a topologically ordered pre-sweep deletes every + /// child record for the company that has a NO_ACTION FK pointing at a parent we need + /// to delete later. This avoids FK constraint errors regardless of how much user-created + /// data has accumulated since the last seed. The sweep order mirrors the FK dependency + /// graph derived from sys.foreign_keys: + /// OvenBatchItems → PowderUsageLogs → InAppNotifications → QuotePhotos → GiftCertificates + /// → JobTemplates → CreditMemoApplications → Refunds → CreditMemos → ReworkRecords + /// → Deposits → Payments → InventoryTransactions → Invoices → Appointments + /// → VendorCreditApplications → Bills → VendorCredits → PurchaseOrders → Expenses + /// → OvenBatches → (self-referential Jobs.OriginalJobId nulled via raw SQL) + /// then the main entity blocks run in safe order. /// /// - /// Child records (job items, quote items, transactions, maintenance records, etc.) are deleted - /// first before their parent to avoid FK constraint violations. Each category is committed - /// with its own SaveChangesAsync() call so a failure in one category does not roll - /// back deletions already completed in an earlier category. - /// - /// - /// Lookup tables (job status, job priority, quote status) are intentionally NOT removed — - /// they are system-level data shared across the company's real records. + /// For the selective (non-force) path the Customers block also pre-deletes Invoices, + /// Payments, Deposits, QuotePhotos, and InAppNotifications that reference the seeded + /// customer/job/quote IDs, so those deletes succeed even when the broader pre-sweep + /// has not run. /// /// - /// ID of the tenant company whose seed data should be removed. - /// Flags controlling which data categories to delete. - /// - /// A with Success = true and a count of records removed, - /// or Success = false with an error message if the company was not found or an - /// exception was thrown. - /// public async Task RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options) { var result = new SeedDataResult { Success = true }; @@ -120,11 +118,73 @@ public partial class SeedDataService return result; } - // --- Customers (+ their jobs, quotes, and related items) --- + // ── ForceRemoveAll pre-sweep ────────────────────────────────────────── + // Deletes every record that has a NO_ACTION FK pointing at a table we delete + // later. Order follows the FK dependency graph (leaves first, roots last). + // Each tier is committed before the next so EF's change tracker stays clean. + if (options.ForceRemoveAll) + { + // Local helper: delete all rows of T for this company, return count. + // All entities inherit BaseEntity which exposes CompanyId. + async Task Sweep() where T : BaseEntity + { + var rows = await _context.Set().IgnoreQueryFilters() + .Where(e => e.CompanyId == companyId).ToListAsync(); + if (rows.Any()) _context.Set().RemoveRange(rows); + return rows.Count; + } + + // Tier 1 — pure leaf records (block nothing of their own) + await Sweep(); // FK → Jobs (Cascade by convention — sweep before jobs) + await Sweep(); // NO_ACTION → Jobs, JobItems, JobItemCoats + await Sweep(); // NO_ACTION → Jobs, JobItems, JobItemCoats + await Sweep(); // NO_ACTION → Customers, Invoices, Quotes + await Sweep(); // NO_ACTION → Quotes + await Sweep(); // NO_ACTION → Customers (GiftCertRedemptions CASCADE) + await Sweep(); // NO_ACTION → Customers (JobTemplateItems CASCADE) + await _context.SaveChangesAsync(); + + // Tier 2 — credit/rework chain (each row blocks the next tier) + await Sweep(); // NO_ACTION → Bills, VendorCredits + await Sweep(); // NO_ACTION → Invoices, Payments, CreditMemos + await Sweep(); // NO_ACTION → Customers, Invoices, ReworkRecords + await _context.SaveChangesAsync(); + await Sweep(); // NO_ACTION → Jobs, JobItems (after CreditMemos gone) + await _context.SaveChangesAsync(); + + // Tier 3 — financial records that block Invoices / Jobs / Quotes + await Sweep(); // NO_ACTION → Jobs, Quotes (CASCADE from Customer anyway) + await Sweep(); // NO_ACTION → Invoices + await Sweep(); // NO_ACTION → Jobs, PurchaseOrders + await _context.SaveChangesAsync(); + await Sweep(); // NO_ACTION → Jobs (InvoiceItems, GiftCertRedemptions CASCADE) + await _context.SaveChangesAsync(); + + // Tier 4 — appointments (NO_ACTION → Customers AND Jobs) + await Sweep(); + await _context.SaveChangesAsync(); + + // Tier 5 — vendor/purchasing chain (BillLineItems.JobId NO_ACTION blocks Jobs) + await Sweep(); // NO_ACTION → Bills + await _context.SaveChangesAsync(); + await Sweep(); // CASCADE → BillLineItems, BillPayments + await Sweep(); // CASCADE → VendorCreditLineItems + await Sweep(); // CASCADE → PurchaseOrderItems + await Sweep(); // NO_ACTION → Jobs + await Sweep(); // NO_ACTION → Equipment, OvenCosts + await _context.SaveChangesAsync(); + + // Jobs have a self-referential NO_ACTION FK (OriginalJobId). NULL it before + // deleting so EF doesn't fail on ordering within the same-table batch delete. + await _context.Database.ExecuteSqlRawAsync( + "UPDATE Jobs SET OriginalJobId = NULL WHERE CompanyId = {0}", companyId); + + details.Add("✓ Pre-sweep complete: child records cleared in FK-safe order"); + } + + // ── Customers (+ jobs, quotes, invoices, and all related children) ──── if (options.Customers) { - // ForceRemoveAll: wipe every customer for the company (demo reset path). - // Normal mode: fingerprint-match by seeded email addresses (selective removal). var seededCustomerIds = options.ForceRemoveAll ? await _context.Customers.IgnoreQueryFilters() .Where(c => c.CompanyId == companyId) @@ -135,13 +195,99 @@ public partial class SeedDataService if (seededCustomerIds.Any()) { - // Jobs and their child records - var seededJobIds = await _context.Jobs - .IgnoreQueryFilters() + var seededJobIds = await _context.Jobs.IgnoreQueryFilters() .Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId)) - .Select(j => j.Id) - .ToListAsync(); + .Select(j => j.Id).ToListAsync(); + var seededQuoteIds = await _context.Quotes.IgnoreQueryFilters() + .Where(q => q.CompanyId == companyId && q.CustomerId.HasValue + && seededCustomerIds.Contains(q.CustomerId.Value)) + .Select(q => q.Id).ToListAsync(); + + // ── Pre-delete records with NO_ACTION FKs pointing at Jobs/Quotes/Customers ── + // For ForceRemoveAll these are already gone (pre-sweep above). For selective + // removal this is the only pass, so we scope to the seeded entity IDs. + + // Appointments — NO_ACTION → Customers AND Jobs + if (seededCustomerIds.Any() || seededJobIds.Any()) + { + var appts = await _context.Set().IgnoreQueryFilters() + .Where(a => a.CompanyId == companyId + && (a.CustomerId.HasValue && seededCustomerIds.Contains(a.CustomerId.Value) + || a.JobId.HasValue && seededJobIds.Contains(a.JobId.Value))) + .ToListAsync(); + if (appts.Any()) _context.Set().RemoveRange(appts); + } + + // InAppNotifications — NO_ACTION → Customers, Quotes (Invoices handled below) + if (seededCustomerIds.Any() || seededQuoteIds.Any()) + { + var nots = await _context.Set().IgnoreQueryFilters() + .Where(n => n.CompanyId == companyId + && (n.CustomerId.HasValue && seededCustomerIds.Contains(n.CustomerId.Value) + || n.QuoteId.HasValue && seededQuoteIds.Contains(n.QuoteId.Value))) + .ToListAsync(); + if (nots.Any()) _context.Set().RemoveRange(nots); + } + + // QuotePhotos — NO_ACTION → Quotes + if (seededQuoteIds.Any()) + { + var qp = await _context.Set().IgnoreQueryFilters() + .Where(p => p.QuoteId.HasValue && seededQuoteIds.Contains(p.QuoteId.Value)).ToListAsync(); + if (qp.Any()) _context.Set().RemoveRange(qp); + } + + // Deposits — NO_ACTION → Jobs, Quotes (CustomerId is CASCADE from Customer but + // we delete deposits explicitly so jobs/quotes can be deleted first) + if (seededCustomerIds.Any()) + { + var deps = await _context.Set().IgnoreQueryFilters() + .Where(d => seededCustomerIds.Contains(d.CustomerId)).ToListAsync(); + if (deps.Any()) _context.Set().RemoveRange(deps); + } + + await _context.SaveChangesAsync(); + + // Invoices — NO_ACTION → Jobs AND Customers; must be gone before Jobs are deleted. + // Collect invoice IDs first so Payments (NO_ACTION → Invoices) can be cleared. + List seededInvoiceIds = []; + if (seededCustomerIds.Any()) + { + seededInvoiceIds = await _context.Set().IgnoreQueryFilters() + .Where(i => i.CompanyId == companyId + && seededCustomerIds.Contains(i.CustomerId)) + .Select(i => i.Id).ToListAsync(); + + if (seededInvoiceIds.Any()) + { + // InAppNotifications referencing these invoices + var invNots = await _context.Set().IgnoreQueryFilters() + .Where(n => n.InvoiceId.HasValue && seededInvoiceIds.Contains(n.InvoiceId.Value)) + .ToListAsync(); + if (invNots.Any()) _context.Set().RemoveRange(invNots); + + // Payments — NO_ACTION → Invoices + var pmts = await _context.Set().IgnoreQueryFilters() + .Where(p => seededInvoiceIds.Contains(p.InvoiceId)).ToListAsync(); + if (pmts.Any()) _context.Set().RemoveRange(pmts); + + await _context.SaveChangesAsync(); + + var invoices = await _context.Set().IgnoreQueryFilters() + .Where(i => seededInvoiceIds.Contains(i.Id)).ToListAsync(); + if (invoices.Any()) + { + _context.Set().RemoveRange(invoices); // InvoiceItems CASCADE + totalRemoved += invoices.Count; + details.Add($"✓ Removed {invoices.Count} invoice(s)"); + } + + await _context.SaveChangesAsync(); + } + } + + // ── Jobs and their cascade children ───────────────────────────────── if (seededJobIds.Any()) { var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters() @@ -164,28 +310,22 @@ public partial class SeedDataService .Where(p => seededJobIds.Contains(p.JobId)).ToListAsync(); if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices); - var timeEntries = await _context.Set().IgnoreQueryFilters() + var timeEntries = await _context.Set().IgnoreQueryFilters() .Where(te => seededJobIds.Contains(te.JobId)).ToListAsync(); - if (timeEntries.Any()) _context.Set().RemoveRange(timeEntries); + if (timeEntries.Any()) _context.Set().RemoveRange(timeEntries); var jobs = await _context.Jobs.IgnoreQueryFilters() .Where(j => seededJobIds.Contains(j.Id)).ToListAsync(); _context.Jobs.RemoveRange(jobs); totalRemoved += jobs.Count; - details.Add($"✓ Removed {jobs.Count} seeded job(s)"); + details.Add($"✓ Removed {jobs.Count} job(s)"); } - // Quotes and their child records - var seededQuoteIds = await _context.Quotes - .IgnoreQueryFilters() - .Where(q => q.CompanyId == companyId && q.CustomerId.HasValue && seededCustomerIds.Contains(q.CustomerId.Value)) - .Select(q => q.Id) - .ToListAsync(); - + // ── Quotes and their cascade children ─────────────────────────────── if (seededQuoteIds.Any()) { - // Collect prediction IDs before removing items (FK is NoAction — predictions - // must be deleted after the items that reference them are gone). + // Collect AiItemPrediction IDs before removing QuoteItems (FK is NoAction — + // predictions must be orphaned after items are gone, then deleted separately). var predictionIds = await _context.QuoteItems.IgnoreQueryFilters() .Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null) .Select(qi => qi.AiPredictionId!.Value) @@ -204,25 +344,24 @@ public partial class SeedDataService .Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync(); _context.Quotes.RemoveRange(quotes); totalRemoved += quotes.Count; - details.Add($"✓ Removed {quotes.Count} seeded quote(s)"); + details.Add($"✓ Removed {quotes.Count} quote(s)"); - // Remove orphaned AI predictions now that QuoteItems no longer reference them if (predictionIds.Any()) { - var predictions = await _context.Set() + var predictions = await _context.Set() .IgnoreQueryFilters() .Where(p => predictionIds.Contains(p.Id)) .ToListAsync(); if (predictions.Any()) { - _context.Set().RemoveRange(predictions); + _context.Set().RemoveRange(predictions); totalRemoved += predictions.Count; details.Add($"✓ Removed {predictions.Count} AI prediction(s)"); } } } - // Customer notes + // Customer notes (CASCADE from Customer, but explicit for clarity) var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters() .Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync(); if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes); @@ -231,7 +370,7 @@ public partial class SeedDataService .Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync(); _context.Customers.RemoveRange(customers); totalRemoved += customers.Count; - details.Add($"✓ Removed {customers.Count} seeded customer(s)"); + details.Add($"✓ Removed {customers.Count} customer(s)"); await _context.SaveChangesAsync(); } @@ -241,7 +380,7 @@ public partial class SeedDataService } } - // --- Inventory Items --- + // ── Inventory Items ─────────────────────────────────────────────────── if (options.InventoryItems) { var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray(); @@ -259,7 +398,7 @@ public partial class SeedDataService _context.InventoryItems.RemoveRange(inventoryItems); totalRemoved += inventoryItems.Count; - details.Add($"✓ Removed {inventoryItems.Count} seeded inventory item(s)"); + details.Add($"✓ Removed {inventoryItems.Count} inventory item(s)"); await _context.SaveChangesAsync(); } else @@ -268,7 +407,7 @@ public partial class SeedDataService } } - // --- Equipment (+ maintenance records) --- + // ── Equipment (+ maintenance records) ──────────────────────────────── if (options.Equipment) { var seededEquipment = await _context.Equipment @@ -285,7 +424,7 @@ public partial class SeedDataService _context.Equipment.RemoveRange(seededEquipment); totalRemoved += seededEquipment.Count; - details.Add($"✓ Removed {seededEquipment.Count} seeded equipment record(s)"); + details.Add($"✓ Removed {seededEquipment.Count} equipment record(s)"); await _context.SaveChangesAsync(); } else @@ -294,7 +433,7 @@ public partial class SeedDataService } } - // --- Catalog Items & Categories --- + // ── Catalog Items & Categories ──────────────────────────────────────── if (options.Catalog) { var seededCategories = await _context.CatalogCategories @@ -313,12 +452,12 @@ public partial class SeedDataService { _context.CatalogItems.RemoveRange(catalogItems); totalRemoved += catalogItems.Count; - details.Add($"✓ Removed {catalogItems.Count} seeded catalog item(s)"); + details.Add($"✓ Removed {catalogItems.Count} catalog item(s)"); } _context.CatalogCategories.RemoveRange(seededCategories); totalRemoved += seededCategories.Count; - details.Add($"✓ Removed {seededCategories.Count} seeded catalog categor(y/ies)"); + details.Add($"✓ Removed {seededCategories.Count} catalog categor(y/ies)"); await _context.SaveChangesAsync(); } else @@ -327,7 +466,7 @@ public partial class SeedDataService } } - // --- Pricing Tiers --- + // ── Pricing Tiers ───────────────────────────────────────────────────── if (options.PricingTiers) { var tiers = await _context.PricingTiers @@ -339,7 +478,7 @@ public partial class SeedDataService { _context.PricingTiers.RemoveRange(tiers); totalRemoved += tiers.Count; - details.Add($"✓ Removed {tiers.Count} seeded pricing tier(s)"); + details.Add($"✓ Removed {tiers.Count} pricing tier(s)"); await _context.SaveChangesAsync(); } else @@ -348,7 +487,7 @@ public partial class SeedDataService } } - // --- Operating Costs --- + // ── Operating Costs ─────────────────────────────────────────────────── if (options.OperatingCosts) { var costs = await _context.CompanyOperatingCosts @@ -360,7 +499,7 @@ public partial class SeedDataService { _context.CompanyOperatingCosts.RemoveRange(costs); totalRemoved += costs.Count; - details.Add($"✓ Removed operating costs record"); + details.Add("✓ Removed operating costs record"); await _context.SaveChangesAsync(); } else @@ -369,10 +508,11 @@ public partial class SeedDataService } } - // --- Purchase Orders (removed alongside bills — tightly coupled in demo workflows) --- - if (options.Bills) + // ── Purchase Orders ─────────────────────────────────────────────────── + // Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal. + if (options.Bills && !options.ForceRemoveAll) { - var poIds = await _context.Set() + var poIds = await _context.Set() .IgnoreQueryFilters() .Where(p => p.CompanyId == companyId) .Select(p => p.Id) @@ -380,27 +520,28 @@ public partial class SeedDataService if (poIds.Any()) { - var poItems = await _context.Set() + var poItems = await _context.Set() .IgnoreQueryFilters() .Where(i => poIds.Contains(i.PurchaseOrderId)) .ToListAsync(); - if (poItems.Any()) _context.Set().RemoveRange(poItems); + if (poItems.Any()) _context.Set().RemoveRange(poItems); - var pos = await _context.Set() + var pos = await _context.Set() .IgnoreQueryFilters() .Where(p => poIds.Contains(p.Id)) .ToListAsync(); - _context.Set().RemoveRange(pos); + _context.Set().RemoveRange(pos); totalRemoved += pos.Count; details.Add($"✓ Removed {pos.Count} purchase order(s)"); await _context.SaveChangesAsync(); } } - // --- Vendor Bills (all bills for the company) --- - if (options.Bills) + // ── Vendor Bills ────────────────────────────────────────────────────── + // Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal. + if (options.Bills && !options.ForceRemoveAll) { - var billIds = await _context.Set() + var billIds = await _context.Set() .IgnoreQueryFilters() .Where(b => b.CompanyId == companyId) .Select(b => b.Id) @@ -408,23 +549,23 @@ public partial class SeedDataService if (billIds.Any()) { - var payments = await _context.Set() + var payments = await _context.Set() .IgnoreQueryFilters() .Where(p => billIds.Contains(p.BillId)) .ToListAsync(); - if (payments.Any()) _context.Set().RemoveRange(payments); + if (payments.Any()) _context.Set().RemoveRange(payments); - var lineItems = await _context.Set() + var lineItems = await _context.Set() .IgnoreQueryFilters() .Where(li => billIds.Contains(li.BillId)) .ToListAsync(); - if (lineItems.Any()) _context.Set().RemoveRange(lineItems); + if (lineItems.Any()) _context.Set().RemoveRange(lineItems); - var bills = await _context.Set() + var bills = await _context.Set() .IgnoreQueryFilters() .Where(b => billIds.Contains(b.Id)) .ToListAsync(); - _context.Set().RemoveRange(bills); + _context.Set().RemoveRange(bills); totalRemoved += bills.Count; details.Add($"✓ Removed {bills.Count} vendor bill(s)"); await _context.SaveChangesAsync(); @@ -435,17 +576,18 @@ public partial class SeedDataService } } - // --- Expenses --- - if (options.Expenses) + // ── Expenses ────────────────────────────────────────────────────────── + // Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal. + if (options.Expenses && !options.ForceRemoveAll) { - var expenses = await _context.Set() + var expenses = await _context.Set() .IgnoreQueryFilters() .Where(e => e.CompanyId == companyId) .ToListAsync(); if (expenses.Any()) { - _context.Set().RemoveRange(expenses); + _context.Set().RemoveRange(expenses); totalRemoved += expenses.Count; details.Add($"✓ Removed {expenses.Count} expense(s)"); await _context.SaveChangesAsync(); @@ -456,17 +598,17 @@ public partial class SeedDataService } } - // --- Vendors --- + // ── Vendors ─────────────────────────────────────────────────────────── if (options.Vendors || options.ForceRemoveAll) { - var vendors = await _context.Set() + var vendors = await _context.Set() .IgnoreQueryFilters() .Where(v => v.CompanyId == companyId) .ToListAsync(); if (vendors.Any()) { - _context.Set().RemoveRange(vendors); + _context.Set().RemoveRange(vendors); totalRemoved += vendors.Count; details.Add($"✓ Removed {vendors.Count} vendor(s)"); await _context.SaveChangesAsync(); @@ -477,17 +619,17 @@ public partial class SeedDataService } } - // --- Named Ovens (OvenCost) --- + // ── Named Ovens (OvenCost) ──────────────────────────────────────────── if (options.NamedOvens || options.ForceRemoveAll) { - var ovens = await _context.Set() + var ovens = await _context.Set() .IgnoreQueryFilters() .Where(o => o.CompanyId == companyId) .ToListAsync(); if (ovens.Any()) { - _context.Set().RemoveRange(ovens); + _context.Set().RemoveRange(ovens); totalRemoved += ovens.Count; details.Add($"✓ Removed {ovens.Count} named oven(s)"); await _context.SaveChangesAsync(); @@ -498,32 +640,9 @@ public partial class SeedDataService } } - // --- Appointments --- - if (options.Appointments || options.ForceRemoveAll) - { - var appointments = await _context.Set() - .IgnoreQueryFilters() - .Where(a => a.CompanyId == companyId) - .ToListAsync(); - - if (appointments.Any()) - { - _context.Set().RemoveRange(appointments); - totalRemoved += appointments.Count; - details.Add($"✓ Removed {appointments.Count} appointment(s)"); - await _context.SaveChangesAsync(); - } - else - { - details.Add("• No appointments found"); - } - } - - // --- Shop Workers --- + // ── Shop Workers ────────────────────────────────────────────────────── if (options.Workers) { - // ForceRemoveAll: remove all non-admin company users (role = Worker/Employee). - // Normal mode: fingerprint-match by @pcldemo.com email domain. var workerUsers = options.ForceRemoveAll ? await _userManager.Users .Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker") diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs index 94bac0e..d3429c8 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs @@ -115,19 +115,26 @@ public partial class SeedDataService if (workers.Count == 0) return 0; - // Only create entries for jobs that have been worked on - var activeJobs = await _context.Set() + // Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can + // silently return null navigation properties when query filters interact with IgnoreQueryFilters. + var workedStatusIds = await _context.Set() .IgnoreQueryFilters() - .Where(j => j.CompanyId == company.Id && !j.IsDeleted) - .Include(j => j.JobStatus) + .Where(s => s.CompanyId == company.Id && new[] + { + "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", + "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", + "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" + }.Contains(s.StatusCode)) + .Select(s => s.Id) .ToListAsync(); - var workedJobs = activeJobs.Where(j => - j.JobStatus?.StatusCode is - "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or - "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK" or - "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" - ).ToList(); + if (workedStatusIds.Count == 0) return 0; + + var workedJobs = await _context.Set() + .IgnoreQueryFilters() + .Where(j => j.CompanyId == company.Id && !j.IsDeleted + && workedStatusIds.Contains(j.JobStatusId)) + .ToListAsync(); if (workedJobs.Count == 0) return 0; diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs index 4e44671..c00036f 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs @@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; @@ -13,15 +14,18 @@ public partial class SeedDataService : ISeedDataService private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly RoleManager _roleManager; + private readonly IAccountBalanceService _accountBalanceService; public SeedDataService( ApplicationDbContext context, UserManager userManager, - RoleManager roleManager) + RoleManager roleManager, + IAccountBalanceService accountBalanceService) { _context = context; _userManager = userManager; _roleManager = roleManager; + _accountBalanceService = accountBalanceService; } /// @@ -426,8 +430,53 @@ public partial class SeedDataService : ISeedDataService await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company)); + // Ensure chart of accounts exists before bills/expenses — both seeders silently return 0 + // if the AP or checking account is missing. SeedDefaultChartOfAccountsAsync is idempotent. + try + { + var accountsAdded = await SeedDefaultChartOfAccountsAsync(company); + var systemAccountsAdded = await EnsureSystemAccountsAsync(company); + if (accountsAdded > 0) + details.Add($"✓ {accountsAdded} chart of account(s) created"); + if (systemAccountsAdded > 0) + details.Add($"✓ {systemAccountsAdded} missing system account(s) added"); + } + catch (Exception ex) { errors.Add($"✗ Chart of accounts: {ex.Message}"); _context.ChangeTracker.Clear(); } + await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company)); await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company)); + + // Accounts survive resets (no removal sweep), so the chart-of-accounts seeder skips them + // on every reset after the first. But 12 months of seeded expenses outpace ~3 months of + // seeded revenue, and without a prior-period cash balance the checking account shows a + // large negative. Patch the opening balances unconditionally so every reset is realistic. + try + { + var checkingAcct = await _context.Set().IgnoreQueryFilters() + .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted + && a.AccountSubType == AccountSubType.Checking); + if (checkingAcct != null && checkingAcct.OpeningBalance == 0) + { + checkingAcct.OpeningBalance = 75_000m; + checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1); + checkingAcct.CurrentBalance = 75_000m; + await _context.SaveChangesAsync(); + details.Add("✓ Checking account opening balance set to $75,000"); + } + var savingsAcct = await _context.Set().IgnoreQueryFilters() + .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted + && a.AccountSubType == AccountSubType.Savings); + if (savingsAcct != null && savingsAcct.OpeningBalance == 0) + { + savingsAcct.OpeningBalance = 14_500m; + savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1); + savingsAcct.CurrentBalance = 14_500m; + await _context.SaveChangesAsync(); + details.Add("✓ Savings account opening balance set to $14,500"); + } + } + catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); } + await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company)); if (company.CompanyCode == "DEMO") @@ -443,6 +492,15 @@ public partial class SeedDataService : ISeedDataService catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); } } + // Replay all GL transactions so CurrentBalance reflects the full seeded history, + // including the opening balances patched above. + try + { + await _accountBalanceService.RecalculateAllAsync(company.Id); + details.Add("✓ Account balances recalculated"); + } + catch (Exception ex) { errors.Add($"✗ Account balance recalculation: {ex.Message}"); _context.ChangeTracker.Clear(); } + if (errors.Any()) { details.AddRange(errors); diff --git a/src/PowderCoating.Web/Controllers/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs index bf77915..0657fba 100644 --- a/src/PowderCoating.Web/Controllers/CustomersController.cs +++ b/src/PowderCoating.Web/Controllers/CustomersController.cs @@ -91,7 +91,9 @@ public class CustomersController : Controller // Build orderBy function Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { - "CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName), + "CompanyName" => q => gridRequest.SortDirection == "asc" + ? q.OrderBy(c => c.CompanyName ?? c.ContactLastName) + : q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName), "ContactName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName) : q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName), @@ -100,7 +102,7 @@ public class CustomersController : Controller "CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance), "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive), "LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate), - _ => q => q.OrderBy(c => c.CompanyName) + _ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName) }; // Get paged data diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 2fcd0de..13c69fd 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -1888,7 +1888,7 @@ public class InvoicesController : Controller /// Details view can show an inline toast with the delivery outcome. /// [HttpPost, ValidateAntiForgeryToken] - public async Task ResendInvoice(int id, string? overrideEmail = null) + public async Task ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false) { try { @@ -1902,7 +1902,9 @@ public class InvoicesController : Controller if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff) return Json(new { success = false, message = "Voided invoices cannot be resent." }); - // Validate override email when provided + if (!sendEmail && !sendSms) + return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." }); + overrideEmail = overrideEmail?.Trim(); if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@')) return Json(new { success = false, message = "The email address provided is not valid." }); @@ -1915,32 +1917,55 @@ public class InvoicesController : Controller ? overrideEmail : invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty; - if (string.IsNullOrWhiteSpace(recipientEmail)) + if (sendEmail && string.IsNullOrWhiteSpace(recipientEmail)) return Json(new { success = false, message = "No email address on file. Please provide an address to send to." }); + // Ensure a permanent view token exists so the SMS link always works. + string? viewUrl = null; + if (sendSms) + { + if (string.IsNullOrEmpty(invoice.PublicViewToken)) + { + invoice.PublicViewToken = Guid.NewGuid().ToString("N"); + await _unitOfWork.Invoices.UpdateAsync(invoice); + await _unitOfWork.CompleteAsync(); + } + viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}"; + } + byte[]? pdfBytes = null; string? pdfFilename = null; - try + if (sendEmail) { - pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); - pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf"; - } - catch (Exception pdfEx) - { - _logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id); + try + { + pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); + pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf"; + } + catch (Exception pdfEx) + { + _logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id); + } } - await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail); + await _notificationService.NotifyInvoiceSentAsync( + invoice, pdfBytes, pdfFilename, + overrideEmail: overrideEmail, + sendSms: sendSms, + viewUrl: viewUrl); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); if (latestLog?.Status == NotificationStatus.Failed) - return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); + return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" }); - if (latestLog?.Status == NotificationStatus.Skipped) + if (latestLog?.Status == NotificationStatus.Skipped && !sendSms) return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." }); - return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." }); + var channels = new List(); + if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})"); + if (sendSms) channels.Add("SMS"); + return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." }); } catch (Exception ex) { diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index 7b8a0a2..b628af2 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -146,12 +146,19 @@ public class ReportsController : Controller var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m; // === REVENUE ANALYTICS === - // Pre-filter completed jobs by date range once for monthly calculations - var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList(); + // CompletedDate is the authoritative "when the job finished" date. + // UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported + // jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort. + static DateTime JobMonthDate(Job j) => + j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt; + + var completedJobsInRange = completedJobs + .Where(j => JobMonthDate(j) >= startDate) + .ToList(); // Group by month for efficient monthly aggregations var jobsByMonth = completedJobsInRange - .GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1)) + .GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); }) .ToDictionary(g => g.Key, g => g.ToList()); // Monthly revenue trend diff --git a/src/PowderCoating.Web/Controllers/SeedDataController.cs b/src/PowderCoating.Web/Controllers/SeedDataController.cs index e178e21..7b26d3c 100644 --- a/src/PowderCoating.Web/Controllers/SeedDataController.cs +++ b/src/PowderCoating.Web/Controllers/SeedDataController.cs @@ -137,7 +137,7 @@ public class SeedDataController : Controller OperatingCosts = true, Bills = true, Expenses = true, - Workers = true, + Workers = false, // workers stay static — never deleted on reset Vendors = true, NamedOvens = true, Appointments = true, diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index b46004f..1ebf99e 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -12,7 +12,7 @@ var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff; var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue; var canPay = !isVoided && Model.BalanceDue > 0; - var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid; + var canResend = !isDraft && !isVoided; var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail); var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail; var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone; @@ -654,23 +654,43 @@ @if (canResend) { - @if (!hasEmail) + @if (hasEmail && !emailOptedOut && hasSms) { + @* Both email + SMS — channel choice modal *@ } - else if (emailOptedOut) + else if (hasSms && (!hasEmail || emailOptedOut)) { - + } + else if (hasEmail && !emailOptedOut) + { + @* Email only *@ + + } + else if (!hasEmail) + { + @* No email on file — let staff enter one *@ + } else { - } @@ -1138,6 +1158,46 @@ + +@if (canResend && hasEmail && !emailOptedOut && hasSms) +{ + +} +