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,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportTeachers;
/**
* Commande pour lancer l'import d'enseignants en batch.
*
* Dispatchée de manière asynchrone via le command bus.
*/
final readonly class ImportTeachersCommand
{
public function __construct(
public string $batchId,
public string $tenantId,
public string $schoolName,
public string $academicYearId,
public bool $createMissingSubjects = false,
public bool $updateExisting = false,
) {
}
}

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

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\StudentImportField;
use function mb_strtolower;
use function sprintf;
use function trim;
/**
* Détecte les doublons parmi les lignes d'import :
* - contre les élèves existants en base (email, numéro élève, nom+prénom+classe)
* - au sein du fichier lui-même (lignes identiques).
*/
final readonly class DuplicateDetector
{
/**
* @param list<ImportRow> $rows
* @param list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}> $existingStudents
*
* @return list<ImportRow>
*/
public function detecter(array $rows, array $existingStudents): array
{
$byEmail = [];
$byStudentNumber = [];
$byNameClass = [];
foreach ($existingStudents as $student) {
if ($student['email'] !== null && trim($student['email']) !== '') {
$byEmail[mb_strtolower(trim($student['email']))] = true;
}
if ($student['studentNumber'] !== null && trim($student['studentNumber']) !== '') {
$byStudentNumber[trim($student['studentNumber'])] = true;
}
$key = $this->nameClassKey(
$student['firstName'],
$student['lastName'],
$student['className'] ?? '',
);
if ($key !== null) {
$byNameClass[$key] = true;
}
}
$result = [];
foreach ($rows as $row) {
$match = $this->findMatch($row, $byEmail, $byStudentNumber, $byNameClass);
if ($match !== null) {
$row = $row->avecErreurs(new ImportRowError(
'_duplicate',
sprintf('Cet élève existe déjà (correspondance : %s).', $match),
));
} else {
$this->indexRow($row, $byEmail, $byStudentNumber, $byNameClass);
}
$result[] = $row;
}
return $result;
}
/**
* @param array<string, true> $byEmail
* @param array<string, true> $byStudentNumber
* @param array<string, true> $byNameClass
*/
private function findMatch(
ImportRow $row,
array $byEmail,
array $byStudentNumber,
array $byNameClass,
): ?string {
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email !== null && trim($email) !== '') {
$normalized = mb_strtolower(trim($email));
if (isset($byEmail[$normalized])) {
return 'email';
}
}
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
if ($studentNumber !== null && trim($studentNumber) !== '') {
if (isset($byStudentNumber[trim($studentNumber)])) {
return 'numéro élève';
}
}
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
if ($key !== null && isset($byNameClass[$key])) {
return 'nom + classe';
}
return null;
}
/**
* @param array<string, true> $byEmail
* @param array<string, true> $byStudentNumber
* @param array<string, true> $byNameClass
*/
private function indexRow(
ImportRow $row,
array &$byEmail,
array &$byStudentNumber,
array &$byNameClass,
): void {
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email !== null && trim($email) !== '') {
$byEmail[mb_strtolower(trim($email))] = true;
}
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
if ($studentNumber !== null && trim($studentNumber) !== '') {
$byStudentNumber[trim($studentNumber)] = true;
}
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
if ($key !== null) {
$byNameClass[$key] = true;
}
}
private function nameClassKey(string $firstName, string $lastName, string $className): ?string
{
$first = mb_strtolower(trim($firstName));
$last = mb_strtolower(trim($lastName));
$class = mb_strtolower(trim($className));
if ($first === '' || $last === '' || $class === '') {
return null;
}
return $last . '|' . $first . '|' . $class;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
/**
* Charge les élèves existants d'un tenant pour une année scolaire,
* avec leur affectation de classe, afin de détecter les doublons à l'import.
*/
final readonly class ExistingStudentFinder
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}>
*/
public function findAllForTenant(TenantId $tenantId, AcademicYearId $academicYearId): array
{
$sql = <<<'SQL'
SELECT u.first_name, u.last_name, u.email, u.student_number, sc.name AS class_name
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE u.tenant_id = :tenant_id
AND u.roles::jsonb @> :role
SQL;
/** @var list<array{first_name: string, last_name: string, email: ?string, student_number: ?string, class_name: ?string}> $rows */
$rows = $this->connection->fetchAllAssociative($sql, [
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
'role' => '"ROLE_ELEVE"',
]);
return array_map(
static fn (array $row) => [
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'email' => $row['email'],
'studentNumber' => $row['student_number'],
'className' => $row['class_name'],
],
$rows,
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
/**
* Charge les enseignants existants d'un tenant
* afin de détecter les doublons à l'import.
*/
final readonly class ExistingTeacherFinder
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return list<array{firstName: string, lastName: string, email: string}>
*/
public function findAllForTenant(TenantId $tenantId): array
{
$sql = <<<'SQL'
SELECT u.first_name, u.last_name, u.email
FROM users u
WHERE u.tenant_id = :tenant_id
AND u.roles::jsonb @> :role
SQL;
/** @var list<array{first_name: string, last_name: string, email: string}> $rows */
$rows = $this->connection->fetchAllAssociative($sql, [
'tenant_id' => (string) $tenantId,
'role' => '"ROLE_PROF"',
]);
return array_map(
static fn (array $row) => [
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'email' => $row['email'],
],
$rows,
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use function array_filter;
use function array_map;
use function array_values;
use function explode;
use function trim;
/**
* Parse une valeur multi-éléments séparés par un délimiteur.
*
* Utilisé pour les champs matières et classes dans l'import enseignants
* où une cellule CSV peut contenir "Mathématiques, Physique".
*
* @see FR77: Import enseignants via CSV
*/
final readonly class MultiValueParser
{
/**
* @param non-empty-string $separator
*
* @return list<string>
*/
public function parse(string $value, string $separator = ','): array
{
if (trim($value) === '') {
return [];
}
return array_values(array_filter(
array_map(
static fn (string $item): string => trim($item),
explode($separator, $value),
),
static fn (string $item): bool => $item !== '',
));
}
}

View File

@@ -9,12 +9,14 @@ use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use function in_array;
use InvalidArgumentException;
@@ -34,6 +36,8 @@ final readonly class StudentImportOrchestrator
private ClassRepository $classRepository,
private ImportBatchRepository $importBatchRepository,
private SavedColumnMappingRepository $savedMappingRepository,
private ExistingStudentFinder $existingStudentFinder,
private DuplicateDetector $duplicateDetector,
private Clock $clock,
) {
}
@@ -97,12 +101,18 @@ final readonly class StudentImportOrchestrator
*
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
*/
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId): array
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId, ?AcademicYearId $academicYearId = null): array
{
$existingClasses = $this->getExistingClassNames($tenantId);
$validator = new ImportRowValidator($existingClasses);
$validatedRows = $validator->validerTout($batch->lignes());
if ($academicYearId !== null) {
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingStudents);
}
$batch->enregistrerLignes($validatedRows);
$this->importBatchRepository->save($batch);
@@ -122,15 +132,31 @@ final readonly class StudentImportOrchestrator
*
* Quand createMissingClasses est activé, les erreurs de classe inconnue
* sont retirées en re-validant sans vérification de classe.
* La détection de doublons est ré-appliquée après re-validation
* pour ne pas perdre les erreurs _duplicate.
*/
public function prepareForConfirmation(
StudentImportBatch $batch,
bool $createMissingClasses,
bool $importValidOnly,
TenantId $tenantId,
?AcademicYearId $academicYearId = null,
): void {
if ($createMissingClasses) {
$validator = new ImportRowValidator();
$revalidated = $validator->validerTout($batch->lignes());
// Strip old errors before re-validating — the previous validation
// may have added className errors that we no longer want.
$cleanRows = array_map(
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
$batch->lignes(),
);
$revalidated = $validator->validerTout($cleanRows);
if ($academicYearId !== null) {
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingStudents);
}
$batch->enregistrerLignes($revalidated);
}
@@ -169,8 +195,10 @@ final readonly class StudentImportOrchestrator
}
/**
* Vérifie que les colonnes du mapping sauvegardé correspondent
* aux colonnes détectées dans le fichier.
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
*
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
* mais ne le sont pas par le mapping sauvegardé.
*
* @param array<string, StudentImportField> $mapping
* @param list<string> $columns
@@ -183,6 +211,11 @@ final readonly class StudentImportOrchestrator
}
}
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
if (count($autoMapping) > count($mapping)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use function in_array;
/**
* Suggère un mapping automatique des colonnes pour l'import enseignants.
*
* @see AC2: Mapping spécifique enseignants
*/
final readonly class TeacherColumnMappingSuggester
{
/**
* Mapping générique par mots-clés pour les enseignants.
*
* @var array<string, TeacherImportField>
*/
private const array GENERIC_KEYWORDS = [
'nom' => TeacherImportField::LAST_NAME,
'last' => TeacherImportField::LAST_NAME,
'family' => TeacherImportField::LAST_NAME,
'surname' => TeacherImportField::LAST_NAME,
'prénom' => TeacherImportField::FIRST_NAME,
'prenom' => TeacherImportField::FIRST_NAME,
'first' => TeacherImportField::FIRST_NAME,
'given' => TeacherImportField::FIRST_NAME,
'email' => TeacherImportField::EMAIL,
'mail' => TeacherImportField::EMAIL,
'courriel' => TeacherImportField::EMAIL,
'matière' => TeacherImportField::SUBJECTS,
'matiere' => TeacherImportField::SUBJECTS,
'matières' => TeacherImportField::SUBJECTS,
'matieres' => TeacherImportField::SUBJECTS,
'subject' => TeacherImportField::SUBJECTS,
'discipline' => TeacherImportField::SUBJECTS,
'classe' => TeacherImportField::CLASSES,
'classes' => TeacherImportField::CLASSES,
'class' => TeacherImportField::CLASSES,
'groupe' => TeacherImportField::CLASSES,
];
/**
* @param list<string> $columns Colonnes détectées dans le fichier
* @param KnownImportFormat $detectedFormat Format détecté
*
* @return array<string, TeacherImportField> Mapping suggéré (colonne → champ)
*/
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
{
return $this->mapperGenerique($columns);
}
/**
* @param list<string> $columns
*
* @return array<string, TeacherImportField>
*/
private function mapperGenerique(array $columns): array
{
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
$mapping[$column] = $field;
$usedFields[] = $field;
break;
}
}
}
return $mapping;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\TeacherImportField;
use function mb_strtolower;
use function trim;
/**
* Détecte les doublons parmi les lignes d'import enseignants :
* - contre les enseignants existants en base (par email)
* - au sein du fichier lui-même (lignes avec le même email).
*/
final readonly class TeacherDuplicateDetector
{
/**
* @param list<ImportRow> $rows
* @param list<array{firstName: string, lastName: string, email: string}> $existingTeachers
*
* @return list<ImportRow>
*/
public function detecter(array $rows, array $existingTeachers): array
{
/** @var array<string, true> $byEmail */
$byEmail = [];
foreach ($existingTeachers as $teacher) {
if (trim($teacher['email']) !== '') {
$byEmail[mb_strtolower(trim($teacher['email']))] = true;
}
}
$result = [];
foreach ($rows as $row) {
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
if ($email !== null && trim($email) !== '') {
$normalized = mb_strtolower(trim($email));
if (isset($byEmail[$normalized])) {
$row = $row->avecErreurs(new ImportRowError(
'_duplicate',
'Cet enseignant existe déjà (correspondance : email).',
));
} else {
$byEmail[$normalized] = true;
}
}
$result[] = $row;
}
return $result;
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use function in_array;
use InvalidArgumentException;
use function trim;
/**
* Orchestre la chaîne d'import d'enseignants : parse → détection → mapping → validation.
*
* @see FR77: Import enseignants via CSV
*/
final readonly class TeacherImportOrchestrator
{
public function __construct(
private CsvParser $csvParser,
private XlsxParser $xlsxParser,
private ImportFormatDetector $formatDetector,
private TeacherColumnMappingSuggester $mappingSuggester,
private SubjectRepository $subjectRepository,
private ClassRepository $classRepository,
private TeacherImportBatchRepository $teacherImportBatchRepository,
private SavedTeacherColumnMappingRepository $savedMappingRepository,
private ExistingTeacherFinder $existingTeacherFinder,
private TeacherDuplicateDetector $duplicateDetector,
private Clock $clock,
) {
}
/**
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
* crée le batch et enregistre les lignes mappées.
*
* @return array{batch: TeacherImportBatch, suggestedMapping: array<string, TeacherImportField>}
*/
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
{
$parseResult = match ($extension) {
'csv', 'txt' => $this->csvParser->parse($filePath),
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
};
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
$batch = TeacherImportBatch::creer(
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $parseResult->totalRows(),
detectedColumns: $parseResult->columns,
detectedFormat: $detectedFormat,
createdAt: $this->clock->now(),
);
$rows = $this->mapRows($parseResult, $suggestedMapping);
$batch->enregistrerLignes($rows);
$this->teacherImportBatchRepository->save($batch);
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
}
/**
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
*/
public function applyMapping(TeacherImportBatch $batch, TeacherColumnMapping $columnMapping): void
{
$batch->appliquerMapping($columnMapping);
$remapped = [];
foreach ($batch->lignes() as $row) {
$mappedData = [];
foreach ($columnMapping->mapping as $column => $field) {
$mappedData[$field->value] = $row->rawData[$column] ?? '';
}
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
}
$batch->enregistrerLignes($remapped);
$this->teacherImportBatchRepository->save($batch);
}
/**
* Valide les lignes du batch et retourne les résultats avec les matières et classes inconnues.
*
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownSubjects: list<string>, unknownClasses: list<string>}
*/
public function generatePreview(TeacherImportBatch $batch, TenantId $tenantId): array
{
$existingSubjects = $this->getExistingSubjectNames($tenantId);
$existingClasses = $this->getExistingClassNames($tenantId);
$validator = new TeacherImportRowValidator($existingSubjects, $existingClasses);
$validatedRows = $validator->validerTout($batch->lignes());
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingTeachers);
$batch->enregistrerLignes($validatedRows);
$this->teacherImportBatchRepository->save($batch);
$report = ImportReport::fromValidatedRows($validatedRows);
$unknownSubjects = $this->detectUnknownValues($validatedRows, TeacherImportField::SUBJECTS, $existingSubjects);
$unknownClasses = $this->detectUnknownValues($validatedRows, TeacherImportField::CLASSES, $existingClasses);
return [
'validatedRows' => $validatedRows,
'report' => $report,
'unknownSubjects' => $unknownSubjects,
'unknownClasses' => $unknownClasses,
];
}
/**
* Prépare le batch pour la confirmation : re-valide si nécessaire
* et filtre les lignes selon les options choisies par l'utilisateur.
*
* La détection de doublons est ré-appliquée après re-validation
* pour ne pas perdre les erreurs _duplicate.
*/
public function prepareForConfirmation(
TeacherImportBatch $batch,
bool $createMissingSubjects,
bool $importValidOnly,
TenantId $tenantId,
): void {
if ($createMissingSubjects) {
$validator = new TeacherImportRowValidator();
// Strip old errors before re-validating — the previous validation
// may have added subject/class errors that we no longer want.
$cleanRows = array_map(
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
$batch->lignes(),
);
$revalidated = $validator->validerTout($cleanRows);
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingTeachers);
$batch->enregistrerLignes($revalidated);
}
if ($importValidOnly) {
$batch->enregistrerLignes($batch->lignesValides());
}
if ($batch->mapping !== null) {
$this->savedMappingRepository->save(
$batch->tenantId,
$batch->mapping->format,
$batch->mapping->mapping,
);
}
$this->teacherImportBatchRepository->save($batch);
}
/**
* @param list<string> $columns
*
* @return array<string, TeacherImportField>
*/
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
{
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
return $saved;
}
return $this->mappingSuggester->suggerer($columns, $format);
}
/**
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
*
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
* mais ne le sont pas par le mapping sauvegardé (ex: colonne « Matières » absente
* d'un mapping sauvegardé à 3 colonnes).
*
* @param array<string, TeacherImportField> $mapping
* @param list<string> $columns
*/
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
{
foreach (array_keys($mapping) as $column) {
if (!in_array($column, $columns, true)) {
return false;
}
}
// Reject saved mapping if file has more columns than the mapping covers:
// the auto-detection might map them and the user expects to see them.
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
if (count($autoMapping) > count($mapping)) {
return false;
}
return true;
}
/**
* @param array<string, TeacherImportField> $mapping
*
* @return list<ImportRow>
*/
private function mapRows(FileParseResult $parseResult, array $mapping): array
{
$rows = [];
$lineNumber = 1;
foreach ($parseResult->rows as $rawData) {
$mappedData = [];
foreach ($mapping as $column => $field) {
$mappedData[$field->value] = $rawData[$column] ?? '';
}
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
++$lineNumber;
}
return $rows;
}
/**
* @return list<string>
*/
private function getExistingSubjectNames(TenantId $tenantId): array
{
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($subject) => (string) $subject->name,
$subjects,
));
}
/**
* @return list<string>
*/
private function getExistingClassNames(TenantId $tenantId): array
{
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($class) => (string) $class->name,
$classes,
));
}
/**
* @param list<ImportRow> $rows
* @param list<string> $existingValues
*
* @return list<string>
*/
private function detectUnknownValues(array $rows, TeacherImportField $field, array $existingValues): array
{
$parser = new MultiValueParser();
$unknown = [];
foreach ($rows as $row) {
$raw = $row->mappedData[$field->value] ?? null;
if ($raw === null || trim($raw) === '') {
continue;
}
foreach ($parser->parse($raw) as $value) {
if (!in_array($value, $existingValues, true)
&& !in_array($value, $unknown, true)
) {
$unknown[] = $value;
}
}
}
return $unknown;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\TeacherImportField;
use const FILTER_VALIDATE_EMAIL;
use function in_array;
use function sprintf;
use function trim;
/**
* Valide les lignes d'import enseignants après mapping.
*
* Vérifie les champs obligatoires (nom, prénom, email),
* le format email, et l'existence des matières/classes référencées.
*
* @see AC2: Mapping spécifique enseignants
* @see AC3: Gestion matières inexistantes
* @see AC5: Gestion doublons email
*/
final readonly class TeacherImportRowValidator
{
private MultiValueParser $multiValueParser;
/**
* @param list<string>|null $existingSubjectNames Noms des matières existantes. null = pas de vérification.
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
*/
public function __construct(
private ?array $existingSubjectNames = null,
private ?array $existingClassNames = null,
) {
$this->multiValueParser = new MultiValueParser();
}
public function valider(ImportRow $row): ImportRow
{
$errors = [];
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
$errors = [...$errors, ...$this->validerEmail($row)];
$errors = [...$errors, ...$this->validerMatieres($row)];
$errors = [...$errors, ...$this->validerClasses($row)];
if ($errors !== []) {
return $row->avecErreurs(...$errors);
}
return $row;
}
/**
* @param list<ImportRow> $rows
*
* @return list<ImportRow>
*/
public function validerTout(array $rows): array
{
return array_map($this->valider(...), $rows);
}
/**
* @return list<ImportRowError>
*/
private function validerChampsObligatoires(ImportRow $row): array
{
$errors = [];
foreach (TeacherImportField::champsObligatoires() as $field) {
$value = $row->mappedData[$field->value] ?? null;
if ($value === null || trim($value) === '') {
$errors[] = new ImportRowError(
$field->value,
sprintf('Le champ "%s" est obligatoire.', $field->label()),
);
}
}
return $errors;
}
/**
* @return list<ImportRowError>
*/
private function validerEmail(ImportRow $row): array
{
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
if ($email === null || trim($email) === '') {
return [];
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return [new ImportRowError(
TeacherImportField::EMAIL->value,
sprintf('L\'adresse email "%s" est invalide.', $email),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerMatieres(ImportRow $row): array
{
if ($this->existingSubjectNames === null) {
return [];
}
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? null;
if ($subjectsRaw === null || trim($subjectsRaw) === '') {
return [];
}
$subjects = $this->multiValueParser->parse($subjectsRaw);
$unknown = [];
foreach ($subjects as $subject) {
if (!in_array($subject, $this->existingSubjectNames, true)) {
$unknown[] = $subject;
}
}
if ($unknown !== []) {
return [new ImportRowError(
TeacherImportField::SUBJECTS->value,
sprintf('Matière(s) inexistante(s) : %s', implode(', ', $unknown)),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerClasses(ImportRow $row): array
{
if ($this->existingClassNames === null) {
return [];
}
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? null;
if ($classesRaw === null || trim($classesRaw) === '') {
return [];
}
$classes = $this->multiValueParser->parse($classesRaw);
$unknown = [];
foreach ($classes as $class) {
if (!in_array($class, $this->existingClassNames, true)) {
$unknown[] = $class;
}
}
if ($unknown !== []) {
return [new ImportRowError(
TeacherImportField::CLASSES->value,
sprintf('Classe(s) inexistante(s) : %s', implode(', ', $unknown)),
)];
}
return [];
}
}