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.
358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
<?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\Model\SchoolClass\AcademicYearId;
|
|
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);
|
|
|
|
$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,
|
|
'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();
|
|
$tenantId = TenantId::fromString($user->tenantId());
|
|
$batch = $this->getBatch($id, $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,
|
|
$tenantId,
|
|
AcademicYearId::fromString($academicYearId),
|
|
);
|
|
|
|
$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,
|
|
);
|
|
}
|
|
}
|