27bfd4db4d
- 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>
1306 lines
56 KiB
C#
1306 lines
56 KiB
C#
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 (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 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;
|
||
}
|
||
}
|