Initial commit: sync from server - all domains and code

This commit is contained in:
Hermes Agent
2026-05-18 22:12:40 +02:00
commit 22206d361a
54 changed files with 3636 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\AppointmentRepository;
use App\Repositories\InstructorRepository;
use App\Repositories\ReferenceDataRepository;
use App\Repositories\StudentRepository;
use App\Services\CalendarService;
use App\Services\RequirementService;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class AppointmentsController
{
public function index(Request $request): void
{
$user = Auth::requireUser();
$from = (string) $request->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'];
}
}