Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Jobs.cs
T
spouliot 7735fe3cce Demo data realism + invoice resend via SMS on any status
Seed data fixes:
- Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added
  entities — root cause of all "same month" chart issues
- Customer seeder: generates 15 customers/month from Jan → current month;
  keeps 10 commercial anchors in deterministic order for job seeder index map
- Invoice seeder: historical range bumped from 2→8 paid invoices/month so
  P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses)
- Month -1 bumped to 7 paid invoices to stay above expenses
- Jobs: set UpdatedAt to historical event date so analytics don't need null fallback
- Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for
  revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs
- SeedDataService: inject IAccountBalanceService; auto-recalculate account balances
  after seeding; patch checking/savings opening balances unconditionally on reset
- Customer list: sort by CompanyName ?? ContactLastName so individuals and
  commercial accounts interleave instead of appearing as two blocks

Invoice resend:
- ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only
  resend no longer requires an email address on file
- Ensures PublicViewToken exists before SMS so the view link is always valid
- canResend in Details view now allows Paid invoices (removed != Paid guard)
- Resend button shows channel-choice modal when customer has both email + SMS,
  direct SMS button when SMS only, or email button when email only
- New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice
- resendInvoice() JS updated to pass sendEmail/sendSms query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:20:04 -04:00

376 lines
19 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;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// Per-customer job counts and price ranges are defined by <c>CustomerProfile(ci)</c>
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
/// </para>
/// <para>
/// A shuffled <c>visitSchedule</c> (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.
/// </para>
/// <para>
/// Status pool (fixed seed 99): Delivered &times;10, Completed &times;8,
/// ReadyForPickup &times;5, then decreasing counts for in-progress stages, with
/// exactly one Cancelled and one OnHold.
/// Priority pool (fixed seed 77): Normal 76&thinsp;%, High 12&thinsp;%, Urgent 8&thinsp;%,
/// Rush 4&thinsp;% — rush is genuinely rare.
/// </para>
/// </remarks>
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;
var approvedQuotes = await _context.Set<Quote>()
.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<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;
// ── 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,
// 1026 = 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<string>();
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<string>();
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 09) 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<int>();
var individualVisits = new List<int>();
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<int>(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<Job>();
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 ~112 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<JobItem>();
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<Job>().AddRangeAsync(jobs);
await _context.SaveChangesAsync();
return jobs.Count;
}
}