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

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

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

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260225211435 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create teacher_import_batches and saved_teacher_column_mappings tables for CSV/XLSX teacher import wizard';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226141803 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add composite index on teacher_import_batches (tenant_id, created_at DESC)';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants échoue.
*/
final readonly class ImportEnseignantsEchoue implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants est lancé.
*/
final readonly class ImportEnseignantsLance implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $totalRows,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants est terminé avec succès.
*/
final readonly class ImportEnseignantsTermine implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $importedCount,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -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é.',

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use App\Administration\Domain\Exception\MappingIncompletException;
use function in_array;
/**
* Value Object représentant l'association entre les colonnes du fichier source
* et les champs Classeo pour l'import enseignants.
*
* @see FR77: Import enseignants via CSV
*/
final readonly class TeacherColumnMapping
{
/**
* @param array<string, TeacherImportField> $mapping Colonne source → champ Classeo
*/
private function __construct(
public array $mapping,
public KnownImportFormat $format,
) {
}
/**
* @param array<string, TeacherImportField> $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<string>
*/
public function colonnesSources(): array
{
return array_keys($this->mapping);
}
public function equals(self $other): bool
{
return $this->mapping === $other->mapping && $this->format === $other->format;
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use App\Administration\Domain\Event\ImportEnseignantsEchoue;
use App\Administration\Domain\Event\ImportEnseignantsLance;
use App\Administration\Domain\Event\ImportEnseignantsTermine;
use App\Administration\Domain\Exception\ImportNonDemarrableException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use DateTimeImmutable;
use function min;
/**
* Aggregate Root représentant un lot d'import d'enseignants.
*
* Gère le cycle de vie d'un import depuis l'upload du fichier
* jusqu'à la confirmation finale.
*
* @see FR77: Import enseignants via CSV
*/
final class TeacherImportBatch extends AggregateRoot
{
public private(set) ?TeacherColumnMapping $mapping = null;
public private(set) int $importedCount = 0;
public private(set) int $errorCount = 0;
public private(set) ?DateTimeImmutable $completedAt = null;
/** @var list<ImportRow> */
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<string> */
public private(set) array $detectedColumns,
public private(set) ?KnownImportFormat $detectedFormat,
public private(set) ImportStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
}
/**
* @param list<string> $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<ImportRow> $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<ImportRow>
*/
public function lignes(): array
{
return $this->rows;
}
/**
* @return list<ImportRow>
*/
public function lignesValides(): array
{
return array_values(array_filter(
$this->rows,
static fn (ImportRow $row): bool => $row->estValide(),
));
}
/**
* @return list<ImportRow>
*/
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<string> $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;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
/**
* Champs Classeo disponibles pour le mapping d'import enseignants.
*
* @see FR77: Import enseignants via CSV
*/
enum TeacherImportField: string
{
case LAST_NAME = 'lastName';
case FIRST_NAME = 'firstName';
case EMAIL = 'email';
case SUBJECTS = 'subjects';
case CLASSES = 'classes';
public function estObligatoire(): bool
{
return match ($this) {
self::LAST_NAME, self::FIRST_NAME, self::EMAIL => 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<self>
*/
public static function champsObligatoires(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $field): bool => $field->estObligatoire(),
));
}
}

View File

@@ -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.
*

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Shared\Domain\Tenant\TenantId;
interface SavedTeacherColumnMappingRepository
{
/**
* @param array<string, TeacherImportField> $mapping
*/
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void;
/**
* @return array<string, TeacherImportField>|null
*/
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Shared\Domain\Tenant\TenantId;
interface TeacherImportBatchRepository
{
public function save(TeacherImportBatch $batch): void;
public function get(ImportBatchId $id): TeacherImportBatch;
public function findById(ImportBatchId $id): ?TeacherImportBatch;
/**
* @return list<TeacherImportBatch>
*/
public function findByTenant(TenantId $tenantId): array;
}

View File

@@ -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,

View File

@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand;
use App\Administration\Application\Service\Import\ImportReport;
use App\Administration\Application\Service\Import\TeacherImportOrchestrator;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Exception\MappingIncompletException;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_slice;
use DateTimeInterface;
use function in_array;
use InvalidArgumentException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Endpoints pour le wizard d'import d'enseignants via CSV/XLSX.
*
* @see FR77: Import enseignants via CSV
*/
#[Route('/api/import/teachers')]
final readonly class TeacherImportController
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
public function __construct(
private Security $security,
private TeacherImportBatchRepository $teacherImportBatchRepository,
private TeacherImportOrchestrator $orchestrator,
private MessageBusInterface $commandBus,
private TenantContext $tenantContext,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* T5.1 : Upload d'un fichier CSV ou XLSX.
*
* Retourne les colonnes détectées, un aperçu et un mapping suggéré.
*/
#[Route('/upload', methods: ['POST'], name: 'api_import_teachers_upload')]
public function upload(Request $request): JsonResponse
{
$user = $this->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<string, string> $mappingData */
$mappingData = $data['mapping'] ?? [];
/** @var string $formatValue */
$formatValue = $data['format'] ?? '';
$format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM;
/** @var array<string, TeacherImportField> $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<string, TeacherImportField> $mapping
*
* @return array<string, string>
*/
private function serializeMapping(array $mapping): array
{
$result = [];
foreach ($mapping as $column => $field) {
$result[$column] = $field->value;
}
return $result;
}
/**
* @param list<ImportRow> $rows
*
* @return list<array<string, mixed>>
*/
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,
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineSavedTeacherColumnMappingRepository implements SavedTeacherColumnMappingRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
{
$serialized = [];
foreach ($mapping as $column => $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<string, string> $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;
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\ImportStatus;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineTeacherImportBatchRepository implements TeacherImportBatchRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TeacherImportBatch $batch): void
{
$this->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<string, mixed> $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<string> $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<string, string>, format: string} $data */
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
/** @var array<string, TeacherImportField> $mapping */
$mapping = [];
foreach ($data['mapping'] as $column => $fieldValue) {
$mapping[$column] = TeacherImportField::from($fieldValue);
}
return TeacherColumnMapping::creer($mapping, KnownImportFormat::from($data['format']));
}
/**
* @return list<ImportRow>
*/
private function hydrateRows(string $json): array
{
/** @var list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}> $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<string, string>, 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<ImportRow> $rows
*
* @return list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}>
*/
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,
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemorySavedTeacherColumnMappingRepository implements SavedTeacherColumnMappingRepository
{
/** @var array<string, array<string, TeacherImportField>> */
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;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryTeacherImportBatchRepository implements TeacherImportBatchRepository
{
/** @var array<string, TeacherImportBatch> */
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),
));
}
}

View File

@@ -0,0 +1,603 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ImportTeachers;
use App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand;
use App\Administration\Application\Command\ImportTeachers\ImportTeachersHandler;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportStatus;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherImportBatchRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ImportTeachersHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryTeacherImportBatchRepository $importBatchRepository;
private InMemoryUserRepository $userRepository;
private InMemorySubjectRepository $subjectRepository;
private InMemoryClassRepository $classRepository;
private InMemoryTeacherAssignmentRepository $assignmentRepository;
private ImportTeachersHandler $handler;
private TenantId $tenantId;
private SchoolId $schoolId;
private MessageBusInterface $eventBus;
protected function setUp(): void
{
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-25 10:00:00');
}
};
$this->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<ImportRow> $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,
);
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\DuplicateDetector;
use App\Administration\Domain\Model\Import\ImportRow;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DuplicateDetectorTest extends TestCase
{
private DuplicateDetector $detector;
protected function setUp(): void
{
$this->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<string, string> $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,
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\MultiValueParser;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class MultiValueParserTest extends TestCase
{
private MultiValueParser $parser;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\TeacherColumnMappingSuggester;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use function count;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use const SORT_REGULAR;
final class TeacherColumnMappingSuggesterTest extends TestCase
{
private TeacherColumnMappingSuggester $suggester;
protected function setUp(): void
{
$this->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']);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\TeacherDuplicateDetector;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherDuplicateDetectorTest extends TestCase
{
private TeacherDuplicateDetector $detector;
protected function setUp(): void
{
$this->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<string, string> $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,
];
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\CsvParser;
use App\Administration\Application\Service\Import\ImportFormatDetector;
use App\Administration\Application\Service\Import\ImportReport;
use App\Administration\Application\Service\Import\TeacherColumnMappingSuggester;
use App\Administration\Application\Service\Import\TeacherImportRowValidator;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Test d'intégration de la chaîne complète d'import enseignants avec de vrais fichiers CSV.
*
* Parse → Détection format → Mapping → Validation → Rapport
*/
final class TeacherImportIntegrationTest extends TestCase
{
#[Test]
public function fullTeacherImportPipeline(): void
{
$filePath = $this->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;
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\TeacherImportRowValidator;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\TeacherImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherImportRowValidatorTest extends TestCase
{
#[Test]
public function validRowPassesValidation(): void
{
$validator = new TeacherImportRowValidator();
$row = $this->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<string, string> $mappedData
*/
private function createRow(array $mappedData, int $line = 1): ImportRow
{
return new ImportRow($line, $mappedData, $mappedData);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Exception\MappingIncompletException;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherColumnMappingTest extends TestCase
{
#[Test]
public function creerWithAllRequiredFieldsSucceeds(): void
{
$mapping = TeacherColumnMapping::creer(
[
'Nom' => 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));
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Event\ImportEnseignantsLance;
use App\Administration\Domain\Event\ImportEnseignantsTermine;
use App\Administration\Domain\Exception\ImportNonDemarrableException;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\ImportStatus;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherImportBatchTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
#[Test]
public function creerCreatesBatchWithPendingStatus(): void
{
$batch = $this->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,
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Model\Import\TeacherImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherImportFieldTest extends TestCase
{
#[Test]
public function champsObligatoiresReturnsRequiredFields(): void
{
$required = TeacherImportField::champsObligatoires();
self::assertCount(3, $required);
self::assertContains(TeacherImportField::LAST_NAME, $required);
self::assertContains(TeacherImportField::FIRST_NAME, $required);
self::assertContains(TeacherImportField::EMAIL, $required);
}
#[Test]
public function estObligatoireForRequiredFields(): void
{
self::assertTrue(TeacherImportField::LAST_NAME->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);
}
}

View File

@@ -0,0 +1,3 @@
Nom,Prénom,Email
Dupont,Jean,jean.dupont@ecole.fr
Martin,Marie,marie.martin@ecole.fr
1 Nom Prénom Email
2 Dupont Jean jean.dupont@ecole.fr
3 Martin Marie marie.martin@ecole.fr

View File

@@ -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
1 Nom Prénom Email Matières Classes Téléphone
2 Dupont Jean jean.dupont@ecole.fr Mathématiques 6A, 6B 0601020304
3 Martin Marie marie.martin@ecole.fr Français, Histoire 5A, 5B 0602030405
4 Bernard Pierre pierre.bernard@ecole.fr Physique | Chimie 4A 0603040506
5 Leroy Sophie sophie.leroy@ecole.fr Anglais 6A, 5A, 4A 0604050607
6 Moreau Lucas SVT 6B 0605060708
7 Petit Emma emma-invalide EPS 5B 0606070809
8 Roux Thomas thomas.roux@ecole.fr 3A 0607080910
9 Garcia Julie julie.garcia@ecole.fr Mathématiques, Physique 6A, 5A 0608091011

View File

@@ -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;;
1 Nom Prénom Email Matières Classes
2 Dupont Jean jean.dupont@ecole.fr Mathématiques 6A, 6B
3 Martin Marie marie.martin@ecole.fr Français, Histoire 5A
4 Bernard Pierre pierre.bernard@ecole.fr

View File

@@ -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 }) => {

View File

@@ -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 */ }
});
});

View File

@@ -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 */ }
}
});
});

View File

@@ -81,11 +81,16 @@
<span class="action-label">Identité visuelle</span>
<span class="action-hint">Logo et couleurs</span>
</a>
<div class="action-card disabled" aria-disabled="true">
<a class="action-card" href="/admin/import/students">
<span class="action-icon">📤</span>
<span class="action-label">Importer des données</span>
<span class="action-hint">Bientôt disponible</span>
</div>
<span class="action-label">Importer des élèves</span>
<span class="action-hint">CSV ou XLSX</span>
</a>
<a class="action-card" href="/admin/import/teachers">
<span class="action-icon">📤</span>
<span class="action-label">Importer des enseignants</span>
<span class="action-hint">CSV ou XLSX</span>
</a>
</div>
</div>
@@ -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;
}

View File

@@ -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<string, string>;
preview: PreviewRow[];
}
export interface PreviewRow {
line: number;
data: Record<string, string>;
valid: boolean;
errors: RowError[];
}
export interface RowError {
column: string;
message: string;
}
export interface MappingResult {
id: string;
mapping: Record<string, string>;
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<UploadResult> {
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<string, string>,
format: string
): Promise<MappingResult> {
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<PreviewResult> {
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<ConfirmResult> {
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<ImportStatus> {
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<ImportReport> {
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();
}

View File

@@ -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<ImportStatus | null>(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 @@
<!-- Summary -->
<div class="preview-summary">
<div class="summary-card valid">
<span class="summary-number">{previewResult.validCount}</span>
<span class="summary-number">{adjustedValidCount}</span>
<span class="summary-label">Lignes valides</span>
</div>
<div class="summary-card error">
<span class="summary-number">{previewResult.errorCount}</span>
<span class="summary-number">{adjustedErrorCount}</span>
<span class="summary-label">Lignes en erreur</span>
</div>
{#if duplicateCount > 0}
<div class="summary-card duplicate">
<span class="summary-number">{duplicateCount}</span>
<span class="summary-label">Doublons détectés</span>
</div>
{/if}
<div class="summary-card total">
<span class="summary-number">{previewResult.totalRows}</span>
<span class="summary-label">Total</span>
@@ -611,9 +634,14 @@
<!-- Unknown classes -->
{#if previewResult.unknownClasses.length > 0}
<div class="unknown-classes">
<div class="unknown-classes" class:resolved={createMissingClasses}>
{#if createMissingClasses}
<h3>Classes qui seront créées</h3>
<p>Les classes suivantes seront créées automatiquement lors de l'import :</p>
{:else}
<h3>Classes non trouvées</h3>
<p>Les classes suivantes n'existent pas encore dans Classeo :</p>
{/if}
<div class="class-tags">
{#each previewResult.unknownClasses as cls}
<span class="class-tag">{cls}</span>
@@ -627,11 +655,11 @@
{/if}
<!-- Import options -->
{#if previewResult.errorCount > 0}
{#if adjustedErrorCount > 0}
<div class="import-options">
<label class="radio-label">
<input type="radio" name="importMode" value={true} bind:group={importValidOnly} />
Importer uniquement les {previewResult.validCount} lignes valides
Importer uniquement les {adjustedValidCount} lignes valides
</label>
<label class="radio-label">
<input type="radio" name="importMode" value={false} bind:group={importValidOnly} />
@@ -656,7 +684,7 @@
</tr>
</thead>
<tbody>
{#each previewResult.rows as row}
{#each adjustedPreviewRows as row}
<tr class:row-valid={row.valid} class:row-error={!row.valid}>
<td>{row.line}</td>
<td>{row.data['lastName'] ?? ''}</td>
@@ -698,12 +726,12 @@
<button
class="btn-primary"
onclick={launchImport}
disabled={isConfirming || (previewResult.validCount === 0 && importValidOnly)}
disabled={isConfirming || (adjustedValidCount === 0 && importValidOnly)}
>
{#if isConfirming}
Lancement...
{:else}
Lancer l'import ({importValidOnly ? previewResult.validCount : previewResult.totalRows} élèves)
Lancer l'import ({importValidOnly ? adjustedValidCount : previewResult.totalRows} élèves)
{/if}
</button>
</div>
@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -515,10 +515,15 @@
<h1>Gestion des utilisateurs</h1>
<p class="subtitle">Invitez et gérez les utilisateurs de votre établissement</p>
</div>
<div class="header-actions">
<a href="/admin/import/teachers" class="btn-secondary">
Importer enseignants (CSV)
</a>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Inviter un utilisateur
</button>
</div>
</header>
{#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;