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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user