diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.AiPredictions.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.AiPredictions.cs
new file mode 100644
index 0000000..4f33fe5
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.AiPredictions.cs
@@ -0,0 +1,140 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public partial class SeedDataService
+{
+ ///
+ /// Seeds 8 demo records and attaches them to the first
+ /// 8 eligible records, marking those items as AI-analysed.
+ ///
+ ///
+ ///
+ /// "Eligible" means SurfaceAreaSqFt > 0, not a labor item, and not already
+ /// linked to a prediction. This ensures the seeder is safe to run even if a partial seed
+ /// left some items pre-linked.
+ ///
+ ///
+ /// Each prediction record captures a realistic AI analysis: predicted surface area,
+ /// estimated minutes, complexity tier, unit price, confidence level, reasoning text, and
+ /// comma-separated AI tags. Three of the eight items have UserOverrodeEstimate = true
+ /// to demonstrate the override-tracking feature on the AI Accuracy report.
+ ///
+ ///
+ /// Items are updated in-place with IsAiItem = true and the FK
+ /// AiPredictionId pointing to the new prediction. SaveChangesAsync is called
+ /// per item so any single FK conflict (unlikely in a fresh seed) does not abort the others.
+ ///
+ /// Idempotency: returns 0 immediately if any AiItemPrediction records already exist for
+ /// the company.
+ ///
+ /// The tenant company to seed predictions for.
+ /// Number of prediction records inserted, or 0 if already seeded.
+ private async Task SeedAiPredictionsAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
+
+ if (existingCount > 0)
+ return 0;
+
+ // Grab the first 8 eligible quote items ordered by id for determinism
+ var quoteItems = await _context.Set()
+ .IgnoreQueryFilters()
+ .Include(qi => qi.Quote)
+ .Where(qi => qi.CompanyId == company.Id
+ && !qi.IsDeleted
+ && qi.SurfaceAreaSqFt > 0
+ && !qi.IsLaborItem
+ && qi.AiPredictionId == null)
+ .OrderBy(qi => qi.Id)
+ .Take(8)
+ .ToListAsync();
+
+ if (quoteItems.Count == 0)
+ return 0;
+
+ // Per-slot prediction specs — deterministic, varied across complexity/confidence tiers.
+ // PredictedSqFt is intentionally close but NOT identical to the actual item SqFt so the
+ // AI Accuracy report shows realistic prediction deltas.
+ var specs = new[]
+ {
+ // slot 0 — complex automotive, AI nailed it
+ ( sqft: 13.5m, mins: 88, complexity: "Complex", confidence: "High",
+ price: 125.00m, tags: "automotive,tubular,custom", rounds: 1, overrode: false,
+ reasoning: "Detected a tubular motorcycle frame with multiple weld joints. High complexity due to intricate geometry and masking requirements around bearing surfaces. Confidence high — similar frames appear frequently in training data." ),
+
+ // slot 1 — wheel set, quick read, accepted as-is
+ ( sqft: 11.2m, mins: 42, complexity: "Simple", confidence: "High",
+ price: 98.00m, tags: "automotive,wheels,aluminum", rounds: 1, overrode: false,
+ reasoning: "Four aluminum wheels, uniform shape, minimal masking needed. Straightforward batch candidate for the main oven. Estimated surface area based on standard 18\" wheel profile." ),
+
+ // slot 2 — bumper job, user bumped sqft slightly
+ ( sqft: 14.8m, mins: 62, complexity: "Moderate", confidence: "Medium",
+ price: 135.00m, tags: "automotive,bumper,off-road", rounds: 2, overrode: true,
+ reasoning: "Steel off-road bumper and rock sliders. Moderate complexity — flat stock with mounting tabs. Second image round requested for accurate rock slider dimensions. User adjusted surface area slightly after physical measurement." ),
+
+ // slot 3 — large gate, low confidence, user corrected price
+ ( sqft: 34.2m, mins: 195, complexity: "Complex", confidence: "Low",
+ price: 310.00m, tags: "architectural,gate,ornamental", rounds: 2, overrode: true,
+ reasoning: "Wrought iron entry gate with decorative scrollwork. Low confidence due to depth ambiguity in photos — scrollwork surface area is difficult to estimate from images alone. Recommend physical measurement before finalising price. User overrode unit price after measuring on-site." ),
+
+ // slot 4 — patio furniture, solid read
+ ( sqft: 22.8m, mins: 52, complexity: "Moderate", confidence: "High",
+ price: 195.00m, tags: "furniture,outdoor,patio", rounds: 1, overrode: false,
+ reasoning: "Six-piece patio furniture set: four chairs, one table, one side table. Powder-coated tubular steel, standard outdoor finish. Good photo coverage — confidence high. Recommend Textured Beige or Satin Bronze for exterior durability." ),
+
+ // slot 5 — handrail, accepted price
+ ( sqft: 39.0m, mins: 118, complexity: "Moderate", confidence: "High",
+ price: 342.00m, tags: "architectural,handrail,railing", rounds: 1, overrode: false,
+ reasoning: "40-foot steel handrail system, square tube construction. Consistent profile makes area calculation straightforward. Standard Gloss Black most common finish for this application — confirmed with customer." ),
+
+ // slot 6 — brake calipers, small & simple
+ ( sqft: 3.8m, mins: 30, complexity: "Simple", confidence: "High",
+ price: 65.00m, tags: "automotive,brake,caliper", rounds: 1, overrode: false,
+ reasoning: "Set of four brake calipers, cast iron with machined mating surfaces. Masking required on piston bores and bleed nipples. Candy Red most requested finish. High confidence — calipers are a common item with well-established pricing." ),
+
+ // slot 7 — bicycle frame, two-round conversation
+ ( sqft: 6.1m, mins: 65, complexity: "Moderate", confidence: "Medium",
+ price: 82.00m, tags: "recreational,bicycle,frame", rounds: 2, overrode: true,
+ reasoning: "Road bicycle frame, aluminium alloy. Second image round needed to assess cable routing channels and dropout geometry. Moderate complexity due to small-radius bends. User adjusted surface area after AI initially underestimated top-tube length." )
+ };
+
+ var seeded = 0;
+
+ for (int i = 0; i < quoteItems.Count && i < specs.Length; i++)
+ {
+ var item = quoteItems[i];
+ var s = specs[i];
+
+ var prediction = new AiItemPrediction
+ {
+ PredictedSurfaceAreaSqFt = s.sqft,
+ PredictedMinutes = s.mins,
+ PredictedComplexity = s.complexity,
+ PredictedUnitPrice = s.price,
+ Confidence = s.confidence,
+ Reasoning = s.reasoning,
+ AiTags = s.tags,
+ ConversationRounds = s.rounds,
+ UserOverrodeEstimate = s.overrode,
+ CompanyId = company.Id,
+ CreatedAt = item.CreatedAt
+ };
+
+ await _context.Set().AddAsync(prediction);
+ await _context.SaveChangesAsync();
+ seeded++;
+
+ // Mark the quote item as AI-analysed and link the prediction
+ item.IsAiItem = true;
+ item.AiPredictionId = prediction.Id;
+ item.AiTags = s.tags;
+ await _context.SaveChangesAsync();
+ }
+
+ return seeded;
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Appointments.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Appointments.cs
index c2a5348..ec4ef95 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Appointments.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Appointments.cs
@@ -337,15 +337,122 @@ public partial class SeedDataService
var startDate = DateTime.Today;
var appointmentTitles = new Dictionary
{
- ["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
- ["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
+ ["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
+ ["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" },
- ["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
+ ["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
};
// Get status IDs by code for easy assignment
- var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
- var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
+ var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
+ var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
+ var completedStatusId = appointmentStatuses.First(s => s.StatusCode == "COMPLETED").Id;
+ var cancelledStatusId = appointmentStatuses.First(s => s.StatusCode == "CANCELLED").Id;
+ var noShowStatusId = appointmentStatuses.First(s => s.StatusCode == "NO_SHOW").Id;
+ var rescheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "RESCHEDULED").Id;
+
+ // ── PAST APPOINTMENTS (last 90 days) — Completed, Cancelled, No Show ──────
+ // Walks backward through weekdays; ~40% chance of an appointment per day
+ // for ~25 records spread naturally across the history window.
+ var pastRandom = new Random(77); // separate seed keeps past/future independent
+ var pastAppointmentSeq = 1;
+
+ static string? CancelNote(Random r)
+ {
+ var reasons = new[]
+ {
+ "Customer cancelled — rescheduling for next week.",
+ "Customer cancelled — no reason given.",
+ "Shop closed for equipment maintenance.",
+ "Customer called to reschedule.",
+ "Customer unavailable — will call back.",
+ "Cancelled by shop — scheduling conflict."
+ };
+ return reasons[r.Next(reasons.Length)];
+ }
+
+ for (int daysBack = 1; daysBack <= 90 && pastAppointmentSeq <= 25; daysBack++)
+ {
+ var pastDate = DateTime.Today.AddDays(-daysBack);
+ if (pastDate.DayOfWeek == DayOfWeek.Saturday || pastDate.DayOfWeek == DayOfWeek.Sunday)
+ continue;
+ if (pastRandom.Next(100) >= 40) // ~40% chance = ~26 weekday hits over 90 days
+ continue;
+
+ var aptType = appointmentTypes[pastRandom.Next(appointmentTypes.Count)];
+ var customer = customers[pastRandom.Next(customers.Count)];
+
+ int startHour = pastRandom.Next(8, 17);
+ int startMinute = pastRandom.Next(0, 4) * 15;
+ var aptStart = new DateTime(pastDate.Year, pastDate.Month, pastDate.Day, startHour, startMinute, 0, DateTimeKind.Utc);
+ int duration = pastRandom.Next(1, 5) * 30;
+ var aptEnd = aptStart.AddMinutes(duration);
+
+ // 60% Completed, 25% Cancelled, 10% No Show, 5% Rescheduled
+ int roll = pastRandom.Next(100);
+ int pastStatusId;
+ DateTime? actualStart = null, actualEnd = null;
+ string? pastNotes = null;
+
+ if (roll < 60)
+ {
+ pastStatusId = completedStatusId;
+ actualStart = aptStart.AddMinutes(pastRandom.Next(-5, 11)); // ±5–10 min variance
+ actualEnd = aptEnd.AddMinutes(pastRandom.Next(-10, 16));
+ }
+ else if (roll < 85)
+ {
+ pastStatusId = cancelledStatusId;
+ pastNotes = CancelNote(pastRandom);
+ }
+ else if (roll < 95)
+ {
+ pastStatusId = noShowStatusId;
+ pastNotes = "Customer did not arrive. Follow-up call left.";
+ }
+ else
+ {
+ pastStatusId = rescheduledStatusId;
+ pastNotes = "Rescheduled at customer request — see follow-up appointment.";
+ }
+
+ // Optional job link (35% chance for past JOB_WORK; 15% for others)
+ int? pastJobId = null;
+ if (jobs.Any())
+ {
+ int linkChance = aptType.TypeCode == "JOB_WORK" ? 35 : 15;
+ if (pastRandom.Next(100) < linkChance)
+ pastJobId = jobs[pastRandom.Next(jobs.Count)].Id;
+ }
+
+ string? assignedId = null;
+ if (workers.Any() && pastRandom.Next(100) < 60)
+ assignedId = workers[pastRandom.Next(workers.Count)].Id;
+
+ var pastLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
+ var pastTitle = $"{pastLabel} — {appointmentTitles[aptType.TypeCode][pastRandom.Next(appointmentTitles[aptType.TypeCode].Length)]}";
+
+ appointments.Add(new Appointment
+ {
+ AppointmentNumber = $"APT-{pastDate:yyMM}-{pastAppointmentSeq++:D4}",
+ CustomerId = customer.Id,
+ JobId = pastJobId,
+ AppointmentStatusId = pastStatusId,
+ AppointmentTypeId = aptType.Id,
+ AssignedUserId = assignedId,
+ Title = pastTitle,
+ ScheduledStartTime = aptStart,
+ ScheduledEndTime = aptEnd,
+ ActualStartTime = actualStart,
+ ActualEndTime = actualEnd,
+ IsAllDay = false,
+ IsReminderEnabled = false, // reminders don't fire for past appointments
+ ReminderMinutesBefore = 30,
+ Notes = pastNotes,
+ CompanyId = company.Id,
+ CreatedAt = aptStart.AddDays(-pastRandom.Next(1, 8)) // booked 1–7 days ahead
+ });
+ }
// Generate 50 appointments across next 60 days (weekdays only)
int appointmentsCreated = 0;
@@ -388,7 +495,8 @@ public partial class SeedDataService
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
// Title
- string title = $"{customer.CompanyName} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
+ string customerLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
+ string title = $"{customerLabel} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
// Optional job link (40% chance if type is JOB_WORK, 20% for others)
int? jobId = null;
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
index 85fc858..cf2c356 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
@@ -180,6 +180,14 @@ public partial class SeedDataService
if (seededQuoteIds.Any())
{
+ // Collect prediction IDs before removing items (FK is NoAction — predictions
+ // must be deleted after the items that reference them are gone).
+ var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
+ .Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
+ .Select(qi => qi.AiPredictionId!.Value)
+ .Distinct()
+ .ToListAsync();
+
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
@@ -193,6 +201,21 @@ public partial class SeedDataService
_context.Quotes.RemoveRange(quotes);
totalRemoved += quotes.Count;
details.Add($"✓ Removed {quotes.Count} seeded quote(s)");
+
+ // Remove orphaned AI predictions now that QuoteItems no longer reference them
+ if (predictionIds.Any())
+ {
+ var predictions = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(p => predictionIds.Contains(p.Id))
+ .ToListAsync();
+ if (predictions.Any())
+ {
+ _context.Set().RemoveRange(predictions);
+ totalRemoved += predictions.Count;
+ details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
+ }
+ }
}
// Customer notes
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
index b70cd78..4e44671 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
@@ -425,6 +425,7 @@ public partial class SeedDataService : ISeedDataService
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
+ await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));