273 lines
12 KiB
C#
273 lines
12 KiB
C#
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 (0–6).
|
||
// 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 following the distribution plan above.
|
||
// APPROVED is the majority (35/75) to give SeedJobsAsync enough approved quotes to link jobs to.
|
||
static string StatusFor(int i) => i switch
|
||
{
|
||
< 8 => "DRAFT",
|
||
< 20 => "SENT",
|
||
< 55 => "APPROVED",
|
||
< 65 => "REJECTED",
|
||
_ => "EXPIRED"
|
||
};
|
||
|
||
var quotes = new List<Quote>();
|
||
|
||
for (int i = 0; i < 75; i++)
|
||
{
|
||
var customer = customers[i % customers.Count];
|
||
var statusCode = StatusFor(i);
|
||
|
||
// Spread creation dates over the past 90 days; older first
|
||
var daysAgo = 90 - (int)(i * 1.2);
|
||
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;
|
||
}
|
||
}
|