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>
128 lines
5.9 KiB
C#
128 lines
5.9 KiB
C#
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 → 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>
|
||
/// 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>
|
||
/// 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;
|
||
}
|
||
}
|