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:
@@ -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 → IN_PREPARATION → (SANDBLASTING if any item requires it)
|
||||
/// → (MASKING_TAPING if any item requires it) → CLEANING → IN_OVEN
|
||||
/// → COATING → CURING → QUALITY_CHECK → [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 % of the job's total cycle time (CreatedAt → CompletedDate) is
|
||||
/// distributed across work stages using fixed per-stage weights that reflect realistic
|
||||
/// relative durations (e.g. SANDBLASTING > CLEANING). The remaining 15 %
|
||||
/// is left as residual "terminal status" time, which surfaces correctly in the report's
|
||||
/// last-entry formula <c>(job.CompletedDate − 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 4–8 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user