Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
T
spouliot 7735fe3cce 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>
2026-06-11 13:20:04 -04:00

682 lines
35 KiB
C#

using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Canonical email addresses of all customers created by the seed operation.
/// Used during removal to identify seeded customers without relying on a flag column —
/// matching on email is stable even if the records were soft-deleted between seed and remove.
/// </summary>
private static readonly string[] SeededCustomerEmails =
[
// Commercial — NC Triangle area
"matt@carolinafab.com", "ctanner@apexmotorsports.com", "jpruitt@triangleoffroad.com",
"bsmith@smithwelding.com", "kmorales@raleigharchitectural.com", "tgreco@eastcoastpw.com",
"dshaw@piedmontmetalworks.com", "lpatel@caryindustrial.com", "rblake@durhamtech.com",
"mcoleman@wakecountyfleet.gov",
// Individual residential
"jdavis@email.com", "sjenkins@email.com", "mthompson@email.com", "rmiller@email.com",
"jclark@email.com", "dwilson@email.com", "landerson@email.com", "tharris@email.com",
"kwhite@email.com", "jtaylor@email.com", "mbrown@email.com", "clee@email.com",
"agarcia@email.com", "kmartinez@email.com", "nrodriguez@email.com",
"bhall@email.com", "pyoung@email.com"
];
/// <summary>
/// Serial numbers assigned to all equipment records created by the seed operation.
/// Serial numbers are manufacturer-assigned strings that are stable identifiers even across
/// soft-delete cycles, making them safe fingerprints for seeded equipment detection.
/// </summary>
private static readonly string[] SeededEquipmentSerials =
[
"RFS240023456", "RFS180012789", "NOR120045678", "CC800034512",
"EMP483623890", "CLM101223456", "ATC7523467", "PAC50034521",
"BE489612345", "GEM0623456"
];
/// <summary>
/// Display names of the catalog categories created by the seed operation.
/// Category name is the only reliable fingerprint because seeded categories carry no
/// special flag; the name list is kept in sync with <see cref="SeedCatalogAsync"/>.
/// </summary>
private static readonly string[] SeededCatalogCategoryNames =
[
"Automotive Wheels", "Engine Components", "Outdoor Furniture",
"Railings & Handrails", "Gates & Fencing", "Fitness Equipment", "Office & Commercial"
];
/// <summary>
/// SKU suffixes appended to the company code when seeding inventory items
/// (e.g. <c>DEMO-PWD-BLK-001</c>). The full SKU is reconstructed at removal time
/// as <c>{CompanyCode}{suffix}</c>, matching the pattern used in the inventory seeder.
/// </summary>
private static readonly string[] SeededInventorySkuSuffixes =
[
// 6 powders
"-PWD-GBK-001", "-PWD-MBK-001", "-PWD-CHR-001", "-PWD-CRD-001",
"-PWD-SWH-001", "-PWD-IPU-001",
// 5 consumables
"-MSK-001", "-PLG-001", "-HKS-001", "-ACT-001", "-BLM-001"
];
/// <summary>
/// Display names of the pricing tiers created by the seed operation.
/// Tiers are matched by name at removal time; the list must stay in sync with the
/// pricing tier seeder to avoid leaving orphaned tiers behind.
/// </summary>
private static readonly string[] SeededPricingTierNames =
[
"Standard", "Silver", "Gold", "Platinum"
];
/// <summary>
/// Physically removes previously seeded demo data for the specified company, respecting
/// the caller-supplied <paramref name="options"/> flags so operators can selectively
/// remove only the data categories they want to clean up.
/// </summary>
/// <remarks>
/// <para>
/// When <c>ForceRemoveAll = true</c> a topologically ordered pre-sweep deletes every
/// child record for the company that has a NO_ACTION FK pointing at a parent we need
/// to delete later. This avoids FK constraint errors regardless of how much user-created
/// data has accumulated since the last seed. The sweep order mirrors the FK dependency
/// graph derived from <c>sys.foreign_keys</c>:
/// OvenBatchItems → PowderUsageLogs → InAppNotifications → QuotePhotos → GiftCertificates
/// → JobTemplates → CreditMemoApplications → Refunds → CreditMemos → ReworkRecords
/// → Deposits → Payments → InventoryTransactions → Invoices → Appointments
/// → VendorCreditApplications → Bills → VendorCredits → PurchaseOrders → Expenses
/// → OvenBatches → (self-referential Jobs.OriginalJobId nulled via raw SQL)
/// then the main entity blocks run in safe order.
/// </para>
/// <para>
/// For the selective (non-force) path the Customers block also pre-deletes Invoices,
/// Payments, Deposits, QuotePhotos, and InAppNotifications that reference the seeded
/// customer/job/quote IDs, so those deletes succeed even when the broader pre-sweep
/// has not run.
/// </para>
/// </remarks>
public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options)
{
var result = new SeedDataResult { Success = true };
var details = new List<string>();
int totalRemoved = 0;
try
{
var company = await _context.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted);
if (company == null)
{
result.Success = false;
result.Message = "Company not found";
return result;
}
// ── 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)
{
var seededCustomerIds = options.ForceRemoveAll
? await _context.Customers.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId)
.Select(c => c.Id).ToListAsync()
: await _context.Customers.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCustomerEmails.Contains(c.Email))
.Select(c => c.Id).ToListAsync();
if (seededCustomerIds.Any())
{
var seededJobIds = await _context.Jobs.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId))
.Select(j => j.Id).ToListAsync();
var seededQuoteIds = await _context.Quotes.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue
&& seededCustomerIds.Contains(q.CustomerId.Value))
.Select(q => q.Id).ToListAsync();
// ── Pre-delete records with NO_ACTION FKs pointing at Jobs/Quotes/Customers ──
// For ForceRemoveAll these are already gone (pre-sweep above). For selective
// removal this is the only pass, so we scope to the seeded entity IDs.
// Appointments — NO_ACTION → Customers AND Jobs
if (seededCustomerIds.Any() || seededJobIds.Any())
{
var appts = await _context.Set<Appointment>().IgnoreQueryFilters()
.Where(a => a.CompanyId == companyId
&& (a.CustomerId.HasValue && seededCustomerIds.Contains(a.CustomerId.Value)
|| a.JobId.HasValue && seededJobIds.Contains(a.JobId.Value)))
.ToListAsync();
if (appts.Any()) _context.Set<Appointment>().RemoveRange(appts);
}
// InAppNotifications — NO_ACTION → Customers, Quotes (Invoices handled below)
if (seededCustomerIds.Any() || seededQuoteIds.Any())
{
var nots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
.Where(n => n.CompanyId == companyId
&& (n.CustomerId.HasValue && seededCustomerIds.Contains(n.CustomerId.Value)
|| n.QuoteId.HasValue && seededQuoteIds.Contains(n.QuoteId.Value)))
.ToListAsync();
if (nots.Any()) _context.Set<InAppNotification>().RemoveRange(nots);
}
// QuotePhotos — NO_ACTION → Quotes
if (seededQuoteIds.Any())
{
var qp = await _context.Set<QuotePhoto>().IgnoreQueryFilters()
.Where(p => p.QuoteId.HasValue && seededQuoteIds.Contains(p.QuoteId.Value)).ToListAsync();
if (qp.Any()) _context.Set<QuotePhoto>().RemoveRange(qp);
}
// Deposits — NO_ACTION → Jobs, Quotes (CustomerId is CASCADE from Customer but
// we delete deposits explicitly so jobs/quotes can be deleted first)
if (seededCustomerIds.Any())
{
var deps = await _context.Set<Deposit>().IgnoreQueryFilters()
.Where(d => seededCustomerIds.Contains(d.CustomerId)).ToListAsync();
if (deps.Any()) _context.Set<Deposit>().RemoveRange(deps);
}
await _context.SaveChangesAsync();
// Invoices — NO_ACTION → Jobs AND Customers; must be gone before Jobs are deleted.
// Collect invoice IDs first so Payments (NO_ACTION → Invoices) can be cleared.
List<int> seededInvoiceIds = [];
if (seededCustomerIds.Any())
{
seededInvoiceIds = await _context.Set<Invoice>().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId
&& seededCustomerIds.Contains(i.CustomerId))
.Select(i => i.Id).ToListAsync();
if (seededInvoiceIds.Any())
{
// InAppNotifications referencing these invoices
var invNots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
.Where(n => n.InvoiceId.HasValue && seededInvoiceIds.Contains(n.InvoiceId.Value))
.ToListAsync();
if (invNots.Any()) _context.Set<InAppNotification>().RemoveRange(invNots);
// Payments — NO_ACTION → Invoices
var pmts = await _context.Set<Payment>().IgnoreQueryFilters()
.Where(p => seededInvoiceIds.Contains(p.InvoiceId)).ToListAsync();
if (pmts.Any()) _context.Set<Payment>().RemoveRange(pmts);
await _context.SaveChangesAsync();
var invoices = await _context.Set<Invoice>().IgnoreQueryFilters()
.Where(i => seededInvoiceIds.Contains(i.Id)).ToListAsync();
if (invoices.Any())
{
_context.Set<Invoice>().RemoveRange(invoices); // InvoiceItems CASCADE
totalRemoved += invoices.Count;
details.Add($"✓ Removed {invoices.Count} invoice(s)");
}
await _context.SaveChangesAsync();
}
}
// ── Jobs and their cascade children ─────────────────────────────────
if (seededJobIds.Any())
{
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPhotos.Any()) _context.JobPhotos.RemoveRange(jobPhotos);
var jobNotes = await _context.JobNotes.IgnoreQueryFilters()
.Where(n => seededJobIds.Contains(n.JobId)).ToListAsync();
if (jobNotes.Any()) _context.JobNotes.RemoveRange(jobNotes);
var jobItems = await _context.JobItems.IgnoreQueryFilters()
.Where(i => seededJobIds.Contains(i.JobId)).ToListAsync();
if (jobItems.Any()) _context.JobItems.RemoveRange(jobItems);
var jobStatusHistory = await _context.JobStatusHistory.IgnoreQueryFilters()
.Where(h => seededJobIds.Contains(h.JobId)).ToListAsync();
if (jobStatusHistory.Any()) _context.JobStatusHistory.RemoveRange(jobStatusHistory);
var jobPrepServices = await _context.JobPrepServices.IgnoreQueryFilters()
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
var timeEntries = await _context.Set<JobTimeEntry>().IgnoreQueryFilters()
.Where(te => seededJobIds.Contains(te.JobId)).ToListAsync();
if (timeEntries.Any()) _context.Set<JobTimeEntry>().RemoveRange(timeEntries);
var jobs = await _context.Jobs.IgnoreQueryFilters()
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
_context.Jobs.RemoveRange(jobs);
totalRemoved += jobs.Count;
details.Add($"✓ Removed {jobs.Count} job(s)");
}
// ── Quotes and their cascade children ───────────────────────────────
if (seededQuoteIds.Any())
{
// Collect AiItemPrediction IDs before removing QuoteItems (FK is NoAction —
// predictions must be orphaned after items are gone, then deleted separately).
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
.Select(qi => qi.AiPredictionId!.Value)
.Distinct()
.ToListAsync();
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
var quotePrepServices = await _context.QuotePrepServices.IgnoreQueryFilters()
.Where(p => seededQuoteIds.Contains(p.QuoteId)).ToListAsync();
if (quotePrepServices.Any()) _context.QuotePrepServices.RemoveRange(quotePrepServices);
var quotes = await _context.Quotes.IgnoreQueryFilters()
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
_context.Quotes.RemoveRange(quotes);
totalRemoved += quotes.Count;
details.Add($"✓ Removed {quotes.Count} quote(s)");
if (predictionIds.Any())
{
var predictions = await _context.Set<AiItemPrediction>()
.IgnoreQueryFilters()
.Where(p => predictionIds.Contains(p.Id))
.ToListAsync();
if (predictions.Any())
{
_context.Set<AiItemPrediction>().RemoveRange(predictions);
totalRemoved += predictions.Count;
details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
}
}
}
// Customer notes (CASCADE from Customer, but explicit for clarity)
var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters()
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
var customers = await _context.Customers.IgnoreQueryFilters()
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
_context.Customers.RemoveRange(customers);
totalRemoved += customers.Count;
details.Add($"✓ Removed {customers.Count} customer(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded customers found");
}
}
// ── Inventory Items ───────────────────────────────────────────────────
if (options.InventoryItems)
{
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
var inventoryItems = await _context.InventoryItems
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && seededSkus.Contains(i.SKU))
.ToListAsync();
if (inventoryItems.Any())
{
var inventoryIds = inventoryItems.Select(i => i.Id).ToList();
var transactions = await _context.InventoryTransactions.IgnoreQueryFilters()
.Where(t => inventoryIds.Contains(t.InventoryItemId)).ToListAsync();
if (transactions.Any()) _context.InventoryTransactions.RemoveRange(transactions);
_context.InventoryItems.RemoveRange(inventoryItems);
totalRemoved += inventoryItems.Count;
details.Add($"✓ Removed {inventoryItems.Count} inventory item(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded inventory items found");
}
}
// ── Equipment (+ maintenance records) ────────────────────────────────
if (options.Equipment)
{
var seededEquipment = await _context.Equipment
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && SeededEquipmentSerials.Contains(e.SerialNumber))
.ToListAsync();
if (seededEquipment.Any())
{
var equipmentIds = seededEquipment.Select(e => e.Id).ToList();
var maintenance = await _context.MaintenanceRecords.IgnoreQueryFilters()
.Where(m => equipmentIds.Contains(m.EquipmentId)).ToListAsync();
if (maintenance.Any()) _context.MaintenanceRecords.RemoveRange(maintenance);
_context.Equipment.RemoveRange(seededEquipment);
totalRemoved += seededEquipment.Count;
details.Add($"✓ Removed {seededEquipment.Count} equipment record(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded equipment found");
}
}
// ── Catalog Items & Categories ────────────────────────────────────────
if (options.Catalog)
{
var seededCategories = await _context.CatalogCategories
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCatalogCategoryNames.Contains(c.Name))
.ToListAsync();
if (seededCategories.Any())
{
var categoryIds = seededCategories.Select(c => c.Id).ToList();
var catalogItems = await _context.CatalogItems.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && categoryIds.Contains(i.CategoryId))
.ToListAsync();
if (catalogItems.Any())
{
_context.CatalogItems.RemoveRange(catalogItems);
totalRemoved += catalogItems.Count;
details.Add($"✓ Removed {catalogItems.Count} catalog item(s)");
}
_context.CatalogCategories.RemoveRange(seededCategories);
totalRemoved += seededCategories.Count;
details.Add($"✓ Removed {seededCategories.Count} catalog categor(y/ies)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded catalog data found");
}
}
// ── Pricing Tiers ─────────────────────────────────────────────────────
if (options.PricingTiers)
{
var tiers = await _context.PricingTiers
.IgnoreQueryFilters()
.Where(t => t.CompanyId == companyId && SeededPricingTierNames.Contains(t.TierName))
.ToListAsync();
if (tiers.Any())
{
_context.PricingTiers.RemoveRange(tiers);
totalRemoved += tiers.Count;
details.Add($"✓ Removed {tiers.Count} pricing tier(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded pricing tiers found");
}
}
// ── Operating Costs ───────────────────────────────────────────────────
if (options.OperatingCosts)
{
var costs = await _context.CompanyOperatingCosts
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId)
.ToListAsync();
if (costs.Any())
{
_context.CompanyOperatingCosts.RemoveRange(costs);
totalRemoved += costs.Count;
details.Add("✓ Removed operating costs record");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No operating costs record found");
}
}
// ── Purchase Orders ───────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Bills && !options.ForceRemoveAll)
{
var poIds = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId)
.Select(p => p.Id)
.ToListAsync();
if (poIds.Any())
{
var poItems = await _context.Set<PurchaseOrderItem>()
.IgnoreQueryFilters()
.Where(i => poIds.Contains(i.PurchaseOrderId))
.ToListAsync();
if (poItems.Any()) _context.Set<PurchaseOrderItem>().RemoveRange(poItems);
var pos = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(p => poIds.Contains(p.Id))
.ToListAsync();
_context.Set<PurchaseOrder>().RemoveRange(pos);
totalRemoved += pos.Count;
details.Add($"✓ Removed {pos.Count} purchase order(s)");
await _context.SaveChangesAsync();
}
}
// ── Vendor Bills ──────────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Bills && !options.ForceRemoveAll)
{
var billIds = await _context.Set<Bill>()
.IgnoreQueryFilters()
.Where(b => b.CompanyId == companyId)
.Select(b => b.Id)
.ToListAsync();
if (billIds.Any())
{
var payments = await _context.Set<BillPayment>()
.IgnoreQueryFilters()
.Where(p => billIds.Contains(p.BillId))
.ToListAsync();
if (payments.Any()) _context.Set<BillPayment>().RemoveRange(payments);
var lineItems = await _context.Set<BillLineItem>()
.IgnoreQueryFilters()
.Where(li => billIds.Contains(li.BillId))
.ToListAsync();
if (lineItems.Any()) _context.Set<BillLineItem>().RemoveRange(lineItems);
var bills = await _context.Set<Bill>()
.IgnoreQueryFilters()
.Where(b => billIds.Contains(b.Id))
.ToListAsync();
_context.Set<Bill>().RemoveRange(bills);
totalRemoved += bills.Count;
details.Add($"✓ Removed {bills.Count} vendor bill(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No vendor bills found");
}
}
// ── Expenses ──────────────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Expenses && !options.ForceRemoveAll)
{
var expenses = await _context.Set<Expense>()
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId)
.ToListAsync();
if (expenses.Any())
{
_context.Set<Expense>().RemoveRange(expenses);
totalRemoved += expenses.Count;
details.Add($"✓ Removed {expenses.Count} expense(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No expenses found");
}
}
// ── Vendors ───────────────────────────────────────────────────────────
if (options.Vendors || options.ForceRemoveAll)
{
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == companyId)
.ToListAsync();
if (vendors.Any())
{
_context.Set<Vendor>().RemoveRange(vendors);
totalRemoved += vendors.Count;
details.Add($"✓ Removed {vendors.Count} vendor(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No vendors found");
}
}
// ── Named Ovens (OvenCost) ────────────────────────────────────────────
if (options.NamedOvens || options.ForceRemoveAll)
{
var ovens = await _context.Set<OvenCost>()
.IgnoreQueryFilters()
.Where(o => o.CompanyId == companyId)
.ToListAsync();
if (ovens.Any())
{
_context.Set<OvenCost>().RemoveRange(ovens);
totalRemoved += ovens.Count;
details.Add($"✓ Removed {ovens.Count} named oven(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No named ovens found");
}
}
// ── Shop Workers ──────────────────────────────────────────────────────
if (options.Workers)
{
var workerUsers = options.ForceRemoveAll
? await _userManager.Users
.Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker")
.ToListAsync()
: await _userManager.Users
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == companyId)
.ToListAsync();
if (workerUsers.Any())
{
foreach (var wu in workerUsers)
await _userManager.DeleteAsync(wu);
totalRemoved += workerUsers.Count;
details.Add($"✓ Removed {workerUsers.Count} demo shop worker(s)");
}
else
{
details.Add("• No demo shop workers found");
}
}
result.ItemsSeeded = totalRemoved;
result.Details = details;
result.Message = totalRemoved > 0
? $"Removed {totalRemoved} seeded record(s) from {company.CompanyName}"
: $"No matching seeded records found for {company.CompanyName}";
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Error removing seed data: {ex.Message}";
}
return result;
}
}