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,36 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Competency\StudentCompetencyResultId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class ResultatCompetenceModifie implements DomainEvent
{
public function __construct(
public StudentCompetencyResultId $resultId,
public string $competencyEvaluationId,
public string $studentId,
public string $oldLevelCode,
public string $newLevelCode,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->resultId->value;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Competency\StudentCompetencyResultId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class ResultatCompetenceSaisi implements DomainEvent
{
public function __construct(
public StudentCompetencyResultId $resultId,
public string $competencyEvaluationId,
public string $studentId,
public string $levelCode,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->resultId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Grade\AppreciationTemplateId;
use DomainException;
use function sprintf;
final class AppreciationTemplateNonTrouveeException extends DomainException
{
public static function withId(AppreciationTemplateId $id): self
{
return new self(sprintf(
'Le modèle d\'appréciation avec l\'ID "%s" n\'a pas été trouvé.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class AppreciationTropLongueException extends DomainException
{
public static function depasseLimite(int $length, int $maxLength): self
{
return new self(sprintf(
'L\'appréciation dépasse la limite de %d caractères (%d donnés).',
$maxLength,
$length,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class CategorieAppreciationInvalideException extends DomainException
{
public static function pourValeur(string $value): self
{
return new self(sprintf(
'La catégorie d\'appréciation "%s" n\'est pas valide. Valeurs acceptées : positive, neutral, improvement.',
$value,
));
}
}

View File

@@ -18,4 +18,13 @@ final class GradeNotFoundException extends DomainException
$id,
));
}
public static function forStudent(string $studentId, string $evaluationId): self
{
return new self(sprintf(
'Aucune note trouvée pour l\'élève "%s" dans l\'évaluation "%s".',
$studentId,
$evaluationId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Grade\AppreciationTemplateId;
use DomainException;
use function sprintf;
final class NonProprietaireDuModeleException extends DomainException
{
public static function withId(AppreciationTemplateId $id): self
{
return new self(sprintf(
'Vous n\'êtes pas le propriétaire du modèle d\'appréciation "%s".',
$id,
));
}
}

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;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Grade\AppreciationTemplate;
use App\Scolarite\Domain\Model\Grade\AppreciationTemplateId;
use App\Shared\Domain\Tenant\TenantId;
interface AppreciationTemplateRepository
{
public function save(AppreciationTemplate $template): void;
public function findById(AppreciationTemplateId $id, TenantId $tenantId): ?AppreciationTemplate;
/** @return array<AppreciationTemplate> */
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
public function delete(AppreciationTemplateId $id, TenantId $tenantId): void;
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Competency\CompetencyEvaluation;
use App\Scolarite\Domain\Model\Competency\CompetencyEvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
interface CompetencyEvaluationRepository
{
public function save(CompetencyEvaluation $competencyEvaluation): void;
public function findById(CompetencyEvaluationId $id): ?CompetencyEvaluation;
/** @return array<CompetencyEvaluation> */
public function findByEvaluation(EvaluationId $evaluationId): array;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Competency\CompetencyFramework;
use App\Scolarite\Domain\Model\Competency\CompetencyFrameworkId;
use App\Shared\Domain\Tenant\TenantId;
interface CompetencyFrameworkRepository
{
public function save(CompetencyFramework $framework): void;
public function findById(CompetencyFrameworkId $id, TenantId $tenantId): ?CompetencyFramework;
public function findDefault(TenantId $tenantId): ?CompetencyFramework;
/** @return array<CompetencyFramework> */
public function findByTenant(TenantId $tenantId): array;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Competency\Competency;
use App\Scolarite\Domain\Model\Competency\CompetencyFrameworkId;
use App\Scolarite\Domain\Model\Competency\CompetencyId;
use App\Shared\Domain\Tenant\TenantId;
interface CompetencyRepository
{
public function save(Competency $competency): void;
public function findById(CompetencyId $id, TenantId $tenantId): ?Competency;
/** @return array<Competency> */
public function findByFramework(CompetencyFrameworkId $frameworkId): array;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Competency\CustomCompetencyLevel;
use App\Shared\Domain\Tenant\TenantId;
interface CustomCompetencyLevelRepository
{
public function save(CustomCompetencyLevel $level): void;
/** @return array<CustomCompetencyLevel> */
public function findByTenant(TenantId $tenantId): array;
public function hasByTenant(TenantId $tenantId): bool;
}

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
interface EvaluationRepository
{
@@ -28,4 +30,16 @@ interface EvaluationRepository
/** @return array<Evaluation> */
public function findByClass(ClassId $classId, TenantId $tenantId): array;
/** @return array<Evaluation> Toutes les évaluations publiées (notes visibles) */
public function findAllWithPublishedGrades(TenantId $tenantId): array;
/** @return array<Evaluation> Évaluations dont les notes sont publiées */
public function findWithPublishedGradesBySubjectAndClassInDateRange(
SubjectId $subjectId,
ClassId $classId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
TenantId $tenantId,
): array;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
interface EvaluationStatisticsRepository
{
public function save(EvaluationId $evaluationId, ClassStatistics $statistics): void;
public function findByEvaluation(EvaluationId $evaluationId): ?ClassStatistics;
public function delete(EvaluationId $evaluationId): void;
}

View File

@@ -14,6 +14,8 @@ interface GradeRepository
{
public function save(Grade $grade): void;
public function saveAppreciation(Grade $grade): void;
/** @throws GradeNotFoundException */
public function get(GradeId $id, TenantId $tenantId): Grade;
@@ -22,5 +24,12 @@ interface GradeRepository
/** @return array<Grade> */
public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array;
/**
* @param array<EvaluationId> $evaluationIds
*
* @return array<string, list<Grade>> Clé = evaluationId (string)
*/
public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array;
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
interface StudentAverageRepository
{
public function saveSubjectAverage(
TenantId $tenantId,
UserId $studentId,
SubjectId $subjectId,
string $periodId,
float $average,
int $gradeCount,
): void;
public function saveGeneralAverage(
TenantId $tenantId,
UserId $studentId,
string $periodId,
float $average,
): void;
/** @return list<float> Moyennes matières d'un élève pour une période */
public function findSubjectAveragesForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): array;
/** @return list<array{subjectId: string, subjectName: string|null, average: float, gradeCount: int}> */
public function findDetailedSubjectAveragesForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): array;
public function findGeneralAverageForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): ?float;
public function deleteSubjectAverage(
UserId $studentId,
SubjectId $subjectId,
string $periodId,
TenantId $tenantId,
): void;
public function deleteGeneralAverage(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): void;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Competency\CompetencyEvaluationId;
use App\Scolarite\Domain\Model\Competency\CompetencyId;
use App\Scolarite\Domain\Model\Competency\StudentCompetencyResult;
use App\Shared\Domain\Tenant\TenantId;
interface StudentCompetencyResultRepository
{
public function save(StudentCompetencyResult $result): void;
public function delete(StudentCompetencyResult $result): void;
/** @return array<StudentCompetencyResult> */
public function findByCompetencyEvaluation(
CompetencyEvaluationId $competencyEvaluationId,
TenantId $tenantId,
): array;
public function findByCompetencyEvaluationAndStudent(
CompetencyEvaluationId $competencyEvaluationId,
UserId $studentId,
TenantId $tenantId,
): ?StudentCompetencyResult;
/** @return array<StudentCompetencyResult> */
public function findByStudentAndCompetency(
UserId $studentId,
CompetencyId $competencyId,
TenantId $tenantId,
): array;
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Service;
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
use function array_sum;
use function count;
use function intdiv;
use function round;
use function sort;
final class AverageCalculator
{
/**
* Moyenne matière pondérée par les coefficients, normalisée sur /20.
* Les notes absent/dispensé doivent être exclues en amont.
*
* Formule : Σ(note_sur_20 × coef) / Σ(coef)
*
* @param list<GradeEntry> $grades
*/
public function calculateSubjectAverage(array $grades): ?float
{
if ($grades === []) {
return null;
}
$sumWeighted = 0.0;
$sumCoef = 0.0;
foreach ($grades as $entry) {
$normalizedValue = $entry->gradeScale->convertTo20($entry->value);
$sumWeighted += $normalizedValue * $entry->coefficient->value;
$sumCoef += $entry->coefficient->value;
}
return round($sumWeighted / $sumCoef, 2);
}
/**
* Moyenne générale : moyenne arithmétique des moyennes matières.
* Les matières sans note sont exclues en amont.
*
* @param list<float> $subjectAverages
*/
public function calculateGeneralAverage(array $subjectAverages): ?float
{
if ($subjectAverages === []) {
return null;
}
return round(array_sum($subjectAverages) / count($subjectAverages), 2);
}
/**
* Statistiques de classe pour une évaluation : min, max, moyenne, médiane.
* Les absents et dispensés doivent être exclus en amont.
*
* @param list<float> $values
*/
public function calculateClassStatistics(array $values): ClassStatistics
{
if ($values === []) {
return new ClassStatistics(
average: null,
min: null,
max: null,
median: null,
gradedCount: 0,
);
}
sort($values);
$count = count($values);
return new ClassStatistics(
average: round(array_sum($values) / $count, 2),
min: $values[0],
max: $values[$count - 1],
median: $this->calculateMedian($values),
gradedCount: $count,
);
}
/**
* @param list<float> $sortedValues Valeurs déjà triées par ordre croissant
*/
private function calculateMedian(array $sortedValues): float
{
$count = count($sortedValues);
$middle = intdiv($count, 2);
if ($count % 2 === 0) {
return round(($sortedValues[$middle - 1] + $sortedValues[$middle]) / 2, 2);
}
return $sortedValues[$middle];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Service;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
final readonly class GradeEntry
{
public function __construct(
public float $value,
public GradeScale $gradeScale,
public Coefficient $coefficient,
) {
}
}