diff --git a/backend/Dockerfile b/backend/Dockerfile index 394bbf6..0bd1374 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,8 +13,11 @@ RUN apk add --no-cache \ file \ gettext \ git \ + freetype-dev \ icu-dev \ imagemagick-dev \ + libjpeg-turbo-dev \ + libpng-dev \ libzip-dev \ postgresql-dev \ rabbitmq-c-dev \ @@ -22,7 +25,8 @@ RUN apk add --no-cache \ $PHPIZE_DEPS # Install PHP extensions (opcache is pre-installed in FrankenPHP) -RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets +RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install gd intl pcntl pdo_pgsql zip sockets # Install Imagick extension for image processing (logo resize, etc.) RUN pecl install imagick && docker-php-ext-enable imagick diff --git a/backend/composer.json b/backend/composer.json index 6bc6c49..cc13c8a 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -17,6 +17,7 @@ "doctrine/orm": "^3.3", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", + "phpoffice/phpspreadsheet": "^5.4", "promphp/prometheus_client_php": "^2.14", "ramsey/uuid": "^4.7", "sentry/sentry-symfony": "^5.8", diff --git a/backend/composer.lock b/backend/composer.lock index 659f1bd..084181c 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fb9fd4887621a91ef8635fd6092e53b2", + "content-hash": "8b72e362a7720afa0811f80f9ef6e8d5", "packages": [ { "name": "api-platform/core", @@ -284,6 +284,85 @@ ], "time": "2025-11-24T14:40:29+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "doctrine/collections", "version": "2.6.0", @@ -1822,6 +1901,191 @@ ], "time": "2025-12-20T17:47:00+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -1990,6 +2254,115 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af", + "reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0" + }, + "time": "2026-01-11T04:52:00+00:00" + }, { "name": "promphp/prometheus_client_php", "version": "v2.14.1", @@ -2472,6 +2845,57 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -8139,85 +8563,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/semver", "version": "3.4.4", diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index fcb7a38..58d16ee 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -52,3 +52,5 @@ framework: App\Administration\Domain\Event\MotDePasseChange: async # CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert) # ConnexionReussie, ConnexionEchouee: sync (audit-only, no email) + # Import élèves → async (batch processing, peut être long) + App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index d37fb18..bd3351d 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -78,6 +78,7 @@ security: - { path: ^/api/token/logout, roles: PUBLIC_ACCESS } - { path: ^/api/password/forgot, roles: PUBLIC_ACCESS } - { path: ^/api/password/reset, roles: PUBLIC_ACCESS } + - { path: ^/api/import, roles: ROLE_ADMIN } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } when@test: diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 85ad58e..5a89178 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -209,6 +209,14 @@ services: App\Administration\Application\Port\ImageProcessor: alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor + # Import Batch Repository (Story 3.1 - Import élèves via CSV) + App\Administration\Domain\Repository\ImportBatchRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineImportBatchRepository + + # Saved Column Mapping Repository (Story 3.1 - T3.3 Réutilisation des mappings) + App\Administration\Domain\Repository\SavedColumnMappingRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedColumnMappingRepository + # Student Guardian Repository (Story 2.7 - Liaison parents-enfants) App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: arguments: diff --git a/backend/migrations/Version20260224143000.php b/backend/migrations/Version20260224143000.php new file mode 100644 index 0000000..ce3efa9 --- /dev/null +++ b/backend/migrations/Version20260224143000.php @@ -0,0 +1,44 @@ +addSql('CREATE TABLE student_import_batches ( + id UUID NOT NULL, + tenant_id UUID NOT NULL, + original_filename VARCHAR(255) NOT NULL, + total_rows INT NOT NULL DEFAULT 0, + detected_columns JSONB NOT NULL DEFAULT \'[]\', + detected_format VARCHAR(50) DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT \'pending\', + mapping_data JSONB DEFAULT NULL, + imported_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + rows_data JSONB NOT NULL DEFAULT \'[]\', + created_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ DEFAULT NULL, + PRIMARY KEY (id) + )'); + + $this->addSql('CREATE INDEX idx_student_import_batches_tenant ON student_import_batches (tenant_id)'); + $this->addSql('CREATE INDEX idx_student_import_batches_status ON student_import_batches (status)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE student_import_batches'); + } +} diff --git a/backend/migrations/Version20260224214219.php b/backend/migrations/Version20260224214219.php new file mode 100644 index 0000000..6b16b6d --- /dev/null +++ b/backend/migrations/Version20260224214219.php @@ -0,0 +1,34 @@ +addSql(<<<'SQL' + CREATE TABLE saved_column_mappings ( + tenant_id UUID NOT NULL, + format VARCHAR(50) NOT NULL, + mapping_data JSONB NOT NULL, + saved_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (tenant_id, format) + ) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE saved_column_mappings'); + } +} diff --git a/backend/src/Administration/Application/Command/ImportStudents/ImportStudentsCommand.php b/backend/src/Administration/Application/Command/ImportStudents/ImportStudentsCommand.php new file mode 100644 index 0000000..5caca22 --- /dev/null +++ b/backend/src/Administration/Application/Command/ImportStudents/ImportStudentsCommand.php @@ -0,0 +1,22 @@ +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 */ + $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 $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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/ColumnMappingSuggester.php b/backend/src/Administration/Application/Service/Import/ColumnMappingSuggester.php new file mode 100644 index 0000000..0a29175 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ColumnMappingSuggester.php @@ -0,0 +1,159 @@ + + */ + 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 + */ + 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 $columns Colonnes détectées dans le fichier + * @param KnownImportFormat $detectedFormat Format détecté + * + * @return array 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 $columns + * @param array $reference + * + * @return array + */ + 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 $columns + * + * @return array + */ + 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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/CsvParser.php b/backend/src/Administration/Application/Service/Import/CsvParser.php new file mode 100644 index 0000000..b72cd0b --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/CsvParser.php @@ -0,0 +1,146 @@ +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> + */ + 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 $sanitized */ + $sanitized = array_map('strval', $line); + $lines[] = $sanitized; + } + + fclose($stream); + + return $lines; + } + + /** + * @param list $line + */ + private function isEmptyLine(array $line): bool + { + return count($line) === 1 && trim((string) $line[0]) === ''; + } +} diff --git a/backend/src/Administration/Application/Service/Import/DateParser.php b/backend/src/Administration/Application/Service/Import/DateParser.php new file mode 100644 index 0000000..a20dba5 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/DateParser.php @@ -0,0 +1,33 @@ +format($format) === $trimmed) { + return $parsed; + } + } + + return null; + } +} diff --git a/backend/src/Administration/Application/Service/Import/FileParseResult.php b/backend/src/Administration/Application/Service/Import/FileParseResult.php new file mode 100644 index 0000000..6db93b5 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/FileParseResult.php @@ -0,0 +1,39 @@ + $columns Noms des colonnes détectées + * @param list> $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> + */ + public function preview(int $limit = 5): array + { + return array_slice($this->rows, 0, $limit); + } +} diff --git a/backend/src/Administration/Application/Service/Import/ImportFormatDetector.php b/backend/src/Administration/Application/Service/Import/ImportFormatDetector.php new file mode 100644 index 0000000..4faf169 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ImportFormatDetector.php @@ -0,0 +1,105 @@ + $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 $normalizedColumns + */ + private function matchesPronote(array $normalizedColumns): bool + { + $pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS); + + return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3); + } + + /** + * @param list $normalizedColumns + */ + private function matchesEcoleDirecte(array $normalizedColumns): bool + { + $ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS); + + return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3); + } + + /** + * @param list $actualColumns + * @param list $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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/ImportReport.php b/backend/src/Administration/Application/Service/Import/ImportReport.php new file mode 100644 index 0000000..b578534 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ImportReport.php @@ -0,0 +1,83 @@ + $validRows Lignes valides importées + * @param list $errorRows Lignes en erreur + * @param list $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 $rows Toutes les lignes validées + * @param list $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 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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/ImportRowValidator.php b/backend/src/Administration/Application/Service/Import/ImportRowValidator.php new file mode 100644 index 0000000..c06c588 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/ImportRowValidator.php @@ -0,0 +1,214 @@ +|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 $rows + * + * @return list + */ + public function validerTout(array $rows): array + { + return array_map($this->valider(...), $rows); + } + + /** + * @return list + */ + 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 + */ + 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 + */ + 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 + */ + 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)]; + } +} diff --git a/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php b/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php new file mode 100644 index 0000000..b4afad2 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php @@ -0,0 +1,248 @@ +} + */ + 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, report: ImportReport, unknownClasses: list} + */ + 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 $columns + * + * @return array + */ + 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 $mapping + * @param list $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 $mapping + * + * @return list + */ + 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 + */ + 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 $rows + * @param list $existingClasses + * + * @return list + */ + 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; + } +} diff --git a/backend/src/Administration/Application/Service/Import/XlsxParser.php b/backend/src/Administration/Application/Service/Import/XlsxParser.php new file mode 100644 index 0000000..54ebbb1 --- /dev/null +++ b/backend/src/Administration/Application/Service/Import/XlsxParser.php @@ -0,0 +1,68 @@ +getMessage()); + } + + $sheet = $spreadsheet->getActiveSheet(); + $data = $sheet->toArray('', true, true, false); + + if ($data === []) { + throw FichierImportInvalideException::fichierVide(); + } + + /** @var list $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 $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 $line + */ + private function isEmptyLine(array $line): bool + { + foreach ($line as $cell) { + if ($cell !== null && $cell !== '') { + return false; + } + } + + return true; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportElevesEchoue.php b/backend/src/Administration/Domain/Event/ImportElevesEchoue.php new file mode 100644 index 0000000..fde5647 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportElevesEchoue.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportElevesLance.php b/backend/src/Administration/Domain/Event/ImportElevesLance.php new file mode 100644 index 0000000..93e0964 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportElevesLance.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ImportElevesTermine.php b/backend/src/Administration/Domain/Event/ImportElevesTermine.php new file mode 100644 index 0000000..2ea1696 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ImportElevesTermine.php @@ -0,0 +1,39 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->batchId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/FichierImportInvalideException.php b/backend/src/Administration/Domain/Exception/FichierImportInvalideException.php new file mode 100644 index 0000000..6eb71ed --- /dev/null +++ b/backend/src/Administration/Domain/Exception/FichierImportInvalideException.php @@ -0,0 +1,43 @@ +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, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/MappingIncompletException.php b/backend/src/Administration/Domain/Exception/MappingIncompletException.php new file mode 100644 index 0000000..8a23533 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/MappingIncompletException.php @@ -0,0 +1,22 @@ +label(), + $champ->value, + )); + } +} diff --git a/backend/src/Administration/Domain/Model/Import/ColumnMapping.php b/backend/src/Administration/Domain/Model/Import/ColumnMapping.php new file mode 100644 index 0000000..ee93525 --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/ColumnMapping.php @@ -0,0 +1,66 @@ + $mapping Colonne source → champ Classeo + */ + private function __construct( + public array $mapping, + public KnownImportFormat $format, + ) { + } + + /** + * @param array $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 + */ + public function colonnesSources(): array + { + return array_keys($this->mapping); + } + + public function equals(self $other): bool + { + return $this->mapping === $other->mapping && $this->format === $other->format; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/ImportBatchId.php b/backend/src/Administration/Domain/Model/Import/ImportBatchId.php new file mode 100644 index 0000000..53eb03c --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/ImportBatchId.php @@ -0,0 +1,11 @@ + $rawData Données brutes (colonne → valeur) + * @param array $mappedData Données mappées (champ Classeo → valeur) + * @param list $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]), + ); + } +} diff --git a/backend/src/Administration/Domain/Model/Import/ImportRowError.php b/backend/src/Administration/Domain/Model/Import/ImportRowError.php new file mode 100644 index 0000000..04d213b --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/ImportRowError.php @@ -0,0 +1,24 @@ +column, $this->message); + } +} diff --git a/backend/src/Administration/Domain/Model/Import/ImportStatus.php b/backend/src/Administration/Domain/Model/Import/ImportStatus.php new file mode 100644 index 0000000..2fa4734 --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/ImportStatus.php @@ -0,0 +1,36 @@ + 'En attente', + self::PROCESSING => 'En cours', + self::COMPLETED => 'Terminé', + self::FAILED => 'Échoué', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/KnownImportFormat.php b/backend/src/Administration/Domain/Model/Import/KnownImportFormat.php new file mode 100644 index 0000000..ce3bd0b --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/KnownImportFormat.php @@ -0,0 +1,24 @@ + 'Pronote', + self::ECOLE_DIRECTE => 'École Directe', + self::CUSTOM => 'Personnalisé', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/StudentImportBatch.php b/backend/src/Administration/Domain/Model/Import/StudentImportBatch.php new file mode 100644 index 0000000..96e76bb --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/StudentImportBatch.php @@ -0,0 +1,245 @@ + */ + 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 */ + 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 $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 $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 + */ + public function lignes(): array + { + return $this->rows; + } + + /** + * @return list + */ + public function lignesValides(): array + { + return array_values(array_filter( + $this->rows, + static fn (ImportRow $row): bool => $row->estValide(), + )); + } + + /** + * @return list + */ + 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 $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; + } +} diff --git a/backend/src/Administration/Domain/Model/Import/StudentImportField.php b/backend/src/Administration/Domain/Model/Import/StudentImportField.php new file mode 100644 index 0000000..efa463a --- /dev/null +++ b/backend/src/Administration/Domain/Model/Import/StudentImportField.php @@ -0,0 +1,53 @@ + 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 + */ + public static function champsObligatoires(): array + { + return array_values(array_filter( + self::cases(), + static fn (self $field): bool => $field->estObligatoire(), + )); + } +} diff --git a/backend/src/Administration/Domain/Repository/ImportBatchRepository.php b/backend/src/Administration/Domain/Repository/ImportBatchRepository.php new file mode 100644 index 0000000..70eab68 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/ImportBatchRepository.php @@ -0,0 +1,23 @@ + + */ + public function findByTenant(TenantId $tenantId): array; +} diff --git a/backend/src/Administration/Domain/Repository/SavedColumnMappingRepository.php b/backend/src/Administration/Domain/Repository/SavedColumnMappingRepository.php new file mode 100644 index 0000000..7a297a8 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/SavedColumnMappingRepository.php @@ -0,0 +1,28 @@ + $mapping + */ + public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void; + + /** + * @return array|null + */ + public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array; +} diff --git a/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php b/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php new file mode 100644 index 0000000..bfbf840 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php @@ -0,0 +1,346 @@ +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 $mappingData */ + $mappingData = $data['mapping'] ?? []; + + /** @var string $formatValue */ + $formatValue = $data['format'] ?? ''; + $format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM; + + /** @var array $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 $mapping + * + * @return array + */ + private function serializeMapping(array $mapping): array + { + $result = []; + foreach ($mapping as $column => $field) { + $result[$column] = $field->value; + } + + return $result; + } + + /** + * @param list $rows + * + * @return list> + */ + 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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineImportBatchRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineImportBatchRepository.php new file mode 100644 index 0000000..ef98f3b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineImportBatchRepository.php @@ -0,0 +1,241 @@ +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 $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 $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, format: string} $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + /** @var array $mapping */ + $mapping = []; + foreach ($data['mapping'] as $column => $fieldValue) { + $mapping[$column] = StudentImportField::from($fieldValue); + } + + return ColumnMapping::creer($mapping, KnownImportFormat::from($data['format'])); + } + + /** + * @return list + */ + private function hydrateRows(string $json): array + { + /** @var list, mappedData: array, errors: list}> $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, 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 $rows + * + * @return list, mappedData: array, errors: list}> + */ + 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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedColumnMappingRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedColumnMappingRepository.php new file mode 100644 index 0000000..d34eb05 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedColumnMappingRepository.php @@ -0,0 +1,82 @@ + $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 $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; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryImportBatchRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryImportBatchRepository.php new file mode 100644 index 0000000..7021390 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryImportBatchRepository.php @@ -0,0 +1,45 @@ + */ + 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), + )); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedColumnMappingRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedColumnMappingRepository.php new file mode 100644 index 0000000..b7f8b07 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedColumnMappingRepository.php @@ -0,0 +1,34 @@ +> */ + 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; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ImportStudents/ImportStudentsHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ImportStudents/ImportStudentsHandlerTest.php new file mode 100644 index 0000000..83f1c9f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ImportStudents/ImportStudentsHandlerTest.php @@ -0,0 +1,293 @@ +tenantId = TenantId::fromString(self::TENANT_ID); + $this->academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + + $this->importBatchRepository = new InMemoryImportBatchRepository(); + $this->userRepository = new InMemoryUserRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->classAssignmentRepository = new InMemoryClassAssignmentRepository(); + + $connection = $this->createMock(Connection::class); + + $this->handler = new ImportStudentsHandler( + $this->importBatchRepository, + $this->userRepository, + $this->classRepository, + $this->classAssignmentRepository, + new SchoolIdResolver(), + $connection, + $clock, + new NullLogger(), + ); + } + + #[Test] + public function importsStudentsWithExistingClasses(): void + { + $class = $this->createClass('6ème A'); + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'), + $this->createMappedRow(2, 'Martin', 'Marie', '6ème A'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(2, $updatedBatch->importedCount); + self::assertSame(0, $updatedBatch->errorCount); + } + + #[Test] + public function importsStudentsWithEmail(): void + { + $this->createClass('6ème A'); + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A', 'jean@test.com'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $students = $this->userRepository->findStudentsByTenant($this->tenantId); + + self::assertCount(1, $students); + self::assertNotNull($students[0]->email); + } + + #[Test] + public function importsStudentsWithoutEmail(): void + { + $this->createClass('6ème A'); + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $students = $this->userRepository->findStudentsByTenant($this->tenantId); + + self::assertCount(1, $students); + self::assertNull($students[0]->email); + } + + #[Test] + public function createsMissingClassesWhenEnabled(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + createMissingClasses: true, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(1, $updatedBatch->importedCount); + + $createdClass = $this->classRepository->findByName( + new ClassName('6ème A'), + $this->tenantId, + $this->academicYearId, + ); + self::assertNotNull($createdClass); + } + + #[Test] + public function countsErrorsForMissingClassesWhenNotEnabled(): void + { + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'), + $this->createMappedRow(2, 'Martin', 'Marie', '5ème B'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + createMissingClasses: false, + )); + + $updatedBatch = $this->importBatchRepository->get($batch->id); + + self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status); + self::assertSame(0, $updatedBatch->importedCount); + self::assertSame(2, $updatedBatch->errorCount); + } + + #[Test] + public function createsClassAssignments(): void + { + $class = $this->createClass('6ème A'); + $batch = $this->createBatchWithRows([ + $this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'), + ]); + + ($this->handler)(new ImportStudentsCommand( + batchId: (string) $batch->id, + tenantId: self::TENANT_ID, + schoolName: 'École Test', + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + $students = $this->userRepository->findStudentsByTenant($this->tenantId); + self::assertCount(1, $students); + + $assignment = $this->classAssignmentRepository->findByStudent( + $students[0]->id, + $this->academicYearId, + $this->tenantId, + ); + self::assertNotNull($assignment); + self::assertTrue($assignment->classId->equals($class->id)); + } + + private function createClass(string $name): SchoolClass + { + $class = SchoolClass::creer( + tenantId: $this->tenantId, + schoolId: SchoolId::generate(), + academicYearId: $this->academicYearId, + name: new ClassName($name), + level: null, + capacity: null, + createdAt: new DateTimeImmutable('2026-01-01'), + ); + + $this->classRepository->save($class); + + return $class; + } + + /** + * @param list $rows + */ + private function createBatchWithRows(array $rows): StudentImportBatch + { + $batch = StudentImportBatch::creer( + tenantId: $this->tenantId, + originalFilename: 'test.csv', + totalRows: count($rows), + detectedColumns: ['Nom', 'Prénom', 'Classe'], + detectedFormat: KnownImportFormat::CUSTOM, + createdAt: new DateTimeImmutable('2026-02-24 09:00:00'), + ); + + $mapping = ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + 'Email' => StudentImportField::EMAIL, + ], + KnownImportFormat::CUSTOM, + ); + + $batch->appliquerMapping($mapping); + $batch->enregistrerLignes($rows); + $this->importBatchRepository->save($batch); + + return $batch; + } + + private function createMappedRow( + int $line, + string $lastName, + string $firstName, + string $className, + ?string $email = null, + ): ImportRow { + $mappedData = [ + 'lastName' => $lastName, + 'firstName' => $firstName, + 'className' => $className, + ]; + + if ($email !== null) { + $mappedData['email'] = $email; + } + + return new ImportRow( + lineNumber: $line, + rawData: $mappedData, + mappedData: $mappedData, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/ColumnMappingSuggesterTest.php b/backend/tests/Unit/Administration/Application/Service/Import/ColumnMappingSuggesterTest.php new file mode 100644 index 0000000..f250892 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/ColumnMappingSuggesterTest.php @@ -0,0 +1,106 @@ +suggester = new ColumnMappingSuggester(); + } + + #[Test] + public function suggestPronoteMapping(): void + { + $columns = ['Élèves', 'Né(e) le', 'Sexe', 'Adresse E-mail', 'Classe de rattachement']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::PRONOTE); + + self::assertSame(StudentImportField::FULL_NAME, $mapping['Élèves']); + self::assertSame(StudentImportField::BIRTH_DATE, $mapping['Né(e) le']); + self::assertSame(StudentImportField::GENDER, $mapping['Sexe']); + self::assertSame(StudentImportField::EMAIL, $mapping['Adresse E-mail']); + self::assertSame(StudentImportField::CLASS_NAME, $mapping['Classe de rattachement']); + } + + #[Test] + public function suggestEcoleDirecteMapping(): void + { + $columns = ['NOM', 'PRENOM', 'CLASSE', 'DATE_NAISSANCE', 'SEXE']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::ECOLE_DIRECTE); + + self::assertSame(StudentImportField::LAST_NAME, $mapping['NOM']); + self::assertSame(StudentImportField::FIRST_NAME, $mapping['PRENOM']); + self::assertSame(StudentImportField::CLASS_NAME, $mapping['CLASSE']); + self::assertSame(StudentImportField::BIRTH_DATE, $mapping['DATE_NAISSANCE']); + self::assertSame(StudentImportField::GENDER, $mapping['SEXE']); + } + + #[Test] + public function suggestGenericMappingByKeywords(): void + { + $columns = ['Nom', 'Prénom', 'Classe', 'Email']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(StudentImportField::LAST_NAME, $mapping['Nom']); + self::assertSame(StudentImportField::FIRST_NAME, $mapping['Prénom']); + self::assertSame(StudentImportField::CLASS_NAME, $mapping['Classe']); + self::assertSame(StudentImportField::EMAIL, $mapping['Email']); + } + + #[Test] + public function suggestDoesNotDuplicateFields(): void + { + $columns = ['Nom', 'Nom de famille', 'Prénom', 'Classe']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + $mappedFields = array_values($mapping); + $uniqueFields = array_unique($mappedFields, SORT_REGULAR); + + self::assertCount(count($uniqueFields), $mappedFields); + } + + #[Test] + public function suggestHandlesUnknownColumns(): void + { + $columns = ['ColonneInconnue', 'AutreColonne', 'Nom', 'Classe']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertArrayNotHasKey('ColonneInconnue', $mapping); + self::assertArrayNotHasKey('AutreColonne', $mapping); + self::assertArrayHasKey('Nom', $mapping); + self::assertArrayHasKey('Classe', $mapping); + } + + #[Test] + public function suggestHandlesEnglishColumnNames(): void + { + $columns = ['Last Name', 'First Name', 'Class', 'Email']; + + $mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM); + + self::assertSame(StudentImportField::LAST_NAME, $mapping['Last Name']); + self::assertSame(StudentImportField::FIRST_NAME, $mapping['First Name']); + self::assertSame(StudentImportField::CLASS_NAME, $mapping['Class']); + self::assertSame(StudentImportField::EMAIL, $mapping['Email']); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/CsvParserTest.php b/backend/tests/Unit/Administration/Application/Service/Import/CsvParserTest.php new file mode 100644 index 0000000..88a23d5 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/CsvParserTest.php @@ -0,0 +1,121 @@ +parser = new CsvParser(); + } + + #[Test] + public function parseSemicolonSeparatedCsv(): void + { + $result = $this->parser->parse($this->fixture('eleves_simple.csv')); + + self::assertSame(['Nom', 'Prénom', 'Classe', 'Email'], $result->columns); + self::assertSame(3, $result->totalRows()); + self::assertSame('Dupont', $result->rows[0]['Nom']); + self::assertSame('Jean', $result->rows[0]['Prénom']); + self::assertSame('6ème A', $result->rows[0]['Classe']); + self::assertSame('jean.dupont@email.com', $result->rows[0]['Email']); + } + + #[Test] + public function parseCommaSeparatedCsv(): void + { + $result = $this->parser->parse($this->fixture('eleves_comma.csv')); + + self::assertSame(['Nom', 'Prénom', 'Classe'], $result->columns); + self::assertSame(2, $result->totalRows()); + self::assertSame('Dupont', $result->rows[0]['Nom']); + } + + #[Test] + public function parsePronoteFormatCsv(): void + { + $result = $this->parser->parse($this->fixture('eleves_pronote.csv')); + + self::assertContains('Élèves', $result->columns); + self::assertContains('Né(e) le', $result->columns); + self::assertContains('Sexe', $result->columns); + self::assertSame(27, $result->totalRows()); + self::assertSame('BERTHE Alexandre', $result->rows[0]['Élèves']); + self::assertSame('07/07/2011', $result->rows[0]['Né(e) le']); + self::assertSame('Masculin', $result->rows[0]['Sexe']); + self::assertSame('alexandre.berthe@fournisseur.fr', $result->rows[0]['Adresse E-mail']); + } + + #[Test] + public function previewReturnsLimitedRows(): void + { + $result = $this->parser->parse($this->fixture('eleves_simple.csv')); + + $preview = $result->preview(2); + + self::assertCount(2, $preview); + self::assertSame('Dupont', $preview[0]['Nom']); + self::assertSame('Martin', $preview[1]['Nom']); + } + + #[Test] + public function parseHandlesUtf8Bom(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_'); + file_put_contents($tempFile, "\xEF\xBB\xBFNom;Prénom\nDupont;Jean\n"); + + try { + $result = $this->parser->parse($tempFile); + + self::assertSame(['Nom', 'Prénom'], $result->columns); + self::assertSame(1, $result->totalRows()); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function parseEmptyFileThrowsException(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_'); + file_put_contents($tempFile, ''); + + try { + $this->expectException(FichierImportInvalideException::class); + + $this->parser->parse($tempFile); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function parseHandlesEmptyRowsGracefully(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_'); + file_put_contents($tempFile, "Nom;Prénom\nDupont;Jean\n\n\nMartin;Marie\n"); + + try { + $result = $this->parser->parse($tempFile); + + self::assertSame(2, $result->totalRows()); + } finally { + unlink($tempFile); + } + } + + private function fixture(string $filename): string + { + return __DIR__ . '/../../../../../fixtures/import/' . $filename; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/ImportFormatDetectorTest.php b/backend/tests/Unit/Administration/Application/Service/Import/ImportFormatDetectorTest.php new file mode 100644 index 0000000..6495fd0 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/ImportFormatDetectorTest.php @@ -0,0 +1,70 @@ +detector = new ImportFormatDetector(); + } + + #[Test] + public function detectsPronoteFormat(): void + { + $columns = ['Élèves', 'Encouragement/Valorisation', 'Né(e) le', 'Sexe', 'Adresse E-mail', 'Entrée', 'Sortie', 'Classe de rattachement']; + + $format = $this->detector->detecter($columns); + + self::assertSame(KnownImportFormat::PRONOTE, $format); + } + + #[Test] + public function detectsEcoleDirecteFormat(): void + { + $columns = ['NOM', 'PRENOM', 'CLASSE', 'DATE_NAISSANCE', 'SEXE']; + + $format = $this->detector->detecter($columns); + + self::assertSame(KnownImportFormat::ECOLE_DIRECTE, $format); + } + + #[Test] + public function detectsCustomFormatForUnknownColumns(): void + { + $columns = ['Nom', 'Prénom', 'Classe', 'Email']; + + $format = $this->detector->detecter($columns); + + self::assertSame(KnownImportFormat::CUSTOM, $format); + } + + #[Test] + public function detectsPronoteWithPartialMatch(): void + { + $columns = ['Élèves', 'Né(e) le', 'Sexe', 'Autre colonne']; + + $format = $this->detector->detecter($columns); + + self::assertSame(KnownImportFormat::PRONOTE, $format); + } + + #[Test] + public function detectsEcoleDirecteWithCaseVariations(): void + { + $columns = ['nom', 'prenom', 'classe', 'date_naissance']; + + $format = $this->detector->detecter($columns); + + self::assertSame(KnownImportFormat::ECOLE_DIRECTE, $format); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/ImportRowValidatorTest.php b/backend/tests/Unit/Administration/Application/Service/Import/ImportRowValidatorTest.php new file mode 100644 index 0000000..499e67e --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/ImportRowValidatorTest.php @@ -0,0 +1,311 @@ +createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function missingLastNameAddsError(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => '', + 'firstName' => 'Jean', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + self::assertCount(1, $result->errors); + self::assertSame('lastName', $result->errors[0]->column); + } + + #[Test] + public function missingMultipleFieldsAddsMultipleErrors(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => '', + 'firstName' => '', + 'className' => '', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + self::assertCount(3, $result->errors); + } + + #[Test] + public function invalidEmailAddsError(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'email' => 'not-an-email', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + self::assertSame('email', $result->errors[0]->column); + } + + #[Test] + public function validEmailPasses(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'email' => 'jean.dupont@email.com', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function emptyEmailIsAccepted(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'email' => '', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function invalidDateAddsError(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'birthDate' => 'not-a-date', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + self::assertSame('birthDate', $result->errors[0]->column); + } + + #[Test] + public function frenchDateFormatAccepted(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'birthDate' => '15/03/2014', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function isoDateFormatAccepted(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + 'birthDate' => '2014-03-15', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function unknownClassAddsError(): void + { + $validator = new ImportRowValidator(['6ème A', '6ème B']); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '5ème C', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + self::assertSame('className', $result->errors[0]->column); + } + + #[Test] + public function knownClassPasses(): void + { + $validator = new ImportRowValidator(['6ème A', '6ème B']); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function classValidationSkippedWhenNoClassesProvided(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => 'ClasseInconnue', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function validerToutValidatesAllRows(): void + { + $validator = new ImportRowValidator(); + $rows = [ + $this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A']), + $this->createRow(['lastName' => '', 'firstName' => 'Marie', 'className' => '6B']), + $this->createRow(['lastName' => 'Bernard', 'firstName' => 'Pierre', 'className' => '5A']), + ]; + + $results = $validator->validerTout($rows); + + self::assertCount(3, $results); + self::assertTrue($results[0]->estValide()); + self::assertFalse($results[1]->estValide()); + self::assertTrue($results[2]->estValide()); + } + + #[Test] + public function fullNameExpandsToLastNameAndFirstName(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'fullName' => 'BERTHE Alexandre', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + self::assertSame('BERTHE', $result->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Alexandre', $result->valeurChamp(StudentImportField::FIRST_NAME)); + } + + #[Test] + public function fullNameDoesNotOverrideExistingLastNameFirstName(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'fullName' => 'BERTHE Alexandre', + 'lastName' => 'Dupont', + 'firstName' => 'Jean', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertTrue($result->estValide()); + self::assertSame('Dupont', $result->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Jean', $result->valeurChamp(StudentImportField::FIRST_NAME)); + } + + #[Test] + public function splitFullNameHandlesCompoundLastName(): void + { + [$lastName, $firstName] = ImportRowValidator::splitFullName('DE LA FONTAINE Jean'); + + self::assertSame('DE LA FONTAINE', $lastName); + self::assertSame('Jean', $firstName); + } + + #[Test] + public function splitFullNameHandlesSimpleName(): void + { + [$lastName, $firstName] = ImportRowValidator::splitFullName('DUPONT Marie'); + + self::assertSame('DUPONT', $lastName); + self::assertSame('Marie', $firstName); + } + + #[Test] + public function splitFullNameHandlesCompoundFirstName(): void + { + [$lastName, $firstName] = ImportRowValidator::splitFullName('OLIVIER Jean-Philippe'); + + self::assertSame('OLIVIER', $lastName); + self::assertSame('Jean-Philippe', $firstName); + } + + #[Test] + public function emptyFullNameDoesNotExpand(): void + { + $validator = new ImportRowValidator(); + $row = $this->createRow([ + 'fullName' => '', + 'className' => '6ème A', + ]); + + $result = $validator->valider($row); + + self::assertFalse($result->estValide()); + } + + /** + * @param array $mappedData + */ + private function createRow(array $mappedData, int $lineNumber = 1): ImportRow + { + return new ImportRow( + lineNumber: $lineNumber, + rawData: $mappedData, + mappedData: $mappedData, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/PronoteImportIntegrationTest.php b/backend/tests/Unit/Administration/Application/Service/Import/PronoteImportIntegrationTest.php new file mode 100644 index 0000000..2821a05 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/PronoteImportIntegrationTest.php @@ -0,0 +1,153 @@ +parse($filePath); + + self::assertSame(27, $parseResult->totalRows()); + self::assertContains('Élèves', $parseResult->columns); + + // 2. Détecter le format + $detector = new ImportFormatDetector(); + $format = $detector->detecter($parseResult->columns); + + self::assertSame(KnownImportFormat::PRONOTE, $format); + + // 3. Suggérer le mapping + $suggester = new ColumnMappingSuggester(); + $suggestedMapping = $suggester->suggerer($parseResult->columns, $format); + + self::assertSame(StudentImportField::FULL_NAME, $suggestedMapping['Élèves']); + self::assertSame(StudentImportField::BIRTH_DATE, $suggestedMapping['Né(e) le']); + self::assertSame(StudentImportField::GENDER, $suggestedMapping['Sexe']); + self::assertSame(StudentImportField::EMAIL, $suggestedMapping['Adresse E-mail']); + + // 4. Appliquer le mapping sur les lignes + $rows = []; + $lineNumber = 1; + + foreach ($parseResult->rows as $rawData) { + $mappedData = []; + foreach ($suggestedMapping as $column => $field) { + $mappedData[$field->value] = $rawData[$column] ?? ''; + } + $rows[] = new ImportRow($lineNumber, $rawData, $mappedData); + ++$lineNumber; + } + + self::assertCount(27, $rows); + self::assertSame('BERTHE Alexandre', $rows[0]->valeurChamp(StudentImportField::FULL_NAME)); + + // 5. Valider les lignes (pas de vérification de classes existantes) + $validator = new ImportRowValidator(); + $validatedRows = $validator->validerTout($rows); + + // Toutes les lignes devraient être valides car FULL_NAME → LAST_NAME + FIRST_NAME + $validCount = 0; + $errorCount = 0; + + foreach ($validatedRows as $row) { + if ($row->estValide()) { + ++$validCount; + } else { + ++$errorCount; + } + } + + // Certaines lignes ont une classe vide → erreur sur className obligatoire + self::assertGreaterThan(0, $validCount + $errorCount); + self::assertSame(27, $validCount + $errorCount); + + // Vérifions que le splitFullName a bien fonctionné sur la première ligne + $firstRow = $validatedRows[0]; + self::assertSame('BERTHE', $firstRow->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Alexandre', $firstRow->valeurChamp(StudentImportField::FIRST_NAME)); + + // Vérifions Jean-Philippe (prénom composé) + $jeanPhilippe = null; + foreach ($validatedRows as $row) { + if (str_contains($row->valeurChamp(StudentImportField::FULL_NAME) ?? '', 'OLIVIER')) { + $jeanPhilippe = $row; + break; + } + } + self::assertNotNull($jeanPhilippe); + self::assertSame('OLIVIER', $jeanPhilippe->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Jean-Philippe', $jeanPhilippe->valeurChamp(StudentImportField::FIRST_NAME)); + + // 6. Générer le rapport + $report = ImportReport::fromValidatedRows($validatedRows); + + self::assertSame(27, $report->totalRows); + self::assertSame($validCount, $report->importedCount); + self::assertSame($errorCount, $report->errorCount); + } + + #[Test] + public function pronoteClasseDeRattachementIsEmpty(): void + { + $filePath = __DIR__ . '/../../../../../fixtures/import/eleves_pronote.csv'; + + $parser = new CsvParser(); + $parseResult = $parser->parse($filePath); + + // Dans le fichier Pronote de démo, "Classe de rattachement" est vide pour tous + foreach ($parseResult->rows as $row) { + self::assertSame('', $row['Classe de rattachement']); + } + } + + #[Test] + public function pronoteRowsWithEmptyClassAreInvalid(): void + { + $validator = new ImportRowValidator(); + $row = new ImportRow( + lineNumber: 1, + rawData: ['Élèves' => 'BERTHE Alexandre', 'Classe de rattachement' => ''], + mappedData: [ + 'fullName' => 'BERTHE Alexandre', + 'className' => '', + 'birthDate' => '07/07/2011', + 'gender' => 'Masculin', + 'email' => 'alexandre.berthe@fournisseur.fr', + ], + ); + + $result = $validator->valider($row); + + // className est obligatoire → erreur + self::assertFalse($result->estValide()); + self::assertSame('className', $result->errors[0]->column); + + // Mais le nom a bien été splitté + self::assertSame('BERTHE', $result->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Alexandre', $result->valeurChamp(StudentImportField::FIRST_NAME)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/ColumnMappingTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/ColumnMappingTest.php new file mode 100644 index 0000000..f4c529d --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/ColumnMappingTest.php @@ -0,0 +1,129 @@ + StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + ], + KnownImportFormat::CUSTOM, + ); + + self::assertCount(3, $mapping->colonnesSources()); + self::assertSame(KnownImportFormat::CUSTOM, $mapping->format); + } + + #[Test] + public function creerWithOptionalFieldsSucceeds(): void + { + $mapping = ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + 'Email' => StudentImportField::EMAIL, + 'Naissance' => StudentImportField::BIRTH_DATE, + ], + KnownImportFormat::PRONOTE, + ); + + self::assertCount(5, $mapping->colonnesSources()); + } + + #[Test] + public function creerSansNomLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + ColumnMapping::creer( + [ + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function creerSansPrenomLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function creerSansClasseLeveException(): void + { + $this->expectException(MappingIncompletException::class); + + ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + ], + KnownImportFormat::CUSTOM, + ); + } + + #[Test] + public function champPourReturnsMappedField(): void + { + $mapping = ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + ], + KnownImportFormat::CUSTOM, + ); + + self::assertSame(StudentImportField::LAST_NAME, $mapping->champPour('Nom')); + self::assertSame(StudentImportField::FIRST_NAME, $mapping->champPour('Prénom')); + self::assertNull($mapping->champPour('Inconnu')); + } + + #[Test] + public function equalsComparesCorrectly(): void + { + $mapping1 = ColumnMapping::creer( + ['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME], + KnownImportFormat::CUSTOM, + ); + + $mapping2 = ColumnMapping::creer( + ['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME], + KnownImportFormat::CUSTOM, + ); + + $mapping3 = ColumnMapping::creer( + ['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME], + KnownImportFormat::PRONOTE, + ); + + self::assertTrue($mapping1->equals($mapping2)); + self::assertFalse($mapping1->equals($mapping3)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/ImportRowTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/ImportRowTest.php new file mode 100644 index 0000000..5df5c6f --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/ImportRowTest.php @@ -0,0 +1,74 @@ + 'Dupont', 'Prénom' => 'Jean'], + mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean'], + ); + + self::assertTrue($row->estValide()); + self::assertSame(1, $row->lineNumber); + } + + #[Test] + public function rowAvecErreursEstInvalide(): void + { + $row = new ImportRow( + lineNumber: 3, + rawData: ['Nom' => '', 'Prénom' => 'Jean'], + mappedData: ['lastName' => '', 'firstName' => 'Jean'], + errors: [new ImportRowError('lastName', 'Le nom est obligatoire')], + ); + + self::assertFalse($row->estValide()); + self::assertCount(1, $row->errors); + } + + #[Test] + public function valeurChampReturnsMappedValue(): void + { + $row = new ImportRow( + lineNumber: 1, + rawData: [], + mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean'], + ); + + self::assertSame('Dupont', $row->valeurChamp(StudentImportField::LAST_NAME)); + self::assertSame('Jean', $row->valeurChamp(StudentImportField::FIRST_NAME)); + self::assertNull($row->valeurChamp(StudentImportField::EMAIL)); + } + + #[Test] + public function avecErreursCreatesNewRowWithAdditionalErrors(): void + { + $row = new ImportRow( + lineNumber: 1, + rawData: [], + mappedData: ['lastName' => ''], + errors: [new ImportRowError('lastName', 'Le nom est obligatoire')], + ); + + $newRow = $row->avecErreurs( + new ImportRowError('email', 'Email invalide'), + ); + + self::assertCount(1, $row->errors); + self::assertCount(2, $newRow->errors); + self::assertSame($row->lineNumber, $newRow->lineNumber); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/ImportStatusTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/ImportStatusTest.php new file mode 100644 index 0000000..bea85db --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/ImportStatusTest.php @@ -0,0 +1,39 @@ +peutDemarrer()); + self::assertFalse(ImportStatus::PROCESSING->peutDemarrer()); + self::assertFalse(ImportStatus::COMPLETED->peutDemarrer()); + self::assertFalse(ImportStatus::FAILED->peutDemarrer()); + } + + #[Test] + public function estTermineForCompletedAndFailed(): void + { + self::assertFalse(ImportStatus::PENDING->estTermine()); + self::assertFalse(ImportStatus::PROCESSING->estTermine()); + self::assertTrue(ImportStatus::COMPLETED->estTermine()); + self::assertTrue(ImportStatus::FAILED->estTermine()); + } + + #[Test] + public function labelReturnsReadableText(): void + { + self::assertSame('En attente', ImportStatus::PENDING->label()); + self::assertSame('En cours', ImportStatus::PROCESSING->label()); + self::assertSame('Terminé', ImportStatus::COMPLETED->label()); + self::assertSame('Échoué', ImportStatus::FAILED->label()); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportBatchTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportBatchTest.php new file mode 100644 index 0000000..e2ca2a3 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportBatchTest.php @@ -0,0 +1,281 @@ +createBatch(); + + self::assertSame(ImportStatus::PENDING, $batch->status); + self::assertSame(0, $batch->importedCount); + self::assertSame(0, $batch->errorCount); + self::assertNull($batch->completedAt); + self::assertNull($batch->mapping); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $createdAt = new DateTimeImmutable('2026-02-24 10:00:00'); + $columns = ['Nom', 'Prénom', 'Classe']; + + $batch = StudentImportBatch::creer( + tenantId: $tenantId, + originalFilename: 'eleves.csv', + totalRows: 50, + detectedColumns: $columns, + detectedFormat: KnownImportFormat::PRONOTE, + createdAt: $createdAt, + ); + + self::assertTrue($batch->tenantId->equals($tenantId)); + self::assertSame('eleves.csv', $batch->originalFilename); + self::assertSame(50, $batch->totalRows); + self::assertSame($columns, $batch->detectedColumns); + self::assertSame(KnownImportFormat::PRONOTE, $batch->detectedFormat); + self::assertEquals($createdAt, $batch->createdAt); + } + + #[Test] + public function creerDoesNotRecordAnyEvent(): void + { + $batch = $this->createBatch(); + + self::assertEmpty($batch->pullDomainEvents()); + } + + #[Test] + public function appliquerMappingSetsMapping(): void + { + $batch = $this->createBatch(); + $mapping = $this->createValidMapping(); + + $batch->appliquerMapping($mapping); + + self::assertNotNull($batch->mapping); + self::assertTrue($batch->mapping->equals($mapping)); + } + + #[Test] + public function enregistrerLignesStoresRows(): void + { + $batch = $this->createBatch(); + $rows = [ + new ImportRow(1, ['Nom' => 'Dupont'], ['lastName' => 'Dupont']), + new ImportRow(2, ['Nom' => 'Martin'], ['lastName' => 'Martin']), + ]; + + $batch->enregistrerLignes($rows); + + self::assertCount(2, $batch->lignes()); + self::assertSame(50, $batch->totalRows); + } + + #[Test] + public function demarrerTransitionsToProcessingAndRecordsEvent(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $at = new DateTimeImmutable('2026-02-24 11:00:00'); + + $batch->demarrer($at); + + self::assertSame(ImportStatus::PROCESSING, $batch->status); + + $events = $batch->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ImportElevesLance::class, $events[0]); + self::assertTrue($events[0]->batchId->equals($batch->id)); + self::assertTrue($events[0]->tenantId->equals($batch->tenantId)); + } + + #[Test] + public function demarrerSansMappingLeveException(): void + { + $batch = $this->createBatch(); + + $this->expectException(ImportNonDemarrableException::class); + + $batch->demarrer(new DateTimeImmutable()); + } + + #[Test] + public function demarrerDepuisStatutNonPendingLeveException(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + + $this->expectException(ImportNonDemarrableException::class); + + $batch->demarrer(new DateTimeImmutable()); + } + + #[Test] + public function terminerSetsCompletedStatusAndRecordsEvent(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + $batch->pullDomainEvents(); + + $at = new DateTimeImmutable('2026-02-24 12:00:00'); + $batch->terminer(45, 5, $at); + + self::assertSame(ImportStatus::COMPLETED, $batch->status); + self::assertSame(45, $batch->importedCount); + self::assertSame(5, $batch->errorCount); + self::assertEquals($at, $batch->completedAt); + self::assertTrue($batch->estTermine()); + + $events = $batch->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ImportElevesTermine::class, $events[0]); + self::assertSame(45, $events[0]->importedCount); + self::assertSame(5, $events[0]->errorCount); + } + + #[Test] + public function echouerSetsFailedStatus(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + $batch->pullDomainEvents(); + + $at = new DateTimeImmutable('2026-02-24 12:00:00'); + $batch->echouer(50, $at); + + self::assertSame(ImportStatus::FAILED, $batch->status); + self::assertSame(50, $batch->errorCount); + self::assertEquals($at, $batch->completedAt); + self::assertTrue($batch->estTermine()); + } + + #[Test] + public function lignesValidesFiltersCorrectly(): void + { + $batch = $this->createBatch(); + $rows = [ + new ImportRow(1, [], ['lastName' => 'Dupont']), + new ImportRow(2, [], ['lastName' => ''], [new ImportRowError('lastName', 'Nom vide')]), + new ImportRow(3, [], ['lastName' => 'Martin']), + ]; + + $batch->enregistrerLignes($rows); + + self::assertCount(2, $batch->lignesValides()); + self::assertCount(1, $batch->lignesEnErreur()); + } + + #[Test] + public function progressionCalculatesCorrectly(): void + { + $batch = $this->createBatch(); + $batch->appliquerMapping($this->createValidMapping()); + $batch->demarrer(new DateTimeImmutable()); + + $batch->terminer(40, 10, new DateTimeImmutable()); + + self::assertEqualsWithDelta(100.0, $batch->progression(), 0.01); + } + + #[Test] + public function progressionReturnsZeroForEmptyBatch(): void + { + $batch = StudentImportBatch::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + originalFilename: 'empty.csv', + totalRows: 0, + detectedColumns: [], + detectedFormat: null, + createdAt: new DateTimeImmutable(), + ); + + self::assertEqualsWithDelta(0.0, $batch->progression(), 0.01); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = \App\Administration\Domain\Model\Import\ImportBatchId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $mapping = $this->createValidMapping(); + $createdAt = new DateTimeImmutable('2026-02-24 10:00:00'); + $completedAt = new DateTimeImmutable('2026-02-24 12:00:00'); + + $batch = StudentImportBatch::reconstitute( + id: $id, + tenantId: $tenantId, + originalFilename: 'eleves.csv', + totalRows: 50, + detectedColumns: ['Nom', 'Prénom', 'Classe'], + detectedFormat: KnownImportFormat::PRONOTE, + status: ImportStatus::COMPLETED, + mapping: $mapping, + importedCount: 45, + errorCount: 5, + createdAt: $createdAt, + completedAt: $completedAt, + ); + + self::assertTrue($batch->id->equals($id)); + self::assertTrue($batch->tenantId->equals($tenantId)); + self::assertSame('eleves.csv', $batch->originalFilename); + self::assertSame(50, $batch->totalRows); + self::assertSame(ImportStatus::COMPLETED, $batch->status); + self::assertNotNull($batch->mapping); + self::assertSame(45, $batch->importedCount); + self::assertSame(5, $batch->errorCount); + self::assertEquals($createdAt, $batch->createdAt); + self::assertEquals($completedAt, $batch->completedAt); + self::assertEmpty($batch->pullDomainEvents()); + } + + private function createBatch(): StudentImportBatch + { + return StudentImportBatch::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + originalFilename: 'eleves.csv', + totalRows: 50, + detectedColumns: ['Nom', 'Prénom', 'Classe', 'Email'], + detectedFormat: KnownImportFormat::CUSTOM, + createdAt: new DateTimeImmutable('2026-02-24 10:00:00'), + ); + } + + private function createValidMapping(): ColumnMapping + { + return ColumnMapping::creer( + [ + 'Nom' => StudentImportField::LAST_NAME, + 'Prénom' => StudentImportField::FIRST_NAME, + 'Classe' => StudentImportField::CLASS_NAME, + ], + KnownImportFormat::CUSTOM, + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportFieldTest.php b/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportFieldTest.php new file mode 100644 index 0000000..3509c50 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Import/StudentImportFieldTest.php @@ -0,0 +1,49 @@ +estObligatoire()); + self::assertTrue(StudentImportField::FIRST_NAME->estObligatoire()); + self::assertTrue(StudentImportField::CLASS_NAME->estObligatoire()); + } + + #[Test] + public function estObligatoireFalseForOptionalFields(): void + { + self::assertFalse(StudentImportField::EMAIL->estObligatoire()); + self::assertFalse(StudentImportField::BIRTH_DATE->estObligatoire()); + self::assertFalse(StudentImportField::GENDER->estObligatoire()); + self::assertFalse(StudentImportField::STUDENT_NUMBER->estObligatoire()); + } + + #[Test] + public function labelReturnsReadableText(): void + { + self::assertSame('Nom', StudentImportField::LAST_NAME->label()); + self::assertSame('Prénom', StudentImportField::FIRST_NAME->label()); + self::assertSame('Classe', StudentImportField::CLASS_NAME->label()); + self::assertSame('Email', StudentImportField::EMAIL->label()); + } +} diff --git a/backend/tests/fixtures/import/eleves_comma.csv b/backend/tests/fixtures/import/eleves_comma.csv new file mode 100644 index 0000000..9cc4396 --- /dev/null +++ b/backend/tests/fixtures/import/eleves_comma.csv @@ -0,0 +1,3 @@ +Nom,Prénom,Classe +Dupont,Jean,6ème A +Martin,Marie,6ème B diff --git a/backend/tests/fixtures/import/eleves_ecole_directe.csv b/backend/tests/fixtures/import/eleves_ecole_directe.csv new file mode 100644 index 0000000..06292e2 --- /dev/null +++ b/backend/tests/fixtures/import/eleves_ecole_directe.csv @@ -0,0 +1,3 @@ +NOM;PRENOM;CLASSE;DATE_NAISSANCE;SEXE +Dupont;Jean;6A;2014-03-15;M +Martin;Marie;6B;2014-07-22;F diff --git a/backend/tests/fixtures/import/eleves_pronote.csv b/backend/tests/fixtures/import/eleves_pronote.csv new file mode 100644 index 0000000..c7caa76 --- /dev/null +++ b/backend/tests/fixtures/import/eleves_pronote.csv @@ -0,0 +1,28 @@ +Élèves;Encouragement/Valorisation;Né(e) le;Sexe;Adresse E-mail;Entrée;Sortie;Classe de rattachement;Tuteur;Cnx Ele.;Cnx Resp.;Option 1;Option 2;Option 3;Régime +"BERTHE Alexandre";"";"07/07/2011";"Masculin";"alexandre.berthe@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE" +"BILLAUD Amelia";"";"30/01/2011";"Féminin";"amelia.billaud@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"BILLET Julien";"";"22/04/2011";"Masculin";"julien.billet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"09/01/2019";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE" +"BLANCHET Antoine";"";"11/10/2011";"Masculin";"antoine.blanchet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"BONNET Adeline";"";"10/12/2011";"Féminin";"adeline.bonnet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"CAZENAVE Valentin";"";"15/08/2010";"Masculin";"valentin.cazenave@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"CHABE Ilyes";"";"03/10/2011";"Masculin";"ilyes.chabe@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"CHOPIN Elisa";"";"24/02/2011";"Féminin";"elisa.chopin@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE" +"DELAUNAY Alexandre";"";"16/09/2011";"Masculin";"alexandre.delaunay@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"DIOT Melanie";"";"20/12/2010";"Féminin";"melanie.diot@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"ESTEVE Martin";"";"09/07/2011";"Masculin";"martin.esteve@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"FERNANDEZ Juliette";"";"16/05/2011";"Féminin";"juliette.fernandez@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"GRANGE Sabrina";"";"16/01/2010";"Féminin";"sabrina.grange@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE" +"HUGUET Clara";"";"11/01/2012";"Féminin";"clara.huguet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE" +"IMBERT Vincent";"";"28/02/2012";"Masculin";"vincent.imbert@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE" +"LAVIGNE Sandy";"";"09/01/2012";"Féminin";"sandy.lavigne@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE" +"MATHIS Hugo";"";"22/04/2011";"Masculin";"hugo.mathis@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"MAYER Laura";"";"11/07/2011";"Féminin";"laura.mayer@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"MENAGER Pauline";"";"05/01/2012";"Féminin";"pauline.menager@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"MONTAGNE Clement";"";"10/01/2012";"Masculin";"clement.montagne@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"OLIVIER Jean-Philippe";"";"03/01/2012";"Masculin";"jean-philippe.olivier@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"PEREZ Alison";"";"09/07/2011";"Féminin";"alison.perez@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"RUIZ Delphine";"";"03/05/2011";"Féminin";"delphine.ruiz@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE" +"SALOMON Alexandre";"";"14/05/2011";"Masculin";"alexandre.salomon@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE" +"SCHMITT Romain";"";"22/08/2011";"Masculin";"romain.schmitt@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" +"SERRES Adeline";"";"07/12/2010";"Féminin";"adeline.serres@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE" +"VALLET Alexandre";"";"23/03/2011";"Masculin";"alexandre.vallet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT" \ No newline at end of file diff --git a/backend/tests/fixtures/import/eleves_simple.csv b/backend/tests/fixtures/import/eleves_simple.csv new file mode 100644 index 0000000..3a793f6 --- /dev/null +++ b/backend/tests/fixtures/import/eleves_simple.csv @@ -0,0 +1,4 @@ +Nom;Prénom;Classe;Email +Dupont;Jean;6ème A;jean.dupont@email.com +Martin;Marie;6ème B;marie.martin@email.com +Bernard;Pierre;5ème A; diff --git a/frontend/e2e/student-import.spec.ts b/frontend/e2e/student-import.spec.ts new file mode 100644 index 0000000..0226bfd --- /dev/null +++ b/frontend/e2e/student-import.spec.ts @@ -0,0 +1,493 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { writeFileSync, mkdirSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-import-admin@example.com'; +const ADMIN_PASSWORD = 'ImportTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runCommand(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId, academicYearId }; +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +// Create CSV fixture file for tests +function createCsvFixture(filename: string, content: string): string { + const tmpDir = join(__dirname, 'fixtures'); + mkdirSync(tmpDir, { recursive: true }); + const filePath = join(tmpDir, filename); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +test.describe('Student Import via CSV', () => { + test.describe.configure({ mode: 'serial' }); + + let classId: string; + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Clean up auto-created class from previous runs (FK: assignments first) + try { + runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`); + runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`); + } catch { /* ignore */ } + + // Create a class for valid import rows + const { schoolId, academicYearId } = resolveDeterministicIds(); + const suffix = Date.now().toString().slice(-8); + classId = `00000100-e2e0-4000-8000-${suffix}0001`; + + try { + runCommand( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Import A', NULL, NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { + // Class may already exist + } + }); + + test('displays the import wizard page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({ + timeout: 15000 + }); + + // Verify stepper is visible with 4 steps + await expect(page.locator('.stepper .step')).toHaveCount(4); + + // Verify dropzone is visible + await expect(page.locator('.dropzone')).toBeVisible(); + await expect(page.getByText(/glissez votre fichier/i)).toBeVisible(); + }); + + test('shows format help cards', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({ + timeout: 15000 + }); + + await expect(page.getByText(/formats supportés/i)).toBeVisible(); + await expect(page.getByText('Pronote', { exact: true })).toBeVisible(); + await expect(page.getByText('EcoleDirecte', { exact: true })).toBeVisible(); + await expect(page.getByText(/personnalisé/i)).toBeVisible(); + }); + + test('uploads a CSV file and shows mapping step', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\nMartin;Marie;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-test.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + // Upload via file input + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Should transition to mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // File info should be visible + await expect(page.getByText(/e2e-import-test\.csv/i)).toBeVisible(); + await expect(page.getByText(/2 lignes/i)).toBeVisible(); + + // Column names should appear in mapping + await expect(page.locator('.column-name').filter({ hasText: /^Nom$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Prénom$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Classe$/ })).toBeVisible(); + + // Clean up + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('validates required fields in mapping', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-required.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled + const validateButton = page.getByRole('button', { name: /valider le mapping/i }); + await expect(validateButton).toBeVisible(); + + // Clean up + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('navigates back from mapping to upload', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-back.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Click back button + await page.getByRole('button', { name: /retour/i }).click(); + + // Should be back on upload step + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 }); + + // Clean up + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('rejects non-CSV files', async ({ page }) => { + const txtPath = createCsvFixture('e2e-import-bad.pdf', 'not a csv file'); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(txtPath); + + // Should show error + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + + // Clean up + try { unlinkSync(txtPath); } catch { /* ignore */ } + }); + + test('shows preview step with valid/error counts', async ({ page }) => { + const csvContent = + 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-preview.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Submit mapping + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Wait for preview step + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Should show valid and error counts + await expect(page.locator('.summary-card.valid')).toBeVisible(); + await expect(page.locator('.summary-card.error')).toBeVisible(); + + // Clean up + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('navigable from students page via import button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + + await expect( + page.getByRole('heading', { name: /gestion des élèves/i }) + ).toBeVisible({ timeout: 15000 }); + + // Click import link + const importLink = page.getByRole('link', { name: /importer.*csv/i }); + await expect(importLink).toBeVisible(); + await importLink.click(); + + // Should navigate to import page + await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({ + timeout: 15000 + }); + }); + + test('[P0] completes full import flow with progress and report', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nTestImport;Alice;E2E Import A\nTestImport;Bob;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + // Step 1: Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Step 2: Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Step 3: Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Step 4: Confirmation — wait for completion (import may be too fast for progressbar to be visible) + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify report stats + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('2'); + const errorStat = stats.filter({ hasText: /erreurs/ }); + await expect(errorStat.locator('.stat-value')).toHaveText('0'); + + // Verify action buttons + await expect(page.getByRole('button', { name: /télécharger le rapport/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P1] imports only valid rows when errors exist', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nDurand;Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-valid-only.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + // Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Verify error count + await expect(page.locator('.summary-card.error .summary-number')).toHaveText('2'); + + // Verify error detail rows are visible + await expect(page.locator('.error-detail').first()).toBeVisible(); + + // "Import valid only" radio should be selected by default + const validOnlyRadio = page.locator('input[type="radio"][name="importMode"][value="true"]'); + await expect(validOnlyRadio).toBeChecked(); + + // Launch import (should only import 1 valid row) + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify only 1 student imported + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('1'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P1] shows unknown classes and allows auto-creation', async ({ page }) => { + const csvContent = 'Nom;Prénom;Classe\nLemaire;Paul;E2E NewAutoClass\n'; + const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + // Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Verify unknown classes section + await expect(page.locator('.unknown-classes')).toBeVisible(); + await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass'); + + // Check auto-create checkbox + await page.locator('.unknown-classes input[type="checkbox"]').check(); + + // Select "import all rows" since unknown class makes row invalid (validCount=0) + await page.locator('input[type="radio"][name="importMode"][value="false"]').check(); + + // Launch import + await page.getByRole('button', { name: /lancer l'import/i }).click(); + + // Wait for completion + await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 }); + + // Verify student imported + const stats = page.locator('.report-stats .stat'); + const importedStat = stats.filter({ hasText: /importés/ }); + await expect(importedStat.locator('.stat-value')).toHaveText('1'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup: delete assignments then class (FK constraint) + try { + runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`); + runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`); + } catch { /* ignore */ } + }); + + test('[P1] detects Pronote format and pre-fills mapping', async ({ page }) => { + // Pronote format needs 3+ matching columns: Élèves, Né(e) le, Sexe, Classe de rattachement + const csvContent = 'Élèves;Né(e) le;Sexe;Classe de rattachement\nDUPONT Jean;15/03/2010;M;E2E Import A\n'; + const csvPath = createCsvFixture('e2e-import-pronote.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Verify format detection badge + await expect(page.locator('.format-badge')).toBeVisible(); + await expect(page.locator('.format-badge')).toContainText('Pronote'); + + // Verify pre-filled mapping: Élèves → Nom complet (fullName) + const elevesRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Élèves$/ }) }); + await expect(elevesRow.locator('select')).toHaveValue('fullName'); + + // Verify pre-filled mapping: Classe de rattachement → Classe (className) + const classeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Classe de rattachement$/ }) }); + await expect(classeRow.locator('select')).toHaveValue('className'); + + // Verify pre-filled mapping: Né(e) le → Date de naissance (birthDate) + const dateRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Né\(e\) le$/ }) }); + await expect(dateRow.locator('select')).toHaveValue('birthDate'); + + // Verify pre-filled mapping: Sexe → Genre (gender) + const sexeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Sexe$/ }) }); + await expect(sexeRow.locator('select')).toHaveValue('gender'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => { + // Create CSV with 8 data rows (more than the 5-line preview limit) + const csvContent = [ + 'Nom;Prénom;Classe', + 'Alpha;Un;E2E Import A', + 'Bravo;Deux;E2E Import A', + 'Charlie;Trois;E2E Import A', + 'Delta;Quatre;E2E Import A', + 'Echo;Cinq;E2E Import A', + 'Foxtrot;Six;E2E Import A', + 'Golf;Sept;E2E Import A', + 'Hotel;Huit;E2E Import A' + ].join('\n') + '\n'; + const csvPath = createCsvFixture('e2e-import-preview-5.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Verify preview section exists + await expect(page.locator('.preview-section')).toBeVisible(); + + // Verify heading shows 5 premières lignes + await expect(page.locator('.preview-section h3')).toContainText('5 premières lignes'); + + // Verify exactly 5 rows in the preview table (not 8) + await expect(page.locator('.preview-table tbody tr')).toHaveCount(5); + + // Verify total row count in file info + await expect(page.getByText(/8 lignes/i)).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P2] rejects files exceeding 10 MB limit', async ({ page }) => { + // Create a CSV file that exceeds 10 MB + const header = 'Nom;Prénom;Classe\n'; + const line = 'Dupont;Jean;E2E Import A\n'; + const targetSize = 10 * 1024 * 1024 + 100; // just over 10 MB + const repeats = Math.ceil((targetSize - header.length) / line.length); + const content = header + line.repeat(repeats); + const csvPath = createCsvFixture('e2e-import-too-large.csv', content); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/students`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Should show error about file size + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/dépasse la taille maximale de 10 Mo/i)).toBeVisible(); + + // Should stay on upload step (not transition to mapping) + await expect(page.locator('.dropzone')).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 957910d..233b196 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -79,6 +79,7 @@ export default tseslint.config( fetch: 'readonly', HTMLElement: 'readonly', HTMLDivElement: 'readonly', + HTMLSelectElement: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', URL: 'readonly', @@ -88,7 +89,10 @@ export default tseslint.config( AbortController: 'readonly', DOMException: 'readonly', setTimeout: 'readonly', - clearTimeout: 'readonly' + clearTimeout: 'readonly', + DragEvent: 'readonly', + File: 'readonly', + Blob: 'readonly' } }, plugins: { diff --git a/frontend/src/lib/features/import/api/studentImport.ts b/frontend/src/lib/features/import/api/studentImport.ts new file mode 100644 index 0000000..7190681 --- /dev/null +++ b/frontend/src/lib/features/import/api/studentImport.ts @@ -0,0 +1,186 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +// === Types === + +export interface UploadResult { + id: string; + filename: string; + totalRows: number; + columns: string[]; + detectedFormat: string; + suggestedMapping: Record; + preview: PreviewRow[]; +} + +export interface PreviewRow { + line: number; + data: Record; + valid: boolean; + errors: RowError[]; +} + +export interface RowError { + column: string; + message: string; +} + +export interface MappingResult { + id: string; + mapping: Record; + totalRows: number; +} + +export interface PreviewResult { + id: string; + totalRows: number; + validCount: number; + errorCount: number; + rows: PreviewRow[]; + unknownClasses: string[]; +} + +export interface ConfirmResult { + id: string; + status: string; + message: string; +} + +export interface ImportStatus { + id: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + totalRows: number; + importedCount: number; + errorCount: number; + progression: number; + completedAt: string | null; +} + +export interface ImportReport { + id: string; + status: string; + totalRows: number; + importedCount: number; + errorCount: number; + report: string[]; + errors: { line: number; errors: RowError[] }[]; +} + +// === API Functions === + +/** + * Upload un fichier CSV ou XLSX pour l'import d'élèves. + */ +export async function uploadFile(file: File): Promise { + const apiUrl = getApiBaseUrl(); + const formData = new FormData(); + formData.append('file', file); + + const response = await authenticatedFetch(`${apiUrl}/import/students/upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de l\'upload' + ); + } + + return await response.json(); +} + +/** + * Applique le mapping des colonnes. + */ +export async function applyMapping( + batchId: string, + mapping: Record, + format: string +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/mapping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mapping, format }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors du mapping' + ); + } + + return await response.json(); +} + +/** + * Récupère la preview avec validation. + */ +export async function fetchPreview(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/preview`); + + if (!response.ok) { + throw new Error('Erreur lors de la validation'); + } + + return await response.json(); +} + +/** + * Confirme et lance l'import. + */ +export async function confirmImport( + batchId: string, + options: { createMissingClasses: boolean; importValidOnly: boolean } +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/confirm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? + data?.message ?? + data?.detail ?? + 'Erreur lors de la confirmation' + ); + } + + return await response.json(); +} + +/** + * Récupère le statut et la progression de l'import. + */ +export async function fetchImportStatus(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/status`); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération du statut'); + } + + return await response.json(); +} + +/** + * Récupère le rapport détaillé de l'import. + */ +export async function fetchImportReport(batchId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/report`); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération du rapport'); + } + + return await response.json(); +} diff --git a/frontend/src/routes/admin/import/students/+page.svelte b/frontend/src/routes/admin/import/students/+page.svelte new file mode 100644 index 0000000..1dc6b74 --- /dev/null +++ b/frontend/src/routes/admin/import/students/+page.svelte @@ -0,0 +1,1726 @@ + + + + Import élèves - Classeo + + +
+ + + + + + + + {#if error} + + {/if} + + + {#if currentStep === 'upload'} +
+
{ if (e.key === 'Enter' || e.key === ' ') fileInput?.click(); }} + > + {#if isUploading} +
+
+

Analyse du fichier en cours...

+
+ {:else} +
+ +

Glissez votre fichier ici

+

ou cliquez pour parcourir

+

CSV, XLSX - Max 10 Mo

+
+ {/if} + + +
+ + +
+

Formats supportés

+
+
+ Pronote +

Export élèves depuis Pronote (CSV avec colonnes Nom de l'élève, Prénom, Classe...)

+
+
+ EcoleDirecte +

Export depuis EcoleDirecte (CSV avec colonnes NOM, PRENOM, CLASSE...)

+
+
+ Personnalisé +

Tout fichier CSV ou Excel avec au minimum : Nom, Prénom, Classe

+
+
+
+
+ {/if} + + + {#if currentStep === 'mapping'} +
+ {#if uploadResult} +
+
+ {uploadResult.filename} - {uploadResult.totalRows} lignes détectées + {#if detectedFormat !== 'custom'} + + Format {detectedFormat === 'pronote' ? 'Pronote' : 'EcoleDirecte'} détecté + + {/if} +
+
+ + + {#if uploadResult.preview.length > 0} +
+

Aperçu des données ({Math.min(5, uploadResult.preview.length)} premières lignes)

+
+ + + + + {#each uploadResult.columns as col} + + {/each} + + + + {#each uploadResult.preview.slice(0, 5) as row} + + + {#each uploadResult.columns as col} + + {/each} + + {/each} + +
#{col}
{row.line}{row.data[mapping[col] ?? ''] ?? Object.values(row.data)[uploadResult.columns.indexOf(col)] ?? ''}
+
+
+ {/if} + + +
+

Association des colonnes

+

Glissez-déposez les champs Classeo sur les colonnes de votre fichier, ou utilisez les menus déroulants. Les champs marqués * sont obligatoires.

+ + +
+ Champs Classeo : +
+ {#each CLASSEO_FIELDS as field} + handleFieldDragStart(e, field.value)} + ondragend={handleFieldDragEnd} + role="button" + tabindex={isFieldMapped(field.value) ? -1 : 0} + aria-label="{field.label}{field.required ? ' (obligatoire)' : ''}" + > + {field.label}{field.required ? ' *' : ''} + + {/each} +
+
+ +
+ {#each uploadResult.columns as column} +
handleMappingRowDragOver(e, column)} + ondragleave={() => handleMappingRowDragLeave(column)} + ondrop={(e) => handleMappingRowDrop(e, column)} + > +
+ {column} +
+
+ +
+
+ +
+
+ {/each} +
+ + {#if !requiredFieldsMapped} +

+ Tous les champs obligatoires (*) doivent être associés pour continuer. +

+ {/if} +
+ +
+ + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'preview'} +
+ {#if isLoadingPreview} +
+
+

Validation des données en cours...

+
+ {:else if previewResult} + +
+
+ {previewResult.validCount} + Lignes valides +
+
+ {previewResult.errorCount} + Lignes en erreur +
+
+ {previewResult.totalRows} + Total +
+
+ + + {#if previewResult.unknownClasses.length > 0} +
+

Classes non trouvées

+

Les classes suivantes n'existent pas encore dans Classeo :

+
+ {#each previewResult.unknownClasses as cls} + {cls} + {/each} +
+ +
+ {/if} + + + {#if previewResult.errorCount > 0} +
+ + +
+ {/if} + + +
+

Détail des données

+
+ + + + + + + + + + + + + {#each previewResult.rows as row} + + + + + + + + + {#if !row.valid} + + + + {/if} + {/each} + +
LigneNomPrénomClasseEmailStatut
{row.line}{row.data['lastName'] ?? ''}{row.data['firstName'] ?? ''}{row.data['className'] ?? ''}{row.data['email'] ?? ''} + {#if row.valid} + Valide + {:else} + `${e.column}: ${e.message}`).join(', ')}> + Erreur + + {/if} +
+ {#each row.errors as err} + {err.column} : {err.message} + {/each} +
+
+
+ +
+ + + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'confirmation'} +
+ {#if importStatus?.status === 'completed' || importStatus?.status === 'failed'} + +
+ {#if importStatus.status === 'completed'} +
+ +

Import terminé

+
+ {:else} +
+ +

Import échoué

+
+ {/if} + +
+
+ {importStatus.importedCount} + élèves importés +
+
+ {importStatus.errorCount} + erreurs +
+
+ + {#if importReport} + + {#if importReport.report.length > 0} +
+

Rapport

+
    + {#each importReport.report as line} +
  • {line}
  • + {/each} +
+
+ {/if} + + + {#if importReport.errors.length > 0} +
+

Détail des erreurs

+
+ + + + + + + + + {#each importReport.errors as err} + + + + + {/each} + +
LigneErreur
{err.line} + {#each err.errors as e} + {e.column} : {e.message} + {/each} +
+
+
+ {/if} + {/if} + +
+ {#if importReport} + + {/if} + + +
+
+ {:else} + +
+
+

Import en cours...

+

Veuillez patienter pendant l'import de vos élèves.

+
+ +
+
+
+

+ {importStatus?.progression ?? 0}% + {#if importStatus} + - {importStatus.importedCount + importStatus.errorCount} / {importStatus.totalRows} lignes traitées + {/if} +

+
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/routes/admin/students/+page.svelte b/frontend/src/routes/admin/students/+page.svelte index 4b2b547..03e5cd1 100644 --- a/frontend/src/routes/admin/students/+page.svelte +++ b/frontend/src/routes/admin/students/+page.svelte @@ -389,10 +389,15 @@

Gestion des élèves

Créez et gérez les élèves de votre établissement

- +
+ + Importer (CSV) + + +
{#if error} @@ -730,6 +735,12 @@ flex-wrap: wrap; } + .header-actions { + display: flex; + gap: 0.75rem; + align-items: center; + } + .header-content h1 { margin: 0; font-size: 1.5rem;