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.
281 lines
10 KiB
PHP
281 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Console;
|
|
|
|
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\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
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\Console\RecalculerToutesMoyennesCommand;
|
|
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\TenantDatabaseSwitcher;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
|
use DateTimeImmutable;
|
|
use Override;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
|
|
final class RecalculerToutesMoyennesCommandTest 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_ID = '22222222-2222-2222-2222-222222222222';
|
|
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 TenantConfig $tenantConfig;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->evaluationRepo = new InMemoryEvaluationRepository();
|
|
$this->gradeRepo = new InMemoryGradeRepository();
|
|
$this->evalStatsRepo = new InMemoryEvaluationStatisticsRepository();
|
|
$this->studentAvgRepo = new InMemoryStudentAverageRepository();
|
|
|
|
$this->tenantConfig = new TenantConfig(
|
|
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
|
subdomain: 'ecole-test',
|
|
databaseUrl: 'postgresql://test',
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itBackfillsStatisticsAndAveragesForPublishedEvaluations(): void
|
|
{
|
|
$this->seedPublishedEvaluationWithGrades();
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode(), $tester->getDisplay());
|
|
self::assertStringContainsString('1 évaluation(s) publiée(s)', $tester->getDisplay());
|
|
self::assertStringContainsString('1 évaluation(s) traitée(s) avec succès', $tester->getDisplay());
|
|
|
|
// Vérifier que les stats évaluation sont créées
|
|
$evaluations = $this->evaluationRepo->findAllWithPublishedGrades(TenantId::fromString(self::TENANT_ID));
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluations[0]->id);
|
|
|
|
self::assertNotNull($stats);
|
|
self::assertSame(2, $stats->gradedCount);
|
|
|
|
// Vérifier que la moyenne matière est créée
|
|
$subjectAvg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_ID),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertNotNull($subjectAvg);
|
|
self::assertSame(14.0, $subjectAvg['average']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itReportsSuccessWhenNoPublishedEvaluations(): void
|
|
{
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('aucune évaluation publiée', $tester->getDisplay());
|
|
self::assertStringContainsString('0 évaluation(s) traitée(s) avec succès', $tester->getDisplay());
|
|
}
|
|
|
|
#[Test]
|
|
public function itIgnoresUnpublishedEvaluations(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
// Évaluation NON publiée
|
|
$evaluation = Evaluation::creer(
|
|
tenantId: $tenantId,
|
|
classId: ClassId::fromString(self::CLASS_ID),
|
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
|
title: 'Non publiée',
|
|
description: null,
|
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient(1.0),
|
|
now: $now,
|
|
);
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('aucune évaluation publiée', $tester->getDisplay());
|
|
}
|
|
|
|
#[Test]
|
|
public function itProcessesMultipleEvaluations(): void
|
|
{
|
|
$this->seedPublishedEvaluationWithGrades();
|
|
$this->seedPublishedEvaluationWithGrades(coefficient: 2.0);
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('2 évaluation(s) publiée(s)', $tester->getDisplay());
|
|
self::assertStringContainsString('2 évaluation(s) traitée(s) avec succès', $tester->getDisplay());
|
|
}
|
|
|
|
private function seedPublishedEvaluationWithGrades(float $coefficient = 1.0): void
|
|
{
|
|
$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: 'Contrôle',
|
|
description: null,
|
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient($coefficient),
|
|
now: $now,
|
|
);
|
|
$evaluation->publierNotes($now);
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
$grade1 = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluation->id,
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: new GradeValue(14.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: $now,
|
|
);
|
|
$grade1->pullDomainEvents();
|
|
$this->gradeRepo->save($grade1);
|
|
|
|
$grade2 = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluation->id,
|
|
studentId: UserId::fromString('33333333-3333-3333-3333-333333333333'),
|
|
value: new GradeValue(10.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: $now,
|
|
);
|
|
$grade2->pullDomainEvents();
|
|
$this->gradeRepo->save($grade2);
|
|
}
|
|
|
|
private function executeCommand(): CommandTester
|
|
{
|
|
$tenantContext = new TenantContext();
|
|
|
|
$periodFinder = new class implements PeriodFinder {
|
|
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
|
{
|
|
return new PeriodInfo(
|
|
periodId: RecalculerToutesMoyennesCommandTest::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(),
|
|
);
|
|
|
|
$tenantRegistry = new class($this->tenantConfig) implements TenantRegistry {
|
|
public function __construct(private readonly TenantConfig $config)
|
|
{
|
|
}
|
|
|
|
#[Override]
|
|
public function getConfig(InfraTenantId $tenantId): TenantConfig
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
#[Override]
|
|
public function getBySubdomain(string $subdomain): TenantConfig
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
#[Override]
|
|
public function exists(string $subdomain): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
#[Override]
|
|
public function getAllConfigs(): array
|
|
{
|
|
return [$this->config];
|
|
}
|
|
};
|
|
|
|
$databaseSwitcher = new class implements TenantDatabaseSwitcher {
|
|
#[Override]
|
|
public function useTenantDatabase(string $databaseUrl): void
|
|
{
|
|
}
|
|
|
|
#[Override]
|
|
public function useDefaultDatabase(): void
|
|
{
|
|
}
|
|
|
|
#[Override]
|
|
public function currentDatabaseUrl(): ?string
|
|
{
|
|
return null;
|
|
}
|
|
};
|
|
|
|
$command = new RecalculerToutesMoyennesCommand(
|
|
evaluationRepository: $this->evaluationRepo,
|
|
tenantRegistry: $tenantRegistry,
|
|
tenantContext: $tenantContext,
|
|
databaseSwitcher: $databaseSwitcher,
|
|
service: $service,
|
|
);
|
|
|
|
$tester = new CommandTester($command);
|
|
$tester->execute([]);
|
|
|
|
return $tester;
|
|
}
|
|
}
|