From 2420e35492efb3fc983c7be7bffeedb65622187c Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 25 Feb 2026 16:51:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20l'import=20d'=C3=A9l=C3=A8v?= =?UTF-8?q?es=20via=20fichier=20CSV=20ou=20XLSX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs. --- backend/Dockerfile | 6 +- backend/composer.json | 1 + backend/composer.lock | 505 ++++- backend/config/packages/messenger.yaml | 2 + backend/config/packages/security.yaml | 1 + backend/config/services.yaml | 8 + backend/migrations/Version20260224143000.php | 44 + backend/migrations/Version20260224214219.php | 34 + .../ImportStudents/ImportStudentsCommand.php | 22 + .../ImportStudents/ImportStudentsHandler.php | 225 +++ .../Service/Import/ColumnMappingSuggester.php | 159 ++ .../Application/Service/Import/CsvParser.php | 146 ++ .../Application/Service/Import/DateParser.php | 33 + .../Service/Import/FileParseResult.php | 39 + .../Service/Import/ImportFormatDetector.php | 105 + .../Service/Import/ImportReport.php | 83 + .../Service/Import/ImportRowValidator.php | 214 ++ .../Import/StudentImportOrchestrator.php | 248 +++ .../Application/Service/Import/XlsxParser.php | 68 + .../Domain/Event/ImportElevesEchoue.php | 38 + .../Domain/Event/ImportElevesLance.php | 38 + .../Domain/Event/ImportElevesTermine.php | 39 + .../FichierImportInvalideException.php | 43 + .../ImportBatchNotFoundException.php | 21 + .../Exception/ImportDejaEnCoursException.php | 21 + .../ImportNonDemarrableException.php | 31 + .../Exception/MappingIncompletException.php | 22 + .../Domain/Model/Import/ColumnMapping.php | 66 + .../Domain/Model/Import/ImportBatchId.php | 11 + .../Domain/Model/Import/ImportRow.php | 48 + .../Domain/Model/Import/ImportRowError.php | 24 + .../Domain/Model/Import/ImportStatus.php | 36 + .../Domain/Model/Import/KnownImportFormat.php | 24 + .../Model/Import/StudentImportBatch.php | 245 +++ .../Model/Import/StudentImportField.php | 53 + .../Repository/ImportBatchRepository.php | 23 + .../SavedColumnMappingRepository.php | 28 + .../Controller/StudentImportController.php | 346 ++++ .../DoctrineImportBatchRepository.php | 241 +++ .../DoctrineSavedColumnMappingRepository.php | 82 + .../InMemoryImportBatchRepository.php | 45 + .../InMemorySavedColumnMappingRepository.php | 34 + .../ImportStudentsHandlerTest.php | 293 +++ .../Import/ColumnMappingSuggesterTest.php | 106 + .../Service/Import/CsvParserTest.php | 121 ++ .../Import/ImportFormatDetectorTest.php | 70 + .../Service/Import/ImportRowValidatorTest.php | 311 +++ .../Import/PronoteImportIntegrationTest.php | 153 ++ .../Domain/Model/Import/ColumnMappingTest.php | 129 ++ .../Domain/Model/Import/ImportRowTest.php | 74 + .../Domain/Model/Import/ImportStatusTest.php | 39 + .../Model/Import/StudentImportBatchTest.php | 281 +++ .../Model/Import/StudentImportFieldTest.php | 49 + .../tests/fixtures/import/eleves_comma.csv | 3 + .../fixtures/import/eleves_ecole_directe.csv | 3 + .../tests/fixtures/import/eleves_pronote.csv | 28 + .../tests/fixtures/import/eleves_simple.csv | 4 + frontend/e2e/student-import.spec.ts | 493 +++++ frontend/eslint.config.js | 6 +- .../lib/features/import/api/studentImport.ts | 186 ++ .../routes/admin/import/students/+page.svelte | 1726 +++++++++++++++++ .../src/routes/admin/students/+page.svelte | 19 +- 62 files changed, 7510 insertions(+), 86 deletions(-) create mode 100644 backend/migrations/Version20260224143000.php create mode 100644 backend/migrations/Version20260224214219.php create mode 100644 backend/src/Administration/Application/Command/ImportStudents/ImportStudentsCommand.php create mode 100644 backend/src/Administration/Application/Command/ImportStudents/ImportStudentsHandler.php create mode 100644 backend/src/Administration/Application/Service/Import/ColumnMappingSuggester.php create mode 100644 backend/src/Administration/Application/Service/Import/CsvParser.php create mode 100644 backend/src/Administration/Application/Service/Import/DateParser.php create mode 100644 backend/src/Administration/Application/Service/Import/FileParseResult.php create mode 100644 backend/src/Administration/Application/Service/Import/ImportFormatDetector.php create mode 100644 backend/src/Administration/Application/Service/Import/ImportReport.php create mode 100644 backend/src/Administration/Application/Service/Import/ImportRowValidator.php create mode 100644 backend/src/Administration/Application/Service/Import/StudentImportOrchestrator.php create mode 100644 backend/src/Administration/Application/Service/Import/XlsxParser.php create mode 100644 backend/src/Administration/Domain/Event/ImportElevesEchoue.php create mode 100644 backend/src/Administration/Domain/Event/ImportElevesLance.php create mode 100644 backend/src/Administration/Domain/Event/ImportElevesTermine.php create mode 100644 backend/src/Administration/Domain/Exception/FichierImportInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/ImportBatchNotFoundException.php create mode 100644 backend/src/Administration/Domain/Exception/ImportDejaEnCoursException.php create mode 100644 backend/src/Administration/Domain/Exception/ImportNonDemarrableException.php create mode 100644 backend/src/Administration/Domain/Exception/MappingIncompletException.php create mode 100644 backend/src/Administration/Domain/Model/Import/ColumnMapping.php create mode 100644 backend/src/Administration/Domain/Model/Import/ImportBatchId.php create mode 100644 backend/src/Administration/Domain/Model/Import/ImportRow.php create mode 100644 backend/src/Administration/Domain/Model/Import/ImportRowError.php create mode 100644 backend/src/Administration/Domain/Model/Import/ImportStatus.php create mode 100644 backend/src/Administration/Domain/Model/Import/KnownImportFormat.php create mode 100644 backend/src/Administration/Domain/Model/Import/StudentImportBatch.php create mode 100644 backend/src/Administration/Domain/Model/Import/StudentImportField.php create mode 100644 backend/src/Administration/Domain/Repository/ImportBatchRepository.php create mode 100644 backend/src/Administration/Domain/Repository/SavedColumnMappingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Controller/StudentImportController.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineImportBatchRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSavedColumnMappingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryImportBatchRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySavedColumnMappingRepository.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ImportStudents/ImportStudentsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/ColumnMappingSuggesterTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/CsvParserTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/ImportFormatDetectorTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/ImportRowValidatorTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/PronoteImportIntegrationTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/ColumnMappingTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/ImportRowTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/ImportStatusTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/StudentImportBatchTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Import/StudentImportFieldTest.php create mode 100644 backend/tests/fixtures/import/eleves_comma.csv create mode 100644 backend/tests/fixtures/import/eleves_ecole_directe.csv create mode 100644 backend/tests/fixtures/import/eleves_pronote.csv create mode 100644 backend/tests/fixtures/import/eleves_simple.csv create mode 100644 frontend/e2e/student-import.spec.ts create mode 100644 frontend/src/lib/features/import/api/studentImport.ts create mode 100644 frontend/src/routes/admin/import/students/+page.svelte 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;