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,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportStudents;
|
||||
|
||||
/**
|
||||
* Commande pour lancer l'import d'élèves en batch.
|
||||
*
|
||||
* Dispatchée de manière asynchrone via le event bus.
|
||||
*/
|
||||
final readonly class ImportStudentsCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $academicYearId,
|
||||
public bool $createMissingClasses = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportStudents;
|
||||
|
||||
use App\Administration\Application\Service\Import\DateParser;
|
||||
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use DomainException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handler pour l'import d'élèves en batch.
|
||||
*
|
||||
* Traite les lignes valides du batch, crée les élèves et les affecte aux classes.
|
||||
*
|
||||
* @see AC5: Import validé → élèves créés en base
|
||||
* @see NFR-SC6: 500 élèves importés en < 2 minutes
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ImportStudentsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ImportBatchRepository $importBatchRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ImportStudentsCommand $command): void
|
||||
{
|
||||
$batchId = ImportBatchId::fromString($command->batchId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$batch = $this->importBatchRepository->get($batchId);
|
||||
|
||||
$batch->demarrer($now);
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
$lignes = $batch->lignes();
|
||||
$importedCount = 0;
|
||||
$errorCount = 0;
|
||||
$processedCount = 0;
|
||||
$createdClasses = [];
|
||||
|
||||
/** @var array<string, ClassId> */
|
||||
$classCache = [];
|
||||
|
||||
foreach ($lignes as $row) {
|
||||
try {
|
||||
$className = trim($row->valeurChamp(StudentImportField::CLASS_NAME) ?? '');
|
||||
|
||||
if (!isset($classCache[$className])) {
|
||||
$classCache[$className] = $this->resolveClassId(
|
||||
$className,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$command->schoolName,
|
||||
$command->createMissingClasses,
|
||||
$now,
|
||||
$createdClasses,
|
||||
);
|
||||
}
|
||||
|
||||
$classId = $classCache[$className];
|
||||
|
||||
$firstName = trim($row->valeurChamp(StudentImportField::FIRST_NAME) ?? '');
|
||||
$lastName = trim($row->valeurChamp(StudentImportField::LAST_NAME) ?? '');
|
||||
|
||||
if ($firstName === '' && $lastName === '') {
|
||||
$fullName = trim($row->valeurChamp(StudentImportField::FULL_NAME) ?? '');
|
||||
if ($fullName !== '') {
|
||||
[$lastName, $firstName] = ImportRowValidator::splitFullName($fullName);
|
||||
}
|
||||
}
|
||||
$emailRaw = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
$birthDate = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
$trimmedStudentNumber = $studentNumber !== null && trim($studentNumber) !== '' ? trim($studentNumber) : null;
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
if ($emailRaw !== null && trim($emailRaw) !== '') {
|
||||
$emailVO = new Email(trim($emailRaw));
|
||||
|
||||
if ($this->userRepository->findByEmail($emailVO, $tenantId) !== null) {
|
||||
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', trim($emailRaw)));
|
||||
}
|
||||
|
||||
$user = User::inviter(
|
||||
email: $emailVO,
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $now,
|
||||
dateNaissance: DateParser::parse($birthDate),
|
||||
studentNumber: $trimmedStudentNumber,
|
||||
);
|
||||
} else {
|
||||
$user = User::inscrire(
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
inscritAt: $now,
|
||||
dateNaissance: DateParser::parse($birthDate),
|
||||
studentNumber: $trimmedStudentNumber,
|
||||
);
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $user->id,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $now,
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
++$importedCount;
|
||||
} catch (DomainException $e) {
|
||||
$this->logger->warning('Import ligne {line} échouée : {message}', [
|
||||
'line' => $row->lineNumber,
|
||||
'message' => $e->getMessage(),
|
||||
'batch_id' => $command->batchId,
|
||||
]);
|
||||
++$errorCount;
|
||||
}
|
||||
|
||||
++$processedCount;
|
||||
if ($processedCount % 50 === 0) {
|
||||
$batch->mettreAJourProgression($importedCount, $errorCount);
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->terminer($importedCount, $errorCount, $this->clock->now());
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $createdClasses
|
||||
*/
|
||||
private function resolveClassId(
|
||||
string $className,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
string $schoolName,
|
||||
bool $createMissingClasses,
|
||||
DateTimeImmutable $now,
|
||||
array &$createdClasses,
|
||||
): ClassId {
|
||||
$classNameVO = new ClassName($className);
|
||||
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
|
||||
|
||||
if ($class !== null) {
|
||||
return $class->id;
|
||||
}
|
||||
|
||||
if (!$createMissingClasses) {
|
||||
throw new DomainException(sprintf('La classe "%s" n\'existe pas.', $className));
|
||||
}
|
||||
|
||||
$newClass = SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId)),
|
||||
academicYearId: $academicYearId,
|
||||
name: $classNameVO,
|
||||
level: null,
|
||||
capacity: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->classRepository->save($newClass);
|
||||
$createdClasses[] = $className;
|
||||
|
||||
return $newClass->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Suggère un mapping automatique des colonnes basé sur les noms de colonnes
|
||||
* et le format détecté.
|
||||
*
|
||||
* @see AC3: Mapping automatique proposé basé sur noms de colonnes
|
||||
*/
|
||||
final readonly class ColumnMappingSuggester
|
||||
{
|
||||
/**
|
||||
* Mappings pré-configurés pour le format Pronote.
|
||||
*
|
||||
* @var array<string, StudentImportField>
|
||||
*/
|
||||
private const array PRONOTE_MAPPING = [
|
||||
'élèves' => StudentImportField::FULL_NAME,
|
||||
'eleves' => StudentImportField::FULL_NAME,
|
||||
'né(e) le' => StudentImportField::BIRTH_DATE,
|
||||
'ne(e) le' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'classe de rattachement' => StudentImportField::CLASS_NAME,
|
||||
'adresse e mail' => StudentImportField::EMAIL,
|
||||
];
|
||||
|
||||
private const array ECOLE_DIRECTE_MAPPING = [
|
||||
'nom' => StudentImportField::LAST_NAME,
|
||||
'prenom' => StudentImportField::FIRST_NAME,
|
||||
'classe' => StudentImportField::CLASS_NAME,
|
||||
'date naissance' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'email' => StudentImportField::EMAIL,
|
||||
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping générique par mots-clés.
|
||||
*
|
||||
* @var array<string, StudentImportField>
|
||||
*/
|
||||
private const array GENERIC_KEYWORDS = [
|
||||
'élèves' => StudentImportField::FULL_NAME,
|
||||
'eleves' => StudentImportField::FULL_NAME,
|
||||
'nom' => StudentImportField::LAST_NAME,
|
||||
'last' => StudentImportField::LAST_NAME,
|
||||
'family' => StudentImportField::LAST_NAME,
|
||||
'surname' => StudentImportField::LAST_NAME,
|
||||
'prénom' => StudentImportField::FIRST_NAME,
|
||||
'prenom' => StudentImportField::FIRST_NAME,
|
||||
'first' => StudentImportField::FIRST_NAME,
|
||||
'given' => StudentImportField::FIRST_NAME,
|
||||
'classe' => StudentImportField::CLASS_NAME,
|
||||
'class' => StudentImportField::CLASS_NAME,
|
||||
'groupe' => StudentImportField::CLASS_NAME,
|
||||
'email' => StudentImportField::EMAIL,
|
||||
'mail' => StudentImportField::EMAIL,
|
||||
'courriel' => StudentImportField::EMAIL,
|
||||
'naissance' => StudentImportField::BIRTH_DATE,
|
||||
'birth' => StudentImportField::BIRTH_DATE,
|
||||
'date' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'genre' => StudentImportField::GENDER,
|
||||
'gender' => StudentImportField::GENDER,
|
||||
'numéro' => StudentImportField::STUDENT_NUMBER,
|
||||
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||
'number' => StudentImportField::STUDENT_NUMBER,
|
||||
'matricule' => StudentImportField::STUDENT_NUMBER,
|
||||
];
|
||||
|
||||
/**
|
||||
* Suggère un mapping pour les colonnes données.
|
||||
*
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
* @param KnownImportFormat $detectedFormat Format détecté
|
||||
*
|
||||
* @return array<string, StudentImportField> Mapping suggéré (colonne → champ)
|
||||
*/
|
||||
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
|
||||
{
|
||||
return match ($detectedFormat) {
|
||||
KnownImportFormat::PRONOTE => $this->mapperAvecReference($columns, self::PRONOTE_MAPPING),
|
||||
KnownImportFormat::ECOLE_DIRECTE => $this->mapperAvecReference($columns, self::ECOLE_DIRECTE_MAPPING),
|
||||
KnownImportFormat::CUSTOM => $this->mapperGenerique($columns),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
* @param array<string, StudentImportField> $reference
|
||||
*
|
||||
* @return array<string, StudentImportField>
|
||||
*/
|
||||
private function mapperAvecReference(array $columns, array $reference): array
|
||||
{
|
||||
$normalizedReference = [];
|
||||
foreach ($reference as $key => $field) {
|
||||
$normalizedReference[$this->normaliser($key)] = $field;
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
if (isset($normalizedReference[$normalized]) && !in_array($normalizedReference[$normalized], $usedFields, true)) {
|
||||
$mapping[$column] = $normalizedReference[$normalized];
|
||||
$usedFields[] = $normalizedReference[$normalized];
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, StudentImportField>
|
||||
*/
|
||||
private function mapperGenerique(array $columns): array
|
||||
{
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
|
||||
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
|
||||
$mapping[$column] = $field;
|
||||
$usedFields[] = $field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
|
||||
use function count;
|
||||
use function fclose;
|
||||
use function fgetcsv;
|
||||
use function fopen;
|
||||
use function mb_convert_encoding;
|
||||
use function mb_detect_encoding;
|
||||
|
||||
/**
|
||||
* Service de parsing de fichiers CSV avec détection d'encoding UTF-8.
|
||||
*
|
||||
* Supporte les séparateurs courants (virgule, point-virgule, tabulation).
|
||||
*/
|
||||
final readonly class CsvParser
|
||||
{
|
||||
private const int MAX_LINE_LENGTH = 0;
|
||||
private const array SEPARATORS = [';', ',', "\t"];
|
||||
|
||||
public function parse(string $filePath): FileParseResult
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||
}
|
||||
|
||||
$content = $this->convertToUtf8($content);
|
||||
$content = $this->stripBom($content);
|
||||
|
||||
$separator = $this->detectSeparator($content);
|
||||
|
||||
$lines = $this->parseContent($content, $separator);
|
||||
|
||||
if ($lines === []) {
|
||||
throw FichierImportInvalideException::fichierVide();
|
||||
}
|
||||
|
||||
$columns = array_shift($lines);
|
||||
|
||||
$rows = [];
|
||||
foreach ($lines as $line) {
|
||||
if ($this->isEmptyLine($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $index => $column) {
|
||||
$row[$column] = $line[$index] ?? '';
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return new FileParseResult($columns, $rows);
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertToUtf8(string $content): string
|
||||
{
|
||||
$encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||
|
||||
if ($encoding !== false && $encoding !== 'UTF-8') {
|
||||
$converted = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||||
|
||||
return $converted !== false ? $converted : $content;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function stripBom(string $content): string
|
||||
{
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
return substr($content, 3);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function detectSeparator(string $content): string
|
||||
{
|
||||
$firstLine = strtok($content, "\n");
|
||||
if ($firstLine === false) {
|
||||
return ';';
|
||||
}
|
||||
|
||||
$maxCount = 0;
|
||||
$bestSeparator = ';';
|
||||
|
||||
foreach (self::SEPARATORS as $separator) {
|
||||
$count = substr_count($firstLine, $separator);
|
||||
if ($count > $maxCount) {
|
||||
$maxCount = $count;
|
||||
$bestSeparator = $separator;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<list<string>>
|
||||
*/
|
||||
private function parseContent(string $content, string $separator): array
|
||||
{
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
if ($stream === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
$lines = [];
|
||||
while (($line = fgetcsv($stream, self::MAX_LINE_LENGTH, $separator, '"', '')) !== false) {
|
||||
/** @var list<string> $sanitized */
|
||||
$sanitized = array_map('strval', $line);
|
||||
$lines[] = $sanitized;
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $line
|
||||
*/
|
||||
private function isEmptyLine(array $line): bool
|
||||
{
|
||||
return count($line) === 1 && trim((string) $line[0]) === '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Parse des dates dans les formats courants utilisés par les fichiers d'import.
|
||||
*/
|
||||
final class DateParser
|
||||
{
|
||||
private const array FORMATS = ['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'];
|
||||
|
||||
public static function parse(?string $date): ?DateTimeImmutable
|
||||
{
|
||||
if ($date === null || trim($date) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($date);
|
||||
|
||||
foreach (self::FORMATS as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
|
||||
if ($parsed !== false && $parsed->format($format) === $trimmed) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* Résultat du parsing d'un fichier d'import.
|
||||
*
|
||||
* Contient les colonnes détectées et les données brutes extraites.
|
||||
*/
|
||||
final readonly class FileParseResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $columns Noms des colonnes détectées
|
||||
* @param list<array<string, string>> $rows Données brutes (colonne → valeur)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $columns,
|
||||
public array $rows,
|
||||
) {
|
||||
}
|
||||
|
||||
public function totalRows(): int
|
||||
{
|
||||
return count($this->rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, string>>
|
||||
*/
|
||||
public function preview(int $limit = 5): array
|
||||
{
|
||||
return array_slice($this->rows, 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
|
||||
/**
|
||||
* Détecte automatiquement le format d'import (Pronote, École Directe)
|
||||
* à partir des noms de colonnes.
|
||||
*/
|
||||
final readonly class ImportFormatDetector
|
||||
{
|
||||
/**
|
||||
* Colonnes caractéristiques de Pronote.
|
||||
*/
|
||||
private const array PRONOTE_COLUMNS = [
|
||||
'Élèves',
|
||||
'Né(e) le',
|
||||
'Sexe',
|
||||
'Classe de rattachement',
|
||||
'Adresse E-mail',
|
||||
];
|
||||
|
||||
/**
|
||||
* Colonnes caractéristiques d'École Directe.
|
||||
*/
|
||||
private const array ECOLE_DIRECTE_COLUMNS = [
|
||||
'NOM',
|
||||
'PRENOM',
|
||||
'CLASSE',
|
||||
'DATE_NAISSANCE',
|
||||
'SEXE',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
*/
|
||||
public function detecter(array $columns): KnownImportFormat
|
||||
{
|
||||
$normalizedColumns = array_map($this->normaliser(...), $columns);
|
||||
|
||||
if ($this->matchesPronote($normalizedColumns)) {
|
||||
return KnownImportFormat::PRONOTE;
|
||||
}
|
||||
|
||||
if ($this->matchesEcoleDirecte($normalizedColumns)) {
|
||||
return KnownImportFormat::ECOLE_DIRECTE;
|
||||
}
|
||||
|
||||
return KnownImportFormat::CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $normalizedColumns
|
||||
*/
|
||||
private function matchesPronote(array $normalizedColumns): bool
|
||||
{
|
||||
$pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS);
|
||||
|
||||
return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $normalizedColumns
|
||||
*/
|
||||
private function matchesEcoleDirecte(array $normalizedColumns): bool
|
||||
{
|
||||
$ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS);
|
||||
|
||||
return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $actualColumns
|
||||
* @param list<string> $expectedColumns
|
||||
*/
|
||||
private function matchThreshold(array $actualColumns, array $expectedColumns, int $minMatches): bool
|
||||
{
|
||||
$matches = 0;
|
||||
|
||||
foreach ($expectedColumns as $expected) {
|
||||
foreach ($actualColumns as $actual) {
|
||||
if ($actual === $expected) {
|
||||
++$matches;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matches >= $minMatches;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
|
||||
use function count;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Rapport d'import généré après validation ou exécution.
|
||||
*
|
||||
* @see AC5: Rapport affiché : X élèves importés, Y erreurs ignorées
|
||||
*/
|
||||
final readonly class ImportReport
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $validRows Lignes valides importées
|
||||
* @param list<ImportRow> $errorRows Lignes en erreur
|
||||
* @param list<string> $createdClasses Classes créées automatiquement
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalRows,
|
||||
public int $importedCount,
|
||||
public int $errorCount,
|
||||
public array $validRows,
|
||||
public array $errorRows,
|
||||
public array $createdClasses = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows Toutes les lignes validées
|
||||
* @param list<string> $createdClasses Classes créées automatiquement
|
||||
*/
|
||||
public static function fromValidatedRows(array $rows, array $createdClasses = []): self
|
||||
{
|
||||
$valid = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row->estValide()) {
|
||||
$valid[] = $row;
|
||||
} else {
|
||||
$errors[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
totalRows: count($rows),
|
||||
importedCount: count($valid),
|
||||
errorCount: count($errors),
|
||||
validRows: $valid,
|
||||
errorRows: $errors,
|
||||
createdClasses: $createdClasses,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un résumé texte du rapport.
|
||||
*
|
||||
* @return list<string> Lignes du rapport
|
||||
*/
|
||||
public function lignesRapport(): array
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = sprintf('Import terminé : %d élèves importés, %d erreurs', $this->importedCount, $this->errorCount);
|
||||
|
||||
if ($this->createdClasses !== []) {
|
||||
$lines[] = sprintf('Classes créées automatiquement : %s', implode(', ', $this->createdClasses));
|
||||
}
|
||||
|
||||
foreach ($this->errorRows as $row) {
|
||||
foreach ($row->errors as $error) {
|
||||
$lines[] = sprintf('Ligne %d, %s', $row->lineNumber, $error);
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Valide les lignes d'import après mapping.
|
||||
*
|
||||
* Vérifie que les champs obligatoires sont remplis,
|
||||
* les formats sont corrects (email, dates), et les classes existent.
|
||||
*
|
||||
* @see AC4: Lignes valides en vert, lignes avec erreurs en rouge
|
||||
*/
|
||||
final readonly class ImportRowValidator
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
|
||||
*/
|
||||
public function __construct(
|
||||
private ?array $existingClassNames = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function valider(ImportRow $row): ImportRow
|
||||
{
|
||||
$row = $this->expanderNomComplet($row);
|
||||
|
||||
$errors = [];
|
||||
|
||||
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
|
||||
$errors = [...$errors, ...$this->validerEmail($row)];
|
||||
$errors = [...$errors, ...$this->validerDateNaissance($row)];
|
||||
$errors = [...$errors, ...$this->validerClasse($row)];
|
||||
|
||||
if ($errors !== []) {
|
||||
return $row->avecErreurs(...$errors);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function validerTout(array $rows): array
|
||||
{
|
||||
return array_map($this->valider(...), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerChampsObligatoires(ImportRow $row): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach (StudentImportField::champsObligatoires() as $field) {
|
||||
$value = $row->valeurChamp($field);
|
||||
|
||||
if ($value === null || trim($value) === '') {
|
||||
$errors[] = new ImportRowError(
|
||||
$field->value,
|
||||
sprintf('Le champ "%s" est obligatoire.', $field->label()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerEmail(ImportRow $row): array
|
||||
{
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
|
||||
if ($email === null || trim($email) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::EMAIL->value,
|
||||
sprintf('L\'adresse email "%s" est invalide.', $email),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerDateNaissance(ImportRow $row): array
|
||||
{
|
||||
$date = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||
|
||||
if ($date === null || trim($date) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (DateParser::parse($date) === null) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::BIRTH_DATE->value,
|
||||
sprintf('La date "%s" est invalide. Formats acceptés : JJ/MM/AAAA, AAAA-MM-JJ.', $date),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerClasse(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingClassNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
|
||||
if ($className === null || trim($className) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!in_array(trim($className), $this->existingClassNames, true)) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::CLASS_NAME->value,
|
||||
sprintf('La classe "%s" n\'existe pas.', $className),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Si FULL_NAME est renseigné et que LAST_NAME/FIRST_NAME sont vides,
|
||||
* on dérive nom et prénom depuis le nom complet (format "NOM Prénom").
|
||||
*/
|
||||
private function expanderNomComplet(ImportRow $row): ImportRow
|
||||
{
|
||||
$fullName = $row->valeurChamp(StudentImportField::FULL_NAME);
|
||||
|
||||
if ($fullName === null || trim($fullName) === '') {
|
||||
return $row;
|
||||
}
|
||||
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
|
||||
if (($lastName !== null && trim($lastName) !== '') || ($firstName !== null && trim($firstName) !== '')) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
[$derivedLast, $derivedFirst] = self::splitFullName(trim($fullName));
|
||||
|
||||
$mappedData = $row->mappedData;
|
||||
$mappedData[StudentImportField::LAST_NAME->value] = $derivedLast;
|
||||
$mappedData[StudentImportField::FIRST_NAME->value] = $derivedFirst;
|
||||
|
||||
return new ImportRow($row->lineNumber, $row->rawData, $mappedData, $row->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sépare un nom complet au format "NOM Prénom" en [nom, prénom].
|
||||
*
|
||||
* Convention Pronote : le nom de famille est en majuscules, le prénom en casse mixte.
|
||||
* Si la convention n'est pas détectable, on prend le premier mot comme nom.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
public static function splitFullName(string $fullName): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($fullName));
|
||||
if ($parts === false || $parts === []) {
|
||||
return [$fullName, ''];
|
||||
}
|
||||
|
||||
$uppercaseParts = [];
|
||||
$rest = [];
|
||||
$foundNonUpper = false;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!$foundNonUpper && mb_strtoupper($part) === $part && preg_match('/\p{L}/u', $part)) {
|
||||
$uppercaseParts[] = $part;
|
||||
} else {
|
||||
$foundNonUpper = true;
|
||||
$rest[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uppercaseParts !== [] && $rest !== []) {
|
||||
return [implode(' ', $uppercaseParts), implode(' ', $rest)];
|
||||
}
|
||||
|
||||
$lastName = array_shift($parts);
|
||||
|
||||
return [$lastName ?? $fullName, implode(' ', $parts)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
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\ClassRepository;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Orchestre la chaîne d'import d'élèves : parse → détection → mapping → validation.
|
||||
*
|
||||
* Extrait la logique métier du contrôleur pour respecter l'architecture hexagonale.
|
||||
*/
|
||||
final readonly class StudentImportOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private CsvParser $csvParser,
|
||||
private XlsxParser $xlsxParser,
|
||||
private ImportFormatDetector $formatDetector,
|
||||
private ColumnMappingSuggester $mappingSuggester,
|
||||
private ClassRepository $classRepository,
|
||||
private ImportBatchRepository $importBatchRepository,
|
||||
private SavedColumnMappingRepository $savedMappingRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
|
||||
* crée le batch et enregistre les lignes mappées.
|
||||
*
|
||||
* @return array{batch: StudentImportBatch, suggestedMapping: array<string, StudentImportField>}
|
||||
*/
|
||||
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
|
||||
{
|
||||
$parseResult = match ($extension) {
|
||||
'csv', 'txt' => $this->csvParser->parse($filePath),
|
||||
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
|
||||
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
|
||||
};
|
||||
|
||||
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
|
||||
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
|
||||
|
||||
$batch = StudentImportBatch::creer(
|
||||
tenantId: $tenantId,
|
||||
originalFilename: $originalFilename,
|
||||
totalRows: $parseResult->totalRows(),
|
||||
detectedColumns: $parseResult->columns,
|
||||
detectedFormat: $detectedFormat,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$rows = $this->mapRows($parseResult, $suggestedMapping);
|
||||
$batch->enregistrerLignes($rows);
|
||||
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
|
||||
*/
|
||||
public function applyMapping(StudentImportBatch $batch, ColumnMapping $columnMapping): void
|
||||
{
|
||||
$batch->appliquerMapping($columnMapping);
|
||||
|
||||
$remapped = [];
|
||||
foreach ($batch->lignes() as $row) {
|
||||
$mappedData = [];
|
||||
foreach ($columnMapping->mapping as $column => $field) {
|
||||
$mappedData[$field->value] = $row->rawData[$column] ?? '';
|
||||
}
|
||||
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
|
||||
}
|
||||
$batch->enregistrerLignes($remapped);
|
||||
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les lignes du batch et retourne les résultats avec les classes inconnues.
|
||||
*
|
||||
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
|
||||
*/
|
||||
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId): array
|
||||
{
|
||||
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||
$validator = new ImportRowValidator($existingClasses);
|
||||
|
||||
$validatedRows = $validator->validerTout($batch->lignes());
|
||||
$batch->enregistrerLignes($validatedRows);
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
$report = ImportReport::fromValidatedRows($validatedRows);
|
||||
$unknownClasses = $this->detectUnknownClasses($validatedRows, $existingClasses);
|
||||
|
||||
return [
|
||||
'validatedRows' => $validatedRows,
|
||||
'report' => $report,
|
||||
'unknownClasses' => $unknownClasses,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare le batch pour la confirmation : re-valide si nécessaire
|
||||
* et filtre les lignes selon les options choisies par l'utilisateur.
|
||||
*
|
||||
* Quand createMissingClasses est activé, les erreurs de classe inconnue
|
||||
* sont retirées en re-validant sans vérification de classe.
|
||||
*/
|
||||
public function prepareForConfirmation(
|
||||
StudentImportBatch $batch,
|
||||
bool $createMissingClasses,
|
||||
bool $importValidOnly,
|
||||
): void {
|
||||
if ($createMissingClasses) {
|
||||
$validator = new ImportRowValidator();
|
||||
$revalidated = $validator->validerTout($batch->lignes());
|
||||
$batch->enregistrerLignes($revalidated);
|
||||
}
|
||||
|
||||
if ($importValidOnly) {
|
||||
$batch->enregistrerLignes($batch->lignesValides());
|
||||
}
|
||||
|
||||
if ($batch->mapping !== null) {
|
||||
$this->savedMappingRepository->save(
|
||||
$batch->tenantId,
|
||||
$batch->mapping->format,
|
||||
$batch->mapping->mapping,
|
||||
);
|
||||
}
|
||||
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggère un mapping en priorité depuis les mappings sauvegardés,
|
||||
* puis en fallback depuis la détection automatique.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, StudentImportField>
|
||||
*/
|
||||
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
|
||||
{
|
||||
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
|
||||
|
||||
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
|
||||
return $saved;
|
||||
}
|
||||
|
||||
return $this->mappingSuggester->suggerer($columns, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que les colonnes du mapping sauvegardé correspondent
|
||||
* aux colonnes détectées dans le fichier.
|
||||
*
|
||||
* @param array<string, StudentImportField> $mapping
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
|
||||
{
|
||||
foreach (array_keys($mapping) as $column) {
|
||||
if (!in_array($column, $columns, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, StudentImportField> $mapping
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
private function mapRows(FileParseResult $parseResult, array $mapping): array
|
||||
{
|
||||
$rows = [];
|
||||
$lineNumber = 1;
|
||||
|
||||
foreach ($parseResult->rows as $rawData) {
|
||||
$mappedData = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$mappedData[$field->value] = $rawData[$column] ?? '';
|
||||
}
|
||||
|
||||
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
|
||||
++$lineNumber;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getExistingClassNames(TenantId $tenantId): array
|
||||
{
|
||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($class) => (string) $class->name,
|
||||
$classes,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<string> $existingClasses
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function detectUnknownClasses(array $rows, array $existingClasses): array
|
||||
{
|
||||
$unknown = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
if ($className !== null
|
||||
&& trim($className) !== ''
|
||||
&& !in_array(trim($className), $existingClasses, true)
|
||||
&& !in_array(trim($className), $unknown, true)
|
||||
) {
|
||||
$unknown[] = trim($className);
|
||||
}
|
||||
}
|
||||
|
||||
return $unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Exception as SpreadsheetException;
|
||||
|
||||
/**
|
||||
* Service de parsing de fichiers XLSX (Excel) via PhpSpreadsheet.
|
||||
*/
|
||||
final readonly class XlsxParser
|
||||
{
|
||||
public function parse(string $filePath): FileParseResult
|
||||
{
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
} catch (SpreadsheetException $e) {
|
||||
throw FichierImportInvalideException::formatInvalide($filePath, $e->getMessage());
|
||||
}
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$data = $sheet->toArray('', true, true, false);
|
||||
|
||||
if ($data === []) {
|
||||
throw FichierImportInvalideException::fichierVide();
|
||||
}
|
||||
|
||||
/** @var list<string|int|float|bool|null> $headerRow */
|
||||
$headerRow = array_shift($data);
|
||||
$columns = array_values(array_map(static fn (string|int|float|bool|null $v): string => (string) $v, $headerRow));
|
||||
|
||||
$rows = [];
|
||||
foreach ($data as $line) {
|
||||
/** @var list<mixed> $cells */
|
||||
$cells = $line;
|
||||
if ($this->isEmptyLine($cells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $index => $column) {
|
||||
/** @var string|int|float|bool|null $cellValue */
|
||||
$cellValue = $cells[$index] ?? '';
|
||||
$row[$column] = (string) $cellValue;
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return new FileParseResult($columns, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $line
|
||||
*/
|
||||
private function isEmptyLine(array $line): bool
|
||||
{
|
||||
foreach ($line as $cell) {
|
||||
if ($cell !== null && $cell !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user