feat: Permettre la création et modification de l'emploi du temps des classes
L'administration a besoin de construire et maintenir les emplois du temps hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont pas en conflit (même créneau, classes différentes) et que les affectations enseignant-matière-classe sont respectées. Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot), la détection de conflits (classe, enseignant, salle) avec possibilité de forcer, la validation des affectations côté serveur (AC2), l'intégration calendrier pour les jours bloqués, une vue mobile-first avec onglets jour par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
@@ -175,6 +175,16 @@ services:
|
||||
App\Scolarite\Domain\Repository\TeacherReplacementRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository
|
||||
|
||||
# Schedule (Story 4.1 - Emploi du temps)
|
||||
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
|
||||
|
||||
App\Scolarite\Domain\Service\ScheduleConflictDetector:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Application\Port\EnseignantAffectationChecker:
|
||||
alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker
|
||||
|
||||
# Super Admin Repositories (Story 2.10 - Multi-établissements)
|
||||
App\SuperAdmin\Domain\Repository\SuperAdminRepository:
|
||||
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository
|
||||
|
||||
52
backend/migrations/Version20260302091704.php
Normal file
52
backend/migrations/Version20260302091704.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260302091704 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Créer la table schedule_slots pour l\'emploi du temps';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE schedule_slots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
class_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL,
|
||||
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
start_time VARCHAR(5) NOT NULL,
|
||||
end_time VARCHAR(5) NOT NULL,
|
||||
room VARCHAR(50),
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT valid_times CHECK (end_time > start_time),
|
||||
CONSTRAINT fk_schedule_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_schedule_subject FOREIGN KEY (subject_id) REFERENCES subjects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_schedule_teacher FOREIGN KEY (teacher_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_schedule_tenant ON schedule_slots(tenant_id)');
|
||||
$this->addSql('CREATE INDEX idx_schedule_class ON schedule_slots(class_id)');
|
||||
$this->addSql('CREATE INDEX idx_schedule_teacher ON schedule_slots(teacher_id)');
|
||||
$this->addSql('CREATE INDEX idx_schedule_day ON schedule_slots(day_of_week)');
|
||||
$this->addSql('CREATE INDEX idx_schedule_teacher_day ON schedule_slots(tenant_id, teacher_id, day_of_week)');
|
||||
$this->addSql('CREATE INDEX idx_schedule_room_day ON schedule_slots(tenant_id, room, day_of_week) WHERE room IS NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE schedule_slots');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
final readonly class CreateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring = true,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
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\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(CreateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$timeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: DayOfWeek::from($command->dayOfWeek),
|
||||
timeSlot: $timeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $command->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
|
||||
$conflicts = $this->conflictDetector->detectConflicts($slot, $tenantId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
final readonly class DeleteScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DeleteScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScheduleSlot Le slot supprimé (pour dispatch events)
|
||||
*/
|
||||
public function __invoke(DeleteScheduleSlotCommand $command): ScheduleSlot
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$slot->supprimer($this->clock->now());
|
||||
$this->repository->delete($slotId, $tenantId);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
final readonly class UpdateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
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\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(UpdateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$dayOfWeek = DayOfWeek::from($command->dayOfWeek);
|
||||
$newTimeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
// Détecte les conflits avant de modifier le slot.
|
||||
// On crée un slot temporaire avec les nouvelles valeurs pour la détection.
|
||||
$preview = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
$conflicts = $this->conflictDetector->detectConflicts($preview, $tenantId, $slotId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$slot->modifier(
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
at: $this->clock->now(),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour vérifier qu'un enseignant est affecté à une classe/matière.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface EnseignantAffectationChecker
|
||||
{
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class BlockedDateDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public string $reason,
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
|
||||
* pour une plage de dates donnée.
|
||||
*
|
||||
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetBlockedDatesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<BlockedDateDto> */
|
||||
public function __invoke(GetBlockedDatesQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($query->academicYearId);
|
||||
|
||||
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
|
||||
|
||||
$startDate = new DateTimeImmutable($query->startDate);
|
||||
$endDate = new DateTimeImmutable($query->endDate);
|
||||
$oneDay = new DateInterval('P1D');
|
||||
|
||||
$blockedDates = [];
|
||||
$current = $startDate;
|
||||
|
||||
while ($current <= $endDate) {
|
||||
$dayOfWeek = (int) $current->format('N');
|
||||
$dateStr = $current->format('Y-m-d');
|
||||
|
||||
if ($dayOfWeek >= 6) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
||||
type: 'weekend',
|
||||
);
|
||||
} elseif ($calendar !== null) {
|
||||
$entry = $calendar->trouverEntreePourDate($current);
|
||||
|
||||
if ($entry !== null) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $entry->label,
|
||||
type: $entry->type->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$current = $current->add($oneDay);
|
||||
}
|
||||
|
||||
return $blockedDates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class GetBlockedDatesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetScheduleSlotsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ScheduleSlotDto> */
|
||||
public function __invoke(GetScheduleSlotsQuery $query): array
|
||||
{
|
||||
try {
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$slots = $this->repository->findByClass(ClassId::fromString($query->classId), $tenantId);
|
||||
|
||||
if ($query->teacherId !== null) {
|
||||
$teacherId = UserId::fromString($query->teacherId);
|
||||
$slots = array_values(array_filter(
|
||||
$slots,
|
||||
static fn (ScheduleSlot $slot) => $slot->teacherId->equals($teacherId),
|
||||
));
|
||||
}
|
||||
} elseif ($query->teacherId !== null) {
|
||||
$slots = $this->repository->findByTeacher(UserId::fromString($query->teacherId), $tenantId);
|
||||
} else {
|
||||
$slots = [];
|
||||
}
|
||||
} catch (InvalidUuidStringException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(ScheduleSlotDto::fromDomain(...), $slots);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
final readonly class GetScheduleSlotsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $classId = null,
|
||||
public ?string $teacherId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ScheduleSlotDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(ScheduleSlot $slot): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $slot->id,
|
||||
classId: (string) $slot->classId,
|
||||
subjectId: (string) $slot->subjectId,
|
||||
teacherId: (string) $slot->teacherId,
|
||||
dayOfWeek: $slot->dayOfWeek->value,
|
||||
startTime: $slot->timeSlot->startTime,
|
||||
endTime: $slot->timeSlot->endTime,
|
||||
room: $slot->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
createdAt: $slot->createdAt,
|
||||
updatedAt: $slot->updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
backend/src/Scolarite/Domain/Event/CoursCree.php
Normal file
43
backend/src/Scolarite/Domain/Event/CoursCree.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursCree implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
43
backend/src/Scolarite/Domain/Event/CoursModifie.php
Normal file
43
backend/src/Scolarite/Domain/Event/CoursModifie.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursModifie implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
36
backend/src/Scolarite/Domain/Event/CoursSupprime.php
Normal file
36
backend/src/Scolarite/Domain/Event/CoursSupprime.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursSupprime implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class CreneauHoraireInvalideException extends RuntimeException
|
||||
{
|
||||
public static function finAvantDebut(string $startTime, string $endTime): self
|
||||
{
|
||||
return new self("L'heure de fin ($endTime) doit être après l'heure de début ($startTime).");
|
||||
}
|
||||
|
||||
public static function dureeTropCourte(int $minutes): self
|
||||
{
|
||||
return new self("La durée du créneau ($minutes min) est inférieure au minimum de 5 minutes.");
|
||||
}
|
||||
|
||||
public static function formatInvalide(string $time): self
|
||||
{
|
||||
return new self("Le format de l'heure '$time' est invalide. Format attendu : HH:MM.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnseignantNonAffecteException extends RuntimeException
|
||||
{
|
||||
public static function pourClasseEtMatiere(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
): self {
|
||||
return new self(
|
||||
"L'enseignant ($teacherId) n'est pas affecté à la classe ($classId) pour la matière ($subjectId).",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use RuntimeException;
|
||||
|
||||
final class ScheduleSlotNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function avecId(ScheduleSlotId $id): self
|
||||
{
|
||||
return new self("Créneau d'emploi du temps introuvable : $id.");
|
||||
}
|
||||
}
|
||||
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
enum DayOfWeek: int
|
||||
{
|
||||
case MONDAY = 1;
|
||||
case TUESDAY = 2;
|
||||
case WEDNESDAY = 3;
|
||||
case THURSDAY = 4;
|
||||
case FRIDAY = 5;
|
||||
case SATURDAY = 6;
|
||||
case SUNDAY = 7;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MONDAY => 'Lundi',
|
||||
self::TUESDAY => 'Mardi',
|
||||
self::WEDNESDAY => 'Mercredi',
|
||||
self::THURSDAY => 'Jeudi',
|
||||
self::FRIDAY => 'Vendredi',
|
||||
self::SATURDAY => 'Samedi',
|
||||
self::SUNDAY => 'Dimanche',
|
||||
};
|
||||
}
|
||||
}
|
||||
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\CoursCree;
|
||||
use App\Scolarite\Domain\Event\CoursModifie;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un créneau dans l'emploi du temps.
|
||||
*
|
||||
* Un créneau lie une classe, une matière et un enseignant à un jour de la semaine
|
||||
* et un horaire. Le créneau peut être récurrent (hebdomadaire) ou ponctuel.
|
||||
*
|
||||
* @see FR26: Créer et modifier l'emploi du temps des classes
|
||||
*/
|
||||
final class ScheduleSlot extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ScheduleSlotId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) ClassId $classId,
|
||||
public private(set) SubjectId $subjectId,
|
||||
public private(set) UserId $teacherId,
|
||||
public private(set) DayOfWeek $dayOfWeek,
|
||||
public private(set) TimeSlot $timeSlot,
|
||||
public private(set) ?string $room,
|
||||
public private(set) bool $isRecurring,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau créneau dans l'emploi du temps.
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$slot = new self(
|
||||
id: ScheduleSlotId::generate(),
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$slot->recordEvent(new CoursCree(
|
||||
slotId: $slot->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $now,
|
||||
));
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie les propriétés du créneau.
|
||||
*/
|
||||
public function modifier(
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
DateTimeImmutable $at,
|
||||
): void {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$this->classId = $classId;
|
||||
$this->subjectId = $subjectId;
|
||||
$this->teacherId = $teacherId;
|
||||
$this->dayOfWeek = $dayOfWeek;
|
||||
$this->timeSlot = $timeSlot;
|
||||
$this->room = $room;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new CoursModifie(
|
||||
slotId: $this->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre l'événement de suppression avant le hard-delete par le repository.
|
||||
*/
|
||||
public function supprimer(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->recordEvent(new CoursSupprime(
|
||||
slotId: $this->id,
|
||||
classId: $this->classId,
|
||||
subjectId: $this->subjectId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour.
|
||||
*/
|
||||
public function conflictsAvec(self $other): bool
|
||||
{
|
||||
return $this->dayOfWeek === $other->dayOfWeek
|
||||
&& $this->timeSlot->overlaps($other->timeSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un ScheduleSlot depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ScheduleSlotId $id,
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$slot = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$slot->updatedAt = $updatedAt;
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ScheduleSlotId extends EntityId
|
||||
{
|
||||
}
|
||||
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
|
||||
use function explode;
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* Value Object représentant un créneau horaire (heure début + heure fin).
|
||||
*
|
||||
* Format attendu : "HH:MM" (24h).
|
||||
* Contraintes : fin > début, durée minimum 5 minutes.
|
||||
*/
|
||||
final readonly class TimeSlot
|
||||
{
|
||||
private const string TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/';
|
||||
private const int MINIMUM_DURATION_MINUTES = 5;
|
||||
|
||||
public function __construct(
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
) {
|
||||
if (preg_match(self::TIME_PATTERN, $startTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($startTime);
|
||||
}
|
||||
|
||||
if (preg_match(self::TIME_PATTERN, $endTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($endTime);
|
||||
}
|
||||
|
||||
if ($endTime <= $startTime) {
|
||||
throw CreneauHoraireInvalideException::finAvantDebut($startTime, $endTime);
|
||||
}
|
||||
|
||||
$duration = $this->computeDurationInMinutes($startTime, $endTime);
|
||||
|
||||
if ($duration < self::MINIMUM_DURATION_MINUTES) {
|
||||
throw CreneauHoraireInvalideException::dureeTropCourte($duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau chevauche un autre créneau.
|
||||
*
|
||||
* Deux créneaux adjacents (fin de l'un = début de l'autre) ne se chevauchent pas.
|
||||
*/
|
||||
public function overlaps(self $other): bool
|
||||
{
|
||||
return $this->startTime < $other->endTime && $other->startTime < $this->endTime;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->startTime === $other->startTime
|
||||
&& $this->endTime === $other->endTime;
|
||||
}
|
||||
|
||||
public function durationInMinutes(): int
|
||||
{
|
||||
return $this->computeDurationInMinutes($this->startTime, $this->endTime);
|
||||
}
|
||||
|
||||
private function computeDurationInMinutes(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMin] = explode(':', $start);
|
||||
[$endHour, $endMin] = explode(':', $end);
|
||||
|
||||
return ((int) $endHour * 60 + (int) $endMin) - ((int) $startHour * 60 + (int) $startMin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface ScheduleSlotRepository
|
||||
{
|
||||
public function save(ScheduleSlot $slot): void;
|
||||
|
||||
/** @throws ScheduleSlotNotFoundException */
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot;
|
||||
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot;
|
||||
|
||||
public function delete(ScheduleSlotId $id, TenantId $tenantId): void;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
}
|
||||
23
backend/src/Scolarite/Domain/Service/ScheduleConflict.php
Normal file
23
backend/src/Scolarite/Domain/Service/ScheduleConflict.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
|
||||
/**
|
||||
* Représente un conflit détecté entre deux créneaux.
|
||||
*/
|
||||
final readonly class ScheduleConflict
|
||||
{
|
||||
/**
|
||||
* @param 'class'|'teacher'|'room' $type
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public ScheduleSlot $conflictingSlot,
|
||||
public string $description,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Détecte les conflits de créneaux dans l'emploi du temps.
|
||||
*
|
||||
* Vérifie les conflits enseignant (même enseignant, même créneau horaire)
|
||||
* et les conflits de salle (même salle, même créneau horaire).
|
||||
*/
|
||||
final readonly class ScheduleConflictDetector
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ScheduleConflict>
|
||||
*/
|
||||
public function detectConflicts(
|
||||
ScheduleSlot $slot,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$conflicts = [];
|
||||
|
||||
$classConflicts = $this->repository->findOverlappingForClass(
|
||||
$slot->classId,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($classConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'class',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "La classe a déjà un cours le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
|
||||
$teacherConflicts = $this->repository->findOverlappingForTeacher(
|
||||
$slot->teacherId,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($teacherConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'teacher',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "L'enseignant est déjà occupé le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
|
||||
if ($slot->room !== null) {
|
||||
$roomConflicts = $this->repository->findOverlappingForRoom(
|
||||
$slot->room,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($roomConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'room',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "La salle {$slot->room} est déjà occupée le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour créer un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class CreateScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new CreateScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
classId: $data->classId ?? '',
|
||||
subjectId: $data->subjectId ?? '',
|
||||
teacherId: $data->teacherId ?? '',
|
||||
dayOfWeek: $data->dayOfWeek ?? 1,
|
||||
startTime: $data->startTime ?? '',
|
||||
endTime: $data->endTime ?? '',
|
||||
room: $data->room,
|
||||
isRecurring: $data->isRecurring ?? true,
|
||||
forceConflicts: $data->forceConflicts ?? false,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$slot = $result['slot'];
|
||||
/** @var array<ScheduleConflict> $conflicts */
|
||||
$conflicts = $result['conflicts'];
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
$resource = ScheduleSlotResource::fromDomain($slot);
|
||||
|
||||
if ($conflicts !== []) {
|
||||
$resource->conflicts = array_map(
|
||||
static fn (ScheduleConflict $c) => [
|
||||
'type' => $c->type,
|
||||
'description' => $c->description,
|
||||
'slotId' => (string) $c->conflictingSlot->id,
|
||||
],
|
||||
$conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (CreneauHoraireInvalideException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, null>
|
||||
*/
|
||||
final readonly class DeleteScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à supprimer des créneaux.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new DeleteScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
);
|
||||
|
||||
$slot = ($this->handler)($command);
|
||||
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class UpdateScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::EDIT)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new UpdateScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
classId: $data->classId ?? '',
|
||||
subjectId: $data->subjectId ?? '',
|
||||
teacherId: $data->teacherId ?? '',
|
||||
dayOfWeek: $data->dayOfWeek ?? 1,
|
||||
startTime: $data->startTime ?? '',
|
||||
endTime: $data->endTime ?? '',
|
||||
room: $data->room,
|
||||
forceConflicts: $data->forceConflicts ?? false,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$slot = $result['slot'];
|
||||
/** @var array<ScheduleConflict> $conflicts */
|
||||
$conflicts = $result['conflicts'];
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
$resource = ScheduleSlotResource::fromDomain($slot);
|
||||
|
||||
if ($conflicts !== []) {
|
||||
$resource->conflicts = array_map(
|
||||
static fn (ScheduleConflict $c) => [
|
||||
'type' => $c->type,
|
||||
'description' => $c->description,
|
||||
'slotId' => (string) $c->conflictingSlot->id,
|
||||
],
|
||||
$conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
} catch (CreneauHoraireInvalideException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\BlockedDateResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer les dates bloquées (jours fériés, vacances, etc.).
|
||||
*
|
||||
* @implements ProviderInterface<BlockedDateResource>
|
||||
*/
|
||||
final readonly class BlockedDateCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetBlockedDatesHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<BlockedDateResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter les dates bloquées.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var array<string, string> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (!isset($filters['startDate'], $filters['endDate'])) {
|
||||
throw new BadRequestHttpException('Les paramètres startDate et endDate sont requis.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = new GetBlockedDatesQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
startDate: (string) $filters['startDate'],
|
||||
endDate: (string) $filters['endDate'],
|
||||
);
|
||||
|
||||
$dtos = ($this->handler)($query);
|
||||
|
||||
return array_map(BlockedDateResource::fromDto(...), $dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsHandler;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer l'emploi du temps avec filtrage par classe ou enseignant.
|
||||
*
|
||||
* @implements ProviderInterface<ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class ScheduleSlotCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetScheduleSlotsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ScheduleSlotResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var array<string, string> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
$query = new GetScheduleSlotsQuery(
|
||||
tenantId: $tenantId,
|
||||
classId: isset($filters['classId']) ? (string) $filters['classId'] : null,
|
||||
teacherId: isset($filters['teacherId']) ? (string) $filters['teacherId'] : null,
|
||||
);
|
||||
|
||||
$dtos = ($this->handler)($query);
|
||||
|
||||
return array_map(ScheduleSlotResource::fromDto(...), $dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer un créneau d'emploi du temps par son ID.
|
||||
*
|
||||
* @implements ProviderInterface<ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class ScheduleSlotItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps.");
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$slot = $this->repository->get(ScheduleSlotId::fromString($slotId), $tenantId);
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
return ScheduleSlotResource::fromDomain($slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\BlockedDateDto;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\BlockedDateCollectionProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'BlockedDate',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/blocked-dates',
|
||||
provider: BlockedDateCollectionProvider::class,
|
||||
name: 'get_blocked_dates',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BlockedDateResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $date = null;
|
||||
|
||||
public ?string $reason = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public static function fromDto(BlockedDateDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->date = $dto->date;
|
||||
$resource->reason = $dto->reason;
|
||||
$resource->type = $dto->type;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\ScheduleSlotDto;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\CreateScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\DeleteScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\UpdateScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ScheduleSlotCollectionProvider;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ScheduleSlotItemProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion de l'emploi du temps.
|
||||
*
|
||||
* @see Story 4.1 - Création et Modification de l'Emploi du Temps
|
||||
* @see FR26 - Créer et modifier l'emploi du temps des classes
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ScheduleSlot',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/slots',
|
||||
provider: ScheduleSlotCollectionProvider::class,
|
||||
name: 'get_schedule_slots',
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
name: 'get_schedule_slot',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/schedule/slots',
|
||||
processor: CreateScheduleSlotProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_schedule_slot',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
processor: UpdateScheduleSlotProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_schedule_slot',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
processor: DeleteScheduleSlotProcessor::class,
|
||||
name: 'delete_schedule_slot',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ScheduleSlotResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])]
|
||||
public ?string $classId = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])]
|
||||
public ?string $subjectId = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'enseignant est requis.", groups: ['create'])]
|
||||
public ?string $teacherId = null;
|
||||
|
||||
#[Assert\NotNull(message: 'Le jour de la semaine est requis.', groups: ['create'])]
|
||||
#[Assert\Range(min: 1, max: 7, notInRangeMessage: 'Le jour doit être compris entre 1 (lundi) et 7 (dimanche).')]
|
||||
public ?int $dayOfWeek = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'heure de début est requise.", groups: ['create'])]
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $startTime = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'heure de fin est requise.", groups: ['create'])]
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $endTime = null;
|
||||
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le nom de la salle ne peut pas dépasser {{ limit }} caractères.')]
|
||||
public ?string $room = null;
|
||||
|
||||
public ?bool $isRecurring = null;
|
||||
|
||||
/**
|
||||
* Si true, forcer la création/modification malgré les conflits.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $forceConflicts = null;
|
||||
|
||||
/**
|
||||
* Conflits détectés lors de la création/modification.
|
||||
* Renvoyé en réponse si des conflits existent.
|
||||
*
|
||||
* @var array<array{type: string, description: string, slotId: string}>|null
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?array $conflicts = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromDomain(ScheduleSlot $slot): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $slot->id;
|
||||
$resource->classId = (string) $slot->classId;
|
||||
$resource->subjectId = (string) $slot->subjectId;
|
||||
$resource->teacherId = (string) $slot->teacherId;
|
||||
$resource->dayOfWeek = $slot->dayOfWeek->value;
|
||||
$resource->startTime = $slot->timeSlot->startTime;
|
||||
$resource->endTime = $slot->timeSlot->endTime;
|
||||
$resource->room = $slot->room;
|
||||
$resource->isRecurring = $slot->isRecurring;
|
||||
$resource->createdAt = $slot->createdAt;
|
||||
$resource->updatedAt = $slot->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public static function fromDto(ScheduleSlotDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->classId = $dto->classId;
|
||||
$resource->subjectId = $dto->subjectId;
|
||||
$resource->teacherId = $dto->teacherId;
|
||||
$resource->dayOfWeek = $dto->dayOfWeek;
|
||||
$resource->startTime = $dto->startTime;
|
||||
$resource->endTime = $dto->endTime;
|
||||
$resource->room = $dto->room;
|
||||
$resource->isRecurring = $dto->isRecurring;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ScheduleSlot $slot): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :created_at, :updated_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
class_id = EXCLUDED.class_id,
|
||||
subject_id = EXCLUDED.subject_id,
|
||||
teacher_id = EXCLUDED.teacher_id,
|
||||
day_of_week = EXCLUDED.day_of_week,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
room = EXCLUDED.room,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $slot->id,
|
||||
'tenant_id' => (string) $slot->tenantId,
|
||||
'class_id' => (string) $slot->classId,
|
||||
'subject_id' => (string) $slot->subjectId,
|
||||
'teacher_id' => (string) $slot->teacherId,
|
||||
'day_of_week' => $slot->dayOfWeek->value,
|
||||
'start_time' => $slot->timeSlot->startTime,
|
||||
'end_time' => $slot->timeSlot->endTime,
|
||||
'room' => $slot->room,
|
||||
'is_recurring' => $slot->isRecurring ? 'true' : 'false',
|
||||
'created_at' => $slot->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $slot->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot === null) {
|
||||
throw ScheduleSlotNotFoundException::avecId($id);
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM schedule_slots WHERE id = :id AND tenant_id = :tenant_id',
|
||||
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ScheduleSlotId $id, TenantId $tenantId): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM schedule_slots WHERE id = :id AND tenant_id = :tenant_id',
|
||||
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM schedule_slots
|
||||
WHERE class_id = :class_id AND tenant_id = :tenant_id
|
||||
ORDER BY day_of_week, start_time',
|
||||
['class_id' => (string) $classId, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM schedule_slots
|
||||
WHERE teacher_id = :teacher_id AND tenant_id = :tenant_id
|
||||
ORDER BY day_of_week, start_time',
|
||||
['teacher_id' => (string) $teacherId, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND class_id = :class_id
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'class_id' => (string) $classId,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND teacher_id = :teacher_id
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'teacher_id' => (string) $teacherId,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND room = :room
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'room' => $room,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<ScheduleSlot>
|
||||
*/
|
||||
private function hydrateMany(array $rows): array
|
||||
{
|
||||
return array_map($this->hydrate(...), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): ScheduleSlot
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string $subjectId */
|
||||
$subjectId = $row['subject_id'];
|
||||
/** @var string $teacherId */
|
||||
$teacherId = $row['teacher_id'];
|
||||
/** @var int $dayOfWeek */
|
||||
$dayOfWeek = $row['day_of_week'];
|
||||
/** @var string $startTime */
|
||||
$startTime = $row['start_time'];
|
||||
/** @var string $endTime */
|
||||
$endTime = $row['end_time'];
|
||||
/** @var string|null $room */
|
||||
$room = $row['room'];
|
||||
/** @var bool $isRecurring */
|
||||
$isRecurring = $row['is_recurring'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return ScheduleSlot::reconstitute(
|
||||
id: ScheduleSlotId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
classId: ClassId::fromString($classId),
|
||||
subjectId: SubjectId::fromString($subjectId),
|
||||
teacherId: UserId::fromString($teacherId),
|
||||
dayOfWeek: DayOfWeek::from((int) $dayOfWeek),
|
||||
timeSlot: new TimeSlot($startTime, $endTime),
|
||||
room: $room,
|
||||
isRecurring: (bool) $isRecurring,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_values;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemoryScheduleSlotRepository implements ScheduleSlotRepository
|
||||
{
|
||||
/** @var array<string, ScheduleSlot> */
|
||||
private array $byId = [];
|
||||
|
||||
#[Override]
|
||||
public function save(ScheduleSlot $slot): void
|
||||
{
|
||||
$this->byId[(string) $slot->id] = $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot === null) {
|
||||
throw ScheduleSlotNotFoundException::avecId($id);
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot
|
||||
{
|
||||
$slot = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($slot !== null && !$slot->tenantId->equals($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ScheduleSlotId $id, TenantId $tenantId): void
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot !== null) {
|
||||
unset($this->byId[(string) $id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->classId->equals($classId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->teacherId->equals($teacherId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->classId->equals($classId)
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->teacherId->equals($teacherId)
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->room === $room
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
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.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class ScheduleSlotVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'SCHEDULE_VIEW';
|
||||
public const string CREATE = 'SCHEDULE_CREATE';
|
||||
public const string EDIT = 'SCHEDULE_EDIT';
|
||||
public const string DELETE = 'SCHEDULE_DELETE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CREATE,
|
||||
self::EDIT,
|
||||
self::DELETE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CREATE, self::EDIT, self::DELETE => $this->canManage($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canManage(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Port\TeacherAssignmentChecker;
|
||||
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\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Vérifie l'affectation enseignant en résolvant automatiquement l'année académique courante.
|
||||
*/
|
||||
final readonly class CurrentYearEnseignantAffectationChecker implements EnseignantAffectationChecker
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherAssignmentChecker $assignmentChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->assignmentChecker->estAffecte(
|
||||
$teacherId,
|
||||
$classId,
|
||||
$subjectId,
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
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\Command\CreateScheduleSlot\CreateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotHandler;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CreateScheduleSlotHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
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 $repository;
|
||||
private CreateScheduleSlotHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryScheduleSlotRepository();
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-02 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new CreateScheduleSlotHandler(
|
||||
$this->repository,
|
||||
new ScheduleConflictDetector($this->repository),
|
||||
$this->createAlwaysAssignedChecker(),
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsSlotSuccessfully(): void
|
||||
{
|
||||
$result = ($this->handler)($this->createCommand());
|
||||
|
||||
/** @var ScheduleSlot $slot */
|
||||
$slot = $result['slot'];
|
||||
|
||||
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
|
||||
self::assertSame('08:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('09:00', $slot->timeSlot->endTime);
|
||||
self::assertEmpty($result['conflicts']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function savesSlotToRepository(): void
|
||||
{
|
||||
$result = ($this->handler)($this->createCommand());
|
||||
|
||||
/** @var ScheduleSlot $slot */
|
||||
$slot = $result['slot'];
|
||||
|
||||
$found = $this->repository->findById($slot->id, TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertNotNull($found);
|
||||
self::assertTrue($found->id->equals($slot->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsConflictsWithoutSaving(): void
|
||||
{
|
||||
// Créer un slot existant
|
||||
($this->handler)($this->createCommand());
|
||||
|
||||
// Même enseignant, même créneau, classe différente
|
||||
$result = ($this->handler)(new CreateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
room: null,
|
||||
));
|
||||
|
||||
self::assertNotEmpty($result['conflicts']);
|
||||
// Le slot conflictuel ne devrait PAS être sauvegardé
|
||||
$allSlots = $this->repository->findByTeacher(
|
||||
UserId::fromString(self::TEACHER_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
self::assertCount(1, $allSlots);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function forceSavesSlotWithConflicts(): void
|
||||
{
|
||||
($this->handler)($this->createCommand());
|
||||
|
||||
$result = ($this->handler)(new CreateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
room: null,
|
||||
forceConflicts: true,
|
||||
));
|
||||
|
||||
self::assertNotEmpty($result['conflicts']);
|
||||
// Malgré les conflits, le slot est sauvegardé
|
||||
$allSlots = $this->repository->findByTeacher(
|
||||
UserId::fromString(self::TEACHER_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
self::assertCount(2, $allSlots);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsUnassignedTeacher(): void
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-02 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new CreateScheduleSlotHandler(
|
||||
$this->repository,
|
||||
new ScheduleConflictDetector($this->repository),
|
||||
$this->createNeverAssignedChecker(),
|
||||
$clock,
|
||||
);
|
||||
|
||||
$this->expectException(EnseignantNonAffecteException::class);
|
||||
($handler)($this->createCommand());
|
||||
}
|
||||
|
||||
private function createCommand(): CreateScheduleSlotCommand
|
||||
{
|
||||
return new CreateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
|
||||
{
|
||||
return new class implements EnseignantAffectationChecker {
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createNeverAssignedChecker(): EnseignantAffectationChecker
|
||||
{
|
||||
return new class implements EnseignantAffectationChecker {
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
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\Command\DeleteScheduleSlot\DeleteScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
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\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DeleteScheduleSlotHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
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 $repository;
|
||||
private DeleteScheduleSlotHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryScheduleSlotRepository();
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new DeleteScheduleSlotHandler($this->repository, $clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deletesSlotFromRepository(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
($this->handler)(new DeleteScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
));
|
||||
|
||||
self::assertNull($this->repository->findById($slot->id, $tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSlotWithCoursSupprime(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
$slot->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$deletedSlot = ($this->handler)(new DeleteScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
));
|
||||
|
||||
$events = $deletedSlot->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursSupprime::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenSlotNotFound(): void
|
||||
{
|
||||
$this->expectException(ScheduleSlotNotFoundException::class);
|
||||
|
||||
($this->handler)(new DeleteScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: '550e8400-e29b-41d4-a716-446655440099',
|
||||
));
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(): ScheduleSlot
|
||||
{
|
||||
$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: DayOfWeek::MONDAY,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
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\Command\UpdateScheduleSlot\UpdateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotHandler;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UpdateScheduleSlotHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string OTHER_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryScheduleSlotRepository $repository;
|
||||
private UpdateScheduleSlotHandler $handler;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryScheduleSlotRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new UpdateScheduleSlotHandler(
|
||||
$this->repository,
|
||||
new ScheduleConflictDetector($this->repository),
|
||||
$this->createAlwaysAssignedChecker(),
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updatesSlotProperties(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
$newSubjectId = '550e8400-e29b-41d4-a716-446655440031';
|
||||
|
||||
$result = ($this->handler)(new UpdateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: $newSubjectId,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::WEDNESDAY->value,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
room: 'Salle 301',
|
||||
));
|
||||
|
||||
/** @var ScheduleSlot $updated */
|
||||
$updated = $result['slot'];
|
||||
|
||||
self::assertTrue($updated->subjectId->equals(SubjectId::fromString($newSubjectId)));
|
||||
self::assertSame(DayOfWeek::WEDNESDAY, $updated->dayOfWeek);
|
||||
self::assertSame('10:00', $updated->timeSlot->startTime);
|
||||
self::assertSame('Salle 301', $updated->room);
|
||||
self::assertEmpty($result['conflicts']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updatesClassId(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$result = ($this->handler)(new UpdateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
classId: self::OTHER_CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: null,
|
||||
));
|
||||
|
||||
/** @var ScheduleSlot $updated */
|
||||
$updated = $result['slot'];
|
||||
|
||||
self::assertTrue($updated->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
|
||||
self::assertFalse($updated->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function persistsClassIdChange(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
($this->handler)(new UpdateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
classId: self::OTHER_CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: null,
|
||||
));
|
||||
|
||||
$persisted = $this->repository->get($slot->id, TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertTrue($persisted->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function excludesSelfFromConflictDetection(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
// Modifier le slot sans changer le créneau → pas de conflit avec lui-même
|
||||
$result = ($this->handler)(new UpdateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: 'Salle 101',
|
||||
));
|
||||
|
||||
self::assertEmpty($result['conflicts']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsUnassignedTeacher(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$handler = new UpdateScheduleSlotHandler(
|
||||
$this->repository,
|
||||
new ScheduleConflictDetector($this->repository),
|
||||
$this->createNeverAssignedChecker(),
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$this->expectException(EnseignantNonAffecteException::class);
|
||||
($handler)(new UpdateScheduleSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: null,
|
||||
));
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(): ScheduleSlot
|
||||
{
|
||||
$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: DayOfWeek::MONDAY,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
|
||||
{
|
||||
return new class implements EnseignantAffectationChecker {
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createNeverAssignedChecker(): EnseignantAffectationChecker
|
||||
{
|
||||
return new class implements EnseignantAffectationChecker {
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetBlockedDatesHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
|
||||
private InMemorySchoolCalendarRepository $calendarRepository;
|
||||
private GetBlockedDatesHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calendarRepository = new InMemorySchoolCalendarRepository();
|
||||
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsWeekendsAsBlocked(): void
|
||||
{
|
||||
// 2026-03-02 est un lundi, 2026-03-08 est un dimanche
|
||||
$result = ($this->handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-08',
|
||||
));
|
||||
|
||||
$weekendDates = array_filter($result, static fn ($d) => $d->type === 'weekend');
|
||||
|
||||
self::assertCount(2, $weekendDates);
|
||||
$dates = array_map(static fn ($d) => $d->date, array_values($weekendDates));
|
||||
self::assertContains('2026-03-07', $dates);
|
||||
self::assertContains('2026-03-08', $dates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsHolidaysAsBlocked(): void
|
||||
{
|
||||
$calendar = $this->createCalendarWithHoliday(
|
||||
new DateTimeImmutable('2026-03-04'),
|
||||
'Jour de test',
|
||||
);
|
||||
$this->calendarRepository->save($calendar);
|
||||
|
||||
$result = ($this->handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$holidays = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::HOLIDAY->value);
|
||||
|
||||
self::assertCount(1, $holidays);
|
||||
$holiday = array_values($holidays)[0];
|
||||
self::assertSame('2026-03-04', $holiday->date);
|
||||
self::assertSame('Jour de test', $holiday->reason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyForWeekWithNoBlockedDates(): void
|
||||
{
|
||||
// Lundi à vendredi sans calendrier configuré
|
||||
$result = ($this->handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
self::assertEmpty($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsVacationsAsBlocked(): void
|
||||
{
|
||||
$calendar = $this->createCalendarWithVacation(
|
||||
new DateTimeImmutable('2026-03-02'),
|
||||
new DateTimeImmutable('2026-03-06'),
|
||||
'Vacances de printemps',
|
||||
);
|
||||
$this->calendarRepository->save($calendar);
|
||||
|
||||
$result = ($this->handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$vacations = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::VACATION->value);
|
||||
|
||||
self::assertCount(5, $vacations);
|
||||
}
|
||||
|
||||
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||
|
||||
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
|
||||
$calendar->ajouterEntree(new CalendarEntry(
|
||||
id: CalendarEntryId::generate(),
|
||||
type: CalendarEntryType::HOLIDAY,
|
||||
startDate: $date,
|
||||
endDate: $date,
|
||||
label: $label,
|
||||
));
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
private function createCalendarWithVacation(
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate,
|
||||
string $label,
|
||||
): SchoolCalendar {
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||
|
||||
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
|
||||
$calendar->ajouterEntree(new CalendarEntry(
|
||||
id: CalendarEntryId::generate(),
|
||||
type: CalendarEntryType::VACATION,
|
||||
startDate: $startDate,
|
||||
endDate: $endDate,
|
||||
label: $label,
|
||||
));
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
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\Query\GetScheduleSlots\GetScheduleSlotsHandler;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsQuery;
|
||||
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\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetScheduleSlotsHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_A = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string CLASS_B = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_A = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TEACHER_B = '550e8400-e29b-41d4-a716-446655440011';
|
||||
|
||||
private InMemoryScheduleSlotRepository $repository;
|
||||
private GetScheduleSlotsHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryScheduleSlotRepository();
|
||||
$this->handler = new GetScheduleSlotsHandler($this->repository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSlotsFilteredByClass(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: self::CLASS_A,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame(self::CLASS_A, $result[0]->classId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSlotsFilteredByTeacher(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
teacherId: self::TEACHER_A,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSlotsFilteredByClassAndTeacher(): void
|
||||
{
|
||||
// Classe A, enseignant A
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
// Classe A, enseignant B
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
|
||||
// Classe B, enseignant A
|
||||
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::WEDNESDAY);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: self::CLASS_A,
|
||||
teacherId: self::TEACHER_A,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame(self::CLASS_A, $result[0]->classId);
|
||||
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenClassAndTeacherDontMatch(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: self::CLASS_A,
|
||||
teacherId: self::TEACHER_B,
|
||||
));
|
||||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoFilters(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyForInvalidClassIdUuid(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
classId: 'not-a-valid-uuid',
|
||||
));
|
||||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyForInvalidTeacherIdUuid(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
teacherId: 'invalid',
|
||||
));
|
||||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function teacherFilterReturnsAllClassesForTeacher(): void
|
||||
{
|
||||
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
|
||||
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::TUESDAY);
|
||||
|
||||
$result = ($this->handler)(new GetScheduleSlotsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
teacherId: self::TEACHER_A,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
}
|
||||
|
||||
private function createSlot(
|
||||
string $classId,
|
||||
string $teacherId,
|
||||
DayOfWeek $day = DayOfWeek::MONDAY,
|
||||
): ScheduleSlot {
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString($classId),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString($teacherId),
|
||||
dayOfWeek: $day,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\CoursCree;
|
||||
use App\Scolarite\Domain\Event\CoursModifie;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScheduleSlotTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
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';
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesSlotWithCorrectProperties(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
|
||||
self::assertTrue($slot->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
self::assertTrue($slot->subjectId->equals(SubjectId::fromString(self::SUBJECT_ID)));
|
||||
self::assertTrue($slot->teacherId->equals(UserId::fromString(self::TEACHER_ID)));
|
||||
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
|
||||
self::assertSame('08:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('09:00', $slot->timeSlot->endTime);
|
||||
self::assertNull($slot->room);
|
||||
self::assertTrue($slot->isRecurring);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerRecordsCoursCreeEvent(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursCree::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerWithRoomSetsRoom(): void
|
||||
{
|
||||
$slot = $this->createSlot(room: 'Salle 101');
|
||||
|
||||
self::assertSame('Salle 101', $slot->room);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierUpdatesProperties(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$newSubjectId = SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031');
|
||||
$newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011');
|
||||
$newTimeSlot = new TimeSlot('10:00', '11:00');
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
|
||||
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440021');
|
||||
|
||||
$slot->modifier(
|
||||
classId: $newClassId,
|
||||
subjectId: $newSubjectId,
|
||||
teacherId: $newTeacherId,
|
||||
dayOfWeek: DayOfWeek::TUESDAY,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: 'Salle 202',
|
||||
at: $now,
|
||||
);
|
||||
|
||||
self::assertTrue($slot->classId->equals($newClassId));
|
||||
self::assertTrue($slot->subjectId->equals($newSubjectId));
|
||||
self::assertTrue($slot->teacherId->equals($newTeacherId));
|
||||
self::assertSame(DayOfWeek::TUESDAY, $slot->dayOfWeek);
|
||||
self::assertSame('10:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('11:00', $slot->timeSlot->endTime);
|
||||
self::assertSame('Salle 202', $slot->room);
|
||||
self::assertEquals($now, $slot->updatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierRecordsCoursModifieEvent(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
$slot->modifier(
|
||||
classId: $slot->classId,
|
||||
subjectId: $slot->subjectId,
|
||||
teacherId: $slot->teacherId,
|
||||
dayOfWeek: DayOfWeek::TUESDAY,
|
||||
timeSlot: new TimeSlot('10:00', '11:00'),
|
||||
room: null,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursModifie::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function supprimerRecordsCoursSupprime(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
$slot->supprimer($now);
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursSupprime::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
|
||||
{
|
||||
$id = ScheduleSlotId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$classId = ClassId::fromString(self::CLASS_ID);
|
||||
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
|
||||
$teacherId = UserId::fromString(self::TEACHER_ID);
|
||||
$timeSlot = new TimeSlot('14:00', '15:30');
|
||||
$createdAt = new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
$updatedAt = new DateTimeImmutable('2026-03-02 14:00:00');
|
||||
|
||||
$slot = ScheduleSlot::reconstitute(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: DayOfWeek::FRIDAY,
|
||||
timeSlot: $timeSlot,
|
||||
room: 'Salle 305',
|
||||
isRecurring: false,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
|
||||
self::assertTrue($slot->id->equals($id));
|
||||
self::assertTrue($slot->tenantId->equals($tenantId));
|
||||
self::assertTrue($slot->classId->equals($classId));
|
||||
self::assertTrue($slot->subjectId->equals($subjectId));
|
||||
self::assertTrue($slot->teacherId->equals($teacherId));
|
||||
self::assertSame(DayOfWeek::FRIDAY, $slot->dayOfWeek);
|
||||
self::assertSame('14:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('15:30', $slot->timeSlot->endTime);
|
||||
self::assertSame('Salle 305', $slot->room);
|
||||
self::assertFalse($slot->isRecurring);
|
||||
self::assertEquals($createdAt, $slot->createdAt);
|
||||
self::assertEquals($updatedAt, $slot->updatedAt);
|
||||
self::assertEmpty($slot->pullDomainEvents());
|
||||
}
|
||||
|
||||
private function createSlot(?string $room = null): ScheduleSlot
|
||||
{
|
||||
return 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: DayOfWeek::MONDAY,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TimeSlotTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function createsValidTimeSlot(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '09:00');
|
||||
|
||||
self::assertSame('08:00', $timeSlot->startTime);
|
||||
self::assertSame('09:00', $timeSlot->endTime);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenEndTimeBeforeStartTime(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('10:00', '09:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenEndTimeEqualsStartTime(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('08:00', '08:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenDurationLessThanFiveMinutes(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('08:00', '08:04');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function acceptsFiveMinuteDuration(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '08:05');
|
||||
|
||||
self::assertSame('08:00', $timeSlot->startTime);
|
||||
self::assertSame('08:05', $timeSlot->endTime);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsTrueWhenTimesOverlap(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:30', '09:30');
|
||||
|
||||
self::assertTrue($slot1->overlaps($slot2));
|
||||
self::assertTrue($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsFalseWhenTimesDoNotOverlap(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->overlaps($slot2));
|
||||
self::assertFalse($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsTrueWhenOneContainsOther(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '12:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertTrue($slot1->overlaps($slot2));
|
||||
self::assertTrue($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsFalseWhenAdjacent(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->overlaps($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameTimes(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:00', '09:00');
|
||||
|
||||
self::assertTrue($slot1->equals($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentTimes(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->equals($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function durationInMinutesReturnsCorrectValue(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '09:30');
|
||||
|
||||
self::assertSame(90, $timeSlot->durationInMinutes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
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 ScheduleConflictDetectorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
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 $repository;
|
||||
private ScheduleConflictDetector $detector;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryScheduleSlotRepository();
|
||||
$this->detector = new ScheduleConflictDetector($this->repository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsClassConflict(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
// Même classe, même créneau, enseignant différent
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440011',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertNotEmpty($conflicts);
|
||||
$types = array_map(static fn ($c) => $c->type, $conflicts);
|
||||
self::assertContains('class', $types);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noClassConflictWhenDifferentClass(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440011',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertEmpty($conflicts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsTeacherConflict(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertNotEmpty($conflicts);
|
||||
self::assertSame('teacher', $conflicts[0]->type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noConflictWhenDifferentDay(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::TUESDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertEmpty($conflicts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noConflictWhenAdjacentTimes(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertEmpty($conflicts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsRoomConflict(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: 'Salle 101',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440011',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
room: 'Salle 101',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertNotEmpty($conflicts);
|
||||
self::assertSame('room', $conflicts[0]->type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noRoomConflictWhenNoRoom(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440011',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
self::assertEmpty($conflicts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function excludesCurrentSlotWhenUpdating(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($existingSlot, $tenantId, $existingSlot->id);
|
||||
|
||||
self::assertEmpty($conflicts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsBothTeacherAndRoomConflicts(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$existingSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
room: 'Salle 101',
|
||||
);
|
||||
$this->repository->save($existingSlot);
|
||||
|
||||
$newSlot = $this->createSlot(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
startTime: '08:30',
|
||||
endTime: '09:30',
|
||||
room: 'Salle 101',
|
||||
);
|
||||
|
||||
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
|
||||
|
||||
$types = array_map(static fn ($c) => $c->type, $conflicts);
|
||||
self::assertContains('teacher', $types);
|
||||
self::assertContains('room', $types);
|
||||
}
|
||||
|
||||
private function createSlot(
|
||||
string $teacherId = self::TEACHER_ID,
|
||||
string $classId = self::CLASS_ID,
|
||||
DayOfWeek $dayOfWeek = DayOfWeek::MONDAY,
|
||||
string $startTime = '08:00',
|
||||
string $endTime = '09:00',
|
||||
?string $room = null,
|
||||
): ScheduleSlot {
|
||||
return ScheduleSlot::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString($classId),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString($teacherId),
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: new TimeSlot($startTime, $endTime),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user