Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs
T
spouliot 6eb7be0193 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>
2026-06-12 09:26:40 -04:00

128 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// 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>
/// 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>
/// 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>
/// 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 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;
// 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" };
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobItems)
.Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
.ToListAsync();
jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
if (jobs.Count == 0) return 0;
var statuses = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s);
var records = new List<JobStatusHistory>();
foreach (var job in jobs)
{
var totalSeconds = (job.CompletedDate!.Value - job.CreatedAt).TotalSeconds;
if (totalSeconds < 60) continue; // skip malformed dates
var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
var needsMask = job.JobItems.Any(i => i.RequiresMasking);
// 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
{
"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++)
{
if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
records.Add(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = fromLookup.Id,
ToStatusId = toLookup.Id,
ChangedDate = currentDate,
CompanyId = company.Id,
CreatedAt = currentDate,
});
}
}
if (records.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
}