Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
T
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

1306 lines
56 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.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;
public SeedDataService(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
}
/// <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(); }
await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company));
await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company));
await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company));
await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company));
await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company));
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
if (company.CompanyCode == "DEMO")
{
try
{
var demoUsersCreated = await SeedDemoCompanyUsersAsync(company);
details.Add(demoUsersCreated > 0
? $"✓ {demoUsersCreated} demo user(s) created"
: "• Demo users already exist (skipped)");
result.ItemsSeeded += demoUsersCreated;
}
catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); }
}
if (errors.Any())
{
details.AddRange(errors);
result.Warnings.AddRange(errors);
result.Message = $"Seeding completed with {errors.Count} error(s) for {company.CompanyName}. Re-run to retry failed steps.";
}
else
{
result.Message = $"Company data seeded successfully for {company.CompanyName}";
}
result.Details = details;
return result;
}
/// <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 ten representative inventory items (eight powder colours, one cleaner, one
/// masking tape roll) for the company, linking each to the appropriate category lookup.
/// </summary>
/// <remarks>
/// Returns a tuple rather than a plain int because each item is saved individually
/// (one <c>SaveChangesAsync</c> call per item) so that a duplicate-SKU error on one
/// item does not roll back the entire batch. Failed items are captured as per-item
/// warning strings rather than aborting the seeder.
///
/// SKUs are prefixed with the company's <see cref="Company.CompanyCode"/> to guarantee
/// uniqueness across tenants in a shared database — e.g., <c>DEMO-PWD-BLK-001</c>.
/// A missing or empty CompanyCode throws <see cref="InvalidOperationException"/> because
/// SKU collisions would violate the unique index on the InventoryItems table.
///
/// Category IDs are resolved by <c>CategoryCode</c> (e.g., "POWDER", "CLEANER") rather
/// than hard-coded IDs because lookup IDs differ per company and per environment.
///
/// All powder items default to <c>CoverageSqFtPerLb = 30</c> and
/// <c>TransferEfficiency = 65</c>, which are industry-standard starting values used by
/// the pricing engine when calculating powder needed per coat.
/// </remarks>
/// <param name="company">The tenant company to seed inventory for.</param>
/// <returns>
/// A tuple of (count of items successfully inserted, list of per-item warning messages
/// for skipped or failed items).
/// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company)
{
var warnings = new List<string>();
int seededCount = 0;
// Validate company code
if (string.IsNullOrWhiteSpace(company.CompanyCode))
{
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode. Cannot seed inventory with unique SKUs.");
}
var skuPrefix = company.CompanyCode;
// Get category lookups to link items properly
var categories = await _context.InventoryCategoryLookups
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.ToListAsync();
var powderCategory = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
var cleanerCategory = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
var maskingCategory = categories.FirstOrDefault(c => c.CategoryCode == "MASKING");
// Use company code prefix to ensure unique SKUs across companies
var inventoryItems = new List<InventoryItem>
{
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-BLK-001",
Name = "Matte Black Powder",
Description = "High-quality matte black powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Matte Black",
ColorCode = "RAL 9005",
Finish = "Matte",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-MB-001",
QuantityOnHand = 500,
UnitOfMeasure = "lbs",
ReorderPoint = 100,
ReorderQuantity = 250,
MinimumStock = 50,
MaximumStock = 1000,
UnitCost = 4.50m,
AverageCost = 4.50m,
LastPurchasePrice = 4.50m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-30),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-WHT-001",
Name = "Gloss White Powder",
Description = "High-gloss white powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Gloss White",
ColorCode = "RAL 9010",
Finish = "Gloss",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-GW-001",
QuantityOnHand = 400,
UnitOfMeasure = "lbs",
ReorderPoint = 100,
ReorderQuantity = 250,
MinimumStock = 50,
MaximumStock = 1000,
UnitCost = 4.25m,
AverageCost = 4.25m,
LastPurchasePrice = 4.25m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-25),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-RED-001",
Name = "Gloss Red Powder",
Description = "Vibrant gloss red powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Traffic Red",
ColorCode = "RAL 3020",
Finish = "Gloss",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-GR-001",
QuantityOnHand = 150,
UnitOfMeasure = "lbs",
ReorderPoint = 50,
ReorderQuantity = 100,
MinimumStock = 25,
MaximumStock = 500,
UnitCost = 5.75m,
AverageCost = 5.75m,
LastPurchasePrice = 5.75m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-BLU-001",
Name = "Metallic Blue Powder",
Description = "Metallic blue powder coating with shimmer",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Metallic Blue",
ColorCode = "RAL 5002",
Finish = "Metallic",
Manufacturer = "Axalta",
ManufacturerPartNumber = "AX-MB-001",
QuantityOnHand = 200,
UnitOfMeasure = "lbs",
ReorderPoint = 75,
ReorderQuantity = 150,
MinimumStock = 25,
MaximumStock = 500,
UnitCost = 6.25m,
AverageCost = 6.25m,
LastPurchasePrice = 6.25m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-15),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-GRY-001",
Name = "Textured Gray Powder",
Description = "Textured gray powder coating for industrial use",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Textured Gray",
ColorCode = "RAL 7037",
Finish = "Textured",
Manufacturer = "Axalta",
ManufacturerPartNumber = "AX-TG-001",
QuantityOnHand = 300,
UnitOfMeasure = "lbs",
ReorderPoint = 75,
ReorderQuantity = 150,
MinimumStock = 50,
MaximumStock = 600,
UnitCost = 5.00m,
AverageCost = 5.00m,
LastPurchasePrice = 5.00m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-10),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-YEL-001",
Name = "Safety Yellow Powder",
Description = "High-visibility safety yellow powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Safety Yellow",
ColorCode = "RAL 1003",
Finish = "Gloss",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-SY-001",
QuantityOnHand = 125,
UnitOfMeasure = "lbs",
ReorderPoint = 50,
ReorderQuantity = 100,
MinimumStock = 25,
MaximumStock = 400,
UnitCost = 5.50m,
AverageCost = 5.50m,
LastPurchasePrice = 5.50m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-5),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-ORG-001",
Name = "Orange Powder",
Description = "Bright orange powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Pure Orange",
ColorCode = "RAL 2004",
Finish = "Gloss",
Manufacturer = "Axalta",
ManufacturerPartNumber = "AX-PO-001",
QuantityOnHand = 100,
UnitOfMeasure = "lbs",
ReorderPoint = 40,
ReorderQuantity = 80,
MinimumStock = 20,
MaximumStock = 300,
UnitCost = 5.85m,
AverageCost = 5.85m,
LastPurchasePrice = 5.85m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-12),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-PWD-GRN-001",
Name = "Forest Green Powder",
Description = "Deep forest green powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Forest Green",
ColorCode = "RAL 6009",
Finish = "Matte",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-FG-001",
QuantityOnHand = 175,
UnitOfMeasure = "lbs",
ReorderPoint = 60,
ReorderQuantity = 120,
MinimumStock = 30,
MaximumStock = 400,
UnitCost = 5.25m,
AverageCost = 5.25m,
LastPurchasePrice = 5.25m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-8),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-CLN-001",
Name = "Pre-Treatment Cleaner",
Description = "Industrial degreaser and cleaner",
Category = "Cleaner",
InventoryCategoryId = cleanerCategory?.Id,
QuantityOnHand = 50,
UnitOfMeasure = "gallons",
ReorderPoint = 10,
ReorderQuantity = 25,
MinimumStock = 5,
MaximumStock = 100,
UnitCost = 12.50m,
AverageCost = 12.50m,
LastPurchasePrice = 12.50m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-MSK-001",
Name = "High-Temp Masking Tape",
Description = "Heat-resistant masking tape for powder coating",
Category = "Masking",
InventoryCategoryId = maskingCategory?.Id,
QuantityOnHand = 200,
UnitOfMeasure = "rolls",
ReorderPoint = 50,
ReorderQuantity = 100,
MinimumStock = 25,
MaximumStock = 500,
UnitCost = 8.75m,
AverageCost = 8.75m,
LastPurchasePrice = 8.75m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-15),
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
// Add inventory items one at a time to handle duplicates gracefully
foreach (var item in inventoryItems)
{
try
{
// Check if item already exists by SKU
var existingItem = await _context.InventoryItems
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.SKU == item.SKU && i.CompanyId == company.Id);
if (existingItem != null)
{
warnings.Add($"⊘ Skipped: {item.Name} (SKU: {item.SKU}) - Already exists");
continue;
}
await _context.InventoryItems.AddAsync(item);
await _context.SaveChangesAsync();
seededCount++;
}
catch (Exception ex)
{
var friendlyError = GetFriendlyErrorMessage(ex, "inventory item");
warnings.Add($"⊘ Skipped: {item.Name} (SKU: {item.SKU}) - {friendlyError}");
// Detach the failed entity
if (_context.Entry(item).State != EntityState.Detached)
{
_context.Entry(item).State = EntityState.Detached;
}
}
}
return (seededCount, warnings);
}
/// <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>
{
new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Columbia Coatings", ContactName = "Sales", Email = "info@columbiacoatings.com", Phone = "888-265-8247", Website = "https://www.columbiacoatings.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Industrial", ContactName = "Account Rep", Email = "industrial@sherwin-williams.com", Phone = "800-524-5979", Website = "https://www.sherwin-williams.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Ace Hardware Supply", ContactName = "Purchasing", Email = "supply@acehardware.com", Phone = "630-990-6600", Website = "https://www.acehardware.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Fastenal Industrial", ContactName = "Sales Team", Email = "sales@fastenal.com", Phone = "507-454-5374", Website = "https://www.fastenal.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
};
await _context.Set<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;
}
}