feat: Permettre aux élèves de consulter leur emploi du temps
Les élèves n'avaient aucun moyen de voir leur emploi du temps depuis l'application. Cette fonctionnalité ajoute une page dédiée avec deux modes de visualisation (jour et semaine), la navigation temporelle, et le détail des cours au tap. Le backend résout l'EDT de l'élève en chaînant : affectation classe → créneaux récurrents + exceptions + calendrier scolaire → enrichissement des noms (matières/enseignants). Le frontend utilise un cache offline (Workbox NetworkFirst) pour rester consultable hors connexion.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour obtenir le calendrier scolaire de l'année courante.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface CurrentCalendarProvider
|
||||
{
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour lire les noms d'affichage des matières et enseignants.
|
||||
*
|
||||
* Permet à la couche Application de résoudre les noms sans dépendre
|
||||
* directement des repositories du bounded context Administration.
|
||||
*/
|
||||
interface ScheduleDisplayReader
|
||||
{
|
||||
/**
|
||||
* @param string ...$subjectIds Identifiants des matières
|
||||
*
|
||||
* @return array<string, string> Map subjectId => nom de la matière
|
||||
*/
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array;
|
||||
|
||||
/**
|
||||
* @param string ...$teacherIds Identifiants des enseignants
|
||||
*
|
||||
* @return array<string, string> Map teacherId => "Prénom Nom"
|
||||
*/
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour résoudre la classe d'un élève pour l'année scolaire courante.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface StudentClassReader
|
||||
{
|
||||
/**
|
||||
* @return string|null L'identifiant de la classe, ou null si l'élève n'est affecté à aucune classe
|
||||
*/
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentScheduleHandler
|
||||
{
|
||||
public function __construct(
|
||||
private StudentClassReader $studentClassReader,
|
||||
private ScheduleResolver $scheduleResolver,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<StudentScheduleSlotDto> */
|
||||
public function __invoke(GetStudentScheduleQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
$classId = $this->studentClassReader->currentClassId($query->studentId, $tenantId);
|
||||
|
||||
if ($classId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$date = new DateTimeImmutable($query->date);
|
||||
$weekStart = $this->mondayOfWeek($date);
|
||||
|
||||
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
|
||||
|
||||
$resolved = $this->scheduleResolver->resolveForWeek(
|
||||
ClassId::fromString($classId),
|
||||
$weekStart,
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
|
||||
return $this->enrichSlots($resolved, $query->tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ResolvedScheduleSlot> $slots
|
||||
*
|
||||
* @return array<StudentScheduleSlotDto>
|
||||
*/
|
||||
private function enrichSlots(array $slots, string $tenantId): array
|
||||
{
|
||||
if ($slots === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjectIds = array_values(array_unique(
|
||||
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->subjectId, $slots),
|
||||
));
|
||||
$teacherIds = array_values(array_unique(
|
||||
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
|
||||
));
|
||||
|
||||
$subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds);
|
||||
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
|
||||
|
||||
return array_map(
|
||||
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
||||
$s,
|
||||
$subjectNames[(string) $s->subjectId] ?? '',
|
||||
$teacherNames[(string) $s->teacherId] ?? '',
|
||||
),
|
||||
$slots,
|
||||
);
|
||||
}
|
||||
|
||||
private function mondayOfWeek(DateTimeImmutable $date): DateTimeImmutable
|
||||
{
|
||||
$dayOfWeek = (int) $date->format('N');
|
||||
|
||||
if ($dayOfWeek === 1) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return $date->modify('-' . ($dayOfWeek - 1) . ' days');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class GetStudentScheduleQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $studentId,
|
||||
public string $tenantId,
|
||||
public string $date,
|
||||
) {
|
||||
if (DateTimeImmutable::createFromFormat('Y-m-d', $date) === false) {
|
||||
throw new InvalidArgumentException(sprintf('Date invalide : "%s". Format attendu : YYYY-MM-DD.', $date));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
|
||||
final readonly class StudentScheduleSlotDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $slotId,
|
||||
public string $date,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public string $subjectId,
|
||||
public string $subjectName,
|
||||
public string $teacherId,
|
||||
public string $teacherName,
|
||||
public ?string $room,
|
||||
public bool $isModified,
|
||||
public ?string $exceptionId,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromResolved(
|
||||
ResolvedScheduleSlot $slot,
|
||||
string $subjectName,
|
||||
string $teacherName,
|
||||
): self {
|
||||
return new self(
|
||||
slotId: (string) $slot->slotId,
|
||||
date: $slot->date->format('Y-m-d'),
|
||||
dayOfWeek: $slot->dayOfWeek->value,
|
||||
startTime: $slot->timeSlot->startTime,
|
||||
endTime: $slot->timeSlot->endTime,
|
||||
subjectId: (string) $slot->subjectId,
|
||||
subjectName: $subjectName,
|
||||
teacherId: (string) $slot->teacherId,
|
||||
teacherName: $teacherName,
|
||||
room: $slot->room,
|
||||
isModified: $slot->isModified,
|
||||
exceptionId: $slot->exceptionId !== null ? (string) $slot->exceptionId : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function date;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function usort;
|
||||
|
||||
/**
|
||||
* Endpoints de consultation de l'emploi du temps pour l'élève connecté.
|
||||
*
|
||||
* @see Story 4.3 - Consultation EDT par l'Élève
|
||||
* @see FR29 - Consulter emploi du temps (élève)
|
||||
*/
|
||||
#[IsGranted(ScheduleSlotVoter::VIEW)]
|
||||
final readonly class StudentScheduleController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private GetStudentScheduleHandler $handler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* EDT du jour pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/day/{date}', name: 'api_student_schedule_day', methods: ['GET'])]
|
||||
public function day(string $date): JsonResponse
|
||||
{
|
||||
$slots = $this->resolveSchedule($date);
|
||||
|
||||
$daySlots = array_values(array_filter(
|
||||
$slots,
|
||||
static fn (StudentScheduleSlotDto $s): bool => $s->date === $date,
|
||||
));
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlots($daySlots)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* EDT de la semaine pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/week/{date}', name: 'api_student_schedule_week', methods: ['GET'])]
|
||||
public function week(string $date): JsonResponse
|
||||
{
|
||||
$slots = $this->resolveSchedule($date);
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlots($slots)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prochain cours pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/next-class', name: 'api_student_schedule_next_class', methods: ['GET'])]
|
||||
public function nextClass(): JsonResponse
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$now = date('H:i');
|
||||
|
||||
$slots = $this->resolveSchedule($today);
|
||||
|
||||
// Chercher le prochain cours : d'abord aujourd'hui après l'heure actuelle
|
||||
$nextSlot = $this->findNextSlot($slots, $today, $now);
|
||||
|
||||
if ($nextSlot === null) {
|
||||
return new JsonResponse(['data' => null]);
|
||||
}
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlot($nextSlot)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<StudentScheduleSlotDto>
|
||||
*/
|
||||
private function resolveSchedule(string $date): array
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
try {
|
||||
return ($this->handler)(new GetStudentScheduleQuery(
|
||||
studentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
date: $date,
|
||||
));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
} catch (RuntimeException $e) {
|
||||
throw new ServiceUnavailableHttpException(null, "L'emploi du temps n'est pas encore disponible.", $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<StudentScheduleSlotDto> $slots
|
||||
*/
|
||||
private function findNextSlot(array $slots, string $today, string $now): ?StudentScheduleSlotDto
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
// Cours aujourd'hui qui n'a pas encore commencé
|
||||
if ($slot->date === $today && $slot->startTime > $now) {
|
||||
$candidates[] = $slot;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cours des jours suivants de la semaine
|
||||
if ($slot->date > $today) {
|
||||
$candidates[] = $slot;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static function (StudentScheduleSlotDto $a, StudentScheduleSlotDto $b): int {
|
||||
$cmp = $a->date <=> $b->date;
|
||||
|
||||
return $cmp !== 0 ? $cmp : $a->startTime <=> $b->startTime;
|
||||
});
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function getSecurityUser(): SecurityUser
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new AccessDeniedHttpException('Authentification requise.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<StudentScheduleSlotDto> $slots
|
||||
*
|
||||
* @return array<array<string, mixed>>
|
||||
*/
|
||||
private function serializeSlots(array $slots): array
|
||||
{
|
||||
return array_map($this->serializeSlot(...), $slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeSlot(StudentScheduleSlotDto $slot): array
|
||||
{
|
||||
return [
|
||||
'slotId' => $slot->slotId,
|
||||
'date' => $slot->date,
|
||||
'dayOfWeek' => $slot->dayOfWeek,
|
||||
'startTime' => $slot->startTime,
|
||||
'endTime' => $slot->endTime,
|
||||
'subjectId' => $slot->subjectId,
|
||||
'subjectName' => $slot->subjectName,
|
||||
'teacherId' => $slot->teacherId,
|
||||
'teacherName' => $slot->teacherName,
|
||||
'room' => $slot->room,
|
||||
'isModified' => $slot->isModified,
|
||||
'exceptionId' => $slot->exceptionId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
* Voter pour les autorisations sur l'emploi du temps.
|
||||
*
|
||||
* Seuls ADMIN et SUPER_ADMIN peuvent gérer l'EDT.
|
||||
* PROF et VIE_SCOLAIRE peuvent le consulter.
|
||||
* PROF, VIE_SCOLAIRE et ELEVE peuvent le consulter.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
@@ -68,6 +68,7 @@ final class ScheduleSlotVoter extends Voter
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::ELEVE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Fournit le calendrier scolaire de l'année courante.
|
||||
*/
|
||||
final readonly class CurrentYearCalendarProvider implements CurrentCalendarProvider
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
throw new RuntimeException(
|
||||
'Aucune année scolaire configurée pour le tenant ' . $tenantId . '. '
|
||||
. 'Veuillez configurer une année scolaire avant de consulter l\'emploi du temps.',
|
||||
);
|
||||
}
|
||||
|
||||
$yearId = AcademicYearId::fromString($academicYearId);
|
||||
|
||||
return $this->calendarRepository->findByTenantAndYear($tenantId, $yearId)
|
||||
?? SchoolCalendar::initialiser($tenantId, $yearId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Résout la classe d'un élève pour l'année scolaire courante.
|
||||
*/
|
||||
final readonly class CurrentYearStudentClassReader implements StudentClassReader
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string
|
||||
{
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$assignment = $this->classAssignmentRepository->findByStudent(
|
||||
UserId::fromString($studentId),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($assignment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $assignment->classId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Résout les noms d'affichage des matières et enseignants via des requêtes batch.
|
||||
*/
|
||||
final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayReader
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
if ($subjectIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT id, name FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId',
|
||||
['ids' => $subjectIds, 'tenantId' => $tenantId],
|
||||
['ids' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $name */
|
||||
$name = $row['name'];
|
||||
$names[$id] = $name;
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
if ($teacherIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT id, first_name, last_name FROM users WHERE id IN (:ids) AND tenant_id = :tenantId',
|
||||
['ids' => $teacherIds, 'tenantId' => $tenantId],
|
||||
['ids' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
$names[$id] = $firstName . ' ' . $lastName;
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetStudentScheduleHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenStudentHasNoClass(): void
|
||||
{
|
||||
$handler = $this->createHandler(classId: null);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsScheduleForStudentClass(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
$this->saveRecurringSlot(DayOfWeek::TUESDAY, '10:00', '11:00');
|
||||
|
||||
$handler = $this->createHandler(classId: self::CLASS_ID);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02', // Monday
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertContainsOnlyInstancesOf(StudentScheduleSlotDto::class, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrichesSlotsWithSubjectAndTeacherNames(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$handler = $this->createHandler(
|
||||
classId: self::CLASS_ID,
|
||||
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
|
||||
teacherNames: [self::TEACHER_ID => 'Jean Dupont'],
|
||||
);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Mathématiques', $result[0]->subjectName);
|
||||
self::assertSame('Jean Dupont', $result[0]->teacherName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function computesMondayFromAnyDayOfWeek(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$handler = $this->createHandler(classId: self::CLASS_ID);
|
||||
|
||||
// Wednesday of the same week → should still return Monday's slot
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-04', // Wednesday
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('2026-03-02', $result[0]->date);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsCorrectDtoFields(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00', 'Salle 101');
|
||||
|
||||
$handler = $this->createHandler(
|
||||
classId: self::CLASS_ID,
|
||||
subjectNames: [self::SUBJECT_ID => 'Français'],
|
||||
teacherNames: [self::TEACHER_ID => 'Marie Martin'],
|
||||
);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
$dto = $result[0];
|
||||
self::assertSame('2026-03-02', $dto->date);
|
||||
self::assertSame(1, $dto->dayOfWeek);
|
||||
self::assertSame('08:00', $dto->startTime);
|
||||
self::assertSame('09:00', $dto->endTime);
|
||||
self::assertSame(self::SUBJECT_ID, $dto->subjectId);
|
||||
self::assertSame('Français', $dto->subjectName);
|
||||
self::assertSame(self::TEACHER_ID, $dto->teacherId);
|
||||
self::assertSame('Marie Martin', $dto->teacherName);
|
||||
self::assertSame('Salle 101', $dto->room);
|
||||
self::assertFalse($dto->isModified);
|
||||
self::assertNull($dto->exceptionId);
|
||||
}
|
||||
|
||||
private function saveRecurringSlot(
|
||||
DayOfWeek $day,
|
||||
string $start,
|
||||
string $end,
|
||||
?string $room = null,
|
||||
): void {
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
dayOfWeek: $day,
|
||||
timeSlot: new TimeSlot($start, $end),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-01-01'),
|
||||
recurrenceStart: new DateTimeImmutable('2026-01-01'),
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $subjectNames
|
||||
* @param array<string, string> $teacherNames
|
||||
*/
|
||||
private function createHandler(
|
||||
?string $classId = null,
|
||||
array $subjectNames = [],
|
||||
array $teacherNames = [],
|
||||
): GetStudentScheduleHandler {
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$studentClassReader = new class($classId) implements StudentClassReader {
|
||||
public function __construct(private ?string $classId)
|
||||
{
|
||||
}
|
||||
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string
|
||||
{
|
||||
return $this->classId;
|
||||
}
|
||||
};
|
||||
|
||||
$calendarProvider = new class($tenantId) implements CurrentCalendarProvider {
|
||||
public function __construct(private TenantId $tenantId)
|
||||
{
|
||||
}
|
||||
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate());
|
||||
}
|
||||
};
|
||||
|
||||
$displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader {
|
||||
/** @param array<string, string> $subjects @param array<string, string> $teachers */
|
||||
public function __construct(
|
||||
private array $subjects,
|
||||
private array $teachers,
|
||||
) {
|
||||
}
|
||||
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
return $this->subjects;
|
||||
}
|
||||
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
return $this->teachers;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetStudentScheduleHandler(
|
||||
$studentClassReader,
|
||||
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
|
||||
$calendarProvider,
|
||||
$displayReader,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user