using Microsoft.AspNetCore.Identity; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; 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; public SeedDataService( ApplicationDbContext context, UserManager userManager, RoleManager roleManager) { _context = context; _userManager = userManager; _roleManager = roleManager; } /// /// 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(); } await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company)); await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(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("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company)); await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company)); await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(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(); } } 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 ten representative inventory items (eight powder colours, one cleaner, one /// masking tape roll) for the company, linking each to the appropriate category lookup. /// /// /// Returns a tuple rather than a plain int because each item is saved individually /// (one SaveChangesAsync call per item) so that a duplicate-SKU error on one /// item does not roll back the entire batch. Failed items are captured as per-item /// warning strings rather than aborting the seeder. /// /// SKUs are prefixed with the company's to guarantee /// uniqueness across tenants in a shared database — e.g., DEMO-PWD-BLK-001. /// A missing or empty CompanyCode throws because /// SKU collisions would violate the unique index on the InventoryItems table. /// /// Category IDs are resolved by CategoryCode (e.g., "POWDER", "CLEANER") rather /// than hard-coded IDs because lookup IDs differ per company and per environment. /// /// All powder items default to CoverageSqFtPerLb = 30 and /// TransferEfficiency = 65, which are industry-standard starting values used by /// the pricing engine when calculating powder needed per coat. /// /// The tenant company to seed inventory for. /// /// A tuple of (count of items successfully inserted, list of per-item warning messages /// for skipped or failed items). /// private async Task<(int seededCount, List warnings)> SeedInventoryItemsAsync(Company company) { var warnings = new List(); int seededCount = 0; // Validate company code if (string.IsNullOrWhiteSpace(company.CompanyCode)) { throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode. Cannot seed inventory with unique SKUs."); } var skuPrefix = company.CompanyCode; // Get category lookups to link items properly var categories = await _context.InventoryCategoryLookups .IgnoreQueryFilters() .Where(c => c.CompanyId == company.Id && !c.IsDeleted) .ToListAsync(); var powderCategory = categories.FirstOrDefault(c => c.CategoryCode == "POWDER"); var cleanerCategory = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER"); var maskingCategory = categories.FirstOrDefault(c => c.CategoryCode == "MASKING"); // Use company code prefix to ensure unique SKUs across companies var inventoryItems = new List { new InventoryItem { SKU = $"{skuPrefix}-PWD-BLK-001", Name = "Matte Black Powder", Description = "High-quality matte black powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Matte Black", ColorCode = "RAL 9005", Finish = "Matte", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "TG-MB-001", QuantityOnHand = 500, UnitOfMeasure = "lbs", ReorderPoint = 100, ReorderQuantity = 250, MinimumStock = 50, MaximumStock = 1000, UnitCost = 4.50m, AverageCost = 4.50m, LastPurchasePrice = 4.50m, LastPurchaseDate = DateTime.UtcNow.AddDays(-30), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-WHT-001", Name = "Gloss White Powder", Description = "High-gloss white powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Gloss White", ColorCode = "RAL 9010", Finish = "Gloss", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "TG-GW-001", QuantityOnHand = 400, UnitOfMeasure = "lbs", ReorderPoint = 100, ReorderQuantity = 250, MinimumStock = 50, MaximumStock = 1000, UnitCost = 4.25m, AverageCost = 4.25m, LastPurchasePrice = 4.25m, LastPurchaseDate = DateTime.UtcNow.AddDays(-25), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-RED-001", Name = "Gloss Red Powder", Description = "Vibrant gloss red powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Traffic Red", ColorCode = "RAL 3020", Finish = "Gloss", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "TG-GR-001", QuantityOnHand = 150, UnitOfMeasure = "lbs", ReorderPoint = 50, ReorderQuantity = 100, MinimumStock = 25, MaximumStock = 500, UnitCost = 5.75m, AverageCost = 5.75m, LastPurchasePrice = 5.75m, LastPurchaseDate = DateTime.UtcNow.AddDays(-20), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-BLU-001", Name = "Metallic Blue Powder", Description = "Metallic blue powder coating with shimmer", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Metallic Blue", ColorCode = "RAL 5002", Finish = "Metallic", Manufacturer = "Axalta", ManufacturerPartNumber = "AX-MB-001", QuantityOnHand = 200, UnitOfMeasure = "lbs", ReorderPoint = 75, ReorderQuantity = 150, MinimumStock = 25, MaximumStock = 500, UnitCost = 6.25m, AverageCost = 6.25m, LastPurchasePrice = 6.25m, LastPurchaseDate = DateTime.UtcNow.AddDays(-15), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-GRY-001", Name = "Textured Gray Powder", Description = "Textured gray powder coating for industrial use", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Textured Gray", ColorCode = "RAL 7037", Finish = "Textured", Manufacturer = "Axalta", ManufacturerPartNumber = "AX-TG-001", QuantityOnHand = 300, UnitOfMeasure = "lbs", ReorderPoint = 75, ReorderQuantity = 150, MinimumStock = 50, MaximumStock = 600, UnitCost = 5.00m, AverageCost = 5.00m, LastPurchasePrice = 5.00m, LastPurchaseDate = DateTime.UtcNow.AddDays(-10), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-YEL-001", Name = "Safety Yellow Powder", Description = "High-visibility safety yellow powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Safety Yellow", ColorCode = "RAL 1003", Finish = "Gloss", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "TG-SY-001", QuantityOnHand = 125, UnitOfMeasure = "lbs", ReorderPoint = 50, ReorderQuantity = 100, MinimumStock = 25, MaximumStock = 400, UnitCost = 5.50m, AverageCost = 5.50m, LastPurchasePrice = 5.50m, LastPurchaseDate = DateTime.UtcNow.AddDays(-5), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-ORG-001", Name = "Orange Powder", Description = "Bright orange powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Pure Orange", ColorCode = "RAL 2004", Finish = "Gloss", Manufacturer = "Axalta", ManufacturerPartNumber = "AX-PO-001", QuantityOnHand = 100, UnitOfMeasure = "lbs", ReorderPoint = 40, ReorderQuantity = 80, MinimumStock = 20, MaximumStock = 300, UnitCost = 5.85m, AverageCost = 5.85m, LastPurchasePrice = 5.85m, LastPurchaseDate = DateTime.UtcNow.AddDays(-12), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-PWD-GRN-001", Name = "Forest Green Powder", Description = "Deep forest green powder coating", Category = "Powder", InventoryCategoryId = powderCategory?.Id, ColorName = "Forest Green", ColorCode = "RAL 6009", Finish = "Matte", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "TG-FG-001", QuantityOnHand = 175, UnitOfMeasure = "lbs", ReorderPoint = 60, ReorderQuantity = 120, MinimumStock = 30, MaximumStock = 400, UnitCost = 5.25m, AverageCost = 5.25m, LastPurchasePrice = 5.25m, LastPurchaseDate = DateTime.UtcNow.AddDays(-8), CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-CLN-001", Name = "Pre-Treatment Cleaner", Description = "Industrial degreaser and cleaner", Category = "Cleaner", InventoryCategoryId = cleanerCategory?.Id, QuantityOnHand = 50, UnitOfMeasure = "gallons", ReorderPoint = 10, ReorderQuantity = 25, MinimumStock = 5, MaximumStock = 100, UnitCost = 12.50m, AverageCost = 12.50m, LastPurchasePrice = 12.50m, LastPurchaseDate = DateTime.UtcNow.AddDays(-20), IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }, new InventoryItem { SKU = $"{skuPrefix}-MSK-001", Name = "High-Temp Masking Tape", Description = "Heat-resistant masking tape for powder coating", Category = "Masking", InventoryCategoryId = maskingCategory?.Id, QuantityOnHand = 200, UnitOfMeasure = "rolls", ReorderPoint = 50, ReorderQuantity = 100, MinimumStock = 25, MaximumStock = 500, UnitCost = 8.75m, AverageCost = 8.75m, LastPurchasePrice = 8.75m, LastPurchaseDate = DateTime.UtcNow.AddDays(-15), IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow } }; // 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 { new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", IsActive = 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", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Industrial", ContactName = "Account Rep", Email = "industrial@sherwin-williams.com", Phone = "800-524-5979", Website = "https://www.sherwin-williams.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Ace Hardware Supply", ContactName = "Purchasing", Email = "supply@acehardware.com", Phone = "630-990-6600", Website = "https://www.acehardware.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Fastenal Industrial", ContactName = "Sales Team", Email = "sales@fastenal.com", Phone = "507-454-5374", Website = "https://www.fastenal.com", 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; } }