Initial commit: sync from server - all domains and code
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class AppointmentRepository extends BaseRepository
|
||||
{
|
||||
public function listForTenant(int $tenantId, string $from, string $to, ?int $instructorId = null): array
|
||||
{
|
||||
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name, s.needs_glasses AS student_needs_glasses,
|
||||
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
|
||||
lt.name AS lesson_type_name, lt.color AS lesson_type_color, lt.is_rest_relevant,
|
||||
(
|
||||
SELECT au.requirement_key
|
||||
FROM appointment_units au
|
||||
WHERE au.appointment_id = a.id
|
||||
ORDER BY au.id
|
||||
LIMIT 1
|
||||
) AS counted_requirement_key
|
||||
FROM appointments a
|
||||
JOIN instructors i ON i.id = a.instructor_id
|
||||
LEFT JOIN students s ON s.id = a.student_id
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.start_at < :to
|
||||
AND a.end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($instructorId !== null) {
|
||||
$sql .= ' AND a.instructor_id = :instructor_id';
|
||||
$params['instructor_id'] = $instructorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.start_at';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$appointment = $statement->fetch() ?: null;
|
||||
|
||||
if ($appointment !== null) {
|
||||
$appointment['units_breakdown'] = $this->units((int) $appointment['id']);
|
||||
}
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data, int $actorId): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO appointments
|
||||
(tenant_id, student_id, instructor_id, lesson_type_id, title, category, start_at, end_at, units, status, notes, warning_acknowledged, created_by, updated_by, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :student_id, :instructor_id, :lesson_type_id, :title, :category, :start_at, :end_at, :units, :status, :notes, :warning_acknowledged, :created_by, :updated_by, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $data['student_id'] ?: null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'],
|
||||
'category' => $data['category'],
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => (int) $data['units'],
|
||||
'status' => $data['status'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
|
||||
'created_by' => $actorId,
|
||||
'updated_by' => $actorId,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
$appointmentId = (int) $this->db->lastInsertId();
|
||||
$this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []);
|
||||
|
||||
return $this->find($tenantId, $appointmentId);
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data, int $actorId): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE appointments
|
||||
SET student_id = :student_id, instructor_id = :instructor_id, lesson_type_id = :lesson_type_id,
|
||||
title = :title, category = :category, start_at = :start_at, end_at = :end_at, units = :units,
|
||||
status = :status, notes = :notes, warning_acknowledged = :warning_acknowledged,
|
||||
updated_by = :updated_by, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'student_id' => $data['student_id'] ?: null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'],
|
||||
'category' => $data['category'],
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => (int) $data['units'],
|
||||
'status' => $data['status'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceUnits($id, $data['units_breakdown'] ?? []);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function delete(int $tenantId, int $id): void
|
||||
{
|
||||
$statement = $this->db->prepare('DELETE FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
}
|
||||
|
||||
public function units(int $appointmentId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM appointment_units WHERE appointment_id = :appointment_id');
|
||||
$statement->execute(['appointment_id' => $appointmentId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function overlappingForInstructor(int $tenantId, int $instructorId, string $from, string $to, ?int $ignoreId = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM appointments
|
||||
WHERE tenant_id = :tenant_id AND instructor_id = :instructor_id
|
||||
AND start_at < :to AND end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function overlappingForStudent(int $tenantId, int $studentId, string $from, string $to, ?int $ignoreId = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM appointments
|
||||
WHERE tenant_id = :tenant_id AND student_id = :student_id
|
||||
AND start_at < :to AND end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $studentId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function lastRestRelevantForInstructor(int $tenantId, int $instructorId, string $beforeStart, ?int $ignoreId = null): ?array
|
||||
{
|
||||
$sql = 'SELECT a.*, lt.name AS lesson_type_name
|
||||
FROM appointments a
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.instructor_id = :instructor_id
|
||||
AND lt.is_rest_relevant = 1
|
||||
AND a.end_at <= :before_start';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'before_start' => $beforeStart,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND a.id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.end_at DESC LIMIT 1';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function hasRestRelevantAfterStartOnDay(int $tenantId, int $instructorId, string $dayStart, string $dayEnd, string $startAt): bool
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT 1
|
||||
FROM appointments a
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.instructor_id = :instructor_id
|
||||
AND lt.is_rest_relevant = 1
|
||||
AND a.start_at < :day_end
|
||||
AND a.end_at > :day_start
|
||||
AND a.end_at > :start_at
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'day_start' => $dayStart,
|
||||
'day_end' => $dayEnd,
|
||||
'start_at' => $startAt,
|
||||
]);
|
||||
|
||||
return (bool) $statement->fetchColumn();
|
||||
}
|
||||
|
||||
public function nextForTenant(int $tenantId, string $from, ?int $instructorId = null): ?array
|
||||
{
|
||||
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name,
|
||||
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
|
||||
lt.name AS lesson_type_name
|
||||
FROM appointments a
|
||||
JOIN instructors i ON i.id = a.instructor_id
|
||||
LEFT JOIN students s ON s.id = a.student_id
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.start_at >= :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'from' => $from,
|
||||
];
|
||||
|
||||
if ($instructorId !== null) {
|
||||
$sql .= ' AND a.instructor_id = :instructor_id';
|
||||
$params['instructor_id'] = $instructorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.start_at ASC LIMIT 1';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function replaceUnits(int $appointmentId, array $rows): void
|
||||
{
|
||||
$delete = $this->db->prepare('DELETE FROM appointment_units WHERE appointment_id = :appointment_id');
|
||||
$delete->execute(['appointment_id' => $appointmentId]);
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO appointment_units (appointment_id, requirement_key, label, units_counted, created_at)
|
||||
VALUES (:appointment_id, :requirement_key, :label, :units_counted, :created_at)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['units_counted'] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'appointment_id' => $appointmentId,
|
||||
'requirement_key' => $row['requirement_key'] ?? null,
|
||||
'label' => $row['label'] ?? null,
|
||||
'units_counted' => (int) $row['units_counted'],
|
||||
'created_at' => $this->now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
www/fahrschultermin.de/app/Repositories/BaseRepository.php
Normal file
23
www/fahrschultermin.de/app/Repositories/BaseRepository.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Support\Database;
|
||||
use PDO;
|
||||
|
||||
abstract class BaseRepository
|
||||
{
|
||||
protected PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::connection();
|
||||
}
|
||||
|
||||
protected function now(): string
|
||||
{
|
||||
return gmdate('c');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class InstructorRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id ORDER BY last_name, first_name');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function findByUserId(int $tenantId, int $userId): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM instructors WHERE tenant_id = :tenant_id AND user_id = :user_id LIMIT 1'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'user_id' => $userId]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO instructors
|
||||
(tenant_id, user_id, first_name, last_name, color, notes, is_active, pre_start_buffer_minutes, created_at, updated_at)
|
||||
VALUES (:tenant_id, :user_id, :first_name, :last_name, :color, :notes, :is_active, :pre_start_buffer_minutes, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'color' => $data['color'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, (int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE instructors
|
||||
SET user_id = :user_id, first_name = :first_name, last_name = :last_name, color = :color,
|
||||
notes = :notes, is_active = :is_active, pre_start_buffer_minutes = :pre_start_buffer_minutes, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'color' => $data['color'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class ReferenceDataRepository extends BaseRepository
|
||||
{
|
||||
private const DEFAULT_PLANNING_TYPES = [
|
||||
['system_key' => 'private_block', 'name' => 'Privat', 'color' => '#6B7280', 'default_duration' => 60, 'category' => 'private', 'sort_order' => 900],
|
||||
['system_key' => 'work_vehicle_care', 'name' => 'Fahrzeugpflege', 'color' => '#0E5D80', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 910],
|
||||
['system_key' => 'work_office', 'name' => 'Buero', 'color' => '#8B5A2B', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 920],
|
||||
['system_key' => 'work_transfer_drive', 'name' => 'Wechselfahrt', 'color' => '#3B7A57', 'default_duration' => 45, 'category' => 'work', 'sort_order' => 930],
|
||||
['system_key' => 'work_other', 'name' => 'Sonstige Taetigkeit', 'color' => '#7C3AED', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 940],
|
||||
['system_key' => 'theory_a', 'name' => 'Theorie A', 'color' => '#C2410C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 950],
|
||||
['system_key' => 'theory_b', 'name' => 'Theorie B', 'color' => '#A74826', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 960],
|
||||
['system_key' => 'theory_c', 'name' => 'Theorie C', 'color' => '#B45309', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 970],
|
||||
['system_key' => 'theory_d', 'name' => 'Theorie D', 'color' => '#BE123C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 980],
|
||||
['system_key' => 'theory_t', 'name' => 'Theorie T', 'color' => '#047857', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 990],
|
||||
['system_key' => 'theory_bkf', 'name' => 'Theorie BKF', 'color' => '#1D4ED8', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 1000],
|
||||
];
|
||||
|
||||
public function requirementKeyForLessonType(array $lessonType): string
|
||||
{
|
||||
$key = trim((string) ($lessonType['requirement_key'] ?? ''));
|
||||
return $key !== '' ? $key : 'lesson_type_' . (int) $lessonType['id'];
|
||||
}
|
||||
|
||||
public function lessonTypes(int $tenantId): array
|
||||
{
|
||||
$this->ensureDefaultPlanningTypes($tenantId);
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id ORDER BY sort_order, name');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
$lessonTypes = $statement->fetchAll();
|
||||
$visibilityMap = $this->lessonTypeVisibilityMap($tenantId);
|
||||
|
||||
foreach ($lessonTypes as &$lessonType) {
|
||||
$lessonType['visible_template_ids'] = $visibilityMap[(int) $lessonType['id']] ?? [];
|
||||
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
|
||||
}
|
||||
|
||||
return $lessonTypes;
|
||||
}
|
||||
|
||||
public function createLessonType(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO lesson_types
|
||||
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :name, :color, :default_duration, :category, :requirement_key, :is_billable, :is_counted, :is_rest_relevant, :fixed_duration, :sort_order, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
'default_duration' => (int) $data['default_duration'],
|
||||
'category' => $data['category'],
|
||||
'requirement_key' => $data['requirement_key'] ?: null,
|
||||
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
|
||||
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
|
||||
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
|
||||
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
$lessonTypeId = (int) $this->db->lastInsertId();
|
||||
$this->replaceLessonTypeVisibility($tenantId, $lessonTypeId, $data['visible_template_ids'] ?? null);
|
||||
|
||||
return $this->findLessonType($tenantId, $lessonTypeId);
|
||||
}
|
||||
|
||||
public function updateLessonType(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE lesson_types
|
||||
SET name = :name, color = :color, default_duration = :default_duration, category = :category,
|
||||
requirement_key = :requirement_key, is_billable = :is_billable, is_counted = :is_counted,
|
||||
is_rest_relevant = :is_rest_relevant, fixed_duration = :fixed_duration, sort_order = :sort_order,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
'default_duration' => (int) $data['default_duration'],
|
||||
'category' => $data['category'],
|
||||
'requirement_key' => $data['requirement_key'] ?: null,
|
||||
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
|
||||
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
|
||||
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
|
||||
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceLessonTypeVisibility($tenantId, $id, $data['visible_template_ids'] ?? null);
|
||||
|
||||
return $this->findLessonType($tenantId, $id);
|
||||
}
|
||||
|
||||
public function deleteLessonType(int $tenantId, int $id): bool
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $id);
|
||||
if ($lessonType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($lessonType['system_key'] ?? null) === 'private_block') {
|
||||
throw new \RuntimeException('Der feste Privat-Baustein kann nicht geloescht werden.');
|
||||
}
|
||||
|
||||
$usage = $this->db->prepare('SELECT COUNT(*) AS count FROM appointments WHERE tenant_id = :tenant_id AND lesson_type_id = :lesson_type_id');
|
||||
$usage->execute(['tenant_id' => $tenantId, 'lesson_type_id' => $id]);
|
||||
if ((int) ($usage->fetch()['count'] ?? 0) > 0) {
|
||||
throw new \RuntimeException('Diese Stundenart wird bereits in Terminen verwendet und kann nicht geloescht werden.');
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$visibility = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
|
||||
$visibility->execute(['lesson_type_id' => $id]);
|
||||
|
||||
$delete = $this->db->prepare('DELETE FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$delete->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->db->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function reorderLessonTypes(int $tenantId, array $lessonTypeIds): array
|
||||
{
|
||||
$allowedIds = array_map(
|
||||
static fn (array $lessonType): int => (int) $lessonType['id'],
|
||||
$this->lessonTypes($tenantId)
|
||||
);
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE lesson_types
|
||||
SET sort_order = :sort_order, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
|
||||
$sortOrder = 1;
|
||||
foreach ($lessonTypeIds as $lessonTypeId) {
|
||||
$lessonTypeId = (int) $lessonTypeId;
|
||||
if (!in_array($lessonTypeId, $allowedIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statement->execute([
|
||||
'sort_order' => $sortOrder,
|
||||
'updated_at' => $this->now(),
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $lessonTypeId,
|
||||
]);
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
return $this->lessonTypes($tenantId);
|
||||
}
|
||||
|
||||
public function findLessonType(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$lessonType = $statement->fetch() ?: null;
|
||||
if ($lessonType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lessonType['visible_template_ids'] = $this->visibleTemplateIdsForLessonType($tenantId, $id);
|
||||
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
|
||||
|
||||
return $lessonType;
|
||||
}
|
||||
|
||||
public function templates(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
$templates = $statement->fetchAll();
|
||||
|
||||
foreach ($templates as &$template) {
|
||||
$template['requirements'] = $this->templateRequirements((int) $template['id']);
|
||||
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate((int) $template['id']);
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
public function templateRequirements(int $templateId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_template_requirements WHERE template_id = :template_id ORDER BY sort_order, phase_label, label'
|
||||
);
|
||||
$statement->execute(['template_id' => $templateId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function createTemplate(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO license_class_templates
|
||||
(tenant_id, code, name, is_combination, created_at, updated_at)
|
||||
VALUES (:tenant_id, :code, :name, :is_combination, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
$templateId = (int) $this->db->lastInsertId();
|
||||
|
||||
$this->replaceTemplateRequirements($templateId, $data['requirements'] ?? []);
|
||||
$this->attachTemplateToExistingStudentLessonTypes($tenantId, $templateId);
|
||||
|
||||
return $this->findTemplate($tenantId, $templateId);
|
||||
}
|
||||
|
||||
public function updateTemplate(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE license_class_templates
|
||||
SET code = :code, name = :name, is_combination = :is_combination, updated_at = :updated_at
|
||||
WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceTemplateRequirements($id, $data['requirements'] ?? []);
|
||||
|
||||
return $this->findTemplate($tenantId, $id);
|
||||
}
|
||||
|
||||
public function updateLessonTypeMatrix(int $tenantId, array $rows): array
|
||||
{
|
||||
$templates = $this->templates($tenantId);
|
||||
$templateIds = array_map(static fn (array $template): int => (int) $template['id'], $templates);
|
||||
$allowedLessonTypeIds = array_map(
|
||||
static fn (array $lessonType): int => (int) $lessonType['id'],
|
||||
array_filter($this->lessonTypes($tenantId), static fn (array $lessonType): bool => $lessonType['category'] === 'student')
|
||||
);
|
||||
|
||||
$delete = $this->db->prepare(
|
||||
'DELETE FROM lesson_type_template_visibility
|
||||
WHERE template_id = :template_id
|
||||
AND lesson_type_id IN (
|
||||
SELECT id FROM lesson_types WHERE tenant_id = :tenant_id AND category = \'student\'
|
||||
)'
|
||||
);
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
VALUES (:lesson_type_id, :template_id)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$templateId = (int) ($row['template_id'] ?? 0);
|
||||
if (!in_array($templateId, $templateIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delete->execute([
|
||||
'template_id' => $templateId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
foreach ((array) ($row['lesson_type_ids'] ?? []) as $lessonTypeId) {
|
||||
$lessonTypeId = (int) $lessonTypeId;
|
||||
if (!in_array($lessonTypeId, $allowedLessonTypeIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'lesson_type_id' => $lessonTypeId,
|
||||
'template_id' => $templateId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->templates($tenantId);
|
||||
}
|
||||
|
||||
public function isLessonTypeVisibleForTemplate(int $tenantId, int $lessonTypeId, int $templateId): bool
|
||||
{
|
||||
$lessonType = $this->findLessonType($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($lessonType['category'] !== 'student') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($templateId, array_map('intval', $lessonType['visible_template_ids'] ?? []), true);
|
||||
}
|
||||
|
||||
private function replaceTemplateRequirements(int $templateId, array $requirements): void
|
||||
{
|
||||
$delete = $this->db->prepare('DELETE FROM license_template_requirements WHERE template_id = :template_id');
|
||||
$delete->execute(['template_id' => $templateId]);
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO license_template_requirements
|
||||
(template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
VALUES (:template_id, :requirement_key, :label, :required_units, :sort_order, :phase_label)'
|
||||
);
|
||||
|
||||
foreach ($requirements as $index => $requirement) {
|
||||
if (($requirement['label'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'template_id' => $templateId,
|
||||
'requirement_key' => $requirement['requirement_key'],
|
||||
'label' => $requirement['label'],
|
||||
'required_units' => (int) $requirement['required_units'],
|
||||
'sort_order' => (int) ($requirement['sort_order'] ?? $index),
|
||||
'phase_label' => trim((string) ($requirement['phase_label'] ?? '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function findTemplate(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$template = $statement->fetch() ?: null;
|
||||
|
||||
if ($template !== null) {
|
||||
$template['requirements'] = $this->templateRequirements($id);
|
||||
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate($id);
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
private function replaceLessonTypeVisibility(int $tenantId, int $lessonTypeId, ?array $visibleTemplateIds): void
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delete = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
|
||||
$delete->execute(['lesson_type_id' => $lessonTypeId]);
|
||||
|
||||
if ($lessonType['category'] !== 'student') {
|
||||
return;
|
||||
}
|
||||
|
||||
$templateIds = $visibleTemplateIds ?? $this->templateIdsForTenant($tenantId);
|
||||
$templateIds = array_values(array_unique(array_map('intval', $templateIds)));
|
||||
if ($templateIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedTemplateIds = $this->templateIdsForTenant($tenantId);
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
VALUES (:lesson_type_id, :template_id)'
|
||||
);
|
||||
|
||||
foreach ($templateIds as $templateId) {
|
||||
if (!in_array($templateId, $allowedTemplateIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'lesson_type_id' => $lessonTypeId,
|
||||
'template_id' => $templateId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function lessonTypeVisibilityMap(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT lt.id AS lesson_type_id, vis.template_id
|
||||
FROM lesson_types lt
|
||||
LEFT JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
|
||||
WHERE lt.tenant_id = :tenant_id AND lt.category = \'student\'
|
||||
ORDER BY vis.template_id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
$map = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$lessonTypeId = (int) $row['lesson_type_id'];
|
||||
$map[$lessonTypeId] ??= [];
|
||||
if ($row['template_id'] !== null) {
|
||||
$map[$lessonTypeId][] = (int) $row['template_id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function visibleTemplateIdsForLessonType(int $tenantId, int $lessonTypeId): array
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null || $lessonType['category'] !== 'student') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT template_id FROM lesson_type_template_visibility
|
||||
WHERE lesson_type_id = :lesson_type_id
|
||||
ORDER BY template_id'
|
||||
);
|
||||
$statement->execute(['lesson_type_id' => $lessonTypeId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['template_id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function lessonTypeIdsForTemplate(int $templateId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT lesson_type_id FROM lesson_type_template_visibility
|
||||
WHERE template_id = :template_id
|
||||
ORDER BY lesson_type_id'
|
||||
);
|
||||
$statement->execute(['template_id' => $templateId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['lesson_type_id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function templateIdsForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT id FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY id');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function attachTemplateToExistingStudentLessonTypes(int $tenantId, int $templateId): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
SELECT id, :template_id FROM lesson_types
|
||||
WHERE tenant_id = :tenant_id AND category = \'student\''
|
||||
);
|
||||
$statement->execute([
|
||||
'template_id' => $templateId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findLessonTypeRecord(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function ensureDefaultPlanningTypes(int $tenantId): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_types
|
||||
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, system_key, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :name, :color, :default_duration, :category, NULL, 0, 0, :is_rest_relevant, 0, :sort_order, :system_key, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$timestamp = $this->now();
|
||||
foreach (self::DEFAULT_PLANNING_TYPES as $row) {
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $row['name'],
|
||||
'color' => $row['color'],
|
||||
'default_duration' => $row['default_duration'],
|
||||
'category' => $row['category'],
|
||||
'is_rest_relevant' => $row['category'] === 'private' ? 0 : 1,
|
||||
'sort_order' => $row['sort_order'],
|
||||
'system_key' => $row['system_key'],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
294
www/fahrschultermin.de/app/Repositories/StudentRepository.php
Normal file
294
www/fahrschultermin.de/app/Repositories/StudentRepository.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class StudentRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE s.tenant_id = :tenant_id
|
||||
ORDER BY s.last_name, s.first_name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE s.tenant_id = :tenant_id AND s.id = :id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$student = $statement->fetch() ?: null;
|
||||
|
||||
if ($student !== null) {
|
||||
$student['requirements'] = $this->requirements($id);
|
||||
}
|
||||
|
||||
return $student;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO students
|
||||
(tenant_id, template_id, first_name, last_name, phone, email, needs_glasses, notes, status, created_at, updated_at)
|
||||
VALUES (:tenant_id, :template_id, :first_name, :last_name, :phone, :email, :needs_glasses, :notes, :status, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'template_id' => $data['template_id'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
$studentId = (int) $this->db->lastInsertId();
|
||||
$this->snapshotRequirements($studentId, (int) $data['template_id']);
|
||||
|
||||
return $this->find($tenantId, $studentId);
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE students
|
||||
SET template_id = :template_id, first_name = :first_name, last_name = :last_name, phone = :phone, email = :email, needs_glasses = :needs_glasses,
|
||||
notes = :notes, status = :status, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'template_id' => $data['template_id'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function delete(int $tenantId, int $id): bool
|
||||
{
|
||||
$student = $this->find($tenantId, $id);
|
||||
if ($student === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$updateAppointments = $this->db->prepare(
|
||||
'UPDATE appointments SET student_id = NULL, updated_at = :updated_at WHERE tenant_id = :tenant_id AND student_id = :student_id'
|
||||
);
|
||||
$updateAppointments->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $id,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$deleteSnapshots = $this->db->prepare('DELETE FROM student_requirement_snapshots WHERE student_id = :student_id');
|
||||
$deleteSnapshots->execute(['student_id' => $id]);
|
||||
|
||||
$deleteStudent = $this->db->prepare('DELETE FROM students WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$deleteStudent->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->db->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function requirements(int $studentId): array
|
||||
{
|
||||
$this->syncRequirementSnapshots($studentId);
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM student_requirement_snapshots WHERE student_id = :student_id ORDER BY sort_order, phase_label, label'
|
||||
);
|
||||
$statement->execute(['student_id' => $studentId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function updatePriorCompletedUnits(int $tenantId, int $studentId, array $rows): ?array
|
||||
{
|
||||
$student = $this->find($tenantId, $studentId);
|
||||
if ($student === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE student_requirement_snapshots
|
||||
SET prior_completed_units = :prior_completed_units
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => (string) ($row['requirement_key'] ?? ''),
|
||||
'prior_completed_units' => max(0, (int) ($row['prior_completed_units'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->find($tenantId, $studentId);
|
||||
}
|
||||
|
||||
private function snapshotRequirements(int $studentId, int $templateId): void
|
||||
{
|
||||
$this->syncRequirementSnapshots($studentId, $templateId);
|
||||
}
|
||||
|
||||
private function syncRequirementSnapshots(int $studentId, ?int $templateId = null): void
|
||||
{
|
||||
if ($templateId === null) {
|
||||
$student = $this->db->prepare('SELECT template_id FROM students WHERE id = :student_id');
|
||||
$student->execute(['student_id' => $studentId]);
|
||||
$templateId = (int) ($student->fetch()['template_id'] ?? 0);
|
||||
}
|
||||
|
||||
if ($templateId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requirements = $this->db->prepare(
|
||||
'SELECT requirement_key, label, required_units, sort_order, phase_label
|
||||
FROM license_template_requirements WHERE template_id = :template_id'
|
||||
);
|
||||
$requirements->execute(['template_id' => $templateId]);
|
||||
$rows = [];
|
||||
|
||||
foreach ($requirements->fetchAll() as $row) {
|
||||
$rows[$row['requirement_key']] = $row;
|
||||
}
|
||||
$hasPhasedRequirements = array_reduce(
|
||||
$rows,
|
||||
static fn (bool $carry, array $row): bool => $carry || trim((string) ($row['phase_label'] ?? '')) !== '',
|
||||
false
|
||||
);
|
||||
|
||||
$lessonTypes = $this->db->prepare(
|
||||
'SELECT COALESCE(NULLIF(TRIM(lt.requirement_key), \'\'), \'lesson_type_\' || lt.id) AS requirement_key,
|
||||
lt.name AS label,
|
||||
lt.sort_order
|
||||
FROM lesson_types lt
|
||||
JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
|
||||
WHERE vis.template_id = :template_id
|
||||
AND lt.category = \'student\'
|
||||
AND lt.is_counted = 1
|
||||
ORDER BY lt.sort_order, lt.name'
|
||||
);
|
||||
$lessonTypes->execute(['template_id' => $templateId]);
|
||||
|
||||
foreach ($lessonTypes->fetchAll() as $row) {
|
||||
if (isset($rows[$row['requirement_key']])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasPhasedRequirements) {
|
||||
$hasMatchingPhasedRequirement = false;
|
||||
foreach ($rows as $existingRow) {
|
||||
$phaseLabel = trim((string) ($existingRow['phase_label'] ?? ''));
|
||||
if ($phaseLabel === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with((string) $existingRow['requirement_key'], '::' . $row['requirement_key'])) {
|
||||
$hasMatchingPhasedRequirement = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasMatchingPhasedRequirement) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$rows[$row['requirement_key']] = [
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => 0,
|
||||
'sort_order' => $row['sort_order'],
|
||||
];
|
||||
}
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO student_requirement_snapshots
|
||||
(student_id, requirement_key, label, required_units, prior_completed_units, sort_order, phase_label)
|
||||
SELECT :student_id, :requirement_key, :label, :required_units, 0, :sort_order, :phase_label
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM student_requirement_snapshots
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key
|
||||
)'
|
||||
);
|
||||
$update = $this->db->prepare(
|
||||
'UPDATE student_requirement_snapshots
|
||||
SET label = :label,
|
||||
required_units = :required_units,
|
||||
sort_order = :sort_order,
|
||||
phase_label = :phase_label
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$insert->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => $row['required_units'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
'phase_label' => $row['phase_label'] ?? '',
|
||||
]);
|
||||
$update->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => $row['required_units'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
'phase_label' => $row['phase_label'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$placeholders = implode(', ', array_fill(0, count($rows), '?'));
|
||||
$delete = $this->db->prepare(
|
||||
"DELETE FROM student_requirement_snapshots
|
||||
WHERE student_id = ?
|
||||
AND requirement_key NOT IN ($placeholders)
|
||||
AND prior_completed_units = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM appointment_units au
|
||||
JOIN appointments a ON a.id = au.appointment_id
|
||||
WHERE a.student_id = student_requirement_snapshots.student_id
|
||||
AND au.requirement_key = student_requirement_snapshots.requirement_key
|
||||
)"
|
||||
);
|
||||
$delete->execute(array_merge([$studentId], array_keys($rows)));
|
||||
}
|
||||
}
|
||||
}
|
||||
86
www/fahrschultermin.de/app/Repositories/TenantRepository.php
Normal file
86
www/fahrschultermin.de/app/Repositories/TenantRepository.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class TenantRepository extends BaseRepository
|
||||
{
|
||||
public function all(): array
|
||||
{
|
||||
return $this->db->query('SELECT * FROM tenants ORDER BY name')->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM tenants WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO tenants (name, timezone, federal_state, location_label, latitude, longitude, is_active, created_at, updated_at)
|
||||
VALUES (:name, :timezone, :federal_state, :location_label, :latitude, :longitude, :is_active, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'name' => $data['name'],
|
||||
'timezone' => $data['timezone'] ?? 'Europe/Berlin',
|
||||
'federal_state' => $data['federal_state'] ?? 'BE',
|
||||
'location_label' => $data['location_label'] ?? '',
|
||||
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
|
||||
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->find((int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE tenants
|
||||
SET name = :name, timezone = :timezone, federal_state = :federal_state, location_label = :location_label,
|
||||
latitude = :latitude, longitude = :longitude, is_active = :is_active, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'name' => $data['name'],
|
||||
'timezone' => $data['timezone'],
|
||||
'federal_state' => $data['federal_state'] ?? 'BE',
|
||||
'location_label' => $data['location_label'] ?? '',
|
||||
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
|
||||
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function updateForTenantAdmin(int $id, array $data): ?array
|
||||
{
|
||||
$current = $this->find($id);
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->update($id, [
|
||||
'name' => $data['name'] ?? $current['name'],
|
||||
'timezone' => $data['timezone'] ?? $current['timezone'],
|
||||
'federal_state' => $data['federal_state'] ?? ($current['federal_state'] ?? 'BE'),
|
||||
'location_label' => $data['location_label'] ?? ($current['location_label'] ?? ''),
|
||||
'latitude' => array_key_exists('latitude', $data) ? $data['latitude'] : ($current['latitude'] ?? null),
|
||||
'longitude' => array_key_exists('longitude', $data) ? $data['longitude'] : ($current['longitude'] ?? null),
|
||||
'is_active' => $current['is_active'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
112
www/fahrschultermin.de/app/Repositories/UserRepository.php
Normal file
112
www/fahrschultermin.de/app/Repositories/UserRepository.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class UserRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT id, tenant_id, role, email, first_name, last_name, is_active, created_at, updated_at
|
||||
FROM users WHERE tenant_id = :tenant_id ORDER BY role, last_name, first_name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function allUsers(): array
|
||||
{
|
||||
return $this->db->query(
|
||||
'SELECT u.id, u.tenant_id, u.role, u.email, u.first_name, u.last_name, u.is_active,
|
||||
u.created_at, u.updated_at, t.name AS tenant_name
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
ORDER BY COALESCE(t.name, "System"), u.role, u.last_name, u.first_name'
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
WHERE u.email = :email'
|
||||
);
|
||||
$statement->execute(['email' => $email]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
WHERE u.id = :id'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO users
|
||||
(tenant_id, role, email, password_hash, first_name, last_name, is_active, created_at, updated_at)
|
||||
VALUES (:tenant_id, :role, :email, :password_hash, :first_name, :last_name, :is_active, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $data['tenant_id'],
|
||||
'role' => $data['role'],
|
||||
'email' => $data['email'],
|
||||
'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->findById((int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?array
|
||||
{
|
||||
$current = $this->findById($id);
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$passwordHash = $current['password_hash'];
|
||||
if (!empty($data['password'])) {
|
||||
$passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET tenant_id = :tenant_id, role = :role, email = :email, password_hash = :password_hash,
|
||||
first_name = :first_name, last_name = :last_name, is_active = :is_active, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $data['tenant_id'],
|
||||
'role' => $data['role'],
|
||||
'email' => $data['email'],
|
||||
'password_hash' => $passwordHash,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->findById($id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user