Slim API deployment - composer install + full CRUD endpoints

This commit is contained in:
Hermes Agent
2026-05-20 14:36:05 +02:00
parent 48bf9c7088
commit 8ab4e532fd
1727 changed files with 7746 additions and 7 deletions

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\AppointmentRepository;
use App\Repositories\ReferenceDataRepository;
use DateInterval;
use DateTimeImmutable;
final class CalendarService
{
public function validate(int $tenantId, array $payload, ?int $appointmentId = null): array
{
$appointmentRepository = new AppointmentRepository();
$lessonType = (new ReferenceDataRepository())->findLessonType($tenantId, (int) $payload['lesson_type_id']);
$warnings = [
'conflicts' => [],
'rest_period_warnings' => [],
];
$conflicts = $appointmentRepository->overlappingForInstructor(
$tenantId,
(int) $payload['instructor_id'],
$payload['start_at'],
$payload['end_at'],
$appointmentId
);
foreach ($conflicts as $conflict) {
$warnings['conflicts'][] = [
'type' => 'instructor_overlap',
'message' => 'Fahrlehrer ist in diesem Zeitraum bereits verplant.',
'appointment_id' => (int) $conflict['id'],
];
}
if (!empty($payload['student_id'])) {
$studentConflicts = $appointmentRepository->overlappingForStudent(
$tenantId,
(int) $payload['student_id'],
$payload['start_at'],
$payload['end_at'],
$appointmentId
);
foreach ($studentConflicts as $conflict) {
$warnings['conflicts'][] = [
'type' => 'student_overlap',
'message' => 'Fahrschueler ist in diesem Zeitraum bereits verplant.',
'appointment_id' => (int) $conflict['id'],
];
}
}
if ($lessonType !== null && (int) $lessonType['is_rest_relevant'] === 1) {
$newStart = new DateTimeImmutable($payload['start_at']);
$dayStart = $newStart->setTime(0, 0, 0);
$dayEnd = $dayStart->modify('+1 day');
$hasLaterRelevantOnDay = $appointmentRepository->hasRestRelevantAfterStartOnDay(
$tenantId,
(int) $payload['instructor_id'],
$dayStart->format(DATE_ATOM),
$dayEnd->format(DATE_ATOM),
$payload['start_at']
);
if ($hasLaterRelevantOnDay) {
return $warnings;
}
$lastRelevant = $appointmentRepository->lastRestRelevantForInstructor(
$tenantId,
(int) $payload['instructor_id'],
$payload['start_at'],
$appointmentId
);
if ($lastRelevant !== null) {
$restEnds = (new DateTimeImmutable($lastRelevant['end_at']))->add(new DateInterval('PT11H'));
if ($newStart < $restEnds) {
$warnings['rest_period_warnings'][] = [
'type' => 'rest_period',
'message' => 'Achtung: Dieser Termin liegt innerhalb der 11-Stunden-Ruhezeit.',
'rest_until' => $restEnds->format(DATE_ATOM),
'blocking_appointment_id' => (int) $lastRelevant['id'],
];
}
}
}
return $warnings;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class HolidayService
{
private const STATES_WITH_EPIPHANY = ['BW', 'BY', 'ST'];
private const STATES_WITH_WOMENS_DAY = ['BE', 'MV'];
private const STATES_WITH_CORPUS_CHRISTI = ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'];
private const STATES_WITH_ASSUMPTION = ['BY', 'SL'];
private const STATES_WITH_REFORMATION = ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'];
private const STATES_WITH_ALL_SAINTS = ['BW', 'BY', 'NW', 'RP', 'SL'];
private const STATES_WITH_REPENTANCE = ['SN'];
public function forRange(array $tenant, string $from, string $to): array
{
$timezone = new DateTimeZone((string) ($tenant['timezone'] ?? 'Europe/Berlin'));
$state = strtoupper((string) ($tenant['federal_state'] ?? 'BE'));
$start = (new DateTimeImmutable($from, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
$end = (new DateTimeImmutable($to, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
$years = [];
$day = $start;
while ($day <= $end) {
$years[(int) $day->format('Y')] = true;
$day = $day->add(new DateInterval('P1D'));
}
$holidaysByDate = [];
foreach (array_keys($years) as $year) {
foreach ($this->holidaysForYear((int) $year, $state, $timezone) as $date => $name) {
$holidaysByDate[$date] = $name;
}
}
$result = [];
$day = $start;
while ($day < $end) {
$key = $day->format('Y-m-d');
$result[$key] = [
'isSunday' => (int) $day->format('N') === 7,
'holidayName' => $holidaysByDate[$key] ?? null,
];
$day = $day->add(new DateInterval('P1D'));
}
return $result;
}
private function holidaysForYear(int $year, string $state, DateTimeZone $timezone): array
{
$easter = (new DateTimeImmutable('@' . (string) easter_date($year)))->setTimezone($timezone)->setTime(0, 0);
$holidays = [
$this->dateKey($year, 1, 1, $timezone) => 'Neujahr',
$easter->sub(new DateInterval('P2D'))->format('Y-m-d') => 'Karfreitag',
$easter->add(new DateInterval('P1D'))->format('Y-m-d') => 'Ostermontag',
$this->dateKey($year, 5, 1, $timezone) => 'Tag der Arbeit',
$easter->add(new DateInterval('P39D'))->format('Y-m-d') => 'Christi Himmelfahrt',
$easter->add(new DateInterval('P50D'))->format('Y-m-d') => 'Pfingstmontag',
$this->dateKey($year, 10, 3, $timezone) => 'Tag der Deutschen Einheit',
$this->dateKey($year, 12, 25, $timezone) => '1. Weihnachtstag',
$this->dateKey($year, 12, 26, $timezone) => '2. Weihnachtstag',
];
if (in_array($state, self::STATES_WITH_EPIPHANY, true)) {
$holidays[$this->dateKey($year, 1, 6, $timezone)] = 'Heilige Drei Koenige';
}
if (in_array($state, self::STATES_WITH_WOMENS_DAY, true)) {
$holidays[$this->dateKey($year, 3, 8, $timezone)] = 'Internationaler Frauentag';
}
if (in_array($state, self::STATES_WITH_CORPUS_CHRISTI, true)) {
$holidays[$easter->add(new DateInterval('P60D'))->format('Y-m-d')] = 'Fronleichnam';
}
if (in_array($state, self::STATES_WITH_ASSUMPTION, true)) {
$holidays[$this->dateKey($year, 8, 15, $timezone)] = 'Mariae Himmelfahrt';
}
if (in_array($state, self::STATES_WITH_REFORMATION, true)) {
$holidays[$this->dateKey($year, 10, 31, $timezone)] = 'Reformationstag';
}
if (in_array($state, self::STATES_WITH_ALL_SAINTS, true)) {
$holidays[$this->dateKey($year, 11, 1, $timezone)] = 'Allerheiligen';
}
if (in_array($state, self::STATES_WITH_REPENTANCE, true)) {
$holidays[$this->repentanceDay($year, $timezone)->format('Y-m-d')] = 'Buss- und Bettag';
}
return $holidays;
}
private function dateKey(int $year, int $month, int $day, DateTimeZone $timezone): string
{
return (new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day), $timezone))->format('Y-m-d');
}
private function repentanceDay(int $year, DateTimeZone $timezone): DateTimeImmutable
{
$day = new DateTimeImmutable(sprintf('%04d-11-23', $year), $timezone);
while ((int) $day->format('N') !== 3) {
$day = $day->sub(new DateInterval('P1D'));
}
return $day;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\AppointmentRepository;
use App\Repositories\StudentRepository;
use App\Support\Database;
final class RequirementService
{
public function progressForStudent(int $studentId): array
{
$db = Database::connection();
$snapshots = (new StudentRepository())->requirements($studentId);
$statement = $db->prepare(
'SELECT au.requirement_key,
SUM(CASE WHEN a.status = "planned" THEN au.units_counted ELSE 0 END) AS planned_units,
SUM(CASE WHEN a.status = "completed" THEN au.units_counted ELSE 0 END) AS completed_units
FROM appointment_units au
JOIN appointments a ON a.id = au.appointment_id
WHERE a.student_id = :student_id
GROUP BY au.requirement_key'
);
$statement->execute(['student_id' => $studentId]);
$totals = [];
foreach ($statement->fetchAll() as $row) {
$totals[$row['requirement_key']] = [
'planned' => (int) $row['planned_units'],
'completed' => (int) $row['completed_units'],
];
}
return array_map(static function (array $snapshot) use ($totals): array {
$row = $totals[$snapshot['requirement_key']] ?? ['planned' => 0, 'completed' => 0];
$priorCompleted = (int) ($snapshot['prior_completed_units'] ?? 0);
$appointmentCompleted = (int) $row['completed'];
$completed = $priorCompleted + $appointmentCompleted;
$open = (int) $snapshot['required_units'] - $row['planned'] - $completed;
return [
'requirement_key' => $snapshot['requirement_key'],
'phase_label' => $snapshot['phase_label'] ?? '',
'label' => $snapshot['label'],
'required_units' => (int) $snapshot['required_units'],
'planned_units' => $row['planned'],
'completed_units' => $completed,
'prior_completed_units' => $priorCompleted,
'appointment_completed_units' => $appointmentCompleted,
'open_units' => $open,
];
}, $snapshots);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class SunTimesService
{
public function forRange(array $tenant, string $from, string $to): array
{
$latitude = isset($tenant['latitude']) ? (float) $tenant['latitude'] : null;
$longitude = isset($tenant['longitude']) ? (float) $tenant['longitude'] : null;
$timezoneName = (string) ($tenant['timezone'] ?? 'Europe/Berlin');
if ($latitude === null || $longitude === null || $latitude === 0.0 && $longitude === 0.0) {
return [];
}
$timezone = new DateTimeZone($timezoneName);
$start = new DateTimeImmutable($from, new DateTimeZone('UTC'));
$end = new DateTimeImmutable($to, new DateTimeZone('UTC'));
$day = $start->setTimezone($timezone)->setTime(0, 0);
$lastDay = $end->setTimezone($timezone)->setTime(0, 0);
$result = [];
while ($day < $lastDay) {
$solar = date_sun_info($day->setTime(12, 0)->getTimestamp(), $latitude, $longitude);
$key = $day->format('Y-m-d');
$result[$key] = [
'sunrise' => $this->formatSolarTimestamp($solar['sunrise'] ?? false, $timezone),
'sunset' => $this->formatSolarTimestamp($solar['sunset'] ?? false, $timezone),
];
$day = $day->add(new DateInterval('P1D'));
}
return $result;
}
private function formatSolarTimestamp(int|float|bool $timestamp, DateTimeZone $timezone): ?string
{
if (!is_int($timestamp) && !is_float($timestamp)) {
return null;
}
return (new DateTimeImmutable('@' . (string) (int) $timestamp))
->setTimezone($timezone)
->format('H:i');
}
}