Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Quotes.cs
T
spouliot 01f6897d08 Scale demo seed data down for tutorial recordings
Customers: 100 → 27 (15 commercial across auto/industrial/architectural/
fitness/marine/energy, including 2 tax-exempt govts; 12 individuals)

Quotes: 75 → 20; date range extended to 4-6 months (was 90 days);
status distribution adjusted proportionally (2 draft, 3 sent, 10 approved,
3 rejected, 2 expired)

Jobs: fixed 50-loop → per-customer 0-5 jobs (~32 total); jobIdx cycles
all 16 statuses globally so every status is visible; creation dates spread
across 1-5 months for in-progress/early jobs, 2-6 months for completed jobs

SeededCustomerEmails updated to match new 27-customer set (added
gnelson@email.com and carol.evans@email.com)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:56:32 -04:00

273 lines
12 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;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 75 realistic powder coating quotes spread across seven item categories
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc)
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35),
/// Rejected (10), and Expired (10).
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for
/// this company, preventing duplicate quote sets on repeated seed runs.
/// </para>
/// <para>
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans
/// existing numbers with the current month prefix and starts its sequence above the
/// current maximum so seeded quotes never collide with real quotes created in the
/// same month.
/// </para>
/// <para>
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost
/// config that may not yet be populated when seed runs.
/// </para>
/// <para>
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote.
/// </para>
/// <para>
/// The method requires that customers and quote-status lookup rows already exist for the
/// company; it returns 0 if either dependency is missing so that the overall seed
/// operation degrades gracefully rather than throwing.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed quotes for.</param>
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedQuotesAsync(Company company)
{
var existingCount = await _context.Set<Quote>()
.IgnoreQueryFilters()
.CountAsync(q => q.CompanyId == company.Id && !q.IsDeleted);
if (existingCount > 0)
return 0;
var quoteStatuses = await _context.Set<QuoteStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
if (quoteStatuses.Count == 0)
return 0;
// Load all commercial customers
var customers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted)
.OrderBy(c => c.Id)
.ToListAsync();
if (customers.Count == 0)
return 0;
var preparedByUser = await _userManager.Users
.Where(u => u.CompanyId == company.Id)
.FirstOrDefaultAsync();
var now = DateTime.UtcNow;
// Avoid duplicate quote numbers
var prefix = $"QT-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Quote>()
.IgnoreQueryFilters()
.Where(q => q.QuoteNumber.StartsWith(prefix))
.Select(q => q.QuoteNumber)
.ToListAsync();
var maxNum = 0;
foreach (var n in existing)
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Data arrays for varied, realistic content ─────────────────────────
// Returns an array of realistic item descriptions for a given category bucket (06).
// Using a local static function keeps the description data close to where it is
// consumed and avoids polluting the partial class with per-seeder detail arrays.
static string[] ItemDescs(int category) => category switch
{
0 => new[] {
"18\" Aluminum Wheels — Matte Black",
"17\" Steel Wheels — Gloss White",
"20\" Alloy Wheels — Satin Silver",
"16\" Chrome Replica Wheels — Gloss Black",
"Motorcycle Frame — Flat Black",
"Motorcycle Swingarm & Forks — Gloss Black",
"Exhaust Headers — High-Temp Flat Black",
"Intake Manifold — Wrinkle Red",
"Valve Covers — Gloss Red",
"Brake Calipers — Gloss Yellow" },
1 => new[] {
"Steel Shelving Units (10-shelf set)",
"Industrial Equipment Frame",
"Machine Guard Panels",
"Conveyor Frame Sections",
"Heavy Equipment Brackets",
"Pump Housing Assembly",
"Control Panel Enclosure",
"Storage Rack System",
"Scissor Lift Platform",
"Compressor Tank" },
2 => new[] {
"Aluminum Window Frames (set of 8)",
"Steel Handrail System — 40 ft",
"Wrought Iron Fence Panels (6-panel set)",
"Entry Gate — Custom Design",
"Structural Steel Columns (set of 4)",
"Balcony Railing — Satin Black",
"Steel Door Frames (3 units)",
"Architectural Steel Beams",
"Decorative Ironwork — Stair Baluster",
"Aluminum Storefront Frame" },
3 => new[] {
"Commercial Gym Equipment Frame",
"Weight Rack & Benches",
"Outdoor Playground Equipment Parts",
"Bicycle Frame — Gloss Blue",
"BMX Frame Set — Candy Red" },
4 => new[] {
"Boat Trailer Frame — Marine Grade",
"Aluminum Dock Cleats & Hardware",
"Outboard Motor Bracket",
"Marine Fuel Tank Brackets" },
5 => new[] {
"Restaurant Chair Frames (set of 20)",
"Steel Dining Table Bases (set of 8)",
"Patio Furniture Set — 6 Pieces",
"Café Chairs — Hammered Bronze (12-pc)",
"Commercial Bar Stools (set of 10)" },
_ => new[] {
"Custom Steel Parts — Batch Order",
"Agricultural Equipment Panels",
"Traffic Sign Frames (set of 15)",
"Utility Trailer Hitch Assembly",
"Solar Panel Mounting Brackets" }
};
// Returns finish color, prep flags, estimated minutes, and surface area for item index i.
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table.
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch
{
0 => ("Matte Black", true, false, 45, 12.0m),
1 => ("Gloss White", false, false, 30, 8.5m),
2 => ("Satin Silver", true, true, 60, 15.0m),
3 => ("Candy Red", false, true, 35, 9.0m),
4 => ("Textured Gray", true, false, 50, 18.0m),
5 => ("Gloss Black", true, false, 40, 11.0m),
6 => ("Hammered Bronze", false, false, 55, 20.0m),
7 => ("Satin Graphite", true, true, 65, 25.0m),
_ => ("Flat Black", true, false, 35, 10.0m)
};
// Maps quote index to a status code.
// APPROVED is the majority (10/20) to give SeedJobsAsync enough approved quotes to link jobs to.
static string StatusFor(int i) => i switch
{
< 2 => "DRAFT",
< 5 => "SENT",
< 15 => "APPROVED",
< 18 => "REJECTED",
_ => "EXPIRED"
};
var quotes = new List<Quote>();
for (int i = 0; i < 20; i++)
{
var customer = customers[i % customers.Count];
var statusCode = StatusFor(i);
// Spread creation dates over the past 120180 days (4-6 months); older first
var daysAgo = 180 - (int)(i * 9.0);
var quoteDate = now.AddDays(-daysAgo);
var expireDate = quoteDate.AddDays(30);
var category = i % 7;
var descs = ItemDescs(category);
var itemCount = 1 + (i % 3);
var items = new List<QuoteItem>();
for (int j = 0; j < itemCount; j++)
{
var desc = descs[(i + j) % descs.Length];
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
var qty = 1 + (j % 4);
// Unit price scales with surface area and adds a modest multiplier per customer tier
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
items.Add(new QuoteItem
{
Description = desc,
Quantity = qty,
SurfaceAreaSqFt = sqft * qty,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
RequiresSandblasting = sand,
RequiresMasking = mask,
EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null,
CompanyId = company.Id,
CreatedAt = quoteDate
});
}
var subtotal = items.Sum(it => it.TotalPrice);
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee;
var quote = new Quote
{
QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial,
IsRushJob = i % 12 == 0,
QuoteDate = quoteDate,
ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
ItemsSubtotal = subtotal,
SubTotal = subtotal,
DiscountPercent = discountPct,
DiscountAmount = discountAmt,
TaxPercent = taxPct,
TaxAmount = taxAmt,
RushFee = rushFee,
Total = total,
Description = $"Powder coating services — {descs[i % descs.Length].Split('—')[0].Trim()}",
Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 7 == 0 ? "Customer requested color sample before full run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null,
RequiresDeposit = i % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m,
QuoteItems = items,
CompanyId = company.Id,
CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
};
quotes.Add(quote);
seq++;
}
await _context.Set<Quote>().AddRangeAsync(quotes);
await _context.SaveChangesAsync();
return quotes.Count;
}
}