451 lines
19 KiB
C#
451 lines
19 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;
|
|
|
|
// 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 title = $"{customer.CompanyName} - {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;
|
|
}
|
|
}
|