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:
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
|
public partial class SeedDataService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds 8 <see cref="AiItemPrediction"/> demo records and attaches them to the first
|
||||||
|
/// 8 eligible <see cref="QuoteItem"/> records, marking those items as AI-analysed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// "Eligible" means <c>SurfaceAreaSqFt > 0</c>, not a labor item, and not already
|
||||||
|
/// linked to a prediction. This ensures the seeder is safe to run even if a partial seed
|
||||||
|
/// left some items pre-linked.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each prediction record captures a realistic AI analysis: predicted surface area,
|
||||||
|
/// estimated minutes, complexity tier, unit price, confidence level, reasoning text, and
|
||||||
|
/// comma-separated AI tags. Three of the eight items have <c>UserOverrodeEstimate = true</c>
|
||||||
|
/// to demonstrate the override-tracking feature on the AI Accuracy report.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Items are updated in-place with <c>IsAiItem = true</c> and the FK
|
||||||
|
/// <c>AiPredictionId</c> pointing to the new prediction. <c>SaveChangesAsync</c> is called
|
||||||
|
/// per item so any single FK conflict (unlikely in a fresh seed) does not abort the others.
|
||||||
|
/// </para>
|
||||||
|
/// Idempotency: returns 0 immediately if any AiItemPrediction records already exist for
|
||||||
|
/// the company.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="company">The tenant company to seed predictions for.</param>
|
||||||
|
/// <returns>Number of prediction records inserted, or 0 if already seeded.</returns>
|
||||||
|
private async Task<int> SeedAiPredictionsAsync(Company company)
|
||||||
|
{
|
||||||
|
var existingCount = await _context.Set<AiItemPrediction>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
|
||||||
|
|
||||||
|
if (existingCount > 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Grab the first 8 eligible quote items ordered by id for determinism
|
||||||
|
var quoteItems = await _context.Set<QuoteItem>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Include(qi => qi.Quote)
|
||||||
|
.Where(qi => qi.CompanyId == company.Id
|
||||||
|
&& !qi.IsDeleted
|
||||||
|
&& qi.SurfaceAreaSqFt > 0
|
||||||
|
&& !qi.IsLaborItem
|
||||||
|
&& qi.AiPredictionId == null)
|
||||||
|
.OrderBy(qi => qi.Id)
|
||||||
|
.Take(8)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (quoteItems.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Per-slot prediction specs — deterministic, varied across complexity/confidence tiers.
|
||||||
|
// PredictedSqFt is intentionally close but NOT identical to the actual item SqFt so the
|
||||||
|
// AI Accuracy report shows realistic prediction deltas.
|
||||||
|
var specs = new[]
|
||||||
|
{
|
||||||
|
// slot 0 — complex automotive, AI nailed it
|
||||||
|
( sqft: 13.5m, mins: 88, complexity: "Complex", confidence: "High",
|
||||||
|
price: 125.00m, tags: "automotive,tubular,custom", rounds: 1, overrode: false,
|
||||||
|
reasoning: "Detected a tubular motorcycle frame with multiple weld joints. High complexity due to intricate geometry and masking requirements around bearing surfaces. Confidence high — similar frames appear frequently in training data." ),
|
||||||
|
|
||||||
|
// slot 1 — wheel set, quick read, accepted as-is
|
||||||
|
( sqft: 11.2m, mins: 42, complexity: "Simple", confidence: "High",
|
||||||
|
price: 98.00m, tags: "automotive,wheels,aluminum", rounds: 1, overrode: false,
|
||||||
|
reasoning: "Four aluminum wheels, uniform shape, minimal masking needed. Straightforward batch candidate for the main oven. Estimated surface area based on standard 18\" wheel profile." ),
|
||||||
|
|
||||||
|
// slot 2 — bumper job, user bumped sqft slightly
|
||||||
|
( sqft: 14.8m, mins: 62, complexity: "Moderate", confidence: "Medium",
|
||||||
|
price: 135.00m, tags: "automotive,bumper,off-road", rounds: 2, overrode: true,
|
||||||
|
reasoning: "Steel off-road bumper and rock sliders. Moderate complexity — flat stock with mounting tabs. Second image round requested for accurate rock slider dimensions. User adjusted surface area slightly after physical measurement." ),
|
||||||
|
|
||||||
|
// slot 3 — large gate, low confidence, user corrected price
|
||||||
|
( sqft: 34.2m, mins: 195, complexity: "Complex", confidence: "Low",
|
||||||
|
price: 310.00m, tags: "architectural,gate,ornamental", rounds: 2, overrode: true,
|
||||||
|
reasoning: "Wrought iron entry gate with decorative scrollwork. Low confidence due to depth ambiguity in photos — scrollwork surface area is difficult to estimate from images alone. Recommend physical measurement before finalising price. User overrode unit price after measuring on-site." ),
|
||||||
|
|
||||||
|
// slot 4 — patio furniture, solid read
|
||||||
|
( sqft: 22.8m, mins: 52, complexity: "Moderate", confidence: "High",
|
||||||
|
price: 195.00m, tags: "furniture,outdoor,patio", rounds: 1, overrode: false,
|
||||||
|
reasoning: "Six-piece patio furniture set: four chairs, one table, one side table. Powder-coated tubular steel, standard outdoor finish. Good photo coverage — confidence high. Recommend Textured Beige or Satin Bronze for exterior durability." ),
|
||||||
|
|
||||||
|
// slot 5 — handrail, accepted price
|
||||||
|
( sqft: 39.0m, mins: 118, complexity: "Moderate", confidence: "High",
|
||||||
|
price: 342.00m, tags: "architectural,handrail,railing", rounds: 1, overrode: false,
|
||||||
|
reasoning: "40-foot steel handrail system, square tube construction. Consistent profile makes area calculation straightforward. Standard Gloss Black most common finish for this application — confirmed with customer." ),
|
||||||
|
|
||||||
|
// slot 6 — brake calipers, small & simple
|
||||||
|
( sqft: 3.8m, mins: 30, complexity: "Simple", confidence: "High",
|
||||||
|
price: 65.00m, tags: "automotive,brake,caliper", rounds: 1, overrode: false,
|
||||||
|
reasoning: "Set of four brake calipers, cast iron with machined mating surfaces. Masking required on piston bores and bleed nipples. Candy Red most requested finish. High confidence — calipers are a common item with well-established pricing." ),
|
||||||
|
|
||||||
|
// slot 7 — bicycle frame, two-round conversation
|
||||||
|
( sqft: 6.1m, mins: 65, complexity: "Moderate", confidence: "Medium",
|
||||||
|
price: 82.00m, tags: "recreational,bicycle,frame", rounds: 2, overrode: true,
|
||||||
|
reasoning: "Road bicycle frame, aluminium alloy. Second image round needed to assess cable routing channels and dropout geometry. Moderate complexity due to small-radius bends. User adjusted surface area after AI initially underestimated top-tube length." )
|
||||||
|
};
|
||||||
|
|
||||||
|
var seeded = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < quoteItems.Count && i < specs.Length; i++)
|
||||||
|
{
|
||||||
|
var item = quoteItems[i];
|
||||||
|
var s = specs[i];
|
||||||
|
|
||||||
|
var prediction = new AiItemPrediction
|
||||||
|
{
|
||||||
|
PredictedSurfaceAreaSqFt = s.sqft,
|
||||||
|
PredictedMinutes = s.mins,
|
||||||
|
PredictedComplexity = s.complexity,
|
||||||
|
PredictedUnitPrice = s.price,
|
||||||
|
Confidence = s.confidence,
|
||||||
|
Reasoning = s.reasoning,
|
||||||
|
AiTags = s.tags,
|
||||||
|
ConversationRounds = s.rounds,
|
||||||
|
UserOverrodeEstimate = s.overrode,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = item.CreatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.Set<AiItemPrediction>().AddAsync(prediction);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
seeded++;
|
||||||
|
|
||||||
|
// Mark the quote item as AI-analysed and link the prediction
|
||||||
|
item.IsAiItem = true;
|
||||||
|
item.AiPredictionId = prediction.Id;
|
||||||
|
item.AiTags = s.tags;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,6 +346,113 @@ public partial class SeedDataService
|
|||||||
// Get status IDs by code for easy assignment
|
// Get status IDs by code for easy assignment
|
||||||
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
|
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
|
||||||
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").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)
|
// Generate 50 appointments across next 60 days (weekdays only)
|
||||||
int appointmentsCreated = 0;
|
int appointmentsCreated = 0;
|
||||||
@@ -388,7 +495,8 @@ public partial class SeedDataService
|
|||||||
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
|
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
|
||||||
|
|
||||||
// Title
|
// 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)
|
// Optional job link (40% chance if type is JOB_WORK, 20% for others)
|
||||||
int? jobId = null;
|
int? jobId = null;
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
if (seededQuoteIds.Any())
|
if (seededQuoteIds.Any())
|
||||||
{
|
{
|
||||||
|
// Collect prediction IDs before removing items (FK is NoAction — predictions
|
||||||
|
// must be deleted after the items that reference them are gone).
|
||||||
|
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
|
||||||
|
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
|
||||||
|
.Select(qi => qi.AiPredictionId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
|
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
|
||||||
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
|
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
|
||||||
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
|
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
|
||||||
@@ -193,6 +201,21 @@ public partial class SeedDataService
|
|||||||
_context.Quotes.RemoveRange(quotes);
|
_context.Quotes.RemoveRange(quotes);
|
||||||
totalRemoved += quotes.Count;
|
totalRemoved += quotes.Count;
|
||||||
details.Add($"✓ Removed {quotes.Count} seeded quote(s)");
|
details.Add($"✓ Removed {quotes.Count} seeded quote(s)");
|
||||||
|
|
||||||
|
// Remove orphaned AI predictions now that QuoteItems no longer reference them
|
||||||
|
if (predictionIds.Any())
|
||||||
|
{
|
||||||
|
var predictions = await _context.Set<Core.Entities.AiItemPrediction>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(p => predictionIds.Contains(p.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
if (predictions.Any())
|
||||||
|
{
|
||||||
|
_context.Set<Core.Entities.AiItemPrediction>().RemoveRange(predictions);
|
||||||
|
totalRemoved += predictions.Count;
|
||||||
|
details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer notes
|
// Customer notes
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ public partial class SeedDataService : ISeedDataService
|
|||||||
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
|
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
|
||||||
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
||||||
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
||||||
|
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
|
||||||
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
|
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
|
||||||
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
|
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
|
||||||
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
|
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
|
||||||
|
|||||||
Reference in New Issue
Block a user