query('from'); $to = (string) $request->query('to'); $instructorId = null; if ($user['role'] === 'instructor') { $instructor = (new InstructorRepository())->findByUserId((int) $user['tenant_id'], (int) $user['id']); $instructorId = $instructor ? (int) $instructor['id'] : null; } $data = (new AppointmentRepository())->listForTenant( (int) $user['tenant_id'], $from, $to, $instructorId ); Response::json(['data' => $data]); } public function validate(Request $request): void { $user = Auth::requireUser(); $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload); Response::json(['warnings' => $warnings, 'payload' => $payload]); } public function store(Request $request): void { $user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']); $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload); if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) { Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409); } $record = (new AppointmentRepository())->create((int) $user['tenant_id'], $payload, (int) $user['id']); Response::json([ 'data' => $record, 'warnings' => $warnings, 'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [], ], 201); } public function update(Request $request, array $params): void { $user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']); $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload, (int) $params['id']); if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) { Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409); } $record = (new AppointmentRepository())->update((int) $user['tenant_id'], (int) $params['id'], $payload, (int) $user['id']); Response::json([ 'data' => $record, 'warnings' => $warnings, 'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [], ]); } public function destroy(Request $request, array $params): void { $user = Auth::requireRole(['tenant_admin', 'dispatcher']); (new AppointmentRepository())->delete((int) $user['tenant_id'], (int) $params['id']); Response::noContent(); } private function normalizePayload(int $tenantId, array $payload): array { $referenceRepository = new ReferenceDataRepository(); $lessonType = $referenceRepository->findLessonType($tenantId, (int) $payload['lesson_type_id']); if ($lessonType === null) { Response::json(['message' => 'Unbekannte Stundenart'], 422); } $student = null; if (!empty($payload['student_id'])) { $student = (new StudentRepository())->find($tenantId, (int) $payload['student_id']); if ($student === null) { Response::json(['message' => 'Unbekannter Fahrschueler'], 422); } if ( $lessonType['category'] === 'student' && !$referenceRepository->isLessonTypeVisibleForTemplate($tenantId, (int) $lessonType['id'], (int) $student['template_id']) ) { Response::json(['message' => 'Diese Stundenart ist fuer die Fuehrerscheinklasse nicht freigegeben'], 422); } } $startAt = new \DateTimeImmutable((string) $payload['start_at']); $baseDuration = max(1, (int) $lessonType['default_duration']); $providedEndAt = !empty($payload['end_at']) ? new \DateTimeImmutable((string) $payload['end_at']) : null; if ($providedEndAt instanceof \DateTimeImmutable && $providedEndAt > $startAt) { $endAt = $providedEndAt; $duration = max(1, (int) round(($endAt->getTimestamp() - $startAt->getTimestamp()) / 60)); $units = (int) $lessonType['fixed_duration'] === 1 ? 1 : max(1, (int) round($duration / $baseDuration)); } else { $units = max(1, (int) ($payload['units'] ?? 1)); $duration = (int) $lessonType['fixed_duration'] === 1 ? $baseDuration : $baseDuration * $units; $endAt = $startAt->modify(sprintf('+%d minutes', $duration)); } $title = $payload['title'] ?? $lessonType['name'] ?? 'Termin'; $unitsBreakdown = []; if ($lessonType && (int) $lessonType['is_counted'] === 1 && !empty($payload['student_id'])) { $requirementKey = $lessonType['effective_requirement_key'] ?? $lessonType['requirement_key']; if ($student !== null && (int) ($student['template_is_combination'] ?? 0) === 1) { $requirementKey = $this->requirementKeyForPhase($student['requirements'] ?? [], $requirementKey, (string) ($payload['requirement_phase'] ?? '')); } $unitsBreakdown[] = [ 'requirement_key' => $requirementKey, 'label' => $lessonType['name'], 'units_counted' => $units, ]; } return [ 'student_id' => $payload['student_id'] ?? null, 'instructor_id' => (int) $payload['instructor_id'], 'lesson_type_id' => (int) $payload['lesson_type_id'], 'title' => $title, 'category' => $lessonType['category'] ?? ($payload['category'] ?? 'student'), 'start_at' => $startAt->format(DATE_ATOM), 'end_at' => $endAt->format(DATE_ATOM), 'units' => $units, 'status' => $payload['status'] ?? 'planned', 'notes' => $payload['notes'] ?? '', 'warning_acknowledged' => !empty($payload['warning_acknowledged']), 'units_breakdown' => $unitsBreakdown, ]; } private function hasWarnings(array $warnings): bool { return !empty($warnings['conflicts']) || !empty($warnings['rest_period_warnings']); } private function requirementKeyForPhase(array $requirements, string $baseRequirementKey, string $phaseLabel): string { $matchingRows = array_values(array_filter( $requirements, static fn (array $row): bool => str_ends_with((string) $row['requirement_key'], '::' . $baseRequirementKey) || (string) $row['requirement_key'] === $baseRequirementKey )); if ($matchingRows === []) { return $baseRequirementKey; } foreach ($matchingRows as $row) { if ((string) ($row['phase_label'] ?? '') === $phaseLabel) { return (string) $row['requirement_key']; } } return (string) $matchingRows[0]['requirement_key']; } }