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;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Models\Appointment;
|
use App\Models\Appointment;
|
||||||
|
use App\Services\InstructorAvailabilityService;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
final class AppointmentsController
|
final class AppointmentsController
|
||||||
{
|
{
|
||||||
|
private InstructorAvailabilityService $availabilityService;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->availabilityService = new InstructorAvailabilityService();
|
||||||
|
}
|
||||||
|
|
||||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
$tenantId = $request->getAttribute('tenant_id');
|
$tenantId = $request->getAttribute('tenant_id');
|
||||||
$from = $request->getQueryParam('from');
|
$params = $request->getQueryParams();
|
||||||
$to = $request->getQueryParam('to');
|
$from = $params['from'] ?? null;
|
||||||
|
$to = $params['to'] ?? null;
|
||||||
|
|
||||||
$query = Appointment::where('tenant_id', $tenantId)->with(['student', 'instructor', 'lessonType']);
|
$query = Appointment::where('tenant_id', $tenantId)->with(['student', 'instructor', 'lessonType']);
|
||||||
if ($from && $to) {
|
if ($from && $to) {
|
||||||
@@ -40,6 +49,19 @@ final class AppointmentsController
|
|||||||
'message' => 'Instructor has a conflicting appointment in this time range'
|
'message' => 'Instructor has a conflicting appointment in this time range'
|
||||||
], 409);
|
], 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
|
// Conflict check for student
|
||||||
@@ -95,6 +117,20 @@ final class AppointmentsController
|
|||||||
'message' => 'Instructor has a conflicting appointment in this time range'
|
'message' => 'Instructor has a conflicting appointment in this time range'
|
||||||
], 409);
|
], 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)
|
// Conflict check for student (exclude current appointment)
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ final class Instructor extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tenant_id', 'user_id', 'first_name', 'last_name',
|
'tenant_id', 'user_id', 'first_name', 'last_name',
|
||||||
'color', 'notes', 'is_active', 'pre_start_buffer_minutes',
|
'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
|
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