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>
This commit is contained in:
@@ -7,29 +7,28 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 50 powder coating jobs distributed across all 16 statuses, with deliberate
|
||||
/// per-customer revenue targeting so the Revenue by Customer report matches the demo
|
||||
/// company narrative: Carolina Fabrication largest, then Apex Motorsports, Triangle Offroad,
|
||||
/// Smith Welding, and so on down to small individual residential jobs.
|
||||
/// 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>
|
||||
/// Job counts and price ranges are defined per customer index (0 = Carolina Fabrication,
|
||||
/// the top-revenue account) via <c>CustomerProfile(ci)</c>. This replaces the previous
|
||||
/// modulo-formula approach that produced uniform pricing regardless of customer tier.
|
||||
/// 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>
|
||||
/// All 16 statuses are covered: the 16 statuses cycle through the first 16 jobs in
|
||||
/// sequence, guaranteeing every pipeline stage is populated even with only 50 total jobs.
|
||||
/// 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>
|
||||
/// Date logic groups jobs into three buckets — completed (60–150 days ago), in-progress
|
||||
/// (10–50 days ago), and early-stage (2–17 days ago or future-scheduled) — to produce
|
||||
/// realistic dashboard pipeline and calendar views.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first approved quote available for each customer is linked to their first job
|
||||
/// when a match exists, demonstrating the quote-to-job conversion workflow.
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> SeedJobsAsync(Company company)
|
||||
@@ -88,11 +87,11 @@ public partial class SeedDataService
|
||||
var seq = maxNum + 1;
|
||||
|
||||
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
|
||||
// Indices match the customer order seeded in SeedCustomersAsync:
|
||||
// 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 (smaller jobs)
|
||||
// 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
|
||||
@@ -109,9 +108,9 @@ public partial class SeedDataService
|
||||
11 => (1, 150m, 350m), // Sarah Jenkins
|
||||
12 => (1, 200m, 400m), // Mike Thompson
|
||||
13 => (2, 100m, 300m), // Robert Miller
|
||||
14 => (0, 0m, 0m), // Jennifer Clark — no jobs yet
|
||||
14 => (0, 0m, 0m), // Jennifer Clark — prospect only
|
||||
15 => (1, 100m, 250m), // David Wilson
|
||||
16 => (0, 0m, 0m), // Lisa Anderson — no jobs yet
|
||||
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
|
||||
@@ -124,29 +123,99 @@ public partial class SeedDataService
|
||||
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
|
||||
};
|
||||
|
||||
// All 16 statuses cycle globally so every pipeline stage is visible.
|
||||
string[] allStatuses =
|
||||
[
|
||||
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING",
|
||||
"QUALITY_CHECK", "COMPLETED", "READY_FOR_PICKUP", "DELIVERED",
|
||||
"ON_HOLD", "CANCELLED"
|
||||
];
|
||||
string StatusFor(int jobIdx) => allStatuses[jobIdx % allStatuses.Length];
|
||||
|
||||
// Priority distribution weighted toward the interesting end for demo visibility.
|
||||
static string PriorityFor(int i) => (i % 10) switch
|
||||
// ── 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)[]
|
||||
{
|
||||
0 => "RUSH",
|
||||
1 => "RUSH",
|
||||
2 => "URGENT",
|
||||
3 => "URGENT",
|
||||
4 => "HIGH",
|
||||
5 => "HIGH",
|
||||
_ => "NORMAL"
|
||||
};
|
||||
("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]);
|
||||
}
|
||||
|
||||
// Job item descriptions and specs — 15-item pool cycling via (jobIdx*3 + itemIdx) % 15.
|
||||
// ── 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 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<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
|
||||
{
|
||||
@@ -157,128 +226,145 @@ public partial class SeedDataService
|
||||
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),
|
||||
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)
|
||||
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
|
||||
};
|
||||
|
||||
var jobs = new List<Job>();
|
||||
var quoteIdx = 0;
|
||||
var jobIdx = 0;
|
||||
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 ci = 0; ci < customers.Count; ci++)
|
||||
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++)
|
||||
{
|
||||
var ci = visitSchedule[visitIdx];
|
||||
var customer = customers[ci];
|
||||
var (numJobs, minVal, maxVal) = CustomerProfile(ci);
|
||||
var j = jobsByCustomer[ci]++; // within-customer job index
|
||||
var (_, minVal, maxVal) = CustomerProfile(ci);
|
||||
|
||||
for (int j = 0; j < numJobs; j++, jobIdx++, seq++)
|
||||
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++)
|
||||
{
|
||||
var statusCode = StatusFor(jobIdx);
|
||||
var priorityCode = PriorityFor(jobIdx);
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (approvedQuotes[qi].CustomerId == customer.Id)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx = qi + 1;
|
||||
break;
|
||||
}
|
||||
// Every 4th job forcibly links any available approved quote
|
||||
if (quoteIdx % 4 == 0 && qi == quoteIdx)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx++;
|
||||
break;
|
||||
}
|
||||
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
|
||||
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
|
||||
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||||
// 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";
|
||||
|
||||
int daysAgo = isCompleted ? 60 + (jobIdx % 90)
|
||||
: isInProgress ? 10 + (jobIdx % 40)
|
||||
: 2 + (jobIdx % 15);
|
||||
var createdDate = now.AddDays(-daysAgo);
|
||||
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (jobIdx % 5))
|
||||
: isInProgress ? now.AddDays(-(jobIdx % 4))
|
||||
: now.AddDays(3 + (jobIdx % 12));
|
||||
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||||
var dueDate = scheduledDate.AddDays(rushDays);
|
||||
var startedDate = !isCompleted && !isInProgress ? (DateTime?)null : scheduledDate;
|
||||
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||
if (isInProgress) inProgressCount++;
|
||||
if (isCompleted) completedJobCount++;
|
||||
|
||||
// Per-customer value targeting: deterministic within the customer's price range
|
||||
var range = maxVal - minVal;
|
||||
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
|
||||
var itemCount = 1 + (jobIdx % 3);
|
||||
var items = new List<JobItem>();
|
||||
// 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);
|
||||
|
||||
for (int k = 0; k < itemCount; k++)
|
||||
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
|
||||
{
|
||||
var (desc, color, sand, mask, mins) = ItemSpec(jobIdx, 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[jobIdx % 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 && jobIdx % 3 == 0 ? $"PO-{40000 + jobIdx}" : null,
|
||||
SpecialInstructions = jobIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||
jobIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||
InternalNotes = jobIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||
RequiresCustomerApproval = jobIdx % 5 == 0,
|
||||
IsCustomerApproved = jobIdx % 5 != 0 || !isInProgress,
|
||||
JobItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = createdDate
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user