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,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,
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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]) === '';
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves échoue.
*/
final readonly class ImportElevesEchoue implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves est lancé.
*/
final readonly class ImportElevesLance implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $totalRows,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves est terminé avec succès.
*/
final readonly class ImportElevesTermine implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $importedCount,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class FichierImportInvalideException extends DomainException
{
public static function fichierIllisible(string $path): self
{
return new self(sprintf(
'Le fichier "%s" ne peut pas être lu.',
$path,
));
}
public static function fichierVide(): self
{
return new self('Le fichier est vide ou ne contient aucune donnée.');
}
public static function formatInvalide(string $path, string $reason): self
{
return new self(sprintf(
'Le fichier "%s" a un format invalide : %s',
$path,
$reason,
));
}
public static function fichierTropGros(int $sizeBytes, int $maxBytes): self
{
return new self(sprintf(
'Le fichier fait %d Mo mais la limite est de %d Mo.',
(int) ($sizeBytes / 1024 / 1024),
(int) ($maxBytes / 1024 / 1024),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use DomainException;
use function sprintf;
final class ImportBatchNotFoundException extends DomainException
{
public static function withId(ImportBatchId $id): self
{
return new self(sprintf(
'Le lot d\'import "%s" n\'existe pas.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use DomainException;
use function sprintf;
final class ImportDejaEnCoursException extends DomainException
{
public static function pourBatch(ImportBatchId $batchId): self
{
return new self(sprintf(
'L\'import "%s" est déjà en cours de traitement.',
$batchId,
));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportStatus;
use DomainException;
use function sprintf;
final class ImportNonDemarrableException extends DomainException
{
public static function pourStatut(ImportBatchId $batchId, ImportStatus $status): self
{
return new self(sprintf(
'L\'import "%s" ne peut pas être démarré depuis le statut "%s".',
$batchId,
$status->value,
));
}
public static function mappingManquant(ImportBatchId $batchId): self
{
return new self(sprintf(
'L\'import "%s" ne peut pas être démarré sans mapping de colonnes.',
$batchId,
));
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\StudentImportField;
use DomainException;
use function sprintf;
final class MappingIncompletException extends DomainException
{
public static function champManquant(StudentImportField $champ): self
{
return new self(sprintf(
'Le champ obligatoire "%s" (%s) n\'est pas mappé.',
$champ->label(),
$champ->value,
));
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use App\Administration\Domain\Exception\MappingIncompletException;
use function in_array;
/**
* Value Object représentant l'association entre les colonnes du fichier source
* et les champs Classeo.
*
* @see FR76: Import élèves via CSV
*/
final readonly class ColumnMapping
{
/**
* @param array<string, StudentImportField> $mapping Colonne source → champ Classeo
*/
private function __construct(
public array $mapping,
public KnownImportFormat $format,
) {
}
/**
* @param array<string, StudentImportField> $mapping Colonne source → champ Classeo
*/
public static function creer(array $mapping, KnownImportFormat $format): self
{
$mappedFields = array_values($mapping);
$champsObligatoires = StudentImportField::champsObligatoires();
$hasFullName = in_array(StudentImportField::FULL_NAME, $mappedFields, true);
foreach ($champsObligatoires as $champ) {
if ($hasFullName && ($champ === StudentImportField::LAST_NAME || $champ === StudentImportField::FIRST_NAME)) {
continue;
}
if (!in_array($champ, $mappedFields, true)) {
throw MappingIncompletException::champManquant($champ);
}
}
return new self($mapping, $format);
}
public function champPour(string $colonneSource): ?StudentImportField
{
return $this->mapping[$colonneSource] ?? null;
}
/**
* @return list<string>
*/
public function colonnesSources(): array
{
return array_keys($this->mapping);
}
public function equals(self $other): bool
{
return $this->mapping === $other->mapping && $this->format === $other->format;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use App\Shared\Domain\EntityId;
final readonly class ImportBatchId extends EntityId
{
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
/**
* Value Object représentant une ligne du fichier d'import avec ses données mappées.
*
* Chaque ligne contient les valeurs extraites du fichier source,
* associées aux champs Classeo via le ColumnMapping.
*/
final readonly class ImportRow
{
/**
* @param int $lineNumber Numéro de ligne dans le fichier source (1-based)
* @param array<string, string> $rawData Données brutes (colonne → valeur)
* @param array<string, string> $mappedData Données mappées (champ Classeo → valeur)
* @param list<ImportRowError> $errors Erreurs de validation
*/
public function __construct(
public int $lineNumber,
public array $rawData,
public array $mappedData,
public array $errors = [],
) {
}
public function estValide(): bool
{
return $this->errors === [];
}
public function valeurChamp(StudentImportField $field): ?string
{
return $this->mappedData[$field->value] ?? null;
}
public function avecErreurs(ImportRowError ...$erreurs): self
{
return new self(
$this->lineNumber,
$this->rawData,
$this->mappedData,
array_values([...$this->errors, ...$erreurs]),
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use function sprintf;
/**
* Value Object représentant une erreur de validation sur une ligne d'import.
*/
final readonly class ImportRowError
{
public function __construct(
public string $column,
public string $message,
) {
}
public function __toString(): string
{
return sprintf('Colonne "%s" : %s', $this->column, $this->message);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
/**
* Statut du cycle de vie d'un lot d'import.
*/
enum ImportStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
case COMPLETED = 'completed';
case FAILED = 'failed';
public function peutDemarrer(): bool
{
return $this === self::PENDING;
}
public function estTermine(): bool
{
return $this === self::COMPLETED || $this === self::FAILED;
}
public function label(): string
{
return match ($this) {
self::PENDING => 'En attente',
self::PROCESSING => 'En cours',
self::COMPLETED => 'Terminé',
self::FAILED => 'Échoué',
};
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
/**
* Formats d'import connus et détectables automatiquement.
*/
enum KnownImportFormat: string
{
case PRONOTE = 'pronote';
case ECOLE_DIRECTE = 'ecole_directe';
case CUSTOM = 'custom';
public function label(): string
{
return match ($this) {
self::PRONOTE => 'Pronote',
self::ECOLE_DIRECTE => 'École Directe',
self::CUSTOM => 'Personnalisé',
};
}
}

View File

@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use App\Administration\Domain\Event\ImportElevesEchoue;
use App\Administration\Domain\Event\ImportElevesLance;
use App\Administration\Domain\Event\ImportElevesTermine;
use App\Administration\Domain\Exception\ImportNonDemarrableException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use DateTimeImmutable;
use function min;
/**
* Aggregate Root représentant un lot d'import d'élèves.
*
* Gère le cycle de vie d'un import depuis l'upload du fichier
* jusqu'à la confirmation finale.
*
* @see FR76: Import élèves via CSV
* @see NFR-SC6: Import < 2 min pour 500 élèves
*/
final class StudentImportBatch extends AggregateRoot
{
public private(set) ?ColumnMapping $mapping = null;
public private(set) int $importedCount = 0;
public private(set) int $errorCount = 0;
public private(set) ?DateTimeImmutable $completedAt = null;
/** @var list<ImportRow> */
private array $rows = [];
private function __construct(
public private(set) ImportBatchId $id,
public private(set) TenantId $tenantId,
public private(set) string $originalFilename,
public private(set) int $totalRows,
/** @var list<string> */
public private(set) array $detectedColumns,
public private(set) ?KnownImportFormat $detectedFormat,
public private(set) ImportStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
}
/**
* Crée un nouveau lot d'import à partir des métadonnées du fichier parsé.
*
* @param list<string> $detectedColumns Colonnes détectées dans le fichier
*/
public static function creer(
TenantId $tenantId,
string $originalFilename,
int $totalRows,
array $detectedColumns,
?KnownImportFormat $detectedFormat,
DateTimeImmutable $createdAt,
): self {
return new self(
id: ImportBatchId::generate(),
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $totalRows,
detectedColumns: $detectedColumns,
detectedFormat: $detectedFormat,
status: ImportStatus::PENDING,
createdAt: $createdAt,
);
}
/**
* Enregistre le mapping des colonnes validé par l'utilisateur.
*/
public function appliquerMapping(ColumnMapping $mapping): void
{
$this->mapping = $mapping;
}
/**
* Enregistre les lignes parsées et mappées pour preview.
*
* @param list<ImportRow> $rows
*/
public function enregistrerLignes(array $rows): void
{
$this->rows = $rows;
}
/**
* Démarre l'import effectif.
*/
public function demarrer(DateTimeImmutable $at): void
{
if (!$this->status->peutDemarrer()) {
throw ImportNonDemarrableException::pourStatut($this->id, $this->status);
}
if ($this->mapping === null) {
throw ImportNonDemarrableException::mappingManquant($this->id);
}
$this->status = ImportStatus::PROCESSING;
$this->recordEvent(new ImportElevesLance(
batchId: $this->id,
tenantId: $this->tenantId,
totalRows: $this->totalRows,
occurredOn: $at,
));
}
/**
* Met à jour les compteurs de progression pendant le traitement.
*/
public function mettreAJourProgression(int $importedCount, int $errorCount): void
{
$this->importedCount = $importedCount;
$this->errorCount = $errorCount;
}
/**
* Marque l'import comme terminé avec succès.
*/
public function terminer(int $importedCount, int $errorCount, DateTimeImmutable $at): void
{
$this->status = ImportStatus::COMPLETED;
$this->importedCount = $importedCount;
$this->errorCount = $errorCount;
$this->completedAt = $at;
$this->recordEvent(new ImportElevesTermine(
batchId: $this->id,
tenantId: $this->tenantId,
importedCount: $importedCount,
errorCount: $errorCount,
occurredOn: $at,
));
}
/**
* Marque l'import comme échoué.
*/
public function echouer(int $errorCount, DateTimeImmutable $at): void
{
$this->status = ImportStatus::FAILED;
$this->errorCount = $errorCount;
$this->completedAt = $at;
$this->recordEvent(new ImportElevesEchoue(
batchId: $this->id,
tenantId: $this->tenantId,
errorCount: $errorCount,
occurredOn: $at,
));
}
/**
* @return list<ImportRow>
*/
public function lignes(): array
{
return $this->rows;
}
/**
* @return list<ImportRow>
*/
public function lignesValides(): array
{
return array_values(array_filter(
$this->rows,
static fn (ImportRow $row): bool => $row->estValide(),
));
}
/**
* @return list<ImportRow>
*/
public function lignesEnErreur(): array
{
return array_values(array_filter(
$this->rows,
static fn (ImportRow $row): bool => !$row->estValide(),
));
}
public function estTermine(): bool
{
return $this->status->estTermine();
}
public function progression(): float
{
if ($this->totalRows === 0) {
return 0.0;
}
return min(100.0, ($this->importedCount + $this->errorCount) / $this->totalRows * 100);
}
/**
* @internal Pour usage Infrastructure uniquement
*
* @param list<string> $detectedColumns
*/
public static function reconstitute(
ImportBatchId $id,
TenantId $tenantId,
string $originalFilename,
int $totalRows,
array $detectedColumns,
?KnownImportFormat $detectedFormat,
ImportStatus $status,
?ColumnMapping $mapping,
int $importedCount,
int $errorCount,
DateTimeImmutable $createdAt,
?DateTimeImmutable $completedAt,
): self {
$batch = new self(
id: $id,
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $totalRows,
detectedColumns: $detectedColumns,
detectedFormat: $detectedFormat,
status: $status,
createdAt: $createdAt,
);
$batch->mapping = $mapping;
$batch->importedCount = $importedCount;
$batch->errorCount = $errorCount;
$batch->completedAt = $completedAt;
return $batch;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
/**
* Champs Classeo disponibles pour le mapping d'import élèves.
*/
enum StudentImportField: string
{
case LAST_NAME = 'lastName';
case FIRST_NAME = 'firstName';
case FULL_NAME = 'fullName';
case EMAIL = 'email';
case CLASS_NAME = 'className';
case BIRTH_DATE = 'birthDate';
case GENDER = 'gender';
case STUDENT_NUMBER = 'studentNumber';
public function estObligatoire(): bool
{
return match ($this) {
self::LAST_NAME, self::FIRST_NAME, self::CLASS_NAME => true,
default => false,
};
}
public function label(): string
{
return match ($this) {
self::LAST_NAME => 'Nom',
self::FIRST_NAME => 'Prénom',
self::FULL_NAME => 'Nom complet (NOM Prénom)',
self::EMAIL => 'Email',
self::CLASS_NAME => 'Classe',
self::BIRTH_DATE => 'Date de naissance',
self::GENDER => 'Genre',
self::STUDENT_NUMBER => 'Numéro élève',
};
}
/**
* @return list<self>
*/
public static function champsObligatoires(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $field): bool => $field->estObligatoire(),
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Shared\Domain\Tenant\TenantId;
interface ImportBatchRepository
{
public function save(StudentImportBatch $batch): void;
public function get(ImportBatchId $id): StudentImportBatch;
public function findById(ImportBatchId $id): ?StudentImportBatch;
/**
* @return list<StudentImportBatch>
*/
public function findByTenant(TenantId $tenantId): array;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Shared\Domain\Tenant\TenantId;
/**
* Stocke le dernier mapping utilisé par tenant et format
* pour le suggérer lors des imports futurs.
*
* @see T3.3: Sauvegarde mapping pour réutilisation future
*/
interface SavedColumnMappingRepository
{
/**
* @param array<string, StudentImportField> $mapping
*/
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void;
/**
* @return array<string, StudentImportField>|null
*/
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array;
}

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;
}
}