c0e4a66126
- 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>
559 lines
24 KiB
C#
559 lines
24 KiB
C#
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)); // ±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;
|
||
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;
|
||
}
|
||
}
|