feat: Gestion des périodes scolaires
L'administration d'un établissement nécessite de découper l'année scolaire en trimestres ou semestres avant de pouvoir saisir les notes et générer les bulletins. Ce module permet de configurer les périodes par année scolaire (current/previous/next résolus en UUID v5 déterministes), de modifier les dates individuelles avec validation anti-chevauchement, et de consulter la période en cours avec le décompte des jours restants. Les dates par défaut de février s'adaptent aux années bissextiles. Le repository utilise UPSERT transactionnel pour garantir l'intégrité lors du changement de mode (trimestres ↔ semestres). Les domain events de Subject sont étendus pour couvrir toutes les mutations (code, couleur, description) en plus du renommage.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ConfigurePeriods;
|
||||
|
||||
/**
|
||||
* Command pour configurer les périodes d'une année scolaire (trimestres ou semestres).
|
||||
*/
|
||||
final readonly class ConfigurePeriodsCommand
|
||||
{
|
||||
/**
|
||||
* @param string $periodType 'trimester' ou 'semester'
|
||||
* @param int $startYear Année de début (ex: 2025 pour 2025-2026)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public string $periodType,
|
||||
public int $startYear,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ConfigurePeriods;
|
||||
|
||||
use App\Administration\Domain\Event\PeriodesConfigurees;
|
||||
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handler pour configurer les périodes d'une année scolaire.
|
||||
*
|
||||
* Crée les périodes par défaut (trimestres ou semestres) pour l'année donnée.
|
||||
* Refuse si des périodes existent déjà.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ConfigurePeriodsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ConfigurePeriodsCommand $command): PeriodConfiguration
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$periodType = PeriodType::from($command->periodType);
|
||||
|
||||
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
|
||||
if ($existing !== null) {
|
||||
throw PeriodesDejaConfigureesException::pourAnnee($command->academicYearId);
|
||||
}
|
||||
|
||||
$configuration = DefaultPeriods::forType($periodType, $command->startYear);
|
||||
|
||||
$this->repository->save($tenantId, $academicYearId, $configuration);
|
||||
|
||||
$this->eventBus->dispatch(new PeriodesConfigurees(
|
||||
academicYearId: $academicYearId,
|
||||
tenantId: $tenantId,
|
||||
periodType: $periodType,
|
||||
periodCount: count($configuration->periods),
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdatePeriod;
|
||||
|
||||
/**
|
||||
* Command pour modifier les dates d'une période existante.
|
||||
*/
|
||||
final readonly class UpdatePeriodCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public int $sequence,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
public bool $confirmImpact = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdatePeriod;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Event\PeriodeModifiee;
|
||||
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
|
||||
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handler pour modifier les dates d'une période scolaire.
|
||||
*
|
||||
* Reconstruit la PeriodConfiguration avec la période modifiée,
|
||||
* ce qui déclenche la revalidation complète (chevauchement, contiguïté).
|
||||
* Si la période contient des notes, une confirmation explicite est requise.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdatePeriodHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private GradeExistenceChecker $gradeExistenceChecker,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdatePeriodCommand $command): PeriodConfiguration
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
|
||||
if ($existing === null) {
|
||||
throw PeriodesNonConfigureesException::pourAnnee($command->academicYearId);
|
||||
}
|
||||
|
||||
$found = false;
|
||||
$targetLabel = '';
|
||||
$updatedPeriods = [];
|
||||
foreach ($existing->periods as $period) {
|
||||
if ($period->sequence === $command->sequence) {
|
||||
$found = true;
|
||||
$targetLabel = $period->label;
|
||||
$updatedPeriods[] = new AcademicPeriod(
|
||||
sequence: $period->sequence,
|
||||
label: $period->label,
|
||||
startDate: new DateTimeImmutable($command->startDate),
|
||||
endDate: new DateTimeImmutable($command->endDate),
|
||||
);
|
||||
} else {
|
||||
$updatedPeriods[] = $period;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
throw PeriodeNonTrouveeException::pourSequence($command->sequence, $command->academicYearId);
|
||||
}
|
||||
|
||||
if (!$command->confirmImpact) {
|
||||
$hasGrades = $this->gradeExistenceChecker->hasGradesInPeriod(
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$command->sequence,
|
||||
);
|
||||
|
||||
if ($hasGrades) {
|
||||
throw PeriodeAvecNotesException::confirmationRequise($targetLabel);
|
||||
}
|
||||
}
|
||||
|
||||
$newConfiguration = new PeriodConfiguration($existing->type, $updatedPeriods);
|
||||
|
||||
$this->repository->save($tenantId, $academicYearId, $newConfiguration);
|
||||
|
||||
$this->eventBus->dispatch(new PeriodeModifiee(
|
||||
academicYearId: $academicYearId,
|
||||
tenantId: $tenantId,
|
||||
periodSequence: $command->sequence,
|
||||
periodLabel: $targetLabel,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return $newConfiguration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour vérifier l'existence de notes dans une période.
|
||||
*
|
||||
* Implémenté par le module Notes/Évaluations (Epic 6).
|
||||
* L'implémentation par défaut retourne false tant que le module n'existe pas.
|
||||
*/
|
||||
interface GradeExistenceChecker
|
||||
{
|
||||
public function hasGradesInPeriod(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
int $periodSequence,
|
||||
): bool;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer la configuration des périodes d'une année scolaire.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetPeriodsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(GetPeriodsQuery $query): ?PeriodsResultDto
|
||||
{
|
||||
$config = $this->repository->findByAcademicYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
if ($config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$currentPeriod = $config->currentPeriod($now);
|
||||
|
||||
$periodDtos = array_map(
|
||||
static fn ($period) => PeriodDto::fromDomain($period, $now),
|
||||
$config->periods,
|
||||
);
|
||||
|
||||
$currentPeriodDto = $currentPeriod !== null
|
||||
? PeriodDto::fromDomain($currentPeriod, $now)
|
||||
: null;
|
||||
|
||||
return new PeriodsResultDto(
|
||||
type: $config->type->value,
|
||||
periods: $periodDtos,
|
||||
currentPeriod: $currentPeriodDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
/**
|
||||
* Query pour récupérer la configuration des périodes d'une année scolaire.
|
||||
*/
|
||||
final readonly class GetPeriodsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO pour représenter une période scolaire dans les réponses de query.
|
||||
*/
|
||||
final readonly class PeriodDto
|
||||
{
|
||||
public function __construct(
|
||||
public int $sequence,
|
||||
public string $label,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
public bool $isCurrent,
|
||||
public int $daysRemaining,
|
||||
public bool $isPast,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(AcademicPeriod $period, DateTimeImmutable $now): self
|
||||
{
|
||||
return new self(
|
||||
sequence: $period->sequence,
|
||||
label: $period->label,
|
||||
startDate: $period->startDate->format('Y-m-d'),
|
||||
endDate: $period->endDate->format('Y-m-d'),
|
||||
isCurrent: $period->containsDate($now),
|
||||
daysRemaining: $period->daysRemaining($now),
|
||||
isPast: $period->isPast($now),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
/**
|
||||
* DTO encapsulant la configuration complète des périodes.
|
||||
*/
|
||||
final readonly class PeriodsResultDto
|
||||
{
|
||||
/**
|
||||
* @param PeriodDto[] $periods
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public array $periods,
|
||||
public ?PeriodDto $currentPeriod,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasGradesInPeriod;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier la présence de notes dans une période.
|
||||
*
|
||||
* Délègue au port GradeExistenceChecker qui sera implémenté par le module Notes
|
||||
* quand il existera. Pour l'instant, retourne toujours false (pas de notes).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasGradesInPeriodHandler
|
||||
{
|
||||
public function __construct(
|
||||
private GradeExistenceChecker $gradeExistenceChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(HasGradesInPeriodQuery $query): bool
|
||||
{
|
||||
return $this->gradeExistenceChecker->hasGradesInPeriod(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
$query->periodSequence,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasGradesInPeriod;
|
||||
|
||||
/**
|
||||
* Query pour vérifier si des notes existent dans une période donnée.
|
||||
*
|
||||
* Utilisée pour avertir l'administrateur lors de la modification
|
||||
* des dates d'une période passée contenant des évaluations.
|
||||
*/
|
||||
final readonly class HasGradesInPeriodQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public int $periodSequence,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
@@ -20,8 +19,9 @@ final readonly class MatiereModifiee implements DomainEvent
|
||||
public function __construct(
|
||||
public SubjectId $subjectId,
|
||||
public TenantId $tenantId,
|
||||
public SubjectName $ancienNom,
|
||||
public SubjectName $nouveauNom,
|
||||
public string $champ,
|
||||
public ?string $ancienneValeur,
|
||||
public ?string $nouvelleValeur,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
39
backend/src/Administration/Domain/Event/PeriodeModifiee.php
Normal file
39
backend/src/Administration/Domain/Event/PeriodeModifiee.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la modification des dates d'une période scolaire.
|
||||
*/
|
||||
final readonly class PeriodeModifiee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public AcademicYearId $academicYearId,
|
||||
public TenantId $tenantId,
|
||||
public int $periodSequence,
|
||||
public string $periodLabel,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->academicYearId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la configuration initiale des périodes d'une année scolaire.
|
||||
*/
|
||||
final readonly class PeriodesConfigurees implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public AcademicYearId $academicYearId,
|
||||
public TenantId $tenantId,
|
||||
public PeriodType $periodType,
|
||||
public int $periodCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->academicYearId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class InvalidPeriodCountException extends RuntimeException
|
||||
{
|
||||
public static function forType(string $type, int $expected, int $actual): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le mode "%s" attend %d période(s), mais %d fournie(s).',
|
||||
$type,
|
||||
$expected,
|
||||
$actual,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class InvalidPeriodDatesException extends RuntimeException
|
||||
{
|
||||
public static function endBeforeStart(string $label, string $start, string $end): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La date de fin (%s) de la période "%s" doit être postérieure à la date de début (%s).',
|
||||
$end,
|
||||
$label,
|
||||
$start,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PeriodeAvecNotesException extends RuntimeException
|
||||
{
|
||||
public static function confirmationRequise(string $label): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La période "%s" contient des notes. La modification peut impacter les bulletins existants. Confirmez avec confirmImpact=true.',
|
||||
$label,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PeriodeNonTrouveeException extends RuntimeException
|
||||
{
|
||||
public static function pourSequence(int $sequence, string $academicYearId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune période avec la séquence %d trouvée pour l\'année scolaire %s.',
|
||||
$sequence,
|
||||
$academicYearId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PeriodesDejaConfigureesException extends RuntimeException
|
||||
{
|
||||
public static function pourAnnee(string $academicYearId): self
|
||||
{
|
||||
return new self("Les périodes sont déjà configurées pour l'année scolaire {$academicYearId}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PeriodesNonConfigureesException extends RuntimeException
|
||||
{
|
||||
public static function pourAnnee(string $academicYearId): self
|
||||
{
|
||||
return new self("Aucune période configurée pour l'année scolaire {$academicYearId}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PeriodsCoverageGapException extends RuntimeException
|
||||
{
|
||||
public static function gapBetween(string $periodA, string $periodB): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Il y a un trou de couverture entre les périodes "%s" et "%s". Les périodes doivent être contiguës.',
|
||||
$periodA,
|
||||
$periodB,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PeriodsOverlapException extends RuntimeException
|
||||
{
|
||||
public static function between(string $periodA, string $periodB): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Les périodes "%s" et "%s" se chevauchent.',
|
||||
$periodA,
|
||||
$periodB,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Value Object représentant une période scolaire (trimestre ou semestre).
|
||||
*
|
||||
* Une période a un numéro de séquence, un libellé, et des dates de début/fin.
|
||||
* La date de fin doit être strictement postérieure à la date de début.
|
||||
*/
|
||||
final readonly class AcademicPeriod
|
||||
{
|
||||
public function __construct(
|
||||
public int $sequence,
|
||||
public string $label,
|
||||
public DateTimeImmutable $startDate,
|
||||
public DateTimeImmutable $endDate,
|
||||
) {
|
||||
if ($this->endDate <= $this->startDate) {
|
||||
throw InvalidPeriodDatesException::endBeforeStart(
|
||||
$this->label,
|
||||
$this->startDate->format('Y-m-d'),
|
||||
$this->endDate->format('Y-m-d'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function containsDate(DateTimeImmutable $date): bool
|
||||
{
|
||||
$d = $date->format('Y-m-d');
|
||||
|
||||
return $d >= $this->startDate->format('Y-m-d')
|
||||
&& $d <= $this->endDate->format('Y-m-d');
|
||||
}
|
||||
|
||||
public function daysRemaining(DateTimeImmutable $now): int
|
||||
{
|
||||
$today = $now->format('Y-m-d');
|
||||
|
||||
if ($today > $this->endDate->format('Y-m-d')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($today < $this->startDate->format('Y-m-d')) {
|
||||
return (int) $this->startDate->diff($this->endDate)->days;
|
||||
}
|
||||
|
||||
return (int) $now->setTime(0, 0)->diff($this->endDate->setTime(0, 0))->days;
|
||||
}
|
||||
|
||||
public function isPast(DateTimeImmutable $now): bool
|
||||
{
|
||||
return $now->format('Y-m-d') > $this->endDate->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Factory pour générer les périodes par défaut selon le type choisi.
|
||||
*
|
||||
* Trimestres (collège/lycée) : Sept-Nov / Déc-Fév / Mars-Juin
|
||||
* Semestres : Sept-Jan / Fév-Juin
|
||||
*/
|
||||
final class DefaultPeriods
|
||||
{
|
||||
/**
|
||||
* @param int $startYear L'année de début (ex: 2024 pour 2024-2025)
|
||||
*/
|
||||
public static function forType(PeriodType $type, int $startYear): PeriodConfiguration
|
||||
{
|
||||
$endYear = $startYear + 1;
|
||||
$lastDayOfFeb = (new DateTimeImmutable("{$endYear}-03-01"))->modify('-1 day');
|
||||
$firstDayAfterFeb = $lastDayOfFeb->modify('+1 day');
|
||||
|
||||
return match ($type) {
|
||||
PeriodType::TRIMESTER => new PeriodConfiguration($type, [
|
||||
new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable("{$startYear}-09-01"),
|
||||
endDate: new DateTimeImmutable("{$startYear}-11-30"),
|
||||
),
|
||||
new AcademicPeriod(
|
||||
sequence: 2,
|
||||
label: 'T2',
|
||||
startDate: new DateTimeImmutable("{$startYear}-12-01"),
|
||||
endDate: $lastDayOfFeb,
|
||||
),
|
||||
new AcademicPeriod(
|
||||
sequence: 3,
|
||||
label: 'T3',
|
||||
startDate: $firstDayAfterFeb,
|
||||
endDate: new DateTimeImmutable("{$endYear}-06-30"),
|
||||
),
|
||||
]),
|
||||
PeriodType::SEMESTER => new PeriodConfiguration($type, [
|
||||
new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'S1',
|
||||
startDate: new DateTimeImmutable("{$startYear}-09-01"),
|
||||
endDate: new DateTimeImmutable("{$endYear}-01-31"),
|
||||
),
|
||||
new AcademicPeriod(
|
||||
sequence: 2,
|
||||
label: 'S2',
|
||||
startDate: new DateTimeImmutable("{$endYear}-02-01"),
|
||||
endDate: new DateTimeImmutable("{$endYear}-06-30"),
|
||||
),
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Exception\InvalidPeriodCountException;
|
||||
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
|
||||
use App\Administration\Domain\Exception\PeriodsOverlapException;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function usort;
|
||||
|
||||
/**
|
||||
* Value Object encapsulant la configuration complète des périodes d'une année scolaire.
|
||||
*
|
||||
* Invariants :
|
||||
* - Le nombre de périodes correspond au PeriodType (3 pour trimestres, 2 pour semestres)
|
||||
* - Aucun chevauchement entre périodes
|
||||
* - Les périodes sont contiguës (pas de trou)
|
||||
*/
|
||||
final readonly class PeriodConfiguration
|
||||
{
|
||||
/** @var AcademicPeriod[] */
|
||||
public array $periods;
|
||||
|
||||
/**
|
||||
* @param AcademicPeriod[] $periods
|
||||
*/
|
||||
public function __construct(
|
||||
public PeriodType $type,
|
||||
array $periods,
|
||||
) {
|
||||
if (count($periods) !== $this->type->expectedCount()) {
|
||||
throw InvalidPeriodCountException::forType(
|
||||
$this->type->value,
|
||||
$this->type->expectedCount(),
|
||||
count($periods),
|
||||
);
|
||||
}
|
||||
|
||||
$sorted = $periods;
|
||||
usort($sorted, static fn (AcademicPeriod $a, AcademicPeriod $b): int => $a->startDate <=> $b->startDate);
|
||||
|
||||
self::validateNoOverlap($sorted);
|
||||
self::validateContiguity($sorted);
|
||||
|
||||
$this->periods = $sorted;
|
||||
}
|
||||
|
||||
public function currentPeriod(DateTimeImmutable $now): ?AcademicPeriod
|
||||
{
|
||||
foreach ($this->periods as $period) {
|
||||
if ($period->containsDate($now)) {
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function periodBySequence(int $sequence): ?AcademicPeriod
|
||||
{
|
||||
foreach ($this->periods as $period) {
|
||||
if ($period->sequence === $sequence) {
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function startDate(): DateTimeImmutable
|
||||
{
|
||||
return $this->periods[0]->startDate;
|
||||
}
|
||||
|
||||
public function endDate(): DateTimeImmutable
|
||||
{
|
||||
return $this->periods[count($this->periods) - 1]->endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AcademicPeriod[] $periods Sorted by startDate
|
||||
*/
|
||||
private static function validateNoOverlap(array $periods): void
|
||||
{
|
||||
for ($i = 1; $i < count($periods); ++$i) {
|
||||
if ($periods[$i]->startDate <= $periods[$i - 1]->endDate) {
|
||||
throw PeriodsOverlapException::between(
|
||||
$periods[$i - 1]->label,
|
||||
$periods[$i]->label,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AcademicPeriod[] $periods Sorted by startDate
|
||||
*/
|
||||
private static function validateContiguity(array $periods): void
|
||||
{
|
||||
for ($i = 1; $i < count($periods); ++$i) {
|
||||
$previousEnd = $periods[$i - 1]->endDate;
|
||||
$nextStart = $periods[$i]->startDate;
|
||||
|
||||
$dayAfterPreviousEnd = $previousEnd->modify('+1 day');
|
||||
|
||||
if ($dayAfterPreviousEnd->format('Y-m-d') !== $nextStart->format('Y-m-d')) {
|
||||
throw PeriodsCoverageGapException::gapBetween(
|
||||
$periods[$i - 1]->label,
|
||||
$periods[$i]->label,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
/**
|
||||
* Type de découpage de l'année scolaire.
|
||||
*/
|
||||
enum PeriodType: string
|
||||
{
|
||||
case TRIMESTER = 'trimester';
|
||||
case SEMESTER = 'semester';
|
||||
|
||||
/**
|
||||
* Nombre de périodes attendues pour ce type.
|
||||
*/
|
||||
public function expectedCount(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::TRIMESTER => 3,
|
||||
self::SEMESTER => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,9 @@ final class Subject extends AggregateRoot
|
||||
$this->recordEvent(new MatiereModifiee(
|
||||
subjectId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
ancienNom: $ancienNom,
|
||||
nouveauNom: $nouveauNom,
|
||||
champ: 'nom',
|
||||
ancienneValeur: (string) $ancienNom,
|
||||
nouvelleValeur: (string) $nouveauNom,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
@@ -106,8 +107,18 @@ final class Subject extends AggregateRoot
|
||||
return;
|
||||
}
|
||||
|
||||
$ancienCode = $this->code;
|
||||
$this->code = $nouveauCode;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new MatiereModifiee(
|
||||
subjectId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'code',
|
||||
ancienneValeur: (string) $ancienCode,
|
||||
nouvelleValeur: (string) $nouveauCode,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,8 +134,18 @@ final class Subject extends AggregateRoot
|
||||
return;
|
||||
}
|
||||
|
||||
$ancienneCouleur = $this->color;
|
||||
$this->color = $couleur;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new MatiereModifiee(
|
||||
subjectId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'couleur',
|
||||
ancienneValeur: $ancienneCouleur !== null ? (string) $ancienneCouleur : null,
|
||||
nouvelleValeur: $couleur !== null ? (string) $couleur : null,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +157,18 @@ final class Subject extends AggregateRoot
|
||||
return;
|
||||
}
|
||||
|
||||
$ancienneDescription = $this->description;
|
||||
$this->description = $description;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new MatiereModifiee(
|
||||
subjectId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'description',
|
||||
ancienneValeur: $ancienneDescription,
|
||||
nouvelleValeur: $description,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface PeriodConfigurationRepository
|
||||
{
|
||||
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void;
|
||||
|
||||
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
|
||||
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour configurer les périodes d'une année scolaire.
|
||||
*
|
||||
* @implements ProcessorInterface<PeriodResource, PeriodResource>
|
||||
*/
|
||||
final readonly class ConfigurePeriodsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurePeriodsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PeriodResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
try {
|
||||
$startYear = $data->startYear
|
||||
?? $this->academicYearResolver->resolveStartYear($rawAcademicYearId)
|
||||
?? (int) $this->clock->now()->format('Y');
|
||||
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
periodType: $data->periodType ?? 'trimester',
|
||||
startYear: $startYear,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $config->type->value;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($config->periods as $period) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $period->sequence;
|
||||
$item->label = $period->label;
|
||||
$item->startDate = $period->startDate->format('Y-m-d');
|
||||
$item->endDate = $period->endDate->format('Y-m-d');
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (PeriodesDejaConfigureesException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,18 +73,7 @@ final readonly class CreateSubjectProcessor implements ProcessorInterface
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the created resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (SubjectDejaExistanteException $e) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodCommand;
|
||||
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
|
||||
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
|
||||
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
|
||||
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
|
||||
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
|
||||
use App\Administration\Domain\Exception\PeriodsOverlapException;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier les dates d'une période.
|
||||
*
|
||||
* @implements ProcessorInterface<PeriodResource, PeriodResource>
|
||||
*/
|
||||
final readonly class UpdatePeriodProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdatePeriodHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PeriodResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
/** @var int|string $sequence */
|
||||
$sequence = $uriVariables['sequence'];
|
||||
|
||||
$startDate = $data->startDate;
|
||||
$endDate = $data->endDate;
|
||||
|
||||
if ($startDate === null || $startDate === '' || $endDate === null || $endDate === '') {
|
||||
throw new BadRequestHttpException('Les dates de début et de fin sont obligatoires.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new UpdatePeriodCommand(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
sequence: (int) $sequence,
|
||||
startDate: $startDate,
|
||||
endDate: $endDate,
|
||||
confirmImpact: $data->confirmImpact ?? false,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $config->type->value;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($config->periods as $period) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $period->sequence;
|
||||
$item->label = $period->label;
|
||||
$item->startDate = $period->startDate->format('Y-m-d');
|
||||
$item->endDate = $period->endDate->format('Y-m-d');
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (PeriodesNonConfigureesException|PeriodeNonTrouveeException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (PeriodeAvecNotesException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,18 +86,7 @@ final readonly class UpdateSubjectProcessor implements ProcessorInterface
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the updated resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsQuery;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
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 la configuration des périodes.
|
||||
*
|
||||
* @implements ProviderInterface<PeriodResource>
|
||||
*/
|
||||
final readonly class PeriodsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetPeriodsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
$result = ($this->handler)(new GetPeriodsQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
));
|
||||
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $result->type;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($result->periods as $periodDto) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $periodDto->sequence;
|
||||
$item->label = $periodDto->label;
|
||||
$item->startDate = $periodDto->startDate;
|
||||
$item->endDate = $periodDto->endDate;
|
||||
$item->isCurrent = $periodDto->isCurrent;
|
||||
$item->daysRemaining = $periodDto->daysRemaining;
|
||||
$item->isPast = $periodDto->isPast;
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
if ($result->currentPeriod !== null) {
|
||||
$current = new PeriodItem();
|
||||
$current->sequence = $result->currentPeriod->sequence;
|
||||
$current->label = $result->currentPeriod->label;
|
||||
$current->startDate = $result->currentPeriod->startDate;
|
||||
$current->endDate = $result->currentPeriod->endDate;
|
||||
$current->isCurrent = true;
|
||||
$current->daysRemaining = $result->currentPeriod->daysRemaining;
|
||||
$resource->currentPeriod = $current;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -60,23 +60,6 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
|
||||
|
||||
$subjectDtos = ($this->handler)($query);
|
||||
|
||||
return array_map(
|
||||
static function ($dto) {
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->code = $dto->code;
|
||||
$resource->color = $dto->color;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
$resource->teacherCount = $dto->teacherCount;
|
||||
$resource->classCount = $dto->classCount;
|
||||
|
||||
return $resource;
|
||||
},
|
||||
$subjectDtos,
|
||||
);
|
||||
return array_map(SubjectResource::fromDto(...), $subjectDtos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +62,6 @@ final readonly class SubjectItemProvider implements ProviderInterface
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.');
|
||||
}
|
||||
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* DTO pour une période individuelle dans la réponse API.
|
||||
*
|
||||
* Séparé de PeriodResource pour éviter qu'API Platform
|
||||
* ne tente de générer un IRI pour chaque période.
|
||||
*/
|
||||
final class PeriodItem
|
||||
{
|
||||
public ?int $sequence = null;
|
||||
|
||||
public ?string $label = null;
|
||||
|
||||
public ?string $startDate = null;
|
||||
|
||||
public ?string $endDate = null;
|
||||
|
||||
public ?bool $isCurrent = null;
|
||||
|
||||
public ?int $daysRemaining = null;
|
||||
|
||||
public ?bool $isPast = null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Infrastructure\Api\Processor\ConfigurePeriodsProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdatePeriodProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\PeriodsProvider;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion des périodes scolaires.
|
||||
*
|
||||
* @see FR75 - Structurer l'année pour bulletins et moyennes
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'Period',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods',
|
||||
provider: PeriodsProvider::class,
|
||||
name: 'get_periods',
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods',
|
||||
read: false,
|
||||
processor: ConfigurePeriodsProcessor::class,
|
||||
name: 'configure_periods',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods/{sequence}',
|
||||
read: false,
|
||||
processor: UpdatePeriodProcessor::class,
|
||||
name: 'update_period',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class PeriodResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $academicYearId = null;
|
||||
|
||||
public ?int $sequence = null;
|
||||
|
||||
#[Assert\Choice(choices: ['trimester', 'semester'], message: 'Le type de période doit être "trimester" ou "semester".')]
|
||||
public ?string $periodType = null;
|
||||
|
||||
public ?int $startYear = null;
|
||||
|
||||
#[Assert\Date(message: 'La date de début doit être une date valide (YYYY-MM-DD).')]
|
||||
public ?string $startDate = null;
|
||||
|
||||
#[Assert\Date(message: 'La date de fin doit être une date valide (YYYY-MM-DD).')]
|
||||
public ?string $endDate = null;
|
||||
|
||||
public ?string $label = null;
|
||||
|
||||
public ?bool $isCurrent = null;
|
||||
|
||||
public ?int $daysRemaining = null;
|
||||
|
||||
public ?bool $isPast = null;
|
||||
|
||||
public ?bool $confirmImpact = null;
|
||||
|
||||
/** @var PeriodItem[]|null */
|
||||
public ?array $periods = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public ?PeriodItem $currentPeriod = null;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateSubjectProcessor;
|
||||
@@ -127,4 +129,42 @@ final class SubjectResource
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?bool $hasGrades = null;
|
||||
|
||||
/**
|
||||
* Crée un SubjectResource à partir du domain model.
|
||||
*/
|
||||
public static function fromDomain(Subject $subject): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un SubjectResource à partir d'un DTO de query.
|
||||
*/
|
||||
public static function fromDto(SubjectDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->code = $dto->code;
|
||||
$resource->color = $dto->color;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
$resource->teacherCount = $dto->teacherCount;
|
||||
$resource->classCount = $dto->classCount;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,28 +28,32 @@ final readonly class DoctrineClassRepository implements ClassRepository
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($class->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
|
||||
} else {
|
||||
$this->connection->insert('school_classes', $data);
|
||||
}
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, description, created_at, updated_at, deleted_at)
|
||||
VALUES (:id, :tenant_id, :school_id, :academic_year_id, :name, :level, :capacity, :status, :description, :created_at, :updated_at, :deleted_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
level = EXCLUDED.level,
|
||||
capacity = EXCLUDED.capacity,
|
||||
status = EXCLUDED.status,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at',
|
||||
[
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrinePeriodConfigurationRepository implements PeriodConfigurationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
|
||||
{
|
||||
$this->connection->transactional(function () use ($tenantId, $academicYearId, $configuration): void {
|
||||
$tenantIdStr = (string) $tenantId;
|
||||
$academicYearIdStr = (string) $academicYearId;
|
||||
$now = (new DateTimeImmutable())->format(DateTimeImmutable::ATOM);
|
||||
|
||||
$sequences = [];
|
||||
foreach ($configuration->periods as $period) {
|
||||
$sequences[] = $period->sequence;
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO academic_periods (tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date, created_at, updated_at)
|
||||
VALUES (:tenant_id, :academic_year_id, :period_type, :sequence, :label, :start_date, :end_date, :created_at, :updated_at)
|
||||
ON CONFLICT (tenant_id, academic_year_id, sequence) DO UPDATE SET
|
||||
period_type = EXCLUDED.period_type,
|
||||
label = EXCLUDED.label,
|
||||
start_date = EXCLUDED.start_date,
|
||||
end_date = EXCLUDED.end_date,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'tenant_id' => $tenantIdStr,
|
||||
'academic_year_id' => $academicYearIdStr,
|
||||
'period_type' => $configuration->type->value,
|
||||
'sequence' => $period->sequence,
|
||||
'label' => $period->label,
|
||||
'start_date' => $period->startDate->format('Y-m-d'),
|
||||
'end_date' => $period->endDate->format('Y-m-d'),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove any extra periods (e.g. switching from trimester to semester would leave stale rows)
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM academic_periods
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND sequence > :max_sequence',
|
||||
[
|
||||
'tenant_id' => $tenantIdStr,
|
||||
'academic_year_id' => $academicYearIdStr,
|
||||
'max_sequence' => count($configuration->periods),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM academic_periods
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
ORDER BY sequence ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
],
|
||||
);
|
||||
|
||||
if (count($rows) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $periodType */
|
||||
$periodType = $rows[0]['period_type'];
|
||||
|
||||
$periods = array_map(static function (array $row): AcademicPeriod {
|
||||
/** @var int|string $sequence */
|
||||
$sequence = $row['sequence'];
|
||||
/** @var string $label */
|
||||
$label = $row['label'];
|
||||
/** @var string $startDate */
|
||||
$startDate = $row['start_date'];
|
||||
/** @var string $endDate */
|
||||
$endDate = $row['end_date'];
|
||||
|
||||
return new AcademicPeriod(
|
||||
sequence: (int) $sequence,
|
||||
label: $label,
|
||||
startDate: new DateTimeImmutable($startDate),
|
||||
endDate: new DateTimeImmutable($endDate),
|
||||
);
|
||||
}, $rows);
|
||||
|
||||
return new PeriodConfiguration(PeriodType::from($periodType), $periods);
|
||||
}
|
||||
}
|
||||
@@ -28,27 +28,31 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
|
||||
#[Override]
|
||||
public function save(Subject $subject): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $subject->id,
|
||||
'tenant_id' => (string) $subject->tenantId,
|
||||
'school_id' => (string) $subject->schoolId,
|
||||
'name' => (string) $subject->name,
|
||||
'code' => (string) $subject->code,
|
||||
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||
'status' => $subject->status->value,
|
||||
'description' => $subject->description,
|
||||
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($subject->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
|
||||
} else {
|
||||
$this->connection->insert('subjects', $data);
|
||||
}
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, description, created_at, updated_at, deleted_at)
|
||||
VALUES (:id, :tenant_id, :school_id, :name, :code, :color, :status, :description, :created_at, :updated_at, :deleted_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
code = EXCLUDED.code,
|
||||
color = EXCLUDED.color,
|
||||
status = EXCLUDED.status,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at',
|
||||
[
|
||||
'id' => (string) $subject->id,
|
||||
'tenant_id' => (string) $subject->tenantId,
|
||||
'school_id' => (string) $subject->schoolId,
|
||||
'name' => (string) $subject->name,
|
||||
'code' => (string) $subject->code,
|
||||
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||
'status' => $subject->status->value,
|
||||
'description' => $subject->description,
|
||||
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
final class InMemoryPeriodConfigurationRepository implements PeriodConfigurationRepository
|
||||
{
|
||||
/** @var array<string, PeriodConfiguration> */
|
||||
private array $configurations = [];
|
||||
|
||||
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
|
||||
{
|
||||
$this->configurations[$this->key($tenantId, $academicYearId)] = $configuration;
|
||||
}
|
||||
|
||||
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
|
||||
{
|
||||
return $this->configurations[$this->key($tenantId, $academicYearId)] ?? null;
|
||||
}
|
||||
|
||||
private function key(TenantId $tenantId, AcademicYearId $academicYearId): string
|
||||
{
|
||||
return (string) $tenantId . ':' . (string) $academicYearId;
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ final class InMemorySubjectRepository implements SubjectRepository
|
||||
): ?Subject {
|
||||
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
|
||||
|
||||
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
|
||||
if ($subject !== null && $subject->deletedAt !== null) {
|
||||
// Filtrer les matières archivées (cohérent avec findActiveByTenantAndSchool)
|
||||
if ($subject !== null && $subject->status !== SubjectStatus::ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur les périodes scolaires.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
|
||||
* - Autres rôles : lecture seule
|
||||
*
|
||||
* @extends Voter<string, PeriodResource|null>
|
||||
*/
|
||||
final class PeriodVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'PERIOD_VIEW';
|
||||
public const string CONFIGURE = 'PERIOD_CONFIGURE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CONFIGURE,
|
||||
];
|
||||
|
||||
#[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 UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CONFIGURE => $this->canConfigure($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,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canConfigure(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,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Service;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* Résout les identifiants spéciaux 'current' et 'next' en UUID v5 déterministes
|
||||
* basés sur le tenant et l'année scolaire.
|
||||
*
|
||||
* L'année scolaire est calculée selon le calendrier français :
|
||||
* septembre → juin (ex: sept 2025 = année 2025-2026).
|
||||
*/
|
||||
final readonly class CurrentAcademicYearResolver
|
||||
{
|
||||
/** Namespace UUID v5 dédié aux années scolaires. */
|
||||
private const string NAMESPACE = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
public function __construct(
|
||||
private TenantContext $tenantContext,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null UUID résolu, ou null si l'identifiant est invalide
|
||||
*/
|
||||
public function resolve(string $academicYearId): ?string
|
||||
{
|
||||
if (Uuid::isValid($academicYearId)) {
|
||||
return $academicYearId;
|
||||
}
|
||||
|
||||
$startYear = $this->resolveStartYear($academicYearId);
|
||||
if ($startYear === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$name = $tenantId . ':' . $startYear . '-' . ($startYear + 1);
|
||||
|
||||
return Uuid::uuid5(self::NAMESPACE, $name)->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'année de début scolaire pour un identifiant spécial.
|
||||
*
|
||||
* @return int|null L'année de début (ex: 2025 pour 2025-2026), ou null si invalide
|
||||
*/
|
||||
public function resolveStartYear(string $academicYearId): ?int
|
||||
{
|
||||
$offset = match ($academicYearId) {
|
||||
'previous' => -1,
|
||||
'current' => 0,
|
||||
'next' => 1,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($offset === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$month = (int) $now->format('n');
|
||||
$year = (int) $now->format('Y');
|
||||
|
||||
return ($month >= 9 ? $year : $year - 1) + $offset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Implémentation par défaut qui retourne toujours false.
|
||||
*
|
||||
* Sera remplacée par une implémentation réelle quand le module Notes (Epic 6) existera.
|
||||
*/
|
||||
final class NoOpGradeExistenceChecker implements GradeExistenceChecker
|
||||
{
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
int $periodSequence,
|
||||
): bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user