Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs
T
2026-04-23 21:38:24 -04:00

163 lines
6.8 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 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.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for
/// this company.
/// </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.
/// </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).
/// </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>
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;
// 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);
// Load jobs with their current status
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0 || statusMap.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 pipelineIndex = pipeline
.Select((code, idx) => (code, idx))
.ToDictionary(t => t.code, t => t.idx);
var history = new List<JobStatusHistory>();
var now = DateTime.UtcNow;
foreach (var job in jobs)
{
var currentCode = job.JobStatus.StatusCode;
// 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;
if (currentCode == "ON_HOLD")
{
// Traversed up to QUALITY_CHECK then went ON_HOLD
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"];
}
else if (currentCode == "CANCELLED")
{
// 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];
}
// 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;
// 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
{
JobId = job.Id,
FromStatusId = fromId,
ToStatusId = toId,
ChangedDate = changedDate,
Notes = null,
CompanyId = company.Id,
CreatedAt = changedDate
});
}
}
if (history.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(history);
await _context.SaveChangesAsync();
return history.Count;
}
}