feat: Permettre l'import d'élèves via fichier CSV ou XLSX
L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs.
This commit is contained in:
@@ -13,8 +13,11 @@ RUN apk add --no-cache \
|
|||||||
file \
|
file \
|
||||||
gettext \
|
gettext \
|
||||||
git \
|
git \
|
||||||
|
freetype-dev \
|
||||||
icu-dev \
|
icu-dev \
|
||||||
imagemagick-dev \
|
imagemagick-dev \
|
||||||
|
libjpeg-turbo-dev \
|
||||||
|
libpng-dev \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
postgresql-dev \
|
postgresql-dev \
|
||||||
rabbitmq-c-dev \
|
rabbitmq-c-dev \
|
||||||
@@ -22,7 +25,8 @@ RUN apk add --no-cache \
|
|||||||
$PHPIZE_DEPS
|
$PHPIZE_DEPS
|
||||||
|
|
||||||
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
|
# 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.)
|
# Install Imagick extension for image processing (logo resize, etc.)
|
||||||
RUN pecl install imagick && docker-php-ext-enable imagick
|
RUN pecl install imagick && docker-php-ext-enable imagick
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.4",
|
||||||
"promphp/prometheus_client_php": "^2.14",
|
"promphp/prometheus_client_php": "^2.14",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"sentry/sentry-symfony": "^5.8",
|
"sentry/sentry-symfony": "^5.8",
|
||||||
|
|||||||
505
backend/composer.lock
generated
505
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2",
|
"content-hash": "8b72e362a7720afa0811f80f9ef6e8d5",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -284,6 +284,85 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-11-24T14:40:29+00:00"
|
"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",
|
"name": "doctrine/collections",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -1822,6 +1901,191 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-20T17:47:00+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -1990,6 +2254,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-12T15:59:08+00:00"
|
"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",
|
"name": "promphp/prometheus_client_php",
|
||||||
"version": "v2.14.1",
|
"version": "v2.14.1",
|
||||||
@@ -2472,6 +2845,57 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"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",
|
"name": "ralouphie/getallheaders",
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
@@ -8139,85 +8563,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2022-12-23T10:58:28+00:00"
|
"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",
|
"name": "composer/semver",
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
|
|||||||
@@ -52,3 +52,5 @@ framework:
|
|||||||
App\Administration\Domain\Event\MotDePasseChange: async
|
App\Administration\Domain\Event\MotDePasseChange: async
|
||||||
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
|
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
|
||||||
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
|
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
|
||||||
|
# Import élèves → async (batch processing, peut être long)
|
||||||
|
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ security:
|
|||||||
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
|
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
|
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
|
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/import, roles: ROLE_ADMIN }
|
||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -209,6 +209,14 @@ services:
|
|||||||
App\Administration\Application\Port\ImageProcessor:
|
App\Administration\Application\Port\ImageProcessor:
|
||||||
alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor
|
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)
|
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
|
||||||
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
44
backend/migrations/Version20260224143000.php
Normal file
44
backend/migrations/Version20260224143000.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260224143000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create student_import_batches table for CSV/XLSX student import wizard';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/migrations/Version20260224214219.php
Normal file
34
backend/migrations/Version20260224214219.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260224214219 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajouter la table saved_column_mappings pour réutiliser les mappings d\'import';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ImportStudents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commande pour lancer l'import d'élèves en batch.
|
||||||
|
*
|
||||||
|
* Dispatchée de manière asynchrone via le event bus.
|
||||||
|
*/
|
||||||
|
final readonly class ImportStudentsCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $batchId,
|
||||||
|
public string $tenantId,
|
||||||
|
public string $schoolName,
|
||||||
|
public string $academicYearId,
|
||||||
|
public bool $createMissingClasses = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ImportStudents;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\DateParser;
|
||||||
|
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||||
|
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use DomainException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour l'import d'élèves en batch.
|
||||||
|
*
|
||||||
|
* Traite les lignes valides du batch, crée les élèves et les affecte aux classes.
|
||||||
|
*
|
||||||
|
* @see AC5: Import validé → élèves créés en base
|
||||||
|
* @see NFR-SC6: 500 élèves importés en < 2 minutes
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class ImportStudentsHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ImportBatchRepository $importBatchRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private ClassAssignmentRepository $classAssignmentRepository,
|
||||||
|
private SchoolIdResolver $schoolIdResolver,
|
||||||
|
private Connection $connection,
|
||||||
|
private Clock $clock,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ImportStudentsCommand $command): void
|
||||||
|
{
|
||||||
|
$batchId = ImportBatchId::fromString($command->batchId);
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
$batch = $this->importBatchRepository->get($batchId);
|
||||||
|
|
||||||
|
$batch->demarrer($now);
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
|
||||||
|
$lignes = $batch->lignes();
|
||||||
|
$importedCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
$processedCount = 0;
|
||||||
|
$createdClasses = [];
|
||||||
|
|
||||||
|
/** @var array<string, ClassId> */
|
||||||
|
$classCache = [];
|
||||||
|
|
||||||
|
foreach ($lignes as $row) {
|
||||||
|
try {
|
||||||
|
$className = trim($row->valeurChamp(StudentImportField::CLASS_NAME) ?? '');
|
||||||
|
|
||||||
|
if (!isset($classCache[$className])) {
|
||||||
|
$classCache[$className] = $this->resolveClassId(
|
||||||
|
$className,
|
||||||
|
$tenantId,
|
||||||
|
$academicYearId,
|
||||||
|
$command->schoolName,
|
||||||
|
$command->createMissingClasses,
|
||||||
|
$now,
|
||||||
|
$createdClasses,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$classId = $classCache[$className];
|
||||||
|
|
||||||
|
$firstName = trim($row->valeurChamp(StudentImportField::FIRST_NAME) ?? '');
|
||||||
|
$lastName = trim($row->valeurChamp(StudentImportField::LAST_NAME) ?? '');
|
||||||
|
|
||||||
|
if ($firstName === '' && $lastName === '') {
|
||||||
|
$fullName = trim($row->valeurChamp(StudentImportField::FULL_NAME) ?? '');
|
||||||
|
if ($fullName !== '') {
|
||||||
|
[$lastName, $firstName] = ImportRowValidator::splitFullName($fullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$emailRaw = $row->valeurChamp(StudentImportField::EMAIL);
|
||||||
|
$birthDate = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||||
|
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||||
|
$trimmedStudentNumber = $studentNumber !== null && trim($studentNumber) !== '' ? trim($studentNumber) : null;
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($emailRaw !== null && trim($emailRaw) !== '') {
|
||||||
|
$emailVO = new Email(trim($emailRaw));
|
||||||
|
|
||||||
|
if ($this->userRepository->findByEmail($emailVO, $tenantId) !== null) {
|
||||||
|
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', trim($emailRaw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::inviter(
|
||||||
|
email: $emailVO,
|
||||||
|
role: Role::ELEVE,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolName: $command->schoolName,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
invitedAt: $now,
|
||||||
|
dateNaissance: DateParser::parse($birthDate),
|
||||||
|
studentNumber: $trimmedStudentNumber,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$user = User::inscrire(
|
||||||
|
role: Role::ELEVE,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolName: $command->schoolName,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
inscritAt: $now,
|
||||||
|
dateNaissance: DateParser::parse($birthDate),
|
||||||
|
studentNumber: $trimmedStudentNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
$assignment = ClassAssignment::affecter(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
studentId: $user->id,
|
||||||
|
classId: $classId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
assignedAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->classAssignmentRepository->save($assignment);
|
||||||
|
|
||||||
|
$this->connection->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$importedCount;
|
||||||
|
} catch (DomainException $e) {
|
||||||
|
$this->logger->warning('Import ligne {line} échouée : {message}', [
|
||||||
|
'line' => $row->lineNumber,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'batch_id' => $command->batchId,
|
||||||
|
]);
|
||||||
|
++$errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$processedCount;
|
||||||
|
if ($processedCount % 50 === 0) {
|
||||||
|
$batch->mettreAJourProgression($importedCount, $errorCount);
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch->terminer($importedCount, $errorCount, $this->clock->now());
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $createdClasses
|
||||||
|
*/
|
||||||
|
private function resolveClassId(
|
||||||
|
string $className,
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
string $schoolName,
|
||||||
|
bool $createMissingClasses,
|
||||||
|
DateTimeImmutable $now,
|
||||||
|
array &$createdClasses,
|
||||||
|
): ClassId {
|
||||||
|
$classNameVO = new ClassName($className);
|
||||||
|
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
return $class->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$createMissingClasses) {
|
||||||
|
throw new DomainException(sprintf('La classe "%s" n\'existe pas.', $className));
|
||||||
|
}
|
||||||
|
|
||||||
|
$newClass = SchoolClass::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId)),
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $classNameVO,
|
||||||
|
level: null,
|
||||||
|
capacity: null,
|
||||||
|
createdAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->classRepository->save($newClass);
|
||||||
|
$createdClasses[] = $className;
|
||||||
|
|
||||||
|
return $newClass->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggère un mapping automatique des colonnes basé sur les noms de colonnes
|
||||||
|
* et le format détecté.
|
||||||
|
*
|
||||||
|
* @see AC3: Mapping automatique proposé basé sur noms de colonnes
|
||||||
|
*/
|
||||||
|
final readonly class ColumnMappingSuggester
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mappings pré-configurés pour le format Pronote.
|
||||||
|
*
|
||||||
|
* @var array<string, StudentImportField>
|
||||||
|
*/
|
||||||
|
private const array PRONOTE_MAPPING = [
|
||||||
|
'élèves' => StudentImportField::FULL_NAME,
|
||||||
|
'eleves' => StudentImportField::FULL_NAME,
|
||||||
|
'né(e) le' => StudentImportField::BIRTH_DATE,
|
||||||
|
'ne(e) le' => StudentImportField::BIRTH_DATE,
|
||||||
|
'sexe' => StudentImportField::GENDER,
|
||||||
|
'classe de rattachement' => StudentImportField::CLASS_NAME,
|
||||||
|
'adresse e mail' => StudentImportField::EMAIL,
|
||||||
|
];
|
||||||
|
|
||||||
|
private const array ECOLE_DIRECTE_MAPPING = [
|
||||||
|
'nom' => StudentImportField::LAST_NAME,
|
||||||
|
'prenom' => StudentImportField::FIRST_NAME,
|
||||||
|
'classe' => StudentImportField::CLASS_NAME,
|
||||||
|
'date naissance' => StudentImportField::BIRTH_DATE,
|
||||||
|
'sexe' => StudentImportField::GENDER,
|
||||||
|
'email' => StudentImportField::EMAIL,
|
||||||
|
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping générique par mots-clés.
|
||||||
|
*
|
||||||
|
* @var array<string, StudentImportField>
|
||||||
|
*/
|
||||||
|
private const array GENERIC_KEYWORDS = [
|
||||||
|
'élèves' => StudentImportField::FULL_NAME,
|
||||||
|
'eleves' => StudentImportField::FULL_NAME,
|
||||||
|
'nom' => StudentImportField::LAST_NAME,
|
||||||
|
'last' => StudentImportField::LAST_NAME,
|
||||||
|
'family' => StudentImportField::LAST_NAME,
|
||||||
|
'surname' => StudentImportField::LAST_NAME,
|
||||||
|
'prénom' => StudentImportField::FIRST_NAME,
|
||||||
|
'prenom' => StudentImportField::FIRST_NAME,
|
||||||
|
'first' => StudentImportField::FIRST_NAME,
|
||||||
|
'given' => StudentImportField::FIRST_NAME,
|
||||||
|
'classe' => StudentImportField::CLASS_NAME,
|
||||||
|
'class' => StudentImportField::CLASS_NAME,
|
||||||
|
'groupe' => StudentImportField::CLASS_NAME,
|
||||||
|
'email' => StudentImportField::EMAIL,
|
||||||
|
'mail' => StudentImportField::EMAIL,
|
||||||
|
'courriel' => StudentImportField::EMAIL,
|
||||||
|
'naissance' => StudentImportField::BIRTH_DATE,
|
||||||
|
'birth' => StudentImportField::BIRTH_DATE,
|
||||||
|
'date' => StudentImportField::BIRTH_DATE,
|
||||||
|
'sexe' => StudentImportField::GENDER,
|
||||||
|
'genre' => StudentImportField::GENDER,
|
||||||
|
'gender' => StudentImportField::GENDER,
|
||||||
|
'numéro' => StudentImportField::STUDENT_NUMBER,
|
||||||
|
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||||
|
'number' => StudentImportField::STUDENT_NUMBER,
|
||||||
|
'matricule' => StudentImportField::STUDENT_NUMBER,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggère un mapping pour les colonnes données.
|
||||||
|
*
|
||||||
|
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||||
|
* @param KnownImportFormat $detectedFormat Format détecté
|
||||||
|
*
|
||||||
|
* @return array<string, StudentImportField> Mapping suggéré (colonne → champ)
|
||||||
|
*/
|
||||||
|
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
|
||||||
|
{
|
||||||
|
return match ($detectedFormat) {
|
||||||
|
KnownImportFormat::PRONOTE => $this->mapperAvecReference($columns, self::PRONOTE_MAPPING),
|
||||||
|
KnownImportFormat::ECOLE_DIRECTE => $this->mapperAvecReference($columns, self::ECOLE_DIRECTE_MAPPING),
|
||||||
|
KnownImportFormat::CUSTOM => $this->mapperGenerique($columns),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $columns
|
||||||
|
* @param array<string, StudentImportField> $reference
|
||||||
|
*
|
||||||
|
* @return array<string, StudentImportField>
|
||||||
|
*/
|
||||||
|
private function mapperAvecReference(array $columns, array $reference): array
|
||||||
|
{
|
||||||
|
$normalizedReference = [];
|
||||||
|
foreach ($reference as $key => $field) {
|
||||||
|
$normalizedReference[$this->normaliser($key)] = $field;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapping = [];
|
||||||
|
$usedFields = [];
|
||||||
|
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$normalized = $this->normaliser($column);
|
||||||
|
|
||||||
|
if (isset($normalizedReference[$normalized]) && !in_array($normalizedReference[$normalized], $usedFields, true)) {
|
||||||
|
$mapping[$column] = $normalizedReference[$normalized];
|
||||||
|
$usedFields[] = $normalizedReference[$normalized];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $columns
|
||||||
|
*
|
||||||
|
* @return array<string, StudentImportField>
|
||||||
|
*/
|
||||||
|
private function mapperGenerique(array $columns): array
|
||||||
|
{
|
||||||
|
$mapping = [];
|
||||||
|
$usedFields = [];
|
||||||
|
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$normalized = $this->normaliser($column);
|
||||||
|
|
||||||
|
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
|
||||||
|
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
|
||||||
|
$mapping[$column] = $field;
|
||||||
|
$usedFields[] = $field;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normaliser(string $column): string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower(trim($column));
|
||||||
|
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||||
|
|
||||||
|
/** @var string $result */
|
||||||
|
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
use function fclose;
|
||||||
|
use function fgetcsv;
|
||||||
|
use function fopen;
|
||||||
|
use function mb_convert_encoding;
|
||||||
|
use function mb_detect_encoding;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de parsing de fichiers CSV avec détection d'encoding UTF-8.
|
||||||
|
*
|
||||||
|
* Supporte les séparateurs courants (virgule, point-virgule, tabulation).
|
||||||
|
*/
|
||||||
|
final readonly class CsvParser
|
||||||
|
{
|
||||||
|
private const int MAX_LINE_LENGTH = 0;
|
||||||
|
private const array SEPARATORS = [';', ',', "\t"];
|
||||||
|
|
||||||
|
public function parse(string $filePath): FileParseResult
|
||||||
|
{
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
if ($handle === false) {
|
||||||
|
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = file_get_contents($filePath);
|
||||||
|
if ($content === false) {
|
||||||
|
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $this->convertToUtf8($content);
|
||||||
|
$content = $this->stripBom($content);
|
||||||
|
|
||||||
|
$separator = $this->detectSeparator($content);
|
||||||
|
|
||||||
|
$lines = $this->parseContent($content, $separator);
|
||||||
|
|
||||||
|
if ($lines === []) {
|
||||||
|
throw FichierImportInvalideException::fichierVide();
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = array_shift($lines);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($this->isEmptyLine($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [];
|
||||||
|
foreach ($columns as $index => $column) {
|
||||||
|
$row[$column] = $line[$index] ?? '';
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileParseResult($columns, $rows);
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertToUtf8(string $content): string
|
||||||
|
{
|
||||||
|
$encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||||
|
|
||||||
|
if ($encoding !== false && $encoding !== 'UTF-8') {
|
||||||
|
$converted = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||||||
|
|
||||||
|
return $converted !== false ? $converted : $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripBom(string $content): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||||
|
return substr($content, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectSeparator(string $content): string
|
||||||
|
{
|
||||||
|
$firstLine = strtok($content, "\n");
|
||||||
|
if ($firstLine === false) {
|
||||||
|
return ';';
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxCount = 0;
|
||||||
|
$bestSeparator = ';';
|
||||||
|
|
||||||
|
foreach (self::SEPARATORS as $separator) {
|
||||||
|
$count = substr_count($firstLine, $separator);
|
||||||
|
if ($count > $maxCount) {
|
||||||
|
$maxCount = $count;
|
||||||
|
$bestSeparator = $separator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bestSeparator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<list<string>>
|
||||||
|
*/
|
||||||
|
private function parseContent(string $content, string $separator): array
|
||||||
|
{
|
||||||
|
$stream = fopen('php://temp', 'r+');
|
||||||
|
if ($stream === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($stream, $content);
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
while (($line = fgetcsv($stream, self::MAX_LINE_LENGTH, $separator, '"', '')) !== false) {
|
||||||
|
/** @var list<string> $sanitized */
|
||||||
|
$sanitized = array_map('strval', $line);
|
||||||
|
$lines[] = $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $line
|
||||||
|
*/
|
||||||
|
private function isEmptyLine(array $line): bool
|
||||||
|
{
|
||||||
|
return count($line) === 1 && trim((string) $line[0]) === '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse des dates dans les formats courants utilisés par les fichiers d'import.
|
||||||
|
*/
|
||||||
|
final class DateParser
|
||||||
|
{
|
||||||
|
private const array FORMATS = ['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'];
|
||||||
|
|
||||||
|
public static function parse(?string $date): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($date === null || trim($date) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($date);
|
||||||
|
|
||||||
|
foreach (self::FORMATS as $format) {
|
||||||
|
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
|
||||||
|
if ($parsed !== false && $parsed->format($format) === $trimmed) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use function array_slice;
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat du parsing d'un fichier d'import.
|
||||||
|
*
|
||||||
|
* Contient les colonnes détectées et les données brutes extraites.
|
||||||
|
*/
|
||||||
|
final readonly class FileParseResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $columns Noms des colonnes détectées
|
||||||
|
* @param list<array<string, string>> $rows Données brutes (colonne → valeur)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $columns,
|
||||||
|
public array $rows,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalRows(): int
|
||||||
|
{
|
||||||
|
return count($this->rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, string>>
|
||||||
|
*/
|
||||||
|
public function preview(int $limit = 5): array
|
||||||
|
{
|
||||||
|
return array_slice($this->rows, 0, $limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte automatiquement le format d'import (Pronote, École Directe)
|
||||||
|
* à partir des noms de colonnes.
|
||||||
|
*/
|
||||||
|
final readonly class ImportFormatDetector
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Colonnes caractéristiques de Pronote.
|
||||||
|
*/
|
||||||
|
private const array PRONOTE_COLUMNS = [
|
||||||
|
'Élèves',
|
||||||
|
'Né(e) le',
|
||||||
|
'Sexe',
|
||||||
|
'Classe de rattachement',
|
||||||
|
'Adresse E-mail',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colonnes caractéristiques d'École Directe.
|
||||||
|
*/
|
||||||
|
private const array ECOLE_DIRECTE_COLUMNS = [
|
||||||
|
'NOM',
|
||||||
|
'PRENOM',
|
||||||
|
'CLASSE',
|
||||||
|
'DATE_NAISSANCE',
|
||||||
|
'SEXE',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||||
|
*/
|
||||||
|
public function detecter(array $columns): KnownImportFormat
|
||||||
|
{
|
||||||
|
$normalizedColumns = array_map($this->normaliser(...), $columns);
|
||||||
|
|
||||||
|
if ($this->matchesPronote($normalizedColumns)) {
|
||||||
|
return KnownImportFormat::PRONOTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->matchesEcoleDirecte($normalizedColumns)) {
|
||||||
|
return KnownImportFormat::ECOLE_DIRECTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KnownImportFormat::CUSTOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $normalizedColumns
|
||||||
|
*/
|
||||||
|
private function matchesPronote(array $normalizedColumns): bool
|
||||||
|
{
|
||||||
|
$pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS);
|
||||||
|
|
||||||
|
return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $normalizedColumns
|
||||||
|
*/
|
||||||
|
private function matchesEcoleDirecte(array $normalizedColumns): bool
|
||||||
|
{
|
||||||
|
$ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS);
|
||||||
|
|
||||||
|
return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $actualColumns
|
||||||
|
* @param list<string> $expectedColumns
|
||||||
|
*/
|
||||||
|
private function matchThreshold(array $actualColumns, array $expectedColumns, int $minMatches): bool
|
||||||
|
{
|
||||||
|
$matches = 0;
|
||||||
|
|
||||||
|
foreach ($expectedColumns as $expected) {
|
||||||
|
foreach ($actualColumns as $actual) {
|
||||||
|
if ($actual === $expected) {
|
||||||
|
++$matches;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches >= $minMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normaliser(string $column): string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower(trim($column));
|
||||||
|
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||||
|
|
||||||
|
/** @var string $result */
|
||||||
|
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rapport d'import généré après validation ou exécution.
|
||||||
|
*
|
||||||
|
* @see AC5: Rapport affiché : X élèves importés, Y erreurs ignorées
|
||||||
|
*/
|
||||||
|
final readonly class ImportReport
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $validRows Lignes valides importées
|
||||||
|
* @param list<ImportRow> $errorRows Lignes en erreur
|
||||||
|
* @param list<string> $createdClasses Classes créées automatiquement
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $totalRows,
|
||||||
|
public int $importedCount,
|
||||||
|
public int $errorCount,
|
||||||
|
public array $validRows,
|
||||||
|
public array $errorRows,
|
||||||
|
public array $createdClasses = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $rows Toutes les lignes validées
|
||||||
|
* @param list<string> $createdClasses Classes créées automatiquement
|
||||||
|
*/
|
||||||
|
public static function fromValidatedRows(array $rows, array $createdClasses = []): self
|
||||||
|
{
|
||||||
|
$valid = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row->estValide()) {
|
||||||
|
$valid[] = $row;
|
||||||
|
} else {
|
||||||
|
$errors[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
totalRows: count($rows),
|
||||||
|
importedCount: count($valid),
|
||||||
|
errorCount: count($errors),
|
||||||
|
validRows: $valid,
|
||||||
|
errorRows: $errors,
|
||||||
|
createdClasses: $createdClasses,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un résumé texte du rapport.
|
||||||
|
*
|
||||||
|
* @return list<string> Lignes du rapport
|
||||||
|
*/
|
||||||
|
public function lignesRapport(): array
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = sprintf('Import terminé : %d élèves importés, %d erreurs', $this->importedCount, $this->errorCount);
|
||||||
|
|
||||||
|
if ($this->createdClasses !== []) {
|
||||||
|
$lines[] = sprintf('Classes créées automatiquement : %s', implode(', ', $this->createdClasses));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->errorRows as $row) {
|
||||||
|
foreach ($row->errors as $error) {
|
||||||
|
$lines[] = sprintf('Ligne %d, %s', $row->lineNumber, $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
|
||||||
|
use const FILTER_VALIDATE_EMAIL;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les lignes d'import après mapping.
|
||||||
|
*
|
||||||
|
* Vérifie que les champs obligatoires sont remplis,
|
||||||
|
* les formats sont corrects (email, dates), et les classes existent.
|
||||||
|
*
|
||||||
|
* @see AC4: Lignes valides en vert, lignes avec erreurs en rouge
|
||||||
|
*/
|
||||||
|
final readonly class ImportRowValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private ?array $existingClassNames = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valider(ImportRow $row): ImportRow
|
||||||
|
{
|
||||||
|
$row = $this->expanderNomComplet($row);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
|
||||||
|
$errors = [...$errors, ...$this->validerEmail($row)];
|
||||||
|
$errors = [...$errors, ...$this->validerDateNaissance($row)];
|
||||||
|
$errors = [...$errors, ...$this->validerClasse($row)];
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return $row->avecErreurs(...$errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $rows
|
||||||
|
*
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
public function validerTout(array $rows): array
|
||||||
|
{
|
||||||
|
return array_map($this->valider(...), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRowError>
|
||||||
|
*/
|
||||||
|
private function validerChampsObligatoires(ImportRow $row): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach (StudentImportField::champsObligatoires() as $field) {
|
||||||
|
$value = $row->valeurChamp($field);
|
||||||
|
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
$errors[] = new ImportRowError(
|
||||||
|
$field->value,
|
||||||
|
sprintf('Le champ "%s" est obligatoire.', $field->label()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRowError>
|
||||||
|
*/
|
||||||
|
private function validerEmail(ImportRow $row): array
|
||||||
|
{
|
||||||
|
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||||
|
|
||||||
|
if ($email === null || trim($email) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||||
|
return [new ImportRowError(
|
||||||
|
StudentImportField::EMAIL->value,
|
||||||
|
sprintf('L\'adresse email "%s" est invalide.', $email),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRowError>
|
||||||
|
*/
|
||||||
|
private function validerDateNaissance(ImportRow $row): array
|
||||||
|
{
|
||||||
|
$date = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||||
|
|
||||||
|
if ($date === null || trim($date) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateParser::parse($date) === null) {
|
||||||
|
return [new ImportRowError(
|
||||||
|
StudentImportField::BIRTH_DATE->value,
|
||||||
|
sprintf('La date "%s" est invalide. Formats acceptés : JJ/MM/AAAA, AAAA-MM-JJ.', $date),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRowError>
|
||||||
|
*/
|
||||||
|
private function validerClasse(ImportRow $row): array
|
||||||
|
{
|
||||||
|
if ($this->existingClassNames === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||||
|
|
||||||
|
if ($className === null || trim($className) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array(trim($className), $this->existingClassNames, true)) {
|
||||||
|
return [new ImportRowError(
|
||||||
|
StudentImportField::CLASS_NAME->value,
|
||||||
|
sprintf('La classe "%s" n\'existe pas.', $className),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si FULL_NAME est renseigné et que LAST_NAME/FIRST_NAME sont vides,
|
||||||
|
* on dérive nom et prénom depuis le nom complet (format "NOM Prénom").
|
||||||
|
*/
|
||||||
|
private function expanderNomComplet(ImportRow $row): ImportRow
|
||||||
|
{
|
||||||
|
$fullName = $row->valeurChamp(StudentImportField::FULL_NAME);
|
||||||
|
|
||||||
|
if ($fullName === null || trim($fullName) === '') {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||||
|
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||||
|
|
||||||
|
if (($lastName !== null && trim($lastName) !== '') || ($firstName !== null && trim($firstName) !== '')) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$derivedLast, $derivedFirst] = self::splitFullName(trim($fullName));
|
||||||
|
|
||||||
|
$mappedData = $row->mappedData;
|
||||||
|
$mappedData[StudentImportField::LAST_NAME->value] = $derivedLast;
|
||||||
|
$mappedData[StudentImportField::FIRST_NAME->value] = $derivedFirst;
|
||||||
|
|
||||||
|
return new ImportRow($row->lineNumber, $row->rawData, $mappedData, $row->errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sépare un nom complet au format "NOM Prénom" en [nom, prénom].
|
||||||
|
*
|
||||||
|
* Convention Pronote : le nom de famille est en majuscules, le prénom en casse mixte.
|
||||||
|
* Si la convention n'est pas détectable, on prend le premier mot comme nom.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
public static function splitFullName(string $fullName): array
|
||||||
|
{
|
||||||
|
$parts = preg_split('/\s+/', trim($fullName));
|
||||||
|
if ($parts === false || $parts === []) {
|
||||||
|
return [$fullName, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uppercaseParts = [];
|
||||||
|
$rest = [];
|
||||||
|
$foundNonUpper = false;
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (!$foundNonUpper && mb_strtoupper($part) === $part && preg_match('/\p{L}/u', $part)) {
|
||||||
|
$uppercaseParts[] = $part;
|
||||||
|
} else {
|
||||||
|
$foundNonUpper = true;
|
||||||
|
$rest[] = $part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uppercaseParts !== [] && $rest !== []) {
|
||||||
|
return [implode(' ', $uppercaseParts), implode(' ', $rest)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastName = array_shift($parts);
|
||||||
|
|
||||||
|
return [$lastName ?? $fullName, implode(' ', $parts)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||||
|
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestre la chaîne d'import d'élèves : parse → détection → mapping → validation.
|
||||||
|
*
|
||||||
|
* Extrait la logique métier du contrôleur pour respecter l'architecture hexagonale.
|
||||||
|
*/
|
||||||
|
final readonly class StudentImportOrchestrator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CsvParser $csvParser,
|
||||||
|
private XlsxParser $xlsxParser,
|
||||||
|
private ImportFormatDetector $formatDetector,
|
||||||
|
private ColumnMappingSuggester $mappingSuggester,
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private ImportBatchRepository $importBatchRepository,
|
||||||
|
private SavedColumnMappingRepository $savedMappingRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
|
||||||
|
* crée le batch et enregistre les lignes mappées.
|
||||||
|
*
|
||||||
|
* @return array{batch: StudentImportBatch, suggestedMapping: array<string, StudentImportField>}
|
||||||
|
*/
|
||||||
|
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$parseResult = match ($extension) {
|
||||||
|
'csv', 'txt' => $this->csvParser->parse($filePath),
|
||||||
|
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
|
||||||
|
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
|
||||||
|
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
|
||||||
|
|
||||||
|
$batch = StudentImportBatch::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
originalFilename: $originalFilename,
|
||||||
|
totalRows: $parseResult->totalRows(),
|
||||||
|
detectedColumns: $parseResult->columns,
|
||||||
|
detectedFormat: $detectedFormat,
|
||||||
|
createdAt: $this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->mapRows($parseResult, $suggestedMapping);
|
||||||
|
$batch->enregistrerLignes($rows);
|
||||||
|
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
|
||||||
|
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
|
||||||
|
*/
|
||||||
|
public function applyMapping(StudentImportBatch $batch, ColumnMapping $columnMapping): void
|
||||||
|
{
|
||||||
|
$batch->appliquerMapping($columnMapping);
|
||||||
|
|
||||||
|
$remapped = [];
|
||||||
|
foreach ($batch->lignes() as $row) {
|
||||||
|
$mappedData = [];
|
||||||
|
foreach ($columnMapping->mapping as $column => $field) {
|
||||||
|
$mappedData[$field->value] = $row->rawData[$column] ?? '';
|
||||||
|
}
|
||||||
|
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
|
||||||
|
}
|
||||||
|
$batch->enregistrerLignes($remapped);
|
||||||
|
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les lignes du batch et retourne les résultats avec les classes inconnues.
|
||||||
|
*
|
||||||
|
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
|
||||||
|
*/
|
||||||
|
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||||
|
$validator = new ImportRowValidator($existingClasses);
|
||||||
|
|
||||||
|
$validatedRows = $validator->validerTout($batch->lignes());
|
||||||
|
$batch->enregistrerLignes($validatedRows);
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
|
||||||
|
$report = ImportReport::fromValidatedRows($validatedRows);
|
||||||
|
$unknownClasses = $this->detectUnknownClasses($validatedRows, $existingClasses);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'validatedRows' => $validatedRows,
|
||||||
|
'report' => $report,
|
||||||
|
'unknownClasses' => $unknownClasses,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare le batch pour la confirmation : re-valide si nécessaire
|
||||||
|
* et filtre les lignes selon les options choisies par l'utilisateur.
|
||||||
|
*
|
||||||
|
* Quand createMissingClasses est activé, les erreurs de classe inconnue
|
||||||
|
* sont retirées en re-validant sans vérification de classe.
|
||||||
|
*/
|
||||||
|
public function prepareForConfirmation(
|
||||||
|
StudentImportBatch $batch,
|
||||||
|
bool $createMissingClasses,
|
||||||
|
bool $importValidOnly,
|
||||||
|
): void {
|
||||||
|
if ($createMissingClasses) {
|
||||||
|
$validator = new ImportRowValidator();
|
||||||
|
$revalidated = $validator->validerTout($batch->lignes());
|
||||||
|
$batch->enregistrerLignes($revalidated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($importValidOnly) {
|
||||||
|
$batch->enregistrerLignes($batch->lignesValides());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch->mapping !== null) {
|
||||||
|
$this->savedMappingRepository->save(
|
||||||
|
$batch->tenantId,
|
||||||
|
$batch->mapping->format,
|
||||||
|
$batch->mapping->mapping,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->importBatchRepository->save($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggère un mapping en priorité depuis les mappings sauvegardés,
|
||||||
|
* puis en fallback depuis la détection automatique.
|
||||||
|
*
|
||||||
|
* @param list<string> $columns
|
||||||
|
*
|
||||||
|
* @return array<string, StudentImportField>
|
||||||
|
*/
|
||||||
|
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
|
||||||
|
|
||||||
|
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mappingSuggester->suggerer($columns, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que les colonnes du mapping sauvegardé correspondent
|
||||||
|
* aux colonnes détectées dans le fichier.
|
||||||
|
*
|
||||||
|
* @param array<string, StudentImportField> $mapping
|
||||||
|
* @param list<string> $columns
|
||||||
|
*/
|
||||||
|
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
|
||||||
|
{
|
||||||
|
foreach (array_keys($mapping) as $column) {
|
||||||
|
if (!in_array($column, $columns, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, StudentImportField> $mapping
|
||||||
|
*
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
private function mapRows(FileParseResult $parseResult, array $mapping): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$lineNumber = 1;
|
||||||
|
|
||||||
|
foreach ($parseResult->rows as $rawData) {
|
||||||
|
$mappedData = [];
|
||||||
|
foreach ($mapping as $column => $field) {
|
||||||
|
$mappedData[$field->value] = $rawData[$column] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
|
||||||
|
++$lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function getExistingClassNames(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn ($class) => (string) $class->name,
|
||||||
|
$classes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $rows
|
||||||
|
* @param list<string> $existingClasses
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function detectUnknownClasses(array $rows, array $existingClasses): array
|
||||||
|
{
|
||||||
|
$unknown = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||||
|
if ($className !== null
|
||||||
|
&& trim($className) !== ''
|
||||||
|
&& !in_array(trim($className), $existingClasses, true)
|
||||||
|
&& !in_array(trim($className), $unknown, true)
|
||||||
|
) {
|
||||||
|
$unknown[] = trim($className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Reader\Exception as SpreadsheetException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de parsing de fichiers XLSX (Excel) via PhpSpreadsheet.
|
||||||
|
*/
|
||||||
|
final readonly class XlsxParser
|
||||||
|
{
|
||||||
|
public function parse(string $filePath): FileParseResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$spreadsheet = IOFactory::load($filePath);
|
||||||
|
} catch (SpreadsheetException $e) {
|
||||||
|
throw FichierImportInvalideException::formatInvalide($filePath, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$data = $sheet->toArray('', true, true, false);
|
||||||
|
|
||||||
|
if ($data === []) {
|
||||||
|
throw FichierImportInvalideException::fichierVide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string|int|float|bool|null> $headerRow */
|
||||||
|
$headerRow = array_shift($data);
|
||||||
|
$columns = array_values(array_map(static fn (string|int|float|bool|null $v): string => (string) $v, $headerRow));
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($data as $line) {
|
||||||
|
/** @var list<mixed> $cells */
|
||||||
|
$cells = $line;
|
||||||
|
if ($this->isEmptyLine($cells)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [];
|
||||||
|
foreach ($columns as $index => $column) {
|
||||||
|
/** @var string|int|float|bool|null $cellValue */
|
||||||
|
$cellValue = $cells[$index] ?? '';
|
||||||
|
$row[$column] = (string) $cellValue;
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileParseResult($columns, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $line
|
||||||
|
*/
|
||||||
|
private function isEmptyLine(array $line): bool
|
||||||
|
{
|
||||||
|
foreach ($line as $cell) {
|
||||||
|
if ($cell !== null && $cell !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lorsqu'un import d'élèves échoue.
|
||||||
|
*/
|
||||||
|
final readonly class ImportElevesEchoue implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ImportBatchId $batchId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public int $errorCount,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->batchId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lorsqu'un import d'élèves est lancé.
|
||||||
|
*/
|
||||||
|
final readonly class ImportElevesLance implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ImportBatchId $batchId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public int $totalRows,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->batchId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lorsqu'un import d'élèves est terminé avec succès.
|
||||||
|
*/
|
||||||
|
final readonly class ImportElevesTermine implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ImportBatchId $batchId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public int $importedCount,
|
||||||
|
public int $errorCount,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->batchId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class FichierImportInvalideException extends DomainException
|
||||||
|
{
|
||||||
|
public static function fichierIllisible(string $path): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le fichier "%s" ne peut pas être lu.',
|
||||||
|
$path,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fichierVide(): self
|
||||||
|
{
|
||||||
|
return new self('Le fichier est vide ou ne contient aucune donnée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatInvalide(string $path, string $reason): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le fichier "%s" a un format invalide : %s',
|
||||||
|
$path,
|
||||||
|
$reason,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fichierTropGros(int $sizeBytes, int $maxBytes): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le fichier fait %d Mo mais la limite est de %d Mo.',
|
||||||
|
(int) ($sizeBytes / 1024 / 1024),
|
||||||
|
(int) ($maxBytes / 1024 / 1024),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ImportBatchNotFoundException extends DomainException
|
||||||
|
{
|
||||||
|
public static function withId(ImportBatchId $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le lot d\'import "%s" n\'existe pas.',
|
||||||
|
$id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ImportDejaEnCoursException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pourBatch(ImportBatchId $batchId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'L\'import "%s" est déjà en cours de traitement.',
|
||||||
|
$batchId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ImportNonDemarrableException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pourStatut(ImportBatchId $batchId, ImportStatus $status): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'L\'import "%s" ne peut pas être démarré depuis le statut "%s".',
|
||||||
|
$batchId,
|
||||||
|
$status->value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mappingManquant(ImportBatchId $batchId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'L\'import "%s" ne peut pas être démarré sans mapping de colonnes.',
|
||||||
|
$batchId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class MappingIncompletException extends DomainException
|
||||||
|
{
|
||||||
|
public static function champManquant(StudentImportField $champ): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le champ obligatoire "%s" (%s) n\'est pas mappé.',
|
||||||
|
$champ->label(),
|
||||||
|
$champ->value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\MappingIncompletException;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant l'association entre les colonnes du fichier source
|
||||||
|
* et les champs Classeo.
|
||||||
|
*
|
||||||
|
* @see FR76: Import élèves via CSV
|
||||||
|
*/
|
||||||
|
final readonly class ColumnMapping
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, StudentImportField> $mapping Colonne source → champ Classeo
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
public array $mapping,
|
||||||
|
public KnownImportFormat $format,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, StudentImportField> $mapping Colonne source → champ Classeo
|
||||||
|
*/
|
||||||
|
public static function creer(array $mapping, KnownImportFormat $format): self
|
||||||
|
{
|
||||||
|
$mappedFields = array_values($mapping);
|
||||||
|
$champsObligatoires = StudentImportField::champsObligatoires();
|
||||||
|
$hasFullName = in_array(StudentImportField::FULL_NAME, $mappedFields, true);
|
||||||
|
|
||||||
|
foreach ($champsObligatoires as $champ) {
|
||||||
|
if ($hasFullName && ($champ === StudentImportField::LAST_NAME || $champ === StudentImportField::FIRST_NAME)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!in_array($champ, $mappedFields, true)) {
|
||||||
|
throw MappingIncompletException::champManquant($champ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($mapping, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function champPour(string $colonneSource): ?StudentImportField
|
||||||
|
{
|
||||||
|
return $this->mapping[$colonneSource] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function colonnesSources(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->mapping === $other->mapping && $this->format === $other->format;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class ImportBatchId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
48
backend/src/Administration/Domain/Model/Import/ImportRow.php
Normal file
48
backend/src/Administration/Domain/Model/Import/ImportRow.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant une ligne du fichier d'import avec ses données mappées.
|
||||||
|
*
|
||||||
|
* Chaque ligne contient les valeurs extraites du fichier source,
|
||||||
|
* associées aux champs Classeo via le ColumnMapping.
|
||||||
|
*/
|
||||||
|
final readonly class ImportRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $lineNumber Numéro de ligne dans le fichier source (1-based)
|
||||||
|
* @param array<string, string> $rawData Données brutes (colonne → valeur)
|
||||||
|
* @param array<string, string> $mappedData Données mappées (champ Classeo → valeur)
|
||||||
|
* @param list<ImportRowError> $errors Erreurs de validation
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $lineNumber,
|
||||||
|
public array $rawData,
|
||||||
|
public array $mappedData,
|
||||||
|
public array $errors = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estValide(): bool
|
||||||
|
{
|
||||||
|
return $this->errors === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valeurChamp(StudentImportField $field): ?string
|
||||||
|
{
|
||||||
|
return $this->mappedData[$field->value] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avecErreurs(ImportRowError ...$erreurs): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->lineNumber,
|
||||||
|
$this->rawData,
|
||||||
|
$this->mappedData,
|
||||||
|
array_values([...$this->errors, ...$erreurs]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant une erreur de validation sur une ligne d'import.
|
||||||
|
*/
|
||||||
|
final readonly class ImportRowError
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $column,
|
||||||
|
public string $message,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return sprintf('Colonne "%s" : %s', $this->column, $this->message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut du cycle de vie d'un lot d'import.
|
||||||
|
*/
|
||||||
|
enum ImportStatus: string
|
||||||
|
{
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case PROCESSING = 'processing';
|
||||||
|
case COMPLETED = 'completed';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
|
||||||
|
public function peutDemarrer(): bool
|
||||||
|
{
|
||||||
|
return $this === self::PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estTermine(): bool
|
||||||
|
{
|
||||||
|
return $this === self::COMPLETED || $this === self::FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PENDING => 'En attente',
|
||||||
|
self::PROCESSING => 'En cours',
|
||||||
|
self::COMPLETED => 'Terminé',
|
||||||
|
self::FAILED => 'Échoué',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats d'import connus et détectables automatiquement.
|
||||||
|
*/
|
||||||
|
enum KnownImportFormat: string
|
||||||
|
{
|
||||||
|
case PRONOTE = 'pronote';
|
||||||
|
case ECOLE_DIRECTE = 'ecole_directe';
|
||||||
|
case CUSTOM = 'custom';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PRONOTE => 'Pronote',
|
||||||
|
self::ECOLE_DIRECTE => 'École Directe',
|
||||||
|
self::CUSTOM => 'Personnalisé',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\ImportElevesEchoue;
|
||||||
|
use App\Administration\Domain\Event\ImportElevesLance;
|
||||||
|
use App\Administration\Domain\Event\ImportElevesTermine;
|
||||||
|
use App\Administration\Domain\Exception\ImportNonDemarrableException;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function min;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root représentant un lot d'import d'élèves.
|
||||||
|
*
|
||||||
|
* Gère le cycle de vie d'un import depuis l'upload du fichier
|
||||||
|
* jusqu'à la confirmation finale.
|
||||||
|
*
|
||||||
|
* @see FR76: Import élèves via CSV
|
||||||
|
* @see NFR-SC6: Import < 2 min pour 500 élèves
|
||||||
|
*/
|
||||||
|
final class StudentImportBatch extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?ColumnMapping $mapping = null;
|
||||||
|
public private(set) int $importedCount = 0;
|
||||||
|
public private(set) int $errorCount = 0;
|
||||||
|
public private(set) ?DateTimeImmutable $completedAt = null;
|
||||||
|
|
||||||
|
/** @var list<ImportRow> */
|
||||||
|
private array $rows = [];
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) ImportBatchId $id,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) string $originalFilename,
|
||||||
|
public private(set) int $totalRows,
|
||||||
|
/** @var list<string> */
|
||||||
|
public private(set) array $detectedColumns,
|
||||||
|
public private(set) ?KnownImportFormat $detectedFormat,
|
||||||
|
public private(set) ImportStatus $status,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouveau lot d'import à partir des métadonnées du fichier parsé.
|
||||||
|
*
|
||||||
|
* @param list<string> $detectedColumns Colonnes détectées dans le fichier
|
||||||
|
*/
|
||||||
|
public static function creer(
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $originalFilename,
|
||||||
|
int $totalRows,
|
||||||
|
array $detectedColumns,
|
||||||
|
?KnownImportFormat $detectedFormat,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
id: ImportBatchId::generate(),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
originalFilename: $originalFilename,
|
||||||
|
totalRows: $totalRows,
|
||||||
|
detectedColumns: $detectedColumns,
|
||||||
|
detectedFormat: $detectedFormat,
|
||||||
|
status: ImportStatus::PENDING,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre le mapping des colonnes validé par l'utilisateur.
|
||||||
|
*/
|
||||||
|
public function appliquerMapping(ColumnMapping $mapping): void
|
||||||
|
{
|
||||||
|
$this->mapping = $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre les lignes parsées et mappées pour preview.
|
||||||
|
*
|
||||||
|
* @param list<ImportRow> $rows
|
||||||
|
*/
|
||||||
|
public function enregistrerLignes(array $rows): void
|
||||||
|
{
|
||||||
|
$this->rows = $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre l'import effectif.
|
||||||
|
*/
|
||||||
|
public function demarrer(DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if (!$this->status->peutDemarrer()) {
|
||||||
|
throw ImportNonDemarrableException::pourStatut($this->id, $this->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->mapping === null) {
|
||||||
|
throw ImportNonDemarrableException::mappingManquant($this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = ImportStatus::PROCESSING;
|
||||||
|
|
||||||
|
$this->recordEvent(new ImportElevesLance(
|
||||||
|
batchId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
totalRows: $this->totalRows,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les compteurs de progression pendant le traitement.
|
||||||
|
*/
|
||||||
|
public function mettreAJourProgression(int $importedCount, int $errorCount): void
|
||||||
|
{
|
||||||
|
$this->importedCount = $importedCount;
|
||||||
|
$this->errorCount = $errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque l'import comme terminé avec succès.
|
||||||
|
*/
|
||||||
|
public function terminer(int $importedCount, int $errorCount, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
$this->status = ImportStatus::COMPLETED;
|
||||||
|
$this->importedCount = $importedCount;
|
||||||
|
$this->errorCount = $errorCount;
|
||||||
|
$this->completedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new ImportElevesTermine(
|
||||||
|
batchId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
importedCount: $importedCount,
|
||||||
|
errorCount: $errorCount,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque l'import comme échoué.
|
||||||
|
*/
|
||||||
|
public function echouer(int $errorCount, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
$this->status = ImportStatus::FAILED;
|
||||||
|
$this->errorCount = $errorCount;
|
||||||
|
$this->completedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new ImportElevesEchoue(
|
||||||
|
batchId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
errorCount: $errorCount,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
public function lignes(): array
|
||||||
|
{
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
public function lignesValides(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->rows,
|
||||||
|
static fn (ImportRow $row): bool => $row->estValide(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
public function lignesEnErreur(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->rows,
|
||||||
|
static fn (ImportRow $row): bool => !$row->estValide(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estTermine(): bool
|
||||||
|
{
|
||||||
|
return $this->status->estTermine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function progression(): float
|
||||||
|
{
|
||||||
|
if ($this->totalRows === 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(100.0, ($this->importedCount + $this->errorCount) / $this->totalRows * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal Pour usage Infrastructure uniquement
|
||||||
|
*
|
||||||
|
* @param list<string> $detectedColumns
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
ImportBatchId $id,
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $originalFilename,
|
||||||
|
int $totalRows,
|
||||||
|
array $detectedColumns,
|
||||||
|
?KnownImportFormat $detectedFormat,
|
||||||
|
ImportStatus $status,
|
||||||
|
?ColumnMapping $mapping,
|
||||||
|
int $importedCount,
|
||||||
|
int $errorCount,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
?DateTimeImmutable $completedAt,
|
||||||
|
): self {
|
||||||
|
$batch = new self(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
originalFilename: $originalFilename,
|
||||||
|
totalRows: $totalRows,
|
||||||
|
detectedColumns: $detectedColumns,
|
||||||
|
detectedFormat: $detectedFormat,
|
||||||
|
status: $status,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$batch->mapping = $mapping;
|
||||||
|
$batch->importedCount = $importedCount;
|
||||||
|
$batch->errorCount = $errorCount;
|
||||||
|
$batch->completedAt = $completedAt;
|
||||||
|
|
||||||
|
return $batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs Classeo disponibles pour le mapping d'import élèves.
|
||||||
|
*/
|
||||||
|
enum StudentImportField: string
|
||||||
|
{
|
||||||
|
case LAST_NAME = 'lastName';
|
||||||
|
case FIRST_NAME = 'firstName';
|
||||||
|
case FULL_NAME = 'fullName';
|
||||||
|
case EMAIL = 'email';
|
||||||
|
case CLASS_NAME = 'className';
|
||||||
|
case BIRTH_DATE = 'birthDate';
|
||||||
|
case GENDER = 'gender';
|
||||||
|
case STUDENT_NUMBER = 'studentNumber';
|
||||||
|
|
||||||
|
public function estObligatoire(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LAST_NAME, self::FIRST_NAME, self::CLASS_NAME => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LAST_NAME => 'Nom',
|
||||||
|
self::FIRST_NAME => 'Prénom',
|
||||||
|
self::FULL_NAME => 'Nom complet (NOM Prénom)',
|
||||||
|
self::EMAIL => 'Email',
|
||||||
|
self::CLASS_NAME => 'Classe',
|
||||||
|
self::BIRTH_DATE => 'Date de naissance',
|
||||||
|
self::GENDER => 'Genre',
|
||||||
|
self::STUDENT_NUMBER => 'Numéro élève',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<self>
|
||||||
|
*/
|
||||||
|
public static function champsObligatoires(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
self::cases(),
|
||||||
|
static fn (self $field): bool => $field->estObligatoire(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
interface ImportBatchRepository
|
||||||
|
{
|
||||||
|
public function save(StudentImportBatch $batch): void;
|
||||||
|
|
||||||
|
public function get(ImportBatchId $id): StudentImportBatch;
|
||||||
|
|
||||||
|
public function findById(ImportBatchId $id): ?StudentImportBatch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<StudentImportBatch>
|
||||||
|
*/
|
||||||
|
public function findByTenant(TenantId $tenantId): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stocke le dernier mapping utilisé par tenant et format
|
||||||
|
* pour le suggérer lors des imports futurs.
|
||||||
|
*
|
||||||
|
* @see T3.3: Sauvegarde mapping pour réutilisation future
|
||||||
|
*/
|
||||||
|
interface SavedColumnMappingRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, StudentImportField> $mapping
|
||||||
|
*/
|
||||||
|
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, StudentImportField>|null
|
||||||
|
*/
|
||||||
|
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Controller;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\ImportStudents\ImportStudentsCommand;
|
||||||
|
use App\Administration\Application\Service\Import\ImportReport;
|
||||||
|
use App\Administration\Application\Service\Import\StudentImportOrchestrator;
|
||||||
|
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||||
|
use App\Administration\Domain\Exception\MappingIncompletException;
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
|
use function array_slice;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints pour le wizard d'import d'élèves via CSV/XLSX.
|
||||||
|
*
|
||||||
|
* @see Story 3.1 - Import élèves via CSV
|
||||||
|
* @see FR76: Import élèves via CSV
|
||||||
|
*/
|
||||||
|
#[Route('/api/import/students')]
|
||||||
|
final readonly class StudentImportController
|
||||||
|
{
|
||||||
|
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private ImportBatchRepository $importBatchRepository,
|
||||||
|
private StudentImportOrchestrator $orchestrator,
|
||||||
|
private MessageBusInterface $commandBus,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private CurrentAcademicYearResolver $academicYearResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.1 : Upload d'un fichier CSV ou XLSX.
|
||||||
|
*
|
||||||
|
* Retourne les colonnes détectées, un aperçu et un mapping suggéré.
|
||||||
|
*/
|
||||||
|
#[Route('/upload', methods: ['POST'], name: 'api_import_students_upload')]
|
||||||
|
public function upload(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
if (!$file instanceof UploadedFile) {
|
||||||
|
throw new BadRequestHttpException('Un fichier CSV ou XLSX est requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||||
|
throw new BadRequestHttpException('Le fichier dépasse la taille maximale de 10 Mo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower($file->getClientOriginalExtension());
|
||||||
|
if (!in_array($extension, ['csv', 'txt', 'xlsx', 'xls'], true)) {
|
||||||
|
throw new BadRequestHttpException('Extension non supportée. Utilisez CSV ou XLSX.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedMimeTypes = [
|
||||||
|
'text/csv', 'text/plain', 'application/csv',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
];
|
||||||
|
|
||||||
|
$mimeType = $file->getMimeType();
|
||||||
|
if ($mimeType === null || !in_array($mimeType, $allowedMimeTypes, true)) {
|
||||||
|
throw new BadRequestHttpException('Type de fichier non supporté. Utilisez CSV ou XLSX.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->orchestrator->analyzeFile(
|
||||||
|
$file->getPathname(),
|
||||||
|
$extension,
|
||||||
|
$file->getClientOriginalName(),
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
} catch (FichierImportInvalideException|InvalidArgumentException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = $result['batch'];
|
||||||
|
$suggestedMapping = $result['suggestedMapping'];
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'filename' => $batch->originalFilename,
|
||||||
|
'totalRows' => $batch->totalRows,
|
||||||
|
'columns' => $batch->detectedColumns,
|
||||||
|
'detectedFormat' => ($batch->detectedFormat ?? KnownImportFormat::CUSTOM)->value,
|
||||||
|
'suggestedMapping' => $this->serializeMapping($suggestedMapping),
|
||||||
|
'preview' => $this->serializeRows(array_slice($batch->lignes(), 0, 5)),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.2 : Valider et appliquer le mapping des colonnes.
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/mapping', methods: ['POST'], name: 'api_import_students_mapping')]
|
||||||
|
public function mapping(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||||
|
|
||||||
|
$data = $request->toArray();
|
||||||
|
|
||||||
|
/** @var array<string, string> $mappingData */
|
||||||
|
$mappingData = $data['mapping'] ?? [];
|
||||||
|
|
||||||
|
/** @var string $formatValue */
|
||||||
|
$formatValue = $data['format'] ?? '';
|
||||||
|
$format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM;
|
||||||
|
|
||||||
|
/** @var array<string, StudentImportField> $mappingFields */
|
||||||
|
$mappingFields = [];
|
||||||
|
foreach ($mappingData as $column => $fieldValue) {
|
||||||
|
$field = StudentImportField::tryFrom($fieldValue);
|
||||||
|
if ($field !== null) {
|
||||||
|
$mappingFields[$column] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$columnMapping = ColumnMapping::creer($mappingFields, $format);
|
||||||
|
} catch (MappingIncompletException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->orchestrator->applyMapping($batch, $columnMapping);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'mapping' => $this->serializeMapping($columnMapping->mapping),
|
||||||
|
'totalRows' => $batch->totalRows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.3 : Preview avec validation et erreurs.
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/preview', methods: ['GET'], name: 'api_import_students_preview')]
|
||||||
|
public function preview(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
$batch = $this->getBatch($id, $tenantId);
|
||||||
|
|
||||||
|
$result = $this->orchestrator->generatePreview($batch, $tenantId);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'totalRows' => $result['report']->totalRows,
|
||||||
|
'validCount' => $result['report']->importedCount,
|
||||||
|
'errorCount' => $result['report']->errorCount,
|
||||||
|
'rows' => $this->serializeRows($result['validatedRows']),
|
||||||
|
'unknownClasses' => $result['unknownClasses'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.4 : Confirmer et lancer l'import.
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/confirm', methods: ['POST'], name: 'api_import_students_confirm')]
|
||||||
|
public function confirm(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||||
|
|
||||||
|
$data = $request->toArray();
|
||||||
|
|
||||||
|
/** @var bool $createMissingClasses */
|
||||||
|
$createMissingClasses = $data['createMissingClasses'] ?? false;
|
||||||
|
|
||||||
|
/** @var bool $importValidOnly */
|
||||||
|
$importValidOnly = $data['importValidOnly'] ?? true;
|
||||||
|
|
||||||
|
$academicYearId = $this->academicYearResolver->resolve('current')
|
||||||
|
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||||
|
$schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain;
|
||||||
|
|
||||||
|
$this->orchestrator->prepareForConfirmation($batch, $createMissingClasses, $importValidOnly);
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(new ImportStudentsCommand(
|
||||||
|
batchId: (string) $batch->id,
|
||||||
|
tenantId: $user->tenantId(),
|
||||||
|
schoolName: $schoolName,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
createMissingClasses: $createMissingClasses,
|
||||||
|
));
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'status' => 'processing',
|
||||||
|
'message' => 'Import lancé. Suivez la progression via GET /status.',
|
||||||
|
], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.5 : Statut et progression.
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/status', methods: ['GET'], name: 'api_import_students_status')]
|
||||||
|
public function status(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'status' => $batch->status->value,
|
||||||
|
'totalRows' => $batch->totalRows,
|
||||||
|
'importedCount' => $batch->importedCount,
|
||||||
|
'errorCount' => $batch->errorCount,
|
||||||
|
'progression' => $batch->progression(),
|
||||||
|
'completedAt' => $batch->completedAt?->format(DateTimeInterface::ATOM),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T7.6 : Télécharger le rapport.
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/report', methods: ['GET'], name: 'api_import_students_report')]
|
||||||
|
public function report(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||||
|
|
||||||
|
$report = ImportReport::fromValidatedRows($batch->lignes());
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'status' => $batch->status->value,
|
||||||
|
'totalRows' => $report->totalRows,
|
||||||
|
'importedCount' => $batch->importedCount,
|
||||||
|
'errorCount' => $batch->errorCount,
|
||||||
|
'report' => $report->lignesRapport(),
|
||||||
|
'errors' => array_map(
|
||||||
|
static fn (ImportRow $row) => [
|
||||||
|
'line' => $row->lineNumber,
|
||||||
|
'errors' => array_map(
|
||||||
|
static fn (ImportRowError $error) => [
|
||||||
|
'column' => $error->column,
|
||||||
|
'message' => $error->message,
|
||||||
|
],
|
||||||
|
$row->errors,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$report->errorRows,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSecurityUser(): SecurityUser
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if (!$user instanceof SecurityUser) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getBatch(string $id, TenantId $tenantId): StudentImportBatch
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$batch = $this->importBatchRepository->get(ImportBatchId::fromString($id));
|
||||||
|
} catch (ImportBatchNotFoundException|InvalidArgumentException) {
|
||||||
|
throw new NotFoundHttpException('Import non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $batch->tenantId !== (string) $tenantId) {
|
||||||
|
throw new NotFoundHttpException('Import non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, StudentImportField> $mapping
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function serializeMapping(array $mapping): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($mapping as $column => $field) {
|
||||||
|
$result[$column] = $field->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $rows
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function serializeRows(array $rows): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (ImportRow $row) => [
|
||||||
|
'line' => $row->lineNumber,
|
||||||
|
'data' => $row->mappedData,
|
||||||
|
'valid' => $row->estValide(),
|
||||||
|
'errors' => array_map(
|
||||||
|
static fn (ImportRowError $error) => [
|
||||||
|
'column' => $error->column,
|
||||||
|
'message' => $error->message,
|
||||||
|
],
|
||||||
|
$row->errors,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function json_decode;
|
||||||
|
use function json_encode;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineImportBatchRepository implements ImportBatchRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(StudentImportBatch $batch): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'INSERT INTO student_import_batches
|
||||||
|
(id, tenant_id, original_filename, total_rows, detected_columns, detected_format,
|
||||||
|
status, mapping_data, imported_count, error_count, rows_data, created_at, completed_at)
|
||||||
|
VALUES
|
||||||
|
(:id, :tenant_id, :original_filename, :total_rows, :detected_columns, :detected_format,
|
||||||
|
:status, :mapping_data, :imported_count, :error_count, :rows_data, :created_at, :completed_at)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
total_rows = EXCLUDED.total_rows,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
mapping_data = EXCLUDED.mapping_data,
|
||||||
|
imported_count = EXCLUDED.imported_count,
|
||||||
|
error_count = EXCLUDED.error_count,
|
||||||
|
rows_data = EXCLUDED.rows_data,
|
||||||
|
completed_at = EXCLUDED.completed_at',
|
||||||
|
[
|
||||||
|
'id' => (string) $batch->id,
|
||||||
|
'tenant_id' => (string) $batch->tenantId,
|
||||||
|
'original_filename' => $batch->originalFilename,
|
||||||
|
'total_rows' => $batch->totalRows,
|
||||||
|
'detected_columns' => json_encode($batch->detectedColumns, JSON_THROW_ON_ERROR),
|
||||||
|
'detected_format' => $batch->detectedFormat?->value,
|
||||||
|
'status' => $batch->status->value,
|
||||||
|
'mapping_data' => $batch->mapping !== null
|
||||||
|
? json_encode($this->serializeMapping($batch->mapping), JSON_THROW_ON_ERROR)
|
||||||
|
: null,
|
||||||
|
'imported_count' => $batch->importedCount,
|
||||||
|
'error_count' => $batch->errorCount,
|
||||||
|
'rows_data' => json_encode($this->serializeRows($batch->lignes()), JSON_THROW_ON_ERROR),
|
||||||
|
'created_at' => $batch->createdAt->format(DateTimeImmutable::ATOM),
|
||||||
|
'completed_at' => $batch->completedAt?->format(DateTimeImmutable::ATOM),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(ImportBatchId $id): StudentImportBatch
|
||||||
|
{
|
||||||
|
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(ImportBatchId $id): ?StudentImportBatch
|
||||||
|
{
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM student_import_batches WHERE id = :id',
|
||||||
|
['id' => (string) $id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenant(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM student_import_batches WHERE tenant_id = :tenant_id ORDER BY created_at DESC',
|
||||||
|
['tenant_id' => (string) $tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hydrate(array $row): StudentImportBatch
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $tenantId */
|
||||||
|
$tenantId = $row['tenant_id'];
|
||||||
|
/** @var string $originalFilename */
|
||||||
|
$originalFilename = $row['original_filename'];
|
||||||
|
/** @var string|int $totalRowsRaw */
|
||||||
|
$totalRowsRaw = $row['total_rows'];
|
||||||
|
$totalRows = (int) $totalRowsRaw;
|
||||||
|
/** @var string $detectedColumnsJson */
|
||||||
|
$detectedColumnsJson = $row['detected_columns'];
|
||||||
|
/** @var string|null $detectedFormat */
|
||||||
|
$detectedFormat = $row['detected_format'];
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string|null $mappingJson */
|
||||||
|
$mappingJson = $row['mapping_data'];
|
||||||
|
/** @var string|int $importedCountRaw */
|
||||||
|
$importedCountRaw = $row['imported_count'];
|
||||||
|
$importedCount = (int) $importedCountRaw;
|
||||||
|
/** @var string|int $errorCountRaw */
|
||||||
|
$errorCountRaw = $row['error_count'];
|
||||||
|
$errorCount = (int) $errorCountRaw;
|
||||||
|
/** @var string $rowsJson */
|
||||||
|
$rowsJson = $row['rows_data'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string|null $completedAt */
|
||||||
|
$completedAt = $row['completed_at'];
|
||||||
|
|
||||||
|
/** @var list<string> $detectedColumns */
|
||||||
|
$detectedColumns = json_decode($detectedColumnsJson, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$mapping = $mappingJson !== null ? $this->hydrateMapping($mappingJson) : null;
|
||||||
|
|
||||||
|
$batch = StudentImportBatch::reconstitute(
|
||||||
|
id: ImportBatchId::fromString($id),
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
originalFilename: $originalFilename,
|
||||||
|
totalRows: $totalRows,
|
||||||
|
detectedColumns: $detectedColumns,
|
||||||
|
detectedFormat: $detectedFormat !== null ? KnownImportFormat::from($detectedFormat) : null,
|
||||||
|
status: ImportStatus::from($status),
|
||||||
|
mapping: $mapping,
|
||||||
|
importedCount: $importedCount,
|
||||||
|
errorCount: $errorCount,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
completedAt: $completedAt !== null ? new DateTimeImmutable($completedAt) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->hydrateRows($rowsJson);
|
||||||
|
$batch->enregistrerLignes($rows);
|
||||||
|
|
||||||
|
return $batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateMapping(string $json): ColumnMapping
|
||||||
|
{
|
||||||
|
/** @var array{mapping: array<string, string>, format: string} $data */
|
||||||
|
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
/** @var array<string, StudentImportField> $mapping */
|
||||||
|
$mapping = [];
|
||||||
|
foreach ($data['mapping'] as $column => $fieldValue) {
|
||||||
|
$mapping[$column] = StudentImportField::from($fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColumnMapping::creer($mapping, KnownImportFormat::from($data['format']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ImportRow>
|
||||||
|
*/
|
||||||
|
private function hydrateRows(string $json): array
|
||||||
|
{
|
||||||
|
/** @var list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}> $data */
|
||||||
|
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (array $rowData) => new ImportRow(
|
||||||
|
lineNumber: $rowData['lineNumber'],
|
||||||
|
rawData: $rowData['rawData'],
|
||||||
|
mappedData: $rowData['mappedData'],
|
||||||
|
errors: array_map(
|
||||||
|
static fn (array $err) => new ImportRowError($err['column'], $err['message']),
|
||||||
|
$rowData['errors'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{mapping: array<string, string>, format: string}
|
||||||
|
*/
|
||||||
|
private function serializeMapping(ColumnMapping $mapping): array
|
||||||
|
{
|
||||||
|
$serialized = [];
|
||||||
|
foreach ($mapping->mapping as $column => $field) {
|
||||||
|
$serialized[$column] = $field->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mapping' => $serialized,
|
||||||
|
'format' => $mapping->format->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ImportRow> $rows
|
||||||
|
*
|
||||||
|
* @return list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}>
|
||||||
|
*/
|
||||||
|
private function serializeRows(array $rows): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (ImportRow $row) => [
|
||||||
|
'lineNumber' => $row->lineNumber,
|
||||||
|
'rawData' => $row->rawData,
|
||||||
|
'mappedData' => $row->mappedData,
|
||||||
|
'errors' => array_map(
|
||||||
|
static fn (ImportRowError $error) => [
|
||||||
|
'column' => $error->column,
|
||||||
|
'message' => $error->message,
|
||||||
|
],
|
||||||
|
$row->errors,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function json_decode;
|
||||||
|
use function json_encode;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineSavedColumnMappingRepository implements SavedColumnMappingRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
|
||||||
|
{
|
||||||
|
$serialized = [];
|
||||||
|
foreach ($mapping as $column => $field) {
|
||||||
|
$serialized[$column] = $field->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'INSERT INTO saved_column_mappings (tenant_id, format, mapping_data, saved_at)
|
||||||
|
VALUES (:tenant_id, :format, :mapping_data, :saved_at)
|
||||||
|
ON CONFLICT (tenant_id, format) DO UPDATE SET
|
||||||
|
mapping_data = EXCLUDED.mapping_data,
|
||||||
|
saved_at = EXCLUDED.saved_at',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'format' => $format->value,
|
||||||
|
'mapping_data' => json_encode($serialized, JSON_THROW_ON_ERROR),
|
||||||
|
'saved_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
|
||||||
|
{
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT mapping_data FROM saved_column_mappings WHERE tenant_id = :tenant_id AND format = :format',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'format' => $format->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string $json */
|
||||||
|
$json = $row['mapping_data'];
|
||||||
|
|
||||||
|
/** @var array<string, string> $data */
|
||||||
|
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$mapping = [];
|
||||||
|
foreach ($data as $column => $fieldValue) {
|
||||||
|
$field = StudentImportField::tryFrom($fieldValue);
|
||||||
|
if ($field !== null) {
|
||||||
|
$mapping[$column] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapping !== [] ? $mapping : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemoryImportBatchRepository implements ImportBatchRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, StudentImportBatch> */
|
||||||
|
private array $byId = [];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(StudentImportBatch $batch): void
|
||||||
|
{
|
||||||
|
$this->byId[$batch->id->__toString()] = $batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(ImportBatchId $id): StudentImportBatch
|
||||||
|
{
|
||||||
|
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(ImportBatchId $id): ?StudentImportBatch
|
||||||
|
{
|
||||||
|
return $this->byId[$id->__toString()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenant(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->byId,
|
||||||
|
static fn (StudentImportBatch $batch): bool => $batch->tenantId->equals($tenantId),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemorySavedColumnMappingRepository implements SavedColumnMappingRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, array<string, StudentImportField>> */
|
||||||
|
private array $store = [];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
|
||||||
|
{
|
||||||
|
$this->store[$this->key($tenantId, $format)] = $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
|
||||||
|
{
|
||||||
|
return $this->store[$this->key($tenantId, $format)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(TenantId $tenantId, KnownImportFormat $format): string
|
||||||
|
{
|
||||||
|
return (string) $tenantId . ':' . $format->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\ImportStudents;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\ImportStudents\ImportStudentsCommand;
|
||||||
|
use App\Administration\Application\Command\ImportStudents\ImportStudentsHandler;
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryImportBatchRepository;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
final class ImportStudentsHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
private InMemoryImportBatchRepository $importBatchRepository;
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private InMemoryClassRepository $classRepository;
|
||||||
|
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||||
|
private ImportStudentsHandler $handler;
|
||||||
|
private TenantId $tenantId;
|
||||||
|
private AcademicYearId $academicYearId;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-24 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->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<ImportRow> $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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\ColumnMappingSuggester;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
use const SORT_REGULAR;
|
||||||
|
|
||||||
|
final class ColumnMappingSuggesterTest extends TestCase
|
||||||
|
{
|
||||||
|
private ColumnMappingSuggester $suggester;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\CsvParser;
|
||||||
|
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CsvParserTest extends TestCase
|
||||||
|
{
|
||||||
|
private CsvParser $parser;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\ImportFormatDetector;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImportFormatDetectorTest extends TestCase
|
||||||
|
{
|
||||||
|
private ImportFormatDetector $detector;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImportRowValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function validRowRemainsValid(): void
|
||||||
|
{
|
||||||
|
$validator = new ImportRowValidator();
|
||||||
|
$row = $this->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<string, string> $mappedData
|
||||||
|
*/
|
||||||
|
private function createRow(array $mappedData, int $lineNumber = 1): ImportRow
|
||||||
|
{
|
||||||
|
return new ImportRow(
|
||||||
|
lineNumber: $lineNumber,
|
||||||
|
rawData: $mappedData,
|
||||||
|
mappedData: $mappedData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Import\ColumnMappingSuggester;
|
||||||
|
use App\Administration\Application\Service\Import\CsvParser;
|
||||||
|
use App\Administration\Application\Service\Import\ImportFormatDetector;
|
||||||
|
use App\Administration\Application\Service\Import\ImportReport;
|
||||||
|
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test d'intégration de la chaîne complète d'import avec un vrai fichier Pronote.
|
||||||
|
*
|
||||||
|
* Parse → Détection format → Mapping → Validation → Rapport
|
||||||
|
*/
|
||||||
|
final class PronoteImportIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function fullPronoteImportPipeline(): void
|
||||||
|
{
|
||||||
|
$filePath = __DIR__ . '/../../../../../fixtures/import/eleves_pronote.csv';
|
||||||
|
|
||||||
|
// 1. Parser le fichier
|
||||||
|
$parser = new CsvParser();
|
||||||
|
$parseResult = $parser->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\MappingIncompletException;
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ColumnMappingTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function creerWithAllRequiredFieldsSucceeds(): void
|
||||||
|
{
|
||||||
|
$mapping = ColumnMapping::creer(
|
||||||
|
[
|
||||||
|
'Nom' => 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImportRowTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function rowSansErreurEstValide(): void
|
||||||
|
{
|
||||||
|
$row = new ImportRow(
|
||||||
|
lineNumber: 1,
|
||||||
|
rawData: ['Nom' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImportStatusTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function peutDemarrerOnlyForPending(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(ImportStatus::PENDING->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\ImportElevesLance;
|
||||||
|
use App\Administration\Domain\Event\ImportElevesTermine;
|
||||||
|
use App\Administration\Domain\Exception\ImportNonDemarrableException;
|
||||||
|
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRow;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||||
|
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class StudentImportBatchTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerCreatesBatchWithPendingStatus(): void
|
||||||
|
{
|
||||||
|
$batch = $this->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Import;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class StudentImportFieldTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function champsObligatoiresReturnsRequiredFields(): void
|
||||||
|
{
|
||||||
|
$required = StudentImportField::champsObligatoires();
|
||||||
|
|
||||||
|
self::assertCount(3, $required);
|
||||||
|
self::assertContains(StudentImportField::LAST_NAME, $required);
|
||||||
|
self::assertContains(StudentImportField::FIRST_NAME, $required);
|
||||||
|
self::assertContains(StudentImportField::CLASS_NAME, $required);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function estObligatoireForRequiredFields(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(StudentImportField::LAST_NAME->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/tests/fixtures/import/eleves_comma.csv
vendored
Normal file
3
backend/tests/fixtures/import/eleves_comma.csv
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Nom,Prénom,Classe
|
||||||
|
Dupont,Jean,6ème A
|
||||||
|
Martin,Marie,6ème B
|
||||||
|
3
backend/tests/fixtures/import/eleves_ecole_directe.csv
vendored
Normal file
3
backend/tests/fixtures/import/eleves_ecole_directe.csv
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NOM;PRENOM;CLASSE;DATE_NAISSANCE;SEXE
|
||||||
|
Dupont;Jean;6A;2014-03-15;M
|
||||||
|
Martin;Marie;6B;2014-07-22;F
|
||||||
|
28
backend/tests/fixtures/import/eleves_pronote.csv
vendored
Normal file
28
backend/tests/fixtures/import/eleves_pronote.csv
vendored
Normal file
@@ -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"
|
||||||
|
4
backend/tests/fixtures/import/eleves_simple.csv
vendored
Normal file
4
backend/tests/fixtures/import/eleves_simple.csv
vendored
Normal file
@@ -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;
|
||||||
|
493
frontend/e2e/student-import.spec.ts
Normal file
493
frontend/e2e/student-import.spec.ts
Normal file
@@ -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 */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,7 @@ export default tseslint.config(
|
|||||||
fetch: 'readonly',
|
fetch: 'readonly',
|
||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
HTMLDivElement: 'readonly',
|
HTMLDivElement: 'readonly',
|
||||||
|
HTMLSelectElement: 'readonly',
|
||||||
setInterval: 'readonly',
|
setInterval: 'readonly',
|
||||||
clearInterval: 'readonly',
|
clearInterval: 'readonly',
|
||||||
URL: 'readonly',
|
URL: 'readonly',
|
||||||
@@ -88,7 +89,10 @@ export default tseslint.config(
|
|||||||
AbortController: 'readonly',
|
AbortController: 'readonly',
|
||||||
DOMException: 'readonly',
|
DOMException: 'readonly',
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
clearTimeout: 'readonly'
|
clearTimeout: 'readonly',
|
||||||
|
DragEvent: 'readonly',
|
||||||
|
File: 'readonly',
|
||||||
|
Blob: 'readonly'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
186
frontend/src/lib/features/import/api/studentImport.ts
Normal file
186
frontend/src/lib/features/import/api/studentImport.ts
Normal file
@@ -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<string, string>;
|
||||||
|
preview: PreviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewRow {
|
||||||
|
line: number;
|
||||||
|
data: Record<string, string>;
|
||||||
|
valid: boolean;
|
||||||
|
errors: RowError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowError {
|
||||||
|
column: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MappingResult {
|
||||||
|
id: string;
|
||||||
|
mapping: Record<string, string>;
|
||||||
|
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<UploadResult> {
|
||||||
|
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<string, string>,
|
||||||
|
format: string
|
||||||
|
): Promise<MappingResult> {
|
||||||
|
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<PreviewResult> {
|
||||||
|
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<ConfirmResult> {
|
||||||
|
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<ImportStatus> {
|
||||||
|
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<ImportReport> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
1726
frontend/src/routes/admin/import/students/+page.svelte
Normal file
1726
frontend/src/routes/admin/import/students/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -389,10 +389,15 @@
|
|||||||
<h1>Gestion des élèves</h1>
|
<h1>Gestion des élèves</h1>
|
||||||
<p class="subtitle">Créez et gérez les élèves de votre établissement</p>
|
<p class="subtitle">Créez et gérez les élèves de votre établissement</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" onclick={openCreateModal}>
|
<div class="header-actions">
|
||||||
<span class="btn-icon">+</span>
|
<a href="/admin/import/students" class="btn-secondary">
|
||||||
Nouvel élève
|
Importer (CSV)
|
||||||
</button>
|
</a>
|
||||||
|
<button class="btn-primary" onclick={openCreateModal}>
|
||||||
|
<span class="btn-icon">+</span>
|
||||||
|
Nouvel élève
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -730,6 +735,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.header-content h1 {
|
.header-content h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user