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 aedde6707e
694 changed files with 109792 additions and 75 deletions

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Console;
use App\Scolarite\Application\Service\RecalculerMoyennesService;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function count;
use Override;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
/**
* Recalcule toutes les projections de moyennes à partir des données existantes.
*
* Usage:
* php bin/console app:recalculer-moyennes # Tous les tenants
* php bin/console app:recalculer-moyennes --tenant=UUID # Un seul tenant
*/
#[AsCommand(
name: 'app:recalculer-moyennes',
description: 'Recalcule les statistiques évaluations et moyennes élèves depuis les notes publiées',
)]
final class RecalculerToutesMoyennesCommand extends Command
{
public function __construct(
private readonly EvaluationRepository $evaluationRepository,
private readonly TenantRegistry $tenantRegistry,
private readonly TenantContext $tenantContext,
private readonly TenantDatabaseSwitcher $databaseSwitcher,
private readonly RecalculerMoyennesService $service,
) {
parent::__construct();
}
#[Override]
protected function configure(): void
{
$this->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Limiter à un tenant spécifique (UUID)');
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Recalcul des moyennes et statistiques');
/** @var string|null $tenantOption */
$tenantOption = $input->getOption('tenant');
if ($tenantOption !== null) {
$configs = [$this->tenantRegistry->getConfig(TenantId::fromString($tenantOption))];
} else {
$configs = $this->tenantRegistry->getAllConfigs();
}
$totalEvals = 0;
$totalErrors = 0;
foreach ($configs as $config) {
[$processed, $errors] = $this->processTenant($config, $io);
$totalEvals += $processed;
$totalErrors += $errors;
}
if ($totalErrors > 0) {
$io->warning(sprintf(
'%d évaluation(s) traitée(s), %d erreur(s).',
$totalEvals,
$totalErrors,
));
return Command::FAILURE;
}
$io->success(sprintf('%d évaluation(s) traitée(s) avec succès.', $totalEvals));
return Command::SUCCESS;
}
/**
* @return array{int, int} [processed, errors]
*/
private function processTenant(TenantConfig $config, SymfonyStyle $io): array
{
$this->tenantContext->setCurrentTenant($config);
$this->databaseSwitcher->useTenantDatabase($config->databaseUrl);
$tenantId = \App\Shared\Domain\Tenant\TenantId::fromString((string) $config->tenantId);
$evaluations = $this->evaluationRepository->findAllWithPublishedGrades($tenantId);
if ($evaluations === []) {
$io->text(sprintf(' Tenant %s : aucune évaluation publiée.', $config->subdomain));
return [0, 0];
}
$io->text(sprintf(' Tenant %s : %d évaluation(s) publiée(s)', $config->subdomain, count($evaluations)));
$processed = 0;
$errors = 0;
foreach ($evaluations as $evaluation) {
try {
$this->service->recalculerStatistiquesEvaluation($evaluation->id, $tenantId);
$this->service->recalculerTousElevesPourEvaluation($evaluation->id, $tenantId);
++$processed;
} catch (Throwable $e) {
$io->error(sprintf(' Erreur évaluation %s : %s', $evaluation->id, $e->getMessage()));
++$errors;
}
}
$io->text(sprintf(' → %d traitée(s), %d erreur(s)', $processed, $errors));
return [$processed, $errors];
}
}