Demo reset + dev banner suppression for DEMO company

- DemoController: company-code-gated reset action (DEMO only, CSRF protected)
- SeedDataService.Remove: FK-safe topological pre-sweep, all deletes scoped to companyId
- SeedDataService: clock entries, extra seed data, updated customer/worker/job-status seeders
- CompanySettingsController + Index.cshtml: Reset Demo Data button for DEMO company users
- ReportsController + FinancialReportService: supporting report fixes
- _Layout.cshtml: suppress env banner when current company is DEMO (all auth paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:26:40 -04:00
parent 7735fe3cce
commit 6eb7be0193
13 changed files with 963 additions and 221 deletions
@@ -755,17 +755,20 @@ public class FinancialReportService : IFinancialReportService
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var openBills = await _context.Bills
// BalanceDue is a computed property — filter on persisted columns in SQL,
// then apply BalanceDue > 0 client-side after materialisation.
var openBills = (await _context.Bills
.Include(b => b.Vendor)
.Where(b => b.CompanyId == companyId
&& b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided
&& b.Status != BillStatus.Paid
&& b.BillDate <= asOfEnd
&& b.BalanceDue > 0)
&& b.BillDate <= asOfEnd)
.OrderBy(b => b.Vendor!.CompanyName)
.ThenBy(b => b.DueDate)
.ToListAsync();
.ToListAsync())
.Where(b => b.BalanceDue > 0)
.ToList();
static string AgingBucket(int d) => d switch
{
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 90 days of facility clock-in/clock-out records for the 3 demo employees
/// (weekdays only), with realistic variation: full days, half days, late arrivals,
/// early dismissals, and random days off throughout the period.
/// </summary>
/// <remarks>
/// <para>
/// All times are stored in UTC. The shop operates on EST (UTC-5), so a 7:30 AM
/// clock-in becomes 12:30 UTC, and a 5:00 PM clock-out becomes 22:00 UTC.
/// </para>
/// <para>
/// The pattern for each (employee, weekday) is deterministic — derived from a
/// fixed hash of the employee index and day offset — so a reseed always produces
/// the same schedule. This prevents reports from looking different on every reset.
/// </para>
/// <para>
/// Day patterns (approximate distribution):
/// &bull; Full day (7:305:00, ~8.5 h paid) — ~55% of weekdays
/// &bull; Late arrival (9:0010:00 AM in, normal out) — ~15%
/// &bull; Early dismissal (normal in, 1:303:00 PM out) — ~12%
/// &bull; Half day (normal in, 12:0012:30 PM out, ~4 h) — ~10%
/// &bull; Day off (no entry generated) — ~8%
/// </para>
/// <para>
/// Idempotency: returns 0 immediately if any clock entries already exist for
/// this company.
/// </para>
/// </remarks>
private async Task<int> SeedEmployeeClockEntriesAsync(Company company)
{
var existingCount = await _context.Set<EmployeeClockEntry>()
.IgnoreQueryFilters()
.CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
if (existingCount > 0) return 0;
var employees = await _userManager.Users
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
.OrderBy(u => u.Email)
.ToListAsync();
if (employees.Count == 0) return 0;
var now = DateTime.UtcNow.Date;
var entries = new List<EmployeeClockEntry>();
// Notes pools for each scenario, giving the time-clock view narrative variety.
string[] lateNotes = ["Dropped kids at school", "Doctor appointment AM", "Car trouble — called ahead", "Traffic on I-40", "Dentist — morning slot"];
string[] earlyNotes = ["Picking up parts from supplier", "Kid pickup from school", "Doctor appointment PM", "Personal errand — approved", "Left early — make-up hours tomorrow"];
string[] halfDayNotes = ["Half day — personal time", "Dentist then half day", "Training seminar AM only", "Family obligation — half day PTO", "Half day approved by manager"];
string[] dayOffNotes = []; // no entry = day off, no note needed
for (int empIdx = 0; empIdx < employees.Count; empIdx++)
{
var emp = employees[empIdx];
for (int daysBack = 90; daysBack >= 1; daysBack--)
{
var date = now.AddDays(-daysBack);
// Skip weekends
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
continue;
// Deterministic pattern per (employee, day) — no Random state to maintain.
int hash = (empIdx * 37 + daysBack * 13) % 100;
// ~8% day off
if (hash < 8) continue;
// Base clock-in: 7:30 AM EST (12:30 UTC) ± small jitter
// Base clock-out: 5:00 PM EST (22:00 UTC) ± small jitter
int inMinuteUtc = 12 * 60 + 30 + (hash % 15); // 12:3012:44 UTC
int outMinuteUtc = 22 * 60 + (hash % 20); // 22:0022:19 UTC
string? noteText = null;
if (hash < 8)
{
// day off — already handled above
}
else if (hash < 18)
{
// ~10% half day — leave around noon
outMinuteUtc = 16 * 60 + 30 + (hash % 30); // 16:3016:59 UTC = 11:30 AM11:59 AM EST
noteText = halfDayNotes[hash % halfDayNotes.Length];
}
else if (hash < 33)
{
// ~15% late arrival — arrive 9:0010:30 AM EST (14:0015:30 UTC)
inMinuteUtc = 14 * 60 + (hash % 90); // 14:0015:29 UTC
noteText = lateNotes[hash % lateNotes.Length];
}
else if (hash < 45)
{
// ~12% early dismissal — leave 1:303:00 PM EST (18:3020:00 UTC)
outMinuteUtc = 18 * 60 + 30 + (hash % 90); // 18:3019:59 UTC
noteText = earlyNotes[hash % earlyNotes.Length];
}
// else: ~55% full day — use base in/out as-is
var clockIn = date.AddMinutes(inMinuteUtc);
var clockOut = date.AddMinutes(outMinuteUtc);
// Safety: never let clockOut <= clockIn (can happen on very short half-days)
if (clockOut <= clockIn) clockOut = clockIn.AddHours(4);
var hours = Math.Round((decimal)(clockOut - clockIn).TotalHours, 2);
entries.Add(new EmployeeClockEntry
{
UserId = emp.Id,
ClockInTime = clockIn,
ClockOutTime = clockOut,
HoursWorked = hours,
EntryType = ClockEntryType.Work,
Notes = noteText,
CompanyId = company.Id,
CreatedAt = clockIn,
});
}
}
if (entries.Count == 0) return 0;
await _context.Set<EmployeeClockEntry>().AddRangeAsync(entries);
await _context.SaveChangesAsync();
return entries.Count;
}
}
@@ -6,27 +6,29 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds customers at a rate of 15 per calendar month from January of the current year
/// through the current month, mimicking a real shop that acquires customers steadily
/// as the year progresses. Seeding in January produces 15 customers; seeding in June
/// produces 90 (15 &times; 6 months).
/// Seeds customers at a random rate of 2-16 per calendar month across a date window that makes
/// the shop look like an ongoing business regardless of when the reset happens:
/// <list type="bullet">
/// <item>April through December reset: window starts January 1 of the current year.</item>
/// <item>January through March reset: window starts 6 months before the current month
/// (reaching into the prior year) so the shop does not appear brand-new.</item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
/// maps customer indices 09 to specific commercial price profiles, so the commercial
/// maps customer indices 0&ndash;9 to specific commercial price profiles, so the commercial
/// anchors must always occupy the lowest database IDs for this company.
/// </para>
/// <para>
/// Remaining slots in each month (15 anchors_that_month) are filled with procedurally
/// generated individual customers drawn from NC-area name and city pools, so the
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of
/// the reseed date.
/// Anchors are spread evenly across the full window so that commercial accounts appear as
/// established relationships. Procedural individual customers fill remaining slots to reach
/// each month's random target.
/// </para>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
/// this company (they are removed by the reset sweep before a full reseed).
/// this company (removed by the reset sweep before a full reseed).
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed customers for.</param>
@@ -106,23 +108,53 @@ public partial class SeedDataService
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame Candy Red"),
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks Flat Black"),
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame &mdash; Candy Red"),
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks &mdash; Flat Black"),
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings Gloss Black"),
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings &mdash; Gloss Black"),
};
// Spread anchors evenly from Jan 1 of this year through the current month so the
// New Customers chart shows them distributed rather than all in one bar.
int currentMonth = now.Month;
// ── Date window ───────────────────────────────────────────────────────
// Post-Q1 (AprDec): start from January 1 of the current year so the full
// year-to-date story is visible in charts.
// Q1 (JanMar): look back 6 months into the prior year so the shop does not
// appear to have opened the day of the reset.
var windowStart = now.Month <= 3
? new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-6)
: new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
int windowMonths = (now.Year - windowStart.Year) * 12
+ (now.Month - windowStart.Month) + 1;
var rng = new Random();
// Each month gets a random target between 2 and 16 so the customer-growth
// graph looks like a real shop instead of a perfectly flat 15/month line.
var monthlyTargets = Enumerable.Range(0, windowMonths)
.Select(_ => rng.Next(2, 17))
.ToArray();
// Spread anchors evenly across the full window so that commercial accounts
// appear as established customers rather than all clustering in one month.
for (int i = 0; i < anchors.Count; i++)
{
int month = 1 + (i * currentMonth / anchors.Count);
month = Math.Clamp(month, 1, currentMonth);
int day = 3 + (i % 20);
anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc);
int slot = i * windowMonths / anchors.Count;
slot = Math.Clamp(slot, 0, windowMonths - 1);
var baseDate = windowStart.AddMonths(slot);
int day = Math.Min(3 + (i % 20), DateTime.DaysInMonth(baseDate.Year, baseDate.Month));
anchors[i].CreatedAt = new DateTime(baseDate.Year, baseDate.Month, day, 8, 0, 0, DateTimeKind.Utc);
}
// Count anchors per month slot to know how many procedural customers each
// month still needs to reach that slot's random target.
var anchorsPerSlot = new int[windowMonths];
foreach (var a in anchors)
{
int slot = (a.CreatedAt.Year - windowStart.Year) * 12
+ (a.CreatedAt.Month - windowStart.Month);
anchorsPerSlot[Math.Clamp(slot, 0, windowMonths - 1)]++;
}
// Insert anchors in deterministic order (commercial first, then individual).
@@ -170,23 +202,16 @@ public partial class SeedDataService
"27591", "27520", "27597", "27312", "27546" };
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
// Count anchors already assigned to each calendar month
var anchorsPerMonth = new int[currentMonth + 1];
foreach (var a in anchors)
{
if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth)
anchorsPerMonth[a.CreatedAt.Month]++;
}
// Fill each month to exactly 15 customers using procedurally generated individuals.
// These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case).
// Fill each month slot to the target using procedurally generated individual customers.
int genIdx = 0;
for (int month = 1; month <= currentMonth; month++)
for (int slot = 0; slot < windowMonths; slot++)
{
int needed = Math.Max(0, 15 - anchorsPerMonth[month]);
var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc);
int needed = Math.Max(0, monthlyTargets[slot] - anchorsPerSlot[slot]);
var monthDate = windowStart.AddMonths(slot);
int daysInMo = DateTime.DaysInMonth(monthDate.Year, monthDate.Month);
var monthBase = new DateTime(monthDate.Year, monthDate.Month, 1, 9, 0, 0, DateTimeKind.Utc);
for (int slot = 0; slot < needed; slot++, genIdx++)
for (int slotI = 0; slotI < needed; slotI++, genIdx++)
{
string fn = firstNames[genIdx % firstNames.Length];
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
@@ -194,7 +219,9 @@ public partial class SeedDataService
string zip = zips[genIdx % zips.Length];
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
int day = needed > 1 ? 1 + (slot * 27 / needed) : 14;
int day = needed > 1
? Math.Min(1 + (slotI * (daysInMo - 2) / needed), daysInMo - 1)
: 14;
var gen = new Customer
{
@@ -209,7 +236,7 @@ public partial class SeedDataService
PaymentTerms = "Due on receipt",
IsActive = true,
CompanyId = company.Id,
CreatedAt = monthStart.AddDays(day),
CreatedAt = monthBase.AddDays(day),
};
try
@@ -0,0 +1,411 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
// ═══════════════════════════════════════════════════════════════════════════
// Job Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds realistic shop-floor notes on a subset of jobs so that the job
/// detail view looks lived-in. Roughly 70% of jobs receive 1&ndash;2 notes;
/// the selection and text are deterministic so every reset produces the same set.
/// </summary>
private async Task<int> SeedJobNotesAsync(Company company)
{
var existingCount = await _context.Set<JobNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0) return 0;
string[] internalNotes =
[
"Customer confirmed pickup Friday PM — call ahead.",
"Requires extra masking around mounting holes — 6 total.",
"Rush job — prioritize over batch queue.",
"Custom color mix: 70% Gloss Black + 30% Charcoal Grey.",
"Check for pitting before coating — surface prep critical.",
"Customer wants to inspect before final cure.",
"Sandblast to bare metal — all previous coating must come off.",
"Note: customer bringing additional parts next week, hold space.",
"Frame sections must be coated before assembly — check sequence.",
"QC check required — previous run had adhesion issue with this powder.",
"Customer paid deposit via check #4412.",
"Temperature-sensitive substrate — confirm oven profile before load.",
"Left voicemail re: delivery date — awaiting callback.",
"Batch with Triangle Offroad order if timing works out.",
"Hanging rod #3 reserved for this run.",
];
string[] externalNotes =
[
"Customer requested matte finish — confirmed in writing.",
"Delivery arranged for Thursday 9am&ndash;12pm.",
"Customer approved color sample.",
"Special instructions: no masking on inner bore.",
"Customer will drop off Wednesday morning.",
];
var notes = new List<JobNote>();
for (int i = 0; i < jobs.Count; i++)
{
int hash = (i * 31 + 7) % 10;
if (hash >= 7) continue; // ~30% of jobs get no notes
int noteCount = hash < 3 ? 1 : 2;
var job = jobs[i];
for (int n = 0; n < noteCount; n++)
{
bool isInternal = (i + n) % 4 != 0;
var pool = isInternal ? internalNotes : externalNotes;
notes.Add(new JobNote
{
JobId = job.Id,
Note = pool[(i * 3 + n * 7) % pool.Length],
IsImportant = n == 0 && i % 5 == 0,
IsInternal = isInternal,
CompanyId = company.Id,
CreatedAt = job.CreatedAt.AddDays(1 + n),
});
}
}
if (notes.Count == 0) return 0;
await _context.Set<JobNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Customer Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds account-management notes on the 10 commercial anchor customers and a
/// handful of individual customers so that customer detail pages look like a
/// working CRM rather than a blank slate.
/// </summary>
private async Task<int> SeedCustomerNotesAsync(Company company)
{
var existingCount = await _context.Set<CustomerNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
// Targeted notes for named commercial anchor accounts
var anchorData = new (string email, string note, bool important)[]
{
("matt@carolinafab.com", "Net 30 terms per contract; invoices go to accounting@carolinafab.com.", true),
("matt@carolinafab.com", "Prefers Gloss Black and Hammertone for structural steel — confirm any color changes.", false),
("ctanner@apexmotorsports.com", "Racing season turnaround required within 5 business days.", true),
("ctanner@apexmotorsports.com", "Color approval required before coating — never assume from previous run.", false),
("jpruitt@triangleoffroad.com", "Skid plates must be coated inside and out — customer checks coverage.", true),
("bsmith@smithwelding.com", "Monthly standing PO — call Bill to confirm quantities before start.", false),
("kmorales@raleigharchitectural.com", "Architectural work: surface finish is critical, QC photos required.", true),
("kmorales@raleigharchitectural.com", "Preferred color: RAL 9005 Jet Black. Has approved alternate: Gloss Black.", false),
("tgreco@eastcoastpw.com", "Net 15 — has paid late twice; flag for follow-up at 10 days.", true),
("dshaw@piedmontmetalworks.com", "Heavy gauge steel — plan for extended sandblast and pre-heat time.", false),
("lpatel@caryindustrial.com", "Parts arrive disassembled; coordinate reassembly quote if needed.", false),
("rblake@durhamtech.com", "University purchase order required on every invoice — ask for PO number.", true),
("mcoleman@wakecountyfleet.gov", "Government contract — tax exempt, Net 60. Do not charge sales tax.", true),
("mcoleman@wakecountyfleet.gov", "Fleet contact: Michelle Coleman. Secondary: Facilities Dept (919) 555-0100.", false),
};
// Generic notes for individual customers (applied deterministically to first N individuals)
string[] genericIndivNotes =
[
"Repeat customer — prefers same color family as previous job.",
"Customer prefers afternoon pickups — call before loading.",
"Cash payment preferred; has paid by check in the past.",
"Sensitive to turnaround time — communicate delays early.",
"Referred by Carolina Fabrication. Treat as preferred.",
];
var notes = new List<CustomerNote>();
var now = DateTime.UtcNow;
// Anchor commercial notes
foreach (var (email, note, important) in anchorData)
{
var customer = await _context.Set<Customer>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Email == email && c.CompanyId == company.Id && !c.IsDeleted);
if (customer == null) continue;
notes.Add(new CustomerNote
{
CustomerId = customer.Id,
Note = note,
IsImportant = important,
CompanyId = company.Id,
CreatedAt = customer.CreatedAt.AddDays(2),
});
}
// Generic notes on the first 8 individual customers (non-commercial)
var individuals = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted && !c.IsCommercial)
.OrderBy(c => c.Id)
.Take(8)
.ToListAsync();
for (int i = 0; i < individuals.Count; i++)
{
notes.Add(new CustomerNote
{
CustomerId = individuals[i].Id,
Note = genericIndivNotes[i % genericIndivNotes.Length],
IsImportant = false,
CompanyId = company.Id,
CreatedAt = individuals[i].CreatedAt.AddDays(3),
});
}
if (notes.Count == 0) return 0;
await _context.Set<CustomerNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Rework Records
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 7&ndash;8 rework records on completed jobs representing a realistic ~5%
/// rework rate. Mix of internal defects (shop fault) and customer-reported
/// warranty claims. About half are resolved; the rest remain open or in-progress.
/// </summary>
private async Task<int> SeedReworkRecordsAsync(Company company)
{
var existingCount = await _context.Set<ReworkRecord>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
// Only completed/delivered jobs qualify for rework records
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "READY_FOR_PICKUP" };
var statusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id && terminalCodes.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var completedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && statusIds.Contains(j.JobStatusId))
.OrderBy(j => j.Id)
.ToListAsync();
if (completedJobs.Count < 5) return 0;
// Deterministic selection: pick jobs at indices 1, 4, 7, 10, 13, 16, 19
var reworkData = new (int jobIdx, ReworkType type, ReworkReason reason,
string description, ReworkDiscoveredBy by, ReworkStatus status,
ReworkResolution? resolution, decimal estCost, decimal actCost,
bool billable, ReworkPricingType? pricing)[]
{
(1, ReworkType.InternalDefect, ReworkReason.AdhesionFailure, "Coating delaminating on three mounting points — surface prep insufficient.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 85m, 95m, false, ReworkPricingType.ShopFault),
(4, ReworkType.InternalDefect, ReworkReason.Contamination, "Fish-eye defects across main panel — contamination during coating.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 60m, 70m, false, ReworkPricingType.ShopFault),
(7, ReworkType.CustomerWarranty, ReworkReason.ColorMismatch, "Customer says delivered color does not match approved sample.", ReworkDiscoveredBy.Customer, ReworkStatus.Resolved, ReworkResolution.CustomerCredited, 120m, 0m, false, ReworkPricingType.ShopFault),
(10, ReworkType.InternalDefect, ReworkReason.RunsSags, "Runs visible on lower edge — caught during QC inspection.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 45m, 50m, false, ReworkPricingType.ShopFault),
(13, ReworkType.CustomerDamage, ReworkReason.HandlingDamage, "Customer damaged finish during installation — requesting touch-up.", ReworkDiscoveredBy.Customer, ReworkStatus.InProgress, null, 150m, 0m, true, ReworkPricingType.CustomerFull),
(16, ReworkType.InternalDefect, ReworkReason.InsufficientCoverage, "Thin spots on inside radius — insufficient powder in recessed areas.", ReworkDiscoveredBy.Internal, ReworkStatus.Open, null, 70m, 0m, false, ReworkPricingType.ShopFault),
(19, ReworkType.CustomerWarranty, ReworkReason.OvenIssue, "Early cure failure reported 3 months post-delivery — under investigation.", ReworkDiscoveredBy.Customer, ReworkStatus.Open, null, 200m, 0m, false, ReworkPricingType.ShopFault),
};
var records = new List<ReworkRecord>();
foreach (var (jobIdx, type, reason, desc, by, status, resolution, est, act, billable, pricing) in reworkData)
{
if (jobIdx >= completedJobs.Count) continue;
var job = completedJobs[jobIdx];
var discoveredDate = job.CompletedDate ?? job.UpdatedAt ?? job.CreatedAt.AddDays(7);
records.Add(new ReworkRecord
{
JobId = job.Id,
ReworkType = type,
Reason = reason,
DefectDescription = desc,
DiscoveredBy = by,
DiscoveredDate = discoveredDate.AddDays(1),
EstimatedReworkCost = est,
ActualReworkCost = act,
IsBillableToCustomer = billable,
ReworkPricingType = pricing,
Status = status,
Resolution = resolution,
ResolvedDate = status == ReworkStatus.Resolved ? discoveredDate.AddDays(5) : null,
ResolutionNotes = status == ReworkStatus.Resolved ? "Recoated and re-inspected. Customer notified." : null,
CompanyId = company.Id,
CreatedAt = discoveredDate.AddDays(1),
});
}
if (records.Count == 0) return 0;
await _context.Set<ReworkRecord>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Deposits
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds ~18 deposits spread across commercial and individual jobs.
/// Deposits on jobs that have paid invoices are marked applied; deposits on
/// open or pending jobs remain unapplied, giving the Deposits module a realistic mix.
/// </summary>
private async Task<int> SeedDepositsAsync(Company company)
{
var existingCount = await _context.Set<Deposit>()
.IgnoreQueryFilters()
.CountAsync(d => d.CompanyId == company.Id && !d.IsDeleted);
if (existingCount > 0) return 0;
// Grab jobs with their invoices and customer info
var jobsWithInvoices = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
var invoicesByJob = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.JobId.HasValue)
.ToDictionaryAsync(i => i.JobId!.Value, i => i);
// Checking account for deposit account ID
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (jobsWithInvoices.Count == 0) return 0;
// Deposit amounts and methods for variety
decimal[] amounts = [150m, 200m, 250m, 300m, 400m, 500m, 750m, 100m, 350m];
var methods = new[] { PaymentMethod.Cash, PaymentMethod.Check, PaymentMethod.CreditDebitCard,
PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.Cash };
var deposits = new List<Deposit>();
int depSeq = 1;
// Seed a deposit for every 3rd job (deterministic ~33% coverage)
foreach (var (job, idx) in jobsWithInvoices.Select((j, i) => (j, i)))
{
if (idx % 3 != 0) continue;
if (deposits.Count >= 20) break;
var receivedDate = job.CreatedAt.AddDays(1);
decimal amount = amounts[idx % amounts.Length];
var method = methods[idx % methods.Length];
string yymm = receivedDate.ToString("yyMM");
string receipt = $"DEP-{yymm}-{depSeq:D4}";
invoicesByJob.TryGetValue(job.Id, out var invoice);
bool isApplied = invoice != null
&& invoice.Status is InvoiceStatus.Paid or InvoiceStatus.PartiallyPaid;
deposits.Add(new Deposit
{
ReceiptNumber = receipt,
CustomerId = job.CustomerId,
JobId = job.Id,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = method == PaymentMethod.Check ? $"CHK #{4000 + idx}" : null,
Notes = isApplied ? "Applied to invoice on creation." : null,
DepositAccountId = checkingAccount?.Id,
AppliedToInvoiceId = isApplied ? invoice!.Id : null,
AppliedDate = isApplied ? invoice!.InvoiceDate : null,
CompanyId = company.Id,
CreatedAt = receivedDate,
});
depSeq++;
}
if (deposits.Count == 0) return 0;
await _context.Set<Deposit>().AddRangeAsync(deposits);
await _context.SaveChangesAsync();
return deposits.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Reconciliations
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 3 completed monthly bank reconciliations on the primary checking account
/// covering the 3 full calendar months immediately before the current month.
/// Balances are approximated from the account's opening balance plus a realistic
/// monthly cash-flow trajectory, so they look plausible without needing to match
/// actual transaction totals (which vary per reset).
/// </summary>
private async Task<int> SeedBankReconciliationsAsync(Company company)
{
var existingCount = await _context.Set<BankReconciliation>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (checkingAccount == null) return 0;
var now = DateTime.UtcNow;
var reconciliations = new List<BankReconciliation>();
// Build 3 months of completed reconciliations ending the last day of each month.
// Balances step up ~$4-6k per month to reflect a moderately profitable shop.
decimal[] endingBalances = [62_400m, 67_800m, 73_250m];
for (int i = 3; i >= 1; i--)
{
var statementMonth = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
var statementDate = new DateTime(statementMonth.Year, statementMonth.Month,
DateTime.DaysInMonth(statementMonth.Year, statementMonth.Month),
23, 59, 59, DateTimeKind.Utc);
int balIdx = 3 - i; // 0,1,2
decimal ending = endingBalances[balIdx];
decimal beginning = balIdx == 0 ? ending - 5_200m : endingBalances[balIdx - 1];
reconciliations.Add(new BankReconciliation
{
AccountId = checkingAccount.Id,
StatementDate = statementDate,
BeginningBalance = beginning,
EndingBalance = ending,
Status = BankReconciliationStatus.Completed,
CompletedAt = statementDate.AddDays(3),
CompletedBy = "demo@powdercoatinglogix.com",
Notes = $"Monthly close — {statementMonth:MMMM yyyy}. All transactions cleared.",
CompanyId = company.Id,
CreatedAt = statementDate.AddDays(3),
});
}
await _context.Set<BankReconciliation>().AddRangeAsync(reconciliations);
await _context.SaveChangesAsync();
return reconciliations.Count;
}
}
@@ -6,157 +6,122 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a plausible status-transition history for every job belonging to the company,
/// reconstructing the sequence of transitions a job must have passed through to reach
/// its current status.
/// Seeds <see cref="JobStatusHistory"/> transition records for every completed, delivered,
/// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for
/// this company.
/// For each qualifying job the seeder builds a realistic stage sequence:
/// PENDING &rarr; IN_PREPARATION &rarr; (SANDBLASTING if any item requires it)
/// &rarr; (MASKING_TAPING if any item requires it) &rarr; CLEANING &rarr; IN_OVEN
/// &rarr; COATING &rarr; CURING &rarr; QUALITY_CHECK &rarr; [terminal status].
/// </para>
/// <para>
/// The method does not record arbitrary transitions — it follows the canonical 14-step
/// pipeline array (<c>PENDING → QUOTED → APPROVED → … → DELIVERED</c>) and generates
/// one <see cref="JobStatusHistory"/> row per transition step, from <c>PENDING</c> up to
/// and including the job's current status.
/// 85&thinsp;% of the job's total cycle time (CreatedAt &rarr; CompletedDate) is
/// distributed across work stages using fixed per-stage weights that reflect realistic
/// relative durations (e.g. SANDBLASTING &gt; CLEANING). The remaining 15&thinsp;%
/// is left as residual "terminal status" time, which surfaces correctly in the report's
/// last-entry formula <c>(job.CompletedDate &minus; last.ChangedDate)</c>.
/// </para>
/// <para>
/// Terminal side-branch statuses are handled explicitly:
/// <list type="bullet">
/// <item><c>ON_HOLD</c> — assumed to have reached <c>QUALITY_CHECK</c> before pausing.</item>
/// <item><c>CANCELLED</c> — assumed to have been cancelled from <c>IN_PREPARATION</c>.</item>
/// </list>
/// </para>
/// <para>
/// Transition timestamps are spread ~6 hours apart starting from <c>job.CreatedAt</c>.
/// This is an approximation chosen for demo realism; actual production transitions record
/// the wall-clock time at which a user changes the status. A safety clamp prevents any
/// generated timestamp from exceeding <c>DateTime.UtcNow</c>.
/// </para>
/// <para>
/// All history rows are batched into a single <c>AddRangeAsync / SaveChangesAsync</c>
/// call for performance, since the total count can be several hundred rows (50 jobs × up
/// to 14 transitions each).
/// Idempotency: returns 0 immediately if any history records already exist for
/// this company, matching the pattern used by all other partial seeders.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed job status history for.</param>
/// <returns>Total number of history rows inserted, or 0 if already seeded or no jobs exist.</returns>
/// <param name="company">The tenant company to seed history for.</param>
/// <returns>Number of history records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedJobStatusHistoryAsync(Company company)
{
var existingCount = await _context.Set<JobStatusHistory>()
.IgnoreQueryFilters()
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
if (existingCount > 0)
return 0;
if (existingCount > 0) return 0;
// Load all job status lookups into a code → id map
var statusMap = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
// Only completed-terminal jobs have a meaningful CompletedDate to calculate cycle time.
// CANCELLED is excluded — the report cares only about successfully finished work.
var terminalCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
// Load jobs with their current status
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobItems)
.Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
.ToListAsync();
if (jobs.Count == 0 || statusMap.Count == 0)
return 0;
jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
if (jobs.Count == 0) return 0;
// Ordered pipeline — each status code in the order a job advances through it.
// ON_HOLD and CANCELLED are terminal side-branches handled separately.
var pipeline = new[]
{
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
"SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
"COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
};
var statuses = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s);
var pipelineIndex = pipeline
.Select((code, idx) => (code, idx))
.ToDictionary(t => t.code, t => t.idx);
var history = new List<JobStatusHistory>();
var now = DateTime.UtcNow;
var records = new List<JobStatusHistory>();
foreach (var job in jobs)
{
var currentCode = job.JobStatus.StatusCode;
var totalSeconds = (job.CompletedDate!.Value - job.CreatedAt).TotalSeconds;
if (totalSeconds < 60) continue; // skip malformed dates
// Determine the sequence of transitions that happened to reach current state.
// For ON_HOLD: assume it came from QUALITY_CHECK before going on hold.
// For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
string[] codesTraversed;
var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
var needsMask = job.JobItems.Any(i => i.RequiresMasking);
if (currentCode == "ON_HOLD")
// Build ordered stage list; the last element is the terminal "to" status only —
// it never appears as a "from" and is not assigned a work weight.
var stages = new List<string> { "PENDING", "IN_PREPARATION" };
if (needsSand) stages.Add("SANDBLASTING");
if (needsMask) stages.Add("MASKING_TAPING");
stages.AddRange(new[] { "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" });
stages.Add(job.JobStatus.StatusCode);
// Time weight for each "from" status — reflects typical relative hours in that stage.
static double Weight(string code) => code switch
{
// Traversed up to QUALITY_CHECK then went ON_HOLD
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"];
}
else if (currentCode == "CANCELLED")
"PENDING" => 2.0, // intake / scheduling buffer
"IN_PREPARATION" => 1.5, // disassembly, hang, pre-inspect
"SANDBLASTING" => 2.0, // media blast + blow-off
"MASKING_TAPING" => 1.0, // tape & plug work
"CLEANING" => 0.5, // chemical wash + dry
"IN_OVEN" => 1.5, // pre-heat before coating
"COATING" => 1.5, // powder application
"CURING" => 1.0, // oven cure cycle
"QUALITY_CHECK" => 0.5, // inspection & touch-up
_ => 0.5,
};
// Work stages are all entries except the terminal "to" at the end.
int n = stages.Count;
var workWeights = stages.Take(n - 1).Select(Weight).ToList();
double totalWeight = workWeights.Sum();
// 85% of cycle time covers the work stages; 15% becomes terminal-status residual
// so (job.CompletedDate last.ChangedDate) produces a non-zero, plausible value.
double workSeconds = totalSeconds * 0.85;
var currentDate = job.CreatedAt;
for (int i = 0; i < n - 1; i++)
{
// Cancelled from IN_PREPARATION
codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"];
}
else if (pipelineIndex.TryGetValue(currentCode, out int curIdx))
{
// Normal pipeline job — traversed from PENDING up to current status
codesTraversed = pipeline.Take(curIdx + 1).ToArray();
}
else
{
// Unknown status — just record a single PENDING → currentCode entry
codesTraversed = ["PENDING", currentCode];
}
if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
// Spread transition dates backwards from job.CreatedAt.
// Each step took roughly 48 hours, so transitions are spaced a few hours apart.
// Jobs further along in the pipeline have older start dates.
var stepCount = codesTraversed.Length - 1; // number of transitions
if (stepCount <= 0) continue;
currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
// Job was created at job.CreatedAt; each transition is spaced ~6h apart
// so the first transition (PENDING→QUOTED) happened ~6h after creation, etc.
for (int t = 0; t < stepCount; t++)
{
var fromCode = codesTraversed[t];
var toCode = codesTraversed[t + 1];
if (!statusMap.TryGetValue(fromCode, out int fromId)) continue;
if (!statusMap.TryGetValue(toCode, out int toId)) continue;
// Spread: first transitions happened closer to job creation,
// later ones closer to now. Add a few hours per step.
var hoursOffset = (t + 1) * 6;
var changedDate = job.CreatedAt.AddHours(hoursOffset);
// Don't let transitions exceed "now"
if (changedDate > now) changedDate = now.AddMinutes(-(stepCount - t) * 10);
history.Add(new JobStatusHistory
records.Add(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = fromId,
ToStatusId = toId,
ChangedDate = changedDate,
Notes = null,
JobId = job.Id,
FromStatusId = fromLookup.Id,
ToStatusId = toLookup.Id,
ChangedDate = currentDate,
CompanyId = company.Id,
CreatedAt = changedDate
CreatedAt = currentDate,
});
}
}
if (history.Count == 0) return 0;
if (records.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(history);
await _context.Set<JobStatusHistory>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return history.Count;
return records.Count;
}
}
@@ -136,6 +136,12 @@ public partial class SeedDataService
// Tier 1 — pure leaf records (block nothing of their own)
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
await Sweep<EmployeeClockEntry>(); // RESTRICT → ApplicationUser — must go before workers
await Sweep<JobNote>(); // NO_ACTION → Jobs
await Sweep<CustomerNote>(); // NO_ACTION → Customers
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs
await Sweep<Deposit>(); // NO_ACTION → Customers, Jobs, Invoices, Quotes
await Sweep<BankReconciliation>(); // FK → Accounts (accounts stay, but sweep for clean reset)
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
@@ -653,6 +659,18 @@ public partial class SeedDataService
if (workerUsers.Any())
{
// EmployeeClockEntry has DeleteBehavior.Restrict on UserId — delete
// clock entries first so the user delete succeeds without a FK violation.
var workerUserIds = workerUsers.Select(u => u.Id).ToList();
var clockEntries = await _context.Set<EmployeeClockEntry>().IgnoreQueryFilters()
.Where(e => workerUserIds.Contains(e.UserId)).ToListAsync();
if (clockEntries.Any())
{
_context.Set<EmployeeClockEntry>().RemoveRange(clockEntries);
await _context.SaveChangesAsync();
details.Add($"✓ Removed {clockEntries.Count} clock entry/entries");
}
foreach (var wu in workerUsers)
await _userManager.DeleteAsync(wu);
totalRemoved += workerUsers.Count;
@@ -7,32 +7,26 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync
/// to avoid needing a special "IsSeeded" flag on ApplicationUser.
/// Canonical emails of the 3 demo employees (2 workers + 1 manager). Used as fingerprints
/// in RemoveSeedDataAsync to avoid needing a special "IsSeeded" flag on ApplicationUser.
/// </summary>
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",
];
/// <summary>
/// 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).
/// Seeds 3 named employees as ApplicationUser records for the demo company:
/// Mike Sanders (Coater, Worker), Jake Wilson (Sandblaster, Worker),
/// and Sarah Brooks (Shop Manager, Manager role with broader permissions).
/// </summary>
/// <remarks>
/// 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.
/// Workers are seeded before jobs and time entries so that AssignedUserId on Job
/// and UserId on JobTimeEntry and EmployeeClockEntry can reference them.
/// Uses @pcldemo.com email domain — will never conflict with real accounts.
/// Idempotency: bails early if any of the 3 emails already exist for this company.
/// </remarks>
private async Task<int> SeedShopWorkersAsync(Company company)
{
@@ -40,50 +34,71 @@ public partial class SeedDataService
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
if (anyExists) return 0;
const string defaultPassword = "Worker123!";
const string pwd = "Worker123!Demo";
var hireDate = DateTime.UtcNow.AddMonths(-18);
int created = 0;
// (email, firstName, lastName, empNum, position, laborRate)
var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[]
// ── 2 shop workers ────────────────────────────────────────────────────────
foreach (var (email, fn, ln, emp, pos, rate) in new (string, string, string, string, string, decimal)[]
{
("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)
("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
})
{
var user = await _userManager.FindByEmailAsync(email);
if (user != null) continue;
user = new ApplicationUser
if (await _userManager.FindByEmailAsync(email) != null) continue;
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = fn,
LastName = ln,
EmployeeNumber = emp,
UserName = email, Email = email,
FirstName = fn, LastName = ln,
EmployeeNumber = emp, Position = pos,
Department = "Shop Floor",
Position = pos,
LaborCostPerHour = rate,
EmailConfirmed = true,
HireDate = DateTime.UtcNow.AddMonths(-12),
IsActive = true,
EmailConfirmed = true, IsActive = true,
HireDate = hireDate,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Worker,
CanManageJobs = true,
CanViewShopFloor = true,
CreatedAt = DateTime.UtcNow.AddMonths(-12)
CreatedAt = hireDate
};
var r = await _userManager.CreateAsync(user, pwd);
if (!r.Succeeded)
throw new InvalidOperationException(
$"Failed to create {email}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
created++;
}
var result = await _userManager.CreateAsync(user, defaultPassword);
if (result.Succeeded)
// ── 1 manager ─────────────────────────────────────────────────────────────
const string managerEmail = "sarah.brooks@pcldemo.com";
if (await _userManager.FindByEmailAsync(managerEmail) == null)
{
var mgr = new ApplicationUser
{
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
created++;
}
UserName = managerEmail, Email = managerEmail,
FirstName = "Sarah", LastName = "Brooks",
EmployeeNumber = "EMP-003", Position = "Shop Manager",
Department = "Management",
LaborCostPerHour = 32.00m,
EmailConfirmed = true, IsActive = true,
HireDate = hireDate.AddMonths(-6), // hired before the workers
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Manager,
CanManageJobs = true, CanViewShopFloor = true,
CanManageCustomers = true, CanCreateQuotes = true,
CanApproveQuotes = true, CanManageCalendar = true,
CanViewCalendar = true, CanManageProducts = true,
CanViewProducts = true, CanManageEquipment = true,
CanManageMaintenance = true, CanManageInventory = true,
CanViewReports = true,
CreatedAt = hireDate.AddMonths(-6)
};
var r = await _userManager.CreateAsync(mgr, pwd);
if (!r.Succeeded)
throw new InvalidOperationException(
$"Failed to create {managerEmail}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
await _userManager.AddToRoleAsync(mgr, AppConstants.Roles.Employee);
created++;
}
return created;
@@ -427,6 +427,7 @@ public partial class SeedDataService : ISeedDataService
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("Clock entries", details, errors, result, () => SeedEmployeeClockEntriesAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
@@ -477,7 +478,12 @@ public partial class SeedDataService : ISeedDataService
}
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
await RunSeeder("Job notes", details, errors, result, () => SeedJobNotesAsync(company));
await RunSeeder("Customer notes", details, errors, result, () => SeedCustomerNotesAsync(company));
await RunSeeder("Rework records", details, errors, result, () => SeedReworkRecordsAsync(company));
await RunSeeder("Deposits", details, errors, result, () => SeedDepositsAsync(company));
await RunSeeder("Bank recon", details, errors, result, () => SeedBankReconciliationsAsync(company));
if (company.CompanyCode == "DEMO")
{