feat: Calculer automatiquement les moyennes après chaque saisie de notes
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:
61
backend/src/Scolarite/Domain/Model/Competency/Competency.php
Normal file
61
backend/src/Scolarite/Domain/Model/Competency/Competency.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user