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
This commit is contained in:
Hermes Agent
2026-05-20 22:50:26 +02:00
parent f68400e282
commit 5f753c15df
3 changed files with 286 additions and 3 deletions

View File

@@ -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)

View File

@@ -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
{

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Instructor;
use App\Models\Appointment;
use Carbon\Carbon;
final class InstructorAvailabilityService
{
private ?Instructor $instructor = null;
/**
* Check if instructor is available for a new appointment.
* Returns array ['available' => 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];
}
}