Demo seed Phase 1: NC identity, spec inventory, revenue targeting

- Customers: 10 NC commercial (Carolina Fabrication, Apex Motorsports, Triangle
  Offroad, Smith Welding, Raleigh Architectural Metals, etc.) + 17 residential,
  all anchored to Raleigh-Durham area for cohesive tutorial identity
- Inventory: 6 spec powders (Gloss Black, Matte Black, Super Chrome, Candy Red,
  Signal White, Illusion Purple) + 5 consumables (Tape, Silicone Plugs, Hooks,
  Acetone, Blast Media); 2 low-stock + 1 out-of-stock for dashboard alerts
- Vendors: updated to spec (Prismatic Powders, Columbia Coatings, Harbor Freight,
  Grainger, Local Industrial Supply)
- Quotes: 35 quotes (was 20) with 5-status distribution; dates span 5-6 months
- Jobs: 50 jobs (was ~32) with per-customer price ranges so Revenue by Customer
  report shows realistic Pareto curve (Carolina Fabrication largest, etc.)
- Remove.cs: fingerprints updated for all 27 new customer emails + 11 new SKUs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:12:47 -04:00
parent 01f6897d08
commit 1255bc0670
5 changed files with 352 additions and 649 deletions
@@ -6,44 +6,32 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for
/// the given company, spanning automotive, industrial, architectural, fitness, marine,
/// furniture, government, and specialty verticals.
/// Seeds 27 realistic customers for the demo company: 10 commercial accounts and
/// 17 individual/non-commercial customers, all anchored to the Raleigh-Durham NC area.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already
/// exist for this company, preventing duplicate customer sets on repeated seed runs.
/// The NC geographic identity (Carolina Fabrication, Apex Motorsports, Triangle Offroad,
/// Raleigh Architectural Metals, etc.) gives tutorial recordings and marketing screenshots
/// a coherent sense of place rather than a scatter of US cities.
/// </para>
/// <para>
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that
/// a duplicate-email collision on any single record is caught and converted to a warning
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent
/// the DbContext change-tracker from retrying the failed insert on the next
/// <c>SaveChangesAsync</c> call.
/// Revenue is deliberately top-heavy: Carolina Fabrication (Platinum) and Apex Motorsports
/// (Gold) carry the majority of simulated revenue so the Revenue by Customer report shows
/// a realistic Pareto distribution from day one.
/// </para>
/// <para>
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier,
/// rather than throwing.
/// Wake County Fleet Services is seeded as tax-exempt to demonstrate the tax-exempt
/// workflow (0% tax on quotes and invoices, ★ marker in dropdowns).
/// </para>
/// <para>
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>,
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with
/// <c>IsTaxExempt = true</c> to demonstrate the tax-exempt workflow, matching the
/// production rule that tax-exempt customers get 0 % tax on quotes and invoices.
/// </para>
/// <para>
/// The two local helper functions <c>Comm()</c> and <c>Indiv()</c> reduce the per-row
/// line count; they are defined as local functions rather than private methods because
/// they capture the <c>company</c> parameter by closure and are only needed here.
/// Each customer is inserted individually so a duplicate-email collision on any single
/// record is converted to a warning rather than aborting the entire batch.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed customers for.</param>
/// <returns>
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
/// (e.g. because the email already existed).
/// A tuple of (seededCount, warnings) where warnings list any skipped customers.
/// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
{
@@ -52,7 +40,6 @@ public partial class SeedDataService
int skippedCount = 0;
var now = DateTime.UtcNow;
// Early exit — same pattern as all other seeders
var existingCount = await _context.Set<Customer>()
.IgnoreQueryFilters()
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
@@ -70,11 +57,7 @@ public partial class SeedDataService
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
// ── Local helpers keep each customer to 2-3 lines ──────────────────────
//
// Comm() builds a commercial (B2B) Customer with credit limit, tax ID, and pricing tier.
// The LastContactDate formula scrambles the months value so that contacts are spread
// across the past 25 days rather than clustering on the same date for all customers.
// ── Local helpers ──────────────────────────────────────────────────────
Customer Comm(string co, string fn, string ln, string em, string ph,
string city, string st, string zip, string terms, decimal credit, decimal bal,
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) =>
@@ -89,8 +72,6 @@ public partial class SeedDataService
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
};
// Indiv() builds a non-commercial (retail) Customer with simpler fields:
// no credit limit, no tax ID, payment terms default to "Due on receipt".
Customer Indiv(string fn, string ln, string em, string ph,
string city, string st, string zip, string notes, int months) =>
new Customer
@@ -104,50 +85,45 @@ public partial class SeedDataService
var customers = new List<Customer>
{
// ─── Commercial Customers (15) ────────────────────────────────────
// ─── Commercial Customers (10) — NC / Triangle Area ────────────────
// Auto & Motorsports
Comm("Acme Manufacturing Corp", "John", "Smith", "john.smith@acmemfg.com", "(555) 234-5678", "Chicago", "IL", "60601", "Net 30", 50000m, 12500m, "12-3456789", platinumTier, "Large volume customer, weekly shipments", 18),
Comm("Precision Auto Parts LLC", "Sarah", "Johnson", "sjohnson@precisionauto.com", "(555) 345-6789", "Detroit", "MI", "48201", "Net 30", 35000m, 8750m, "23-4567890", goldTier, "Automotive parts manufacturer", 15),
Comm("Classic Wheel Restoration", "Robert", "Taylor", "rtaylor@classicwheels.com", "(555) 789-0123", "Phoenix", "AZ", "85001", "Net 15", 15000m, 3200m, "67-8901234", silverTier, "Classic car wheel specialist", 10),
Comm("MotorSports Custom Shop", "Chris", "Brown", "cbrown@motorsportscustom.com","(555) 901-2345", "Indianapolis", "IN", "46201", "Net 15", 20000m, 9500m, "89-0123456", silverTier, "Performance parts and custom fabrication", 8),
// Top-revenue accounts — drive the Revenue by Customer report story
Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly", 18),
Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components", 15),
Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers", 12),
Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly", 10),
// Industrial & Manufacturing
Comm("Industrial Furniture Co", "Jennifer", "Anderson", "janderson@indfurniture.com", "(555) 890-1234", "Seattle", "WA", "98101", "Net 30", 30000m, 7800m, "78-9012345", goldTier, "Office and outdoor furniture manufacturer", 16),
Comm("Commercial HVAC Systems", "Kevin", "Garcia", "kgarcia@commercialhvac.com", "(555) 345-6780", "Atlanta", "GA", "30301", "Net 30", 32000m, 8900m, "23-4567891", goldTier, "HVAC ductwork and equipment casings", 17),
Comm("Agricultural Equipment Inc", "Sandra", "White", "swhite@agequipment.com", "(555) 678-9013", "Des Moines", "IA", "50301", "Net 30", 42000m, 16800m, "56-7890124", goldTier, "Farm equipment parts and implements", 19),
// Mid-tier commercial
Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates", 13),
Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing", 9),
Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames", 7),
Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels", 8),
// Architectural & Construction
Comm("Urban Railings & Gates", "Michael", "Chen", "mchen@urbanrailings.com", "(555) 456-7890", "San Francisco", "CA", "94102", "Net 15", 25000m, 5200m, "34-5678901", silverTier, "Ornamental iron railings and gates", 12),
Comm("Heritage Architectural Metalworks","Thomas", "Miller", "tmiller@heritagemetal.com", "(555) 123-4567", "Charleston", "SC", "29401", "Net 30", 28000m, 6700m, "01-2345678", goldTier, "Historic restoration and custom architectural pieces",13),
// Specialty accounts
Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures", 11),
Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", 20, true),
// Fitness & Marine
Comm("Fitness Equipment Solutions", "Lisa", "Martinez", "lmartinez@fitequip.com", "(555) 567-8901", "Austin", "TX", "78701", "Net 30", 40000m, 15600m, "45-6789012", goldTier, "Gym equipment frames and accessories", 14),
Comm("Playground Equipment USA", "Nancy", "Martinez", "nmartinez@playgroundusa.com", "(555) 456-7891", "Portland", "OR", "97201", "Net 30", 38000m, 14500m, "34-5678902", platinumTier, "Commercial playground equipment manufacturer", 22),
Comm("Marine Equipment Corp", "Patricia", "Wilson", "pwilson@marineequip.com", "(555) 234-5679", "Miami", "FL", "33101", "Net 30", 35000m, 11400m, "12-3456780", silverTier, "Boat hardware and marine fittings", 11),
// ─── Individual / Non-Commercial Customers (17) ──────────────────
// Commercial & Energy/Government
Comm("Office Systems International", "Brian", "Lee", "blee@officesystems.com", "(555) 567-8902", "Dallas", "TX", "75201", "Net 15", 27000m, 5600m, "45-6789013", silverTier, "Office furniture components and accessories", 9),
Comm("Metro Transit Authority", "David", "Williams", "dwilliams@metrota.gov", "(555) 678-9012", "Boston", "MA", "02101", "Net 60", 75000m, 22000m, "56-7890123", platinumTier, "Government transit contract — tax exempt", 24, true),
Comm("Green Energy Solutions", "Amanda", "Davis", "adavis@greenenergy.com", "(555) 012-3456", "Denver", "CO", "80201", "Net 30", 45000m, 18200m, "90-1234567", platinumTier, "Solar panel frames and mounting hardware", 20),
// ─── Individual / Non-Commercial Customers (12) ───────────────────
Indiv("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6),
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4),
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7),
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3),
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5),
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2),
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8),
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home decor projects", 1),
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5),
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3),
Indiv("Gary", "Nelson", "gnelson@email.com", "(555) 131-4141", "Minneapolis", "MN", "55401", "Snowmobile frame and parts", 2),
Indiv("Carol", "Evans", "carol.evans@email.com", "(555) 242-5252", "Portland", "OR", "97207", "Vintage bicycle restoration", 1),
Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs", 6),
Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts", 4),
Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers", 7),
Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings", 3),
Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes", 5),
Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases", 8),
Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art", 2),
Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats", 5),
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC","27526", "Antique fireplace grate and hardware restoration", 3),
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build", 6),
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC","27540", "Outdoor furniture set, 6 chairs and table", 2),
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red", 3),
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black", 4),
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel", 2),
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate", 1),
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly", 3),
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black", 2),
};
// Add customers one at a time to handle duplicates gracefully
foreach (var customer in customers)
{
try
@@ -161,7 +137,7 @@ public partial class SeedDataService
{
skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
warnings.Add($"Skipped: {name} — email {customer.Email} already exists");
warnings.Add($"Skipped: {name} — email {customer.Email} already exists");
continue;
}
@@ -173,7 +149,7 @@ public partial class SeedDataService
{
skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
warnings.Add($"Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
warnings.Add($"Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
if (_context.Entry(customer).State != EntityState.Detached)
_context.Entry(customer).State = EntityState.Detached;
}
@@ -7,41 +7,31 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses,
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs.
/// Seeds 50 powder coating jobs distributed across all 16 statuses, with deliberate
/// per-customer revenue targeting so the Revenue by Customer report matches the demo
/// company narrative: Carolina Fabrication largest, then Apex Motorsports, Triangle Offroad,
/// Smith Welding, and so on down to small individual residential jobs.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company.
/// Job counts and price ranges are defined per customer index (0 = Carolina Fabrication,
/// the top-revenue account) via <c>CustomerProfile(ci)</c>. This replaces the previous
/// modulo-formula approach that produced uniform pricing regardless of customer tier.
/// </para>
/// <para>
/// The method depends on job-status and job-priority lookup rows (populated earlier in the
/// seed sequence), and on at least one customer record. It returns 0 if any of these
/// dependencies are missing so the overall seed degrades gracefully.
/// All 16 statuses are covered: the 16 statuses cycle through the first 16 jobs in
/// sequence, guaranteeing every pipeline stage is populated even with only 50 total jobs.
/// </para>
/// <para>
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans
/// existing numbers with the current month prefix and starts its sequence above the current
/// maximum so demo jobs never collide with real jobs created in the same calendar month.
/// Date logic groups jobs into three buckets — completed (60150 days ago), in-progress
/// (1050 days ago), and early-stage (217 days ago or future-scheduled) — to produce
/// realistic dashboard pipeline and calendar views.
/// </para>
/// <para>
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
/// quotes). When a match is found the job inherits the quote's customer, description,
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
/// </para>
/// <para>
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
/// in-progress (past start date, no completion), and completed/terminal (both started
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
/// views display a realistic spread rather than all jobs sharing the same date.
/// </para>
/// <para>
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
/// The first approved quote available for each customer is linked to their first job
/// when a match exists, demonstrating the quote-to-job conversion workflow.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed jobs for.</param>
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedJobsAsync(Company company)
{
var existingCount = await _context.Set<Job>()
@@ -73,11 +63,11 @@ public partial class SeedDataService
if (customers.Count == 0)
return 0;
// Grab approved quotes to link to jobs
var approvedQuotes = await _context.Set<Quote>()
.IgnoreQueryFilters()
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
.OrderBy(q => q.Id)
.OrderBy(q => q.CustomerId)
.ThenBy(q => q.Id)
.ToListAsync();
var shopUsers = await _context.Set<ApplicationUser>()
@@ -86,7 +76,6 @@ public partial class SeedDataService
.ToListAsync();
var now = DateTime.UtcNow;
var prefix = $"JOB-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Job>()
.IgnoreQueryFilters()
@@ -98,14 +87,44 @@ public partial class SeedDataService
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Per-customer job counts (27 customers, ~32 total jobs) ─────────
// Varied 0-5 jobs per customer; the global jobIdx cycles all 16 statuses
// so every status is visible without requiring a large fixed pool.
static int JobsFor(int ci) => new[]
{ 3, 2, 1, 2, 0, 2, 1, 3, 0, 1, 2, 1, 0, 2, 1, 1, 2, 0, 1, 0, 2, 1, 0, 1, 0, 1, 2 }[ci];
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
// Indices match the customer order seeded in SeedCustomersAsync:
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
// 1026 = individual residential customers (smaller jobs)
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
{
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
1 => (6, 400m, 1500m), // Apex Motorsports
2 => (5, 350m, 1200m), // Triangle Offroad
3 => (4, 250m, 800m), // Smith Welding
4 => (4, 300m, 900m), // Raleigh Architectural Metals
5 => (3, 200m, 600m), // East Coast Powderworks
6 => (3, 150m, 450m), // Piedmont Metal Works
7 => (2, 200m, 500m), // Cary Industrial Solutions
8 => (2, 350m, 900m), // Durham Tech Equipment
9 => (3, 400m, 1500m), // Wake County Fleet Services
10 => (2, 75m, 250m), // John Davis
11 => (1, 150m, 350m), // Sarah Jenkins
12 => (1, 200m, 400m), // Mike Thompson
13 => (2, 100m, 300m), // Robert Miller
14 => (0, 0m, 0m), // Jennifer Clark — no jobs yet
15 => (1, 100m, 250m), // David Wilson
16 => (0, 0m, 0m), // Lisa Anderson — no jobs yet
17 => (1, 150m, 300m), // Thomas Harris
18 => (0, 0m, 0m), // Karen White — no jobs yet
19 => (1, 250m, 500m), // James Taylor
20 => (0, 0m, 0m), // Michelle Brown — no jobs yet
21 => (1, 100m, 250m), // Chris Lee
22 => (0, 0m, 0m), // Amanda Garcia — no jobs yet
23 => (1, 150m, 350m), // Kevin Martinez
24 => (0, 0m, 0m), // Nancy Rodriguez — no jobs yet
25 => (0, 0m, 0m), // Brian Hall — no jobs yet
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
};
// All 16 statuses in production workflow order — cycled globally across jobs
// so the full pipeline is represented even with fewer total records.
// All 16 statuses cycle globally so every pipeline stage is visible.
string[] allStatuses =
[
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
@@ -113,12 +132,9 @@ public partial class SeedDataService
"QUALITY_CHECK", "COMPLETED", "READY_FOR_PICKUP", "DELIVERED",
"ON_HOLD", "CANCELLED"
];
string StatusFor(int jobIdx) => allStatuses[jobIdx % allStatuses.Length];
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally
// over-represented (4 of 10) relative to production averages so the priority colour
// badges and rush-fee logic are clearly visible in demo data.
// Priority distribution weighted toward the interesting end for demo visibility.
static string PriorityFor(int i) => (i % 10) switch
{
0 => "RUSH",
@@ -127,83 +143,85 @@ public partial class SeedDataService
3 => "URGENT",
4 => "HIGH",
5 => "HIGH",
6 => "HIGH",
_ => "NORMAL"
};
// Returns description, finish color, prep flags, and estimated minutes for a job item.
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index,
// preventing every job from having the same first item.
// Job item descriptions and specs — 15-item pool cycling via (jobIdx*3 + itemIdx) % 15.
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
((i * 3 + j) % 15) switch
{
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45),
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30),
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40),
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90),
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50),
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40)
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame", "Candy Red", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
};
var jobs = new List<Job>();
var quoteIdx = 0;
var jobIdx = 0; // global counter drives status cycling across all customers
var jobIdx = 0;
for (int ci = 0; ci < customers.Count; ci++)
{
var customer = customers[ci];
var numJobs = JobsFor(ci);
var customer = customers[ci];
var (numJobs, minVal, maxVal) = CustomerProfile(ci);
for (int j = 0; j < numJobs; j++, jobIdx++, seq++)
{
var statusCode = StatusFor(jobIdx);
var priorityCode = PriorityFor(jobIdx);
// Link an approved quote when one is available
// Try to link the first available approved quote for this customer
Quote? linkedQuote = null;
if (quoteIdx < approvedQuotes.Count)
for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
{
var candidate = approvedQuotes[quoteIdx];
if (candidate.CustomerId == customer.Id || quoteIdx % 3 == 0)
if (approvedQuotes[qi].CustomerId == customer.Id)
{
linkedQuote = candidate;
linkedQuote = approvedQuotes[qi];
quoteIdx = qi + 1;
break;
}
// Every 4th job forcibly links any available approved quote
if (quoteIdx % 4 == 0 && qi == quoteIdx)
{
linkedQuote = approvedQuotes[qi];
quoteIdx++;
break;
}
}
// Date logic — creation spread over 4-6 months
// Older jobs for completed statuses, recent for in-progress, future-scheduled for early statuses
// Date logic
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
// Spread creation over 30-150 days ago (1-5 months), older jobs for completed statuses
int daysAgo = isCompleted ? 60 + (jobIdx % 90)
: isInProgress ? 10 + (jobIdx % 40)
: 2 + (jobIdx % 15);
var createdDate = now.AddDays(-daysAgo);
int daysAgo = isCompleted ? 60 + (jobIdx % 90)
: isInProgress ? 10 + (jobIdx % 40)
: 2 + (jobIdx % 15);
var createdDate = now.AddDays(-daysAgo);
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (jobIdx % 5))
: isInProgress ? now.AddDays(-(jobIdx % 4))
: now.AddDays(3 + (jobIdx % 12));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = !isEarly ? scheduledDate : (DateTime?)null;
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = !isCompleted && !isInProgress ? (DateTime?)null : scheduledDate;
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
var assignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null;
var itemCount = 1 + (jobIdx % 3);
var items = new List<JobItem>();
// Per-customer value targeting: deterministic within the customer's price range
var range = maxVal - minVal;
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
var itemCount = 1 + (jobIdx % 3);
var items = new List<JobItem>();
for (int k = 0; k < itemCount; k++)
{
@@ -211,7 +229,7 @@ public partial class SeedDataService
var qty = 1 + (k % 3);
var unitPrice = linkedQuote != null && k == 0
? Math.Round(linkedQuote.Total / itemCount, 2)
: Math.Round(75m + (jobIdx % 8) * 12.5m + k * 15m, 2);
: Math.Round(targetValue / itemCount / qty, 2);
items.Add(new JobItem
{
@@ -238,7 +256,7 @@ public partial class SeedDataService
JobNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
QuoteId = linkedQuote?.Id,
AssignedUserId = assignedUserId,
AssignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null,
Description = linkedQuote?.Description
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
JobStatusId = jobStatuses[statusCode],
@@ -250,12 +268,12 @@ public partial class SeedDataService
QuotedPrice = quotedPrice,
FinalPrice = finalPrice,
IsRushJob = priorityCode == "RUSH",
CustomerPO = linkedQuote?.CustomerPO ?? (jobIdx % 3 == 0 ? $"PO-{40000 + jobIdx}" : null),
CustomerPO = customer.IsCommercial && jobIdx % 3 == 0 ? $"PO-{40000 + jobIdx}" : null,
SpecialInstructions = jobIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
jobIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
InternalNotes = jobIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
RequiresCustomerApproval = jobIdx % 5 == 0,
IsCustomerApproved = jobIdx % 5 != 0 || !isEarly,
IsCustomerApproved = jobIdx % 5 != 0 || !isInProgress,
JobItems = items,
CompanyId = company.Id,
CreatedAt = createdDate
@@ -6,39 +6,35 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 75 realistic powder coating quotes spread across seven item categories
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc)
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35),
/// Rejected (10), and Expired (10).
/// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
/// Rejected, and Expired — with a deliberate majority of Approved quotes so that
/// SeedJobsAsync has enough approved records to link jobs to.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for
/// this company, preventing duplicate quote sets on repeated seed runs.
/// All five quote statuses are present so the Quotes Index view demonstrates the full
/// workflow from first contact to customer approval. APPROVED is the majority (16 of 35)
/// because the job seeder links to approved quotes to show the quote-to-job conversion path.
/// </para>
/// <para>
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans
/// existing numbers with the current month prefix and starts its sequence above the
/// current maximum so seeded quotes never collide with real quotes created in the
/// same month.
/// Quotes are distributed across both commercial and individual customers, matching the
/// real-world mix where most quotes come from commercial accounts but individuals do
/// occasionally request quotes for larger projects.
/// </para>
/// <para>
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost
/// config that may not yet be populated when seed runs.
/// Item descriptions, colours, and specs are drawn from the same 15-item pool used by
/// the job seeder so that tutorial screenshots of quotes and linked jobs look consistent.
/// </para>
/// <para>
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote.
/// Pricing is deliberately simple (sqft × rate + variance) rather than running through
/// IPricingCalculationService — this avoids a dependency on operating cost config that
/// may not yet be populated when seed runs.
/// </para>
/// <para>
/// The method requires that customers and quote-status lookup rows already exist for the
/// company; it returns 0 if either dependency is missing so that the overall seed
/// operation degrades gracefully rather than throwing.
/// Dates spread over 150180 days back (56 months) so the historical charts on the
/// dashboard and reports show a meaningful activity curve.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed quotes for.</param>
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedQuotesAsync(Company company)
{
var existingCount = await _context.Set<Quote>()
@@ -56,14 +52,15 @@ public partial class SeedDataService
if (quoteStatuses.Count == 0)
return 0;
// Load all commercial customers
var customers = await _context.Set<Customer>()
// Load all customers — commercial first, then individual
var allCustomers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted)
.OrderBy(c => c.Id)
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.OrderByDescending(c => c.IsCommercial)
.ThenBy(c => c.Id)
.ToListAsync();
if (customers.Count == 0)
if (allCustomers.Count == 0)
return 0;
var preparedByUser = await _userManager.Users
@@ -72,7 +69,6 @@ public partial class SeedDataService
var now = DateTime.UtcNow;
// Avoid duplicate quote numbers
var prefix = $"QT-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Quote>()
.IgnoreQueryFilters()
@@ -84,183 +80,122 @@ public partial class SeedDataService
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Data arrays for varied, realistic content ─────────────────────────
// Returns an array of realistic item descriptions for a given category bucket (06).
// Using a local static function keeps the description data close to where it is
// consumed and avoids polluting the partial class with per-seeder detail arrays.
static string[] ItemDescs(int category) => category switch
{
0 => new[] {
"18\" Aluminum Wheels — Matte Black",
"17\" Steel Wheels — Gloss White",
"20\" Alloy Wheels — Satin Silver",
"16\" Chrome Replica Wheels — Gloss Black",
"Motorcycle Frame — Flat Black",
"Motorcycle Swingarm & Forks — Gloss Black",
"Exhaust Headers — High-Temp Flat Black",
"Intake Manifold — Wrinkle Red",
"Valve Covers — Gloss Red",
"Brake Calipers — Gloss Yellow" },
1 => new[] {
"Steel Shelving Units (10-shelf set)",
"Industrial Equipment Frame",
"Machine Guard Panels",
"Conveyor Frame Sections",
"Heavy Equipment Brackets",
"Pump Housing Assembly",
"Control Panel Enclosure",
"Storage Rack System",
"Scissor Lift Platform",
"Compressor Tank" },
2 => new[] {
"Aluminum Window Frames (set of 8)",
"Steel Handrail System — 40 ft",
"Wrought Iron Fence Panels (6-panel set)",
"Entry Gate — Custom Design",
"Structural Steel Columns (set of 4)",
"Balcony Railing — Satin Black",
"Steel Door Frames (3 units)",
"Architectural Steel Beams",
"Decorative Ironwork — Stair Baluster",
"Aluminum Storefront Frame" },
3 => new[] {
"Commercial Gym Equipment Frame",
"Weight Rack & Benches",
"Outdoor Playground Equipment Parts",
"Bicycle Frame — Gloss Blue",
"BMX Frame Set — Candy Red" },
4 => new[] {
"Boat Trailer Frame — Marine Grade",
"Aluminum Dock Cleats & Hardware",
"Outboard Motor Bracket",
"Marine Fuel Tank Brackets" },
5 => new[] {
"Restaurant Chair Frames (set of 20)",
"Steel Dining Table Bases (set of 8)",
"Patio Furniture Set — 6 Pieces",
"Café Chairs — Hammered Bronze (12-pc)",
"Commercial Bar Stools (set of 10)" },
_ => new[] {
"Custom Steel Parts — Batch Order",
"Agricultural Equipment Panels",
"Traffic Sign Frames (set of 15)",
"Utility Trailer Hitch Assembly",
"Solar Panel Mounting Brackets" }
};
// Returns finish color, prep flags, estimated minutes, and surface area for item index i.
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table.
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch
{
0 => ("Matte Black", true, false, 45, 12.0m),
1 => ("Gloss White", false, false, 30, 8.5m),
2 => ("Satin Silver", true, true, 60, 15.0m),
3 => ("Candy Red", false, true, 35, 9.0m),
4 => ("Textured Gray", true, false, 50, 18.0m),
5 => ("Gloss Black", true, false, 40, 11.0m),
6 => ("Hammered Bronze", false, false, 55, 20.0m),
7 => ("Satin Graphite", true, true, 65, 25.0m),
_ => ("Flat Black", true, false, 35, 10.0m)
};
// Maps quote index to a status code.
// APPROVED is the majority (10/20) to give SeedJobsAsync enough approved quotes to link jobs to.
// ── Status distribution: 3 Draft, 6 Sent, 16 Approved, 5 Rejected, 5 Expired ──
// APPROVED is majority so SeedJobsAsync has enough linked quotes.
static string StatusFor(int i) => i switch
{
< 2 => "DRAFT",
< 5 => "SENT",
< 15 => "APPROVED",
< 18 => "REJECTED",
< 3 => "DRAFT",
< 9 => "SENT",
< 25 => "APPROVED",
< 30 => "REJECTED",
_ => "EXPIRED"
};
var quotes = new List<Quote>();
// ── Item catalogue — 15 common powder coating jobs ──────────────────────
// Index by (i * 3 + j) % 15 so variety cycles independently of quote index.
static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
(i % 15) switch
{
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45, 12.0m),
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30, 8.5m),
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60, 15.0m),
3 => ("Motorcycle Frame", "Matte Black", true, false, 90, 14.0m),
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55, 18.0m),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35, 20.0m),
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50, 22.0m),
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m),
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180, 35.0m),
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35, 4.0m),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60, 30.0m),
11 => ("Bicycle Frame", "Candy Red", true, true, 60, 6.5m),
12 => ("Compressor Tank", "Safety Orange", true, false, 45, 10.0m),
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50, 24.0m),
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m)
};
for (int i = 0; i < 20; i++)
var quotes = new List<Quote>();
const int Total = 35;
for (int i = 0; i < Total; i++)
{
var customer = customers[i % customers.Count];
// Cycle through all customers; top 10 (commercial) appear multiple times
var customer = allCustomers[i % allCustomers.Count];
var statusCode = StatusFor(i);
// Spread creation dates over the past 120180 days (4-6 months); older first
var daysAgo = 180 - (int)(i * 9.0);
var quoteDate = now.AddDays(-daysAgo);
// Spread dates over 30180 days ago; newer quotes have lower index
var daysAgo = 180 - (int)(i * 4.5);
var quoteDate = now.AddDays(-daysAgo);
var expireDate = quoteDate.AddDays(30);
var category = i % 7;
var descs = ItemDescs(category);
var itemCount = 1 + (i % 3);
var items = new List<QuoteItem>();
for (int j = 0; j < itemCount; j++)
{
var desc = descs[(i + j) % descs.Length];
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
var qty = 1 + (j % 4);
// Unit price scales with surface area and adds a modest multiplier per customer tier
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
var (desc, color, sand, mask, mins, sqft) = ItemSpec(i * 3 + j);
var qty = 1 + (j % 4);
var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 6) * 5.0m, 2);
items.Add(new QuoteItem
{
Description = desc,
Quantity = qty,
SurfaceAreaSqFt = sqft * qty,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
Description = desc,
Quantity = qty,
SurfaceAreaSqFt = sqft * qty,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
RequiresSandblasting = sand,
RequiresMasking = mask,
EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null,
CompanyId = company.Id,
CreatedAt = quoteDate
RequiresMasking = mask,
EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
CompanyId = company.Id,
CreatedAt = quoteDate
});
}
var subtotal = items.Sum(it => it.TotalPrice);
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee;
var subtotal = items.Sum(it => it.TotalPrice);
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee;
var quote = new Quote
quotes.Add(new Quote
{
QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial,
IsRushJob = i % 12 == 0,
QuoteDate = quoteDate,
ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
ItemsSubtotal = subtotal,
SubTotal = subtotal,
DiscountPercent = discountPct,
DiscountAmount = discountAmt,
TaxPercent = taxPct,
TaxAmount = taxAmt,
RushFee = rushFee,
Total = total,
Description = $"Powder coating services — {descs[i % descs.Length].Split('')[0].Trim()}",
Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 7 == 0 ? "Customer requested color sample before full run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null,
RequiresDeposit = i % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m,
QuoteItems = items,
CompanyId = company.Id,
CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
};
QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial,
IsRushJob = i % 10 == 0,
QuoteDate = quoteDate,
ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
ItemsSubtotal = subtotal,
SubTotal = subtotal,
DiscountPercent = discountPct,
DiscountAmount = discountAmt,
TaxPercent = taxPct,
TaxAmount = taxAmt,
RushFee = rushFee,
Total = total,
Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 8 == 0 ? "Customer requested color sample before full production run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = customer.IsCommercial && i % 2 == 0 ? $"PO-{30000 + i}" : null,
RequiresDeposit = i % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m,
QuoteItems = items,
CompanyId = company.Id,
CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
});
quotes.Add(quote);
seq++;
}
@@ -12,14 +12,17 @@ public partial class SeedDataService
/// </summary>
private static readonly string[] SeededCustomerEmails =
[
"john.smith@acmemfg.com", "sjohnson@precisionauto.com", "rtaylor@classicwheels.com",
"cbrown@motorsportscustom.com", "janderson@indfurniture.com", "kgarcia@commercialhvac.com",
"swhite@agequipment.com", "mchen@urbanrailings.com", "tmiller@heritagemetal.com",
"lmartinez@fitequip.com", "nmartinez@playgroundusa.com", "pwilson@marineequip.com",
"blee@officesystems.com", "dwilliams@metrota.gov", "adavis@greenenergy.com",
"jthompson@email.com", "mharris@email.com", "wclark@email.com", "elewis@email.com",
"rwalker@email.com", "bhall@email.com", "jallen@email.com", "syoung@email.com",
"cking@email.com", "lwright@email.com", "gnelson@email.com", "carol.evans@email.com"
// Commercial — NC Triangle area
"matt@carolinafab.com", "ctanner@apexmotorsports.com", "jpruitt@triangleoffroad.com",
"bsmith@smithwelding.com", "kmorales@raleigharchitectural.com", "tgreco@eastcoastpw.com",
"dshaw@piedmontmetalworks.com", "lpatel@caryindustrial.com", "rblake@durhamtech.com",
"mcoleman@wakecountyfleet.gov",
// Individual residential
"jdavis@email.com", "sjenkins@email.com", "mthompson@email.com", "rmiller@email.com",
"jclark@email.com", "dwilson@email.com", "landerson@email.com", "tharris@email.com",
"kwhite@email.com", "jtaylor@email.com", "mbrown@email.com", "clee@email.com",
"agarcia@email.com", "kmartinez@email.com", "nrodriguez@email.com",
"bhall@email.com", "pyoung@email.com"
];
/// <summary>
@@ -52,9 +55,11 @@ public partial class SeedDataService
/// </summary>
private static readonly string[] SeededInventorySkuSuffixes =
[
"-PWD-BLK-001", "-PWD-WHT-001", "-PWD-RED-001", "-PWD-BLU-001",
"-PWD-GRY-001", "-PWD-YEL-001", "-PWD-ORG-001", "-PWD-GRN-001",
"-CLN-001", "-MSK-001"
// 6 powders
"-PWD-GBK-001", "-PWD-MBK-001", "-PWD-CHR-001", "-PWD-CRD-001",
"-PWD-SWH-001", "-PWD-IPU-001",
// 5 consumables
"-MSK-001", "-PLG-001", "-HKS-001", "-ACT-001", "-BLM-001"
];
/// <summary>
@@ -742,325 +742,94 @@ public partial class SeedDataService : ISeedDataService
}
/// <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.
/// Seeds 11 inventory items (6 powder colours + 5 consumables) for the company.
/// Two powders are intentionally below reorder point (low-stock alert) and one
/// consumable is at zero (out-of-stock), matching the demo company spec.
/// </summary>
/// <remarks>
/// 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.
/// Powders are the six colours featured in the demo company's jobs and quotes:
/// Gloss Black, Matte Black, Super Chrome (low), Candy Red (low), Signal White,
/// Illusion Purple. Consumables are the five shop supplies shown in tutorials:
/// Masking Tape, Silicone Plugs (out-of-stock), Hanging Hooks, Acetone, Blast Media.
///
/// SKUs are prefixed with 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.
/// SKUs are prefixed with <see cref="Company.CompanyCode"/> to guarantee uniqueness
/// across tenants in a shared database (e.g., DEMO-PWD-GBK-001).
/// </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.");
}
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode.");
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");
var powderCat = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
var cleanerCat = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
var maskingCat = categories.FirstOrDefault(c => c.CategoryCode == "MASKING");
var abrasiveCat = categories.FirstOrDefault(c => c.CategoryCode == "ABRASIVE");
var consumeCat = categories.FirstOrDefault(c => c.CategoryCode == "CONSUMABLE");
// Use company code prefix to ensure unique SKUs across companies
// ── Helper: powder item ───────────────────────────────────────────────
InventoryItem Pwd(string sku, string name, string color, string ral, string finish,
string mfr, string mfrPn, int qty, int reorder, int reorderQty, decimal cost) =>
new InventoryItem
{
SKU = $"{skuPrefix}-{sku}", Name = name, Description = $"{finish} {color} powder coating",
Category = "Powder", InventoryCategoryId = powderCat?.Id,
ColorName = color, ColorCode = ral, Finish = finish,
Manufacturer = mfr, ManufacturerPartNumber = mfrPn,
QuantityOnHand = qty, UnitOfMeasure = "lbs",
ReorderPoint = reorder, ReorderQuantity = reorderQty,
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
LastPurchaseDate = DateTime.UtcNow.AddDays(-30),
CoverageSqFtPerLb = 30m, TransferEfficiency = 65m,
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
};
// ── Helper: supply/consumable item ────────────────────────────────────
InventoryItem Supply(string sku, string name, string desc, string cat,
int? catId, string uom, int qty, int reorder, int reorderQty, decimal cost) =>
new InventoryItem
{
SKU = $"{skuPrefix}-{sku}", Name = name, Description = desc,
Category = cat, InventoryCategoryId = catId,
QuantityOnHand = qty, UnitOfMeasure = uom,
ReorderPoint = reorder, ReorderQuantity = reorderQty,
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
};
// ── 6 Powders (2 low-stock, 0 out-of-stock) ──────────────────────────
// Super Chrome (40 lbs) and Candy Red (25 lbs) are below reorder point
// so the dashboard low-stock alert card is populated on first load.
var inventoryItems = new List<InventoryItem>
{
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
}
Pwd("PWD-GBK-001", "Gloss Black", "Gloss Black", "RAL 9005", "Gloss", "Prismatic Powders", "PP-GBK-001", 300, 80, 200, 4.50m),
Pwd("PWD-MBK-001", "Matte Black", "Matte Black", "RAL 9005", "Matte", "Prismatic Powders", "PP-MBK-001", 500, 100, 250, 4.50m),
Pwd("PWD-CHR-001", "Super Chrome", "Super Chrome", "RAL 9006", "Chrome", "Columbia Coatings", "CC-CHR-001", 40, 100, 150, 8.75m), // LOW STOCK
Pwd("PWD-CRD-001", "Candy Red", "Candy Red", "RAL 3028", "Candy", "Prismatic Powders", "PP-CRD-001", 25, 50, 100, 6.50m), // LOW STOCK
Pwd("PWD-SWH-001", "Signal White", "Signal White", "RAL 9003", "Gloss", "Columbia Coatings", "CC-SWH-001", 400, 80, 200, 4.25m),
Pwd("PWD-IPU-001", "Illusion Purple","Illusion Purple","RAL 4005", "Metallic", "Prismatic Powders", "PP-IPU-001", 150, 60, 120, 7.25m),
// ── 5 Consumables (1 out-of-stock) ───────────────────────────────
// Silicone Plugs at qty=0 so the dashboard shows one out-of-stock item.
Supply("MSK-001", "High-Temp Masking Tape", "2-inch heat-resistant masking tape", "Masking Supplies", maskingCat?.Id, "rolls", 80, 30, 100, 8.75m),
Supply("PLG-001", "Silicone Plugs Assorted", "Assorted silicone masking plugs (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 0, 50, 100, 14.50m), // OUT OF STOCK
Supply("HKS-001", "Powder Coating Hooks", "Steel hanging hooks for racking parts", "Consumables", consumeCat?.Id, "count", 200, 50, 200, 0.35m),
Supply("ACT-001", "Acetone Degreaser", "Industrial acetone for pre-coating degreasing", "Cleaner", cleanerCat?.Id, "gallons", 20, 5, 25, 18.00m),
Supply("BLM-001", "Aluminum Oxide Blast Media","120-grit aluminum oxide blasting media", "Abrasive Media", abrasiveCat?.Id, "lbs", 250, 100, 250, 1.85m),
};
// Add inventory items one at a time to handle duplicates gracefully
@@ -1242,11 +1011,11 @@ public partial class SeedDataService : ISeedDataService
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 },
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 = "Harbor Freight Tools", ContactName = "Purchasing", Email = "purchasing@harborfreight.com", Phone = "800-444-3353", Website = "https://www.harborfreight.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Grainger Industrial Supply",ContactName = "Account Rep", Email = "accounts@grainger.com", Phone = "800-472-4643", Website = "https://www.grainger.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Local Industrial Supply", ContactName = "Sales Team", Email = "sales@localindustrialsupply.com", Phone = "(919) 555-0100", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
};
await _context.Set<Vendor>().AddRangeAsync(vendors);