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.
343 lines
12 KiB
PHP
343 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Infrastructure\EventHandler;
|
|
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Scolarite\Application\Port\PeriodFinder;
|
|
use App\Scolarite\Application\Port\PeriodInfo;
|
|
use App\Scolarite\Application\Service\RecalculerMoyennesService;
|
|
use App\Scolarite\Domain\Event\EvaluationSupprimee;
|
|
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
|
use App\Scolarite\Infrastructure\EventHandler\RecalculerMoyennesOnEvaluationSupprimeeHandler;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryStudentAverageRepository;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class RecalculerMoyennesOnEvaluationSupprimeeHandlerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
public const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
|
private const string STUDENT_1 = '22222222-2222-2222-2222-222222222222';
|
|
private const string STUDENT_2 = '33333333-3333-3333-3333-333333333333';
|
|
private const string TEACHER_ID = '44444444-4444-4444-4444-444444444444';
|
|
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
|
|
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
|
|
|
|
private InMemoryEvaluationRepository $evaluationRepo;
|
|
private InMemoryGradeRepository $gradeRepo;
|
|
private InMemoryEvaluationStatisticsRepository $evalStatsRepo;
|
|
private InMemoryStudentAverageRepository $studentAvgRepo;
|
|
private RecalculerMoyennesOnEvaluationSupprimeeHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->evaluationRepo = new InMemoryEvaluationRepository();
|
|
$this->gradeRepo = new InMemoryGradeRepository();
|
|
$this->evalStatsRepo = new InMemoryEvaluationStatisticsRepository();
|
|
$this->studentAvgRepo = new InMemoryStudentAverageRepository();
|
|
|
|
$tenantContext = new TenantContext();
|
|
$tenantContext->setCurrentTenant(new TenantConfig(
|
|
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
|
subdomain: 'test',
|
|
databaseUrl: 'postgresql://test',
|
|
));
|
|
|
|
$periodFinder = new class implements PeriodFinder {
|
|
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
|
{
|
|
return new PeriodInfo(
|
|
periodId: RecalculerMoyennesOnEvaluationSupprimeeHandlerTest::PERIOD_ID,
|
|
startDate: new DateTimeImmutable('2026-01-01'),
|
|
endDate: new DateTimeImmutable('2026-03-31'),
|
|
);
|
|
}
|
|
};
|
|
|
|
$service = new RecalculerMoyennesService(
|
|
evaluationRepository: $this->evaluationRepo,
|
|
gradeRepository: $this->gradeRepo,
|
|
evaluationStatisticsRepository: $this->evalStatsRepo,
|
|
studentAverageRepository: $this->studentAvgRepo,
|
|
periodFinder: $periodFinder,
|
|
calculator: new AverageCalculator(),
|
|
);
|
|
|
|
$this->handler = new RecalculerMoyennesOnEvaluationSupprimeeHandler(
|
|
tenantContext: $tenantContext,
|
|
evaluationRepository: $this->evaluationRepo,
|
|
gradeRepository: $this->gradeRepo,
|
|
evaluationStatisticsRepository: $this->evalStatsRepo,
|
|
periodFinder: $periodFinder,
|
|
service: $service,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeletesEvaluationStatisticsOnDeletion(): void
|
|
{
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
[self::STUDENT_2, 10.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
// Pré-remplir les stats
|
|
$this->evalStatsRepo->save($evaluationId, new ClassStatistics(
|
|
average: 12.0,
|
|
min: 10.0,
|
|
max: 14.0,
|
|
median: 12.0,
|
|
gradedCount: 2,
|
|
));
|
|
|
|
self::assertNotNull($this->evalStatsRepo->findByEvaluation($evaluationId));
|
|
|
|
($this->handler)(new EvaluationSupprimee(
|
|
evaluationId: $evaluationId,
|
|
occurredOn: new DateTimeImmutable(),
|
|
));
|
|
|
|
self::assertNull($this->evalStatsRepo->findByEvaluation($evaluationId));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesStudentAveragesAfterDeletion(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
|
|
// Première évaluation (sera supprimée)
|
|
$evalToDelete = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 10.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
// Deuxième évaluation (reste)
|
|
$evalRemaining = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 18.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
// Pré-remplir les moyennes (comme si les deux évaluations comptaient)
|
|
$this->studentAvgRepo->saveSubjectAverage(
|
|
$tenantId,
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
14.0, // (10+18)/2
|
|
2,
|
|
);
|
|
|
|
// Supprimer la première évaluation (status DELETED mais encore accessible)
|
|
$evaluation = $this->evaluationRepo->findById($evalToDelete, $tenantId);
|
|
$evaluation->supprimer(new DateTimeImmutable());
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
($this->handler)(new EvaluationSupprimee(
|
|
evaluationId: $evalToDelete,
|
|
occurredOn: new DateTimeImmutable(),
|
|
));
|
|
|
|
// La moyenne doit être recalculée sans l'évaluation supprimée
|
|
$subjectAvg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
$tenantId,
|
|
);
|
|
|
|
self::assertNotNull($subjectAvg);
|
|
self::assertSame(18.0, $subjectAvg['average']);
|
|
self::assertSame(1, $subjectAvg['gradeCount']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNothingWhenEvaluationNotFound(): void
|
|
{
|
|
$unknownId = EvaluationId::generate();
|
|
|
|
($this->handler)(new EvaluationSupprimee(
|
|
evaluationId: $unknownId,
|
|
occurredOn: new DateTimeImmutable(),
|
|
));
|
|
|
|
self::assertNull($this->evalStatsRepo->findByEvaluation($unknownId));
|
|
}
|
|
|
|
#[Test]
|
|
public function itOnlyDeletesStatsWhenGradesWereNotPublished(): void
|
|
{
|
|
$evaluationId = $this->seedUnpublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
// Pré-remplir des stats (cas hypothétique)
|
|
$this->evalStatsRepo->save($evaluationId, new ClassStatistics(
|
|
average: 14.0,
|
|
min: 14.0,
|
|
max: 14.0,
|
|
median: 14.0,
|
|
gradedCount: 1,
|
|
));
|
|
|
|
($this->handler)(new EvaluationSupprimee(
|
|
evaluationId: $evaluationId,
|
|
occurredOn: new DateTimeImmutable(),
|
|
));
|
|
|
|
// Stats supprimées
|
|
self::assertNull($this->evalStatsRepo->findByEvaluation($evaluationId));
|
|
|
|
// Pas de recalcul de moyennes élèves (notes non publiées)
|
|
self::assertNull($this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesGeneralAverageAfterDeletion(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
// Pré-remplir
|
|
$this->studentAvgRepo->saveSubjectAverage(
|
|
$tenantId,
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
14.0,
|
|
1,
|
|
);
|
|
$this->studentAvgRepo->saveGeneralAverage(
|
|
$tenantId,
|
|
UserId::fromString(self::STUDENT_1),
|
|
self::PERIOD_ID,
|
|
14.0,
|
|
);
|
|
|
|
// Supprimer l'évaluation
|
|
$evaluation = $this->evaluationRepo->findById($evaluationId, $tenantId);
|
|
$evaluation->supprimer(new DateTimeImmutable());
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
($this->handler)(new EvaluationSupprimee(
|
|
evaluationId: $evaluationId,
|
|
occurredOn: new DateTimeImmutable(),
|
|
));
|
|
|
|
// Plus aucune note publiée → moyennes supprimées
|
|
$generalAvg = $this->studentAvgRepo->findGeneralAverageForStudent(
|
|
UserId::fromString(self::STUDENT_1),
|
|
self::PERIOD_ID,
|
|
$tenantId,
|
|
);
|
|
|
|
self::assertNull($generalAvg);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedPublishedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
): EvaluationId {
|
|
return $this->seedEvaluationWithGrades($grades, $coefficient, published: true);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedUnpublishedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
): EvaluationId {
|
|
return $this->seedEvaluationWithGrades($grades, $coefficient, published: false);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
bool $published = true,
|
|
): EvaluationId {
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
$evaluation = Evaluation::creer(
|
|
tenantId: $tenantId,
|
|
classId: ClassId::fromString(self::CLASS_ID),
|
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
|
title: 'Test Evaluation',
|
|
description: null,
|
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient($coefficient),
|
|
now: $now,
|
|
);
|
|
|
|
if ($published) {
|
|
$evaluation->publierNotes($now);
|
|
}
|
|
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
foreach ($grades as [$studentId, $value, $status]) {
|
|
$grade = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluation->id,
|
|
studentId: UserId::fromString($studentId),
|
|
value: $value !== null ? new GradeValue($value) : null,
|
|
status: $status,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: $now,
|
|
);
|
|
$grade->pullDomainEvents();
|
|
$this->gradeRepo->save($grade);
|
|
}
|
|
|
|
return $evaluation->id;
|
|
}
|
|
}
|