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; } }