a21c05f655
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>
1373 lines
94 KiB
C#
1373 lines
94 KiB
C#
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 (0–2).</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;
|
||
}
|
||
}
|