Initial commit
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user