feat: Configuration du mode de notation par établissement

Les établissements scolaires utilisent des systèmes d'évaluation variés
(notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application
imposait implicitement le mode notes /20, ce qui ne correspondait pas
à la réalité pédagogique de nombreuses écoles.

Cette configuration permet à chaque établissement de choisir son mode
de notation par année scolaire, avec verrouillage automatique dès que
des notes ont été saisies pour éviter les incohérences. Le Score Sérénité
adapte ses pondérations selon le mode choisi (les compétences sont
converties via un mapping, le mode sans notes exclut la composante notes).
This commit is contained in:
2026-02-07 01:06:55 +01:00
parent f19d0ae3ef
commit ff18850a43
51 changed files with 3963 additions and 79 deletions

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
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 ou du changement du mode de notation.
*/
final readonly class ModeNotationConfigure implements DomainEvent
{
public function __construct(
public SchoolGradingConfigurationId $configurationId,
public TenantId $tenantId,
public SchoolId $schoolId,
public AcademicYearId $academicYearId,
public GradingMode $mode,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->configurationId->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
final class CannotChangeGradingModeWithExistingGradesException extends RuntimeException
{
public function __construct()
{
parent::__construct(
'Le mode de notation ne peut pas être modifié car des notes existent déjà cette année.',
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use RuntimeException;
use function sprintf;
final class GradingConfigurationNotFoundException extends RuntimeException
{
public static function withId(SchoolGradingConfigurationId $id): self
{
return new self(sprintf(
'Configuration de notation "%s" introuvable.',
$id,
));
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
/**
* Value Object encapsulant la configuration de notation d'un établissement.
*
* Délègue le comportement au GradingMode tout en fournissant un point
* d'extension si des options supplémentaires sont ajoutées ultérieurement
* (ex: autoriser les demi-points, afficher la moyenne de classe).
*/
final readonly class GradingConfiguration
{
public function __construct(
public GradingMode $mode,
) {
}
public function scaleMax(): ?int
{
return $this->mode->scaleMax();
}
public function estNumerique(): bool
{
return $this->mode->estNumerique();
}
public function calculeMoyenne(): bool
{
return $this->mode->calculeMoyenne();
}
public function equals(self $other): bool
{
return $this->mode === $other->mode;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
/**
* Mode de notation de l'établissement.
*
* Chaque établissement choisit un mode unique qui détermine comment
* les enseignants saisissent les évaluations et comment les moyennes
* sont calculées (ou non).
*
* @see FR24: Supporter différentes pédagogies
*/
enum GradingMode: string
{
case NUMERIC_20 = 'numeric_20';
case NUMERIC_10 = 'numeric_10';
case LETTERS = 'letters';
case COMPETENCIES = 'competencies';
case NO_GRADES = 'no_grades';
/**
* Échelle maximale pour les modes numériques.
*/
public function scaleMax(): ?int
{
return match ($this) {
self::NUMERIC_20 => 20,
self::NUMERIC_10 => 10,
self::LETTERS, self::COMPETENCIES, self::NO_GRADES => null,
};
}
/**
* Détermine si le mode utilise une notation numérique.
*/
public function estNumerique(): bool
{
return match ($this) {
self::NUMERIC_20, self::NUMERIC_10 => true,
self::LETTERS, self::COMPETENCIES, self::NO_GRADES => false,
};
}
/**
* Détermine si le mode nécessite un calcul de moyenne.
*
* Les compétences et le mode sans notes ne calculent pas de moyenne.
*/
public function calculeMoyenne(): bool
{
return match ($this) {
self::NUMERIC_20, self::NUMERIC_10, self::LETTERS => true,
self::COMPETENCIES, self::NO_GRADES => false,
};
}
/**
* Libellé utilisateur en français.
*/
public function label(): string
{
return match ($this) {
self::NUMERIC_20 => 'Notes /20',
self::NUMERIC_10 => 'Notes /10',
self::LETTERS => 'Lettres (A-E)',
self::COMPETENCIES => 'Compétences',
self::NO_GRADES => 'Sans notes',
};
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
use App\Administration\Domain\Event\ModeNotationConfigure;
use App\Administration\Domain\Exception\CannotChangeGradingModeWithExistingGradesException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root associant un mode de notation à un établissement pour une année scolaire.
*
* Le mode de notation détermine comment les enseignants évaluent les élèves :
* numériquement, par lettres, par compétences ou sans notes du tout.
*
* Invariant : le mode ne peut pas être changé si des notes existent déjà.
*
* @see FR24: Supporter différentes pédagogies
*/
final class SchoolGradingConfiguration extends AggregateRoot
{
public const GradingMode DEFAULT_MODE = GradingMode::NUMERIC_20;
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) SchoolGradingConfigurationId $id,
public private(set) TenantId $tenantId,
public private(set) SchoolId $schoolId,
public private(set) AcademicYearId $academicYearId,
public private(set) GradingConfiguration $gradingConfiguration,
public private(set) DateTimeImmutable $configuredAt,
) {
$this->updatedAt = $configuredAt;
}
public static function generateId(): SchoolGradingConfigurationId
{
return SchoolGradingConfigurationId::generate();
}
/**
* Configure le mode de notation pour un établissement et une année scolaire.
*
* @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer
*/
public static function configurer(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
GradingMode $mode,
bool $hasExistingGrades,
DateTimeImmutable $configuredAt,
): self {
// Le mode par défaut (NUMERIC_20) est autorisé même avec des notes existantes :
// c'est le mode implicite avant toute configuration, donc le créer explicitement
// est une opération idempotente. En revanche, changerMode() bloque tout changement
// dès qu'il y a des notes, quel que soit le mode cible.
if ($hasExistingGrades && $mode !== self::DEFAULT_MODE) {
throw new CannotChangeGradingModeWithExistingGradesException();
}
$config = new self(
id: SchoolGradingConfigurationId::generate(),
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
gradingConfiguration: new GradingConfiguration($mode),
configuredAt: $configuredAt,
);
$config->recordEvent(new ModeNotationConfigure(
configurationId: $config->id,
tenantId: $config->tenantId,
schoolId: $config->schoolId,
academicYearId: $config->academicYearId,
mode: $mode,
occurredOn: $configuredAt,
));
return $config;
}
/**
* Change le mode de notation.
*
* @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer
*/
public function changerMode(
GradingMode $nouveauMode,
bool $hasExistingGrades,
DateTimeImmutable $at,
): void {
if ($this->gradingConfiguration->mode === $nouveauMode) {
return;
}
if ($hasExistingGrades) {
throw new CannotChangeGradingModeWithExistingGradesException();
}
$this->gradingConfiguration = new GradingConfiguration($nouveauMode);
$this->updatedAt = $at;
$this->recordEvent(new ModeNotationConfigure(
configurationId: $this->id,
tenantId: $this->tenantId,
schoolId: $this->schoolId,
academicYearId: $this->academicYearId,
mode: $nouveauMode,
occurredOn: $at,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
SchoolGradingConfigurationId $id,
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
GradingConfiguration $gradingConfiguration,
DateTimeImmutable $configuredAt,
DateTimeImmutable $updatedAt,
): self {
$config = new self(
id: $id,
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
gradingConfiguration: $gradingConfiguration,
configuredAt: $configuredAt,
);
$config->updatedAt = $updatedAt;
return $config;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
use App\Shared\Domain\EntityId;
final readonly class SchoolGradingConfigurationId extends EntityId
{
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
interface GradingConfigurationRepository
{
public function save(SchoolGradingConfiguration $configuration): void;
public function findBySchoolAndYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): ?SchoolGradingConfiguration;
public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration;
}