From 584664e7c8ec132443afe9c0f400aa3924ce2269 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 10 Jun 2026 22:20:04 -0400 Subject: [PATCH] Demo seed Phase 2: workers, time entries, maintenance records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5 named shop workers seeded as ApplicationUser (Employee role): Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector), Tyler Green (General), Chris Mason (Lead) — @pcldemo.com fingerprint domain - Job time entries seeded for all in-progress and completed jobs; Worker Productivity report will have data from day one - Maintenance history seeded per equipment: 2 completed records + 1 upcoming scheduled + 1 overdue record on Pressure Pot for overdue alert demo - Equipment renamed to spec names: Main Batch Oven, Small Batch Oven, Powder Coating Booth, Blast Cabinet, Pressure Pot Blaster, Air Compressor, Wash Station, Forklift (replaced Overhead Conveyor which wasn't in spec) - RemoveSeedDataOptions.Workers added; Remove.cs cleans up workers + time entries on Demo Reset; SeedDataController resets workers in ResetDemoCompany Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/ISeedDataService.cs | 1 + .../Services/SeedDataService.Equipment.cs | 34 ++-- .../Services/SeedDataService.Maintenance.cs | 146 +++++++++++++++ .../Services/SeedDataService.Remove.cs | 24 +++ .../Services/SeedDataService.Workers.cs | 172 ++++++++++++++++++ .../Services/SeedDataService.cs | 4 + .../Controllers/SeedDataController.cs | 1 + 7 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs create mode 100644 src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs diff --git a/src/PowderCoating.Application/Interfaces/ISeedDataService.cs b/src/PowderCoating.Application/Interfaces/ISeedDataService.cs index d108a62..d60169a 100644 --- a/src/PowderCoating.Application/Interfaces/ISeedDataService.cs +++ b/src/PowderCoating.Application/Interfaces/ISeedDataService.cs @@ -47,6 +47,7 @@ public class RemoveSeedDataOptions public bool OperatingCosts { get; set; } public bool Bills { get; set; } public bool Expenses { get; set; } + public bool Workers { get; set; } } public class SeedDataResult diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs index 36fa79f..6fef4b5 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs @@ -57,7 +57,7 @@ public partial class SeedDataService { new Equipment { - EquipmentName = "Batch Powder Coating Oven #1", + EquipmentName = "Main Batch Oven", EquipmentNumber = $"{company.CompanyCode}-OVN-001", EquipmentType = "Oven", Manufacturer = "Reliant Finishing Systems", @@ -78,7 +78,7 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Batch Powder Coating Oven #2", + EquipmentName = "Small Batch Oven", EquipmentNumber = $"{company.CompanyCode}-OVN-002", EquipmentType = "Oven", Manufacturer = "Reliant Finishing Systems", @@ -99,7 +99,7 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Automated Powder Coating Booth #1", + EquipmentName = "Powder Coating Booth", EquipmentNumber = $"{company.CompanyCode}-BOOTH-001", EquipmentType = "Spray Booth", Manufacturer = "Nordson Corporation", @@ -120,7 +120,7 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Manual Powder Coating Booth #2", + EquipmentName = "Manual Powder Booth", EquipmentNumber = $"{company.CompanyCode}-BOOTH-002", EquipmentType = "Spray Booth", Manufacturer = "Columbia Coatings", @@ -162,7 +162,7 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Media Blast Room", + EquipmentName = "Pressure Pot Blaster", EquipmentNumber = $"{company.CompanyCode}-BLAST-002", EquipmentType = "Sandblaster", Manufacturer = "Clemco Industries", @@ -183,7 +183,7 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Rotary Screw Air Compressor", + EquipmentName = "Air Compressor", EquipmentNumber = $"{company.CompanyCode}-COMP-001", EquipmentType = "Compressor", Manufacturer = "Atlas Copco", @@ -204,28 +204,28 @@ public partial class SeedDataService }, new Equipment { - EquipmentName = "Overhead Conveyor System", - EquipmentNumber = $"{company.CompanyCode}-CONV-001", - EquipmentType = "Conveyor", - Manufacturer = "Pacline Conveyors", - Model = "PAC-500 Overhead", + EquipmentName = "Forklift", + EquipmentNumber = $"{company.CompanyCode}-FORK-001", + EquipmentType = "Forklift", + Manufacturer = "Toyota", + Model = "8FGCU25", SerialNumber = "PAC50034521", PurchaseDate = DateTime.UtcNow.AddYears(-4), - PurchasePrice = 52000m, + PurchasePrice = 28000m, WarrantyExpiration = DateTime.UtcNow.AddYears(-2), Status = EquipmentStatus.Operational, - Location = "Main Production Line", + Location = "Loading Area", RecommendedMaintenanceIntervalDays = 180, - LastMaintenanceDate = DateTime.UtcNow.AddDays(-120), - NextScheduledMaintenance = DateTime.UtcNow.AddDays(60), - Notes = "500 lb capacity overhead conveyor with power and free sections", + LastMaintenanceDate = DateTime.UtcNow.AddDays(-90), + NextScheduledMaintenance = DateTime.UtcNow.AddDays(90), + Notes = "5,000 lb capacity propane forklift — used for loading/unloading customer parts", IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow.AddYears(-4) }, new Equipment { - EquipmentName = "Parts Washer System", + EquipmentName = "Wash Station", EquipmentNumber = $"{company.CompanyCode}-WASH-001", EquipmentType = "Washer", Manufacturer = "Better Engineering", diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs new file mode 100644 index 0000000..fba9e9a --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.Infrastructure.Services; + +public partial class SeedDataService +{ + /// + /// Seeds maintenance records for all seeded equipment: historical completed records, + /// upcoming scheduled records, and one overdue record for the Pressure Pot so the + /// Equipment Maintenance report always has meaningful data. + /// + /// + /// + /// Each piece of equipment gets 2-3 completed historical maintenance records (up to + /// 12 months back) plus one upcoming scheduled record. The Pressure Pot additionally + /// has one overdue record (past due date, still Scheduled) to populate the overdue + /// indicator on the Equipment page. + /// + /// + /// Labor and parts costs are realistic for shop equipment maintenance, giving the + /// Equipment Maintenance Cost report non-trivial totals from day one. + /// + /// + /// Idempotency: bails early if any maintenance records already exist for this company's equipment. + /// + /// + private async Task SeedMaintenanceRecordsAsync(Company company) + { + var equipmentIds = await _context.Set() + .IgnoreQueryFilters() + .Where(e => e.CompanyId == company.Id && !e.IsDeleted && SeededEquipmentSerials.Contains(e.SerialNumber)) + .Select(e => e.Id) + .ToListAsync(); + + if (equipmentIds.Count == 0) return 0; + + var existingCount = await _context.Set() + .IgnoreQueryFilters() + .CountAsync(m => equipmentIds.Contains(m.EquipmentId)); + if (existingCount > 0) return 0; + + var equipment = await _context.Set() + .IgnoreQueryFilters() + .Where(e => equipmentIds.Contains(e.Id)) + .ToListAsync(); + + // Try to grab a worker user to assign as performed-by + var worker = await _userManager.Users + .Where(u => u.CompanyId == company.Id && u.IsActive) + .FirstOrDefaultAsync(); + + var now = DateTime.UtcNow; + var records = new List(); + + // Per-equipment maintenance spec: (type, daysAgoFirst, intervalDays, laborCost, partsCost, notes) + // Each equipment gets 2 completed records + 1 scheduled upcoming. + // The Pressure Pot also gets 1 overdue record. + static (string type, decimal labor, decimal parts, string desc, string work) + MaintSpec(int i) => (i % 5) switch + { + 0 => ("Preventive", 120m, 45m, "Quarterly preventive maintenance", "Inspected elements, cleaned contacts, checked gaskets"), + 1 => ("Inspection", 80m, 0m, "Monthly operational inspection", "Checked all systems, calibrated temperature probes"), + 2 => ("Repair", 200m, 185m, "Filter and seal replacement", "Replaced intake filters and worn door seals"), + 3 => ("Preventive", 140m, 60m, "Semi-annual preventive maintenance", "Lubricated moving parts, replaced wear items"), + _ => ("Inspection", 60m, 0m, "Pre-season inspection and cleaning", "Full operational test, cleaned all surfaces"), + }; + + int idx = 0; + foreach (var eq in equipment) + { + var isPressurePot = eq.SerialNumber == "CLM101223456"; // Media Blast Room / Pressure Pot + + for (int r = 0; r < 2; r++) + { + var (mtype, labor, parts, desc, work) = MaintSpec(idx + r); + var daysAgo = 180 - r * 60 - (idx % 4) * 15; + var scheduled = now.AddDays(-daysAgo); + var total = labor + parts; + + records.Add(new MaintenanceRecord + { + EquipmentId = eq.Id, + MaintenanceType = mtype, + Status = MaintenanceStatus.Completed, + Priority = MaintenancePriority.Normal, + ScheduledDate = scheduled, + CompletedDate = scheduled.AddDays(1), + PerformedById = worker?.Id, + AssignedUserId = worker?.Id, + Description = desc, + WorkPerformed = work, + LaborCost = labor, + PartsCost = parts, + TotalCost = total, + DowntimeHours = 2m + r, + CompanyId = company.Id, + CreatedAt = scheduled.AddDays(-7) + }); + } + + // One upcoming scheduled record + var upcomingDays = 15 + (idx % 30); + records.Add(new MaintenanceRecord + { + EquipmentId = eq.Id, + MaintenanceType = "Preventive", + Status = MaintenanceStatus.Scheduled, + Priority = MaintenancePriority.Normal, + ScheduledDate = now.AddDays(upcomingDays), + AssignedUserId = worker?.Id, + Description = "Scheduled preventive maintenance", + LaborCost = 0m, PartsCost = 0m, TotalCost = 0m, + CompanyId = company.Id, + CreatedAt = now.AddDays(-7) + }); + + // Overdue record for the pressure pot only + if (isPressurePot) + { + records.Add(new MaintenanceRecord + { + EquipmentId = eq.Id, + MaintenanceType = "Repair", + Status = MaintenanceStatus.Scheduled, + Priority = MaintenancePriority.High, + ScheduledDate = now.AddDays(-20), // overdue + AssignedUserId = worker?.Id, + Description = "Filter replacement — OVERDUE", + Notes = "Media filter became clogged ahead of schedule. Shop is running reduced blast capacity until repaired.", + LaborCost = 0m, PartsCost = 0m, TotalCost = 0m, + CompanyId = company.Id, + CreatedAt = now.AddDays(-30) + }); + } + + idx++; + } + + await _context.Set().AddRangeAsync(records); + await _context.SaveChangesAsync(); + + return records.Count; + } +} diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs index a5f10a8..4e14328 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs @@ -160,6 +160,10 @@ public partial class SeedDataService .Where(p => seededJobIds.Contains(p.JobId)).ToListAsync(); if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices); + var timeEntries = await _context.Set().IgnoreQueryFilters() + .Where(te => seededJobIds.Contains(te.JobId)).ToListAsync(); + if (timeEntries.Any()) _context.Set().RemoveRange(timeEntries); + var jobs = await _context.Jobs.IgnoreQueryFilters() .Where(j => seededJobIds.Contains(j.Id)).ToListAsync(); _context.Jobs.RemoveRange(jobs); @@ -397,6 +401,26 @@ public partial class SeedDataService } } + // --- Shop Workers --- + if (options.Workers) + { + var workerUsers = await _userManager.Users + .Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == companyId) + .ToListAsync(); + + if (workerUsers.Any()) + { + foreach (var wu in workerUsers) + await _userManager.DeleteAsync(wu); + totalRemoved += workerUsers.Count; + details.Add($"✓ Removed {workerUsers.Count} demo shop worker(s)"); + } + else + { + details.Add("• No demo shop workers found"); + } + } + result.ItemsSeeded = totalRemoved; result.Details = details; result.Message = totalRemoved > 0 diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs new file mode 100644 index 0000000..94bac0e --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Shared.Constants; + +namespace PowderCoating.Infrastructure.Services; + +public partial class SeedDataService +{ + /// + /// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync + /// to avoid needing a special "IsSeeded" flag on ApplicationUser. + /// + internal static readonly string[] SeededWorkerEmails = + [ + "mike.sanders@pcldemo.com", + "jake.wilson@pcldemo.com", + "sarah.brooks@pcldemo.com", + "tyler.green@pcldemo.com", + "chris.mason@pcldemo.com", + ]; + + /// + /// Seeds 5 named shop workers as ApplicationUser records for the demo company: + /// Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector), + /// Tyler Green (General Worker), and Chris Mason (Shop Lead). + /// + /// + /// Workers are ApplicationUser records with CompanyRole = ShopFloor and the + /// Employee system role. They are seeded before jobs and time entries so that + /// AssignedUserId on Job and UserId on JobTimeEntry can reference them. + /// + /// Uses a consistent email domain (@pcldemo.com) that will never conflict with + /// real user accounts, making them safe to identify and remove on Demo Reset. + /// + /// Idempotency: bails early if any of the 5 worker emails already exist. + /// + private async Task SeedShopWorkersAsync(Company company) + { + var anyExists = await _userManager.Users + .AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id); + if (anyExists) return 0; + + const string defaultPassword = "Worker123!"; + int created = 0; + + // (email, firstName, lastName, empNum, position, laborRate) + var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[] + { + ("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m), + ("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m), + ("sarah.brooks@pcldemo.com", "Sarah", "Brooks", "EMP-003", "Quality Inspector", 24.00m), + ("tyler.green@pcldemo.com", "Tyler", "Green", "EMP-004", "General Worker", 18.00m), + ("chris.mason@pcldemo.com", "Chris", "Mason", "EMP-005", "Shop Lead", 28.00m), + }; + + foreach (var (email, fn, ln, emp, pos, rate) in workers) + { + var user = await _userManager.FindByEmailAsync(email); + if (user != null) continue; + + user = new ApplicationUser + { + UserName = email, + Email = email, + FirstName = fn, + LastName = ln, + EmployeeNumber = emp, + Department = "Shop Floor", + Position = pos, + LaborCostPerHour = rate, + EmailConfirmed = true, + HireDate = DateTime.UtcNow.AddMonths(-12), + IsActive = true, + CompanyId = company.Id, + CompanyRole = AppConstants.CompanyRoles.Worker, + CanManageJobs = true, + CanViewShopFloor = true, + CreatedAt = DateTime.UtcNow.AddMonths(-12) + }; + + var result = await _userManager.CreateAsync(user, defaultPassword); + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee); + created++; + } + } + + return created; + } + + /// + /// Seeds job time entries for completed and in-progress jobs, giving the Worker + /// Productivity report meaningful data from day one. + /// + /// + /// Each completed or in-progress job receives 2–4 time entries spread across the + /// 5 demo workers, with realistic hours for each coating stage (sandblasting, + /// masking, coating, curing, inspection). The total hours roughly correlate with + /// the job's EstimatedMinutes from its first JobItem. + /// + /// Idempotency: bails early if any time entries already exist for this company's jobs. + /// + private async Task SeedJobTimeEntriesAsync(Company company) + { + var existingCount = await _context.Set() + .IgnoreQueryFilters() + .CountAsync(te => te.CompanyId == company.Id && !te.IsDeleted); + if (existingCount > 0) return 0; + + var workers = await _userManager.Users + .Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id) + .OrderBy(u => u.Email) + .ToListAsync(); + + if (workers.Count == 0) return 0; + + // Only create entries for jobs that have been worked on + var activeJobs = await _context.Set() + .IgnoreQueryFilters() + .Where(j => j.CompanyId == company.Id && !j.IsDeleted) + .Include(j => j.JobStatus) + .ToListAsync(); + + var workedJobs = activeJobs.Where(j => + j.JobStatus?.StatusCode is + "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or + "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK" or + "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" + ).ToList(); + + if (workedJobs.Count == 0) return 0; + + string[] stages = ["Sandblasting", "Masking & Prep", "Coating", "Curing", "Inspection"]; + decimal[] stageHours = [1.5m, 0.75m, 1.25m, 0.5m, 0.5m]; + + var entries = new List(); + int jobIdx = 0; + + foreach (var job in workedJobs) + { + // 2-4 entries per job cycling through stages + var entryCount = 2 + (jobIdx % 3); + var workDate = (job.StartedDate ?? job.CreatedAt).AddDays(1); + + for (int e = 0; e < entryCount; e++) + { + var worker = workers[(jobIdx + e) % workers.Count]; + var stageIdx = e % stages.Length; + + entries.Add(new JobTimeEntry + { + JobId = job.Id, + UserId = worker.Id, + UserDisplayName = worker.FullName, + WorkDate = workDate.AddDays(e), + HoursWorked = stageHours[stageIdx], + Stage = stages[stageIdx], + CompanyId = company.Id, + CreatedAt = workDate.AddDays(e) + }); + } + + jobIdx++; + } + + await _context.Set().AddRangeAsync(entries); + await _context.SaveChangesAsync(); + + return entries.Count; + } +} diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs index 9220e31..e5217de 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs @@ -411,13 +411,17 @@ public partial class SeedDataService : ISeedDataService } catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); } + // Workers must be seeded before jobs so AssignedUserId FK resolves + await RunSeeder("Shop workers", details, errors, result, () => SeedShopWorkersAsync(company)); await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company)); + await RunSeeder("Maintenance", details, errors, result, () => SeedMaintenanceRecordsAsync(company)); await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company)); await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company)); await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company)); await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company)); await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company)); await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company)); + await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company)); await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company)); diff --git a/src/PowderCoating.Web/Controllers/SeedDataController.cs b/src/PowderCoating.Web/Controllers/SeedDataController.cs index 2f16eec..0e88a5e 100644 --- a/src/PowderCoating.Web/Controllers/SeedDataController.cs +++ b/src/PowderCoating.Web/Controllers/SeedDataController.cs @@ -136,6 +136,7 @@ public class SeedDataController : Controller OperatingCosts = true, Bills = true, Expenses = true, + Workers = true, }; var removeResult = await _seedDataService.RemoveSeedDataAsync(demo.Id, removeOptions);