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:
@@ -2265,6 +2265,8 @@ modelBuilder.Entity<Job>()
|
|||||||
|
|
||||||
if (entry.State == EntityState.Added)
|
if (entry.State == EntityState.Added)
|
||||||
{
|
{
|
||||||
|
// 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.CreatedAt = DateTime.UtcNow;
|
||||||
entity.CreatedBy = currentUser;
|
entity.CreatedBy = currentUser;
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,11 @@ public partial class SeedDataService
|
|||||||
var accounts = new List<Account>
|
var accounts = new List<Account>
|
||||||
{
|
{
|
||||||
// ── ASSETS ────────────────────────────────────────────────────────
|
// ── 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 },
|
// Opening balances represent accumulated cash before the 12-month seeded history window.
|
||||||
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 },
|
// 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 = "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 = "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 },
|
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;
|
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 ─────────────────────────────────────────────────────
|
// ── POWDER ORDERS ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Month -3: Large powder restock — Paid
|
// Month -3: Large powder restock — Paid
|
||||||
@@ -694,50 +793,55 @@ public partial class SeedDataService
|
|||||||
seeded++;
|
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;
|
var rentAccount = rentAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago");
|
for (int m = 12; m >= 1; m--)
|
||||||
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago");
|
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(-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");
|
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;
|
var utilAccount = utilitiesAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago");
|
decimal[] gasAmts = [310m, 295m, 260m, 218m, 185m, 172m, 168m, 179m, 196m, 225m, 255m, 241m];
|
||||||
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago");
|
for (int m = 12; m >= 1; m--)
|
||||||
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month");
|
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;
|
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(-365), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1 (prior year)");
|
||||||
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
|
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;
|
var adAccount = advertisingAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
decimal[] adAmts = [120m, 120m, 135m, 150m, 150m, 165m, 150m, 165m, 175m, 175m, 175m, 175m];
|
||||||
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
for (int m = 12; m >= 1; m--)
|
||||||
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign");
|
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(-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");
|
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;
|
var swAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
for (int m = 12; m >= 1; m--)
|
||||||
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
await AddExp(now.AddDays(-(m * 30)), 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 — current month");
|
||||||
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
|
||||||
|
|
||||||
// ── OFFICE SUPPLIES ───────────────────────────────────────────────────
|
// ── OFFICE SUPPLIES — quarterly ───────────────────────────────────────
|
||||||
var offAccount = officeSuppliesAcct ?? fallbackExpense;
|
var offAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
|
var firstVendorId = vendors.FirstOrDefault()?.Id;
|
||||||
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
|
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;
|
var bankAccount = bankChargesAcct ?? fallbackExpense;
|
||||||
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees");
|
decimal[] feeAmts = [24m, 25m, 26m, 28m, 27m, 29m, 28m, 30m, 31m, 29m, 31m, 32m];
|
||||||
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees");
|
for (int m = 12; m >= 1; m--)
|
||||||
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees");
|
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");
|
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees — current month");
|
||||||
|
|
||||||
return seeded;
|
return seeded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,38 +6,35 @@ namespace PowderCoating.Infrastructure.Services;
|
|||||||
public partial class SeedDataService
|
public partial class SeedDataService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds 27 realistic customers for the demo company: 10 commercial accounts and
|
/// Seeds customers at a rate of 15 per calendar month from January of the current year
|
||||||
/// 17 individual/non-commercial customers, all anchored to the Raleigh-Durham NC area.
|
/// through the current month, mimicking a real shop that acquires customers steadily
|
||||||
|
/// as the year progresses. Seeding in January produces 15 customers; seeding in June
|
||||||
|
/// produces 90 (15 × 6 months).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The NC geographic identity (Carolina Fabrication, Apex Motorsports, Triangle Offroad,
|
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
|
||||||
/// Raleigh Architectural Metals, etc.) gives tutorial recordings and marketing screenshots
|
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
|
||||||
/// a coherent sense of place rather than a scatter of US cities.
|
/// maps customer indices 0–9 to specific commercial price profiles, so the commercial
|
||||||
|
/// anchors must always occupy the lowest database IDs for this company.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Revenue is deliberately top-heavy: Carolina Fabrication (Platinum) and Apex Motorsports
|
/// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally
|
||||||
/// (Gold) carry the majority of simulated revenue so the Revenue by Customer report shows
|
/// generated individual customers drawn from NC-area name and city pools, so the
|
||||||
/// a realistic Pareto distribution from day one.
|
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of
|
||||||
|
/// the reseed date.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Wake County Fleet Services is seeded as tax-exempt to demonstrate the tax-exempt
|
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
|
||||||
/// workflow (0% tax on quotes and invoices, ★ marker in dropdowns).
|
/// this company (they are removed by the reset sweep before a full reseed).
|
||||||
/// </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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="company">The tenant company to seed customers for.</param>
|
/// <param name="company">The tenant company to seed customers for.</param>
|
||||||
/// <returns>
|
/// <returns>A tuple of (seededCount, warnings) where warnings list skipped records.</returns>
|
||||||
/// A tuple of (seededCount, warnings) where warnings list any skipped customers.
|
|
||||||
/// </returns>
|
|
||||||
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
|
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
|
||||||
{
|
{
|
||||||
var warnings = new List<string>();
|
var warnings = new List<string>();
|
||||||
int seededCount = 0;
|
int seededCount = 0;
|
||||||
int skippedCount = 0;
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
var existingCount = await _context.Set<Customer>()
|
var existingCount = await _context.Set<Customer>()
|
||||||
@@ -57,101 +54,176 @@ public partial class SeedDataService
|
|||||||
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
|
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
|
||||||
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
|
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,
|
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 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
|
new Customer
|
||||||
{
|
{
|
||||||
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
|
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
|
||||||
Phone = ph, City = city, State = st, ZipCode = zip,
|
Phone = ph, City = city, State = st, ZipCode = zip,
|
||||||
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
|
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
|
||||||
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
|
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
|
||||||
IsActive = true,
|
IsActive = true, GeneralNotes = notes, CompanyId = company.Id
|
||||||
LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)),
|
|
||||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Customer Indiv(string fn, string ln, string em, string ph,
|
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
|
new Customer
|
||||||
{
|
{
|
||||||
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
|
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
|
||||||
City = city, State = st, ZipCode = zip,
|
City = city, State = st, ZipCode = zip,
|
||||||
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
|
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
|
||||||
LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)),
|
GeneralNotes = notes, CompanyId = company.Id
|
||||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var customers = new List<Customer>
|
// ── 27 hand-crafted anchors — ORDER IS CRITICAL ───────────────────────
|
||||||
|
// SeedJobsAsync.CustomerProfile() maps indices 0–9 to commercial accounts
|
||||||
|
// and 10–26 to known individual accounts. Never reorder these.
|
||||||
|
var anchors = new List<Customer>
|
||||||
{
|
{
|
||||||
// ─── Commercial Customers (10) — NC / Triangle Area ────────────────
|
// Commercial (ci 0–9) — drive the Revenue by Customer report story
|
||||||
|
Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly"),
|
||||||
|
Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components"),
|
||||||
|
Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers"),
|
||||||
|
Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly"),
|
||||||
|
Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates"),
|
||||||
|
Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing"),
|
||||||
|
Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames"),
|
||||||
|
Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels"),
|
||||||
|
Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures"),
|
||||||
|
Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", true),
|
||||||
|
|
||||||
// Top-revenue accounts — drive the Revenue by Customer report story
|
// Individual (ci 10–26) — residential work and hobby builds
|
||||||
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),
|
Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs"),
|
||||||
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),
|
Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts"),
|
||||||
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),
|
Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and 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", 10),
|
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"),
|
||||||
// Mid-tier commercial
|
Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases"),
|
||||||
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),
|
Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art"),
|
||||||
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),
|
Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats"),
|
||||||
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),
|
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
|
||||||
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),
|
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"),
|
||||||
// Specialty accounts
|
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
|
||||||
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),
|
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
|
||||||
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),
|
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"),
|
||||||
// ─── Individual / Non-Commercial Customers (17) ──────────────────
|
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"),
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
var existingCustomer = await _context.Set<Customer>()
|
var dup = await _context.Set<Customer>().IgnoreQueryFilters()
|
||||||
.IgnoreQueryFilters()
|
.FirstOrDefaultAsync(c => c.Email == anchor.Email && c.CompanyId == company.Id && !c.IsDeleted);
|
||||||
.FirstOrDefaultAsync(c => c.Email == customer.Email
|
if (dup != null) { warnings.Add($"Skipped anchor {anchor.Email} — already exists"); continue; }
|
||||||
&& c.CompanyId == company.Id && !c.IsDeleted);
|
await _context.Set<Customer>().AddAsync(anchor);
|
||||||
|
|
||||||
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);
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
seededCount++;
|
seededCount++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
skippedCount++;
|
warnings.Add($"Skipped anchor {anchor.Email} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||||
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
|
if (_context.Entry(anchor).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
|
||||||
warnings.Add($"Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
|
_context.Entry(anchor).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||||
if (_context.Entry(customer).State != EntityState.Detached)
|
}
|
||||||
_context.Entry(customer).State = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-16
@@ -54,14 +54,22 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (items.Count == 0) return 0;
|
if (items.Count == 0) return 0;
|
||||||
|
|
||||||
// Load completed/delivered jobs to generate usage transactions against
|
// Two-query approach: resolve status IDs first to avoid Include() navigation
|
||||||
var completedJobs = await _context.Set<Job>()
|
// returning null when global query filters interact with IgnoreQueryFilters().
|
||||||
|
var completedStatusIds = await _context.Set<JobStatusLookup>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||||
&& (j.JobStatus.StatusCode == "COMPLETED"
|
&& completedStatusIds.Contains(j.JobStatusId))
|
||||||
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|
.Include(j => j.JobItems)
|
||||||
|| j.JobStatus.StatusCode == "DELIVERED"))
|
|
||||||
.Include(j => j.JobStatus)
|
|
||||||
.OrderBy(j => j.Id)
|
.OrderBy(j => j.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -90,14 +98,18 @@ public partial class SeedDataService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Purchase transactions — 3 months of restocks ──────────────────────
|
// ── Purchase transactions — 12 months of monthly restocks ────────────
|
||||||
// Simulate monthly powder purchases for top items
|
var powderItems = items.Take(8).ToList();
|
||||||
var powderItems = items.Take(8).ToList(); // focus on powder coat items
|
// Quantities vary slightly month to month to give the inventory chart a natural shape
|
||||||
|
var purchaseOffsets = new (int daysAgo, decimal mult)[]
|
||||||
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
|
|
||||||
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
|
|
||||||
{
|
{
|
||||||
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);
|
var qty = Math.Round(25m * qtyMult, 0);
|
||||||
txns.Add(new InventoryTransaction
|
txns.Add(new InventoryTransaction
|
||||||
@@ -144,9 +156,10 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
|
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
|
||||||
{
|
{
|
||||||
// Completed date spread: most within the last 60 days
|
// Use the job's actual completion date so powder usage history spans the same
|
||||||
var daysAgo = 10 + (idx % 55);
|
// 12-month window as jobs, giving the Powder Usage report non-trivial data in
|
||||||
var usageDate = now.AddDays(-daysAgo);
|
// 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)
|
// Pick a color-matched powder item (or rotate)
|
||||||
var firstItem = job.JobItems?.FirstOrDefault();
|
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) ─────────────────────────────────────────────────
|
// ── Month −3 (6 paid) ─────────────────────────────────────────────────
|
||||||
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
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");
|
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, 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);
|
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) ───────────────────────────
|
// ── Month −1 (7 paid + 2 partial + 2 sent) ───────────────────────────
|
||||||
await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
|
// 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses.
|
||||||
await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056");
|
await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||||
await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
|
await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054");
|
||||||
await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||||
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
|
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, 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.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.");
|
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
|
public partial class SeedDataService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds 50 powder coating jobs distributed across all 16 statuses, with deliberate
|
/// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic
|
||||||
/// per-customer revenue targeting so the Revenue by Customer report matches the demo
|
/// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold)
|
||||||
/// company narrative: Carolina Fabrication largest, then Apex Motorsports, Triangle Offroad,
|
/// and a shuffled visit order so jobs from different customers are interleaved naturally
|
||||||
/// Smith Welding, and so on down to small individual residential jobs.
|
/// rather than appearing as per-customer blocks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Job counts and price ranges are defined per customer index (0 = Carolina Fabrication,
|
/// Per-customer job counts and price ranges are defined by <c>CustomerProfile(ci)</c>
|
||||||
/// the top-revenue account) via <c>CustomerProfile(ci)</c>. This replaces the previous
|
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
|
||||||
/// modulo-formula approach that produced uniform pricing regardless of customer tier.
|
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// All 16 statuses are covered: the 16 statuses cycle through the first 16 jobs in
|
/// A shuffled <c>visitSchedule</c> (fixed seed 42) drives the outer loop so that
|
||||||
/// sequence, guaranteeing every pipeline stage is populated even with only 50 total jobs.
|
/// Carolina Fabrication, Apex Motorsports, and individual customers appear
|
||||||
|
/// interleaved in creation-date order rather than in consecutive customer blocks.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Date logic groups jobs into three buckets — completed (60–150 days ago), in-progress
|
/// Status pool (fixed seed 99): Delivered ×10, Completed ×8,
|
||||||
/// (10–50 days ago), and early-stage (2–17 days ago or future-scheduled) — to produce
|
/// ReadyForPickup ×5, then decreasing counts for in-progress stages, with
|
||||||
/// realistic dashboard pipeline and calendar views.
|
/// exactly one Cancelled and one OnHold.
|
||||||
/// </para>
|
/// Priority pool (fixed seed 77): Normal 76 %, High 12 %, Urgent 8 %,
|
||||||
/// <para>
|
/// Rush 4 % — rush is genuinely rare.
|
||||||
/// 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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> SeedJobsAsync(Company company)
|
private async Task<int> SeedJobsAsync(Company company)
|
||||||
@@ -88,11 +87,11 @@ public partial class SeedDataService
|
|||||||
var seq = maxNum + 1;
|
var seq = maxNum + 1;
|
||||||
|
|
||||||
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
|
// ── 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,
|
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||||
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||||
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||||
// 10–26 = individual residential customers (smaller jobs)
|
// 10–26 = individual residential customers
|
||||||
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
|
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
|
||||||
{
|
{
|
||||||
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
|
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
|
||||||
@@ -109,9 +108,9 @@ public partial class SeedDataService
|
|||||||
11 => (1, 150m, 350m), // Sarah Jenkins
|
11 => (1, 150m, 350m), // Sarah Jenkins
|
||||||
12 => (1, 200m, 400m), // Mike Thompson
|
12 => (1, 200m, 400m), // Mike Thompson
|
||||||
13 => (2, 100m, 300m), // Robert Miller
|
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
|
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
|
17 => (1, 150m, 300m), // Thomas Harris
|
||||||
18 => (0, 0m, 0m), // Karen White — no jobs yet
|
18 => (0, 0m, 0m), // Karen White — no jobs yet
|
||||||
19 => (1, 250m, 500m), // James Taylor
|
19 => (1, 250m, 500m), // James Taylor
|
||||||
@@ -124,29 +123,99 @@ public partial class SeedDataService
|
|||||||
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
|
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
|
||||||
};
|
};
|
||||||
|
|
||||||
// All 16 statuses cycle globally so every pipeline stage is visible.
|
// ── Status pool: realistic shop distribution (total = 50) ──────────────
|
||||||
string[] allStatuses =
|
// Delivered and Completed dominate; exactly one Cancelled and one OnHold.
|
||||||
[
|
var statusPool = new List<string>();
|
||||||
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
foreach (var (code, count) in new (string Code, int Count)[]
|
||||||
"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
|
|
||||||
{
|
{
|
||||||
0 => "RUSH",
|
("DELIVERED", 10),
|
||||||
1 => "RUSH",
|
("COMPLETED", 8),
|
||||||
2 => "URGENT",
|
("READY_FOR_PICKUP", 5),
|
||||||
3 => "URGENT",
|
("IN_PREPARATION", 4),
|
||||||
4 => "HIGH",
|
("SANDBLASTING", 4),
|
||||||
5 => "HIGH",
|
("COATING", 3),
|
||||||
_ => "NORMAL"
|
("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 0–9) and individual (ci 10+) ──
|
||||||
|
// A plain Fisher-Yates on the full list clusters commercial entries because they
|
||||||
|
// outnumber individual ones 4:1; splitting into two pools and distributing
|
||||||
|
// individual jobs evenly throughout ensures the two types never appear in blocks.
|
||||||
|
var commercialVisits = new List<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) =>
|
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
|
||||||
((i * 3 + j) % 15) switch
|
((i * 3 + j) % 15) switch
|
||||||
{
|
{
|
||||||
@@ -169,17 +238,20 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
var jobs = new List<Job>();
|
var jobs = new List<Job>();
|
||||||
var quoteIdx = 0;
|
var quoteIdx = 0;
|
||||||
var jobIdx = 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 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];
|
||||||
var statusCode = StatusFor(jobIdx);
|
|
||||||
var priorityCode = PriorityFor(jobIdx);
|
|
||||||
|
|
||||||
// Try to link the first available approved quote for this customer
|
// Try to link the first available approved quote for this customer
|
||||||
Quote? linkedQuote = null;
|
Quote? linkedQuote = null;
|
||||||
@@ -191,7 +263,7 @@ public partial class SeedDataService
|
|||||||
quoteIdx = qi + 1;
|
quoteIdx = qi + 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Every 4th job forcibly links any available approved quote
|
// Every 4th job, forcibly consume the next available approved quote
|
||||||
if (quoteIdx % 4 == 0 && qi == quoteIdx)
|
if (quoteIdx % 4 == 0 && qi == quoteIdx)
|
||||||
{
|
{
|
||||||
linkedQuote = approvedQuotes[qi];
|
linkedQuote = approvedQuotes[qi];
|
||||||
@@ -200,32 +272,43 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date logic
|
// Date logic: completed jobs furthest back, ready-for-pickup recent past,
|
||||||
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
|
// 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"
|
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||||||
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
||||||
|
|
||||||
int daysAgo = isCompleted ? 60 + (jobIdx % 90)
|
if (isInProgress) inProgressCount++;
|
||||||
: isInProgress ? 10 + (jobIdx % 40)
|
if (isCompleted) completedJobCount++;
|
||||||
: 2 + (jobIdx % 15);
|
|
||||||
|
// Completed jobs spread linearly ~1–12 months back for chart coverage.
|
||||||
|
// Ready-for-pickup: job finished recently, just waiting on customer.
|
||||||
|
// In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future.
|
||||||
|
int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14
|
||||||
|
: isReadyForPickup ? 5 + (visitIdx % 8)
|
||||||
|
: isInProgress ? 10 + (visitIdx % 40)
|
||||||
|
: 2 + (visitIdx % 15);
|
||||||
var createdDate = now.AddDays(-daysAgo);
|
var createdDate = now.AddDays(-daysAgo);
|
||||||
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (jobIdx % 5))
|
|
||||||
: isInProgress ? now.AddDays(-(jobIdx % 4))
|
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5))
|
||||||
: now.AddDays(3 + (jobIdx % 12));
|
: 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 rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||||||
var dueDate = scheduledDate.AddDays(rushDays);
|
var dueDate = scheduledDate.AddDays(rushDays);
|
||||||
var startedDate = !isCompleted && !isInProgress ? (DateTime?)null : scheduledDate;
|
var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null;
|
||||||
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
|
var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||||
|
|
||||||
// Per-customer value targeting: deterministic within the customer's price range
|
// Per-customer value targeting: deterministic variance within the customer's price range
|
||||||
var range = maxVal - minVal;
|
var range = maxVal - minVal;
|
||||||
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
|
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
|
||||||
var itemCount = 1 + (jobIdx % 3);
|
var itemCount = 1 + (visitIdx % 3);
|
||||||
var items = new List<JobItem>();
|
var items = new List<JobItem>();
|
||||||
|
|
||||||
for (int k = 0; k < itemCount; k++)
|
for (int k = 0; k < itemCount; k++)
|
||||||
{
|
{
|
||||||
var (desc, color, sand, mask, mins) = ItemSpec(jobIdx, k);
|
var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k);
|
||||||
var qty = 1 + (k % 3);
|
var qty = 1 + (k % 3);
|
||||||
var unitPrice = linkedQuote != null && k == 0
|
var unitPrice = linkedQuote != null && k == 0
|
||||||
? Math.Round(linkedQuote.Total / itemCount, 2)
|
? Math.Round(linkedQuote.Total / itemCount, 2)
|
||||||
@@ -256,7 +339,7 @@ public partial class SeedDataService
|
|||||||
JobNumber = $"{prefix}{seq:D4}",
|
JobNumber = $"{prefix}{seq:D4}",
|
||||||
CustomerId = customer.Id,
|
CustomerId = customer.Id,
|
||||||
QuoteId = linkedQuote?.Id,
|
QuoteId = linkedQuote?.Id,
|
||||||
AssignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null,
|
AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null,
|
||||||
Description = linkedQuote?.Description
|
Description = linkedQuote?.Description
|
||||||
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
|
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
|
||||||
JobStatusId = jobStatuses[statusCode],
|
JobStatusId = jobStatuses[statusCode],
|
||||||
@@ -268,18 +351,21 @@ public partial class SeedDataService
|
|||||||
QuotedPrice = quotedPrice,
|
QuotedPrice = quotedPrice,
|
||||||
FinalPrice = finalPrice,
|
FinalPrice = finalPrice,
|
||||||
IsRushJob = priorityCode == "RUSH",
|
IsRushJob = priorityCode == "RUSH",
|
||||||
CustomerPO = customer.IsCommercial && jobIdx % 3 == 0 ? $"PO-{40000 + jobIdx}" : null,
|
CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null,
|
||||||
SpecialInstructions = jobIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||||
jobIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||||
InternalNotes = jobIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||||
RequiresCustomerApproval = jobIdx % 5 == 0,
|
RequiresCustomerApproval = visitIdx % 5 == 0,
|
||||||
IsCustomerApproved = jobIdx % 5 != 0 || !isInProgress,
|
IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress,
|
||||||
JobItems = items,
|
JobItems = items,
|
||||||
CompanyId = company.Id,
|
CompanyId = company.Id,
|
||||||
CreatedAt = createdDate
|
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);
|
await _context.Set<Job>().AddRangeAsync(jobs);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|||||||
@@ -7,32 +7,28 @@ public partial class SeedDataService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
|
/// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
|
||||||
/// Rejected, and Expired — with a deliberate majority of Approved quotes so that
|
/// Rejected, and Expired — with Approved as the clear majority so that SeedJobsAsync
|
||||||
/// SeedJobsAsync has enough approved records to link jobs to.
|
/// has enough linked quotes to demonstrate the quote-to-job conversion workflow.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// All five quote statuses are present so the Quotes Index view demonstrates the full
|
/// Customers are loaded in Id-ascending order (matching the job seeder) and a
|
||||||
/// workflow from first contact to customer approval. APPROVED is the majority (16 of 35)
|
/// shuffled visit schedule interleaves commercial and individual customers so that
|
||||||
/// because the job seeder links to approved quotes to show the quote-to-job conversion path.
|
/// statuses and customer types are naturally mixed rather than appearing in blocks.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Quotes are distributed across both commercial and individual customers, matching the
|
/// Status pool (fixed seed 55): Approved ×18, Sent ×8, Draft ×4,
|
||||||
/// real-world mix where most quotes come from commercial accounts but individuals do
|
/// Rejected ×3, Expired ×2. Shuffled with Fisher-Yates (fixed seed 55)
|
||||||
/// occasionally request quotes for larger projects.
|
/// so every reset produces the same interleaved sequence.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Item descriptions, colours, and specs are drawn from the same 15-item pool used by
|
/// Per-customer quote counts mirror real business activity: top commercial accounts
|
||||||
/// the job seeder so that tutorial screenshots of quotes and linked jobs look consistent.
|
/// (Carolina Fabrication, Apex, Triangle) generate the most quotes; prospect customers
|
||||||
|
/// (Jennifer Clark, Lisa Anderson, Michelle Brown) have quotes but no jobs.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Pricing is deliberately simple (sqft × rate + variance) rather than running through
|
/// Dates spread over 7–185 days back (roughly 6 months) with a small jitter term so
|
||||||
/// IPricingCalculationService — this avoids a dependency on operating cost config that
|
/// historical charts show a natural activity curve without obviously linear spacing.
|
||||||
/// may not yet be populated when seed runs.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Dates spread over 150–180 days back (5–6 months) so the historical charts on the
|
|
||||||
/// dashboard and reports show a meaningful activity curve.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> SeedQuotesAsync(Company company)
|
private async Task<int> SeedQuotesAsync(Company company)
|
||||||
@@ -52,12 +48,11 @@ public partial class SeedDataService
|
|||||||
if (quoteStatuses.Count == 0)
|
if (quoteStatuses.Count == 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
// Load all customers — commercial first, then individual
|
// Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align
|
||||||
var allCustomers = await _context.Set<Customer>()
|
var allCustomers = await _context.Set<Customer>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
||||||
.OrderByDescending(c => c.IsCommercial)
|
.OrderBy(c => c.Id)
|
||||||
.ThenBy(c => c.Id)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (allCustomers.Count == 0)
|
if (allCustomers.Count == 0)
|
||||||
@@ -68,7 +63,6 @@ public partial class SeedDataService
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
var prefix = $"QT-{now:yy}{now.Month:D2}-";
|
var prefix = $"QT-{now:yy}{now.Month:D2}-";
|
||||||
var existing = await _context.Set<Quote>()
|
var existing = await _context.Set<Quote>()
|
||||||
.IgnoreQueryFilters()
|
.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;
|
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
|
||||||
var seq = maxNum + 1;
|
var seq = maxNum + 1;
|
||||||
|
|
||||||
// ── Status distribution: 3 Draft, 6 Sent, 16 Approved, 5 Rejected, 5 Expired ──
|
// ── Per-customer quote counts (must sum to 35) ─────────────────────────
|
||||||
// APPROVED is majority so SeedJobsAsync has enough linked quotes.
|
// Indices match the Id-ascending customer list from SeedCustomersAsync:
|
||||||
static string StatusFor(int i) => i switch
|
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||||
|
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||||
|
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||||
|
// 10–26 = individual residential customers
|
||||||
|
static int QuotesFor(int ci) => ci switch
|
||||||
{
|
{
|
||||||
< 3 => "DRAFT",
|
0 => 4, // Carolina Fabrication — most quotes
|
||||||
< 9 => "SENT",
|
1 => 3, // Apex Motorsports
|
||||||
< 25 => "APPROVED",
|
2 => 3, // Triangle Offroad
|
||||||
< 30 => "REJECTED",
|
3 => 3, // Smith Welding
|
||||||
_ => "EXPIRED"
|
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 0–9) and individual (ci 10+) ──
|
||||||
|
// Same two-pool approach as the job seeder — prevents commercial customers from
|
||||||
|
// clustering at the top of any list sorted by quote number or date.
|
||||||
|
var commercialVisits = new List<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 ──────────────────────
|
// ── 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) =>
|
static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
|
||||||
(i % 15) switch
|
(i % 15) switch
|
||||||
{
|
{
|
||||||
@@ -114,28 +187,30 @@ public partial class SeedDataService
|
|||||||
};
|
};
|
||||||
|
|
||||||
var quotes = new List<Quote>();
|
var quotes = new List<Quote>();
|
||||||
const int Total = 35;
|
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 ci = visitSchedule[visitIdx];
|
||||||
var customer = allCustomers[i % allCustomers.Count];
|
var customer = allCustomers[ci];
|
||||||
var statusCode = StatusFor(i);
|
var j = quotesByCustomer[ci]++; // within-customer quote index (unused except for date jitter)
|
||||||
|
var statusCode = statusPool[visitIdx];
|
||||||
|
|
||||||
// Spread dates over 30–180 days ago; newer quotes have lower index
|
// Dates spread 7–185 days back; small jitter via ci so identical-status quotes
|
||||||
var daysAgo = 180 - (int)(i * 4.5);
|
// 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 quoteDate = now.AddDays(-daysAgo);
|
||||||
var expireDate = quoteDate.AddDays(30);
|
var expireDate = quoteDate.AddDays(30);
|
||||||
|
|
||||||
var itemCount = 1 + (i % 3);
|
var itemCount = 1 + (visitIdx % 3);
|
||||||
var items = new List<QuoteItem>();
|
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 (desc, color, sand, mask, mins, sqft) = ItemSpec(visitIdx * 3 + k);
|
||||||
var qty = 1 + (j % 4);
|
var qty = 1 + (k % 4);
|
||||||
var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
|
var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
|
||||||
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 6) * 5.0m, 2);
|
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (visitIdx % 6) * 5.0m, 2);
|
||||||
|
|
||||||
items.Add(new QuoteItem
|
items.Add(new QuoteItem
|
||||||
{
|
{
|
||||||
@@ -147,8 +222,8 @@ public partial class SeedDataService
|
|||||||
RequiresSandblasting = sand,
|
RequiresSandblasting = sand,
|
||||||
RequiresMasking = mask,
|
RequiresMasking = mask,
|
||||||
EstimatedMinutes = mins,
|
EstimatedMinutes = mins,
|
||||||
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
Complexity = (visitIdx % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
||||||
Notes = j == 0 && i % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
|
Notes = k == 0 && visitIdx % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
|
||||||
CompanyId = company.Id,
|
CompanyId = company.Id,
|
||||||
CreatedAt = quoteDate
|
CreatedAt = quoteDate
|
||||||
});
|
});
|
||||||
@@ -160,7 +235,7 @@ public partial class SeedDataService
|
|||||||
var afterDiscount = subtotal - discountAmt;
|
var afterDiscount = subtotal - discountAmt;
|
||||||
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
|
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
|
||||||
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
|
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;
|
var total = afterDiscount + taxAmt + rushFee;
|
||||||
|
|
||||||
quotes.Add(new Quote
|
quotes.Add(new Quote
|
||||||
@@ -170,7 +245,7 @@ public partial class SeedDataService
|
|||||||
PreparedById = preparedByUser?.Id,
|
PreparedById = preparedByUser?.Id,
|
||||||
QuoteStatusId = quoteStatuses[statusCode],
|
QuoteStatusId = quoteStatuses[statusCode],
|
||||||
IsCommercial = customer.IsCommercial,
|
IsCommercial = customer.IsCommercial,
|
||||||
IsRushJob = i % 10 == 0,
|
IsRushJob = visitIdx % 10 == 0,
|
||||||
QuoteDate = quoteDate,
|
QuoteDate = quoteDate,
|
||||||
ExpirationDate = expireDate,
|
ExpirationDate = expireDate,
|
||||||
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
|
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
|
||||||
@@ -185,11 +260,11 @@ public partial class SeedDataService
|
|||||||
Total = total,
|
Total = total,
|
||||||
Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
|
Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
|
||||||
Terms = customer.PaymentTerms ?? "Net 30",
|
Terms = customer.PaymentTerms ?? "Net 30",
|
||||||
Notes = i % 8 == 0 ? "Customer requested color sample before full production run." :
|
Notes = visitIdx % 8 == 0 ? "Customer requested color sample before full production run." :
|
||||||
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
visitIdx % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
||||||
CustomerPO = customer.IsCommercial && i % 2 == 0 ? $"PO-{30000 + i}" : null,
|
CustomerPO = customer.IsCommercial && visitIdx % 2 == 0 ? $"PO-{30000 + visitIdx}" : null,
|
||||||
RequiresDeposit = i % 4 == 0,
|
RequiresDeposit = visitIdx % 4 == 0,
|
||||||
DepositPercent = i % 4 == 0 ? 50m : 0m,
|
DepositPercent = visitIdx % 4 == 0 ? 50m : 0m,
|
||||||
QuoteItems = items,
|
QuoteItems = items,
|
||||||
CompanyId = company.Id,
|
CompanyId = company.Id,
|
||||||
CreatedAt = quoteDate,
|
CreatedAt = quoteDate,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Services;
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
@@ -79,28 +80,25 @@ public partial class SeedDataService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// All queries use <c>IgnoreQueryFilters()</c> so that records already soft-deleted by users
|
/// When <c>ForceRemoveAll = true</c> a topologically ordered pre-sweep deletes every
|
||||||
/// are still found and physically removed — this prevents orphaned data from accumulating
|
/// child record for the company that has a NO_ACTION FK pointing at a parent we need
|
||||||
/// in the database after partial cleanup.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Child records (job items, quote items, transactions, maintenance records, etc.) are deleted
|
/// For the selective (non-force) path the Customers block also pre-deletes Invoices,
|
||||||
/// first before their parent to avoid FK constraint violations. Each category is committed
|
/// Payments, Deposits, QuotePhotos, and InAppNotifications that reference the seeded
|
||||||
/// with its own <c>SaveChangesAsync()</c> call so a failure in one category does not roll
|
/// customer/job/quote IDs, so those deletes succeed even when the broader pre-sweep
|
||||||
/// back deletions already completed in an earlier category.
|
/// has not run.
|
||||||
/// </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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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)
|
public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options)
|
||||||
{
|
{
|
||||||
var result = new SeedDataResult { Success = true };
|
var result = new SeedDataResult { Success = true };
|
||||||
@@ -120,11 +118,73 @@ public partial class SeedDataService
|
|||||||
return result;
|
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)
|
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
|
var seededCustomerIds = options.ForceRemoveAll
|
||||||
? await _context.Customers.IgnoreQueryFilters()
|
? await _context.Customers.IgnoreQueryFilters()
|
||||||
.Where(c => c.CompanyId == companyId)
|
.Where(c => c.CompanyId == companyId)
|
||||||
@@ -135,13 +195,99 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (seededCustomerIds.Any())
|
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))
|
.Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId))
|
||||||
.Select(j => j.Id)
|
.Select(j => j.Id).ToListAsync();
|
||||||
.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())
|
if (seededJobIds.Any())
|
||||||
{
|
{
|
||||||
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
|
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
|
||||||
@@ -164,28 +310,22 @@ public partial class SeedDataService
|
|||||||
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
|
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
|
||||||
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
|
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();
|
.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()
|
var jobs = await _context.Jobs.IgnoreQueryFilters()
|
||||||
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
|
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
|
||||||
_context.Jobs.RemoveRange(jobs);
|
_context.Jobs.RemoveRange(jobs);
|
||||||
totalRemoved += jobs.Count;
|
totalRemoved += jobs.Count;
|
||||||
details.Add($"✓ Removed {jobs.Count} seeded job(s)");
|
details.Add($"✓ Removed {jobs.Count} job(s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quotes and their child records
|
// ── Quotes and their cascade children ───────────────────────────────
|
||||||
var seededQuoteIds = await _context.Quotes
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue && seededCustomerIds.Contains(q.CustomerId.Value))
|
|
||||||
.Select(q => q.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (seededQuoteIds.Any())
|
if (seededQuoteIds.Any())
|
||||||
{
|
{
|
||||||
// Collect prediction IDs before removing items (FK is NoAction — predictions
|
// Collect AiItemPrediction IDs before removing QuoteItems (FK is NoAction —
|
||||||
// must be deleted after the items that reference them are gone).
|
// predictions must be orphaned after items are gone, then deleted separately).
|
||||||
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
|
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
|
||||||
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
|
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
|
||||||
.Select(qi => qi.AiPredictionId!.Value)
|
.Select(qi => qi.AiPredictionId!.Value)
|
||||||
@@ -204,25 +344,24 @@ public partial class SeedDataService
|
|||||||
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
|
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
|
||||||
_context.Quotes.RemoveRange(quotes);
|
_context.Quotes.RemoveRange(quotes);
|
||||||
totalRemoved += quotes.Count;
|
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())
|
if (predictionIds.Any())
|
||||||
{
|
{
|
||||||
var predictions = await _context.Set<Core.Entities.AiItemPrediction>()
|
var predictions = await _context.Set<AiItemPrediction>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => predictionIds.Contains(p.Id))
|
.Where(p => predictionIds.Contains(p.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
if (predictions.Any())
|
if (predictions.Any())
|
||||||
{
|
{
|
||||||
_context.Set<Core.Entities.AiItemPrediction>().RemoveRange(predictions);
|
_context.Set<AiItemPrediction>().RemoveRange(predictions);
|
||||||
totalRemoved += predictions.Count;
|
totalRemoved += predictions.Count;
|
||||||
details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
|
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()
|
var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters()
|
||||||
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
|
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
|
||||||
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
|
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
|
||||||
@@ -231,7 +370,7 @@ public partial class SeedDataService
|
|||||||
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
|
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
|
||||||
_context.Customers.RemoveRange(customers);
|
_context.Customers.RemoveRange(customers);
|
||||||
totalRemoved += customers.Count;
|
totalRemoved += customers.Count;
|
||||||
details.Add($"✓ Removed {customers.Count} seeded customer(s)");
|
details.Add($"✓ Removed {customers.Count} customer(s)");
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
@@ -241,7 +380,7 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Inventory Items ---
|
// ── Inventory Items ───────────────────────────────────────────────────
|
||||||
if (options.InventoryItems)
|
if (options.InventoryItems)
|
||||||
{
|
{
|
||||||
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
|
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
|
||||||
@@ -259,7 +398,7 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
_context.InventoryItems.RemoveRange(inventoryItems);
|
_context.InventoryItems.RemoveRange(inventoryItems);
|
||||||
totalRemoved += inventoryItems.Count;
|
totalRemoved += inventoryItems.Count;
|
||||||
details.Add($"✓ Removed {inventoryItems.Count} seeded inventory item(s)");
|
details.Add($"✓ Removed {inventoryItems.Count} inventory item(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -268,7 +407,7 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Equipment (+ maintenance records) ---
|
// ── Equipment (+ maintenance records) ────────────────────────────────
|
||||||
if (options.Equipment)
|
if (options.Equipment)
|
||||||
{
|
{
|
||||||
var seededEquipment = await _context.Equipment
|
var seededEquipment = await _context.Equipment
|
||||||
@@ -285,7 +424,7 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
_context.Equipment.RemoveRange(seededEquipment);
|
_context.Equipment.RemoveRange(seededEquipment);
|
||||||
totalRemoved += seededEquipment.Count;
|
totalRemoved += seededEquipment.Count;
|
||||||
details.Add($"✓ Removed {seededEquipment.Count} seeded equipment record(s)");
|
details.Add($"✓ Removed {seededEquipment.Count} equipment record(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -294,7 +433,7 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Catalog Items & Categories ---
|
// ── Catalog Items & Categories ────────────────────────────────────────
|
||||||
if (options.Catalog)
|
if (options.Catalog)
|
||||||
{
|
{
|
||||||
var seededCategories = await _context.CatalogCategories
|
var seededCategories = await _context.CatalogCategories
|
||||||
@@ -313,12 +452,12 @@ public partial class SeedDataService
|
|||||||
{
|
{
|
||||||
_context.CatalogItems.RemoveRange(catalogItems);
|
_context.CatalogItems.RemoveRange(catalogItems);
|
||||||
totalRemoved += catalogItems.Count;
|
totalRemoved += catalogItems.Count;
|
||||||
details.Add($"✓ Removed {catalogItems.Count} seeded catalog item(s)");
|
details.Add($"✓ Removed {catalogItems.Count} catalog item(s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
_context.CatalogCategories.RemoveRange(seededCategories);
|
_context.CatalogCategories.RemoveRange(seededCategories);
|
||||||
totalRemoved += seededCategories.Count;
|
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();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -327,7 +466,7 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pricing Tiers ---
|
// ── Pricing Tiers ─────────────────────────────────────────────────────
|
||||||
if (options.PricingTiers)
|
if (options.PricingTiers)
|
||||||
{
|
{
|
||||||
var tiers = await _context.PricingTiers
|
var tiers = await _context.PricingTiers
|
||||||
@@ -339,7 +478,7 @@ public partial class SeedDataService
|
|||||||
{
|
{
|
||||||
_context.PricingTiers.RemoveRange(tiers);
|
_context.PricingTiers.RemoveRange(tiers);
|
||||||
totalRemoved += tiers.Count;
|
totalRemoved += tiers.Count;
|
||||||
details.Add($"✓ Removed {tiers.Count} seeded pricing tier(s)");
|
details.Add($"✓ Removed {tiers.Count} pricing tier(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -348,7 +487,7 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Operating Costs ---
|
// ── Operating Costs ───────────────────────────────────────────────────
|
||||||
if (options.OperatingCosts)
|
if (options.OperatingCosts)
|
||||||
{
|
{
|
||||||
var costs = await _context.CompanyOperatingCosts
|
var costs = await _context.CompanyOperatingCosts
|
||||||
@@ -360,7 +499,7 @@ public partial class SeedDataService
|
|||||||
{
|
{
|
||||||
_context.CompanyOperatingCosts.RemoveRange(costs);
|
_context.CompanyOperatingCosts.RemoveRange(costs);
|
||||||
totalRemoved += costs.Count;
|
totalRemoved += costs.Count;
|
||||||
details.Add($"✓ Removed operating costs record");
|
details.Add("✓ Removed operating costs record");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -369,10 +508,11 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Purchase Orders (removed alongside bills — tightly coupled in demo workflows) ---
|
// ── Purchase Orders ───────────────────────────────────────────────────
|
||||||
if (options.Bills)
|
// 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()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => p.CompanyId == companyId)
|
.Where(p => p.CompanyId == companyId)
|
||||||
.Select(p => p.Id)
|
.Select(p => p.Id)
|
||||||
@@ -380,27 +520,28 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (poIds.Any())
|
if (poIds.Any())
|
||||||
{
|
{
|
||||||
var poItems = await _context.Set<Core.Entities.PurchaseOrderItem>()
|
var poItems = await _context.Set<PurchaseOrderItem>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(i => poIds.Contains(i.PurchaseOrderId))
|
.Where(i => poIds.Contains(i.PurchaseOrderId))
|
||||||
.ToListAsync();
|
.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => poIds.Contains(p.Id))
|
.Where(p => poIds.Contains(p.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
_context.Set<Core.Entities.PurchaseOrder>().RemoveRange(pos);
|
_context.Set<PurchaseOrder>().RemoveRange(pos);
|
||||||
totalRemoved += pos.Count;
|
totalRemoved += pos.Count;
|
||||||
details.Add($"✓ Removed {pos.Count} purchase order(s)");
|
details.Add($"✓ Removed {pos.Count} purchase order(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Vendor Bills (all bills for the company) ---
|
// ── Vendor Bills ──────────────────────────────────────────────────────
|
||||||
if (options.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(b => b.CompanyId == companyId)
|
.Where(b => b.CompanyId == companyId)
|
||||||
.Select(b => b.Id)
|
.Select(b => b.Id)
|
||||||
@@ -408,23 +549,23 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (billIds.Any())
|
if (billIds.Any())
|
||||||
{
|
{
|
||||||
var payments = await _context.Set<Core.Entities.BillPayment>()
|
var payments = await _context.Set<BillPayment>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => billIds.Contains(p.BillId))
|
.Where(p => billIds.Contains(p.BillId))
|
||||||
.ToListAsync();
|
.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(li => billIds.Contains(li.BillId))
|
.Where(li => billIds.Contains(li.BillId))
|
||||||
.ToListAsync();
|
.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(b => billIds.Contains(b.Id))
|
.Where(b => billIds.Contains(b.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
_context.Set<Core.Entities.Bill>().RemoveRange(bills);
|
_context.Set<Bill>().RemoveRange(bills);
|
||||||
totalRemoved += bills.Count;
|
totalRemoved += bills.Count;
|
||||||
details.Add($"✓ Removed {bills.Count} vendor bill(s)");
|
details.Add($"✓ Removed {bills.Count} vendor bill(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
@@ -435,17 +576,18 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Expenses ---
|
// ── Expenses ──────────────────────────────────────────────────────────
|
||||||
if (options.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()
|
.IgnoreQueryFilters()
|
||||||
.Where(e => e.CompanyId == companyId)
|
.Where(e => e.CompanyId == companyId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (expenses.Any())
|
if (expenses.Any())
|
||||||
{
|
{
|
||||||
_context.Set<Core.Entities.Expense>().RemoveRange(expenses);
|
_context.Set<Expense>().RemoveRange(expenses);
|
||||||
totalRemoved += expenses.Count;
|
totalRemoved += expenses.Count;
|
||||||
details.Add($"✓ Removed {expenses.Count} expense(s)");
|
details.Add($"✓ Removed {expenses.Count} expense(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
@@ -456,17 +598,17 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Vendors ---
|
// ── Vendors ───────────────────────────────────────────────────────────
|
||||||
if (options.Vendors || options.ForceRemoveAll)
|
if (options.Vendors || options.ForceRemoveAll)
|
||||||
{
|
{
|
||||||
var vendors = await _context.Set<Core.Entities.Vendor>()
|
var vendors = await _context.Set<Vendor>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(v => v.CompanyId == companyId)
|
.Where(v => v.CompanyId == companyId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (vendors.Any())
|
if (vendors.Any())
|
||||||
{
|
{
|
||||||
_context.Set<Core.Entities.Vendor>().RemoveRange(vendors);
|
_context.Set<Vendor>().RemoveRange(vendors);
|
||||||
totalRemoved += vendors.Count;
|
totalRemoved += vendors.Count;
|
||||||
details.Add($"✓ Removed {vendors.Count} vendor(s)");
|
details.Add($"✓ Removed {vendors.Count} vendor(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
@@ -477,17 +619,17 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Named Ovens (OvenCost) ---
|
// ── Named Ovens (OvenCost) ────────────────────────────────────────────
|
||||||
if (options.NamedOvens || options.ForceRemoveAll)
|
if (options.NamedOvens || options.ForceRemoveAll)
|
||||||
{
|
{
|
||||||
var ovens = await _context.Set<Core.Entities.OvenCost>()
|
var ovens = await _context.Set<OvenCost>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(o => o.CompanyId == companyId)
|
.Where(o => o.CompanyId == companyId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (ovens.Any())
|
if (ovens.Any())
|
||||||
{
|
{
|
||||||
_context.Set<Core.Entities.OvenCost>().RemoveRange(ovens);
|
_context.Set<OvenCost>().RemoveRange(ovens);
|
||||||
totalRemoved += ovens.Count;
|
totalRemoved += ovens.Count;
|
||||||
details.Add($"✓ Removed {ovens.Count} named oven(s)");
|
details.Add($"✓ Removed {ovens.Count} named oven(s)");
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
@@ -498,32 +640,9 @@ public partial class SeedDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Appointments ---
|
// ── Shop Workers ──────────────────────────────────────────────────────
|
||||||
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 ---
|
|
||||||
if (options.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
|
var workerUsers = options.ForceRemoveAll
|
||||||
? await _userManager.Users
|
? await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker")
|
.Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker")
|
||||||
|
|||||||
@@ -115,19 +115,26 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (workers.Count == 0) return 0;
|
if (workers.Count == 0) return 0;
|
||||||
|
|
||||||
// Only create entries for jobs that have been worked on
|
// Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can
|
||||||
var activeJobs = await _context.Set<Job>()
|
// silently return null navigation properties when query filters interact with IgnoreQueryFilters.
|
||||||
|
var workedStatusIds = await _context.Set<JobStatusLookup>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
|
.Where(s => s.CompanyId == company.Id && new[]
|
||||||
.Include(j => j.JobStatus)
|
{
|
||||||
|
"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();
|
.ToListAsync();
|
||||||
|
|
||||||
var workedJobs = activeJobs.Where(j =>
|
if (workedStatusIds.Count == 0) return 0;
|
||||||
j.JobStatus?.StatusCode is
|
|
||||||
"IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or
|
var workedJobs = await _context.Set<Job>()
|
||||||
"IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK" or
|
.IgnoreQueryFilters()
|
||||||
"COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED"
|
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||||
).ToList();
|
&& workedStatusIds.Contains(j.JobStatusId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
if (workedJobs.Count == 0) return 0;
|
if (workedJobs.Count == 0) return 0;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
@@ -13,15 +14,18 @@ public partial class SeedDataService : ISeedDataService
|
|||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly RoleManager<IdentityRole> _roleManager;
|
private readonly RoleManager<IdentityRole> _roleManager;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public SeedDataService(
|
public SeedDataService(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
RoleManager<IdentityRole> roleManager)
|
RoleManager<IdentityRole> roleManager,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_roleManager = roleManager;
|
_roleManager = roleManager;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -426,8 +430,53 @@ public partial class SeedDataService : ISeedDataService
|
|||||||
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
||||||
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
||||||
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(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("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
|
||||||
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(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));
|
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
|
||||||
|
|
||||||
if (company.CompanyCode == "DEMO")
|
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(); }
|
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())
|
if (errors.Any())
|
||||||
{
|
{
|
||||||
details.AddRange(errors);
|
details.AddRange(errors);
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ public class CustomersController : Controller
|
|||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
|
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"
|
"ContactName" => q => gridRequest.SortDirection == "asc"
|
||||||
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
||||||
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(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),
|
"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),
|
"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),
|
"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
|
// Get paged data
|
||||||
|
|||||||
@@ -1888,7 +1888,7 @@ public class InvoicesController : Controller
|
|||||||
/// Details view can show an inline toast with the delivery outcome.
|
/// Details view can show an inline toast with the delivery outcome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[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
|
try
|
||||||
{
|
{
|
||||||
@@ -1902,7 +1902,9 @@ public class InvoicesController : Controller
|
|||||||
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
||||||
return Json(new { success = false, message = "Voided invoices cannot be resent." });
|
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();
|
overrideEmail = overrideEmail?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
|
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
|
||||||
return Json(new { success = false, message = "The email address provided is not valid." });
|
return Json(new { success = false, message = "The email address provided is not valid." });
|
||||||
@@ -1915,11 +1917,26 @@ public class InvoicesController : Controller
|
|||||||
? overrideEmail
|
? overrideEmail
|
||||||
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
|
: 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." });
|
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;
|
byte[]? pdfBytes = null;
|
||||||
string? pdfFilename = null;
|
string? pdfFilename = null;
|
||||||
|
if (sendEmail)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||||
@@ -1929,18 +1946,26 @@ public class InvoicesController : Controller
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
_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);
|
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||||
|
|
||||||
if (latestLog?.Status == NotificationStatus.Failed)
|
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 = 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,12 +146,19 @@ public class ReportsController : Controller
|
|||||||
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
|
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
|
||||||
|
|
||||||
// === REVENUE ANALYTICS ===
|
// === REVENUE ANALYTICS ===
|
||||||
// Pre-filter completed jobs by date range once for monthly calculations
|
// CompletedDate is the authoritative "when the job finished" date.
|
||||||
var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
// 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
|
// Group by month for efficient monthly aggregations
|
||||||
var jobsByMonth = completedJobsInRange
|
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());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
// Monthly revenue trend
|
// Monthly revenue trend
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ public class SeedDataController : Controller
|
|||||||
OperatingCosts = true,
|
OperatingCosts = true,
|
||||||
Bills = true,
|
Bills = true,
|
||||||
Expenses = true,
|
Expenses = true,
|
||||||
Workers = true,
|
Workers = false, // workers stay static — never deleted on reset
|
||||||
Vendors = true,
|
Vendors = true,
|
||||||
NamedOvens = true,
|
NamedOvens = true,
|
||||||
Appointments = true,
|
Appointments = true,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
||||||
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
|
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
|
||||||
var canPay = !isVoided && Model.BalanceDue > 0;
|
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 hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||||
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
||||||
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
|
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
|
||||||
@@ -654,23 +654,43 @@
|
|||||||
</a>
|
</a>
|
||||||
@if (canResend)
|
@if (canResend)
|
||||||
{
|
{
|
||||||
@if (!hasEmail)
|
@if (hasEmail && !emailOptedOut && hasSms)
|
||||||
{
|
{
|
||||||
|
@* Both email + SMS — channel choice modal *@
|
||||||
<button type="button" class="btn btn-outline-primary"
|
<button type="button" class="btn btn-outline-primary"
|
||||||
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
|
data-bs-toggle="modal" data-bs-target="#resendChannelModal">
|
||||||
<i class="bi bi-send me-2"></i>Send Invoice
|
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else if (emailOptedOut)
|
else if (hasSms && (!hasEmail || emailOptedOut))
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-outline-primary" disabled
|
@* SMS only *@
|
||||||
title="Email notifications are turned off for this customer">
|
<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
|
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
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
|
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -1138,6 +1158,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 · @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 · @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 & 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 -->
|
<!-- Notifications Sent Modal -->
|
||||||
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
|
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<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('resendInvoiceSending').classList.remove('d-none');
|
||||||
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
||||||
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
||||||
@@ -1549,6 +1609,8 @@
|
|||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
||||||
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
||||||
|
url += '&sendEmail=' + (sendEmail ? 'true' : 'false');
|
||||||
|
url += '&sendSms=' + (sendSms ? 'true' : 'false');
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1567,11 +1629,11 @@
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
||||||
header.className = 'modal-header bg-success text-white';
|
header.className = 'modal-header bg-success text-white';
|
||||||
showInfo(data.message, 'Email Sent');
|
showInfo(data.message, 'Invoice Sent');
|
||||||
} else {
|
} else {
|
||||||
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||||||
header.className = 'modal-header bg-danger text-white';
|
header.className = 'modal-header bg-danger text-white';
|
||||||
showWarning(data.message, 'Email Not Sent');
|
showWarning(data.message, 'Send Failed');
|
||||||
}
|
}
|
||||||
msg.textContent = data.message;
|
msg.textContent = data.message;
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user