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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
238
api/src/Services/InstructorAvailabilityService.php
Normal file
238
api/src/Services/InstructorAvailabilityService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user