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

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

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

View File

@@ -0,0 +1,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);
}
}