Initial commit
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user