From 5f753c15df29e9fcc94aa4b4d697e9750b999e7b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 20 May 2026 22:50:26 +0200 Subject: [PATCH] Add InstructorAvailabilityService with vacation, units limits, break rules - InstructorAvailabilityService checks: 1. Vacation (instructor_vacations table) 2. Regular availability (instructor_availability weekday/time windows) 3. Daily units limit (max_daily_units + max_daily_override) 4. Weekly units limit (max_weekly_units + max_weekly_override) 5. Break rules (between appointments, continuous minutes) - AppointmentsController now uses availabilityService - Fixed getQueryParam() -> getQueryParams() compatibility - Instructor model updated with max_*_units fields --- .../Controllers/AppointmentsController.php | 40 ++- api/src/Models/Instructor.php | 11 +- .../InstructorAvailabilityService.php | 238 ++++++++++++++++++ 3 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 api/src/Services/InstructorAvailabilityService.php diff --git a/api/src/Controllers/AppointmentsController.php b/api/src/Controllers/AppointmentsController.php index e4253e5..f524fb7 100644 --- a/api/src/Controllers/AppointmentsController.php +++ b/api/src/Controllers/AppointmentsController.php @@ -5,16 +5,25 @@ declare(strict_types=1); namespace App\Controllers; use App\Models\Appointment; +use App\Services\InstructorAvailabilityService; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; final class AppointmentsController { + private InstructorAvailabilityService $availabilityService; + + public function __construct() + { + $this->availabilityService = new InstructorAvailabilityService(); + } + public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $tenantId = $request->getAttribute('tenant_id'); - $from = $request->getQueryParam('from'); - $to = $request->getQueryParam('to'); + $params = $request->getQueryParams(); + $from = $params['from'] ?? null; + $to = $params['to'] ?? null; $query = Appointment::where('tenant_id', $tenantId)->with(['student', 'instructor', 'lessonType']); if ($from && $to) { @@ -40,6 +49,19 @@ final class AppointmentsController 'message' => 'Instructor has a conflicting appointment in this time range' ], 409); } + + // Check instructor availability rules + $availCheck = $this->availabilityService->checkAvailability( + (int) $data['instructor_id'], + $data['start_at'], + $data['end_at'], + $data['units'] ?? 1 + ); + if (!$availCheck['available']) { + return $this->json($response, [ + 'message' => 'Instructor not available: ' . $availCheck['reason'] + ], 409); + } } // Conflict check for student @@ -95,6 +117,20 @@ final class AppointmentsController 'message' => 'Instructor has a conflicting appointment in this time range' ], 409); } + + // Check instructor availability rules (exclude current appointment) + $availCheck = $this->availabilityService->checkAvailability( + (int) $data['instructor_id'], + $data['start_at'], + $data['end_at'], + $data['units'] ?? 1, + (int) $args['id'] + ); + if (!$availCheck['available']) { + return $this->json($response, [ + 'message' => 'Instructor not available: ' . $availCheck['reason'] + ], 409); + } } // Conflict check for student (exclude current appointment) diff --git a/api/src/Models/Instructor.php b/api/src/Models/Instructor.php index 9c4c924..9f5fb19 100644 --- a/api/src/Models/Instructor.php +++ b/api/src/Models/Instructor.php @@ -13,8 +13,17 @@ final class Instructor extends Model protected $fillable = [ 'tenant_id', 'user_id', 'first_name', 'last_name', 'color', 'notes', 'is_active', 'pre_start_buffer_minutes', + 'max_daily_units', 'max_daily_override', + 'max_weekly_units', 'max_weekly_override', + ]; + protected $casts = [ + 'is_active' => 'integer', + 'tenant_id' => 'integer', + 'max_daily_units' => 'integer', + 'max_daily_override' => 'integer', + 'max_weekly_units' => 'integer', + 'max_weekly_override' => 'integer', ]; - protected $casts = ['is_active' => 'integer', 'tenant_id' => 'integer']; public function tenant(): BelongsTo { diff --git a/api/src/Services/InstructorAvailabilityService.php b/api/src/Services/InstructorAvailabilityService.php new file mode 100644 index 0000000..308f9e5 --- /dev/null +++ b/api/src/Services/InstructorAvailabilityService.php @@ -0,0 +1,238 @@ + bool, 'reason' => string|null] + */ + public function checkAvailability( + int $instructorId, + string $startAt, + string $endAt, + int $units = 1, + ?int $excludeAppointmentId = null + ): array { + $instructor = Instructor::find($instructorId); + if (!$instructor) { + return ['available' => false, 'reason' => 'Instructor not found']; + } + + $this->instructor = $instructor; + + if (!$instructor->is_active) { + return ['available' => false, 'reason' => 'Instructor is inactive']; + } + + $start = Carbon::parse($startAt); + $end = Carbon::parse($endAt); + $date = $start->toDateString(); + + // 1. Check vacation + if ($this->isOnVacation($instructorId, $date)) { + return ['available' => false, 'reason' => 'Instructor is on vacation on this day']; + } + + // 2. Check regular availability (weekday + time window) + if (!$this->isWithinAvailability($instructorId, $start, $end)) { + return ['available' => false, 'reason' => 'Outside instructor\'s regular availability window']; + } + + // 3. Check daily units limit + $dailyCheck = $this->checkDailyUnitsLimit($instructorId, $date, $units, $excludeAppointmentId); + if (!$dailyCheck['allowed']) { + return $dailyCheck; + } + + // 4. Check weekly units limit + $weekStart = $start->copy()->startOfWeek()->toDateString(); + $weekEnd = $start->copy()->endOfWeek()->toDateString(); + $weeklyCheck = $this->checkWeeklyUnitsLimit($instructorId, $weekStart, $weekEnd, $units, $excludeAppointmentId); + if (!$weeklyCheck['allowed']) { + return $weeklyCheck; + } + + // 5. Check break rules (between appointments) + $breakCheck = $this->checkBreakRules($instructorId, $start, $end, $excludeAppointmentId); + if (!$breakCheck['allowed']) { + return $breakCheck; + } + + return ['available' => true, 'reason' => null]; + } + + private function isOnVacation(int $instructorId, string $date): bool + { + $result = \DB::select(" + SELECT 1 FROM instructor_vacations + WHERE instructor_id = ? + AND ?::date BETWEEN date_from AND date_to + LIMIT 1 + ", [$instructorId, $date]); + + return count($result) > 0; + } + + private function isWithinAvailability(int $instructorId, Carbon $start, Carbon $end): bool + { + $weekday = $start->dayOfWeek; + + // First check if there's any availability record for this weekday + $avail = \DB::select(" + SELECT 1 FROM instructor_availability + WHERE instructor_id = ? AND weekday = ? AND is_active = 1 + LIMIT 1 + ", [$instructorId, $weekday]); + + if (count($avail) === 0) { + return false; // No availability for this day + } + + // Get all availability windows for this day + $windows = \DB::select(" + SELECT time_from, time_to FROM instructor_availability + WHERE instructor_id = ? AND weekday = ? AND is_active = 1 + ", [$instructorId, $weekday]); + + $dateStr = $start->toDateString(); + foreach ($windows as $window) { + $winStart = Carbon::parse($dateStr . ' ' . $window->time_from); + $winEnd = Carbon::parse($dateStr . ' ' . $window->time_to); + + if ($start->gte($winStart) && $end->lte($winEnd)) { + return true; // Appointment fits within this window + } + } + + return false; + } + + private function checkDailyUnitsLimit(int $instructorId, string $date, int $units, ?int $excludeId): array + { + $instructor = $this->instructor; + $maxDaily = $instructor->max_daily_units ?? 0; + $maxOverride = $instructor->max_daily_override ?? 0; + $maxUnits = $maxDaily + $maxOverride; + + if ($maxUnits <= 0) { + return ['allowed' => true]; // No limit set + } + + $excludeClause = $excludeId ? "AND id != {$excludeId}" : ""; + $result = \DB::select(" + SELECT COALESCE(SUM(units), 0) as total_units + FROM appointments + WHERE instructor_id = ? + AND DATE(start_at) = ? + AND status != 'cancelled' + {$excludeClause} + ", [$instructorId, $date]); + + $currentUnits = (int) $result[0]->total_units; + + if ($currentUnits + $units > $maxUnits) { + return [ + 'allowed' => false, + 'reason' => "Daily units limit exceeded ({$currentUnits}/{$maxUnits} units, trying to add {$units})" + ]; + } + + return ['allowed' => true]; + } + + private function checkWeeklyUnitsLimit(int $instructorId, string $weekStart, string $weekEnd, int $units, ?int $excludeId): array + { + $instructor = $this->instructor; + $maxWeekly = $instructor->max_weekly_units ?? 0; + $maxOverride = $instructor->max_weekly_override ?? 0; + $maxUnits = $maxWeekly + $maxOverride; + + if ($maxUnits <= 0) { + return ['allowed' => true]; // No limit set + } + + $excludeClause = $excludeId ? "AND id != {$excludeId}" : ""; + $result = \DB::select(" + SELECT COALESCE(SUM(units), 0) as total_units + FROM appointments + WHERE instructor_id = ? + AND DATE(start_at) BETWEEN ? AND ? + AND status != 'cancelled' + {$excludeClause} + ", [$instructorId, $weekStart, $weekEnd]); + + $currentUnits = (int) $result[0]->total_units; + + if ($currentUnits + $units > $maxUnits) { + return [ + 'allowed' => false, + 'reason' => "Weekly units limit exceeded ({$currentUnits}/{$maxUnits} units)" + ]; + } + + return ['allowed' => true]; + } + + private function checkBreakRules(int $instructorId, Carbon $start, Carbon $end, ?int $excludeId): array + { + // Get break rules for this instructor + $rules = \DB::select(" + SELECT * FROM instructor_break_rules + WHERE instructor_id = ? AND is_active = 1 + ", [$instructorId]); + + foreach ($rules as $rule) { + switch ($rule->condition_type) { + case 'between_appointments': + // Check if there's an appointment ending within threshold before start + $excludeClause = $excludeId ? "AND id != {$excludeId}" : ""; + $threshold = (int) $rule->threshold; + + $prevAppt = \DB::select(" + SELECT id, end_at FROM appointments + WHERE instructor_id = ? + AND end_at > ? - INTERVAL '{$threshold} minutes' + AND end_at <= ? + AND status != 'cancelled' + {$excludeClause} + ORDER BY end_at DESC + LIMIT 1 + ", [$instructorId, $start->toDateTimeString(), $start->toDateTimeString()]); + + if (count($prevAppt) > 0) { + $gapMinutes = $start->diffInMinutes(Carbon::parse($prevAppt[0]->end_at)); + if ($gapMinutes < $threshold) { + return [ + 'allowed' => false, + 'reason' => "Break rule violated: need {$rule->break_minutes}min break after previous appointment" + ]; + } + } + break; + + case 'continuous_minutes': + // Check if this appointment exceeds continuous minutes + $durationMinutes = $end->diffInMinutes($start); + if ($durationMinutes > (int) $rule->threshold) { + return [ + 'allowed' => false, + 'reason' => "Continuous lesson exceeds {$rule->threshold} minutes" + ]; + } + break; + } + } + + return ['allowed' => true]; + } +} \ No newline at end of file