feat: Permettre l'import d'enseignants via fichier CSV ou XLSX

L'établissement a besoin d'importer en masse ses enseignants depuis les
exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est
déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping
→ aperçu → import) réutilise l'architecture de l'import élèves tout en
ajoutant la gestion des matières et des classes enseignées.

Corrections de la review #2 intégrées :
- La commande ImportTeachersCommand est routée en async via Messenger
  pour ne pas bloquer la requête HTTP sur les gros fichiers.
- Le handler est protégé par un try/catch Throwable pour marquer le
  batch en échec si une erreur inattendue survient, évitant qu'il
  reste bloqué en statut "processing".
- Les domain events (UtilisateurInvite) sont dispatchés sur l'event
  bus après chaque création d'utilisateur, déclenchant l'envoi des
  emails d'invitation.
- L'option "mettre à jour les enseignants existants" (AC5) permet de
  choisir entre ignorer ou mettre à jour nom/prénom et ajouter les
  affectations manquantes pour les doublons détectés par email.
This commit is contained in:
2026-02-27 01:49:01 +01:00
parent f2f57bb999
commit de5880e25e
52 changed files with 7462 additions and 47 deletions

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportTeachers;
use App\Administration\Application\Service\Import\MultiValueParser;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use DomainException;
use function in_array;
use function mb_strlen;
use Psr\Log\LoggerInterface;
use function sprintf;
use function strtoupper;
use function substr;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Throwable;
use function trim;
/**
* Handler pour l'import d'enseignants en batch.
*
* Traite les lignes valides du batch, crée les enseignants et les affecte
* aux matières/classes via TeacherAssignment.
*
* @see AC4: Import validé → enseignants créés
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ImportTeachersHandler
{
private MultiValueParser $multiValueParser;
public function __construct(
private TeacherImportBatchRepository $teacherImportBatchRepository,
private UserRepository $userRepository,
private SubjectRepository $subjectRepository,
private ClassRepository $classRepository,
private TeacherAssignmentRepository $teacherAssignmentRepository,
private SchoolIdResolver $schoolIdResolver,
private Connection $connection,
private Clock $clock,
private LoggerInterface $logger,
private MessageBusInterface $eventBus,
) {
$this->multiValueParser = new MultiValueParser();
}
public function __invoke(ImportTeachersCommand $command): void
{
$batchId = ImportBatchId::fromString($command->batchId);
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($command->tenantId));
$now = $this->clock->now();
$batch = $this->teacherImportBatchRepository->get($batchId);
$batch->demarrer($now);
$this->teacherImportBatchRepository->save($batch);
$lignes = $batch->lignes();
$importedCount = 0;
$errorCount = 0;
$processedCount = 0;
try {
/** @var array<string, SubjectId> $subjectCache */
$subjectCache = [];
/** @var list<string> $existingSubjectCodes */
$existingSubjectCodes = [];
/** @var list<string> $newlyCreatedCodes */
$newlyCreatedCodes = [];
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
$subjectCache[(string) $subject->name] = $subject->id;
$existingSubjectCodes[] = (string) $subject->code;
}
/** @var array<string, ClassId> */
$classCache = [];
foreach ($lignes as $row) {
try {
$firstName = trim($row->mappedData[TeacherImportField::FIRST_NAME->value] ?? '');
$lastName = trim($row->mappedData[TeacherImportField::LAST_NAME->value] ?? '');
$emailRaw = trim($row->mappedData[TeacherImportField::EMAIL->value] ?? '');
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? '';
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? '';
$emailVO = new Email($emailRaw);
$existingUser = $this->userRepository->findByEmail($emailVO, $tenantId);
if ($existingUser !== null && !$command->updateExisting) {
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', $emailRaw));
}
$this->connection->beginTransaction();
try {
$subjects = $this->multiValueParser->parse($subjectsRaw);
$classes = $this->multiValueParser->parse($classesRaw);
$resolvedSubjectIds = $this->resolveSubjectIds(
$subjects,
$tenantId,
$schoolId,
$command->createMissingSubjects,
$now,
$subjectCache,
$existingSubjectCodes,
$newlyCreatedCodes,
);
$resolvedClassIds = $this->resolveClassIds(
$classes,
$tenantId,
$academicYearId,
$classCache,
);
if ($existingUser !== null) {
$existingUser->mettreAJourInfos($firstName, $lastName);
$this->userRepository->save($existingUser);
$this->addMissingAssignments(
$existingUser,
$resolvedSubjectIds,
$resolvedClassIds,
$tenantId,
$academicYearId,
$now,
);
$this->connection->commit();
} else {
$user = User::inviter(
email: $emailVO,
role: Role::PROF,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
invitedAt: $now,
);
$this->userRepository->save($user);
foreach ($resolvedSubjectIds as $subjectId) {
foreach ($resolvedClassIds as $classId) {
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $user->id,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->teacherAssignmentRepository->save($assignment);
}
}
$this->connection->commit();
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
++$importedCount;
} catch (DomainException $e) {
$this->logger->warning('Import enseignant ligne {line} échouée : {message}', [
'line' => $row->lineNumber,
'message' => $e->getMessage(),
'batch_id' => $command->batchId,
]);
++$errorCount;
}
++$processedCount;
if ($processedCount % 50 === 0) {
$batch->mettreAJourProgression($importedCount, $errorCount);
$this->teacherImportBatchRepository->save($batch);
}
}
$batch->terminer($importedCount, $errorCount, $this->clock->now());
$this->teacherImportBatchRepository->save($batch);
} catch (Throwable $e) {
$batch->echouer($errorCount, $this->clock->now());
$this->teacherImportBatchRepository->save($batch);
throw $e;
}
}
/**
* Ajoute les affectations manquantes pour un enseignant existant.
*
* @param list<SubjectId> $subjectIds
* @param list<ClassId> $classIds
*/
private function addMissingAssignments(
User $teacher,
array $subjectIds,
array $classIds,
TenantId $tenantId,
AcademicYearId $academicYearId,
DateTimeImmutable $now,
): void {
foreach ($subjectIds as $subjectId) {
foreach ($classIds as $classId) {
$existing = $this->teacherAssignmentRepository->findByTeacherClassSubject(
$teacher->id,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($existing !== null) {
continue;
}
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher->id,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->teacherAssignmentRepository->save($assignment);
}
}
}
/**
* @param list<string> $subjectNames
* @param array<string, SubjectId> $cache
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*
* @return list<SubjectId>
*/
private function resolveSubjectIds(
array $subjectNames,
TenantId $tenantId,
SchoolId $schoolId,
bool $createMissing,
DateTimeImmutable $now,
array &$cache,
array &$existingCodes,
array &$newlyCreatedCodes,
): array {
$ids = [];
foreach ($subjectNames as $name) {
if (isset($cache[$name])) {
$ids[] = $cache[$name];
continue;
}
if ($createMissing) {
$subjectId = $this->createSubject($name, $tenantId, $schoolId, $now, $existingCodes, $newlyCreatedCodes);
$cache[$name] = $subjectId;
$ids[] = $subjectId;
}
}
return $ids;
}
/**
* @param list<string> $classNames
* @param array<string, ClassId> $cache
*
* @return list<ClassId>
*/
private function resolveClassIds(
array $classNames,
TenantId $tenantId,
AcademicYearId $academicYearId,
array &$cache,
): array {
$ids = [];
foreach ($classNames as $name) {
if (isset($cache[$name])) {
$ids[] = $cache[$name];
continue;
}
$classNameVO = new ClassName($name);
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
if ($class !== null) {
$cache[$name] = $class->id;
$ids[] = $class->id;
}
}
return $ids;
}
/**
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*/
private function createSubject(
string $name,
TenantId $tenantId,
SchoolId $schoolId,
DateTimeImmutable $now,
array &$existingCodes,
array &$newlyCreatedCodes,
): SubjectId {
if (trim($name) === '') {
throw new DomainException('Le nom de la matière ne peut pas être vide.');
}
$code = $this->generateUniqueSubjectCode($name, $existingCodes, $newlyCreatedCodes);
if ($code === '') {
throw new DomainException(sprintf('Impossible de générer un code pour la matière "%s".', $name));
}
$subject = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName($name),
code: new SubjectCode($code),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject);
$newlyCreatedCodes[] = $code;
return $subject->id;
}
/**
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*/
private function generateUniqueSubjectCode(string $name, array $existingCodes, array $newlyCreatedCodes): string
{
$base = strtoupper(substr(trim($name), 0, 4));
if (mb_strlen($base) < 2) {
$base .= 'XX';
}
$allCodes = [...$existingCodes, ...$newlyCreatedCodes];
if (!in_array($base, $allCodes, true)) {
return $base;
}
for ($i = 2; $i <= 99; ++$i) {
$candidate = $base . $i;
if (!in_array($candidate, $allCodes, true)) {
return $candidate;
}
}
return $base;
}
}