using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
///
/// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic
/// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold)
/// and a shuffled visit order so jobs from different customers are interleaved naturally
/// rather than appearing as per-customer blocks.
///
///
///
/// Per-customer job counts and price ranges are defined by CustomerProfile(ci)
/// where ci is the customer's position in the Id-ascending list seeded by
/// SeedCustomersAsync (0 = Carolina Fabrication, the largest account).
///
///
/// A shuffled visitSchedule (fixed seed 42) drives the outer loop so that
/// Carolina Fabrication, Apex Motorsports, and individual customers appear
/// interleaved in creation-date order rather than in consecutive customer blocks.
///
///
/// Status pool (fixed seed 99): Delivered ×10, Completed ×8,
/// ReadyForPickup ×5, then decreasing counts for in-progress stages, with
/// exactly one Cancelled and one OnHold.
/// Priority pool (fixed seed 77): Normal 76 %, High 12 %, Urgent 8 %,
/// Rush 4 % — rush is genuinely rare.
///
///
private async Task SeedJobsAsync(Company company)
{
var existingCount = await _context.Set()
.IgnoreQueryFilters()
.CountAsync(j => j.CompanyId == company.Id && !j.IsDeleted);
if (existingCount > 0)
return 0;
var jobStatuses = await _context.Set()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
var jobPriorities = await _context.Set()
.IgnoreQueryFilters()
.Where(p => p.CompanyId == company.Id)
.ToDictionaryAsync(p => p.PriorityCode, p => p.Id);
if (jobStatuses.Count == 0 || jobPriorities.Count == 0)
return 0;
var customers = await _context.Set()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.OrderBy(c => c.Id)
.ToListAsync();
if (customers.Count == 0)
return 0;
var approvedQuotes = await _context.Set()
.IgnoreQueryFilters()
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
.OrderBy(q => q.CustomerId)
.ThenBy(q => q.Id)
.ToListAsync();
var shopUsers = await _context.Set()
.Where(u => u.CompanyId == company.Id && u.IsActive)
.OrderBy(u => u.Id)
.ToListAsync();
var now = DateTime.UtcNow;
var prefix = $"JOB-{now:yy}{now.Month:D2}-";
var existing = await _context.Set()
.IgnoreQueryFilters()
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => j.JobNumber)
.ToListAsync();
var maxNum = 0;
foreach (var n in existing)
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
// Indices match the customer insertion order from SeedCustomersAsync (ascending Id):
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
// 10–26 = individual residential customers
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
{
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
1 => (6, 400m, 1500m), // Apex Motorsports
2 => (5, 350m, 1200m), // Triangle Offroad
3 => (4, 250m, 800m), // Smith Welding
4 => (4, 300m, 900m), // Raleigh Architectural Metals
5 => (3, 200m, 600m), // East Coast Powderworks
6 => (3, 150m, 450m), // Piedmont Metal Works
7 => (2, 200m, 500m), // Cary Industrial Solutions
8 => (2, 350m, 900m), // Durham Tech Equipment
9 => (3, 400m, 1500m), // Wake County Fleet Services
10 => (2, 75m, 250m), // John Davis
11 => (1, 150m, 350m), // Sarah Jenkins
12 => (1, 200m, 400m), // Mike Thompson
13 => (2, 100m, 300m), // Robert Miller
14 => (0, 0m, 0m), // Jennifer Clark — prospect only
15 => (1, 100m, 250m), // David Wilson
16 => (0, 0m, 0m), // Lisa Anderson — prospect only
17 => (1, 150m, 300m), // Thomas Harris
18 => (0, 0m, 0m), // Karen White — no jobs yet
19 => (1, 250m, 500m), // James Taylor
20 => (0, 0m, 0m), // Michelle Brown — no jobs yet
21 => (1, 100m, 250m), // Chris Lee
22 => (0, 0m, 0m), // Amanda Garcia — no jobs yet
23 => (1, 150m, 350m), // Kevin Martinez
24 => (0, 0m, 0m), // Nancy Rodriguez — no jobs yet
25 => (0, 0m, 0m), // Brian Hall — no jobs yet
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
};
// ── Status pool: realistic shop distribution (total = 50) ──────────────
// Delivered and Completed dominate; exactly one Cancelled and one OnHold.
var statusPool = new List();
foreach (var (code, count) in new (string Code, int Count)[]
{
("DELIVERED", 10),
("COMPLETED", 8),
("READY_FOR_PICKUP", 5),
("IN_PREPARATION", 4),
("SANDBLASTING", 4),
("COATING", 3),
("QUALITY_CHECK", 3),
("CURING", 3),
("MASKING_TAPING", 2),
("IN_OVEN", 2),
("CLEANING", 1),
("PENDING", 1),
("QUOTED", 1),
("APPROVED", 1),
("ON_HOLD", 1),
("CANCELLED", 1),
})
{
for (int k = 0; k < count; k++) statusPool.Add(code);
}
// Fisher-Yates shuffle with a fixed seed so resets produce the same distribution
var statusRng = new Random(99);
for (int k = statusPool.Count - 1; k > 0; k--)
{
var swap = statusRng.Next(k + 1);
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
}
// ── Priority pool: realistic distribution (total = 50) ─────────────────
// Rush jobs are genuinely rare; most work is Normal priority.
var priorityPool = new List();
foreach (var (code, count) in new (string Code, int Count)[]
{
("NORMAL", 38),
("HIGH", 6),
("URGENT", 4),
("RUSH", 2),
})
{
for (int k = 0; k < count; k++) priorityPool.Add(code);
}
var priorityRng = new Random(77);
for (int k = priorityPool.Count - 1; k > 0; k--)
{
var swap = priorityRng.Next(k + 1);
(priorityPool[k], priorityPool[swap]) = (priorityPool[swap], priorityPool[k]);
}
// ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ──
// A plain Fisher-Yates on the full list clusters commercial entries because they
// outnumber individual ones 4:1; splitting into two pools and distributing
// individual jobs evenly throughout ensures the two types never appear in blocks.
var commercialVisits = new List();
var individualVisits = new List();
for (int ci = 0; ci < customers.Count; ci++)
{
var (numJobs, _, _) = CustomerProfile(ci);
for (int j = 0; j < numJobs; j++)
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
}
var rngC = new Random(42);
for (int k = commercialVisits.Count - 1; k > 0; k--)
{
var swap = rngC.Next(k + 1);
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
}
var rngI = new Random(17);
for (int k = individualVisits.Count - 1; k > 0; k--)
{
var swap = rngI.Next(k + 1);
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
}
// Distribute individual visits at evenly-spaced positions throughout the commercial list
var visitSchedule = new List(commercialVisits.Count + individualVisits.Count);
double indStride = individualVisits.Count > 0
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
: double.MaxValue;
int indInsertIdx = 0;
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
{
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
visitSchedule.Add(individualVisits[indInsertIdx++]);
visitSchedule.Add(commercialVisits[comIdx]);
}
while (indInsertIdx < individualVisits.Count)
visitSchedule.Add(individualVisits[indInsertIdx++]);
// Job item descriptions and specs — 15-item pool cycling via (visitIdx*3 + itemIdx) % 15.
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
((i * 3 + j) % 15) switch
{
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame", "Candy Red", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
};
var jobs = new List();
var quoteIdx = 0;
var jobsByCustomer = new int[customers.Count]; // within-customer job counter per ci
var jobIdx = 0; // global counter for misc modulos
var inProgressCount = 0; // caps "Carried Over" card to 2 jobs
var completedJobCount = 0; // drives linear date spread over 12 months
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++)
{
var ci = visitSchedule[visitIdx];
var customer = customers[ci];
var j = jobsByCustomer[ci]++; // within-customer job index
var (_, minVal, maxVal) = CustomerProfile(ci);
var statusCode = statusPool[visitIdx];
var priorityCode = priorityPool[visitIdx];
// Try to link the first available approved quote for this customer
Quote? linkedQuote = null;
for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
{
if (approvedQuotes[qi].CustomerId == customer.Id)
{
linkedQuote = approvedQuotes[qi];
quoteIdx = qi + 1;
break;
}
// Every 4th job, forcibly consume the next available approved quote
if (quoteIdx % 4 == 0 && qi == quoteIdx)
{
linkedQuote = approvedQuotes[qi];
quoteIdx++;
break;
}
}
// Date logic: completed jobs furthest back, ready-for-pickup recent past,
// in-progress spread forward, pending/quoted in the future.
var isCompleted = statusCode is "COMPLETED" or "DELIVERED" or "CANCELLED";
var isReadyForPickup = statusCode == "READY_FOR_PICKUP";
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
if (isInProgress) inProgressCount++;
if (isCompleted) completedJobCount++;
// Completed jobs spread linearly ~1–12 months back for chart coverage.
// Ready-for-pickup: job finished recently, just waiting on customer.
// In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future.
int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14
: isReadyForPickup ? 5 + (visitIdx % 8)
: isInProgress ? 10 + (visitIdx % 40)
: 2 + (visitIdx % 15);
var createdDate = now.AddDays(-daysAgo);
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5))
: isReadyForPickup ? now.AddDays(visitIdx % 5)
: isInProgress ? now.AddDays(inProgressCount <= 3 ? -(4 - inProgressCount) : inProgressCount - 3)
: now.AddDays(3 + (visitIdx % 12));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null;
var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null;
// Per-customer value targeting: deterministic variance within the customer's price range
var range = maxVal - minVal;
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
var itemCount = 1 + (visitIdx % 3);
var items = new List();
for (int k = 0; k < itemCount; k++)
{
var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k);
var qty = 1 + (k % 3);
var unitPrice = linkedQuote != null && k == 0
? Math.Round(linkedQuote.Total / itemCount, 2)
: Math.Round(targetValue / itemCount / qty, 2);
items.Add(new JobItem
{
Description = desc,
Quantity = qty,
ColorName = color,
SurfaceAreaSqFt = 10m + k * 3.5m,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
LaborCost = Math.Round(unitPrice * qty * 0.35m, 2),
RequiresSandblasting = sand,
RequiresMasking = mask,
EstimatedMinutes = mins,
CompanyId = company.Id,
CreatedAt = createdDate
});
}
var finalPrice = items.Sum(it => it.TotalPrice);
var quotedPrice = linkedQuote?.Total ?? Math.Round(finalPrice * 1.05m, 2);
jobs.Add(new Job
{
JobNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
QuoteId = linkedQuote?.Id,
AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null,
Description = linkedQuote?.Description
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
JobStatusId = jobStatuses[statusCode],
JobPriorityId = jobPriorities[priorityCode],
ScheduledDate = scheduledDate,
StartedDate = startedDate,
CompletedDate = completedDate,
DueDate = dueDate,
QuotedPrice = quotedPrice,
FinalPrice = finalPrice,
IsRushJob = priorityCode == "RUSH",
CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null,
SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
RequiresCustomerApproval = visitIdx % 5 == 0,
IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress,
JobItems = items,
CompanyId = company.Id,
CreatedAt = createdDate,
// Set UpdatedAt to the historical event date so analytics charts group into the
// correct month. The EF interceptor only stamps UpdatedAt on Modified saves,
// leaving it null for seeded entities, which the analytics filter treats as excluded.
UpdatedAt = completedDate ?? (isInProgress ? scheduledDate : (DateTime?)null) ?? createdDate
});
}
await _context.Set().AddRangeAsync(jobs);
await _context.SaveChangesAsync();
return jobs.Count;
}
}