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 public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for /// Seeds 27 realistic customers for the demo company: 10 commercial accounts and
/// the given company, spanning automotive, industrial, architectural, fitness, marine, /// 17 individual/non-commercial customers, all anchored to the Raleigh-Durham NC area.
/// furniture, government, and specialty verticals.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already /// The NC geographic identity (Carolina Fabrication, Apex Motorsports, Triangle Offroad,
/// exist for this company, preventing duplicate customer sets on repeated seed runs. /// Raleigh Architectural Metals, etc.) gives tutorial recordings and marketing screenshots
/// a coherent sense of place rather than a scatter of US cities.
/// </para> /// </para>
/// <para> /// <para>
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that /// Revenue is deliberately top-heavy: Carolina Fabrication (Platinum) and Apex Motorsports
/// a duplicate-email collision on any single record is caught and converted to a warning /// (Gold) carry the majority of simulated revenue so the Revenue by Customer report shows
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent /// a realistic Pareto distribution from day one.
/// the DbContext change-tracker from retrying the failed insert on the next
/// <c>SaveChangesAsync</c> call.
/// </para> /// </para>
/// <para> /// <para>
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's /// Wake County Fleet Services is seeded as tax-exempt to demonstrate the tax-exempt
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier, /// workflow (0% tax on quotes and invoices, ★ marker in dropdowns).
/// rather than throwing.
/// </para> /// </para>
/// <para> /// <para>
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>, /// Each customer is inserted individually so a duplicate-email collision on any single
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with /// record is converted to a warning rather than aborting the entire batch.
/// <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.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed customers for.</param> /// <param name="company">The tenant company to seed customers for.</param>
/// <returns> /// <returns>
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number /// A tuple of (seededCount, warnings) where warnings list any skipped customers.
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
/// (e.g. because the email already existed).
/// </returns> /// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company) private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
{ {
@@ -52,7 +40,6 @@ public partial class SeedDataService
int skippedCount = 0; int skippedCount = 0;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Early exit — same pattern as all other seeders
var existingCount = await _context.Set<Customer>() var existingCount = await _context.Set<Customer>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted); .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 goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum"); var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
// ── Local helpers keep each customer to 2-3 lines ────────────────────── // ── Local helpers ──────────────────────────────────────────────────────
//
// 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.
Customer Comm(string co, string fn, string ln, string em, string ph, 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 city, string st, string zip, string terms, decimal credit, decimal bal,
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) => 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) 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, Customer Indiv(string fn, string ln, string em, string ph,
string city, string st, string zip, string notes, int months) => string city, string st, string zip, string notes, int months) =>
new Customer new Customer
@@ -104,50 +85,45 @@ public partial class SeedDataService
var customers = new List<Customer> var customers = new List<Customer>
{ {
// ─── Commercial Customers (15) ──────────────────────────────────── // ─── Commercial Customers (10) — NC / Triangle Area ────────────────
// Auto & Motorsports // Top-revenue accounts — drive the Revenue by Customer report story
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("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("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("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("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("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("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), 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 // Mid-tier commercial
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("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("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("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("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), 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 // Specialty accounts
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("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("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), 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 // ─── Individual / Non-Commercial Customers (17) ──────────────────
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),
// Commercial & Energy/Government Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs", 6),
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), Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts", 4),
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), Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers", 7),
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), 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),
// ─── Individual / Non-Commercial Customers (12) ─────────────────── 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("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6), Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats", 5),
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4), Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC","27526", "Antique fireplace grate and hardware restoration", 3),
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7), Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build", 6),
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3), Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC","27540", "Outdoor furniture set, 6 chairs and table", 2),
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5), Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red", 3),
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2), Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black", 4),
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8), Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel", 2),
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home decor projects", 1), Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate", 1),
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5), Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly", 3),
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3), Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black", 2),
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),
}; };
// Add customers one at a time to handle duplicates gracefully
foreach (var customer in customers) foreach (var customer in customers)
{ {
try try
@@ -161,7 +137,7 @@ public partial class SeedDataService
{ {
skippedCount++; skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}"; 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; continue;
} }
@@ -173,7 +149,7 @@ public partial class SeedDataService
{ {
skippedCount++; skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}"; 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) if (_context.Entry(customer).State != EntityState.Detached)
_context.Entry(customer).State = EntityState.Detached; _context.Entry(customer).State = EntityState.Detached;
} }
@@ -7,41 +7,31 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses, /// Seeds 50 powder coating jobs distributed across all 16 statuses, with deliberate
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs. /// 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> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <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>
/// <para> /// <para>
/// The method depends on job-status and job-priority lookup rows (populated earlier in the /// All 16 statuses are covered: the 16 statuses cycle through the first 16 jobs in
/// seed sequence), and on at least one customer record. It returns 0 if any of these /// sequence, guaranteeing every pipeline stage is populated even with only 50 total jobs.
/// dependencies are missing so the overall seed degrades gracefully.
/// </para> /// </para>
/// <para> /// <para>
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans /// Date logic groups jobs into three buckets — completed (60150 days ago), in-progress
/// existing numbers with the current month prefix and starts its sequence above the current /// (1050 days ago), and early-stage (217 days ago or future-scheduled) — to produce
/// maximum so demo jobs never collide with real jobs created in the same calendar month. /// realistic dashboard pipeline and calendar views.
/// </para> /// </para>
/// <para> /// <para>
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded /// The first approved quote available for each customer is linked to their first job
/// quotes). When a match is found the job inherits the quote's customer, description, /// when a match exists, demonstrating the quote-to-job conversion workflow.
/// 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.
/// </para> /// </para>
/// </remarks> /// </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) private async Task<int> SeedJobsAsync(Company company)
{ {
var existingCount = await _context.Set<Job>() var existingCount = await _context.Set<Job>()
@@ -73,11 +63,11 @@ public partial class SeedDataService
if (customers.Count == 0) if (customers.Count == 0)
return 0; return 0;
// Grab approved quotes to link to jobs
var approvedQuotes = await _context.Set<Quote>() var approvedQuotes = await _context.Set<Quote>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED") .Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
.OrderBy(q => q.Id) .OrderBy(q => q.CustomerId)
.ThenBy(q => q.Id)
.ToListAsync(); .ToListAsync();
var shopUsers = await _context.Set<ApplicationUser>() var shopUsers = await _context.Set<ApplicationUser>()
@@ -86,7 +76,6 @@ public partial class SeedDataService
.ToListAsync(); .ToListAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var prefix = $"JOB-{now:yy}{now.Month:D2}-"; var prefix = $"JOB-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Job>() var existing = await _context.Set<Job>()
.IgnoreQueryFilters() .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; if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1; var seq = maxNum + 1;
// ── Per-customer job counts (27 customers, ~32 total jobs) ───────── // ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
// Varied 0-5 jobs per customer; the global jobIdx cycles all 16 statuses // Indices match the customer order seeded in SeedCustomersAsync:
// so every status is visible without requiring a large fixed pool. // 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
static int JobsFor(int ci) => new[] // 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
{ 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]; // 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 // All 16 statuses cycle globally so every pipeline stage is visible.
// so the full pipeline is represented even with fewer total records.
string[] allStatuses = string[] allStatuses =
[ [
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
@@ -113,12 +132,9 @@ public partial class SeedDataService
"QUALITY_CHECK", "COMPLETED", "READY_FOR_PICKUP", "DELIVERED", "QUALITY_CHECK", "COMPLETED", "READY_FOR_PICKUP", "DELIVERED",
"ON_HOLD", "CANCELLED" "ON_HOLD", "CANCELLED"
]; ];
string StatusFor(int jobIdx) => allStatuses[jobIdx % allStatuses.Length]; string StatusFor(int jobIdx) => allStatuses[jobIdx % allStatuses.Length];
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally // Priority distribution weighted toward the interesting end for demo visibility.
// over-represented (4 of 10) relative to production averages so the priority colour
// badges and rush-fee logic are clearly visible in demo data.
static string PriorityFor(int i) => (i % 10) switch static string PriorityFor(int i) => (i % 10) switch
{ {
0 => "RUSH", 0 => "RUSH",
@@ -127,67 +143,68 @@ public partial class SeedDataService
3 => "URGENT", 3 => "URGENT",
4 => "HIGH", 4 => "HIGH",
5 => "HIGH", 5 => "HIGH",
6 => "HIGH",
_ => "NORMAL" _ => "NORMAL"
}; };
// Returns description, finish color, prep flags, and estimated minutes for a job item. // Job item descriptions and specs — 15-item pool cycling via (jobIdx*3 + itemIdx) % 15.
// 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.
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) => static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
((i * 3 + j) % 15) switch ((i * 3 + j) % 15) switch
{ {
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45), 0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30), 1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40), 2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90), 3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55), 4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35), 5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50), 6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120), 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180), 8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35), 9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60), 10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60), 11 => ("Bicycle Frame", "Candy Red", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45), 12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50), 13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40) _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
}; };
var jobs = new List<Job>(); var jobs = new List<Job>();
var quoteIdx = 0; 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++) for (int ci = 0; ci < customers.Count; ci++)
{ {
var customer = customers[ci]; var customer = customers[ci];
var numJobs = JobsFor(ci); var (numJobs, minVal, maxVal) = CustomerProfile(ci);
for (int j = 0; j < numJobs; j++, jobIdx++, seq++) for (int j = 0; j < numJobs; j++, jobIdx++, seq++)
{ {
var statusCode = StatusFor(jobIdx); var statusCode = StatusFor(jobIdx);
var priorityCode = PriorityFor(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; Quote? linkedQuote = null;
if (quoteIdx < approvedQuotes.Count) for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
{ {
var candidate = approvedQuotes[quoteIdx]; if (approvedQuotes[qi].CustomerId == customer.Id)
if (candidate.CustomerId == customer.Id || quoteIdx % 3 == 0)
{ {
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++; quoteIdx++;
break;
} }
} }
// Date logic — creation spread over 4-6 months // Date logic
// Older jobs for completed statuses, recent for in-progress, future-scheduled for early statuses
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED"; var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK"; or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
// Spread creation over 30-150 days ago (1-5 months), older jobs for completed statuses
int daysAgo = isCompleted ? 60 + (jobIdx % 90) int daysAgo = isCompleted ? 60 + (jobIdx % 90)
: isInProgress ? 10 + (jobIdx % 40) : isInProgress ? 10 + (jobIdx % 40)
: 2 + (jobIdx % 15); : 2 + (jobIdx % 15);
@@ -197,11 +214,12 @@ public partial class SeedDataService
: now.AddDays(3 + (jobIdx % 12)); : now.AddDays(3 + (jobIdx % 12));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7; var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays); var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = !isEarly ? scheduledDate : (DateTime?)null; var startedDate = !isCompleted && !isInProgress ? (DateTime?)null : scheduledDate;
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null; var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
var assignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null; // 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 itemCount = 1 + (jobIdx % 3);
var items = new List<JobItem>(); var items = new List<JobItem>();
@@ -211,7 +229,7 @@ public partial class SeedDataService
var qty = 1 + (k % 3); var qty = 1 + (k % 3);
var unitPrice = linkedQuote != null && k == 0 var unitPrice = linkedQuote != null && k == 0
? Math.Round(linkedQuote.Total / itemCount, 2) ? 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 items.Add(new JobItem
{ {
@@ -238,7 +256,7 @@ public partial class SeedDataService
JobNumber = $"{prefix}{seq:D4}", JobNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id, CustomerId = customer.Id,
QuoteId = linkedQuote?.Id, QuoteId = linkedQuote?.Id,
AssignedUserId = assignedUserId, AssignedUserId = shopUsers.Count > 0 ? shopUsers[jobIdx % shopUsers.Count].Id : null,
Description = linkedQuote?.Description Description = linkedQuote?.Description
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}", ?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
JobStatusId = jobStatuses[statusCode], JobStatusId = jobStatuses[statusCode],
@@ -250,12 +268,12 @@ public partial class SeedDataService
QuotedPrice = quotedPrice, QuotedPrice = quotedPrice,
FinalPrice = finalPrice, FinalPrice = finalPrice,
IsRushJob = priorityCode == "RUSH", 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." : SpecialInstructions = jobIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
jobIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null, 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, InternalNotes = jobIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
RequiresCustomerApproval = jobIdx % 5 == 0, RequiresCustomerApproval = jobIdx % 5 == 0,
IsCustomerApproved = jobIdx % 5 != 0 || !isEarly, IsCustomerApproved = jobIdx % 5 != 0 || !isInProgress,
JobItems = items, JobItems = items,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = createdDate CreatedAt = createdDate
@@ -6,39 +6,35 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 75 realistic powder coating quotes spread across seven item categories /// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc) /// Rejected, and Expired — with a deliberate majority of Approved quotes so that
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35), /// SeedJobsAsync has enough approved records to link jobs to.
/// Rejected (10), and Expired (10).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for /// All five quote statuses are present so the Quotes Index view demonstrates the full
/// this company, preventing duplicate quote sets on repeated seed runs. /// 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>
/// <para> /// <para>
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans /// Quotes are distributed across both commercial and individual customers, matching the
/// existing numbers with the current month prefix and starts its sequence above the /// real-world mix where most quotes come from commercial accounts but individuals do
/// current maximum so seeded quotes never collide with real quotes created in the /// occasionally request quotes for larger projects.
/// same month.
/// </para> /// </para>
/// <para> /// <para>
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through /// Item descriptions, colours, and specs are drawn from the same 15-item pool used by
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost /// the job seeder so that tutorial screenshots of quotes and linked jobs look consistent.
/// config that may not yet be populated when seed runs.
/// </para> /// </para>
/// <para> /// <para>
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production /// Pricing is deliberately simple (sqft × rate + variance) rather than running through
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote. /// IPricingCalculationService — this avoids a dependency on operating cost config that
/// may not yet be populated when seed runs.
/// </para> /// </para>
/// <para> /// <para>
/// The method requires that customers and quote-status lookup rows already exist for the /// Dates spread over 150180 days back (56 months) so the historical charts on the
/// company; it returns 0 if either dependency is missing so that the overall seed /// dashboard and reports show a meaningful activity curve.
/// operation degrades gracefully rather than throwing.
/// </para> /// </para>
/// </remarks> /// </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) private async Task<int> SeedQuotesAsync(Company company)
{ {
var existingCount = await _context.Set<Quote>() var existingCount = await _context.Set<Quote>()
@@ -56,14 +52,15 @@ public partial class SeedDataService
if (quoteStatuses.Count == 0) if (quoteStatuses.Count == 0)
return 0; return 0;
// Load all commercial customers // Load all customers — commercial first, then individual
var customers = await _context.Set<Customer>() var allCustomers = await _context.Set<Customer>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted) .Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.OrderBy(c => c.Id) .OrderByDescending(c => c.IsCommercial)
.ThenBy(c => c.Id)
.ToListAsync(); .ToListAsync();
if (customers.Count == 0) if (allCustomers.Count == 0)
return 0; return 0;
var preparedByUser = await _userManager.Users var preparedByUser = await _userManager.Users
@@ -72,7 +69,6 @@ public partial class SeedDataService
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Avoid duplicate quote numbers
var prefix = $"QT-{now:yy}{now.Month:D2}-"; var prefix = $"QT-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Quote>() var existing = await _context.Set<Quote>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
@@ -84,122 +80,62 @@ public partial class SeedDataService
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x; if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1; var seq = maxNum + 1;
// ── Data arrays for varied, realistic content ───────────────────────── // ── Status distribution: 3 Draft, 6 Sent, 16 Approved, 5 Rejected, 5 Expired ──
// APPROVED is majority so SeedJobsAsync has enough linked quotes.
// 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.
static string StatusFor(int i) => i switch static string StatusFor(int i) => i switch
{ {
< 2 => "DRAFT", < 3 => "DRAFT",
< 5 => "SENT", < 9 => "SENT",
< 15 => "APPROVED", < 25 => "APPROVED",
< 18 => "REJECTED", < 30 => "REJECTED",
_ => "EXPIRED" _ => "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.
for (int i = 0; i < 20; i++) static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
(i % 15) switch
{ {
var customer = customers[i % customers.Count]; 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)
};
var quotes = new List<Quote>();
const int Total = 35;
for (int i = 0; i < Total; i++)
{
// Cycle through all customers; top 10 (commercial) appear multiple times
var customer = allCustomers[i % allCustomers.Count];
var statusCode = StatusFor(i); var statusCode = StatusFor(i);
// Spread creation dates over the past 120180 days (4-6 months); older first // Spread dates over 30180 days ago; newer quotes have lower index
var daysAgo = 180 - (int)(i * 9.0); var daysAgo = 180 - (int)(i * 4.5);
var quoteDate = now.AddDays(-daysAgo); var quoteDate = now.AddDays(-daysAgo);
var expireDate = quoteDate.AddDays(30); var expireDate = quoteDate.AddDays(30);
var category = i % 7;
var descs = ItemDescs(category);
var itemCount = 1 + (i % 3); var itemCount = 1 + (i % 3);
var items = new List<QuoteItem>(); var items = new List<QuoteItem>();
for (int j = 0; j < itemCount; j++) for (int j = 0; j < itemCount; j++)
{ {
var desc = descs[(i + j) % descs.Length]; var (desc, color, sand, mask, mins, sqft) = ItemSpec(i * 3 + j);
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
var qty = 1 + (j % 4); 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);
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m); var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 6) * 5.0m, 2);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
items.Add(new QuoteItem items.Add(new QuoteItem
{ {
@@ -212,7 +148,7 @@ public partial class SeedDataService
RequiresMasking = mask, RequiresMasking = mask,
EstimatedMinutes = mins, EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" }, Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null, Notes = j == 0 && i % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = quoteDate CreatedAt = quoteDate
}); });
@@ -224,17 +160,17 @@ public partial class SeedDataService
var afterDiscount = subtotal - discountAmt; var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m; var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2); var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m; var rushFee = i % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee; var total = afterDiscount + taxAmt + rushFee;
var quote = new Quote quotes.Add(new Quote
{ {
QuoteNumber = $"{prefix}{seq:D4}", QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id, CustomerId = customer.Id,
PreparedById = preparedByUser?.Id, PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode], QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial, IsCommercial = customer.IsCommercial,
IsRushJob = i % 12 == 0, IsRushJob = i % 10 == 0,
QuoteDate = quoteDate, QuoteDate = quoteDate,
ExpirationDate = expireDate, ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null, SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
@@ -247,20 +183,19 @@ public partial class SeedDataService
TaxAmount = taxAmt, TaxAmount = taxAmt,
RushFee = rushFee, RushFee = rushFee,
Total = total, Total = total,
Description = $"Powder coating services — {descs[i % descs.Length].Split('')[0].Trim()}", Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
Terms = customer.PaymentTerms ?? "Net 30", Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 7 == 0 ? "Customer requested color sample before full run." : Notes = i % 8 == 0 ? "Customer requested color sample before full production run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null, i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null, CustomerPO = customer.IsCommercial && i % 2 == 0 ? $"PO-{30000 + i}" : null,
RequiresDeposit = i % 4 == 0, RequiresDeposit = i % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m, DepositPercent = i % 4 == 0 ? 50m : 0m,
QuoteItems = items, QuoteItems = items,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = quoteDate, CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1) UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
}; });
quotes.Add(quote);
seq++; seq++;
} }
@@ -12,14 +12,17 @@ public partial class SeedDataService
/// </summary> /// </summary>
private static readonly string[] SeededCustomerEmails = private static readonly string[] SeededCustomerEmails =
[ [
"john.smith@acmemfg.com", "sjohnson@precisionauto.com", "rtaylor@classicwheels.com", // Commercial — NC Triangle area
"cbrown@motorsportscustom.com", "janderson@indfurniture.com", "kgarcia@commercialhvac.com", "matt@carolinafab.com", "ctanner@apexmotorsports.com", "jpruitt@triangleoffroad.com",
"swhite@agequipment.com", "mchen@urbanrailings.com", "tmiller@heritagemetal.com", "bsmith@smithwelding.com", "kmorales@raleigharchitectural.com", "tgreco@eastcoastpw.com",
"lmartinez@fitequip.com", "nmartinez@playgroundusa.com", "pwilson@marineequip.com", "dshaw@piedmontmetalworks.com", "lpatel@caryindustrial.com", "rblake@durhamtech.com",
"blee@officesystems.com", "dwilliams@metrota.gov", "adavis@greenenergy.com", "mcoleman@wakecountyfleet.gov",
"jthompson@email.com", "mharris@email.com", "wclark@email.com", "elewis@email.com", // Individual residential
"rwalker@email.com", "bhall@email.com", "jallen@email.com", "syoung@email.com", "jdavis@email.com", "sjenkins@email.com", "mthompson@email.com", "rmiller@email.com",
"cking@email.com", "lwright@email.com", "gnelson@email.com", "carol.evans@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> /// <summary>
@@ -52,9 +55,11 @@ public partial class SeedDataService
/// </summary> /// </summary>
private static readonly string[] SeededInventorySkuSuffixes = private static readonly string[] SeededInventorySkuSuffixes =
[ [
"-PWD-BLK-001", "-PWD-WHT-001", "-PWD-RED-001", "-PWD-BLU-001", // 6 powders
"-PWD-GRY-001", "-PWD-YEL-001", "-PWD-ORG-001", "-PWD-GRN-001", "-PWD-GBK-001", "-PWD-MBK-001", "-PWD-CHR-001", "-PWD-CRD-001",
"-CLN-001", "-MSK-001" "-PWD-SWH-001", "-PWD-IPU-001",
// 5 consumables
"-MSK-001", "-PLG-001", "-HKS-001", "-ACT-001", "-BLM-001"
]; ];
/// <summary> /// <summary>
@@ -742,325 +742,94 @@ public partial class SeedDataService : ISeedDataService
} }
/// <summary> /// <summary>
/// Seeds ten representative inventory items (eight powder colours, one cleaner, one /// Seeds 11 inventory items (6 powder colours + 5 consumables) for the company.
/// masking tape roll) for the company, linking each to the appropriate category lookup. /// 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> /// </summary>
/// <remarks> /// <remarks>
/// Returns a tuple rather than a plain int because each item is saved individually /// Powders are the six colours featured in the demo company's jobs and quotes:
/// (one <c>SaveChangesAsync</c> call per item) so that a duplicate-SKU error on one /// Gloss Black, Matte Black, Super Chrome (low), Candy Red (low), Signal White,
/// item does not roll back the entire batch. Failed items are captured as per-item /// Illusion Purple. Consumables are the five shop supplies shown in tutorials:
/// warning strings rather than aborting the seeder. /// 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 /// SKUs are prefixed with <see cref="Company.CompanyCode"/> to guarantee uniqueness
/// uniqueness across tenants in a shared database e.g., <c>DEMO-PWD-BLK-001</c>. /// across tenants in a shared database (e.g., DEMO-PWD-GBK-001).
/// 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> /// </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) private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company)
{ {
var warnings = new List<string>(); var warnings = new List<string>();
int seededCount = 0; int seededCount = 0;
// Validate company code
if (string.IsNullOrWhiteSpace(company.CompanyCode)) if (string.IsNullOrWhiteSpace(company.CompanyCode))
{ throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode.");
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode. Cannot seed inventory with unique SKUs.");
}
var skuPrefix = company.CompanyCode; var skuPrefix = company.CompanyCode;
// Get category lookups to link items properly
var categories = await _context.InventoryCategoryLookups var categories = await _context.InventoryCategoryLookups
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted) .Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.ToListAsync(); .ToListAsync();
var powderCategory = categories.FirstOrDefault(c => c.CategoryCode == "POWDER"); var powderCat = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
var cleanerCategory = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER"); var cleanerCat = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
var maskingCategory = categories.FirstOrDefault(c => c.CategoryCode == "MASKING"); 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> var inventoryItems = new List<InventoryItem>
{ {
new InventoryItem Pwd("PWD-GBK-001", "Gloss Black", "Gloss Black", "RAL 9005", "Gloss", "Prismatic Powders", "PP-GBK-001", 300, 80, 200, 4.50m),
{ Pwd("PWD-MBK-001", "Matte Black", "Matte Black", "RAL 9005", "Matte", "Prismatic Powders", "PP-MBK-001", 500, 100, 250, 4.50m),
SKU = $"{skuPrefix}-PWD-BLK-001", Pwd("PWD-CHR-001", "Super Chrome", "Super Chrome", "RAL 9006", "Chrome", "Columbia Coatings", "CC-CHR-001", 40, 100, 150, 8.75m), // LOW STOCK
Name = "Matte Black Powder", Pwd("PWD-CRD-001", "Candy Red", "Candy Red", "RAL 3028", "Candy", "Prismatic Powders", "PP-CRD-001", 25, 50, 100, 6.50m), // LOW STOCK
Description = "High-quality matte black powder coating", Pwd("PWD-SWH-001", "Signal White", "Signal White", "RAL 9003", "Gloss", "Columbia Coatings", "CC-SWH-001", 400, 80, 200, 4.25m),
Category = "Powder", Pwd("PWD-IPU-001", "Illusion Purple","Illusion Purple","RAL 4005", "Metallic", "Prismatic Powders", "PP-IPU-001", 150, 60, 120, 7.25m),
InventoryCategoryId = powderCategory?.Id,
ColorName = "Matte Black", // ── 5 Consumables (1 out-of-stock) ───────────────────────────────
ColorCode = "RAL 9005", // Silicone Plugs at qty=0 so the dashboard shows one out-of-stock item.
Finish = "Matte",
Manufacturer = "Tiger Drylac", Supply("MSK-001", "High-Temp Masking Tape", "2-inch heat-resistant masking tape", "Masking Supplies", maskingCat?.Id, "rolls", 80, 30, 100, 8.75m),
ManufacturerPartNumber = "TG-MB-001", 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
QuantityOnHand = 500, Supply("HKS-001", "Powder Coating Hooks", "Steel hanging hooks for racking parts", "Consumables", consumeCat?.Id, "count", 200, 50, 200, 0.35m),
UnitOfMeasure = "lbs", Supply("ACT-001", "Acetone Degreaser", "Industrial acetone for pre-coating degreasing", "Cleaner", cleanerCat?.Id, "gallons", 20, 5, 25, 18.00m),
ReorderPoint = 100, Supply("BLM-001", "Aluminum Oxide Blast Media","120-grit aluminum oxide blasting media", "Abrasive Media", abrasiveCat?.Id, "lbs", 250, 100, 250, 1.85m),
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 // Add inventory items one at a time to handle duplicates gracefully
@@ -1244,9 +1013,9 @@ public partial class SeedDataService : ISeedDataService
{ {
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 = "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 = "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 = "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 = "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 = "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 = "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 = "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); await _context.Set<Vendor>().AddRangeAsync(vendors);