using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; namespace PowderCoating.Infrastructure.Services; public partial class SeedDataService { /// /// Seeds transition records for every completed, delivered, /// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data. /// /// /// /// 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]. /// /// /// 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 (job.CompletedDate − last.ChangedDate). /// /// /// Idempotency: returns 0 immediately if any history records already exist for /// this company, matching the pattern used by all other partial seeders. /// /// /// The tenant company to seed history for. /// Number of history records inserted, or 0 if already seeded. private async Task SeedJobStatusHistoryAsync(Company company) { var existingCount = await _context.Set() .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() .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() .IgnoreQueryFilters() .Where(s => s.CompanyId == company.Id) .ToDictionaryAsync(s => s.StatusCode, s => s); var records = new List(); 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 { "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().AddRangeAsync(records); await _context.SaveChangesAsync(); return records.Count; } }