Demo data realism + invoice resend via SMS on any status

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:20:04 -04:00
parent 249128e852
commit 7735fe3cce
16 changed files with 1142 additions and 487 deletions
@@ -2265,7 +2265,9 @@ modelBuilder.Entity<Job>()
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)
@@ -44,8 +44,11 @@ public partial class SeedDataService
var accounts = new List<Account>
{
// ── 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 },
@@ -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;
}
@@ -6,38 +6,35 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// 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 &times; 6 months).
/// </summary>
/// <remarks>
/// <para>
/// 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. <see cref="SeedJobsAsync"/>
/// maps customer indices 09 to specific commercial price profiles, so the commercial
/// anchors must always occupy the lowest database IDs for this company.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Wake County Fleet Services is seeded as tax-exempt to demonstrate the tax-exempt
/// workflow (0% tax on quotes and invoices, ★ marker in dropdowns).
/// </para>
/// <para>
/// 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).
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed customers for.</param>
/// <returns>
/// A tuple of (seededCount, warnings) where warnings list any skipped customers.
/// </returns>
/// <returns>A tuple of (seededCount, warnings) where warnings list skipped records.</returns>
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
{
var warnings = new List<string>();
int seededCount = 0;
int skippedCount = 0;
var now = DateTime.UtcNow;
var existingCount = await _context.Set<Customer>()
@@ -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<Customer>
// ── 27 hand-crafted anchors — ORDER IS CRITICAL ───────────────────────
// SeedJobsAsync.CustomerProfile() maps indices 09 to commercial accounts
// and 1026 to known individual accounts. Never reorder these.
var anchors = new List<Customer>
{
// ─── Commercial Customers (10) — NC / Triangle Area ────────────────
// Commercial (ci 09) — 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 1026) — 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<Customer>()
.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<Customer>().AddAsync(customer);
var dup = await _context.Set<Customer>().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<Customer>().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<Customer>().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;
}
}
}
@@ -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<Job>()
// 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<JobStatusLookup>()
.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<Job>()
: await _context.Set<Job>()
.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<InventoryTransaction>();
@@ -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();
@@ -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.");
@@ -7,29 +7,28 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// Job counts and price ranges are defined per customer index (0 = Carolina Fabrication,
/// the top-revenue account) via <c>CustomerProfile(ci)</c>. 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 <c>CustomerProfile(ci)</c>
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
/// </para>
/// <para>
/// 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 <c>visitSchedule</c> (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.
/// </para>
/// <para>
/// Date logic groups jobs into three buckets — completed (60150 days ago), in-progress
/// (1050 days ago), and early-stage (217 days ago or future-scheduled) — to produce
/// realistic dashboard pipeline and calendar views.
/// </para>
/// <para>
/// 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 &times;10, Completed &times;8,
/// ReadyForPickup &times;5, then decreasing counts for in-progress stages, with
/// exactly one Cancelled and one OnHold.
/// Priority pool (fixed seed 77): Normal 76&thinsp;%, High 12&thinsp;%, Urgent 8&thinsp;%,
/// Rush 4&thinsp;% — rush is genuinely rare.
/// </para>
/// </remarks>
private async Task<int> 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,
// 1026 = individual residential customers (smaller jobs)
// 1026 = 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<string>();
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<string>();
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 09) 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<int>();
var individualVisits = new List<int>();
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<int>(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<Job>();
var quoteIdx = 0;
var jobIdx = 0;
var jobs = new List<Job>();
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<JobItem>();
// Completed jobs spread linearly ~112 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<JobItem>();
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<Job>().AddRangeAsync(jobs);
@@ -7,32 +7,28 @@ public partial class SeedDataService
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 &times;18, Sent &times;8, Draft &times;4,
/// Rejected &times;3, Expired &times;2. Shuffled with Fisher-Yates (fixed seed 55)
/// so every reset produces the same interleaved sequence.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Dates spread over 150180 days back (56 months) so the historical charts on the
/// dashboard and reports show a meaningful activity curve.
/// Dates spread over 7185 days back (roughly 6 months) with a small jitter term so
/// historical charts show a natural activity curve without obviously linear spacing.
/// </para>
/// </remarks>
private async Task<int> SeedQuotesAsync(Company company)
@@ -52,12 +48,11 @@ public partial class SeedDataService
if (quoteStatuses.Count == 0)
return 0;
// Load all customerscommercial first, then individual
// Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align
var allCustomers = await _context.Set<Customer>()
.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<Quote>()
.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,
// 1026 = 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<string>();
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 09) 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<int>();
var individualVisits = new List<int>();
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<int>(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<Quote>();
const int Total = 35;
var quotes = new List<Quote>();
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 30180 days ago; newer quotes have lower index
var daysAgo = 180 - (int)(i * 4.5);
var quoteDate = now.AddDays(-daysAgo);
// Dates spread 7185 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<QuoteItem>();
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,
@@ -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
/// </summary>
/// <remarks>
/// <para>
/// All queries use <c>IgnoreQueryFilters()</c> 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 <c>ForceRemoveAll = true</c> 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 <c>sys.foreign_keys</c>:
/// 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.
/// </para>
/// <para>
/// 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 <c>SaveChangesAsync()</c> call so a failure in one category does not roll
/// back deletions already completed in an earlier category.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="companyId">ID of the tenant company whose seed data should be removed.</param>
/// <param name="options">Flags controlling which data categories to delete.</param>
/// <returns>
/// A <see cref="SeedDataResult"/> with <c>Success = true</c> and a count of records removed,
/// or <c>Success = false</c> with an error message if the company was not found or an
/// exception was thrown.
/// </returns>
public async Task<SeedDataResult> 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<int> Sweep<T>() where T : BaseEntity
{
var rows = await _context.Set<T>().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId).ToListAsync();
if (rows.Any()) _context.Set<T>().RemoveRange(rows);
return rows.Count;
}
// Tier 1 — pure leaf records (block nothing of their own)
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
await Sweep<QuotePhoto>(); // NO_ACTION → Quotes
await Sweep<GiftCertificate>(); // NO_ACTION → Customers (GiftCertRedemptions CASCADE)
await Sweep<JobTemplate>(); // NO_ACTION → Customers (JobTemplateItems CASCADE)
await _context.SaveChangesAsync();
// Tier 2 — credit/rework chain (each row blocks the next tier)
await Sweep<CreditMemoApplication>(); // NO_ACTION → Bills, VendorCredits
await Sweep<Refund>(); // NO_ACTION → Invoices, Payments, CreditMemos
await Sweep<CreditMemo>(); // NO_ACTION → Customers, Invoices, ReworkRecords
await _context.SaveChangesAsync();
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs, JobItems (after CreditMemos gone)
await _context.SaveChangesAsync();
// Tier 3 — financial records that block Invoices / Jobs / Quotes
await Sweep<Deposit>(); // NO_ACTION → Jobs, Quotes (CASCADE from Customer anyway)
await Sweep<Payment>(); // NO_ACTION → Invoices
await Sweep<InventoryTransaction>(); // NO_ACTION → Jobs, PurchaseOrders
await _context.SaveChangesAsync();
await Sweep<Invoice>(); // NO_ACTION → Jobs (InvoiceItems, GiftCertRedemptions CASCADE)
await _context.SaveChangesAsync();
// Tier 4 — appointments (NO_ACTION → Customers AND Jobs)
await Sweep<Appointment>();
await _context.SaveChangesAsync();
// Tier 5 — vendor/purchasing chain (BillLineItems.JobId NO_ACTION blocks Jobs)
await Sweep<VendorCreditApplication>(); // NO_ACTION → Bills
await _context.SaveChangesAsync();
await Sweep<Bill>(); // CASCADE → BillLineItems, BillPayments
await Sweep<VendorCredit>(); // CASCADE → VendorCreditLineItems
await Sweep<PurchaseOrder>(); // CASCADE → PurchaseOrderItems
await Sweep<Expense>(); // NO_ACTION → Jobs
await Sweep<OvenBatch>(); // 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<Appointment>().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<Appointment>().RemoveRange(appts);
}
// InAppNotifications — NO_ACTION → Customers, Quotes (Invoices handled below)
if (seededCustomerIds.Any() || seededQuoteIds.Any())
{
var nots = await _context.Set<InAppNotification>().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<InAppNotification>().RemoveRange(nots);
}
// QuotePhotos — NO_ACTION → Quotes
if (seededQuoteIds.Any())
{
var qp = await _context.Set<QuotePhoto>().IgnoreQueryFilters()
.Where(p => p.QuoteId.HasValue && seededQuoteIds.Contains(p.QuoteId.Value)).ToListAsync();
if (qp.Any()) _context.Set<QuotePhoto>().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<Deposit>().IgnoreQueryFilters()
.Where(d => seededCustomerIds.Contains(d.CustomerId)).ToListAsync();
if (deps.Any()) _context.Set<Deposit>().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<int> seededInvoiceIds = [];
if (seededCustomerIds.Any())
{
seededInvoiceIds = await _context.Set<Invoice>().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<InAppNotification>().IgnoreQueryFilters()
.Where(n => n.InvoiceId.HasValue && seededInvoiceIds.Contains(n.InvoiceId.Value))
.ToListAsync();
if (invNots.Any()) _context.Set<InAppNotification>().RemoveRange(invNots);
// Payments — NO_ACTION → Invoices
var pmts = await _context.Set<Payment>().IgnoreQueryFilters()
.Where(p => seededInvoiceIds.Contains(p.InvoiceId)).ToListAsync();
if (pmts.Any()) _context.Set<Payment>().RemoveRange(pmts);
await _context.SaveChangesAsync();
var invoices = await _context.Set<Invoice>().IgnoreQueryFilters()
.Where(i => seededInvoiceIds.Contains(i.Id)).ToListAsync();
if (invoices.Any())
{
_context.Set<Invoice>().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<Core.Entities.JobTimeEntry>().IgnoreQueryFilters()
var timeEntries = await _context.Set<JobTimeEntry>().IgnoreQueryFilters()
.Where(te => seededJobIds.Contains(te.JobId)).ToListAsync();
if (timeEntries.Any()) _context.Set<Core.Entities.JobTimeEntry>().RemoveRange(timeEntries);
if (timeEntries.Any()) _context.Set<JobTimeEntry>().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<Core.Entities.AiItemPrediction>()
var predictions = await _context.Set<AiItemPrediction>()
.IgnoreQueryFilters()
.Where(p => predictionIds.Contains(p.Id))
.ToListAsync();
if (predictions.Any())
{
_context.Set<Core.Entities.AiItemPrediction>().RemoveRange(predictions);
_context.Set<AiItemPrediction>().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<Core.Entities.PurchaseOrder>()
var poIds = await _context.Set<PurchaseOrder>()
.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<Core.Entities.PurchaseOrderItem>()
var poItems = await _context.Set<PurchaseOrderItem>()
.IgnoreQueryFilters()
.Where(i => poIds.Contains(i.PurchaseOrderId))
.ToListAsync();
if (poItems.Any()) _context.Set<Core.Entities.PurchaseOrderItem>().RemoveRange(poItems);
if (poItems.Any()) _context.Set<PurchaseOrderItem>().RemoveRange(poItems);
var pos = await _context.Set<Core.Entities.PurchaseOrder>()
var pos = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(p => poIds.Contains(p.Id))
.ToListAsync();
_context.Set<Core.Entities.PurchaseOrder>().RemoveRange(pos);
_context.Set<PurchaseOrder>().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<Core.Entities.Bill>()
var billIds = await _context.Set<Bill>()
.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<Core.Entities.BillPayment>()
var payments = await _context.Set<BillPayment>()
.IgnoreQueryFilters()
.Where(p => billIds.Contains(p.BillId))
.ToListAsync();
if (payments.Any()) _context.Set<Core.Entities.BillPayment>().RemoveRange(payments);
if (payments.Any()) _context.Set<BillPayment>().RemoveRange(payments);
var lineItems = await _context.Set<Core.Entities.BillLineItem>()
var lineItems = await _context.Set<BillLineItem>()
.IgnoreQueryFilters()
.Where(li => billIds.Contains(li.BillId))
.ToListAsync();
if (lineItems.Any()) _context.Set<Core.Entities.BillLineItem>().RemoveRange(lineItems);
if (lineItems.Any()) _context.Set<BillLineItem>().RemoveRange(lineItems);
var bills = await _context.Set<Core.Entities.Bill>()
var bills = await _context.Set<Bill>()
.IgnoreQueryFilters()
.Where(b => billIds.Contains(b.Id))
.ToListAsync();
_context.Set<Core.Entities.Bill>().RemoveRange(bills);
_context.Set<Bill>().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<Core.Entities.Expense>()
var expenses = await _context.Set<Expense>()
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId)
.ToListAsync();
if (expenses.Any())
{
_context.Set<Core.Entities.Expense>().RemoveRange(expenses);
_context.Set<Expense>().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<Core.Entities.Vendor>()
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == companyId)
.ToListAsync();
if (vendors.Any())
{
_context.Set<Core.Entities.Vendor>().RemoveRange(vendors);
_context.Set<Vendor>().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<Core.Entities.OvenCost>()
var ovens = await _context.Set<OvenCost>()
.IgnoreQueryFilters()
.Where(o => o.CompanyId == companyId)
.ToListAsync();
if (ovens.Any())
{
_context.Set<Core.Entities.OvenCost>().RemoveRange(ovens);
_context.Set<OvenCost>().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<Core.Entities.Appointment>()
.IgnoreQueryFilters()
.Where(a => a.CompanyId == companyId)
.ToListAsync();
if (appointments.Any())
{
_context.Set<Core.Entities.Appointment>().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")
@@ -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<Job>()
// 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<JobStatusLookup>()
.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<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& workedStatusIds.Contains(j.JobStatusId))
.ToListAsync();
if (workedJobs.Count == 0) return 0;
@@ -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<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IAccountBalanceService _accountBalanceService;
public SeedDataService(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager)
RoleManager<IdentityRole> roleManager,
IAccountBalanceService accountBalanceService)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_accountBalanceService = accountBalanceService;
}
/// <summary>
@@ -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<Account>().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<Account>().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);
@@ -91,7 +91,9 @@ public class CustomersController : Controller
// Build orderBy function
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> 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
@@ -1888,7 +1888,7 @@ public class InvoicesController : Controller
/// Details view can show an inline toast with the delivery outcome.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
public async Task<IActionResult> 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<string>();
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)
{
@@ -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
@@ -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,
@@ -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 @@
</a>
@if (canResend)
{
@if (!hasEmail)
@if (hasEmail && !emailOptedOut && hasSms)
{
@* Both email + SMS — channel choice modal *@
<button type="button" class="btn btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
<i class="bi bi-send me-2"></i>Send Invoice
data-bs-toggle="modal" data-bs-target="#resendChannelModal">
<i class="bi bi-send me-2"></i>Re-send Invoice
</button>
}
else if (emailOptedOut)
else if (hasSms && (!hasEmail || emailOptedOut))
{
<button type="button" class="btn btn-outline-primary" disabled
title="Email notifications are turned off for this customer">
@* SMS only *@
<button type="button" class="btn btn-outline-primary"
onclick="resendInvoice(@Model.Id, null, false, true)">
<i class="bi bi-phone me-2"></i>Re-send via SMS
</button>
}
else if (hasEmail && !emailOptedOut)
{
@* Email only *@
<button type="button" class="btn btn-outline-primary"
onclick="resendInvoice(@Model.Id)">
<i class="bi bi-send me-2"></i>Re-send Invoice
</button>
}
else if (!hasEmail)
{
@* No email on file — let staff enter one *@
<button type="button" class="btn btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
<i class="bi bi-send me-2"></i>Re-send Invoice
</button>
}
else
{
<button type="button" class="btn btn-outline-primary" onclick="resendInvoice(@Model.Id)">
@* Email opted out, no SMS *@
<button type="button" class="btn btn-outline-primary" disabled
title="Email notifications are turned off for this customer and no mobile number is on file">
<i class="bi bi-send me-2"></i>Re-send Invoice
</button>
}
@@ -1138,6 +1158,46 @@
</div>
</div>
<!-- Re-send Channel Choice Modal (email opted-in + SMS both available) -->
@if (canResend && hasEmail && !emailOptedOut && hasSms)
{
<div class="modal fade" id="resendChannelModal" tabindex="-1" aria-labelledby="resendChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="resendChannelModalLabel">
<i class="bi bi-send text-primary me-2"></i>Re-send Invoice
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-2">
<p class="mb-3">How would you like to re-send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start"
onclick="resendInvoice(@Model.Id, null, true, false)" data-bs-dismiss="modal">
<i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">PDF attached &middot; @Model.CustomerEmail</small>
</button>
<button type="button" class="btn btn-outline-primary text-start"
onclick="resendInvoice(@Model.Id, null, false, true)" data-bs-dismiss="modal">
<i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">View link &middot; @smsPhone</small>
</button>
<button type="button" class="btn btn-primary text-start"
onclick="resendInvoice(@Model.Id, null, true, true)" data-bs-dismiss="modal">
<i class="bi bi-send me-2"></i>Both Email &amp; SMS
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
</button>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
}
<!-- Notifications Sent Modal -->
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
@@ -1537,7 +1597,7 @@
}
}
function resendInvoice(invoiceId, overrideEmail) {
function resendInvoice(invoiceId, overrideEmail, sendEmail = true, sendSms = false) {
document.getElementById('resendInvoiceSending').classList.remove('d-none');
document.getElementById('resendInvoiceResult').classList.add('d-none');
document.getElementById('resendInvoiceFooter').classList.add('d-none');
@@ -1549,6 +1609,8 @@
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
url += '&sendEmail=' + (sendEmail ? 'true' : 'false');
url += '&sendSms=' + (sendSms ? 'true' : 'false');
fetch(url, {
method: 'POST',
@@ -1567,11 +1629,11 @@
if (data.success) {
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
header.className = 'modal-header bg-success text-white';
showInfo(data.message, 'Email Sent');
showInfo(data.message, 'Invoice Sent');
} else {
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
header.className = 'modal-header bg-danger text-white';
showWarning(data.message, 'Email Not Sent');
showWarning(data.message, 'Send Failed');
}
msg.textContent = data.message;
})