using Microsoft.AspNetCore.Identity; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Infrastructure.Services; public partial class SeedDataService : ISeedDataService { private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly IAccountBalanceService _accountBalanceService; public SeedDataService( ApplicationDbContext context, UserManager userManager, RoleManager roleManager, IAccountBalanceService accountBalanceService) { _context = context; _userManager = userManager; _roleManager = roleManager; _accountBalanceService = accountBalanceService; } /// /// Extracts a human-readable error message from a , /// decoding SQL Server error codes into plain language. /// /// /// SQL Server error 2601/2627 = unique-constraint violation; 547 = foreign-key violation. /// The method tries to pull the constraint name and duplicate key value out of the raw /// SQL message so the Platform Management UI can surface a meaningful alert instead of a /// stack-trace dump. Falls back to a generic message for any other exception type. /// /// The exception thrown by SaveChangesAsync. /// Friendly name of the entity being saved, used in fallback messages. /// A single-sentence, user-readable error description. private string GetFriendlyErrorMessage(Exception ex, string entityName = "record") { if (ex is DbUpdateException dbEx && dbEx.InnerException is SqlException sqlEx) { // Extract the specific constraint or column information var message = sqlEx.Message; // Handle unique constraint violations (Error 2601 or 2627) if (sqlEx.Number == 2601 || sqlEx.Number == 2627) { // Try to extract the duplicate key value var keyStart = message.IndexOf("The duplicate key value is ("); if (keyStart > 0) { var keyEnd = message.IndexOf(").", keyStart); if (keyEnd > keyStart) { var duplicateValue = message.Substring(keyStart + 28, keyEnd - keyStart - 28); // Try to extract the constraint name var constraintStart = message.IndexOf("'IX_"); if (constraintStart > 0) { var constraintEnd = message.IndexOf("'", constraintStart + 1); if (constraintEnd > constraintStart) { var constraintName = message.Substring(constraintStart + 1, constraintEnd - constraintStart - 1); // Extract field name from constraint (e.g., IX_Customers_Email -> Email) var parts = constraintName.Split('_'); if (parts.Length >= 3) { var fieldName = parts[2]; return $"Duplicate {fieldName}: {duplicateValue} already exists"; } } } return $"Duplicate value {duplicateValue} already exists"; } } return $"A {entityName} with this information already exists in the database"; } // Handle foreign key constraint violations (Error 547) if (sqlEx.Number == 547) { if (message.Contains("FOREIGN KEY")) { return $"Cannot add {entityName}: Referenced data does not exist"; } } // Handle other SQL errors return $"Database error: {message.Split('\n')[0]}"; } // Generic exception return $"Error adding {entityName}: {ex.Message}"; } /// /// Returns all non-deleted companies in the platform, ordered by name. /// /// /// Uses IgnoreQueryFilters so that the global multi-tenancy filter (which would /// otherwise restrict results to the current tenant) is bypassed — this method is called /// from the SuperAdmin Platform Management UI and must see every tenant. /// Soft-deleted companies are excluded explicitly via !c.IsDeleted. /// public async Task> GetCompaniesAsync() { return await _context.Companies .IgnoreQueryFilters() .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) .ToListAsync(); } /// /// Seeds platform-wide, one-time system data: the default DEMO company, ASP.NET Identity /// roles, the break-glass SuperAdmin account, manufacturer lookup patterns, and release notes. /// /// /// This method is called manually via Platform Management → Seed Data and is safe to call /// repeatedly — each sub-seeder checks for existing records before inserting. /// It is intentionally NOT called automatically on startup so that platform operators /// control when the database is initialized. /// Individual sub-seeders are called sequentially; a failure in one does not abort the rest /// because each is try/caught internally. /// /// /// A summarising what was created, with /// = false only if an unrecoverable error occurs. /// public async Task SeedSystemDataAsync() { var result = new SeedDataResult { Success = true }; var details = new List(); try { // Seed default company var defaultCompany = await SeedDefaultCompanyAsync(); if (defaultCompany != null) { details.Add("✓ Default company created/verified"); result.ItemsSeeded++; } // Seed roles var rolesCreated = await SeedRolesAsync(); if (rolesCreated > 0) { details.Add($"✓ {rolesCreated} role(s) created"); result.ItemsSeeded += rolesCreated; } // Seed SuperAdmin users var adminsCreated = await SeedSuperAdminUsersAsync(defaultCompany!); if (adminsCreated > 0) { details.Add($"✓ {adminsCreated} SuperAdmin user(s) created"); result.ItemsSeeded += adminsCreated; } // Seed global manufacturer lookup patterns var patternsSeeded = await SeedManufacturerPatternsAsync(); if (patternsSeeded > 0) { details.Add($"✓ {patternsSeeded} manufacturer lookup pattern(s) created"); result.ItemsSeeded += patternsSeeded; } // Seed release notes var notesSeeded = await SeedReleaseNotesAsync(); if (notesSeeded > 0) { details.Add($"✓ {notesSeeded} release note(s) created"); result.ItemsSeeded += notesSeeded; } result.Details = details; result.Message = "System data seeded successfully"; } catch (Exception ex) { result.Success = false; result.Message = $"Error seeding system data: {ex.Message}"; } return result; } /// /// Seeds the minimum required lookup tables for a newly created company: job statuses, /// job priorities, quote statuses, appointment statuses/types, inventory categories, /// prep services, and the default chart of accounts. /// /// /// This is called automatically by the company-creation workflow (not manually) to ensure /// every new tenant has valid lookup data before any jobs or quotes are created. /// It intentionally seeds only lookups — not demo data — so the company starts clean. /// Each sub-seeder is idempotent: it checks for existing rows before inserting. /// /// The primary key of the company to initialise. /// /// A listing which tables were populated. /// Returns a failure result if the company is not found. /// public async Task SeedCompanyLookupsAsync(int companyId) { var result = new SeedDataResult { Success = true }; var details = new List(); 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; } // Seed lookup tables var jobStatusesSeeded = await SeedJobStatusLookupsAsync(company); if (jobStatusesSeeded > 0) { details.Add($"✓ {jobStatusesSeeded} job status(es) created"); result.ItemsSeeded += jobStatusesSeeded; } var jobPrioritiesSeeded = await SeedJobPriorityLookupsAsync(company); if (jobPrioritiesSeeded > 0) { details.Add($"✓ {jobPrioritiesSeeded} job priority/priorities created"); result.ItemsSeeded += jobPrioritiesSeeded; } var quoteStatusesSeeded = await SeedQuoteStatusLookupsAsync(company); if (quoteStatusesSeeded > 0) { details.Add($"✓ {quoteStatusesSeeded} quote status(es) created"); result.ItemsSeeded += quoteStatusesSeeded; } var appointmentStatusesSeeded = await SeedAppointmentStatusLookupsAsync(company); if (appointmentStatusesSeeded > 0) { details.Add($"✓ {appointmentStatusesSeeded} appointment status(es) created"); result.ItemsSeeded += appointmentStatusesSeeded; } var appointmentTypesSeeded = await SeedAppointmentTypeLookupsAsync(company); if (appointmentTypesSeeded > 0) { details.Add($"✓ {appointmentTypesSeeded} appointment type(s) created"); result.ItemsSeeded += appointmentTypesSeeded; } var inventoryCategoriesSeeded = await SeedInventoryCategoryLookupsAsync(company); if (inventoryCategoriesSeeded > 0) { details.Add($"✓ {inventoryCategoriesSeeded} inventory category/categories created"); result.ItemsSeeded += inventoryCategoriesSeeded; } var prepServicesSeeded = await SeedPrepServicesAsync(company); if (prepServicesSeeded > 0) { details.Add($"✓ {prepServicesSeeded} prep service(s) created"); result.ItemsSeeded += prepServicesSeeded; } var accountsSeeded = await SeedDefaultChartOfAccountsAsync(company); if (accountsSeeded > 0) { details.Add($"✓ {accountsSeeded} chart of account(s) created"); result.ItemsSeeded += accountsSeeded; } // Backfill any system accounts added after the initial seed (idempotent). var systemAccountsAdded = await EnsureSystemAccountsAsync(company); if (systemAccountsAdded > 0) { details.Add($"✓ {systemAccountsAdded} missing system account(s) added"); result.ItemsSeeded += systemAccountsAdded; } result.Message = $"Lookup tables initialized for {company.CompanyName}"; result.Details = details; } catch (Exception ex) { result.Success = false; result.Message = $"Error seeding lookup tables: {ex.Message}"; } return result; } /// /// Seeds a full set of demo data for the specified company: lookup tables, inventory, /// operating costs, pricing tiers, customers, equipment, vendors, named ovens, catalog /// items, quotes, jobs, job history, inventory transactions, invoices, vendor bills, /// expenses, and appointments. Also seeds demo user accounts when the company code is "DEMO". /// /// /// Triggered manually via Platform Management → Seed Data; never called on startup. /// Each sub-seeder is wrapped in its own try/catch so that one failure (e.g., a duplicate /// SKU) does not prevent the remaining seeders from running — errors are collected into /// and reported at the end. /// The EF ChangeTracker is cleared after each failure so bad entity state does not bleed /// into subsequent seeders. /// Inventory items and customers use a separate tuple-returning path because they can /// produce per-item warnings (e.g., duplicate SKU) rather than an all-or-nothing result. /// /// The primary key of the company to seed. /// /// A summarising counts, warnings, and any errors. /// is always true; errors appear in Warnings. /// public async Task SeedCompanyDataAsync(int companyId) { var result = new SeedDataResult { Success = true }; var details = new List(); var errors = new List(); 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; } // Each seeder is wrapped in its own try/catch so one failure doesn't // abort the rest. Re-running is safe — each seeder checks for existing // data before inserting. await RunSeeder("Job statuses", details, errors, result, () => SeedJobStatusLookupsAsync(company)); await RunSeeder("Job priorities", details, errors, result, () => SeedJobPriorityLookupsAsync(company)); await RunSeeder("Quote statuses", details, errors, result, () => SeedQuoteStatusLookupsAsync(company)); await RunSeeder("Inventory categories", details, errors, result, () => SeedInventoryCategoryLookupsAsync(company)); await RunSeeder("Appointment statuses", details, errors, result, () => SeedAppointmentStatusLookupsAsync(company)); await RunSeeder("Appointment types", details, errors, result, () => SeedAppointmentTypeLookupsAsync(company)); // Inventory items (returns tuple — handle separately) try { var (inventorySeeded, inventoryWarnings) = await SeedInventoryItemsAsync(company); if (inventorySeeded > 0) { details.Add($"✓ {inventorySeeded} inventory item(s) created"); result.ItemsSeeded += inventorySeeded; } else if (inventoryWarnings.Count == 0) { details.Add("• Inventory items already exist (skipped)"); } if (inventoryWarnings.Any()) { result.Warnings.AddRange(inventoryWarnings); result.ItemsSkipped += inventoryWarnings.Count; } } catch (Exception ex) { errors.Add($"✗ Inventory items: {ex.Message}"); _context.ChangeTracker.Clear(); } // Operating costs (returns bool) try { var costsSeeded = await SeedOperatingCostsAsync(company); details.Add(costsSeeded ? "✓ Operating costs created" : "• Operating costs already exist (skipped)"); if (costsSeeded) result.ItemsSeeded++; } catch (Exception ex) { errors.Add($"✗ Operating costs: {ex.Message}"); _context.ChangeTracker.Clear(); } await RunSeeder("Pricing tiers", details, errors, result, () => SeedPricingTiersAsync(company)); // Customers (returns tuple) try { var (customersSeeded, customerWarnings) = await SeedCustomersAsync(company); if (customersSeeded > 0) { details.Add($"✓ {customersSeeded} customer(s) created"); result.ItemsSeeded += customersSeeded; } else if (customerWarnings.Count == 0) { details.Add("• Customers already exist (skipped)"); } if (customerWarnings.Any()) { result.Warnings.AddRange(customerWarnings); result.ItemsSkipped += customerWarnings.Count; } } catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); } // Workers must be seeded before jobs so AssignedUserId FK resolves await RunSeeder("Shop workers", details, errors, result, () => SeedShopWorkersAsync(company)); await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company)); await RunSeeder("Maintenance", details, errors, result, () => SeedMaintenanceRecordsAsync(company)); await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company)); await RunSeeder("Purchase orders", details, errors, result, () => SeedPurchaseOrdersAsync(company)); await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company)); await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company)); await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company)); await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company)); await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company)); await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company)); await RunSeeder("Clock entries", details, errors, result, () => SeedEmployeeClockEntriesAsync(company)); await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company)); // Ensure chart of accounts exists before bills/expenses — both seeders silently return 0 // if the AP or checking account is missing. SeedDefaultChartOfAccountsAsync is idempotent. try { var accountsAdded = await SeedDefaultChartOfAccountsAsync(company); var systemAccountsAdded = await EnsureSystemAccountsAsync(company); if (accountsAdded > 0) details.Add($"✓ {accountsAdded} chart of account(s) created"); if (systemAccountsAdded > 0) details.Add($"✓ {systemAccountsAdded} missing system account(s) added"); } catch (Exception ex) { errors.Add($"✗ Chart of accounts: {ex.Message}"); _context.ChangeTracker.Clear(); } await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company)); await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company)); // Accounts survive resets (no removal sweep), so the chart-of-accounts seeder skips them // on every reset after the first. But 12 months of seeded expenses outpace ~3 months of // seeded revenue, and without a prior-period cash balance the checking account shows a // large negative. Patch the opening balances unconditionally so every reset is realistic. try { var checkingAcct = await _context.Set().IgnoreQueryFilters() .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.Checking); if (checkingAcct != null && checkingAcct.OpeningBalance == 0) { checkingAcct.OpeningBalance = 75_000m; checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1); checkingAcct.CurrentBalance = 75_000m; await _context.SaveChangesAsync(); details.Add("✓ Checking account opening balance set to $75,000"); } var savingsAcct = await _context.Set().IgnoreQueryFilters() .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.Savings); if (savingsAcct != null && savingsAcct.OpeningBalance == 0) { savingsAcct.OpeningBalance = 14_500m; savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1); savingsAcct.CurrentBalance = 14_500m; await _context.SaveChangesAsync(); details.Add("✓ Savings account opening balance set to $14,500"); } } catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); } await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company)); await RunSeeder("Job notes", details, errors, result, () => SeedJobNotesAsync(company)); await RunSeeder("Customer notes", details, errors, result, () => SeedCustomerNotesAsync(company)); await RunSeeder("Rework records", details, errors, result, () => SeedReworkRecordsAsync(company)); await RunSeeder("Deposits", details, errors, result, () => SeedDepositsAsync(company)); await RunSeeder("Bank recon", details, errors, result, () => SeedBankReconciliationsAsync(company)); if (company.CompanyCode == "DEMO") { try { var demoUsersCreated = await SeedDemoCompanyUsersAsync(company); details.Add(demoUsersCreated > 0 ? $"✓ {demoUsersCreated} demo user(s) created" : "• Demo users already exist (skipped)"); result.ItemsSeeded += demoUsersCreated; } catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); } } // Replay all GL transactions so CurrentBalance reflects the full seeded history, // including the opening balances patched above. try { await _accountBalanceService.RecalculateAllAsync(company.Id); details.Add("✓ Account balances recalculated"); } catch (Exception ex) { errors.Add($"✗ Account balance recalculation: {ex.Message}"); _context.ChangeTracker.Clear(); } if (errors.Any()) { details.AddRange(errors); result.Warnings.AddRange(errors); result.Message = $"Seeding completed with {errors.Count} error(s) for {company.CompanyName}. Re-run to retry failed steps."; } else { result.Message = $"Company data seeded successfully for {company.CompanyName}"; } result.Details = details; return result; } /// /// Executes a single seeder delegate that returns an integer count of rows created, /// appending a success or skip message to and accumulating /// any exception into . /// /// /// This helper exists so that can call every /// count-returning seeder with uniform error isolation in a single line of code. /// When the seeder throws, the EF ChangeTracker is cleared immediately to prevent /// partially-tracked entities from corrupting the context state for subsequent seeders. /// Seeders that return a tuple (e.g., inventory items, customers) are not routed through /// here because they need to surface per-item warnings rather than a single count. /// /// Short display name used in status messages (e.g., "Job statuses"). /// Running list of success/skip lines shown in the UI result. /// Running list of error lines appended on exception. /// The overall result object whose ItemsSeeded is incremented on success. /// The seeder delegate to invoke. private async Task RunSeeder( string label, List details, List errors, SeedDataResult result, Func> seeder) { try { var count = await seeder(); details.Add(count > 0 ? $"✓ {count} {label.ToLower()} created" : $"• {label} already exist (skipped)"); result.ItemsSeeded += count; } catch (Exception ex) { errors.Add($"✗ {label}: {ex.Message}"); // Clear the change tracker so EF doesn't carry over bad state to the next seeder _context.ChangeTracker.Clear(); } } /// /// Ensures the platform's default "DEMO" company exists, creating it if not found. /// /// /// The DEMO company is the anchor tenant used by seeded SuperAdmin accounts and demo data. /// After inserting, CompanyId is set equal to Id — this is intentional: /// the Company entity doubles as a BaseEntity tenant and must reference itself so that /// global query filters (which filter on CompanyId) do not accidentally exclude the record. /// Returns null (not a new object) when the company already exists, which lets /// distinguish "created" from "already present". /// /// The newly created , or null if it already existed. private async Task SeedDefaultCompanyAsync() { var defaultCompany = await _context.Companies .IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.CompanyCode == "DEMO"); if (defaultCompany == null) { defaultCompany = new Company { CompanyName = "Demo Company", CompanyCode = "DEMO", PrimaryContactName = "Admin User", PrimaryContactEmail = "admin@demo.com", Phone = "(555) 123-4567", Address = "123 Demo Street", City = "Demo City", State = "CA", ZipCode = "90210", IsActive = true, SubscriptionStartDate = DateTime.UtcNow, SubscriptionPlan = 2, // Enterprise TimeZone = "America/New_York", CreatedAt = DateTime.UtcNow, CompanyId = 0 }; await _context.Companies.AddAsync(defaultCompany); await _context.SaveChangesAsync(); defaultCompany.CompanyId = defaultCompany.Id; await _context.SaveChangesAsync(); return defaultCompany; } return null; } /// /// Ensures all six ASP.NET Identity application roles exist: /// SuperAdmin, Administrator, Manager, Employee, ShopFloor, and ReadOnly. /// /// /// Roles are checked individually via /// rather than in bulk so that this method can be re-run safely without duplicates. /// Role names are sourced from to keep them in sync /// with authorization policies defined throughout the application. /// /// The number of new roles created (0 if all already existed). private async Task SeedRolesAsync() { string[] roles = new[] { AppConstants.Roles.SuperAdmin, AppConstants.Roles.Administrator, AppConstants.Roles.Manager, AppConstants.Roles.Employee, AppConstants.Roles.ShopFloor, AppConstants.Roles.ReadOnly }; int created = 0; foreach (var role in roles) { if (!await _roleManager.RoleExistsAsync(role)) { await _roleManager.CreateAsync(new IdentityRole(role)); created++; } } return created; } /// /// Ensures the primary break-glass SuperAdmin account exists /// (artemis@powdercoatinglogix.com). /// /// /// This account is the "break glass" owner account — intentional guards in /// PlatformUsersController prevent it from being deleted or demoted. /// It is assigned to the default DEMO company but has CompanyRole = null, /// which is the signal used by the multi-tenancy middleware to grant cross-company access. /// All granular permission flags are set to true because SuperAdmin must be able /// to perform any operation on behalf of any tenant for support purposes. /// Additional SuperAdmin accounts can be created via Platform Management → Platform Users. /// /// The DEMO company the account is anchored to. /// The number of new SuperAdmin users created (0 or 1). private async Task SeedSuperAdminUsersAsync(Company defaultCompany) { int created = 0; // SuperAdmin 1 const string superAdminEmail = "artemis@powdercoatinglogix.com"; const string superAdminPassword = "SuperAdmin123!"; var superAdmin = await _userManager.FindByEmailAsync(superAdminEmail); if (superAdmin == null) { superAdmin = new ApplicationUser { UserName = superAdminEmail, Email = superAdminEmail, FirstName = "Artemis", LastName = "PCL", EmployeeNumber = "SA-001", EmailConfirmed = true, HireDate = DateTime.UtcNow, IsActive = true, Department = "Platform", Position = "Super Administrator", CompanyId = defaultCompany.Id, CompanyRole = null, CanManageJobs = true, CanManageInventory = true, CanManageCustomers = true, CanCreateQuotes = true, CanApproveQuotes = true, CanManageCalendar = true, CanManageProducts = true, CanManageEquipment = true, CanManageVendors = true, CanManageMaintenance = true }; var result = await _userManager.CreateAsync(superAdmin, superAdminPassword); if (result.Succeeded) { await _userManager.AddToRoleAsync(superAdmin, AppConstants.Roles.SuperAdmin); created++; } } return created; } /// /// Seeds demo login accounts for the DEMO company: a Company Administrator /// (demo@powdercoatinglogix.com) and a Manager (manager@demo.com). /// /// /// Only called when company.CompanyCode == "DEMO" — other tenants do not get /// pre-seeded user accounts because their operators create real users themselves. /// The Company Administrator mirrors real-world usage: all permissions enabled, /// CompanyRole = CompanyAdmin, system role = Administrator. /// The Manager account intentionally has CanApproveQuotes = false to demonstrate /// the role-based permission model in the demo environment. /// /// The DEMO company record. /// The number of new user accounts created (0–2). private async Task SeedDemoCompanyUsersAsync(Company company) { int created = 0; // Company Admin const string companyAdminEmail = "demo@powdercoatinglogix.com"; const string companyAdminPassword = "CompanyAdmin123!"; var companyAdmin = await _userManager.FindByEmailAsync(companyAdminEmail); if (companyAdmin == null) { companyAdmin = new ApplicationUser { UserName = companyAdminEmail, Email = companyAdminEmail, FirstName = "Demo", LastName = "User", EmployeeNumber = "CA-001", EmailConfirmed = true, HireDate = DateTime.UtcNow, IsActive = true, Department = "Management", Position = "Company Administrator", CompanyId = company.Id, CompanyRole = AppConstants.CompanyRoles.CompanyAdmin, CanManageJobs = true, CanManageInventory = true, CanManageCustomers = true, CanCreateQuotes = true, CanApproveQuotes = true, CanManageCalendar = true, CanManageProducts = true, CanManageEquipment = true, CanManageVendors = true, CanManageMaintenance = true }; var result = await _userManager.CreateAsync(companyAdmin, companyAdminPassword); if (result.Succeeded) { await _userManager.AddToRoleAsync(companyAdmin, AppConstants.Roles.Administrator); created++; } } // Manager const string managerEmail = "manager@demo.com"; const string managerPassword = "Manager123!"; var manager = await _userManager.FindByEmailAsync(managerEmail); if (manager == null) { manager = new ApplicationUser { UserName = managerEmail, Email = managerEmail, FirstName = "Demo", LastName = "Manager", EmployeeNumber = "MGR-001", EmailConfirmed = true, HireDate = DateTime.UtcNow, IsActive = true, Department = "Operations", Position = "Operations Manager", CompanyId = company.Id, CompanyRole = AppConstants.CompanyRoles.Manager, CanManageJobs = true, CanManageInventory = true, CanManageCustomers = true, CanCreateQuotes = true, CanApproveQuotes = false }; var result = await _userManager.CreateAsync(manager, managerPassword); if (result.Succeeded) { await _userManager.AddToRoleAsync(manager, AppConstants.Roles.Manager); created++; } } return created; } /// /// Seeds 11 inventory items (6 powder colours + 5 consumables) for the company. /// Two powders are intentionally below reorder point (low-stock alert) and one /// consumable is at zero (out-of-stock), matching the demo company spec. /// /// /// Powders are the six colours featured in the demo company's jobs and quotes: /// Gloss Black, Matte Black, Super Chrome (low), Candy Red (low), Signal White, /// Illusion Purple. Consumables are the five shop supplies shown in tutorials: /// Masking Tape, Silicone Plugs (out-of-stock), Hanging Hooks, Acetone, Blast Media. /// /// SKUs are prefixed with to guarantee uniqueness /// across tenants in a shared database (e.g., DEMO-PWD-GBK-001). /// private async Task<(int seededCount, List warnings)> SeedInventoryItemsAsync(Company company) { var warnings = new List(); int seededCount = 0; if (string.IsNullOrWhiteSpace(company.CompanyCode)) throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode."); var skuPrefix = company.CompanyCode; var categories = await _context.InventoryCategoryLookups .IgnoreQueryFilters() .Where(c => c.CompanyId == company.Id && !c.IsDeleted) .ToListAsync(); var powderCat = categories.FirstOrDefault(c => c.CategoryCode == "POWDER"); var cleanerCat = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER"); var maskingCat = categories.FirstOrDefault(c => c.CategoryCode == "MASKING"); var abrasiveCat = categories.FirstOrDefault(c => c.CategoryCode == "ABRASIVE"); var consumeCat = categories.FirstOrDefault(c => c.CategoryCode == "CONSUMABLE"); // ── Helper: powder item ─────────────────────────────────────────────── InventoryItem Pwd(string sku, string name, string color, string ral, string finish, string mfr, string mfrPn, int qty, int reorder, int reorderQty, decimal cost) => new InventoryItem { SKU = $"{skuPrefix}-{sku}", Name = name, Description = $"{finish} {color} powder coating", Category = "Powder", InventoryCategoryId = powderCat?.Id, ColorName = color, ColorCode = ral, Finish = finish, Manufacturer = mfr, ManufacturerPartNumber = mfrPn, QuantityOnHand = qty, UnitOfMeasure = "lbs", ReorderPoint = reorder, ReorderQuantity = reorderQty, MinimumStock = reorder / 2, MaximumStock = reorderQty * 4, UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost, LastPurchaseDate = DateTime.UtcNow.AddDays(-30), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }; // ── Helper: supply/consumable item ──────────────────────────────────── InventoryItem Supply(string sku, string name, string desc, string cat, int? catId, string uom, int qty, int reorder, int reorderQty, decimal cost) => new InventoryItem { SKU = $"{skuPrefix}-{sku}", Name = name, Description = desc, Category = cat, InventoryCategoryId = catId, QuantityOnHand = qty, UnitOfMeasure = uom, ReorderPoint = reorder, ReorderQuantity = reorderQty, MinimumStock = reorder / 2, MaximumStock = reorderQty * 4, UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost, LastPurchaseDate = DateTime.UtcNow.AddDays(-20), IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }; // ── 6 Powders (2 low-stock, 0 out-of-stock) ────────────────────────── // Super Chrome (40 lbs) and Candy Red (25 lbs) are below reorder point // so the dashboard low-stock alert card is populated on first load. var inventoryItems = new List { Pwd("PWD-GBK-001", "Gloss Black", "Gloss Black", "RAL 9005", "Gloss", "Prismatic Powders", "PP-GBK-001", 300, 80, 200, 4.50m), Pwd("PWD-MBK-001", "Matte Black", "Matte Black", "RAL 9005", "Matte", "Prismatic Powders", "PP-MBK-001", 500, 100, 250, 4.50m), Pwd("PWD-CHR-001", "Super Chrome", "Super Chrome", "RAL 9006", "Chrome", "Columbia Coatings", "CC-CHR-001", 40, 100, 150, 8.75m), // LOW STOCK Pwd("PWD-CRD-001", "Candy Red", "Candy Red", "RAL 3028", "Candy", "Prismatic Powders", "PP-CRD-001", 25, 50, 100, 6.50m), // LOW STOCK Pwd("PWD-SWH-001", "Signal White", "Signal White", "RAL 9003", "Gloss", "Columbia Coatings", "CC-SWH-001", 400, 80, 200, 4.25m), Pwd("PWD-IPU-001", "Illusion Purple","Illusion Purple","RAL 4005", "Metallic", "Prismatic Powders", "PP-IPU-001", 150, 60, 120, 7.25m), // ── 5 Consumables (1 out-of-stock) ─────────────────────────────── // Silicone Plugs at qty=0 so the dashboard shows one out-of-stock item. Supply("MSK-001", "High-Temp Masking Tape", "2-inch heat-resistant masking tape", "Masking Supplies", maskingCat?.Id, "rolls", 80, 30, 100, 8.75m), Supply("PLG-001", "Silicone Plugs Assorted", "Assorted silicone masking plugs (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 0, 50, 100, 14.50m), // OUT OF STOCK Supply("HKS-001", "Powder Coating Hooks", "Steel hanging hooks for racking parts", "Consumables", consumeCat?.Id, "count", 200, 50, 200, 0.35m), Supply("ACT-001", "Acetone Degreaser", "Industrial acetone for pre-coating degreasing", "Cleaner", cleanerCat?.Id, "gallons", 20, 5, 25, 18.00m), Supply("BLM-001", "Aluminum Oxide Blast Media","120-grit aluminum oxide blasting media", "Abrasive Media", abrasiveCat?.Id, "lbs", 250, 100, 250, 1.85m), // ── Additional Prismatic Powders ────────────────────────────────────── Pwd("PWD-AWH-001", "Arctic White", "Arctic White", "RAL 9003", "Gloss", "Prismatic Powders", "PP-AWH-001", 400, 100, 250, 4.25m), Pwd("PWD-PUW-001", "Pure White", "Pure White", "RAL 9010", "Gloss", "Prismatic Powders", "PP-PUW-001", 350, 80, 200, 4.25m), Pwd("PWD-CRM-001", "Cream", "Cream", "RAL 9001", "Gloss", "Prismatic Powders", "PP-CRM-001", 200, 50, 150, 4.50m), Pwd("PWD-LIV-001", "Light Ivory", "Light Ivory", "RAL 1015", "Gloss", "Prismatic Powders", "PP-LIV-001", 150, 40, 100, 4.50m), Pwd("PWD-STK-001", "Satin Black", "Satin Black", "RAL 9005", "Satin", "Prismatic Powders", "PP-STK-001", 250, 80, 200, 4.75m), Pwd("PWD-FLK-001", "Flat Black", "Flat Black", "RAL 9005", "Flat", "Prismatic Powders", "PP-FLK-001", 200, 60, 150, 4.50m), Pwd("PWD-TXK-001", "Texture Black", "Texture Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-TXK-001", 175, 50, 150, 5.25m), Pwd("PWD-WRK-001", "Wrinkle Black", "Wrinkle Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-WRK-001", 180, 50, 150, 5.50m), Pwd("PWD-HMK-001", "Hammertone Black", "Hammertone Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-HMK-001", 120, 40, 120, 5.75m), Pwd("PWD-GLR-001", "Gloss Red", "Gloss Red", "RAL 3000", "Gloss", "Prismatic Powders", "PP-GLR-001", 180, 60, 150, 4.75m), Pwd("PWD-WNR-001", "Wine Red", "Wine Red", "RAL 3005", "Gloss", "Prismatic Powders", "PP-WNR-001", 120, 40, 100, 4.75m), Pwd("PWD-STR-001", "Satin Red", "Satin Red", "RAL 3000", "Satin", "Prismatic Powders", "PP-STR-001", 150, 50, 120, 5.00m), Pwd("PWD-TXR-001", "Wrinkle Red", "Wrinkle Red", "RAL 3000", "Texture", "Prismatic Powders", "PP-TXR-001", 80, 30, 80, 5.50m), Pwd("PWD-GLB-001", "Gloss Blue", "Gloss Blue", "RAL 5005", "Gloss", "Prismatic Powders", "PP-GLB-001", 180, 60, 150, 4.75m), Pwd("PWD-SKB-001", "Sky Blue", "Sky Blue", "RAL 5015", "Gloss", "Prismatic Powders", "PP-SKB-001", 150, 50, 120, 4.75m), Pwd("PWD-MTB-001", "Matte Blue", "Matte Blue", "RAL 5005", "Matte", "Prismatic Powders", "PP-MTB-001", 120, 40, 100, 4.75m), Pwd("PWD-NVB-001", "Navy Blue", "Navy Blue", "RAL 5003", "Gloss", "Prismatic Powders", "PP-NVB-001", 200, 60, 150, 4.75m), Pwd("PWD-CNB-001", "Candy Blue", "Candy Blue", "RAL 5005", "Candy", "Prismatic Powders", "PP-CNB-001", 60, 25, 75, 7.50m), Pwd("PWD-GXB-001", "Galaxy Blue Metallic", "Galaxy Blue", "RAL 5005", "Metallic", "Prismatic Powders", "PP-GXB-001", 75, 25, 75, 8.25m), Pwd("PWD-CBM-001", "Cobalt Blue Metallic", "Cobalt Blue", "RAL 5013", "Metallic", "Prismatic Powders", "PP-CBM-001", 80, 30, 80, 8.00m), Pwd("PWD-GLG-001", "Gloss Green", "Gloss Green", "RAL 6002", "Gloss", "Prismatic Powders", "PP-GLG-001", 150, 50, 120, 4.75m), Pwd("PWD-MTG-001", "Matte Green", "Matte Green", "RAL 6002", "Matte", "Prismatic Powders", "PP-MTG-001", 100, 40, 100, 4.75m), Pwd("PWD-HNG-001", "Hunter Green", "Hunter Green", "RAL 6005", "Matte", "Prismatic Powders", "PP-HNG-001", 120, 40, 100, 4.75m), Pwd("PWD-LMG-001", "Lime Green", "Lime Green", "RAL 6018", "Gloss", "Prismatic Powders", "PP-LMG-001", 80, 30, 80, 5.00m), Pwd("PWD-ODG-001", "OD Green", "OD Green", "RAL 6014", "Flat", "Prismatic Powders", "PP-ODG-001", 100, 35, 90, 4.75m), Pwd("PWD-TLG-001", "Teal", "Teal", "RAL 5018", "Matte", "Prismatic Powders", "PP-TLG-001", 90, 30, 80, 5.00m), Pwd("PWD-SYL-001", "Safety Yellow", "Safety Yellow", "RAL 1023", "Gloss", "Prismatic Powders", "PP-SYL-001", 150, 50, 120, 5.00m), Pwd("PWD-JDY-001", "John Deere Yellow", "John Deere Yellow", "RAL 1021", "Gloss", "Prismatic Powders", "PP-JDY-001", 100, 35, 90, 5.00m), Pwd("PWD-SOR-001", "Safety Orange", "Safety Orange", "RAL 2004", "Gloss", "Prismatic Powders", "PP-SOR-001", 120, 40, 100, 5.00m), Pwd("PWD-CGR-001", "Charcoal", "Charcoal", "RAL 7016", "Matte", "Prismatic Powders", "PP-CGR-001", 300, 80, 200, 4.75m), Pwd("PWD-ALV-001", "Aluminum Silver", "Aluminum Silver", "RAL 9006", "Gloss", "Prismatic Powders", "PP-ALV-001", 200, 60, 150, 4.75m), Pwd("PWD-SLG-001", "Slate Gray", "Slate Gray", "RAL 7035", "Matte", "Prismatic Powders", "PP-SLG-001", 180, 60, 150, 4.75m), Pwd("PWD-GRF-001", "Graphite", "Graphite", "RAL 7024", "Satin", "Prismatic Powders", "PP-GRF-001", 150, 50, 120, 5.00m), Pwd("PWD-TFG-001", "Traffic Gray", "Traffic Gray", "RAL 7042", "Matte", "Prismatic Powders", "PP-TFG-001", 120, 40, 100, 4.75m), Pwd("PWD-GNM-001", "Gunmetal", "Gunmetal", "RAL 7016", "Metallic", "Prismatic Powders", "PP-GNM-001", 100, 35, 90, 7.00m), Pwd("PWD-HMS-001", "Hammertone Silver", "Hammertone Silver", "RAL 9006", "Texture", "Prismatic Powders", "PP-HMS-001", 120, 40, 100, 5.75m), Pwd("PWD-HMB-001", "Hammertone Bronze", "Hammertone Bronze", "RAL 8019", "Texture", "Prismatic Powders", "PP-HMB-001", 95, 35, 90, 5.75m), Pwd("PWD-FLO-001", "Fluorescent Orange", "Fluorescent Orange", "RAL 2009", "Fluorescent", "Prismatic Powders", "PP-FLO-001", 50, 20, 60, 9.50m), Pwd("PWD-FLY-001", "Fluorescent Yellow", "Fluorescent Yellow", "RAL 1026", "Fluorescent", "Prismatic Powders", "PP-FLY-001", 50, 20, 60, 9.50m), Pwd("PWD-GLD-001", "Gold Metallic", "Gold", "RAL 1036", "Metallic", "Prismatic Powders", "PP-GLD-001", 80, 30, 80, 8.75m), Pwd("PWD-CPM-001", "Copper Metallic", "Copper", "RAL 2012", "Metallic", "Prismatic Powders", "PP-CPM-001", 75, 25, 75, 8.25m), Pwd("PWD-RSG-001", "Rose Gold Metallic", "Rose Gold", "RAL 3012", "Metallic", "Prismatic Powders", "PP-RSG-001", 60, 20, 60, 8.75m), Pwd("PWD-PNY-001", "Penny Copper Metallic", "Penny Copper", "RAL 8023", "Metallic", "Prismatic Powders", "PP-PNY-001", 55, 20, 60, 8.50m), Pwd("PWD-BRZ-001", "Bronze Metallic", "Bronze", "RAL 8019", "Metallic", "Prismatic Powders", "PP-BRZ-001", 90, 30, 80, 7.50m), Pwd("PWD-ANB-001", "Anodized Bronze", "Anodized Bronze", "RAL 8019", "Metallic", "Prismatic Powders", "PP-ANB-001", 70, 25, 70, 8.00m), Pwd("PWD-ANK-001", "Anodized Black", "Anodized Black", "RAL 9005", "Metallic", "Prismatic Powders", "PP-ANK-001", 85, 30, 80, 7.75m), Pwd("PWD-CHP-001", "Champagne", "Champagne", "RAL 1019", "Satin", "Prismatic Powders", "PP-CHP-001", 100, 35, 90, 5.25m), Pwd("PWD-DES-001", "Desert Tan", "Desert Tan", "RAL 1001", "Matte", "Prismatic Powders", "PP-DES-001", 120, 40, 100, 4.75m), Pwd("PWD-ESP-001", "Espresso Brown", "Espresso Brown", "RAL 8016", "Matte", "Prismatic Powders", "PP-ESP-001", 80, 30, 80, 5.00m), Pwd("PWD-MOC-001", "Mocha Brown", "Mocha Brown", "RAL 8025", "Satin", "Prismatic Powders", "PP-MOC-001", 70, 25, 70, 5.25m), Pwd("PWD-BGN-001", "Burgundy", "Burgundy", "RAL 3032", "Matte", "Prismatic Powders", "PP-BGN-001", 90, 30, 80, 5.00m), Pwd("PWD-PLM-001", "Plum", "Plum", "RAL 4007", "Matte", "Prismatic Powders", "PP-PLM-001", 65, 25, 70, 5.25m), Pwd("PWD-CNO-001", "Candy Orange", "Candy Orange", "RAL 2004", "Candy", "Prismatic Powders", "PP-CNO-001", 50, 20, 60, 7.50m), Pwd("PWD-CNG-001", "Candy Green", "Candy Green", "RAL 6002", "Candy", "Prismatic Powders", "PP-CNG-001", 55, 20, 60, 7.50m), Pwd("PWD-CNP-001", "Candy Purple", "Candy Purple", "RAL 4005", "Candy", "Prismatic Powders", "PP-CNP-001", 55, 20, 60, 7.50m), // ── Columbia Coatings ───────────────────────────────────────────────── Pwd("CC-LIV-001", "Light Ivory", "Light Ivory", "RAL 1015", "Gloss", "Columbia Coatings", "CC-1015-GLO", 120, 40, 100, 4.50m), Pwd("CC-TYL-001", "Traffic Yellow", "Traffic Yellow", "RAL 1023", "Gloss", "Columbia Coatings", "CC-1023-GLO", 100, 35, 90, 4.75m), Pwd("CC-POR-001", "Pure Orange", "Pure Orange", "RAL 2004", "Gloss", "Columbia Coatings", "CC-2004-GLO", 80, 30, 80, 4.75m), Pwd("CC-RBR-001", "Ruby Red", "Ruby Red", "RAL 3003", "Gloss", "Columbia Coatings", "CC-3003-GLO", 100, 35, 90, 5.00m), Pwd("CC-WNR-001", "Wine Red Matte", "Wine Red", "RAL 3005", "Matte", "Columbia Coatings", "CC-3005-MAT", 80, 30, 80, 5.00m), Pwd("CC-RVL-001", "Red Violet", "Red Violet", "RAL 4002", "Satin", "Columbia Coatings", "CC-4002-SAT", 60, 25, 70, 5.25m), Pwd("CC-SAP-001", "Sapphire Blue", "Sapphire Blue", "RAL 5003", "Gloss", "Columbia Coatings", "CC-5003-GLO", 100, 35, 90, 5.00m), Pwd("CC-SKB-001", "Sky Blue", "Sky Blue", "RAL 5015", "Gloss", "Columbia Coatings", "CC-5015-GLO", 90, 30, 80, 4.75m), Pwd("CC-LFG-001", "Leaf Green", "Leaf Green", "RAL 6002", "Gloss", "Columbia Coatings", "CC-6002-GLO", 100, 35, 90, 4.75m), Pwd("CC-MSG-001", "Moss Green", "Moss Green", "RAL 6005", "Matte", "Columbia Coatings", "CC-6005-MAT", 80, 30, 80, 5.00m), Pwd("CC-SVG-001", "Silver Gray", "Silver Gray", "RAL 7001", "Satin", "Columbia Coatings", "CC-7001-SAT", 120, 40, 100, 4.75m), Pwd("CC-ANT-001", "Anthracite", "Anthracite", "RAL 7016", "Matte", "Columbia Coatings", "CC-7016-MAT", 150, 50, 120, 4.75m), Pwd("CC-LGY-001", "Light Gray", "Light Gray", "RAL 7035", "Gloss", "Columbia Coatings", "CC-7035-GLO", 180, 60, 150, 4.50m), Pwd("CC-TGY-001", "Traffic Gray", "Traffic Gray", "RAL 7042", "Satin", "Columbia Coatings", "CC-7042-SAT", 120, 40, 100, 4.75m), Pwd("CC-OCH-001", "Ochre Brown", "Ochre Brown", "RAL 8001", "Gloss", "Columbia Coatings", "CC-8001-GLO", 80, 30, 80, 4.75m), Pwd("CC-GBR-001", "Grey Brown", "Grey Brown", "RAL 8019", "Matte", "Columbia Coatings", "CC-8019-MAT", 70, 25, 70, 5.00m), Pwd("CC-CRM-001", "Cream", "Cream", "RAL 9001", "Gloss", "Columbia Coatings", "CC-9001-GLO", 150, 50, 120, 4.50m), Pwd("CC-PWH-001", "Pure White", "Pure White", "RAL 9010", "Gloss", "Columbia Coatings", "CC-9010-GLO", 200, 60, 150, 4.50m), Pwd("CC-TWH-001", "Traffic White", "Traffic White", "RAL 9016", "Gloss", "Columbia Coatings", "CC-9016-GLO", 180, 60, 150, 4.50m), Pwd("CC-CLG-001", "Clear Gloss", "Clear", "N/A", "Gloss", "Columbia Coatings", "CC-CLR-GLO", 100, 35, 90, 6.75m), // ── Tiger Drylac ────────────────────────────────────────────────────── Pwd("TD-HTK-001", "High-Temp Black 1000F", "High-Temp Black", "N/A", "High-Temp", "Tiger Drylac", "TD-49-90000", 60, 25, 60, 14.50m), Pwd("TD-HTA-001", "High-Temp Aluminum 1000F", "High-Temp Aluminum", "RAL 9006", "High-Temp", "Tiger Drylac", "TD-49-90001", 50, 20, 50, 14.50m), Pwd("TD-EPG-001", "Epoxy Primer Gray", "Epoxy Primer Gray", "RAL 7001", "Primer", "Tiger Drylac", "TD-68-00000", 100, 35, 90, 8.75m), Pwd("TD-EPR-001", "Epoxy Primer Red Oxide", "Epoxy Primer Red", "RAL 3009", "Primer", "Tiger Drylac", "TD-68-30000", 80, 30, 80, 8.75m), Pwd("TD-CLG-001", "Clear Gloss Topcoat", "Clear Gloss", "N/A", "Gloss", "Tiger Drylac", "TD-00-00000", 75, 25, 70, 9.50m), Pwd("TD-CLM-001", "Clear Matte Topcoat", "Clear Matte", "N/A", "Matte", "Tiger Drylac", "TD-00-00001", 60, 20, 60, 9.50m), Pwd("TD-AFG-001", "Anti-Graffiti Clear", "Anti-Graffiti", "N/A", "Clear", "Tiger Drylac", "TD-AG-00000", 40, 15, 50, 15.75m), Pwd("TD-DKB-001", "Dark Bronze", "Dark Bronze", "RAL 8019", "Satin", "Tiger Drylac", "TD-15-50000", 80, 30, 80, 7.25m), Pwd("TD-NAL-001", "Natural Aluminum", "Natural Aluminum", "RAL 9006", "Satin", "Tiger Drylac", "TD-15-60000", 70, 25, 70, 7.00m), Pwd("TD-MTG-001", "Machine Tool Green", "Machine Tool Green", "RAL 6011", "Gloss", "Tiger Drylac", "TD-57-60000", 50, 20, 60, 5.50m), Pwd("TD-MCG-001", "Machinery Gray", "Machinery Gray", "RAL 7040", "Gloss", "Tiger Drylac", "TD-57-00000", 60, 25, 70, 5.50m), Pwd("TD-UBZ-001", "Urban Bronze", "Urban Bronze", "RAL 8024", "Satin", "Tiger Drylac", "TD-15-80000", 75, 25, 70, 7.50m), Pwd("TD-NYL-001", "Nylon Black Functional", "Nylon Black", "RAL 9005", "Functional", "Tiger Drylac", "TD-NY-00000", 40, 15, 50, 18.50m), // ── Sherwin-Williams Powder Coatings ────────────────────────────────── Pwd("SW-STK-001", "Satin Black", "Satin Black", "RAL 9005", "Satin", "Sherwin-Williams Powders", "SW-STK-001", 100, 35, 90, 5.25m), Pwd("SW-STG-001", "Satin Gray", "Satin Gray", "RAL 7035", "Satin", "Sherwin-Williams Powders", "SW-STG-001", 80, 30, 80, 5.25m), Pwd("SW-STB-001", "Satin Bronze", "Satin Bronze", "RAL 8019", "Satin", "Sherwin-Williams Powders", "SW-STB-001", 70, 25, 70, 5.75m), Pwd("SW-WST-001", "White Satin", "White Satin", "RAL 9003", "Satin", "Sherwin-Williams Powders", "SW-WST-001", 100, 35, 90, 5.00m), Pwd("SW-UBZ-001", "Urban Bronze", "Urban Bronze", "RAL 8024", "Satin", "Sherwin-Williams Powders", "SW-UBZ-001", 65, 25, 70, 6.00m), Pwd("SW-MBR-001", "Mission Brown", "Mission Brown", "RAL 8025", "Matte", "Sherwin-Williams Powders", "SW-MBR-001", 60, 25, 70, 5.50m), Pwd("SW-PGR-001", "Patina Green", "Patina Green", "RAL 6011", "Matte", "Sherwin-Williams Powders", "SW-PGR-001", 50, 20, 60, 5.75m), Pwd("SW-AGC-001", "Aged Copper", "Aged Copper", "RAL 8023", "Metallic", "Sherwin-Williams Powders", "SW-AGC-001", 55, 20, 60, 8.25m), Pwd("SW-DGY-001", "Dark Gray Satin", "Dark Gray", "RAL 7016", "Satin", "Sherwin-Williams Powders", "SW-DGY-001", 80, 30, 80, 5.25m), // ── Masking Supplies ────────────────────────────────────────────────── Supply("MSK-HT12-001", "Masking Tape 1/2 inch", "High-temp masking tape 1/2-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 60, 20, 60, 4.25m), Supply("MSK-HT1-001", "Masking Tape 1 inch", "High-temp masking tape 1-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 80, 25, 75, 6.50m), Supply("MSK-HT3-001", "Masking Tape 3 inch", "High-temp masking tape 3-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 40, 15, 50, 9.50m), Supply("MSK-HT4-001", "Masking Tape 4 inch", "High-temp masking tape 4-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 30, 10, 40, 11.75m), Supply("MSK-PAP12-001", "Masking Paper 12 inch", "High-temp masking paper roll, 12-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 25, 8, 25, 14.50m), Supply("MSK-PAP18-001", "Masking Paper 18 inch", "High-temp masking paper roll, 18-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 20, 8, 25, 19.75m), Supply("MSK-PAP24-001", "Masking Paper 24 inch", "High-temp masking paper roll, 24-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 15, 5, 20, 24.50m), Supply("MSK-PLG14-001", "Silicone Plugs 1/4 in", "High-temp silicone plugs 1/4-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 30, 10, 30, 11.25m), Supply("MSK-PLG38-001", "Silicone Plugs 3/8 in", "High-temp silicone plugs 3/8-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 25, 10, 25, 12.50m), Supply("MSK-PLG12-001", "Silicone Plugs 1/2 in", "High-temp silicone plugs 1/2-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 20, 8, 25, 13.75m), Supply("MSK-PLG58-001", "Silicone Plugs 5/8 in", "High-temp silicone plugs 5/8-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 18, 6, 20, 15.00m), Supply("MSK-PLG34-001", "Silicone Plugs 3/4 in", "High-temp silicone plugs 3/4-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 15, 5, 20, 16.50m), Supply("MSK-PLG1-001", "Silicone Plugs 1 inch", "High-temp silicone plugs 1-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 12, 5, 15, 18.75m), Supply("MSK-CAP1-001", "Masking Caps 1 inch", "High-temp square masking caps 1-inch (bag of 50)", "Masking Supplies", maskingCat?.Id, "bags", 25, 8, 25, 9.50m), Supply("MSK-CAP2-001", "Masking Caps 2 inch", "High-temp square masking caps 2-inch (bag of 50)", "Masking Supplies", maskingCat?.Id, "bags", 20, 6, 20, 12.25m), Supply("MSK-DSC2-001", "Masking Discs 2 inch", "Round masking discs 2-inch (pack of 100)", "Masking Supplies", maskingCat?.Id, "packs", 20, 6, 20, 8.75m), Supply("MSK-DSC3-001", "Masking Discs 3 inch", "Round masking discs 3-inch (pack of 100)", "Masking Supplies", maskingCat?.Id, "packs", 15, 5, 15, 9.25m), Supply("MSK-VNL-001", "High-Temp Vinyl Tape", "High-temp vinyl tape 1-inch, 36-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 40, 12, 40, 7.75m), Supply("MSK-FOM-001", "Foam Plugs Assorted", "High-temp foam masking plugs assorted sizes (bag of 50)","Masking Supplies", maskingCat?.Id, "bags", 20, 6, 20, 10.50m), // ── Chemical Pretreatments & Cleaners ──────────────────────────────── Supply("CHM-IPH-001", "Iron Phosphate Pretreatment", "Iron phosphate pretreatment concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 10, 3, 10, 42.50m), Supply("CHM-ZPH-001", "Zinc Phosphate Pretreatment", "Zinc phosphate pretreatment concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 8, 2, 8, 58.75m), Supply("CHM-CFP-001", "Chrome-Free Pretreatment", "Chrome-free conversion coating pretreatment, 5-gal", "Cleaner", cleanerCat?.Id, "gallons", 5, 2, 6, 67.50m), Supply("CHM-ALD-001", "Alkaline Degreaser", "Heavy-duty alkaline degreaser concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 15, 5, 15, 38.50m), Supply("CHM-CTD-001", "Citrus Degreaser", "Citrus-based degreaser concentrate, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 12, 4, 12, 24.75m), Supply("CHM-RIN-001", "Rust Inhibitor", "Water-based rust inhibitor for pretreated metal, 1-gal","Cleaner",cleanerCat?.Id,"gallons", 8, 3, 8, 31.25m), Supply("CHM-MET-001", "Metal Etch", "Acid etch for aluminum and non-ferrous metals, 1-gal", "Cleaner", cleanerCat?.Id, "gallons", 10, 3, 10, 27.50m), Supply("CHM-CCT-001", "Conversion Coating", "Self-etching zinc conversion coating, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 78.00m), Supply("CHM-OGS-001", "Outgassing Agent", "Prevents outgassing pinholes on porous castings, 1-gal","Cleaner",cleanerCat?.Id,"gallons", 4, 2, 5, 44.50m), Supply("CHM-FRP-001", "Flash Rust Preventer", "Prevents flash rust between pretreat and coat, 1-gal", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 29.75m), Supply("CHM-EQP-001", "Equipment Cleaner", "Gun and equipment cleaner/solvent, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 8, 3, 8, 22.50m), Supply("CHM-PHN-001", "pH Neutralizer", "Neutralizes pretreatment rinse water, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 18.75m), Supply("CHM-ZNP-001", "Zinc Phosphate Powder", "Dry zinc phosphate powder, 10-lb bag", "Cleaner", cleanerCat?.Id, "bags", 5, 2, 5, 35.00m), Supply("CHM-IPC-001", "Iron Phosphate Powder", "Dry iron phosphate powder, 10-lb bag", "Cleaner", cleanerCat?.Id, "bags", 5, 2, 5, 28.00m), Supply("CHM-APR-001", "Adhesion Promoter", "Surface adhesion promoter for difficult substrates, qt","Cleaner",cleanerCat?.Id,"quarts", 10, 3, 10, 19.50m), // ── Abrasive Media ──────────────────────────────────────────────────── Supply("ABR-AO80-001", "Aluminum Oxide 80 Grit", "80-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 12, 4, 12, 28.50m), Supply("ABR-AO150-001", "Aluminum Oxide 150 Grit", "150-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 29.50m), Supply("ABR-AO180-001", "Aluminum Oxide 180 Grit", "180-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 6, 2, 6, 30.50m), Supply("ABR-SS70-001", "Steel Shot S-70", "Steel shot S-70, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 32.75m), Supply("ABR-SS110-001", "Steel Shot S-110", "Steel shot S-110, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 33.50m), Supply("ABR-SS230-001", "Steel Shot S-230", "Steel shot S-230, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 34.75m), Supply("ABR-SG25-001", "Steel Grit G-25", "Steel grit G-25, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 35.50m), Supply("ABR-SG40-001", "Steel Grit G-40", "Steel grit G-40, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 36.25m), Supply("ABR-GB8-001", "Glass Bead No.8", "Glass bead No. 8, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 27.50m), Supply("ABR-GB13-001", "Glass Bead No.13", "Glass bead No. 13, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 28.75m), Supply("ABR-GRN-001", "Garnet 80 Grit", "80-grit garnet blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 38.50m), Supply("ABR-SIC-001", "Silicon Carbide 80 Grit", "80-grit silicon carbide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 6, 2, 6, 62.75m), Supply("ABR-PLM-001", "Plastic Media Type I", "Plastic abrasive media Type I, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 45.00m), Supply("ABR-WLN-001", "Walnut Shell Medium", "Walnut shell medium-grit blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 32.50m), Supply("ABR-CRN-001", "Corn Cob Media", "Corn cob blasting media medium-grit, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 27.00m), // ── Hanging Hardware ────────────────────────────────────────────────── Supply("HDW-JHS-001", "J-Hooks Small", "1/8-inch wire J-hooks for small parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 15, 5, 15, 9.75m), Supply("HDW-JHM-001", "J-Hooks Medium", "3/16-inch wire J-hooks for medium parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 12, 4, 12, 13.50m), Supply("HDW-JHL-001", "J-Hooks Large", "1/4-inch wire J-hooks for heavy parts (box of 50)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 17.25m), Supply("HDW-SHS-001", "S-Hooks Small", "Small S-hooks for racking light parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 12, 4, 12, 8.50m), Supply("HDW-SHL-001", "S-Hooks Large", "Large S-hooks for heavy racking (box of 50)", "Consumables", consumeCat?.Id, "boxes", 8, 3, 8, 14.75m), Supply("HDW-GRW-001", "Ground Wire 10ft Coil", "Grounding wire coil for proper gun grounding (each)", "Consumables", consumeCat?.Id, "each", 20, 5, 15, 4.50m), Supply("HDW-RB24-001", "Racking Bar 24 inch", "24-inch steel racking cross bar (each)", "Consumables", consumeCat?.Id, "each", 15, 5, 10, 12.75m), Supply("HDW-RB36-001", "Racking Bar 36 inch", "36-inch steel racking cross bar (each)", "Consumables", consumeCat?.Id, "each", 10, 3, 10, 17.50m), Supply("HDW-W14-001", "Hang Wire 14-Gauge", "14-gauge steel hang wire, 100-ft roll", "Consumables", consumeCat?.Id, "rolls", 10, 3, 10, 8.25m), Supply("HDW-W12-001", "Hang Wire 12-Gauge", "12-gauge steel hang wire, 100-ft roll", "Consumables", consumeCat?.Id, "rolls", 10, 3, 10, 11.50m), Supply("HDW-QCC-001", "Quick-Clamp Connectors","Powder coating quick-clamp connectors (bag of 25)", "Consumables", consumeCat?.Id, "bags", 15, 5, 15, 14.75m), Supply("HDW-TBR-001", "T-Bar Fixture", "T-bar part fixture for flat panel racking (each)", "Consumables", consumeCat?.Id, "each", 8, 2, 5, 22.50m), Supply("HDW-WCA-001", "Wheel Cone Adapters", "Wheel cone adapters for rim coating (set of 4)", "Consumables", consumeCat?.Id, "sets", 6, 2, 4, 34.75m), Supply("HDW-THR-001", "Threaded Rod Hangers", "6-inch threaded rod hangers for custom racking (box of 10)", "Consumables", consumeCat?.Id, "boxes", 8, 2, 6, 19.25m), // ── PPE & Safety ────────────────────────────────────────────────────── Supply("PPE-GVM-001", "Nitrile Gloves Medium", "Nitrile exam gloves medium, powder-free (box of 100)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 12.75m), Supply("PPE-GVL-001", "Nitrile Gloves Large", "Nitrile exam gloves large, powder-free (box of 100)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 12.75m), Supply("PPE-RES-001", "Half-Face Respirator", "Half-face respirator with P100/OV cartridges (each)", "Consumables", consumeCat?.Id, "each", 6, 2, 4, 38.50m), Supply("PPE-FLT-001", "P100 Filter Cartridges", "P100 organic vapor replacement cartridges (pair)", "Consumables", consumeCat?.Id, "pairs", 12, 4, 12, 17.25m), Supply("PPE-SGL-001", "Safety Glasses", "ANSI Z87.1 safety glasses, clear lens (each)", "Consumables", consumeCat?.Id, "each", 20, 6, 12, 4.50m), Supply("PPE-FSH-001", "Face Shield", "Adjustable face shield, 8-inch (each)", "Consumables", consumeCat?.Id, "each", 6, 2, 4, 18.75m), Supply("PPE-CVR-001", "Tyvek Coverall", "Tyvek disposable coverall, medium (each)", "Consumables", consumeCat?.Id, "each", 15, 5, 15, 11.50m), }; // Add inventory items one at a time to handle duplicates gracefully foreach (var item in inventoryItems) { try { // Check if item already exists by SKU var existingItem = await _context.InventoryItems .IgnoreQueryFilters() .FirstOrDefaultAsync(i => i.SKU == item.SKU && i.CompanyId == company.Id); if (existingItem != null) { warnings.Add($"⊘ Skipped: {item.Name} (SKU: {item.SKU}) - Already exists"); continue; } await _context.InventoryItems.AddAsync(item); await _context.SaveChangesAsync(); seededCount++; } catch (Exception ex) { var friendlyError = GetFriendlyErrorMessage(ex, "inventory item"); warnings.Add($"⊘ Skipped: {item.Name} (SKU: {item.SKU}) - {friendlyError}"); // Detach the failed entity if (_context.Entry(item).State != EntityState.Detached) { _context.Entry(item).State = EntityState.Detached; } } } return (seededCount, warnings); } /// /// Creates a single record for the company with /// industry-typical default rates if none exists yet. /// /// /// There is a 1:1 relationship between a company and its operating costs record, so the /// seeder checks for any existing row (ignoring soft-deletes) and skips creation if found. /// The default values — $25/hr labour, $15/hr oven, 30% markup, 7.5% tax — are reasonable /// starting points that operators are expected to customise through Company Settings. /// The OvenOperatingCostPerHour here may later be overwritten by /// if it finds the value is still 0. /// /// The tenant company to create operating costs for. /// true if a new record was created; false if one already existed. private async Task SeedOperatingCostsAsync(Company company) { var existingCosts = await _context.CompanyOperatingCosts .IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.CompanyId == company.Id && !c.IsDeleted); if (existingCosts != null) { return false; } var operatingCosts = new CompanyOperatingCosts { CompanyId = company.Id, StandardLaborRate = 25.00m, OvenOperatingCostPerHour = 15.00m, SandblasterCostPerHour = 12.00m, CoatingBoothCostPerHour = 10.00m, PowderCoatingCostPerSqFt = 0.85m, GeneralMarkupPercentage = 30.00m, TaxPercent = 7.50m, RushChargeType = "Percentage", RushChargePercentage = 25.00m, RushChargeFixedAmount = 0m, CreatedAt = DateTime.UtcNow }; await _context.CompanyOperatingCosts.AddAsync(operatingCosts); await _context.SaveChangesAsync(); return true; } /// /// Seeds four customer pricing tiers (Standard, Silver, Gold, Platinum) with discount /// percentages of 0%, 5%, 10%, and 15% respectively, if none exist for the company. /// /// /// Pricing tiers drive the automatic discount applied to quotes when a customer belongs /// to a tier — the pricing engine reads PricingTier.DiscountPercent at quote- /// calculation time. The Standard tier at 0% acts as the default "no discount" tier. /// All four tiers are inserted in a single AddRangeAsync / SaveChangesAsync /// call (unlike inventory items) because there is no per-item uniqueness constraint that /// could cause a partial failure — tier names are not globally unique. /// /// The tenant company to seed pricing tiers for. /// The number of tiers created (4), or 0 if tiers already existed. private async Task SeedPricingTiersAsync(Company company) { // Check if pricing tiers already exist var existingTiers = await _context.Set() .IgnoreQueryFilters() .AnyAsync(pt => pt.CompanyId == company.Id && !pt.IsDeleted); if (existingTiers) { return 0; } var pricingTiers = new List { new PricingTier { TierName = "Standard", Description = "Standard pricing for regular customers", DiscountPercent = 0m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new PricingTier { TierName = "Silver", Description = "5% discount for valued customers", DiscountPercent = 5.00m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new PricingTier { TierName = "Gold", Description = "10% discount for high-volume customers", DiscountPercent = 10.00m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new PricingTier { TierName = "Platinum", Description = "15% discount for premium customers", DiscountPercent = 15.00m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow } }; await _context.Set().AddRangeAsync(pricingTiers); await _context.SaveChangesAsync(); return pricingTiers.Count; } /// /// Seeds five real-world powder-coating suppliers as demo vendor records for the company /// (Prismatic Powders, Columbia Coatings, Sherwin-Williams Industrial, Ace Hardware, Fastenal). /// /// /// Uses an all-or-nothing AddRangeAsync approach (vs. per-item saves) because /// vendors have no globally-unique constraint — uniqueness is only enforced per company. /// The seeder bails early if any vendors already exist for the company, so re-running /// will not double-up entries. /// These vendor records are referenced by and /// by name match (e.g., "Prismatic", "Columbia"), so the /// names must not be changed without also updating the bills seeder's lookup logic. /// /// The tenant company to seed vendors for. /// The number of vendors created (5), or 0 if vendors already existed. private async Task SeedVendorsAsync(Company company) { var exists = await _context.Set() .IgnoreQueryFilters() .AnyAsync(v => v.CompanyId == company.Id && !v.IsDeleted); if (exists) return 0; var vendors = new List { // ── Powder Suppliers ───────────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", PaymentTerms = "Net 30", IsActive = true, IsPreferred = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Columbia Coatings", ContactName = "Sales", Email = "info@columbiacoatings.com", Phone = "888-265-8247", Website = "https://www.columbiacoatings.com", PaymentTerms = "Net 30", IsActive = true, IsPreferred = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Tiger Drylac USA", ContactName = "Sales", Email = "sales@tigerdrylac.com", Phone = "888-487-9090", Website = "https://www.tigerdrylac.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Powders", ContactName = "Sales Rep", Email = "powders@sherwin-williams.com", Phone = "800-321-8194", Website = "https://www.sherwin-williams.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Eastwood Company", ContactName = "Customer Svc", Email = "support@eastwood.com", Phone = "800-343-9353", Website = "https://www.eastwood.com", PaymentTerms = "Net 15", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Industrial & Hardware Suppliers ────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Grainger Industrial Supply", ContactName = "Account Rep", Email = "accounts@grainger.com", Phone = "800-472-4643", Website = "https://www.grainger.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "MSC Industrial Supply", ContactName = "Account Rep", Email = "accounts@mscdirect.com", Phone = "800-645-7270", Website = "https://www.mscdirect.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "McMaster-Carr", ContactName = "Customer Svc", Email = "orders@mcmaster.com", Phone = "630-833-0300", Website = "https://www.mcmaster.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Fastenal", ContactName = "Branch Mgr", Email = "nc.raleigh@fastenal.com", Phone = "(919) 833-2120", Website = "https://www.fastenal.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Harbor Freight Tools", ContactName = "Purchasing", Email = "purchasing@harborfreight.com", Phone = "800-444-3353", Website = "https://www.harborfreight.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Uline", ContactName = "Sales", Email = "customer.service@uline.com", Phone = "800-295-5510", Website = "https://www.uline.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Local Industrial Supply", ContactName = "Sales Team", Email = "sales@localindustrialsupply.com", Phone = "(919) 555-0100", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Amazon Business", ContactName = "Account Mgr", Email = "business@amazon.com", Phone = "888-281-3847", Website = "https://business.amazon.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Lowe's Pro Supply", ContactName = "Pro Desk", Email = "pro@lowes.com", Phone = "(919) 555-0920", Website = "https://www.lowes.com/l/pro.html", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "NAPA Auto Parts", ContactName = "Store Mgr", Email = "raleigh.south@napa.com", Phone = "(919) 555-0502", Website = "https://www.napaonline.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Abrasive & Blasting Suppliers ───────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Clemco Industries", ContactName = "Sales", Email = "sales@clemcoindustries.com", Phone = "314-770-0377", Website = "https://www.clemcoindustries.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Triangle Abrasives Co", ContactName = "John Marks", Email = "jmarks@triangleabrasives.com", Phone = "(919) 555-0305", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Industrial Gases ────────────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Airgas USA", ContactName = "Account Mgr", Email = "nc.accounts@airgas.com", Phone = "(919) 555-0210", Website = "https://www.airgas.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Linde Gas & Equipment", ContactName = "Route Mgr", Email = "nc.service@linde.com", Phone = "800-755-9277", Website = "https://www.lindeus.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Utilities & Services ────────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Duke Energy Business", ContactName = "Account Svc", Email = "business@duke-energy.com", Phone = "800-777-9898", Website = "https://www.duke-energy.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "AT&T Business Solutions", ContactName = "Account Rep", Email = "smb@att.com", Phone = "800-321-2000", Website = "https://www.att.com/smallbusiness", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Spectrum Business", ContactName = "Account Mgr", Email = "business@spectrum.com", Phone = "855-707-7328", Website = "https://business.spectrum.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Raleigh Electric Supply", ContactName = "Counter Sales", Email = "sales@raleighelectric.com", Phone = "(919) 555-0815", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Carolina Industrial Water", ContactName = "Service Tech", Email = "service@carolinawater.com", Phone = "(919) 555-1102", Notes = "Water filtration and treatment service for spray booth wash system.", PaymentTerms = "Quarterly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Waste & Environmental ───────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Safety-Kleen Systems", ContactName = "Route Driver", Email = "nc.service@safety-kleen.com", Phone = "800-669-5740", Website = "https://www.safety-kleen.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", Notes = "Solvent recycling and chemical waste disposal.", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Raleigh Waste Solutions", ContactName = "Route Mgr", Email = "service@raleighwaste.com", Phone = "(919) 555-0604", PaymentTerms = "Monthly", IsActive = true, Country = "USA", Notes = "Dumpster and roll-off container service.", CreatedAt = DateTime.UtcNow }, // ── Safety & PPE ────────────────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Work N Gear Safety", ContactName = "Sales", Email = "sales@workngear.com", Phone = "800-967-9327", Website = "https://www.workngear.com", PaymentTerms = "Net 15", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Office & Business Services ──────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "HD Supply", ContactName = "Account Rep", Email = "accounts@hdsupply.com", Phone = "800-431-3000", Website = "https://www.hdsupply.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Carolina Office Products", ContactName = "Sales Rep", Email = "sales@carolinaoffice.com", Phone = "(919) 555-0712", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "First Insurance Solutions", ContactName = "Agent", Email = "agents@firstinsurance.com", Phone = "(919) 555-1001", Notes = "Business liability and equipment insurance policy.", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Facility (Landlord) ─────────────────────────────────────────────── new Vendor { CompanyId = company.Id, CompanyName = "Triangle Commercial Properties LLC", ContactName = "Property Mgr", Email = "rentals@trianglecommercial.com", Phone = "(919) 555-0401", Notes = "Shop lease — 4,800 sq ft at 4712 Industrial Blvd, Raleigh NC 27616. Rent due 1st of each month.", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, }; await _context.Set().AddRangeAsync(vendors); await _context.SaveChangesAsync(); return vendors.Count; } /// /// Seeds two named oven configurations ("Main Oven" and "Small Oven") with hourly cost, /// capacity (sq ft), and default cycle time (minutes) for the Oven Scheduler feature. /// /// /// Named ovens are stored in the OvenCost entity (not Equipment) because /// the Oven Scheduler batches jobs by oven cost record, not by equipment record — this /// allows multiple logical "ovens" to share the same physical equipment with different /// cost/capacity profiles. /// /// After inserting the ovens, the method checks the company's /// field: if it is still 0 /// (meaning did not run or set a non-zero value), /// it is synced to the Main Oven's rate. This prevents the pricing engine from /// calculating $0 oven cost on newly seeded companies. /// /// The MaxLoadSqFt and DefaultCycleMinutes fields are used by the /// Oven Scheduler's capacity-planning and suggested-batch logic. /// /// The tenant company to seed oven configurations for. /// The number of oven records created (2), or 0 if any already existed. private async Task SeedOvenCostsAsync(Company company) { var exists = await _context.Set() .IgnoreQueryFilters() .AnyAsync(o => o.CompanyId == company.Id && !o.IsDeleted); if (exists) return 0; var ovens = new List { new OvenCost { CompanyId = company.Id, Label = "Main Oven", CostPerHour = 15.00m, MaxLoadSqFt = 120m, DefaultCycleMinutes = 50, IsActive = true, DisplayOrder = 1, CreatedAt = DateTime.UtcNow }, new OvenCost { CompanyId = company.Id, Label = "Small Oven", CostPerHour = 8.00m, MaxLoadSqFt = 40m, DefaultCycleMinutes = 45, IsActive = true, DisplayOrder = 2, CreatedAt = DateTime.UtcNow }, }; await _context.Set().AddRangeAsync(ovens); await _context.SaveChangesAsync(); // Set operating cost to first oven's rate if not already set var costs = await _context.Set() .FirstOrDefaultAsync(c => c.CompanyId == company.Id); if (costs != null && costs.OvenOperatingCostPerHour == 0) { costs.OvenOperatingCostPerHour = ovens[0].CostPerHour; await _context.SaveChangesAsync(); } return ovens.Count; } }