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