Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Appointments.cs
T
spouliot c0e4a66126 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>
2026-06-10 22:40:12 -04:00

559 lines
24 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 seven standard appointment status lookup rows for a company:
/// Scheduled, Confirmed, In Progress, Completed, Cancelled, No Show, and Rescheduled.
/// </summary>
/// <remarks>
/// Appointment statuses follow the same lookup-table pattern as job statuses: the
/// <c>StatusCode</c> strings are referenced by application code; the display name, colour,
/// and icon are cosmetic and operator-customisable.
///
/// Terminal statuses (Completed, Cancelled, No Show) set <c>IsTerminalStatus = true</c>
/// so the calendar and scheduler can filter them out of the "active" appointment view
/// without hard-coding status codes.
///
/// System-defined statuses (Scheduled, Completed, Cancelled) are marked
/// <c>IsSystemDefined = true</c> to prevent operators from deleting them, as the
/// appointment-creation workflow always defaults to "SCHEDULED" on new records.
///
/// Idempotency: bails early if 7 or more rows already exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed appointment statuses for.</param>
/// <returns>The number of status rows created (7), or 0 if already seeded.</returns>
private async Task<int> SeedAppointmentStatusLookupsAsync(Company company)
{
// Check if appointment statuses already exist for this company
var existingCount = await _context.Set<AppointmentStatusLookup>()
.IgnoreQueryFilters()
.CountAsync(s => s.CompanyId == company.Id && !s.IsDeleted);
if (existingCount >= 7)
{
return 0; // Already seeded
}
var statuses = new List<AppointmentStatusLookup>
{
new AppointmentStatusLookup
{
StatusCode = "SCHEDULED",
DisplayName = "Scheduled",
DisplayOrder = 1,
ColorClass = "primary",
IconClass = "bi-calendar-check",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = false,
Description = "Appointment has been scheduled",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "CONFIRMED",
DisplayName = "Confirmed",
DisplayOrder = 2,
ColorClass = "success",
IconClass = "bi-check-circle",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Customer has confirmed the appointment",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "IN_PROGRESS",
DisplayName = "In Progress",
DisplayOrder = 3,
ColorClass = "warning",
IconClass = "bi-hourglass-split",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Appointment is currently in progress",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "COMPLETED",
DisplayName = "Completed",
DisplayOrder = 4,
ColorClass = "success",
IconClass = "bi-check2-all",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
Description = "Appointment was completed successfully",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "CANCELLED",
DisplayName = "Cancelled",
DisplayOrder = 5,
ColorClass = "danger",
IconClass = "bi-x-circle",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
Description = "Appointment was cancelled",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "NO_SHOW",
DisplayName = "No Show",
DisplayOrder = 6,
ColorClass = "secondary",
IconClass = "bi-person-x",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = true,
Description = "Customer did not show up for appointment",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "RESCHEDULED",
DisplayName = "Rescheduled",
DisplayOrder = 7,
ColorClass = "info",
IconClass = "bi-arrow-repeat",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Appointment has been rescheduled to a different time",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<AppointmentStatusLookup>().AddRangeAsync(statuses);
await _context.SaveChangesAsync();
return statuses.Count;
}
/// <summary>
/// Seeds four standard appointment type lookup rows for a company:
/// Customer Drop-Off, Customer Pick-Up, Consultation/Quote, and Scheduled Job Work.
/// </summary>
/// <remarks>
/// Appointment types categorise the purpose of a calendar entry and control optional UI
/// behaviour via <c>RequiresJobLink</c>: the JOB_WORK type sets this to <c>true</c>,
/// prompting the calendar UI to show a mandatory job selector when scheduling that type.
/// All other types leave it <c>false</c> because drop-offs, pick-ups, and consultations
/// may exist before a job has been created.
///
/// The <c>TypeCode</c> strings are referenced by <see cref="SeedAppointmentsAsync"/>
/// when building appointment titles and determining job-link probability, so they must
/// not be changed after initial seeding.
///
/// Colour classes (purple, green, blue, orange) map to custom CSS variables in the
/// calendar stylesheet — they are distinct from Bootstrap's standard colour palette to
/// give each appointment type a unique calendar stripe colour.
///
/// Idempotency: bails early if 4 or more rows already exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed appointment types for.</param>
/// <returns>The number of type rows created (4), or 0 if already seeded.</returns>
private async Task<int> SeedAppointmentTypeLookupsAsync(Company company)
{
// Check if appointment types already exist for this company
var existingCount = await _context.Set<AppointmentTypeLookup>()
.IgnoreQueryFilters()
.CountAsync(t => t.CompanyId == company.Id && !t.IsDeleted);
if (existingCount >= 4)
{
return 0; // Already seeded
}
var types = new List<AppointmentTypeLookup>
{
new AppointmentTypeLookup
{
TypeCode = "DROP_OFF",
DisplayName = "Customer Drop-Off",
DisplayOrder = 1,
ColorClass = "purple",
IconClass = "bi-box-arrow-in-down",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Customer dropping off items for coating",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "PICK_UP",
DisplayName = "Customer Pick-Up",
DisplayOrder = 2,
ColorClass = "green",
IconClass = "bi-box-arrow-up",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Customer picking up completed items",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "CONSULTATION",
DisplayName = "Consultation/Quote",
DisplayOrder = 3,
ColorClass = "blue",
IconClass = "bi-chat-dots",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Consultation meeting or quote discussion",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "JOB_WORK",
DisplayName = "Scheduled Job Work",
DisplayOrder = 4,
ColorClass = "orange",
IconClass = "bi-tools",
RequiresJobLink = true,
IsActive = true,
IsSystemDefined = true,
Description = "Scheduled work time for a specific job",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<AppointmentTypeLookup>().AddRangeAsync(types);
await _context.SaveChangesAsync();
return types.Count;
}
/// <summary>
/// Seeds up to 50 sample appointments for a company, distributed across weekdays over
/// the next ~60 days, with randomised types, customers, durations, statuses, and optional
/// job links and worker assignments.
/// </summary>
/// <remarks>
/// <b>Purpose:</b> Provides a realistic-looking calendar on first login so that the
/// Appointments / Calendar feature is immediately useful to evaluate in a demo environment.
///
/// <b>Randomisation:</b> A seeded <see cref="Random"/> with seed 42 is used so that the
/// generated appointments are deterministic — re-seeding the same company always produces
/// the same schedule, which simplifies support and testing reproducibility.
///
/// <b>Weekend exclusion:</b> Saturday and Sunday are skipped to match typical shop hours.
/// The loop iterates calendar days (up to a 90-day safety cap) rather than counting
/// business days directly because the simpler approach avoids edge cases around month
/// boundaries and holidays.
///
/// <b>Job links:</b> JOB_WORK type appointments have a 40% chance of linking to an
/// existing job; other types have a 20% chance. Jobs and customers must already be seeded
/// (via <see cref="SeedJobsAsync"/> and <see cref="SeedCustomersAsync"/>) for links to
/// be available — if either collection is empty, the method still seeds appointments but
/// without links.
///
/// <b>Worker assignments:</b> 50% of appointments are assigned to a random active company
/// user. Workers are loaded without <c>IgnoreQueryFilters</c> because the global
/// filter for users is identity-based, not company-scoped, and the check
/// <c>u.CompanyId == company.Id</c> is applied in the LINQ predicate instead.
///
/// <b>Prerequisites:</b> Requires appointment types and statuses to already be seeded;
/// returns 0 without creating any records if either collection is empty.
///
/// Idempotency: returns 0 if 10 or more appointments already exist for the company.
/// The threshold is 10 (not 50) so that a previously partial seed is detected and skipped
/// rather than doubled up.
/// </remarks>
/// <param name="company">The tenant company to seed appointments for.</param>
/// <returns>The number of appointment records created (up to 50), or 0 if skipped.</returns>
private async Task<int> SeedAppointmentsAsync(Company company)
{
// Check if appointments already exist for this company
var existingCount = await _context.Set<Appointment>()
.IgnoreQueryFilters()
.CountAsync(a => a.CompanyId == company.Id && !a.IsDeleted);
if (existingCount >= 10)
{
return 0; // Already seeded (at least some appointments exist)
}
// Get required lookup data
var appointmentTypes = await _context.Set<AppointmentTypeLookup>()
.IgnoreQueryFilters()
.Where(t => t.CompanyId == company.Id)
.OrderBy(t => t.DisplayOrder)
.ToListAsync();
var appointmentStatuses = await _context.Set<AppointmentStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.OrderBy(s => s.DisplayOrder)
.ToListAsync();
var customers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id)
.OrderBy(c => c.Id)
.ToListAsync();
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id)
.OrderBy(j => j.Id)
.ToListAsync();
var workers = await _context.Set<ApplicationUser>()
.Where(u => u.CompanyId == company.Id && u.IsActive)
.OrderBy(u => u.Id)
.ToListAsync();
if (!appointmentTypes.Any() || !appointmentStatuses.Any() || !customers.Any())
{
return 0; // Can't seed appointments without required data
}
var random = new Random(42); // Deterministic for consistency
var appointments = new List<Appointment>();
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" },
["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" },
["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 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;
int daysChecked = 0;
var currentDate = startDate;
while (appointmentsCreated < 50 && daysChecked < 90) // Safety limit
{
currentDate = startDate.AddDays(daysChecked);
daysChecked++;
// Skip weekends
if (currentDate.DayOfWeek == DayOfWeek.Saturday || currentDate.DayOfWeek == DayOfWeek.Sunday)
{
continue;
}
// Create 1-3 appointments per weekday (varied distribution)
int appointmentsToday = random.Next(1, 4);
for (int i = 0; i < appointmentsToday && appointmentsCreated < 50; i++)
{
// Random type
var appointmentType = appointmentTypes[random.Next(appointmentTypes.Count)];
// Random customer
var customer = customers[random.Next(customers.Count)];
// Random time during business hours (8 AM - 4 PM for start times)
int startHour = random.Next(8, 17);
int startMinute = random.Next(0, 4) * 15; // 0, 15, 30, 45
var scheduledStart = new DateTime(currentDate.Year, currentDate.Month, currentDate.Day,
startHour, startMinute, 0, DateTimeKind.Utc);
// Duration: 30 min to 2 hours
int durationMinutes = random.Next(1, 5) * 30; // 30, 60, 90, or 120 minutes
var scheduledEnd = scheduledStart.AddMinutes(durationMinutes);
// Status: 80% SCHEDULED, 20% CONFIRMED (for future appointments)
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
// Title
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;
if (jobs.Any())
{
int jobLinkChance = appointmentType.TypeCode == "JOB_WORK" ? 40 : 20;
if (random.Next(100) < jobLinkChance)
{
jobId = jobs[random.Next(jobs.Count)].Id;
}
}
// Optional user assignment (50% chance)
string? assignedWorkerId = null;
if (workers.Any() && random.Next(100) < 50)
{
assignedWorkerId = workers[random.Next(workers.Count)].Id;
}
// Location (60% chance)
string? location = null;
if (random.Next(100) < 60)
{
var locations = new[] { "Main Office", "Loading Dock", "Shop Floor", "Coating Area", "Reception" };
location = locations[random.Next(locations.Length)];
}
var appointment = new Appointment
{
AppointmentNumber = $"APT-{currentDate:yyMM}-{(appointmentsCreated + 1):D4}",
CustomerId = customer.Id,
JobId = jobId,
AppointmentStatusId = statusId,
AppointmentTypeId = appointmentType.Id,
AssignedUserId = assignedWorkerId,
Title = title,
Description = random.Next(100) < 40 ? $"Scheduled appointment for {appointmentType.DisplayName.ToLower()}" : null,
ScheduledStartTime = scheduledStart,
ScheduledEndTime = scheduledEnd,
IsAllDay = false,
Location = location,
IsReminderEnabled = random.Next(100) < 70, // 70% have reminders
ReminderMinutesBefore = 30,
Notes = random.Next(100) < 30 ? "Auto-generated demo appointment" : null,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
};
appointments.Add(appointment);
appointmentsCreated++;
}
}
await _context.Set<Appointment>().AddRangeAsync(appointments);
await _context.SaveChangesAsync();
return appointments.Count;
}
}