feat: Permettre l'import d'élèves via fichier CSV ou XLSX

L'import manuel élève par élève est fastidieux pour les établissements
qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes
(upload → mapping → preview → confirmation) permet de traiter un
fichier complet en une seule opération, avec détection automatique
du format (Pronote, École Directe) et validation avant import.

L'import est traité de manière asynchrone via Messenger pour ne pas
bloquer l'interface, avec suivi de progression en temps réel et
réutilisation des mappings entre imports successifs.
This commit is contained in:
2026-02-25 16:51:13 +01:00
parent 560b941821
commit 2420e35492
62 changed files with 7510 additions and 86 deletions

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Application\Command\ImportStudents\ImportStudentsCommand;
use App\Administration\Application\Service\Import\ImportReport;
use App\Administration\Application\Service\Import\StudentImportOrchestrator;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Exception\MappingIncompletException;
use App\Administration\Domain\Model\Import\ColumnMapping;
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\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\ImportBatchRepository;
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'élèves via CSV/XLSX.
*
* @see Story 3.1 - Import élèves via CSV
* @see FR76: Import élèves via CSV
*/
#[Route('/api/import/students')]
final readonly class StudentImportController
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
public function __construct(
private Security $security,
private ImportBatchRepository $importBatchRepository,
private StudentImportOrchestrator $orchestrator,
private MessageBusInterface $commandBus,
private TenantContext $tenantContext,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* T7.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_students_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);
}
/**
* T7.2 : Valider et appliquer le mapping des colonnes.
*/
#[Route('/{id}/mapping', methods: ['POST'], name: 'api_import_students_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, StudentImportField> $mappingFields */
$mappingFields = [];
foreach ($mappingData as $column => $fieldValue) {
$field = StudentImportField::tryFrom($fieldValue);
if ($field !== null) {
$mappingFields[$column] = $field;
}
}
try {
$columnMapping = ColumnMapping::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,
]);
}
/**
* T7.3 : Preview avec validation et erreurs.
*/
#[Route('/{id}/preview', methods: ['GET'], name: 'api_import_students_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']),
'unknownClasses' => $result['unknownClasses'],
]);
}
/**
* T7.4 : Confirmer et lancer l'import.
*/
#[Route('/{id}/confirm', methods: ['POST'], name: 'api_import_students_confirm')]
public function confirm(string $id, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
$data = $request->toArray();
/** @var bool $createMissingClasses */
$createMissingClasses = $data['createMissingClasses'] ?? false;
/** @var bool $importValidOnly */
$importValidOnly = $data['importValidOnly'] ?? true;
$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, $createMissingClasses, $importValidOnly);
$this->commandBus->dispatch(new ImportStudentsCommand(
batchId: (string) $batch->id,
tenantId: $user->tenantId(),
schoolName: $schoolName,
academicYearId: $academicYearId,
createMissingClasses: $createMissingClasses,
));
return new JsonResponse([
'id' => (string) $batch->id,
'status' => 'processing',
'message' => 'Import lancé. Suivez la progression via GET /status.',
], Response::HTTP_ACCEPTED);
}
/**
* T7.5 : Statut et progression.
*/
#[Route('/{id}/status', methods: ['GET'], name: 'api_import_students_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),
]);
}
/**
* T7.6 : Télécharger le rapport.
*/
#[Route('/{id}/report', methods: ['GET'], name: 'api_import_students_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): StudentImportBatch
{
try {
$batch = $this->importBatchRepository->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, StudentImportField> $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,241 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Model\Import\ColumnMapping;
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\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\ImportBatchRepository;
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 DoctrineImportBatchRepository implements ImportBatchRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(StudentImportBatch $batch): void
{
$this->connection->executeStatement(
'INSERT INTO student_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): StudentImportBatch
{
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
}
#[Override]
public function findById(ImportBatchId $id): ?StudentImportBatch
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM student_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 student_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): StudentImportBatch
{
/** @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 = StudentImportBatch::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): ColumnMapping
{
/** @var array{mapping: array<string, string>, format: string} $data */
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
/** @var array<string, StudentImportField> $mapping */
$mapping = [];
foreach ($data['mapping'] as $column => $fieldValue) {
$mapping[$column] = StudentImportField::from($fieldValue);
}
return ColumnMapping::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(ColumnMapping $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,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\StudentImportField;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
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 DoctrineSavedColumnMappingRepository implements SavedColumnMappingRepository
{
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_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_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 = StudentImportField::tryFrom($fieldValue);
if ($field !== null) {
$mapping[$column] = $field;
}
}
return $mapping !== [] ? $mapping : null;
}
}

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\StudentImportBatch;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryImportBatchRepository implements ImportBatchRepository
{
/** @var array<string, StudentImportBatch> */
private array $byId = [];
#[Override]
public function save(StudentImportBatch $batch): void
{
$this->byId[$batch->id->__toString()] = $batch;
}
#[Override]
public function get(ImportBatchId $id): StudentImportBatch
{
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
}
#[Override]
public function findById(ImportBatchId $id): ?StudentImportBatch
{
return $this->byId[$id->__toString()] ?? null;
}
#[Override]
public function findByTenant(TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (StudentImportBatch $batch): bool => $batch->tenantId->equals($tenantId),
));
}
}

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\StudentImportField;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemorySavedColumnMappingRepository implements SavedColumnMappingRepository
{
/** @var array<string, array<string, StudentImportField>> */
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;
}
}