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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:40:12 -04:00
parent dbd39a9fe5
commit c0e4a66126
4 changed files with 278 additions and 6 deletions
@@ -337,15 +337,122 @@ public partial class SeedDataService
var startDate = DateTime.Today;
var appointmentTitles = new Dictionary<string, string[]>
{
["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)); // ±510 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 17 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;