7735fe3cce
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>
682 lines
35 KiB
C#
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;
|
|
}
|
|
}
|