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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user