feat: Calculer automatiquement les moyennes après chaque saisie de notes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
This commit is contained in:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit b7dc27f2a5
786 changed files with 118783 additions and 316 deletions

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
final class Competency
{
private function __construct(
public private(set) CompetencyId $id,
public private(set) CompetencyFrameworkId $frameworkId,
public private(set) string $code,
public private(set) string $name,
public private(set) ?string $description,
public private(set) ?CompetencyId $parentId,
public private(set) int $sortOrder,
) {
}
public static function creer(
CompetencyFrameworkId $frameworkId,
string $code,
string $name,
?string $description,
?CompetencyId $parentId,
int $sortOrder,
): self {
return new self(
id: CompetencyId::generate(),
frameworkId: $frameworkId,
code: $code,
name: $name,
description: $description,
parentId: $parentId,
sortOrder: $sortOrder,
);
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
CompetencyId $id,
CompetencyFrameworkId $frameworkId,
string $code,
string $name,
?string $description,
?CompetencyId $parentId,
int $sortOrder,
): self {
return new self(
id: $id,
frameworkId: $frameworkId,
code: $code,
name: $name,
description: $description,
parentId: $parentId,
sortOrder: $sortOrder,
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
final class CompetencyEvaluation
{
private function __construct(
public private(set) CompetencyEvaluationId $id,
public private(set) EvaluationId $evaluationId,
public private(set) CompetencyId $competencyId,
) {
}
public static function creer(
EvaluationId $evaluationId,
CompetencyId $competencyId,
): self {
return new self(
id: CompetencyEvaluationId::generate(),
evaluationId: $evaluationId,
competencyId: $competencyId,
);
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
CompetencyEvaluationId $id,
EvaluationId $evaluationId,
CompetencyId $competencyId,
): self {
return new self(
id: $id,
evaluationId: $evaluationId,
competencyId: $competencyId,
);
}
}

View File

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

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class CompetencyFramework
{
private function __construct(
public private(set) CompetencyFrameworkId $id,
public private(set) TenantId $tenantId,
public private(set) string $name,
public private(set) bool $isDefault,
public private(set) DateTimeImmutable $createdAt,
) {
}
public static function creer(
TenantId $tenantId,
string $name,
bool $isDefault,
DateTimeImmutable $now,
): self {
return new self(
id: CompetencyFrameworkId::generate(),
tenantId: $tenantId,
name: $name,
isDefault: $isDefault,
createdAt: $now,
);
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
CompetencyFrameworkId $id,
TenantId $tenantId,
string $name,
bool $isDefault,
DateTimeImmutable $createdAt,
): self {
return new self(
id: $id,
tenantId: $tenantId,
name: $name,
isDefault: $isDefault,
createdAt: $createdAt,
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
/**
* Niveaux de compétence standards du référentiel français.
*
* Ces niveaux sont les valeurs par défaut pour tout établissement
* en mode compétences. Un établissement peut définir des niveaux
* personnalisés dans la table competency_levels.
*
* @see FR23 : Mode compétences sans notes chiffrées
*/
enum CompetencyLevel: string
{
case NOT_ACQUIRED = 'not_acquired';
case IN_PROGRESS = 'in_progress';
case ACQUIRED = 'acquired';
case EXCEEDED = 'exceeded';
public function label(): string
{
return match ($this) {
self::NOT_ACQUIRED => 'Non acquis',
self::IN_PROGRESS => 'En cours d\'acquisition',
self::ACQUIRED => 'Acquis',
self::EXCEEDED => 'Dépassé',
};
}
public function sortOrder(): int
{
return match ($this) {
self::NOT_ACQUIRED => 1,
self::IN_PROGRESS => 2,
self::ACQUIRED => 3,
self::EXCEEDED => 4,
};
}
public function color(): string
{
return match ($this) {
self::NOT_ACQUIRED => '#e74c3c',
self::IN_PROGRESS => '#f39c12',
self::ACQUIRED => '#2ecc71',
self::EXCEEDED => '#3498db',
};
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use function preg_match;
/**
* Niveau de compétence personnalisé par établissement.
*
* Permet à chaque établissement de définir ses propres niveaux
* au lieu d'utiliser les niveaux standards (CompetencyLevel enum).
*/
final class CustomCompetencyLevel
{
private function __construct(
public private(set) CustomCompetencyLevelId $id,
public private(set) TenantId $tenantId,
public private(set) string $code,
public private(set) string $name,
public private(set) ?string $color,
public private(set) int $sortOrder,
) {
}
public static function creer(
TenantId $tenantId,
string $code,
string $name,
?string $color,
int $sortOrder,
): self {
if ($color !== null && preg_match('/^#[0-9a-fA-F]{6}$/', $color) !== 1) {
throw new DomainException('La couleur doit être au format hexadécimal (#rrggbb).');
}
return new self(
id: CustomCompetencyLevelId::generate(),
tenantId: $tenantId,
code: $code,
name: $name,
color: $color,
sortOrder: $sortOrder,
);
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
CustomCompetencyLevelId $id,
TenantId $tenantId,
string $code,
string $name,
?string $color,
int $sortOrder,
): self {
return new self(
id: $id,
tenantId: $tenantId,
code: $code,
name: $name,
color: $color,
sortOrder: $sortOrder,
);
}
}

View File

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

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Competency;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\ResultatCompetenceModifie;
use App\Scolarite\Domain\Event\ResultatCompetenceSaisi;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class StudentCompetencyResult extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) StudentCompetencyResultId $id,
public private(set) TenantId $tenantId,
public private(set) CompetencyEvaluationId $competencyEvaluationId,
public private(set) UserId $studentId,
public private(set) string $levelCode,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function saisir(
TenantId $tenantId,
CompetencyEvaluationId $competencyEvaluationId,
UserId $studentId,
string $levelCode,
DateTimeImmutable $now,
): self {
$result = new self(
id: StudentCompetencyResultId::generate(),
tenantId: $tenantId,
competencyEvaluationId: $competencyEvaluationId,
studentId: $studentId,
levelCode: $levelCode,
createdAt: $now,
);
$result->recordEvent(new ResultatCompetenceSaisi(
resultId: $result->id,
competencyEvaluationId: (string) $competencyEvaluationId,
studentId: (string) $studentId,
levelCode: $levelCode,
occurredOn: $now,
));
return $result;
}
public function modifier(
string $levelCode,
DateTimeImmutable $now,
): void {
if ($this->levelCode === $levelCode) {
return;
}
$oldLevelCode = $this->levelCode;
$this->levelCode = $levelCode;
$this->updatedAt = $now;
$this->recordEvent(new ResultatCompetenceModifie(
resultId: $this->id,
competencyEvaluationId: (string) $this->competencyEvaluationId,
studentId: (string) $this->studentId,
oldLevelCode: $oldLevelCode,
newLevelCode: $levelCode,
occurredOn: $now,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
StudentCompetencyResultId $id,
TenantId $tenantId,
CompetencyEvaluationId $competencyEvaluationId,
UserId $studentId,
string $levelCode,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$result = new self(
id: $id,
tenantId: $tenantId,
competencyEvaluationId: $competencyEvaluationId,
studentId: $studentId,
levelCode: $levelCode,
createdAt: $createdAt,
);
$result->updatedAt = $updatedAt;
return $result;
}
}

View File

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

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
final readonly class ClassStatistics
{
public function __construct(
public ?float $average,
public ?float $min,
public ?float $max,
public ?float $median,
public int $gradedCount,
) {
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
enum AppreciationCategory: string
{
case POSITIVE = 'positive';
case NEUTRAL = 'neutral';
case IMPROVEMENT = 'improvement';
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class AppreciationTemplate
{
public private(set) DateTimeImmutable $updatedAt;
public private(set) int $usageCount;
private function __construct(
public private(set) AppreciationTemplateId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $teacherId,
public private(set) string $title,
public private(set) string $content,
public private(set) ?AppreciationCategory $category,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
$this->usageCount = 0;
}
public static function creer(
TenantId $tenantId,
UserId $teacherId,
string $title,
string $content,
?AppreciationCategory $category,
DateTimeImmutable $now,
): self {
return new self(
id: AppreciationTemplateId::generate(),
tenantId: $tenantId,
teacherId: $teacherId,
title: $title,
content: $content,
category: $category,
createdAt: $now,
);
}
public function modifier(
string $title,
string $content,
?AppreciationCategory $category,
DateTimeImmutable $now,
): void {
$this->title = $title;
$this->content = $content;
$this->category = $category;
$this->updatedAt = $now;
}
public function incrementerUtilisation(): void
{
++$this->usageCount;
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
AppreciationTemplateId $id,
TenantId $tenantId,
UserId $teacherId,
string $title,
string $content,
?AppreciationCategory $category,
int $usageCount,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$template = new self(
id: $id,
tenantId: $tenantId,
teacherId: $teacherId,
title: $title,
content: $content,
category: $category,
createdAt: $createdAt,
);
$template->updatedAt = $updatedAt;
$template->usageCount = $usageCount;
return $template;
}
}

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
@@ -15,9 +16,15 @@ use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function mb_strlen;
final class Grade extends AggregateRoot
{
public const int MAX_APPRECIATION_LENGTH = 500;
public private(set) DateTimeImmutable $updatedAt;
public private(set) ?string $appreciation;
public private(set) ?DateTimeImmutable $appreciationUpdatedAt;
private function __construct(
public private(set) GradeId $id,
@@ -30,6 +37,8 @@ final class Grade extends AggregateRoot
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
$this->appreciation = null;
$this->appreciationUpdatedAt = null;
}
public static function saisir(
@@ -96,6 +105,17 @@ final class Grade extends AggregateRoot
));
}
public function saisirAppreciation(?string $appreciation, DateTimeImmutable $now): void
{
if ($appreciation !== null && mb_strlen($appreciation) > self::MAX_APPRECIATION_LENGTH) {
throw AppreciationTropLongueException::depasseLimite(mb_strlen($appreciation), self::MAX_APPRECIATION_LENGTH);
}
$this->appreciation = $appreciation !== null && $appreciation !== '' ? $appreciation : null;
$this->appreciationUpdatedAt = $now;
$this->updatedAt = $now;
}
/**
* @internal Pour usage Infrastructure uniquement
*/
@@ -109,6 +129,8 @@ final class Grade extends AggregateRoot
UserId $createdBy,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
?string $appreciation = null,
?DateTimeImmutable $appreciationUpdatedAt = null,
): self {
$grade = new self(
id: $id,
@@ -122,6 +144,8 @@ final class Grade extends AggregateRoot
);
$grade->updatedAt = $updatedAt;
$grade->appreciation = $appreciation;
$grade->appreciationUpdatedAt = $appreciationUpdatedAt;
return $grade;
}