Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
T
spouliot a21c05f655 Expand demo seed: 178 inventory items + 30 vendors
Inventory (11 → 178):
- 101 total powders: 6 core + 55 Prismatic + 20 Columbia + 13 Tiger Drylac + 9 Sherwin-Williams
- 77 supplies: 21 masking, 16 chemicals, 16 abrasives, 15 hanging hardware, 9 PPE
- ForceRemoveAll path now deletes all inventory for the company (not just
  the 11 enumerated SKUs), since transactions are pre-swept before this block

Vendors (5 → 30):
- Tiger Drylac, Sherwin-Williams Powders, Eastwood (powder suppliers)
- Clemco, Triangle Abrasives, Airgas, Linde (blasting/gases)
- Duke Energy, AT&T, Spectrum, Raleigh Electric, Carolina Industrial Water (utilities)
- Safety-Kleen, Raleigh Waste (environmental)
- Work N Gear, HD Supply, Carolina Office, First Insurance (services)
- Triangle Commercial Properties LLC (landlord — shop lease with address + terms)
- Fastenal, MSC, McMaster-Carr, Uline, Amazon Business, Lowe's Pro, NAPA (supply chain)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:06:41 -04:00

1373 lines
94 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IAccountBalanceService _accountBalanceService;
public SeedDataService(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IAccountBalanceService accountBalanceService)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_accountBalanceService = accountBalanceService;
}
/// <summary>
/// Extracts a human-readable error message from a <see cref="DbUpdateException"/>,
/// decoding SQL Server error codes into plain language.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="ex">The exception thrown by <c>SaveChangesAsync</c>.</param>
/// <param name="entityName">Friendly name of the entity being saved, used in fallback messages.</param>
/// <returns>A single-sentence, user-readable error description.</returns>
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}";
}
/// <summary>
/// Returns all non-deleted companies in the platform, ordered by name.
/// </summary>
/// <remarks>
/// Uses <c>IgnoreQueryFilters</c> 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>!c.IsDeleted</c>.
/// </remarks>
public async Task<List<Company>> GetCompaniesAsync()
{
return await _context.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.CompanyName)
.ToListAsync();
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>
/// A <see cref="SeedDataResult"/> summarising what was created, with
/// <see cref="SeedDataResult.Success"/> = <c>false</c> only if an unrecoverable error occurs.
/// </returns>
public async Task<SeedDataResult> SeedSystemDataAsync()
{
var result = new SeedDataResult { Success = true };
var details = new List<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="companyId">The primary key of the company to initialise.</param>
/// <returns>
/// A <see cref="SeedDataResult"/> listing which tables were populated.
/// Returns a failure result if the company is not found.
/// </returns>
public async Task<SeedDataResult> SeedCompanyLookupsAsync(int companyId)
{
var result = new SeedDataResult { Success = true };
var details = new List<string>();
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;
}
/// <summary>
/// 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".
/// </summary>
/// <remarks>
/// 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
/// <see cref="SeedDataResult.Warnings"/> 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.
/// </remarks>
/// <param name="companyId">The primary key of the company to seed.</param>
/// <returns>
/// A <see cref="SeedDataResult"/> summarising counts, warnings, and any errors.
/// <see cref="SeedDataResult.Success"/> is always <c>true</c>; errors appear in Warnings.
/// </returns>
public async Task<SeedDataResult> SeedCompanyDataAsync(int companyId)
{
var result = new SeedDataResult { Success = true };
var details = new List<string>();
var errors = new List<string>();
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<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Checking);
if (checkingAcct != null && checkingAcct.OpeningBalance == 0)
{
checkingAcct.OpeningBalance = 75_000m;
checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
checkingAcct.CurrentBalance = 75_000m;
await _context.SaveChangesAsync();
details.Add("✓ Checking account opening balance set to $75,000");
}
var savingsAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Savings);
if (savingsAcct != null && savingsAcct.OpeningBalance == 0)
{
savingsAcct.OpeningBalance = 14_500m;
savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
savingsAcct.CurrentBalance = 14_500m;
await _context.SaveChangesAsync();
details.Add("✓ Savings account opening balance set to $14,500");
}
}
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
await RunSeeder("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;
}
/// <summary>
/// Executes a single seeder delegate that returns an integer count of rows created,
/// appending a success or skip message to <paramref name="details"/> and accumulating
/// any exception into <paramref name="errors"/>.
/// </summary>
/// <remarks>
/// This helper exists so that <see cref="SeedCompanyDataAsync"/> 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.
/// </remarks>
/// <param name="label">Short display name used in status messages (e.g., "Job statuses").</param>
/// <param name="details">Running list of success/skip lines shown in the UI result.</param>
/// <param name="errors">Running list of error lines appended on exception.</param>
/// <param name="result">The overall result object whose <c>ItemsSeeded</c> is incremented on success.</param>
/// <param name="seeder">The seeder delegate to invoke.</param>
private async Task RunSeeder(
string label,
List<string> details,
List<string> errors,
SeedDataResult result,
Func<Task<int>> 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();
}
}
/// <summary>
/// Ensures the platform's default "DEMO" company exists, creating it if not found.
/// </summary>
/// <remarks>
/// The DEMO company is the anchor tenant used by seeded SuperAdmin accounts and demo data.
/// After inserting, <c>CompanyId</c> is set equal to <c>Id</c> — 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 <c>null</c> (not a new object) when the company already exists, which lets
/// <see cref="SeedSystemDataAsync"/> distinguish "created" from "already present".
/// </remarks>
/// <returns>The newly created <see cref="Company"/>, or <c>null</c> if it already existed.</returns>
private async Task<Company?> 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;
}
/// <summary>
/// Ensures all six ASP.NET Identity application roles exist:
/// SuperAdmin, Administrator, Manager, Employee, ShopFloor, and ReadOnly.
/// </summary>
/// <remarks>
/// Roles are checked individually via <see cref="RoleManager{TRole}.RoleExistsAsync"/>
/// rather than in bulk so that this method can be re-run safely without duplicates.
/// Role names are sourced from <see cref="AppConstants.Roles"/> to keep them in sync
/// with authorization policies defined throughout the application.
/// </remarks>
/// <returns>The number of new roles created (0 if all already existed).</returns>
private async Task<int> 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;
}
/// <summary>
/// Ensures the primary break-glass SuperAdmin account exists
/// (<c>artemis@powdercoatinglogix.com</c>).
/// </summary>
/// <remarks>
/// This account is the "break glass" owner account — intentional guards in
/// <c>PlatformUsersController</c> prevent it from being deleted or demoted.
/// It is assigned to the default DEMO company but has <c>CompanyRole = null</c>,
/// which is the signal used by the multi-tenancy middleware to grant cross-company access.
/// All granular permission flags are set to <c>true</c> 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.
/// </remarks>
/// <param name="defaultCompany">The DEMO company the account is anchored to.</param>
/// <returns>The number of new SuperAdmin users created (0 or 1).</returns>
private async Task<int> 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;
}
/// <summary>
/// Seeds demo login accounts for the DEMO company: a Company Administrator
/// (<c>demo@powdercoatinglogix.com</c>) and a Manager (<c>manager@demo.com</c>).
/// </summary>
/// <remarks>
/// Only called when <c>company.CompanyCode == "DEMO"</c> — 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,
/// <c>CompanyRole = CompanyAdmin</c>, system role = Administrator.
/// The Manager account intentionally has <c>CanApproveQuotes = false</c> to demonstrate
/// the role-based permission model in the demo environment.
/// </remarks>
/// <param name="company">The DEMO company record.</param>
/// <returns>The number of new user accounts created (02).</returns>
private async Task<int> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="Company.CompanyCode"/> to guarantee uniqueness
/// across tenants in a shared database (e.g., DEMO-PWD-GBK-001).
/// </remarks>
private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company)
{
var warnings = new List<string>();
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<InventoryItem>
{
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);
}
/// <summary>
/// Creates a single <see cref="CompanyOperatingCosts"/> record for the company with
/// industry-typical default rates if none exists yet.
/// </summary>
/// <remarks>
/// 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 <c>OvenOperatingCostPerHour</c> here may later be overwritten by
/// <see cref="SeedOvenCostsAsync"/> if it finds the value is still 0.
/// </remarks>
/// <param name="company">The tenant company to create operating costs for.</param>
/// <returns><c>true</c> if a new record was created; <c>false</c> if one already existed.</returns>
private async Task<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Pricing tiers drive the automatic discount applied to quotes when a customer belongs
/// to a tier — the pricing engine reads <c>PricingTier.DiscountPercent</c> at quote-
/// calculation time. The Standard tier at 0% acts as the default "no discount" tier.
/// All four tiers are inserted in a single <c>AddRangeAsync</c> / <c>SaveChangesAsync</c>
/// call (unlike inventory items) because there is no per-item uniqueness constraint that
/// could cause a partial failure — tier names are not globally unique.
/// </remarks>
/// <param name="company">The tenant company to seed pricing tiers for.</param>
/// <returns>The number of tiers created (4), or 0 if tiers already existed.</returns>
private async Task<int> SeedPricingTiersAsync(Company company)
{
// Check if pricing tiers already exist
var existingTiers = await _context.Set<PricingTier>()
.IgnoreQueryFilters()
.AnyAsync(pt => pt.CompanyId == company.Id && !pt.IsDeleted);
if (existingTiers)
{
return 0;
}
var pricingTiers = new List<PricingTier>
{
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<PricingTier>().AddRangeAsync(pricingTiers);
await _context.SaveChangesAsync();
return pricingTiers.Count;
}
/// <summary>
/// Seeds five real-world powder-coating suppliers as demo vendor records for the company
/// (Prismatic Powders, Columbia Coatings, Sherwin-Williams Industrial, Ace Hardware, Fastenal).
/// </summary>
/// <remarks>
/// Uses an all-or-nothing <c>AddRangeAsync</c> 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 <see cref="SeedBillsAsync"/> and
/// <see cref="SeedExpensesAsync"/> by name match (e.g., "Prismatic", "Columbia"), so the
/// names must not be changed without also updating the bills seeder's lookup logic.
/// </remarks>
/// <param name="company">The tenant company to seed vendors for.</param>
/// <returns>The number of vendors created (5), or 0 if vendors already existed.</returns>
private async Task<int> SeedVendorsAsync(Company company)
{
var exists = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.AnyAsync(v => v.CompanyId == company.Id && !v.IsDeleted);
if (exists) return 0;
var vendors = new List<Vendor>
{
// ── 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<Vendor>().AddRangeAsync(vendors);
await _context.SaveChangesAsync();
return vendors.Count;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Named ovens are stored in the <c>OvenCost</c> entity (not <c>Equipment</c>) 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
/// <see cref="CompanyOperatingCosts.OvenOperatingCostPerHour"/> field: if it is still 0
/// (meaning <see cref="SeedOperatingCostsAsync"/> 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 <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> fields are used by the
/// Oven Scheduler's capacity-planning and suggested-batch logic.
/// </remarks>
/// <param name="company">The tenant company to seed oven configurations for.</param>
/// <returns>The number of oven records created (2), or 0 if any already existed.</returns>
private async Task<int> SeedOvenCostsAsync(Company company)
{
var exists = await _context.Set<OvenCost>()
.IgnoreQueryFilters()
.AnyAsync(o => o.CompanyId == company.Id && !o.IsDeleted);
if (exists) return 0;
var ovens = new List<OvenCost>
{
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<OvenCost>().AddRangeAsync(ovens);
await _context.SaveChangesAsync();
// Set operating cost to first oven's rate if not already set
var costs = await _context.Set<CompanyOperatingCosts>()
.FirstOrDefaultAsync(c => c.CompanyId == company.Id);
if (costs != null && costs.OvenOperatingCostPerHour == 0)
{
costs.OvenOperatingCostPerHour = ovens[0].CostPerHour;
await _context.SaveChangesAsync();
}
return ovens.Count;
}
}