275 lines
14 KiB
C#
275 lines
14 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Enums;
|
||
|
||
namespace PowderCoating.Infrastructure.Services;
|
||
|
||
public partial class SeedDataService
|
||
{
|
||
/// <summary>
|
||
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses,
|
||
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company.
|
||
/// </para>
|
||
/// <para>
|
||
/// The method depends on job-status and job-priority lookup rows (populated earlier in the
|
||
/// seed sequence), and on at least one customer record. It returns 0 if any of these
|
||
/// dependencies are missing so the overall seed degrades gracefully.
|
||
/// </para>
|
||
/// <para>
|
||
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans
|
||
/// existing numbers with the current month prefix and starts its sequence above the current
|
||
/// maximum so demo jobs never collide with real jobs created in the same calendar month.
|
||
/// </para>
|
||
/// <para>
|
||
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
|
||
/// quotes). When a match is found the job inherits the quote's customer, description,
|
||
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
|
||
/// </para>
|
||
/// <para>
|
||
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
|
||
/// in-progress (past start date, no completion), and completed/terminal (both started
|
||
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
|
||
/// views display a realistic spread rather than all jobs sharing the same date.
|
||
/// </para>
|
||
/// <para>
|
||
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
|
||
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="company">The tenant company to seed jobs for.</param>
|
||
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
|
||
private async Task<int> SeedJobsAsync(Company company)
|
||
{
|
||
var existingCount = await _context.Set<Job>()
|
||
.IgnoreQueryFilters()
|
||
.CountAsync(j => j.CompanyId == company.Id && !j.IsDeleted);
|
||
|
||
if (existingCount > 0)
|
||
return 0;
|
||
|
||
var jobStatuses = await _context.Set<JobStatusLookup>()
|
||
.IgnoreQueryFilters()
|
||
.Where(s => s.CompanyId == company.Id)
|
||
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
|
||
|
||
var jobPriorities = await _context.Set<JobPriorityLookup>()
|
||
.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<Customer>()
|
||
.IgnoreQueryFilters()
|
||
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
||
.OrderBy(c => c.Id)
|
||
.ToListAsync();
|
||
|
||
if (customers.Count == 0)
|
||
return 0;
|
||
|
||
// Grab approved quotes to link to jobs
|
||
var approvedQuotes = await _context.Set<Quote>()
|
||
.IgnoreQueryFilters()
|
||
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
|
||
.OrderBy(q => q.Id)
|
||
.ToListAsync();
|
||
|
||
var shopUsers = await _context.Set<ApplicationUser>()
|
||
.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<Job>()
|
||
.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;
|
||
|
||
// ── Status plan (50 jobs, covering all 16 statuses) ──────────────────
|
||
// Active pipeline: PENDING(4) QUOTED(3) APPROVED(4) IN_PREPARATION(4)
|
||
// SANDBLASTING(4) MASKING_TAPING(3) CLEANING(3) IN_OVEN(3)
|
||
// COATING(4) CURING(3) QUALITY_CHECK(3) COMPLETED(5)
|
||
// READY_FOR_PICKUP(4) DELIVERED(3) ON_HOLD(2) CANCELLED(2)
|
||
//
|
||
// Maps job index to a status code, distributing all 16 statuses across 50 jobs.
|
||
// ON_HOLD and CANCELLED are placed last (indices 48–49) because they are terminal
|
||
// side-branches that affect date logic and status history traversal differently.
|
||
static string StatusFor(int i) => i switch
|
||
{
|
||
< 4 => "PENDING",
|
||
< 7 => "QUOTED",
|
||
< 11 => "APPROVED",
|
||
< 15 => "IN_PREPARATION",
|
||
< 19 => "SANDBLASTING",
|
||
< 22 => "MASKING_TAPING",
|
||
< 25 => "CLEANING",
|
||
< 28 => "IN_OVEN",
|
||
< 32 => "COATING",
|
||
< 35 => "CURING",
|
||
< 38 => "QUALITY_CHECK",
|
||
< 43 => "COMPLETED",
|
||
< 47 => "READY_FOR_PICKUP",
|
||
< 48 => "DELIVERED",
|
||
< 49 => "ON_HOLD",
|
||
_ => "CANCELLED"
|
||
};
|
||
|
||
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally
|
||
// over-represented (4 of 10) relative to production averages so the priority colour
|
||
// badges and rush-fee logic are clearly visible in demo data.
|
||
static string PriorityFor(int i) => (i % 10) switch
|
||
{
|
||
0 => "RUSH",
|
||
1 => "RUSH",
|
||
2 => "URGENT",
|
||
3 => "URGENT",
|
||
4 => "HIGH",
|
||
5 => "HIGH",
|
||
6 => "HIGH",
|
||
_ => "NORMAL"
|
||
};
|
||
|
||
// Returns description, finish color, prep flags, and estimated minutes for a job item.
|
||
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index,
|
||
// preventing every job from having the same first item.
|
||
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 — Matte Black", "Matte Black", true, false, 45),
|
||
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30),
|
||
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40),
|
||
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90),
|
||
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55),
|
||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
|
||
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50),
|
||
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120),
|
||
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180),
|
||
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35),
|
||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
|
||
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60),
|
||
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
|
||
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50),
|
||
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40)
|
||
};
|
||
|
||
var jobs = new List<Job>();
|
||
var quoteIdx = 0;
|
||
|
||
for (int i = 0; i < 50; i++)
|
||
{
|
||
var statusCode = StatusFor(i);
|
||
var priorityCode = PriorityFor(i);
|
||
var customer = customers[i % customers.Count];
|
||
|
||
// Link an approved quote to the first 25 in-progress/active jobs
|
||
Quote? linkedQuote = null;
|
||
if (i < 25 && quoteIdx < approvedQuotes.Count)
|
||
{
|
||
// Only link if the quote's customer matches OR if customers align by index
|
||
linkedQuote = approvedQuotes[quoteIdx++];
|
||
customer = customers.FirstOrDefault(c => c.Id == linkedQuote.CustomerId) ?? customer;
|
||
}
|
||
|
||
// Date logic — creation spread from -21 days to today
|
||
// Scheduled: future for early statuses, past for completed ones
|
||
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
|
||
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
||
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
|
||
|
||
int daysAgo = isCompleted ? 14 + (i % 7)
|
||
: isInProgress ? 5 + (i % 7)
|
||
: 0 + (i % 5);
|
||
var createdDate = now.AddDays(-daysAgo);
|
||
var scheduledDate = isCompleted ? createdDate.AddDays(2)
|
||
: isInProgress ? now.AddDays(-(i % 3))
|
||
: now.AddDays(2 + (i % 10));
|
||
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||
var dueDate = scheduledDate.AddDays(rushDays);
|
||
var startedDate = (!isEarly) ? scheduledDate : (DateTime?)null;
|
||
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||
|
||
var assignedUserId = shopUsers.Count > 0 ? shopUsers[i % shopUsers.Count].Id : null;
|
||
|
||
var itemCount = 1 + (i % 3);
|
||
var items = new List<JobItem>();
|
||
|
||
for (int j = 0; j < itemCount; j++)
|
||
{
|
||
var (desc, color, sand, mask, mins) = ItemSpec(i, j);
|
||
var qty = 1 + (j % 3);
|
||
var unitPrice = linkedQuote != null && j == 0
|
||
? Math.Round((linkedQuote.Total / itemCount), 2)
|
||
: Math.Round(75m + (i % 8) * 12.5m + j * 15m, 2);
|
||
|
||
items.Add(new JobItem
|
||
{
|
||
Description = desc,
|
||
Quantity = qty,
|
||
ColorName = color,
|
||
SurfaceAreaSqFt = 10m + j * 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 = assignedUserId,
|
||
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 = linkedQuote?.CustomerPO ?? (i % 3 == 0 ? $"PO-{40000 + i}" : null),
|
||
SpecialInstructions = i % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||
i % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||
InternalNotes = i % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||
RequiresCustomerApproval = i % 5 == 0,
|
||
IsCustomerApproved = i % 5 != 0 || !isEarly,
|
||
JobItems = items,
|
||
CompanyId = company.Id,
|
||
CreatedAt = createdDate
|
||
});
|
||
|
||
seq++;
|
||
}
|
||
|
||
await _context.Set<Job>().AddRangeAsync(jobs);
|
||
await _context.SaveChangesAsync();
|
||
|
||
return jobs.Count;
|
||
}
|
||
}
|