From c0e4a661261be5d69e4c70c3eb923721e1de95da Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 10 Jun 2026 22:40:12 -0400 Subject: [PATCH] Phase 4: Past appointments + AI prediction demo data - Appointments: add ~25 past appointments (last 90 days) with Completed, Cancelled, No Show, and Rescheduled statuses; completed records carry ActualStartTime/ActualEndTime with realistic variance; cancel/no-show notes explain why; customer label falls back to ContactFirst/LastName for residential customers - Fix future appointment title for residential customers (was always using CompanyName which is null for individuals) - New SeedDataService.AiPredictions.cs: seeds 8 AiItemPrediction records (varied complexity/confidence/tags/reasoning) and attaches them to the first 8 eligible QuoteItems, marking those items IsAiItem=true; 3 of 8 have UserOverrodeEstimate=true for AI Accuracy report demo - SeedDataService.cs: wire SeedAiPredictionsAsync after Invoices - Remove.cs: collect QuoteItem.AiPredictionId FKs before deleting items, then delete orphaned AiItemPrediction records after quotes are removed Co-Authored-By: Claude Sonnet 4.6 --- .../Services/SeedDataService.AiPredictions.cs | 140 ++++++++++++++++++ .../Services/SeedDataService.Appointments.cs | 120 ++++++++++++++- .../Services/SeedDataService.Remove.cs | 23 +++ .../Services/SeedDataService.cs | 1 + 4 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 src/PowderCoating.Infrastructure/Services/SeedDataService.AiPredictions.cs 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));