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
@@ -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;
}
}