From de5880e25eb5fd844786eb2b32216775ddcfbc64 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Fri, 27 Feb 2026 01:49:01 +0100 Subject: [PATCH] feat: Permettre l'import d'enseignants via fichier CSV ou XLSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/config/packages/messenger.yaml | 3 +- backend/config/services.yaml | 8 + backend/migrations/Version20260225211435.php | 53 + backend/migrations/Version20260226141803.php | 28 + .../ImportTeachers/ImportTeachersCommand.php | 23 + .../ImportTeachers/ImportTeachersHandler.php | 406 ++++ .../Service/Import/DuplicateDetector.php | 152 ++ .../Service/Import/ExistingStudentFinder.php | 54 + .../Service/Import/ExistingTeacherFinder.php | 48 + .../Service/Import/MultiValueParser.php | 42 + .../Import/StudentImportOrchestrator.php | 41 +- .../Import/TeacherColumnMappingSuggester.php | 94 + .../Import/TeacherDuplicateDetector.php | 61 + .../Import/TeacherImportOrchestrator.php | 299 +++ .../Import/TeacherImportRowValidator.php | 177 ++ .../Domain/Event/ImportEnseignantsEchoue.php | 38 + .../Domain/Event/ImportEnseignantsLance.php | 38 + .../Domain/Event/ImportEnseignantsTermine.php | 39 + .../Exception/MappingIncompletException.php | 3 +- .../Model/Import/TeacherColumnMapping.php | 62 + .../Model/Import/TeacherImportBatch.php | 225 +++ .../Model/Import/TeacherImportField.php | 57 + .../Administration/Domain/Model/User/User.php | 9 + .../SavedTeacherColumnMappingRepository.php | 22 + .../TeacherImportBatchRepository.php | 23 + .../Controller/StudentImportController.php | 17 +- .../Controller/TeacherImportController.php | 351 ++++ ...ineSavedTeacherColumnMappingRepository.php | 82 + .../DoctrineTeacherImportBatchRepository.php | 241 +++ ...orySavedTeacherColumnMappingRepository.php | 34 + .../InMemoryTeacherImportBatchRepository.php | 45 + .../ImportTeachersHandlerTest.php | 603 ++++++ .../Service/Import/DuplicateDetectorTest.php | 242 +++ .../Service/Import/MultiValueParserTest.php | 83 + .../TeacherColumnMappingSuggesterTest.php | 105 + .../Import/TeacherDuplicateDetectorTest.php | 149 ++ .../Import/TeacherImportIntegrationTest.php | 198 ++ .../Import/TeacherImportRowValidatorTest.php | 231 +++ .../Model/Import/TeacherColumnMappingTest.php | 129 ++ .../Model/Import/TeacherImportBatchTest.php | 266 +++ .../Model/Import/TeacherImportFieldTest.php | 71 + .../fixtures/import/enseignants_comma.csv | 3 + .../fixtures/import/enseignants_complet.csv | 9 + .../fixtures/import/enseignants_simple.csv | 4 + frontend/e2e/dashboard.spec.ts | 12 +- frontend/e2e/student-import.spec.ts | 116 +- frontend/e2e/teacher-import.spec.ts | 487 +++++ .../organisms/Dashboard/DashboardAdmin.svelte | 20 +- .../lib/features/import/api/teacherImport.ts | 187 ++ .../routes/admin/import/students/+page.svelte | 70 +- .../routes/admin/import/teachers/+page.svelte | 1729 +++++++++++++++++ frontend/src/routes/admin/users/+page.svelte | 20 +- 52 files changed, 7462 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/Version20260225211435.php create mode 100644 backend/migrations/Version20260226141803.php create mode 100644 backend/src/Administration/Application/Command/ImportTeachers/ImportTeachersCommand.php create mode 100644 backend/src/Administration/Application/Command/ImportTeachers/ImportTeachersHandler.php create mode 100644 backend/src/Administration/Application/Service/Import/DuplicateDetector.php create mode 100644 backend/src/Administration/Application/Service/Import/ExistingStudentFinder.php create mode 100644 backend/src/Administration/Application/Service/Import/ExistingTeacherFinder.php create mode 100644 backend/src/Administration/Application/Service/Import/MultiValueParser.php create mode 100644 backend/src/Administration/Application/Service/Import/TeacherColumnMappingSuggester.php create mode 100644 backend/src/Administration/Application/Service/Import/TeacherDuplicateDetector.php create mode 100644 backend/src/Administration/Application/Service/Import/TeacherImportOrchestrator.php create mode 100644 backend/src/Administration/Application/Service/Import/TeacherImportRowValidator.php create mode 100644 backend/src/Administration/Domain/Event/ImportEnseignantsEchoue.php create mode 100644 backend/src/Administration/Domain/Event/ImportEnseignantsLance.php create mode 100644 backend/src/Administration/Domain/Event/ImportEnseignantsTermine.php create mode 100644 backend/src/Administration/Domain/Model/Import/TeacherColumnMapping.php create mode 100644 backend/src/Administration/Domain/Model/Import/TeacherImportBatch.php create mode 100644 backend/src/Administration/Domain/Model/Import/TeacherImportField.php create mode 100644 backend/src/Administration/Domain/Repository/SavedTeacherColumnMappingRepository.php create mode 100644 backend/src/Administration/Domain/Repository/TeacherImportBatchRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Controller/TeacherImportController.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedTeacherColumnMappingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherImportBatchRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedTeacherColumnMappingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherImportBatchRepository.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ImportTeachers/ImportTeachersHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/DuplicateDetectorTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/MultiValueParserTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/TeacherColumnMappingSuggesterTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/TeacherDuplicateDetectorTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/TeacherImportIntegrationTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/TeacherImportRowValidatorTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/TeacherColumnMappingTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportBatchTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportFieldTest.php create mode 100644 backend/tests/fixtures/import/enseignants_comma.csv create mode 100644 backend/tests/fixtures/import/enseignants_complet.csv create mode 100644 backend/tests/fixtures/import/enseignants_simple.csv create mode 100644 frontend/e2e/teacher-import.spec.ts create mode 100644 frontend/src/lib/features/import/api/teacherImport.ts create mode 100644 frontend/src/routes/admin/import/teachers/+page.svelte diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 58d16ee..c0f9d43 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -52,5 +52,6 @@ framework: App\Administration\Domain\Event\MotDePasseChange: async # CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert) # ConnexionReussie, ConnexionEchouee: sync (audit-only, no email) - # Import élèves → async (batch processing, peut être long) + # Import élèves/enseignants → async (batch processing, peut être long) App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async + App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 5a89178..ea81c0d 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -217,6 +217,14 @@ services: App\Administration\Domain\Repository\SavedColumnMappingRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedColumnMappingRepository + # Teacher Import Batch Repository (Story 3.2 - Import enseignants via CSV) + App\Administration\Domain\Repository\TeacherImportBatchRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherImportBatchRepository + + # Saved Teacher Column Mapping Repository (Story 3.2 - Réutilisation des mappings enseignants) + App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedTeacherColumnMappingRepository + # Student Guardian Repository (Story 2.7 - Liaison parents-enfants) App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: arguments: diff --git a/backend/migrations/Version20260225211435.php b/backend/migrations/Version20260225211435.php new file mode 100644 index 0000000..f82465c --- /dev/null +++ b/backend/migrations/Version20260225211435.php @@ -0,0 +1,53 @@ +addSql('CREATE TABLE teacher_import_batches ( + id UUID NOT NULL, + tenant_id UUID NOT NULL, + original_filename VARCHAR(255) NOT NULL, + total_rows INT NOT NULL DEFAULT 0, + detected_columns JSONB NOT NULL DEFAULT \'[]\', + detected_format VARCHAR(50) DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT \'pending\', + mapping_data JSONB DEFAULT NULL, + imported_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + rows_data JSONB NOT NULL DEFAULT \'[]\', + created_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ DEFAULT NULL, + PRIMARY KEY (id) + )'); + + $this->addSql('CREATE INDEX idx_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)'); + $this->addSql('CREATE INDEX idx_teacher_import_batches_status ON teacher_import_batches (status)'); + + $this->addSql('CREATE TABLE saved_teacher_column_mappings ( + tenant_id UUID NOT NULL, + format VARCHAR(50) NOT NULL, + mapping_data JSONB NOT NULL, + saved_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (tenant_id, format) + )'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE saved_teacher_column_mappings'); + $this->addSql('DROP TABLE teacher_import_batches'); + } +} diff --git a/backend/migrations/Version20260226141803.php b/backend/migrations/Version20260226141803.php new file mode 100644 index 0000000..4e759e7 --- /dev/null +++ b/backend/migrations/Version20260226141803.php @@ -0,0 +1,28 @@ +addSql('CREATE INDEX idx_teacher_import_batches_tenant_created ON teacher_import_batches (tenant_id, created_at DESC)'); + $this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE INDEX idx_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)'); + $this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant_created'); + } +} diff --git a/backend/src/Administration/Application/Command/ImportTeachers/ImportTeachersCommand.php b/backend/src/Administration/Application/Command/ImportTeachers/ImportTeachersCommand.php new file mode 100644 index 0000000..f59e2af --- /dev/null +++ b/backend/src/Administration/Application/Command/ImportTeachers/ImportTeachersCommand.php @@ -0,0 +1,23 @@ +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 $subjectCache */ + $subjectCache = []; + /** @var list $existingSubjectCodes */ + $existingSubjectCodes = []; + /** @var list $newlyCreatedCodes */ + $newlyCreatedCodes = []; + + foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) { + $subjectCache[(string) $subject->name] = $subject->id; + $existingSubjectCodes[] = (string) $subject->code; + } + + /** @var array */ + $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 $subjectIds + * @param list $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 $subjectNames + * @param array $cache + * @param list $existingCodes + * @param list $newlyCreatedCodes + * + * @return list + */ + 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 $classNames + * @param array $cache + * + * @return list + */ + 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 $existingCodes + * @param list $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 $existingCodes + * @param list $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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/DuplicateDetector.php b/backend/src/Administration/Application/Service/Import/DuplicateDetector.php new file mode 100644 index 0000000..95fe9a0 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/DuplicateDetector.php @@ -0,0 +1,152 @@ + $rows + * @param list $existingStudents + * + * @return list + */ + 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 $byEmail + * @param array $byStudentNumber + * @param array $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 $byEmail + * @param array $byStudentNumber + * @param array $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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/ExistingStudentFinder.php b/backend/src/Administration/Application/Service/Import/ExistingStudentFinder.php new file mode 100644 index 0000000..8acd7e3 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ExistingStudentFinder.php @@ -0,0 +1,54 @@ + + */ + 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 $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, + ); + } +} diff --git a/backend/src/Administration/Application/Service/Import/ExistingTeacherFinder.php b/backend/src/Administration/Application/Service/Import/ExistingTeacherFinder.php new file mode 100644 index 0000000..049a306 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ExistingTeacherFinder.php @@ -0,0 +1,48 @@ + + */ + 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 $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, + ); + } +} diff --git a/backend/src/Administration/Application/Service/Import/MultiValueParser.php b/backend/src/Administration/Application/Service/Import/MultiValueParser.php new file mode 100644 index 0000000..94b58ec --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/MultiValueParser.php @@ -0,0 +1,42 @@ + + */ + 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 !== '', + )); + } +} diff --git a/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php b/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php index b4afad2..2c7504a 100644 --- a/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php +++ b/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php @@ -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, report: ImportReport, unknownClasses: list} */ - 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 $mapping * @param list $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; } diff --git a/backend/src/Administration/Application/Service/Import/TeacherColumnMappingSuggester.php b/backend/src/Administration/Application/Service/Import/TeacherColumnMappingSuggester.php new file mode 100644 index 0000000..fba2be8 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/TeacherColumnMappingSuggester.php @@ -0,0 +1,94 @@ + + */ + 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 $columns Colonnes détectées dans le fichier + * @param KnownImportFormat $detectedFormat Format détecté + * + * @return array Mapping suggéré (colonne → champ) + */ + public function suggerer(array $columns, KnownImportFormat $detectedFormat): array + { + return $this->mapperGenerique($columns); + } + + /** + * @param list $columns + * + * @return array + */ + 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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/TeacherDuplicateDetector.php b/backend/src/Administration/Application/Service/Import/TeacherDuplicateDetector.php new file mode 100644 index 0000000..32017bc --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/TeacherDuplicateDetector.php @@ -0,0 +1,61 @@ + $rows + * @param list $existingTeachers + * + * @return list + */ + public function detecter(array $rows, array $existingTeachers): array + { + /** @var array $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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/TeacherImportOrchestrator.php b/backend/src/Administration/Application/Service/Import/TeacherImportOrchestrator.php new file mode 100644 index 0000000..aef6f97 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/TeacherImportOrchestrator.php @@ -0,0 +1,299 @@ +} + */ + 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, report: ImportReport, unknownSubjects: list, unknownClasses: list} + */ + 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 $columns + * + * @return array + */ + 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 $mapping + * @param list $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 $mapping + * + * @return list + */ + 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 + */ + 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 + */ + 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 $rows + * @param list $existingValues + * + * @return list + */ + 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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/TeacherImportRowValidator.php b/backend/src/Administration/Application/Service/Import/TeacherImportRowValidator.php new file mode 100644 index 0000000..ff11fb4 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/TeacherImportRowValidator.php @@ -0,0 +1,177 @@ +|null $existingSubjectNames Noms des matières existantes. null = pas de vérification. + * @param list|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 $rows + * + * @return list + */ + public function validerTout(array $rows): array + { + return array_map($this->valider(...), $rows); + } + + /** + * @return list + */ + 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 + */ + 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 + */ + 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 + */ + 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 []; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportEnseignantsEchoue.php b/backend/src/Administration/Domain/Event/ImportEnseignantsEchoue.php new file mode 100644 index 0000000..555ac7a --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportEnseignantsEchoue.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportEnseignantsLance.php b/backend/src/Administration/Domain/Event/ImportEnseignantsLance.php new file mode 100644 index 0000000..426431d --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportEnseignantsLance.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportEnseignantsTermine.php b/backend/src/Administration/Domain/Event/ImportEnseignantsTermine.php new file mode 100644 index 0000000..261b816 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportEnseignantsTermine.php @@ -0,0 +1,39 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/MappingIncompletException.php b/backend/src/Administration/Domain/Exception/MappingIncompletException.php index 8a23533..dc181d0 100644 --- a/backend/src/Administration/Domain/Exception/MappingIncompletException.php +++ b/backend/src/Administration/Domain/Exception/MappingIncompletException.php @@ -5,13 +5,14 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\Import\StudentImportField; +use App\Administration\Domain\Model\Import\TeacherImportField; use DomainException; use function sprintf; final class MappingIncompletException extends DomainException { - public static function champManquant(StudentImportField $champ): self + public static function champManquant(StudentImportField|TeacherImportField $champ): self { return new self(sprintf( 'Le champ obligatoire "%s" (%s) n\'est pas mappé.', diff --git a/backend/src/Administration/Domain/Model/Import/TeacherColumnMapping.php b/backend/src/Administration/Domain/Model/Import/TeacherColumnMapping.php new file mode 100644 index 0000000..ba12e04 --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/TeacherColumnMapping.php @@ -0,0 +1,62 @@ + $mapping Colonne source → champ Classeo + */ + private function __construct( + public array $mapping, + public KnownImportFormat $format, + ) { + } + + /** + * @param array $mapping Colonne source → champ Classeo + */ + public static function creer(array $mapping, KnownImportFormat $format): self + { + $mappedFields = array_values($mapping); + $champsObligatoires = TeacherImportField::champsObligatoires(); + + foreach ($champsObligatoires as $champ) { + if (!in_array($champ, $mappedFields, true)) { + throw MappingIncompletException::champManquant($champ); + } + } + + return new self($mapping, $format); + } + + public function champPour(string $colonneSource): ?TeacherImportField + { + return $this->mapping[$colonneSource] ?? null; + } + + /** + * @return list + */ + public function colonnesSources(): array + { + return array_keys($this->mapping); + } + + public function equals(self $other): bool + { + return $this->mapping === $other->mapping && $this->format === $other->format; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/TeacherImportBatch.php b/backend/src/Administration/Domain/Model/Import/TeacherImportBatch.php new file mode 100644 index 0000000..f9e1472 --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/TeacherImportBatch.php @@ -0,0 +1,225 @@ + */ + private array $rows = []; + + private function __construct( + public private(set) ImportBatchId $id, + public private(set) TenantId $tenantId, + public private(set) string $originalFilename, + public private(set) int $totalRows, + /** @var list */ + public private(set) array $detectedColumns, + public private(set) ?KnownImportFormat $detectedFormat, + public private(set) ImportStatus $status, + public private(set) DateTimeImmutable $createdAt, + ) { + } + + /** + * @param list $detectedColumns Colonnes détectées dans le fichier + */ + public static function creer( + TenantId $tenantId, + string $originalFilename, + int $totalRows, + array $detectedColumns, + ?KnownImportFormat $detectedFormat, + DateTimeImmutable $createdAt, + ): self { + return new self( + id: ImportBatchId::generate(), + tenantId: $tenantId, + originalFilename: $originalFilename, + totalRows: $totalRows, + detectedColumns: $detectedColumns, + detectedFormat: $detectedFormat, + status: ImportStatus::PENDING, + createdAt: $createdAt, + ); + } + + public function appliquerMapping(TeacherColumnMapping $mapping): void + { + $this->mapping = $mapping; + } + + /** + * @param list $rows + */ + public function enregistrerLignes(array $rows): void + { + $this->rows = $rows; + } + + public function demarrer(DateTimeImmutable $at): void + { + if (!$this->status->peutDemarrer()) { + throw ImportNonDemarrableException::pourStatut($this->id, $this->status); + } + + if ($this->mapping === null) { + throw ImportNonDemarrableException::mappingManquant($this->id); + } + + $this->status = ImportStatus::PROCESSING; + + $this->recordEvent(new ImportEnseignantsLance( + batchId: $this->id, + tenantId: $this->tenantId, + totalRows: $this->totalRows, + occurredOn: $at, + )); + } + + public function mettreAJourProgression(int $importedCount, int $errorCount): void + { + $this->importedCount = $importedCount; + $this->errorCount = $errorCount; + } + + public function terminer(int $importedCount, int $errorCount, DateTimeImmutable $at): void + { + $this->status = ImportStatus::COMPLETED; + $this->importedCount = $importedCount; + $this->errorCount = $errorCount; + $this->completedAt = $at; + + $this->recordEvent(new ImportEnseignantsTermine( + batchId: $this->id, + tenantId: $this->tenantId, + importedCount: $importedCount, + errorCount: $errorCount, + occurredOn: $at, + )); + } + + public function echouer(int $errorCount, DateTimeImmutable $at): void + { + $this->status = ImportStatus::FAILED; + $this->errorCount = $errorCount; + $this->completedAt = $at; + + $this->recordEvent(new ImportEnseignantsEchoue( + batchId: $this->id, + tenantId: $this->tenantId, + errorCount: $errorCount, + occurredOn: $at, + )); + } + + /** + * @return list + */ + public function lignes(): array + { + return $this->rows; + } + + /** + * @return list + */ + public function lignesValides(): array + { + return array_values(array_filter( + $this->rows, + static fn (ImportRow $row): bool => $row->estValide(), + )); + } + + /** + * @return list + */ + public function lignesEnErreur(): array + { + return array_values(array_filter( + $this->rows, + static fn (ImportRow $row): bool => !$row->estValide(), + )); + } + + public function estTermine(): bool + { + return $this->status->estTermine(); + } + + public function progression(): float + { + if ($this->totalRows === 0) { + return 0.0; + } + + return min(100.0, ($this->importedCount + $this->errorCount) / $this->totalRows * 100); + } + + /** + * @internal Pour usage Infrastructure uniquement + * + * @param list $detectedColumns + */ + public static function reconstitute( + ImportBatchId $id, + TenantId $tenantId, + string $originalFilename, + int $totalRows, + array $detectedColumns, + ?KnownImportFormat $detectedFormat, + ImportStatus $status, + ?TeacherColumnMapping $mapping, + int $importedCount, + int $errorCount, + DateTimeImmutable $createdAt, + ?DateTimeImmutable $completedAt, + ): self { + $batch = new self( + id: $id, + tenantId: $tenantId, + originalFilename: $originalFilename, + totalRows: $totalRows, + detectedColumns: $detectedColumns, + detectedFormat: $detectedFormat, + status: $status, + createdAt: $createdAt, + ); + + $batch->mapping = $mapping; + $batch->importedCount = $importedCount; + $batch->errorCount = $errorCount; + $batch->completedAt = $completedAt; + + return $batch; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/TeacherImportField.php b/backend/src/Administration/Domain/Model/Import/TeacherImportField.php new file mode 100644 index 0000000..d444ecc --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/TeacherImportField.php @@ -0,0 +1,57 @@ + true, + default => false, + }; + } + + public function estMultiValeur(): bool + { + return match ($this) { + self::SUBJECTS, self::CLASSES => true, + default => false, + }; + } + + public function label(): string + { + return match ($this) { + self::LAST_NAME => 'Nom', + self::FIRST_NAME => 'Prénom', + self::EMAIL => 'Email', + self::SUBJECTS => 'Matières', + self::CLASSES => 'Classes', + }; + } + + /** + * @return list + */ + public static function champsObligatoires(): array + { + return array_values(array_filter( + self::cases(), + static fn (self $field): bool => $field->estObligatoire(), + )); + } +} diff --git a/backend/src/Administration/Domain/Model/User/User.php b/backend/src/Administration/Domain/Model/User/User.php index 2cc3fe1..462fd58 100644 --- a/backend/src/Administration/Domain/Model/User/User.php +++ b/backend/src/Administration/Domain/Model/User/User.php @@ -448,6 +448,15 @@ final class User extends AggregateRoot )); } + /** + * Met à jour le prénom et le nom de l'utilisateur. + */ + public function mettreAJourInfos(string $firstName, string $lastName): void + { + $this->firstName = $firstName; + $this->lastName = $lastName; + } + /** * Changes the user's password. * diff --git a/backend/src/Administration/Domain/Repository/SavedTeacherColumnMappingRepository.php b/backend/src/Administration/Domain/Repository/SavedTeacherColumnMappingRepository.php new file mode 100644 index 0000000..a38bb4c --- /dev/null +++ b/backend/src/Administration/Domain/Repository/SavedTeacherColumnMappingRepository.php @@ -0,0 +1,22 @@ + $mapping + */ + public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void; + + /** + * @return array|null + */ + public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array; +} diff --git a/backend/src/Administration/Domain/Repository/TeacherImportBatchRepository.php b/backend/src/Administration/Domain/Repository/TeacherImportBatchRepository.php new file mode 100644 index 0000000..92ef2ef --- /dev/null +++ b/backend/src/Administration/Domain/Repository/TeacherImportBatchRepository.php @@ -0,0 +1,23 @@ + + */ + public function findByTenant(TenantId $tenantId): array; +} diff --git a/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php b/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php index bfbf840..fa8bb9f 100644 --- a/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php +++ b/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php @@ -17,6 +17,7 @@ use App\Administration\Domain\Model\Import\ImportRowError; 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\ImportBatchRepository; use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; @@ -175,7 +176,10 @@ final readonly class StudentImportController $tenantId = TenantId::fromString($user->tenantId()); $batch = $this->getBatch($id, $tenantId); - $result = $this->orchestrator->generatePreview($batch, $tenantId); + $academicYearIdRaw = $this->academicYearResolver->resolve('current'); + $academicYearId = $academicYearIdRaw !== null ? AcademicYearId::fromString($academicYearIdRaw) : null; + + $result = $this->orchestrator->generatePreview($batch, $tenantId, $academicYearId); return new JsonResponse([ 'id' => (string) $batch->id, @@ -194,7 +198,8 @@ final readonly class StudentImportController public function confirm(string $id, Request $request): JsonResponse { $user = $this->getSecurityUser(); - $batch = $this->getBatch($id, TenantId::fromString($user->tenantId())); + $tenantId = TenantId::fromString($user->tenantId()); + $batch = $this->getBatch($id, $tenantId); $data = $request->toArray(); @@ -208,7 +213,13 @@ final readonly class StudentImportController ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); $schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain; - $this->orchestrator->prepareForConfirmation($batch, $createMissingClasses, $importValidOnly); + $this->orchestrator->prepareForConfirmation( + $batch, + $createMissingClasses, + $importValidOnly, + $tenantId, + AcademicYearId::fromString($academicYearId), + ); $this->commandBus->dispatch(new ImportStudentsCommand( batchId: (string) $batch->id, diff --git a/backend/src/Administration/Infrastructure/Api/Controller/TeacherImportController.php b/backend/src/Administration/Infrastructure/Api/Controller/TeacherImportController.php new file mode 100644 index 0000000..2ae7542 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Controller/TeacherImportController.php @@ -0,0 +1,351 @@ +getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $file = $request->files->get('file'); + if (!$file instanceof UploadedFile) { + throw new BadRequestHttpException('Un fichier CSV ou XLSX est requis.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('Le fichier dépasse la taille maximale de 10 Mo.'); + } + + $extension = strtolower($file->getClientOriginalExtension()); + if (!in_array($extension, ['csv', 'txt', 'xlsx', 'xls'], true)) { + throw new BadRequestHttpException('Extension non supportée. Utilisez CSV ou XLSX.'); + } + + $allowedMimeTypes = [ + 'text/csv', 'text/plain', 'application/csv', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + ]; + + $mimeType = $file->getMimeType(); + if ($mimeType === null || !in_array($mimeType, $allowedMimeTypes, true)) { + throw new BadRequestHttpException('Type de fichier non supporté. Utilisez CSV ou XLSX.'); + } + + try { + $result = $this->orchestrator->analyzeFile( + $file->getPathname(), + $extension, + $file->getClientOriginalName(), + $tenantId, + ); + } catch (FichierImportInvalideException|InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + $batch = $result['batch']; + $suggestedMapping = $result['suggestedMapping']; + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'filename' => $batch->originalFilename, + 'totalRows' => $batch->totalRows, + 'columns' => $batch->detectedColumns, + 'detectedFormat' => ($batch->detectedFormat ?? KnownImportFormat::CUSTOM)->value, + 'suggestedMapping' => $this->serializeMapping($suggestedMapping), + 'preview' => $this->serializeRows(array_slice($batch->lignes(), 0, 5)), + ], Response::HTTP_CREATED); + } + + /** + * T5.2 : Valider et appliquer le mapping des colonnes. + */ + #[Route('/{id}/mapping', methods: ['POST'], name: 'api_import_teachers_mapping')] + public function mapping(string $id, Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + $batch = $this->getBatch($id, TenantId::fromString($user->tenantId())); + + $data = $request->toArray(); + + /** @var array $mappingData */ + $mappingData = $data['mapping'] ?? []; + + /** @var string $formatValue */ + $formatValue = $data['format'] ?? ''; + $format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM; + + /** @var array $mappingFields */ + $mappingFields = []; + foreach ($mappingData as $column => $fieldValue) { + $field = TeacherImportField::tryFrom($fieldValue); + if ($field !== null) { + $mappingFields[$column] = $field; + } + } + + try { + $columnMapping = TeacherColumnMapping::creer($mappingFields, $format); + } catch (MappingIncompletException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + $this->orchestrator->applyMapping($batch, $columnMapping); + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'mapping' => $this->serializeMapping($columnMapping->mapping), + 'totalRows' => $batch->totalRows, + ]); + } + + /** + * T5.3 : Preview avec validation et erreurs. + */ + #[Route('/{id}/preview', methods: ['GET'], name: 'api_import_teachers_preview')] + public function preview(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + $batch = $this->getBatch($id, $tenantId); + + $result = $this->orchestrator->generatePreview($batch, $tenantId); + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'totalRows' => $result['report']->totalRows, + 'validCount' => $result['report']->importedCount, + 'errorCount' => $result['report']->errorCount, + 'rows' => $this->serializeRows($result['validatedRows']), + 'unknownSubjects' => $result['unknownSubjects'], + 'unknownClasses' => $result['unknownClasses'], + ]); + } + + /** + * T5.4 : Confirmer et lancer l'import. + */ + #[Route('/{id}/confirm', methods: ['POST'], name: 'api_import_teachers_confirm')] + public function confirm(string $id, Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + $batch = $this->getBatch($id, $tenantId); + + $data = $request->toArray(); + + /** @var bool $createMissingSubjects */ + $createMissingSubjects = $data['createMissingSubjects'] ?? false; + + /** @var bool $importValidOnly */ + $importValidOnly = $data['importValidOnly'] ?? true; + + /** @var bool $updateExisting */ + $updateExisting = $data['updateExisting'] ?? false; + + $academicYearId = $this->academicYearResolver->resolve('current') + ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); + $schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain; + + $this->orchestrator->prepareForConfirmation($batch, $createMissingSubjects, $importValidOnly, $tenantId); + + $this->commandBus->dispatch(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: $user->tenantId(), + schoolName: $schoolName, + academicYearId: $academicYearId, + createMissingSubjects: $createMissingSubjects, + updateExisting: $updateExisting, + )); + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'status' => 'processing', + 'message' => 'Import lancé. Suivez la progression via GET /status.', + ], Response::HTTP_ACCEPTED); + } + + /** + * T5.5 : Statut et progression. + */ + #[Route('/{id}/status', methods: ['GET'], name: 'api_import_teachers_status')] + public function status(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $batch = $this->getBatch($id, TenantId::fromString($user->tenantId())); + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'status' => $batch->status->value, + 'totalRows' => $batch->totalRows, + 'importedCount' => $batch->importedCount, + 'errorCount' => $batch->errorCount, + 'progression' => $batch->progression(), + 'completedAt' => $batch->completedAt?->format(DateTimeInterface::ATOM), + ]); + } + + /** + * T5.6 : Télécharger le rapport. + */ + #[Route('/{id}/report', methods: ['GET'], name: 'api_import_teachers_report')] + public function report(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $batch = $this->getBatch($id, TenantId::fromString($user->tenantId())); + + $report = ImportReport::fromValidatedRows($batch->lignes()); + + return new JsonResponse([ + 'id' => (string) $batch->id, + 'status' => $batch->status->value, + 'totalRows' => $report->totalRows, + 'importedCount' => $batch->importedCount, + 'errorCount' => $batch->errorCount, + 'report' => $report->lignesRapport(), + 'errors' => array_map( + static fn (ImportRow $row) => [ + 'line' => $row->lineNumber, + 'errors' => array_map( + static fn (ImportRowError $error) => [ + 'column' => $error->column, + 'message' => $error->message, + ], + $row->errors, + ), + ], + $report->errorRows, + ), + ]); + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException(); + } + + return $user; + } + + private function getBatch(string $id, TenantId $tenantId): TeacherImportBatch + { + try { + $batch = $this->teacherImportBatchRepository->get(ImportBatchId::fromString($id)); + } catch (ImportBatchNotFoundException|InvalidArgumentException) { + throw new NotFoundHttpException('Import non trouvé.'); + } + + if ((string) $batch->tenantId !== (string) $tenantId) { + throw new NotFoundHttpException('Import non trouvé.'); + } + + return $batch; + } + + /** + * @param array $mapping + * + * @return array + */ + private function serializeMapping(array $mapping): array + { + $result = []; + foreach ($mapping as $column => $field) { + $result[$column] = $field->value; + } + + return $result; + } + + /** + * @param list $rows + * + * @return list> + */ + private function serializeRows(array $rows): array + { + return array_map( + static fn (ImportRow $row) => [ + 'line' => $row->lineNumber, + 'data' => $row->mappedData, + 'valid' => $row->estValide(), + 'errors' => array_map( + static fn (ImportRowError $error) => [ + 'column' => $error->column, + 'message' => $error->message, + ], + $row->errors, + ), + ], + $rows, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedTeacherColumnMappingRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedTeacherColumnMappingRepository.php new file mode 100644 index 0000000..8fd1553 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedTeacherColumnMappingRepository.php @@ -0,0 +1,82 @@ + $field) { + $serialized[$column] = $field->value; + } + + $this->connection->executeStatement( + 'INSERT INTO saved_teacher_column_mappings (tenant_id, format, mapping_data, saved_at) + VALUES (:tenant_id, :format, :mapping_data, :saved_at) + ON CONFLICT (tenant_id, format) DO UPDATE SET + mapping_data = EXCLUDED.mapping_data, + saved_at = EXCLUDED.saved_at', + [ + 'tenant_id' => (string) $tenantId, + 'format' => $format->value, + 'mapping_data' => json_encode($serialized, JSON_THROW_ON_ERROR), + 'saved_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array + { + $row = $this->connection->fetchAssociative( + 'SELECT mapping_data FROM saved_teacher_column_mappings WHERE tenant_id = :tenant_id AND format = :format', + [ + 'tenant_id' => (string) $tenantId, + 'format' => $format->value, + ], + ); + + if ($row === false) { + return null; + } + + /** @var string $json */ + $json = $row['mapping_data']; + + /** @var array $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $mapping = []; + foreach ($data as $column => $fieldValue) { + $field = TeacherImportField::tryFrom($fieldValue); + if ($field !== null) { + $mapping[$column] = $field; + } + } + + return $mapping !== [] ? $mapping : null; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherImportBatchRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherImportBatchRepository.php new file mode 100644 index 0000000..4f242c4 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherImportBatchRepository.php @@ -0,0 +1,241 @@ +connection->executeStatement( + 'INSERT INTO teacher_import_batches + (id, tenant_id, original_filename, total_rows, detected_columns, detected_format, + status, mapping_data, imported_count, error_count, rows_data, created_at, completed_at) + VALUES + (:id, :tenant_id, :original_filename, :total_rows, :detected_columns, :detected_format, + :status, :mapping_data, :imported_count, :error_count, :rows_data, :created_at, :completed_at) + ON CONFLICT (id) DO UPDATE SET + total_rows = EXCLUDED.total_rows, + status = EXCLUDED.status, + mapping_data = EXCLUDED.mapping_data, + imported_count = EXCLUDED.imported_count, + error_count = EXCLUDED.error_count, + rows_data = EXCLUDED.rows_data, + completed_at = EXCLUDED.completed_at', + [ + 'id' => (string) $batch->id, + 'tenant_id' => (string) $batch->tenantId, + 'original_filename' => $batch->originalFilename, + 'total_rows' => $batch->totalRows, + 'detected_columns' => json_encode($batch->detectedColumns, JSON_THROW_ON_ERROR), + 'detected_format' => $batch->detectedFormat?->value, + 'status' => $batch->status->value, + 'mapping_data' => $batch->mapping !== null + ? json_encode($this->serializeMapping($batch->mapping), JSON_THROW_ON_ERROR) + : null, + 'imported_count' => $batch->importedCount, + 'error_count' => $batch->errorCount, + 'rows_data' => json_encode($this->serializeRows($batch->lignes()), JSON_THROW_ON_ERROR), + 'created_at' => $batch->createdAt->format(DateTimeImmutable::ATOM), + 'completed_at' => $batch->completedAt?->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(ImportBatchId $id): TeacherImportBatch + { + return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id); + } + + #[Override] + public function findById(ImportBatchId $id): ?TeacherImportBatch + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM teacher_import_batches WHERE id = :id', + ['id' => (string) $id], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByTenant(TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM teacher_import_batches WHERE tenant_id = :tenant_id ORDER BY created_at DESC', + ['tenant_id' => (string) $tenantId], + ); + + return array_map(fn ($row) => $this->hydrate($row), $rows); + } + + /** + * @param array $row + */ + private function hydrate(array $row): TeacherImportBatch + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $originalFilename */ + $originalFilename = $row['original_filename']; + /** @var string|int $totalRowsRaw */ + $totalRowsRaw = $row['total_rows']; + $totalRows = (int) $totalRowsRaw; + /** @var string $detectedColumnsJson */ + $detectedColumnsJson = $row['detected_columns']; + /** @var string|null $detectedFormat */ + $detectedFormat = $row['detected_format']; + /** @var string $status */ + $status = $row['status']; + /** @var string|null $mappingJson */ + $mappingJson = $row['mapping_data']; + /** @var string|int $importedCountRaw */ + $importedCountRaw = $row['imported_count']; + $importedCount = (int) $importedCountRaw; + /** @var string|int $errorCountRaw */ + $errorCountRaw = $row['error_count']; + $errorCount = (int) $errorCountRaw; + /** @var string $rowsJson */ + $rowsJson = $row['rows_data']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string|null $completedAt */ + $completedAt = $row['completed_at']; + + /** @var list $detectedColumns */ + $detectedColumns = json_decode($detectedColumnsJson, true, 512, JSON_THROW_ON_ERROR); + + $mapping = $mappingJson !== null ? $this->hydrateMapping($mappingJson) : null; + + $batch = TeacherImportBatch::reconstitute( + id: ImportBatchId::fromString($id), + tenantId: TenantId::fromString($tenantId), + originalFilename: $originalFilename, + totalRows: $totalRows, + detectedColumns: $detectedColumns, + detectedFormat: $detectedFormat !== null ? KnownImportFormat::from($detectedFormat) : null, + status: ImportStatus::from($status), + mapping: $mapping, + importedCount: $importedCount, + errorCount: $errorCount, + createdAt: new DateTimeImmutable($createdAt), + completedAt: $completedAt !== null ? new DateTimeImmutable($completedAt) : null, + ); + + $rows = $this->hydrateRows($rowsJson); + $batch->enregistrerLignes($rows); + + return $batch; + } + + private function hydrateMapping(string $json): TeacherColumnMapping + { + /** @var array{mapping: array, format: string} $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + /** @var array $mapping */ + $mapping = []; + foreach ($data['mapping'] as $column => $fieldValue) { + $mapping[$column] = TeacherImportField::from($fieldValue); + } + + return TeacherColumnMapping::creer($mapping, KnownImportFormat::from($data['format'])); + } + + /** + * @return list + */ + private function hydrateRows(string $json): array + { + /** @var list, mappedData: array, errors: list}> $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return array_map( + static fn (array $rowData) => new ImportRow( + lineNumber: $rowData['lineNumber'], + rawData: $rowData['rawData'], + mappedData: $rowData['mappedData'], + errors: array_map( + static fn (array $err) => new ImportRowError($err['column'], $err['message']), + $rowData['errors'], + ), + ), + $data, + ); + } + + /** + * @return array{mapping: array, format: string} + */ + private function serializeMapping(TeacherColumnMapping $mapping): array + { + $serialized = []; + foreach ($mapping->mapping as $column => $field) { + $serialized[$column] = $field->value; + } + + return [ + 'mapping' => $serialized, + 'format' => $mapping->format->value, + ]; + } + + /** + * @param list $rows + * + * @return list, mappedData: array, errors: list}> + */ + private function serializeRows(array $rows): array + { + return array_map( + static fn (ImportRow $row) => [ + 'lineNumber' => $row->lineNumber, + 'rawData' => $row->rawData, + 'mappedData' => $row->mappedData, + 'errors' => array_map( + static fn (ImportRowError $error) => [ + 'column' => $error->column, + 'message' => $error->message, + ], + $row->errors, + ), + ], + $rows, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedTeacherColumnMappingRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedTeacherColumnMappingRepository.php new file mode 100644 index 0000000..7f72aca --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedTeacherColumnMappingRepository.php @@ -0,0 +1,34 @@ +> */ + private array $store = []; + + #[Override] + public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void + { + $this->store[$this->key($tenantId, $format)] = $mapping; + } + + #[Override] + public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array + { + return $this->store[$this->key($tenantId, $format)] ?? null; + } + + private function key(TenantId $tenantId, KnownImportFormat $format): string + { + return (string) $tenantId . ':' . $format->value; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherImportBatchRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherImportBatchRepository.php new file mode 100644 index 0000000..9d4c786 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherImportBatchRepository.php @@ -0,0 +1,45 @@ + */ + private array $byId = []; + + #[Override] + public function save(TeacherImportBatch $batch): void + { + $this->byId[$batch->id->__toString()] = $batch; + } + + #[Override] + public function get(ImportBatchId $id): TeacherImportBatch + { + return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id); + } + + #[Override] + public function findById(ImportBatchId $id): ?TeacherImportBatch + { + return $this->byId[$id->__toString()] ?? null; + } + + #[Override] + public function findByTenant(TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (TeacherImportBatch $batch): bool => $batch->tenantId->equals($tenantId), + )); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ImportTeachers/ImportTeachersHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ImportTeachers/ImportTeachersHandlerTest.php new file mode 100644 index 0000000..2716db9 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ImportTeachers/ImportTeachersHandlerTest.php @@ -0,0 +1,603 @@ +tenantId = TenantId::fromString(self::TENANT_ID); + $schoolIdResolver = new SchoolIdResolver(); + $this->schoolId = SchoolId::fromString($schoolIdResolver->resolveForTenant(self::TENANT_ID)); + + $this->importBatchRepository = new InMemoryTeacherImportBatchRepository(); + $this->userRepository = new InMemoryUserRepository(); + $this->subjectRepository = new InMemorySubjectRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->assignmentRepository = new InMemoryTeacherAssignmentRepository(); + + $connection = $this->createMock(Connection::class); + + $this->eventBus = $this->createMock(MessageBusInterface::class); + $this->eventBus->method('dispatch')->willReturnCallback( + static fn (object $message) => new Envelope($message), + ); + + $this->handler = new ImportTeachersHandler( + $this->importBatchRepository, + $this->userRepository, + $this->subjectRepository, + $this->classRepository, + $this->assignmentRepository, + $schoolIdResolver, + $connection, + $clock, + new NullLogger(), + $this->eventBus, + ); + } + + #[Test] + public function importsTeachersWithEmailOnly(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + $this->createMappedRow(2, 'Martin', 'Marie', 'marie@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(2, $updatedBatch->importedCount); + self::assertSame(0, $updatedBatch->errorCount); + } + + #[Test] + public function createsTeachersWithProfRole(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $users = $this->userRepository->findAllByTenant($this->tenantId); + + self::assertCount(1, $users); + self::assertTrue($users[0]->aLeRole(Role::PROF)); + } + + #[Test] + public function assignsTeacherToSubjectsAndClasses(): void + { + $subject = $this->createSubject('Mathématiques', 'MATH'); + $class = $this->createClass('6A'); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr', 'Mathématiques', '6A'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $users = $this->userRepository->findAllByTenant($this->tenantId); + self::assertCount(1, $users); + + $assignments = $this->assignmentRepository->findActiveByTeacher($users[0]->id, $this->tenantId); + self::assertCount(1, $assignments); + self::assertTrue($assignments[0]->subjectId->equals($subject->id)); + self::assertTrue($assignments[0]->classId->equals($class->id)); + } + + #[Test] + public function assignsMultipleSubjectsAndClasses(): void + { + $this->createSubject('Mathématiques', 'MATH'); + $this->createSubject('Physique', 'PHYS'); + $this->createClass('6A'); + $this->createClass('6B'); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr', 'Mathématiques, Physique', '6A, 6B'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $users = $this->userRepository->findAllByTenant($this->tenantId); + $assignments = $this->assignmentRepository->findActiveByTeacher($users[0]->id, $this->tenantId); + + // 2 subjects × 2 classes = 4 assignments + self::assertCount(4, $assignments); + } + + #[Test] + public function createsMissingSubjectsWhenEnabled(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr', 'Chimie'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + createMissingSubjects: true, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(1, $updatedBatch->importedCount); + + $subjects = $this->subjectRepository->findAllActiveByTenant($this->tenantId); + self::assertCount(1, $subjects); + self::assertSame('Chimie', (string) $subjects[0]->name); + } + + #[Test] + public function rejectsDuplicateEmails(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + $this->createMappedRow(2, 'Martin', 'Marie', 'jean@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(1, $updatedBatch->importedCount); + self::assertSame(1, $updatedBatch->errorCount); + } + + #[Test] + public function importedTeachersHaveEnAttenteStatus(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $users = $this->userRepository->findAllByTenant($this->tenantId); + + self::assertCount(1, $users); + self::assertSame(StatutCompte::EN_ATTENTE, $users[0]->statut); + } + + #[Test] + public function importedTeachersDispatchUtilisateurInviteEvent(): void + { + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->expects(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(UtilisateurInvite::class)) + ->willReturnCallback(static fn (object $message) => new Envelope($message)); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-25 10:00:00'); + } + }; + + $schoolIdResolver = new SchoolIdResolver(); + $connection = $this->createMock(Connection::class); + + $handler = new ImportTeachersHandler( + $this->importBatchRepository, + $this->userRepository, + $this->subjectRepository, + $this->classRepository, + $this->assignmentRepository, + $schoolIdResolver, + $connection, + $clock, + new NullLogger(), + $eventBus, + ); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + ]); + + ($handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + } + + #[Test] + public function rejectsPreExistingEmailFromDatabase(): void + { + $existingUser = User::inviter( + email: new Email('jean@ecole.fr'), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + firstName: 'Existing', + lastName: 'Teacher', + invitedAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $this->userRepository->save($existingUser); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(0, $updatedBatch->importedCount); + self::assertSame(1, $updatedBatch->errorCount); + + // Verify no new user was created (still only the pre-existing one) + $users = $this->userRepository->findAllByTenant($this->tenantId); + self::assertCount(1, $users); + } + + #[Test] + public function updatesExistingTeacherWhenUpdateExistingEnabled(): void + { + $existingUser = User::inviter( + email: new Email('jean@ecole.fr'), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + firstName: 'Ancien', + lastName: 'Nom', + invitedAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $existingUser->pullDomainEvents(); + $this->userRepository->save($existingUser); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + updateExisting: true, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(1, $updatedBatch->importedCount); + self::assertSame(0, $updatedBatch->errorCount); + + $users = $this->userRepository->findAllByTenant($this->tenantId); + self::assertCount(1, $users); + self::assertSame('Jean', $users[0]->firstName); + self::assertSame('Dupont', $users[0]->lastName); + } + + #[Test] + public function updateExistingAddsMissingAssignments(): void + { + $subject = $this->createSubject('Mathématiques', 'MATH'); + $class = $this->createClass('6A'); + + $existingUser = User::inviter( + email: new Email('jean@ecole.fr'), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $existingUser->pullDomainEvents(); + $this->userRepository->save($existingUser); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr', 'Mathématiques', '6A'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + updateExisting: true, + )); + + $assignments = $this->assignmentRepository->findActiveByTeacher($existingUser->id, $this->tenantId); + self::assertCount(1, $assignments); + self::assertTrue($assignments[0]->subjectId->equals($subject->id)); + self::assertTrue($assignments[0]->classId->equals($class->id)); + } + + #[Test] + public function updateExistingDoesNotDuplicateAssignments(): void + { + $subject = $this->createSubject('Mathématiques', 'MATH'); + $class = $this->createClass('6A'); + + $existingUser = User::inviter( + email: new Email('jean@ecole.fr'), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $existingUser->pullDomainEvents(); + $this->userRepository->save($existingUser); + + // Pre-create the assignment + $assignment = \App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment::creer( + tenantId: $this->tenantId, + teacherId: $existingUser->id, + classId: $class->id, + subjectId: $subject->id, + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $this->assignmentRepository->save($assignment); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', 'jean@ecole.fr', 'Mathématiques', '6A'), + ]); + + ($this->handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + updateExisting: true, + )); + + $assignments = $this->assignmentRepository->findActiveByTeacher($existingUser->id, $this->tenantId); + self::assertCount(1, $assignments); + } + + #[Test] + public function updateExistingDoesNotDispatchInvitationEvent(): void + { + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->expects(self::never())->method('dispatch'); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-25 10:00:00'); + } + }; + + $schoolIdResolver = new SchoolIdResolver(); + $connection = $this->createMock(Connection::class); + + $handler = new ImportTeachersHandler( + $this->importBatchRepository, + $this->userRepository, + $this->subjectRepository, + $this->classRepository, + $this->assignmentRepository, + $schoolIdResolver, + $connection, + $clock, + new NullLogger(), + $eventBus, + ); + + $existingUser = User::inviter( + email: new Email('jean@ecole.fr'), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-20 10:00:00'), + ); + $existingUser->pullDomainEvents(); + $this->userRepository->save($existingUser); + + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'NouveauNom', 'NouveauPrenom', 'jean@ecole.fr'), + ]); + + ($handler)(new ImportTeachersCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + updateExisting: true, + )); + } + + private function createSubject(string $name, string $code): Subject + { + $subject = Subject::creer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + name: new SubjectName($name), + code: new SubjectCode($code), + color: null, + createdAt: new DateTimeImmutable('2026-01-01'), + ); + + $this->subjectRepository->save($subject); + + return $subject; + } + + private function createClass(string $name): SchoolClass + { + $class = SchoolClass::creer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName($name), + level: null, + capacity: null, + createdAt: new DateTimeImmutable('2026-01-01'), + ); + + $this->classRepository->save($class); + + return $class; + } + + /** + * @param list $rows + */ + private function createBatchWithRows(array $rows): TeacherImportBatch + { + $batch = TeacherImportBatch::creer( + tenantId: $this->tenantId, + originalFilename: 'enseignants.csv', + totalRows: count($rows), + detectedColumns: ['Nom', 'Prénom', 'Email', 'Matières', 'Classes'], + detectedFormat: KnownImportFormat::CUSTOM, + createdAt: new DateTimeImmutable('2026-02-25 09:00:00'), + ); + + $mapping = TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + 'Matières' => TeacherImportField::SUBJECTS, + 'Classes' => TeacherImportField::CLASSES, + ], + KnownImportFormat::CUSTOM, + ); + + $batch->appliquerMapping($mapping); + $batch->enregistrerLignes($rows); + $this->importBatchRepository->save($batch); + + return $batch; + } + + private function createMappedRow( + int $line, + string $lastName, + string $firstName, + string $email, + string $subjects = '', + string $classes = '', + ): ImportRow { + $mappedData = [ + 'lastName' => $lastName, + 'firstName' => $firstName, + 'email' => $email, + 'subjects' => $subjects, + 'classes' => $classes, + ]; + + return new ImportRow( + lineNumber: $line, + rawData: $mappedData, + mappedData: $mappedData, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/DuplicateDetectorTest.php b/backend/tests/Unit/Administration/Application/Service/Import/DuplicateDetectorTest.php new file mode 100644 index 0000000..9676d56 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/DuplicateDetectorTest.php @@ -0,0 +1,242 @@ +detector = new DuplicateDetector(); + } + + #[Test] + public function detectsDuplicateByEmail(): void + { + $existing = [ + $this->student('Jean', 'Dupont', 'jean@example.com', null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com', 'className' => '6A']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertSame('_duplicate', $result[0]->errors[0]->column); + self::assertStringContainsString('email', $result[0]->errors[0]->message); + } + + #[Test] + public function detectsDuplicateByEmailCaseInsensitive(): void + { + $existing = [ + $this->student('Jean', 'Dupont', 'Jean.Dupont@Example.COM', null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean.dupont@example.com', 'className' => '6A']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertStringContainsString('email', $result[0]->errors[0]->message); + } + + #[Test] + public function detectsDuplicateByStudentNumber(): void + { + $existing = [ + $this->student('Jean', 'Dupont', null, 'STU-001', '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'studentNumber' => 'STU-001', 'className' => '6A']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertStringContainsString('numéro élève', $result[0]->errors[0]->message); + } + + #[Test] + public function detectsDuplicateByNameAndClass(): void + { + $existing = [ + $this->student('Jean', 'Dupont', null, null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertStringContainsString('nom + classe', $result[0]->errors[0]->message); + } + + #[Test] + public function nameAndClassMatchIsCaseInsensitive(): void + { + $existing = [ + $this->student('JEAN', 'DUPONT', null, null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'dupont', 'firstName' => 'jean', 'className' => '6a']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + } + + #[Test] + public function sameNameDifferentClassIsNotDuplicate(): void + { + $existing = [ + $this->student('Jean', 'Dupont', null, null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6B']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + } + + #[Test] + public function detectsIntraFileDuplicate(): void + { + $existing = []; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A'], 1), + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A'], 2), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + self::assertFalse($result[1]->estValide()); + self::assertSame('_duplicate', $result[1]->errors[0]->column); + } + + #[Test] + public function detectsIntraFileDuplicateByEmail(): void + { + $existing = []; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com', 'className' => '6A'], 1), + $this->createRow(['lastName' => 'Martin', 'firstName' => 'Pierre', 'email' => 'jean@example.com', 'className' => '5B'], 2), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + self::assertFalse($result[1]->estValide()); + } + + #[Test] + public function rowWithoutEmailOrNumberOrClassIsNotDuplicate(): void + { + $existing = [ + $this->student('Jean', 'Dupont', null, null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + } + + #[Test] + public function multipleRowsMixedDuplicatesAndValid(): void + { + $existing = [ + $this->student('Jean', 'Dupont', 'jean@example.com', null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com', 'className' => '6A'], 1), + $this->createRow(['lastName' => 'Martin', 'firstName' => 'Pierre', 'className' => '6A'], 2), + $this->createRow(['lastName' => 'Bernard', 'firstName' => 'Claire', 'className' => '6B'], 3), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertTrue($result[1]->estValide()); + self::assertTrue($result[2]->estValide()); + } + + #[Test] + public function emailMatchTakesPriorityOverNameClass(): void + { + $existing = [ + $this->student('Jean', 'Dupont', 'jean@example.com', null, '6A'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com', 'className' => '6A']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertStringContainsString('email', $result[0]->errors[0]->message); + } + + #[Test] + public function preservesExistingValidationErrors(): void + { + $existing = [ + $this->student('Jean', 'Dupont', null, null, '6A'), + ]; + + $row = new ImportRow( + lineNumber: 1, + rawData: ['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A'], + mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A'], + errors: [new \App\Administration\Domain\Model\Import\ImportRowError('email', 'Email invalide.')], + ); + + $result = $this->detector->detecter([$row], $existing); + + self::assertCount(2, $result[0]->errors); + self::assertSame('email', $result[0]->errors[0]->column); + self::assertSame('_duplicate', $result[0]->errors[1]->column); + } + + /** + * @param array $mappedData + */ + private function createRow(array $mappedData, int $lineNumber = 1): ImportRow + { + return new ImportRow( + lineNumber: $lineNumber, + rawData: $mappedData, + mappedData: $mappedData, + ); + } + + /** + * @return array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string} + */ + private function student(string $firstName, string $lastName, ?string $email, ?string $studentNumber, ?string $className): array + { + return [ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'email' => $email, + 'studentNumber' => $studentNumber, + 'className' => $className, + ]; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/MultiValueParserTest.php b/backend/tests/Unit/Administration/Application/Service/Import/MultiValueParserTest.php new file mode 100644 index 0000000..18c38aa --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/MultiValueParserTest.php @@ -0,0 +1,83 @@ +parser = new MultiValueParser(); + } + + #[Test] + public function parseSingleValue(): void + { + $result = $this->parser->parse('Mathématiques'); + + self::assertSame(['Mathématiques'], $result); + } + + #[Test] + public function parseMultipleValues(): void + { + $result = $this->parser->parse('Mathématiques, Physique'); + + self::assertSame(['Mathématiques', 'Physique'], $result); + } + + #[Test] + public function parseTrimsWhitespace(): void + { + $result = $this->parser->parse(' Mathématiques , Physique , Chimie '); + + self::assertSame(['Mathématiques', 'Physique', 'Chimie'], $result); + } + + #[Test] + public function parseEmptyStringReturnsEmptyArray(): void + { + $result = $this->parser->parse(''); + + self::assertSame([], $result); + } + + #[Test] + public function parseOnlyWhitespaceReturnsEmptyArray(): void + { + $result = $this->parser->parse(' '); + + self::assertSame([], $result); + } + + #[Test] + public function parseWithCustomSeparator(): void + { + $result = $this->parser->parse('6A;6B;5A', ';'); + + self::assertSame(['6A', '6B', '5A'], $result); + } + + #[Test] + public function parseFiltersOutEmptyValues(): void + { + $result = $this->parser->parse('Mathématiques,,Physique,'); + + self::assertSame(['Mathématiques', 'Physique'], $result); + } + + #[Test] + public function parseWithClassNames(): void + { + $result = $this->parser->parse('6A, 6B, 5A'); + + self::assertSame(['6A', '6B', '5A'], $result); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/TeacherColumnMappingSuggesterTest.php b/backend/tests/Unit/Administration/Application/Service/Import/TeacherColumnMappingSuggesterTest.php new file mode 100644 index 0000000..995a32d --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/TeacherColumnMappingSuggesterTest.php @@ -0,0 +1,105 @@ +suggester = new TeacherColumnMappingSuggester(); + } + + #[Test] + public function suggestGenericMappingByKeywords(): void + { + $columns = ['Nom', 'Prénom', 'Email', 'Matières', 'Classes']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(TeacherImportField::LAST_NAME, $mapping['Nom']); + self::assertSame(TeacherImportField::FIRST_NAME, $mapping['Prénom']); + self::assertSame(TeacherImportField::EMAIL, $mapping['Email']); + self::assertSame(TeacherImportField::SUBJECTS, $mapping['Matières']); + self::assertSame(TeacherImportField::CLASSES, $mapping['Classes']); + } + + #[Test] + public function suggestHandlesEnglishColumnNames(): void + { + $columns = ['Last Name', 'First Name', 'Email', 'Subject', 'Class']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(TeacherImportField::LAST_NAME, $mapping['Last Name']); + self::assertSame(TeacherImportField::FIRST_NAME, $mapping['First Name']); + self::assertSame(TeacherImportField::EMAIL, $mapping['Email']); + self::assertSame(TeacherImportField::SUBJECTS, $mapping['Subject']); + self::assertSame(TeacherImportField::CLASSES, $mapping['Class']); + } + + #[Test] + public function suggestNormalizesAccentsAndCase(): void + { + $columns = ['NOM', 'PRÉNOM', 'EMAIL', 'MATIÈRES', 'CLASSES']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(TeacherImportField::LAST_NAME, $mapping['NOM']); + self::assertSame(TeacherImportField::FIRST_NAME, $mapping['PRÉNOM']); + self::assertSame(TeacherImportField::EMAIL, $mapping['EMAIL']); + self::assertSame(TeacherImportField::SUBJECTS, $mapping['MATIÈRES']); + self::assertSame(TeacherImportField::CLASSES, $mapping['CLASSES']); + } + + #[Test] + public function suggestDoesNotDuplicateFields(): void + { + $columns = ['Nom', 'Nom de famille', 'Prénom', 'Email']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + $mappedFields = array_values($mapping); + $uniqueFields = array_unique($mappedFields, SORT_REGULAR); + + self::assertCount(count($uniqueFields), $mappedFields); + } + + #[Test] + public function suggestHandlesUnknownColumns(): void + { + $columns = ['ColonneInconnue', 'AutreColonne', 'Nom', 'Email']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertArrayNotHasKey('ColonneInconnue', $mapping); + self::assertArrayNotHasKey('AutreColonne', $mapping); + self::assertArrayHasKey('Nom', $mapping); + self::assertArrayHasKey('Email', $mapping); + } + + #[Test] + public function suggestHandsDisciplineKeyword(): void + { + $columns = ['Nom', 'Prénom', 'Courriel', 'Discipline']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(TeacherImportField::EMAIL, $mapping['Courriel']); + self::assertSame(TeacherImportField::SUBJECTS, $mapping['Discipline']); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/TeacherDuplicateDetectorTest.php b/backend/tests/Unit/Administration/Application/Service/Import/TeacherDuplicateDetectorTest.php new file mode 100644 index 0000000..1df79ea --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/TeacherDuplicateDetectorTest.php @@ -0,0 +1,149 @@ +detector = new TeacherDuplicateDetector(); + } + + #[Test] + public function detectsDuplicateByEmail(): void + { + $existing = [ + $this->teacher('Jean', 'Dupont', 'jean@example.com'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertSame('_duplicate', $result[0]->errors[0]->column); + self::assertStringContainsString('email', $result[0]->errors[0]->message); + } + + #[Test] + public function detectsDuplicateByEmailCaseInsensitive(): void + { + $existing = [ + $this->teacher('Jean', 'Dupont', 'Jean.Dupont@Example.COM'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean.dupont@example.com']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertStringContainsString('email', $result[0]->errors[0]->message); + } + + #[Test] + public function detectsIntraFileDuplicateByEmail(): void + { + $existing = []; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com'], 1), + $this->createRow(['lastName' => 'Martin', 'firstName' => 'Pierre', 'email' => 'jean@example.com'], 2), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + self::assertFalse($result[1]->estValide()); + self::assertSame('_duplicate', $result[1]->errors[0]->column); + } + + #[Test] + public function doesNotFlagDifferentEmails(): void + { + $existing = [ + $this->teacher('Jean', 'Dupont', 'jean@example.com'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean.d@other.com']), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertTrue($result[0]->estValide()); + } + + #[Test] + public function preservesExistingValidationErrors(): void + { + $existing = [ + $this->teacher('Jean', 'Dupont', 'jean@example.com'), + ]; + + $row = new ImportRow( + lineNumber: 1, + rawData: ['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com'], + mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com'], + errors: [new ImportRowError('subjects', 'Matière inexistante.')], + ); + + $result = $this->detector->detecter([$row], $existing); + + self::assertCount(2, $result[0]->errors); + self::assertSame('subjects', $result[0]->errors[0]->column); + self::assertSame('_duplicate', $result[0]->errors[1]->column); + } + + #[Test] + public function multipleRowsMixedDuplicatesAndValid(): void + { + $existing = [ + $this->teacher('Jean', 'Dupont', 'jean@example.com'), + ]; + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'email' => 'jean@example.com'], 1), + $this->createRow(['lastName' => 'Martin', 'firstName' => 'Pierre', 'email' => 'pierre@example.com'], 2), + $this->createRow(['lastName' => 'Bernard', 'firstName' => 'Claire', 'email' => 'claire@example.com'], 3), + ]; + + $result = $this->detector->detecter($rows, $existing); + + self::assertFalse($result[0]->estValide()); + self::assertTrue($result[1]->estValide()); + self::assertTrue($result[2]->estValide()); + } + + /** + * @param array $mappedData + */ + private function createRow(array $mappedData, int $lineNumber = 1): ImportRow + { + return new ImportRow( + lineNumber: $lineNumber, + rawData: $mappedData, + mappedData: $mappedData, + ); + } + + /** + * @return array{firstName: string, lastName: string, email: string} + */ + private function teacher(string $firstName, string $lastName, string $email): array + { + return [ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'email' => $email, + ]; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportIntegrationTest.php b/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportIntegrationTest.php new file mode 100644 index 0000000..78351b2 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportIntegrationTest.php @@ -0,0 +1,198 @@ +fixture('enseignants_simple.csv'); + + // 1. Parser le fichier + $parser = new CsvParser(); + $parseResult = $parser->parse($filePath); + + self::assertSame(3, $parseResult->totalRows()); + self::assertSame(['Nom', 'Prénom', 'Email', 'Matières', 'Classes'], $parseResult->columns); + + // 2. Détecter le format + $detector = new ImportFormatDetector(); + $format = $detector->detecter($parseResult->columns); + + self::assertSame(KnownImportFormat::CUSTOM, $format); + + // 3. Suggérer le mapping + $suggester = new TeacherColumnMappingSuggester(); + $suggestedMapping = $suggester->suggerer($parseResult->columns, $format); + + self::assertSame(TeacherImportField::LAST_NAME, $suggestedMapping['Nom']); + self::assertSame(TeacherImportField::FIRST_NAME, $suggestedMapping['Prénom']); + self::assertSame(TeacherImportField::EMAIL, $suggestedMapping['Email']); + self::assertSame(TeacherImportField::SUBJECTS, $suggestedMapping['Matières']); + self::assertSame(TeacherImportField::CLASSES, $suggestedMapping['Classes']); + + // 4. Appliquer le mapping sur les lignes + $rows = []; + $lineNumber = 1; + + foreach ($parseResult->rows as $rawData) { + $mappedData = []; + foreach ($suggestedMapping as $column => $field) { + $mappedData[$field->value] = $rawData[$column] ?? ''; + } + $rows[] = new ImportRow($lineNumber, $rawData, $mappedData); + ++$lineNumber; + } + + self::assertCount(3, $rows); + self::assertSame('Dupont', $rows[0]->mappedData[TeacherImportField::LAST_NAME->value]); + self::assertSame('Mathématiques', $rows[0]->mappedData[TeacherImportField::SUBJECTS->value]); + self::assertSame('6A, 6B', $rows[0]->mappedData[TeacherImportField::CLASSES->value]); + + // 5. Valider les lignes + $validator = new TeacherImportRowValidator(); + $validatedRows = $validator->validerTout($rows); + + foreach ($validatedRows as $row) { + self::assertTrue($row->estValide(), "Ligne {$row->lineNumber} devrait être valide"); + } + + // 6. Générer le rapport + $report = ImportReport::fromValidatedRows($validatedRows); + + self::assertSame(3, $report->totalRows); + self::assertSame(3, $report->importedCount); + self::assertSame(0, $report->errorCount); + } + + #[Test] + public function teacherImportWithInvalidRows(): void + { + $filePath = $this->fixture('enseignants_complet.csv'); + + $parser = new CsvParser(); + $parseResult = $parser->parse($filePath); + + self::assertSame(8, $parseResult->totalRows()); + self::assertContains('Téléphone', $parseResult->columns); + + $detector = new ImportFormatDetector(); + $format = $detector->detecter($parseResult->columns); + + $suggester = new TeacherColumnMappingSuggester(); + $suggestedMapping = $suggester->suggerer($parseResult->columns, $format); + + // La colonne Téléphone ne doit pas être mappée + self::assertArrayNotHasKey('Téléphone', $suggestedMapping); + + $rows = []; + $lineNumber = 1; + + foreach ($parseResult->rows as $rawData) { + $mappedData = []; + foreach ($suggestedMapping as $column => $field) { + $mappedData[$field->value] = $rawData[$column] ?? ''; + } + $rows[] = new ImportRow($lineNumber, $rawData, $mappedData); + ++$lineNumber; + } + + $validator = new TeacherImportRowValidator(); + $validatedRows = $validator->validerTout($rows); + + $report = ImportReport::fromValidatedRows($validatedRows); + + // Moreau (ligne 5) : email manquant → erreur + // Petit (ligne 6) : email invalide → erreur + self::assertSame(8, $report->totalRows); + self::assertSame(6, $report->importedCount); + self::assertSame(2, $report->errorCount); + + // Vérifie les lignes en erreur + $errorLines = array_map( + static fn (ImportRow $row) => $row->lineNumber, + $report->errorRows, + ); + self::assertContains(5, $errorLines); + self::assertContains(6, $errorLines); + } + + #[Test] + public function teacherImportCsvCommaFormat(): void + { + $filePath = $this->fixture('enseignants_comma.csv'); + + $parser = new CsvParser(); + $parseResult = $parser->parse($filePath); + + self::assertSame(2, $parseResult->totalRows()); + self::assertSame(['Nom', 'Prénom', 'Email'], $parseResult->columns); + + $suggester = new TeacherColumnMappingSuggester(); + $suggestedMapping = $suggester->suggerer($parseResult->columns, KnownImportFormat::CUSTOM); + + // Pas de colonnes SUBJECTS ni CLASSES + self::assertCount(3, $suggestedMapping); + self::assertSame(TeacherImportField::LAST_NAME, $suggestedMapping['Nom']); + self::assertSame(TeacherImportField::FIRST_NAME, $suggestedMapping['Prénom']); + self::assertSame(TeacherImportField::EMAIL, $suggestedMapping['Email']); + + $rows = []; + $lineNumber = 1; + + foreach ($parseResult->rows as $rawData) { + $mappedData = []; + foreach ($suggestedMapping as $column => $field) { + $mappedData[$field->value] = $rawData[$column] ?? ''; + } + $rows[] = new ImportRow($lineNumber, $rawData, $mappedData); + ++$lineNumber; + } + + $validator = new TeacherImportRowValidator(); + $validatedRows = $validator->validerTout($rows); + + $report = ImportReport::fromValidatedRows($validatedRows); + + self::assertSame(2, $report->totalRows); + self::assertSame(2, $report->importedCount); + self::assertSame(0, $report->errorCount); + } + + #[Test] + public function multiValueSubjectsWithPipeSeparator(): void + { + $filePath = $this->fixture('enseignants_complet.csv'); + + $parser = new CsvParser(); + $parseResult = $parser->parse($filePath); + + // Ligne 3 : Bernard;Pierre;...;Physique | Chimie;4A + $bernardRow = $parseResult->rows[2]; + self::assertSame('Physique | Chimie', $bernardRow['Matières']); + } + + private function fixture(string $filename): string + { + return __DIR__ . '/../../../../../fixtures/import/' . $filename; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportRowValidatorTest.php b/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportRowValidatorTest.php new file mode 100644 index 0000000..d0d3698 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/TeacherImportRowValidatorTest.php @@ -0,0 +1,231 @@ +createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean.dupont@ecole.fr', + ]); + + $validated = $validator->valider($row); + + self::assertTrue($validated->estValide()); + } + + #[Test] + public function missingLastNameCreatesError(): void + { + $validator = new TeacherImportRowValidator(); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => '', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertCount(1, $validated->errors); + self::assertSame('lastName', $validated->errors[0]->column); + } + + #[Test] + public function missingFirstNameCreatesError(): void + { + $validator = new TeacherImportRowValidator(); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => '', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertCount(1, $validated->errors); + self::assertSame('firstName', $validated->errors[0]->column); + } + + #[Test] + public function missingEmailCreatesError(): void + { + $validator = new TeacherImportRowValidator(); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => '', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertCount(1, $validated->errors); + self::assertSame('email', $validated->errors[0]->column); + } + + #[Test] + public function invalidEmailCreatesError(): void + { + $validator = new TeacherImportRowValidator(); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'not-an-email', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertSame('email', $validated->errors[0]->column); + } + + #[Test] + public function unknownSubjectCreatesErrorWhenExistingSubjectsProvided(): void + { + $validator = new TeacherImportRowValidator( + existingSubjectNames: ['Mathématiques', 'Physique'], + ); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + TeacherImportField::SUBJECTS->value => 'Mathématiques, Chimie', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertSame('subjects', $validated->errors[0]->column); + self::assertStringContainsString('Chimie', $validated->errors[0]->message); + } + + #[Test] + public function knownSubjectsPassValidation(): void + { + $validator = new TeacherImportRowValidator( + existingSubjectNames: ['Mathématiques', 'Physique'], + ); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + TeacherImportField::SUBJECTS->value => 'Mathématiques, Physique', + ]); + + $validated = $validator->valider($row); + + self::assertTrue($validated->estValide()); + } + + #[Test] + public function unknownClassCreatesErrorWhenExistingClassesProvided(): void + { + $validator = new TeacherImportRowValidator( + existingClassNames: ['6A', '6B', '5A'], + ); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + TeacherImportField::CLASSES->value => '6A, 4C', + ]); + + $validated = $validator->valider($row); + + self::assertFalse($validated->estValide()); + self::assertSame('classes', $validated->errors[0]->column); + self::assertStringContainsString('4C', $validated->errors[0]->message); + } + + #[Test] + public function emptySubjectsPassValidation(): void + { + $validator = new TeacherImportRowValidator( + existingSubjectNames: ['Mathématiques'], + ); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + TeacherImportField::SUBJECTS->value => '', + ]); + + $validated = $validator->valider($row); + + self::assertTrue($validated->estValide()); + } + + #[Test] + public function validerToutValidatesAllRows(): void + { + $validator = new TeacherImportRowValidator(); + + $rows = [ + $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + ]), + $this->createRow([ + TeacherImportField::LAST_NAME->value => '', + TeacherImportField::FIRST_NAME->value => 'Marie', + TeacherImportField::EMAIL->value => 'marie@ecole.fr', + ]), + ]; + + $validated = $validator->validerTout($rows); + + self::assertCount(2, $validated); + self::assertTrue($validated[0]->estValide()); + self::assertFalse($validated[1]->estValide()); + } + + #[Test] + public function subjectsNotCheckedWhenNoExistingSubjectsProvided(): void + { + $validator = new TeacherImportRowValidator(); + + $row = $this->createRow([ + TeacherImportField::LAST_NAME->value => 'Dupont', + TeacherImportField::FIRST_NAME->value => 'Jean', + TeacherImportField::EMAIL->value => 'jean@ecole.fr', + TeacherImportField::SUBJECTS->value => 'N\'importe quoi', + ]); + + $validated = $validator->valider($row); + + self::assertTrue($validated->estValide()); + } + + /** + * @param array $mappedData + */ + private function createRow(array $mappedData, int $line = 1): ImportRow + { + return new ImportRow($line, $mappedData, $mappedData); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/TeacherColumnMappingTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherColumnMappingTest.php new file mode 100644 index 0000000..f258a1e --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherColumnMappingTest.php @@ -0,0 +1,129 @@ + TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + + self::assertCount(3, $mapping->colonnesSources()); + self::assertSame(KnownImportFormat::CUSTOM, $mapping->format); + } + + #[Test] + public function creerWithOptionalFieldsSucceeds(): void + { + $mapping = TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + 'Matières' => TeacherImportField::SUBJECTS, + 'Classes' => TeacherImportField::CLASSES, + ], + KnownImportFormat::CUSTOM, + ); + + self::assertCount(5, $mapping->colonnesSources()); + } + + #[Test] + public function creerSansNomLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + TeacherColumnMapping::creer( + [ + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function creerSansPrenomLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Email' => TeacherImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function creerSansEmailLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function champPourReturnsMappedField(): void + { + $mapping = TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + + self::assertSame(TeacherImportField::LAST_NAME, $mapping->champPour('Nom')); + self::assertSame(TeacherImportField::FIRST_NAME, $mapping->champPour('Prénom')); + self::assertNull($mapping->champPour('Inconnu')); + } + + #[Test] + public function equalsComparesCorrectly(): void + { + $mapping1 = TeacherColumnMapping::creer( + ['Nom' => TeacherImportField::LAST_NAME, 'Prénom' => TeacherImportField::FIRST_NAME, 'Email' => TeacherImportField::EMAIL], + KnownImportFormat::CUSTOM, + ); + + $mapping2 = TeacherColumnMapping::creer( + ['Nom' => TeacherImportField::LAST_NAME, 'Prénom' => TeacherImportField::FIRST_NAME, 'Email' => TeacherImportField::EMAIL], + KnownImportFormat::CUSTOM, + ); + + $mapping3 = TeacherColumnMapping::creer( + ['Nom' => TeacherImportField::LAST_NAME, 'Prénom' => TeacherImportField::FIRST_NAME, 'Email' => TeacherImportField::EMAIL], + KnownImportFormat::PRONOTE, + ); + + self::assertTrue($mapping1->equals($mapping2)); + self::assertFalse($mapping1->equals($mapping3)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportBatchTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportBatchTest.php new file mode 100644 index 0000000..ab7454e --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportBatchTest.php @@ -0,0 +1,266 @@ +createBatch(); + + self::assertSame(ImportStatus::PENDING, $batch->status); + self::assertSame(0, $batch->importedCount); + self::assertSame(0, $batch->errorCount); + self::assertNull($batch->completedAt); + self::assertNull($batch->mapping); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $createdAt = new DateTimeImmutable('2026-02-25 10:00:00'); + $columns = ['Nom', 'Prénom', 'Email']; + + $batch = TeacherImportBatch::creer( + tenantId: $tenantId, + originalFilename: 'enseignants.csv', + totalRows: 20, + detectedColumns: $columns, + detectedFormat: KnownImportFormat::CUSTOM, + createdAt: $createdAt, + ); + + self::assertTrue($batch->tenantId->equals($tenantId)); + self::assertSame('enseignants.csv', $batch->originalFilename); + self::assertSame(20, $batch->totalRows); + self::assertSame($columns, $batch->detectedColumns); + self::assertSame(KnownImportFormat::CUSTOM, $batch->detectedFormat); + self::assertEquals($createdAt, $batch->createdAt); + } + + #[Test] + public function creerDoesNotRecordAnyEvent(): void + { + $batch = $this->createBatch(); + + self::assertEmpty($batch->pullDomainEvents()); + } + + #[Test] + public function appliquerMappingSetsMapping(): void + { + $batch = $this->createBatch(); + $mapping = $this->createValidMapping(); + + $batch->appliquerMapping($mapping); + + self::assertNotNull($batch->mapping); + self::assertTrue($batch->mapping->equals($mapping)); + } + + #[Test] + public function enregistrerLignesStoresRows(): void + { + $batch = $this->createBatch(); + $rows = [ + new ImportRow(1, ['Nom' => 'Dupont'], ['lastName' => 'Dupont']), + new ImportRow(2, ['Nom' => 'Martin'], ['lastName' => 'Martin']), + ]; + + $batch->enregistrerLignes($rows); + + self::assertCount(2, $batch->lignes()); + } + + #[Test] + public function demarrerTransitionsToProcessingAndRecordsEvent(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $at = new DateTimeImmutable('2026-02-25 11:00:00'); + + $batch->demarrer($at); + + self::assertSame(ImportStatus::PROCESSING, $batch->status); + + $events = $batch->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ImportEnseignantsLance::class, $events[0]); + self::assertTrue($events[0]->batchId->equals($batch->id)); + self::assertTrue($events[0]->tenantId->equals($batch->tenantId)); + } + + #[Test] + public function demarrerSansMappingLeveException(): void + { + $batch = $this->createBatch(); + + $this->expectException(ImportNonDemarrableException::class); + + $batch->demarrer(new DateTimeImmutable()); + } + + #[Test] + public function demarrerDepuisStatutNonPendingLeveException(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + + $this->expectException(ImportNonDemarrableException::class); + + $batch->demarrer(new DateTimeImmutable()); + } + + #[Test] + public function terminerSetsCompletedStatusAndRecordsEvent(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + $batch->pullDomainEvents(); + + $at = new DateTimeImmutable('2026-02-25 12:00:00'); + $batch->terminer(18, 2, $at); + + self::assertSame(ImportStatus::COMPLETED, $batch->status); + self::assertSame(18, $batch->importedCount); + self::assertSame(2, $batch->errorCount); + self::assertEquals($at, $batch->completedAt); + self::assertTrue($batch->estTermine()); + + $events = $batch->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ImportEnseignantsTermine::class, $events[0]); + self::assertSame(18, $events[0]->importedCount); + self::assertSame(2, $events[0]->errorCount); + } + + #[Test] + public function echouerSetsFailedStatus(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + $batch->pullDomainEvents(); + + $at = new DateTimeImmutable('2026-02-25 12:00:00'); + $batch->echouer(20, $at); + + self::assertSame(ImportStatus::FAILED, $batch->status); + self::assertSame(20, $batch->errorCount); + self::assertEquals($at, $batch->completedAt); + self::assertTrue($batch->estTermine()); + } + + #[Test] + public function lignesValidesFiltersCorrectly(): void + { + $batch = $this->createBatch(); + $rows = [ + new ImportRow(1, [], ['lastName' => 'Dupont']), + new ImportRow(2, [], ['lastName' => ''], [new ImportRowError('lastName', 'Nom vide')]), + new ImportRow(3, [], ['lastName' => 'Martin']), + ]; + + $batch->enregistrerLignes($rows); + + self::assertCount(2, $batch->lignesValides()); + self::assertCount(1, $batch->lignesEnErreur()); + } + + #[Test] + public function progressionCalculatesCorrectly(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + + $batch->terminer(18, 2, new DateTimeImmutable()); + + self::assertEqualsWithDelta(100.0, $batch->progression(), 0.01); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = ImportBatchId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $mapping = $this->createValidMapping(); + $createdAt = new DateTimeImmutable('2026-02-25 10:00:00'); + $completedAt = new DateTimeImmutable('2026-02-25 12:00:00'); + + $batch = TeacherImportBatch::reconstitute( + id: $id, + tenantId: $tenantId, + originalFilename: 'enseignants.csv', + totalRows: 20, + detectedColumns: ['Nom', 'Prénom', 'Email'], + detectedFormat: KnownImportFormat::CUSTOM, + status: ImportStatus::COMPLETED, + mapping: $mapping, + importedCount: 18, + errorCount: 2, + createdAt: $createdAt, + completedAt: $completedAt, + ); + + self::assertTrue($batch->id->equals($id)); + self::assertTrue($batch->tenantId->equals($tenantId)); + self::assertSame('enseignants.csv', $batch->originalFilename); + self::assertSame(20, $batch->totalRows); + self::assertSame(ImportStatus::COMPLETED, $batch->status); + self::assertNotNull($batch->mapping); + self::assertSame(18, $batch->importedCount); + self::assertSame(2, $batch->errorCount); + self::assertEquals($createdAt, $batch->createdAt); + self::assertEquals($completedAt, $batch->completedAt); + self::assertEmpty($batch->pullDomainEvents()); + } + + private function createBatch(): TeacherImportBatch + { + return TeacherImportBatch::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + originalFilename: 'enseignants.csv', + totalRows: 20, + detectedColumns: ['Nom', 'Prénom', 'Email', 'Matières', 'Classes'], + detectedFormat: KnownImportFormat::CUSTOM, + createdAt: new DateTimeImmutable('2026-02-25 10:00:00'), + ); + } + + private function createValidMapping(): TeacherColumnMapping + { + return TeacherColumnMapping::creer( + [ + 'Nom' => TeacherImportField::LAST_NAME, + 'Prénom' => TeacherImportField::FIRST_NAME, + 'Email' => TeacherImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportFieldTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportFieldTest.php new file mode 100644 index 0000000..1bdf4ce --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/TeacherImportFieldTest.php @@ -0,0 +1,71 @@ +estObligatoire()); + self::assertTrue(TeacherImportField::FIRST_NAME->estObligatoire()); + self::assertTrue(TeacherImportField::EMAIL->estObligatoire()); + } + + #[Test] + public function estObligatoireFalseForOptionalFields(): void + { + self::assertFalse(TeacherImportField::SUBJECTS->estObligatoire()); + self::assertFalse(TeacherImportField::CLASSES->estObligatoire()); + } + + #[Test] + public function estMultiValeurForMultiValueFields(): void + { + self::assertTrue(TeacherImportField::SUBJECTS->estMultiValeur()); + self::assertTrue(TeacherImportField::CLASSES->estMultiValeur()); + } + + #[Test] + public function estMultiValeurFalseForSingleValueFields(): void + { + self::assertFalse(TeacherImportField::LAST_NAME->estMultiValeur()); + self::assertFalse(TeacherImportField::FIRST_NAME->estMultiValeur()); + self::assertFalse(TeacherImportField::EMAIL->estMultiValeur()); + } + + #[Test] + public function labelReturnsReadableText(): void + { + self::assertSame('Nom', TeacherImportField::LAST_NAME->label()); + self::assertSame('Prénom', TeacherImportField::FIRST_NAME->label()); + self::assertSame('Email', TeacherImportField::EMAIL->label()); + self::assertSame('Matières', TeacherImportField::SUBJECTS->label()); + self::assertSame('Classes', TeacherImportField::CLASSES->label()); + } + + #[Test] + public function allCasesExist(): void + { + $cases = TeacherImportField::cases(); + + self::assertCount(5, $cases); + } +} diff --git a/backend/tests/fixtures/import/enseignants_comma.csv b/backend/tests/fixtures/import/enseignants_comma.csv new file mode 100644 index 0000000..4795b87 --- /dev/null +++ b/backend/tests/fixtures/import/enseignants_comma.csv @@ -0,0 +1,3 @@ +Nom,Prénom,Email +Dupont,Jean,jean.dupont@ecole.fr +Martin,Marie,marie.martin@ecole.fr diff --git a/backend/tests/fixtures/import/enseignants_complet.csv b/backend/tests/fixtures/import/enseignants_complet.csv new file mode 100644 index 0000000..fe2ad3f --- /dev/null +++ b/backend/tests/fixtures/import/enseignants_complet.csv @@ -0,0 +1,9 @@ +Nom;Prénom;Email;Matières;Classes;Téléphone +Dupont;Jean;jean.dupont@ecole.fr;Mathématiques;6A, 6B;0601020304 +Martin;Marie;marie.martin@ecole.fr;Français, Histoire;5A, 5B;0602030405 +Bernard;Pierre;pierre.bernard@ecole.fr;Physique | Chimie;4A;0603040506 +Leroy;Sophie;sophie.leroy@ecole.fr;Anglais;6A, 5A, 4A;0604050607 +Moreau;Lucas;;SVT;6B;0605060708 +Petit;Emma;emma-invalide;EPS;5B;0606070809 +Roux;Thomas;thomas.roux@ecole.fr;;3A;0607080910 +Garcia;Julie;julie.garcia@ecole.fr;Mathématiques, Physique;6A, 5A;0608091011 diff --git a/backend/tests/fixtures/import/enseignants_simple.csv b/backend/tests/fixtures/import/enseignants_simple.csv new file mode 100644 index 0000000..00ce280 --- /dev/null +++ b/backend/tests/fixtures/import/enseignants_simple.csv @@ -0,0 +1,4 @@ +Nom;Prénom;Email;Matières;Classes +Dupont;Jean;jean.dupont@ecole.fr;Mathématiques;6A, 6B +Martin;Marie;marie.martin@ecole.fr;Français, Histoire;5A +Bernard;Pierre;pierre.bernard@ecole.fr;; diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 7a56e3a..ca128ea 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -300,15 +300,17 @@ test.describe('Dashboard', () => { await expect(pedagogyLink).toBeVisible(); }); - test('import action is disabled (bientot disponible)', async ({ page }) => { + test('shows import action cards for students and teachers', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); - await expect(page.getByText(/importer des données/i)).toBeVisible(); - await expect(page.getByText(/bientôt disponible/i)).toBeVisible(); + const studentImport = page.getByRole('link', { name: /importer des élèves/i }); + await expect(studentImport).toBeVisible(); + await expect(studentImport).toHaveAttribute('href', '/admin/import/students'); - const importCard = page.locator('.action-card.disabled'); - await expect(importCard).toBeVisible(); + const teacherImport = page.getByRole('link', { name: /importer des enseignants/i }); + await expect(teacherImport).toBeVisible(); + await expect(teacherImport).toHaveAttribute('href', '/admin/import/teachers'); }); test('shows placeholder sections for admin stats', async ({ page }) => { diff --git a/frontend/e2e/student-import.spec.ts b/frontend/e2e/student-import.spec.ts index 0226bfd..12a7f56 100644 --- a/frontend/e2e/student-import.spec.ts +++ b/frontend/e2e/student-import.spec.ts @@ -265,7 +265,8 @@ test.describe('Student Import via CSV', () => { }); test('[P0] completes full import flow with progress and report', async ({ page }) => { - const csvContent = 'Nom;Prénom;Classe\nTestImport;Alice;E2E Import A\nTestImport;Bob;E2E Import A\n'; + const suffix = Date.now().toString().slice(-6); + const csvContent = `Nom;Prénom;Classe\nTestImport${suffix};Alice;E2E Import A\nTestImport${suffix};Bob;E2E Import A\n`; const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent); await loginAsAdmin(page); @@ -298,10 +299,17 @@ test.describe('Student Import via CSV', () => { await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible(); try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup imported students to avoid cross-run duplicate detection + try { + runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}'`); + } catch { /* ignore */ } }); test('[P1] imports only valid rows when errors exist', async ({ page }) => { - const csvContent = 'Nom;Prénom;Classe\nDurand;Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n'; + const suffix = Date.now().toString().slice(-6); + const csvContent = `Nom;Prénom;Classe\nDurand${suffix};Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n`; const csvPath = createCsvFixture('e2e-import-valid-only.csv', csvContent); await loginAsAdmin(page); @@ -340,10 +348,17 @@ test.describe('Student Import via CSV', () => { await expect(importedStat.locator('.stat-value')).toHaveText('1'); try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup + try { + runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}'`); + } catch { /* ignore */ } }); test('[P1] shows unknown classes and allows auto-creation', async ({ page }) => { - const csvContent = 'Nom;Prénom;Classe\nLemaire;Paul;E2E NewAutoClass\n'; + const suffix = Date.now().toString().slice(-6); + const csvContent = `Nom;Prénom;Classe\nLemaire${suffix};Paul;E2E NewAutoClass\n`; const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent); await loginAsAdmin(page); @@ -364,13 +379,11 @@ test.describe('Student Import via CSV', () => { await expect(page.locator('.unknown-classes')).toBeVisible(); await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass'); - // Check auto-create checkbox + // Check auto-create checkbox — this resolves class errors, + // so the adjusted preview shows all rows as valid and the import button is enabled await page.locator('.unknown-classes input[type="checkbox"]').check(); - // Select "import all rows" since unknown class makes row invalid (validCount=0) - await page.locator('input[type="radio"][name="importMode"][value="false"]').check(); - - // Launch import + // Launch import (no need for radio — adjustedValidCount is now 1) await page.getByRole('button', { name: /lancer l'import/i }).click(); // Wait for completion @@ -383,9 +396,10 @@ test.describe('Student Import via CSV', () => { try { unlinkSync(csvPath); } catch { /* ignore */ } - // Cleanup: delete assignments then class (FK constraint) + // Cleanup: delete assignments, users, then class (FK constraint) try { runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Lemaire${suffix}'`); runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`); } catch { /* ignore */ } }); @@ -490,4 +504,88 @@ test.describe('Student Import via CSV', () => { try { unlinkSync(csvPath); } catch { /* ignore */ } }); + + test('[P1] clicking the dropzone opens the file picker', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + // Click the dropzone and verify the file chooser opens + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser', { timeout: 5000 }), + page.locator('.dropzone').click() + ]); + + // The file chooser was triggered — verify it accepts csv/xlsx + expect(fileChooser).toBeTruthy(); + }); + + test('[P1] detects duplicate students in preview when re-importing same file', async ({ page }) => { + // First import: create students + const suffix = Date.now().toString().slice(-6); + const csvContent = `Nom;Prénom;Classe\nDupliTest${suffix};Alice;E2E Import A\nDupliTest${suffix};Bob;E2E Import A\n`; + const csvPath = createCsvFixture('e2e-import-dupli-first.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + // Upload → Mapping → Preview → Confirm (first import) + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /lancer l'import/i }).click(); + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Second import: same students should be detected as duplicates + await page.goto(`${ALPHA_URL}/admin/import/students`); + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Should show duplicates detected + await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('2'); + + // All rows should be in error (duplicates) + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup: remove imported students + try { + runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}'`); + } catch { /* ignore */ } + }); + + test('[P1] detects intra-file duplicate students in preview', async ({ page }) => { + const suffix = Date.now().toString().slice(-6); + const csvContent = `Nom;Prénom;Classe\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Bob;E2E Import A\n`; + const csvPath = createCsvFixture('e2e-import-intra-dupli.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + // Upload → Mapping → Preview + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Should detect 1 intra-file duplicate (second Alice) + await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1'); + + // 2 valid (first Alice + Bob), 1 error (second Alice) + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('2'); + await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); }); diff --git a/frontend/e2e/teacher-import.spec.ts b/frontend/e2e/teacher-import.spec.ts new file mode 100644 index 0000000..5969abd --- /dev/null +++ b/frontend/e2e/teacher-import.spec.ts @@ -0,0 +1,487 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { writeFileSync, mkdirSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-teacher-import-admin@example.com'; +const ADMIN_PASSWORD = 'TeacherImportTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runCommand(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId, academicYearId }; +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +function createCsvFixture(filename: string, content: string): string { + const tmpDir = join(__dirname, 'fixtures'); + mkdirSync(tmpDir, { recursive: true }); + const filePath = join(tmpDir, filename); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +test.describe('Teacher Import via CSV', () => { + test.describe.configure({ mode: 'serial' }); + + let subjectId: string; + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId } = resolveDeterministicIds(); + const suffix = Date.now().toString().slice(-8); + subjectId = `00000200-e2e0-4000-8000-${suffix}0001`; + + // Create a subject for valid import rows + try { + runCommand( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E Maths', 'MATH', NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { + // Subject may already exist + } + + // Clean up auto-created subjects from previous runs + try { + runCommand(`DELETE FROM teacher_assignments WHERE subject_id IN (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%')`); + runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%'`); + } catch { /* ignore */ } + + // Clean up saved column mappings from previous runs to avoid stale mapping suggestions + try { + runCommand(`DELETE FROM saved_teacher_column_mappings WHERE tenant_id = '${TENANT_ID}'`); + } catch { /* ignore */ } + }); + + test('displays the import wizard page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.getByRole('heading', { name: /import d'enseignants/i })).toBeVisible({ + timeout: 15000 + }); + + // Verify stepper is visible with 4 steps + await expect(page.locator('.stepper .step')).toHaveCount(4); + + // Verify dropzone is visible + await expect(page.locator('.dropzone')).toBeVisible(); + await expect(page.getByText(/glissez votre fichier/i)).toBeVisible(); + }); + + test('uploads a CSV file and shows mapping step', async ({ page }) => { + const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean.dupont@ecole.fr\nMartin;Marie;marie.martin@ecole.fr\n'; + const csvPath = createCsvFixture('e2e-teacher-import-test.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Should transition to mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // File info should be visible + await expect(page.getByText(/e2e-teacher-import-test\.csv/i)).toBeVisible(); + await expect(page.getByText(/2 lignes/i)).toBeVisible(); + + // Column names should appear in mapping + await expect(page.locator('.column-name').filter({ hasText: /^Nom$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Prénom$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Email$/ })).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('validates required fields in mapping', async ({ page }) => { + const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n'; + const csvPath = createCsvFixture('e2e-teacher-import-required.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled + const validateButton = page.getByRole('button', { name: /valider le mapping/i }); + await expect(validateButton).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('navigates back from mapping to upload', async ({ page }) => { + const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n'; + const csvPath = createCsvFixture('e2e-teacher-import-back.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Click back button + await page.getByRole('button', { name: /retour/i }).click(); + + // Should be back on upload step + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 }); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('rejects non-CSV files', async ({ page }) => { + const txtPath = createCsvFixture('e2e-teacher-import-bad.pdf', 'not a csv file'); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(txtPath); + + // Should show error + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + + try { unlinkSync(txtPath); } catch { /* ignore */ } + }); + + test('shows preview step with valid/error counts', async ({ page }) => { + const csvContent = + 'Nom;Prénom;Email\nDupont;Jean;jean.preview@ecole.fr\n;Marie;marie@ecole.fr\nMartin;;martin@ecole.fr\n'; + const csvPath = createCsvFixture('e2e-teacher-import-preview.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Submit mapping + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Wait for preview step + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Should show valid and error counts + await expect(page.locator('.summary-card.valid')).toBeVisible(); + await expect(page.locator('.summary-card.error')).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P0] completes full import flow with progress and report', async ({ page }) => { + const suffix = Date.now().toString().slice(-6); + const email1 = `alice.prof.${suffix}@ecole.fr`; + const email2 = `bob.prof.${suffix}@ecole.fr`; + const csvContent = `Nom;Prénom;Email\nTestProf${suffix};Alice;${email1}\nTestProf${suffix};Bob;${email2}\n`; + const csvPath = createCsvFixture('e2e-teacher-import-full-flow.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + // Step 1: Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Step 2: Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Step 3: Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Step 4: Confirmation — wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify report stats + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('2'); + const errorStat = stats.filter({ hasText: /erreurs/ }); + await expect(errorStat.locator('.stat-value')).toHaveText('0'); + + // Verify action buttons + await expect(page.getByRole('button', { name: /voir les utilisateurs/i })).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup + try { + runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}'))`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}')`); + } catch { /* ignore */ } + }); + + test('[P1] imports only valid rows when errors exist', async ({ page }) => { + const suffix = Date.now().toString().slice(-6); + const validEmail = `sophie.durand.${suffix}@ecole.fr`; + const csvContent = `Nom;Prénom;Email\nDurand${suffix};Sophie;${validEmail}\n;Marie;marie.err@ecole.fr\nMartin;;martin.err@ecole.fr\n`; + const csvPath = createCsvFixture('e2e-teacher-import-valid-only.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + // Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Verify error count + await expect(page.locator('.summary-card.error .summary-number')).toHaveText('2'); + + // Verify error detail rows are visible + await expect(page.locator('.error-detail').first()).toBeVisible(); + + // "Import valid only" radio should be selected by default + const validOnlyRadio = page.locator('input[type="radio"][name="importMode"][value="true"]'); + await expect(validOnlyRadio).toBeChecked(); + + // Launch import (should only import 1 valid row) + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify only 1 teacher imported + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('1'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup + try { + runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}'`); + } catch { /* ignore */ } + }); + + test('[P1] shows unknown subjects and allows auto-creation', async ({ page }) => { + const suffix = Date.now().toString().slice(-6); + const subjectName = `E2E AutoSubject${suffix}`; + const email = `paul.lemaire.${suffix}@ecole.fr`; + const csvContent = `Nom;Prénom;Email;Matières\nLemaire${suffix};Paul;${email};${subjectName}\n`; + const csvPath = createCsvFixture('e2e-teacher-import-auto-subject.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + // Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Verify unknown subjects section + await expect(page.locator('.unknown-items').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.item-tag').first()).toContainText(subjectName); + + // Check auto-create checkbox + await page.locator('.unknown-items input[type="checkbox"]').check(); + + // Launch import + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify teacher imported + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('1'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup + try { + runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}')`); + runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name = '${subjectName}'`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}'`); + } catch { /* ignore */ } + }); + + test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => { + const csvContent = [ + 'Nom;Prénom;Email', + 'Alpha;Un;alpha.un@ecole.fr', + 'Bravo;Deux;bravo.deux@ecole.fr', + 'Charlie;Trois;charlie.trois@ecole.fr', + 'Delta;Quatre;delta.quatre@ecole.fr', + 'Echo;Cinq;echo.cinq@ecole.fr', + 'Foxtrot;Six;foxtrot.six@ecole.fr', + 'Golf;Sept;golf.sept@ecole.fr', + 'Hotel;Huit;hotel.huit@ecole.fr' + ].join('\n') + '\n'; + const csvPath = createCsvFixture('e2e-teacher-import-preview-5.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Verify preview section exists + await expect(page.locator('.preview-section')).toBeVisible(); + + // Verify heading shows 5 premières lignes + await expect(page.locator('.preview-section h3')).toContainText('5 premières lignes'); + + // Verify exactly 5 rows in the preview table (not 8) + await expect(page.locator('.preview-table tbody tr')).toHaveCount(5); + + // Verify total row count in file info + await expect(page.getByText(/8 lignes/i)).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P1] skips teachers with duplicate emails and shows duplicate card', async ({ page }) => { + const DUPLICATE_EMAIL = 'e2e-duplicate-teacher@ecole.fr'; + const UNIQUE_EMAIL = `e2e-unique-teacher-${Date.now()}@ecole.fr`; + + // Clean up any stale user from previous runs (DB + cache) to avoid cache/DB desync + try { + runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}')`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}'`); + } catch { /* ignore */ } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + + // Create a pre-existing user with the duplicate email + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${DUPLICATE_EMAIL} --password=Unused123 --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const csvContent = `Nom;Prénom;Email\nExistant;Dupli;${DUPLICATE_EMAIL}\nNouveau;Unique;${UNIQUE_EMAIL}\n`; + const csvPath = createCsvFixture('e2e-teacher-import-duplicate-email.csv', csvContent); + + try { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/teachers`); + + // Step 1: Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Step 2: Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Step 3: Preview — duplicates are detected and shown in the summary + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Verify duplicate card is visible + await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1'); + + // "Ignorer les doublons" radio should be default + const ignoreRadio = page.locator('input[type="radio"][name="duplicateMode"][value="false"]'); + await expect(ignoreRadio).toBeChecked(); + + // Launch import with "ignore" mode (default) + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Step 4: Report — wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify report: 1 imported (unique email), 0 errors (duplicate was filtered by "import valid only") + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('1'); + const errorStat = stats.filter({ hasText: /erreurs/ }); + await expect(errorStat.locator('.stat-value')).toHaveText('0'); + } finally { + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup: remove both the pre-existing and imported users (DB + cache) + try { + runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}'))`); + runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}')`); + } catch { /* ignore */ } + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { /* ignore */ } + } + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 14452b8..5d8032f 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -81,11 +81,16 @@ Identité visuelle Logo et couleurs - + Importer des élèves + CSV ou XLSX + + + 📤 + Importer des enseignants + CSV ou XLSX + @@ -198,18 +203,13 @@ transition: all 0.2s; } - .action-card:not(.disabled):hover { + .action-card:hover { border-color: #3b82f6; background: #eff6ff; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } - .action-card.disabled { - opacity: 0.6; - cursor: not-allowed; - } - .action-icon { font-size: 2rem; } diff --git a/frontend/src/lib/features/import/api/teacherImport.ts b/frontend/src/lib/features/import/api/teacherImport.ts new file mode 100644 index 0000000..66290c0 --- /dev/null +++ b/frontend/src/lib/features/import/api/teacherImport.ts @@ -0,0 +1,187 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +// === Types === + +export interface UploadResult { + id: string; + filename: string; + totalRows: number; + columns: string[]; + detectedFormat: string; + suggestedMapping: Record; + preview: PreviewRow[]; +} + +export interface PreviewRow { + line: number; + data: Record; + valid: boolean; + errors: RowError[]; +} + +export interface RowError { + column: string; + message: string; +} + +export interface MappingResult { + id: string; + mapping: Record; + totalRows: number; +} + +export interface PreviewResult { + id: string; + totalRows: number; + validCount: number; + errorCount: number; + rows: PreviewRow[]; + unknownSubjects: string[]; + unknownClasses: string[]; +} + +export interface ConfirmResult { + id: string; + status: string; + message: string; +} + +export interface ImportStatus { + id: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + totalRows: number; + importedCount: number; + errorCount: number; + progression: number; + completedAt: string | null; +} + +export interface ImportReport { + id: string; + status: string; + totalRows: number; + importedCount: number; + errorCount: number; + report: string[]; + errors: { line: number; errors: RowError[] }[]; +} + +// === API Functions === + +/** + * Upload un fichier CSV ou XLSX pour l'import d'enseignants. + */ +export async function uploadFile(file: File): Promise { + const apiUrl = getApiBaseUrl(); + const formData = new FormData(); + formData.append('file', file); + + const response = await authenticatedFetch(`${apiUrl}/import/teachers/upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'upload" + ); + } + + return await response.json(); +} + +/** + * Applique le mapping des colonnes. + */ +export async function applyMapping( + batchId: string, + mapping: Record, + format: string +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/teachers/${batchId}/mapping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mapping, format }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors du mapping' + ); + } + + return await response.json(); +} + +/** + * Récupère la preview avec validation. + */ +export async function fetchPreview(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/teachers/${batchId}/preview`); + + if (!response.ok) { + throw new Error('Erreur lors de la validation'); + } + + return await response.json(); +} + +/** + * Confirme et lance l'import. + */ +export async function confirmImport( + batchId: string, + options: { createMissingSubjects: boolean; importValidOnly: boolean; updateExisting: boolean } +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/teachers/${batchId}/confirm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? + data?.message ?? + data?.detail ?? + 'Erreur lors de la confirmation' + ); + } + + return await response.json(); +} + +/** + * Récupère le statut et la progression de l'import. + */ +export async function fetchImportStatus(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/teachers/${batchId}/status`); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération du statut'); + } + + return await response.json(); +} + +/** + * Récupère le rapport détaillé de l'import. + */ +export async function fetchImportReport(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/teachers/${batchId}/report`); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération du rapport'); + } + + return await response.json(); +} diff --git a/frontend/src/routes/admin/import/students/+page.svelte b/frontend/src/routes/admin/import/students/+page.svelte index 1dc6b74..aed7c9c 100644 --- a/frontend/src/routes/admin/import/students/+page.svelte +++ b/frontend/src/routes/admin/import/students/+page.svelte @@ -69,6 +69,22 @@ let createMissingClasses = $state(false); let importValidOnly = $state(true); + // Adjusted preview rows: when createMissingClasses is checked, treat class-not-found errors as resolved + let adjustedPreviewRows = $derived.by(() => { + if (!previewResult) return []; + if (!createMissingClasses) return previewResult.rows; + return previewResult.rows.map((row) => { + if (row.valid) return row; + const remainingErrors = row.errors.filter((e) => e.column !== 'className'); + return { ...row, valid: remainingErrors.length === 0, errors: remainingErrors }; + }); + }); + let adjustedValidCount = $derived(adjustedPreviewRows.filter((r) => r.valid).length); + let adjustedErrorCount = $derived(adjustedPreviewRows.filter((r) => !r.valid).length); + let duplicateCount = $derived( + adjustedPreviewRows.filter((r) => r.errors.some((e) => e.column === '_duplicate')).length + ); + // === Step 4: Confirmation === let isConfirming = $state(false); let importStatus = $state(null); @@ -395,6 +411,7 @@ ondragover={handleDragOver} ondragleave={handleDragLeave} ondrop={handleDrop} + onclick={() => fileInput?.click()} role="button" tabindex="0" aria-label="Zone de dépôt de fichier" @@ -596,13 +613,19 @@
- {previewResult.validCount} + {adjustedValidCount} Lignes valides
- {previewResult.errorCount} + {adjustedErrorCount} Lignes en erreur
+ {#if duplicateCount > 0} +
+ {duplicateCount} + Doublons détectés +
+ {/if}
{previewResult.totalRows} Total @@ -611,9 +634,14 @@ {#if previewResult.unknownClasses.length > 0} -
-

Classes non trouvées

-

Les classes suivantes n'existent pas encore dans Classeo :

+
+ {#if createMissingClasses} +

Classes qui seront créées

+

Les classes suivantes seront créées automatiquement lors de l'import :

+ {:else} +

Classes non trouvées

+

Les classes suivantes n'existent pas encore dans Classeo :

+ {/if}
{#each previewResult.unknownClasses as cls} {cls} @@ -627,11 +655,11 @@ {/if} - {#if previewResult.errorCount > 0} + {#if adjustedErrorCount > 0}
@@ -1316,6 +1344,11 @@ border-color: #fecaca; } + .summary-card.duplicate { + background: #fffbeb; + border-color: #fde68a; + } + .summary-card.total { background: #f9fafb; } @@ -1334,6 +1367,10 @@ color: #dc2626; } + .summary-card.duplicate .summary-number { + color: #d97706; + } + .summary-label { font-size: 0.75rem; color: #6b7280; @@ -1390,6 +1427,11 @@ margin-bottom: 1.5rem; } + .unknown-classes.resolved { + background: #f0fdf4; + border-color: #bbf7d0; + } + .unknown-classes h3 { font-size: 0.9375rem; font-weight: 600; @@ -1397,12 +1439,20 @@ margin: 0 0 0.5rem; } + .unknown-classes.resolved h3 { + color: #166534; + } + .unknown-classes p { font-size: 0.8125rem; color: #92400e; margin: 0 0 0.75rem; } + .unknown-classes.resolved p { + color: #166534; + } + .class-tags { display: flex; flex-wrap: wrap; diff --git a/frontend/src/routes/admin/import/teachers/+page.svelte b/frontend/src/routes/admin/import/teachers/+page.svelte new file mode 100644 index 0000000..9d10e93 --- /dev/null +++ b/frontend/src/routes/admin/import/teachers/+page.svelte @@ -0,0 +1,1729 @@ + + + + Import enseignants - Classeo + + +
+ + + + + + + + {#if error} + + {/if} + + + {#if currentStep === 'upload'} +
+
fileInput?.click()} + role="button" + tabindex="0" + aria-label="Zone de dépôt de fichier" + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInput?.click(); + }} + > + {#if isUploading} +
+
+

Analyse du fichier en cours...

+
+ {:else} +
+ +

Glissez votre fichier ici

+

ou cliquez pour parcourir

+

CSV, XLSX - Max 10 Mo

+
+ {/if} + + +
+ + +
+

Formats supportés

+
+
+ Personnalisé +

+ Fichier CSV ou Excel avec au minimum : Nom, Prénom, Email. Optionnel : Matières, + Classes (valeurs multiples séparées par virgule). +

+
+
+
+
+ {/if} + + + {#if currentStep === 'mapping'} +
+ {#if uploadResult} +
+
+ {uploadResult.filename} - {uploadResult.totalRows} lignes détectées +
+
+ + + {#if uploadResult.preview.length > 0} +
+

+ Aperçu des données ({Math.min(5, uploadResult.preview.length)} premières lignes) +

+
+ + + + + {#each uploadResult.columns as col} + + {/each} + + + + {#each uploadResult.preview.slice(0, 5) as row} + + + {#each uploadResult.columns as col} + + {/each} + + {/each} + +
#{col}
{row.line}{row.data[mapping[col] ?? ''] ?? + Object.values(row.data)[uploadResult.columns.indexOf(col)] ?? + ''}
+
+
+ {/if} + + +
+

Association des colonnes

+

+ Glissez-déposez les champs Classeo sur les colonnes de votre fichier, ou utilisez les + menus déroulants. Les champs marqués * sont obligatoires. +

+ + +
+ Champs Classeo : +
+ {#each CLASSEO_FIELDS as field} + handleFieldDragStart(e, field.value)} + ondragend={handleFieldDragEnd} + role="button" + tabindex={isFieldMapped(field.value) ? -1 : 0} + aria-label="{field.label}{field.required ? ' (obligatoire)' : ''}" + > + {field.label}{field.required ? ' *' : ''} + + {/each} +
+
+ +
+ {#each uploadResult.columns as column} +
handleMappingRowDragOver(e, column)} + ondragleave={() => handleMappingRowDragLeave(column)} + ondrop={(e) => handleMappingRowDrop(e, column)} + > +
+ {column} +
+
+ +
+
+ +
+
+ {/each} +
+ + {#if !requiredFieldsMapped} +

+ Tous les champs obligatoires (*) doivent être associés pour continuer. +

+ {/if} +
+ +
+ + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'preview'} +
+ {#if isLoadingPreview} +
+
+

Validation des données en cours...

+
+ {:else if previewResult} + +
+
+ {adjustedValidCount} + Lignes valides +
+
+ {adjustedErrorCount} + Lignes en erreur +
+ {#if duplicateCount > 0} +
+ {duplicateCount} + Doublons détectés +
+ {/if} +
+ {previewResult.totalRows} + Total +
+
+ + + {#if previewResult.unknownSubjects.length > 0} +
+ {#if createMissingSubjects} +

Matières qui seront créées

+

Les matières suivantes seront créées automatiquement lors de l'import :

+ {:else} +

Matières non trouvées

+

Les matières suivantes n'existent pas encore dans Classeo :

+ {/if} +
+ {#each previewResult.unknownSubjects as subject} + {subject} + {/each} +
+ +
+ {/if} + + + {#if previewResult.unknownClasses.length > 0} +
+

Classes non trouvées

+

Les classes suivantes n'existent pas dans Classeo. Les enseignants seront importés mais sans affectation à ces classes.

+
+ {#each previewResult.unknownClasses as cls} + {cls} + {/each} +
+
+ {/if} + + + {#if duplicateCount > 0} +
+

Gestion des doublons

+ + +
+ {/if} + + + {#if adjustedErrorCount > 0} +
+ + +
+ {/if} + + +
+

Détail des données

+
+ + + + + + + + + + + + + + {#each adjustedPreviewRows as row} + + + + + + + + + + {#if !row.valid} + + + + {/if} + {/each} + +
LigneNomPrénomEmailMatièresClassesStatut
{row.line}{row.data['lastName'] ?? ''}{row.data['firstName'] ?? ''}{row.data['email'] ?? ''}{row.data['subjects'] ?? ''}{row.data['classes'] ?? ''} + {#if row.valid} + Valide + {:else} + `${e.column}: ${e.message}`).join(', ')} + > + Erreur + + {/if} +
+ {#each row.errors as err} + {err.column} : {err.message} + {/each} +
+
+
+ +
+ + + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'confirmation'} +
+ {#if importStatus?.status === 'completed' || importStatus?.status === 'failed'} + +
+ {#if importStatus.status === 'completed'} +
+ +

Import terminé

+
+ {:else} +
+ +

Import échoué

+
+ {/if} + +
+
+ {importStatus.importedCount} + enseignants importés +
+
+ {importStatus.errorCount} + erreurs +
+
+ +
+ + +
+
+ {:else} + +
+
+

Import en cours...

+

Veuillez patienter pendant l'import de vos enseignants.

+
+ +
+
+
+

+ {importStatus?.progression ?? 0}% + {#if importStatus} + - {importStatus.importedCount + importStatus.errorCount} / {importStatus.totalRows} lignes + traitées + {/if} +

+
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index a9c31e6..31bf19c 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -515,10 +515,15 @@

Gestion des utilisateurs

Invitez et gérez les utilisateurs de votre établissement

- +
+ + Importer enseignants (CSV) + + +
{#if error} @@ -942,6 +947,13 @@ font-size: 0.875rem; } + .header-actions { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + } + /* Buttons */ .btn-primary { display: inline-flex;