6eb7be0193
- 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>
195 lines
8.6 KiB
C#
195 lines
8.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Shared.Constants;
|
||
|
||
namespace PowderCoating.Infrastructure.Services;
|
||
|
||
public partial class SeedDataService
|
||
{
|
||
/// <summary>
|
||
/// 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",
|
||
];
|
||
|
||
/// <summary>
|
||
/// 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 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)
|
||
{
|
||
var anyExists = await _userManager.Users
|
||
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
|
||
if (anyExists) return 0;
|
||
|
||
const string pwd = "Worker123!Demo";
|
||
var hireDate = DateTime.UtcNow.AddMonths(-18);
|
||
int created = 0;
|
||
|
||
// ── 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),
|
||
})
|
||
{
|
||
if (await _userManager.FindByEmailAsync(email) != null) continue;
|
||
var user = new ApplicationUser
|
||
{
|
||
UserName = email, Email = email,
|
||
FirstName = fn, LastName = ln,
|
||
EmployeeNumber = emp, Position = pos,
|
||
Department = "Shop Floor",
|
||
LaborCostPerHour = rate,
|
||
EmailConfirmed = true, IsActive = true,
|
||
HireDate = hireDate,
|
||
CompanyId = company.Id,
|
||
CompanyRole = AppConstants.CompanyRoles.Worker,
|
||
CanManageJobs = true,
|
||
CanViewShopFloor = true,
|
||
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++;
|
||
}
|
||
|
||
// ── 1 manager ─────────────────────────────────────────────────────────────
|
||
const string managerEmail = "sarah.brooks@pcldemo.com";
|
||
if (await _userManager.FindByEmailAsync(managerEmail) == null)
|
||
{
|
||
var mgr = new ApplicationUser
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Seeds job time entries for completed and in-progress jobs, giving the Worker
|
||
/// Productivity report meaningful data from day one.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </remarks>
|
||
private async Task<int> SeedJobTimeEntriesAsync(Company company)
|
||
{
|
||
var existingCount = await _context.Set<JobTimeEntry>()
|
||
.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;
|
||
|
||
// Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can
|
||
// silently return null navigation properties when query filters interact with IgnoreQueryFilters.
|
||
var workedStatusIds = await _context.Set<JobStatusLookup>()
|
||
.IgnoreQueryFilters()
|
||
.Where(s => s.CompanyId == company.Id && new[]
|
||
{
|
||
"IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING",
|
||
"IN_OVEN", "COATING", "CURING", "QUALITY_CHECK",
|
||
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
|
||
}.Contains(s.StatusCode))
|
||
.Select(s => s.Id)
|
||
.ToListAsync();
|
||
|
||
if (workedStatusIds.Count == 0) return 0;
|
||
|
||
var workedJobs = await _context.Set<Job>()
|
||
.IgnoreQueryFilters()
|
||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||
&& workedStatusIds.Contains(j.JobStatusId))
|
||
.ToListAsync();
|
||
|
||
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<JobTimeEntry>();
|
||
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<JobTimeEntry>().AddRangeAsync(entries);
|
||
await _context.SaveChangesAsync();
|
||
|
||
return entries.Count;
|
||
}
|
||
}
|