feat: Permettre l'import d'élèves via fichier CSV ou XLSX

L'import manuel élève par élève est fastidieux pour les établissements
qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes
(upload → mapping → preview → confirmation) permet de traiter un
fichier complet en une seule opération, avec détection automatique
du format (Pronote, École Directe) et validation avant import.

L'import est traité de manière asynchrone via Messenger pour ne pas
bloquer l'interface, avec suivi de progression en temps réel et
réutilisation des mappings entre imports successifs.
This commit is contained in:
2026-02-25 16:51:13 +01:00
parent 560b941821
commit 2420e35492
62 changed files with 7510 additions and 86 deletions

View File

@@ -13,8 +13,11 @@ RUN apk add --no-cache \
file \
gettext \
git \
freetype-dev \
icu-dev \
imagemagick-dev \
libjpeg-turbo-dev \
libpng-dev \
libzip-dev \
postgresql-dev \
rabbitmq-c-dev \
@@ -22,7 +25,8 @@ RUN apk add --no-cache \
$PHPIZE_DEPS
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install gd intl pcntl pdo_pgsql zip sockets
# Install Imagick extension for image processing (logo resize, etc.)
RUN pecl install imagick && docker-php-ext-enable imagick

View File

@@ -17,6 +17,7 @@
"doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpoffice/phpspreadsheet": "^5.4",
"promphp/prometheus_client_php": "^2.14",
"ramsey/uuid": "^4.7",
"sentry/sentry-symfony": "^5.8",

505
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2",
"content-hash": "8b72e362a7720afa0811f80f9ef6e8d5",
"packages": [
{
"name": "api-platform/core",
@@ -284,6 +284,85 @@
],
"time": "2025-11-24T14:40:29+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "doctrine/collections",
"version": "2.6.0",
@@ -1822,6 +1901,191 @@
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -1990,6 +2254,115 @@
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.4.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0"
},
"time": "2026-01-11T04:52:00+00:00"
},
{
"name": "promphp/prometheus_client_php",
"version": "v2.14.1",
@@ -2472,6 +2845,57 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
@@ -8139,85 +8563,6 @@
],
"time": "2022-12-23T10:58:28+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",

View File

@@ -52,3 +52,5 @@ framework:
App\Administration\Domain\Event\MotDePasseChange: async
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
# Import élèves → async (batch processing, peut être long)
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async

View File

@@ -78,6 +78,7 @@ security:
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
- { path: ^/api/import, roles: ROLE_ADMIN }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:

View File

@@ -209,6 +209,14 @@ services:
App\Administration\Application\Port\ImageProcessor:
alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor
# Import Batch Repository (Story 3.1 - Import élèves via CSV)
App\Administration\Domain\Repository\ImportBatchRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineImportBatchRepository
# Saved Column Mapping Repository (Story 3.1 - T3.3 Réutilisation des mappings)
App\Administration\Domain\Repository\SavedColumnMappingRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedColumnMappingRepository
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
arguments:

View 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');
}
}

View 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');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportStudents;
/**
* Commande pour lancer l'import d'élèves en batch.
*
* Dispatchée de manière asynchrone via le event bus.
*/
final readonly class ImportStudentsCommand
{
public function __construct(
public string $batchId,
public string $tenantId,
public string $schoolName,
public string $academicYearId,
public bool $createMissingClasses = false,
) {
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportStudents;
use App\Administration\Application\Service\Import\DateParser;
use App\Administration\Application\Service\Import\ImportRowValidator;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use DomainException;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
/**
* Handler pour l'import d'élèves en batch.
*
* Traite les lignes valides du batch, crée les élèves et les affecte aux classes.
*
* @see AC5: Import validé → élèves créés en base
* @see NFR-SC6: 500 élèves importés en < 2 minutes
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ImportStudentsHandler
{
public function __construct(
private ImportBatchRepository $importBatchRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private SchoolIdResolver $schoolIdResolver,
private Connection $connection,
private Clock $clock,
private LoggerInterface $logger,
) {
}
public function __invoke(ImportStudentsCommand $command): void
{
$batchId = ImportBatchId::fromString($command->batchId);
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$now = $this->clock->now();
$batch = $this->importBatchRepository->get($batchId);
$batch->demarrer($now);
$this->importBatchRepository->save($batch);
$lignes = $batch->lignes();
$importedCount = 0;
$errorCount = 0;
$processedCount = 0;
$createdClasses = [];
/** @var array<string, ClassId> */
$classCache = [];
foreach ($lignes as $row) {
try {
$className = trim($row->valeurChamp(StudentImportField::CLASS_NAME) ?? '');
if (!isset($classCache[$className])) {
$classCache[$className] = $this->resolveClassId(
$className,
$tenantId,
$academicYearId,
$command->schoolName,
$command->createMissingClasses,
$now,
$createdClasses,
);
}
$classId = $classCache[$className];
$firstName = trim($row->valeurChamp(StudentImportField::FIRST_NAME) ?? '');
$lastName = trim($row->valeurChamp(StudentImportField::LAST_NAME) ?? '');
if ($firstName === '' && $lastName === '') {
$fullName = trim($row->valeurChamp(StudentImportField::FULL_NAME) ?? '');
if ($fullName !== '') {
[$lastName, $firstName] = ImportRowValidator::splitFullName($fullName);
}
}
$emailRaw = $row->valeurChamp(StudentImportField::EMAIL);
$birthDate = $row->valeurChamp(StudentImportField::BIRTH_DATE);
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
$trimmedStudentNumber = $studentNumber !== null && trim($studentNumber) !== '' ? trim($studentNumber) : null;
$this->connection->beginTransaction();
try {
if ($emailRaw !== null && trim($emailRaw) !== '') {
$emailVO = new Email(trim($emailRaw));
if ($this->userRepository->findByEmail($emailVO, $tenantId) !== null) {
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', trim($emailRaw)));
}
$user = User::inviter(
email: $emailVO,
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
invitedAt: $now,
dateNaissance: DateParser::parse($birthDate),
studentNumber: $trimmedStudentNumber,
);
} else {
$user = User::inscrire(
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
inscritAt: $now,
dateNaissance: DateParser::parse($birthDate),
studentNumber: $trimmedStudentNumber,
);
}
$this->userRepository->save($user);
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $user->id,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $now,
);
$this->classAssignmentRepository->save($assignment);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
++$importedCount;
} catch (DomainException $e) {
$this->logger->warning('Import ligne {line} échouée : {message}', [
'line' => $row->lineNumber,
'message' => $e->getMessage(),
'batch_id' => $command->batchId,
]);
++$errorCount;
}
++$processedCount;
if ($processedCount % 50 === 0) {
$batch->mettreAJourProgression($importedCount, $errorCount);
$this->importBatchRepository->save($batch);
}
}
$batch->terminer($importedCount, $errorCount, $this->clock->now());
$this->importBatchRepository->save($batch);
}
/**
* @param list<string> $createdClasses
*/
private function resolveClassId(
string $className,
TenantId $tenantId,
AcademicYearId $academicYearId,
string $schoolName,
bool $createMissingClasses,
DateTimeImmutable $now,
array &$createdClasses,
): ClassId {
$classNameVO = new ClassName($className);
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
if ($class !== null) {
return $class->id;
}
if (!$createMissingClasses) {
throw new DomainException(sprintf('La classe "%s" n\'existe pas.', $className));
}
$newClass = SchoolClass::creer(
tenantId: $tenantId,
schoolId: SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId)),
academicYearId: $academicYearId,
name: $classNameVO,
level: null,
capacity: null,
createdAt: $now,
);
$this->classRepository->save($newClass);
$createdClasses[] = $className;
return $newClass->id;
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use function in_array;
/**
* Suggère un mapping automatique des colonnes basé sur les noms de colonnes
* et le format détecté.
*
* @see AC3: Mapping automatique proposé basé sur noms de colonnes
*/
final readonly class ColumnMappingSuggester
{
/**
* Mappings pré-configurés pour le format Pronote.
*
* @var array<string, StudentImportField>
*/
private const array PRONOTE_MAPPING = [
'élèves' => StudentImportField::FULL_NAME,
'eleves' => StudentImportField::FULL_NAME,
'né(e) le' => StudentImportField::BIRTH_DATE,
'ne(e) le' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'classe de rattachement' => StudentImportField::CLASS_NAME,
'adresse e mail' => StudentImportField::EMAIL,
];
private const array ECOLE_DIRECTE_MAPPING = [
'nom' => StudentImportField::LAST_NAME,
'prenom' => StudentImportField::FIRST_NAME,
'classe' => StudentImportField::CLASS_NAME,
'date naissance' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'email' => StudentImportField::EMAIL,
'numero' => StudentImportField::STUDENT_NUMBER,
];
/**
* Mapping générique par mots-clés.
*
* @var array<string, StudentImportField>
*/
private const array GENERIC_KEYWORDS = [
'élèves' => StudentImportField::FULL_NAME,
'eleves' => StudentImportField::FULL_NAME,
'nom' => StudentImportField::LAST_NAME,
'last' => StudentImportField::LAST_NAME,
'family' => StudentImportField::LAST_NAME,
'surname' => StudentImportField::LAST_NAME,
'prénom' => StudentImportField::FIRST_NAME,
'prenom' => StudentImportField::FIRST_NAME,
'first' => StudentImportField::FIRST_NAME,
'given' => StudentImportField::FIRST_NAME,
'classe' => StudentImportField::CLASS_NAME,
'class' => StudentImportField::CLASS_NAME,
'groupe' => StudentImportField::CLASS_NAME,
'email' => StudentImportField::EMAIL,
'mail' => StudentImportField::EMAIL,
'courriel' => StudentImportField::EMAIL,
'naissance' => StudentImportField::BIRTH_DATE,
'birth' => StudentImportField::BIRTH_DATE,
'date' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'genre' => StudentImportField::GENDER,
'gender' => StudentImportField::GENDER,
'numéro' => StudentImportField::STUDENT_NUMBER,
'numero' => StudentImportField::STUDENT_NUMBER,
'number' => StudentImportField::STUDENT_NUMBER,
'matricule' => StudentImportField::STUDENT_NUMBER,
];
/**
* Suggère un mapping pour les colonnes données.
*
* @param list<string> $columns Colonnes détectées dans le fichier
* @param KnownImportFormat $detectedFormat Format détecté
*
* @return array<string, StudentImportField> Mapping suggéré (colonne → champ)
*/
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
{
return match ($detectedFormat) {
KnownImportFormat::PRONOTE => $this->mapperAvecReference($columns, self::PRONOTE_MAPPING),
KnownImportFormat::ECOLE_DIRECTE => $this->mapperAvecReference($columns, self::ECOLE_DIRECTE_MAPPING),
KnownImportFormat::CUSTOM => $this->mapperGenerique($columns),
};
}
/**
* @param list<string> $columns
* @param array<string, StudentImportField> $reference
*
* @return array<string, StudentImportField>
*/
private function mapperAvecReference(array $columns, array $reference): array
{
$normalizedReference = [];
foreach ($reference as $key => $field) {
$normalizedReference[$this->normaliser($key)] = $field;
}
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
if (isset($normalizedReference[$normalized]) && !in_array($normalizedReference[$normalized], $usedFields, true)) {
$mapping[$column] = $normalizedReference[$normalized];
$usedFields[] = $normalizedReference[$normalized];
}
}
return $mapping;
}
/**
* @param list<string> $columns
*
* @return array<string, StudentImportField>
*/
private function mapperGenerique(array $columns): array
{
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
$mapping[$column] = $field;
$usedFields[] = $field;
break;
}
}
}
return $mapping;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use function count;
use function fclose;
use function fgetcsv;
use function fopen;
use function mb_convert_encoding;
use function mb_detect_encoding;
/**
* Service de parsing de fichiers CSV avec détection d'encoding UTF-8.
*
* Supporte les séparateurs courants (virgule, point-virgule, tabulation).
*/
final readonly class CsvParser
{
private const int MAX_LINE_LENGTH = 0;
private const array SEPARATORS = [';', ',', "\t"];
public function parse(string $filePath): FileParseResult
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw FichierImportInvalideException::fichierIllisible($filePath);
}
try {
$content = file_get_contents($filePath);
if ($content === false) {
throw FichierImportInvalideException::fichierIllisible($filePath);
}
$content = $this->convertToUtf8($content);
$content = $this->stripBom($content);
$separator = $this->detectSeparator($content);
$lines = $this->parseContent($content, $separator);
if ($lines === []) {
throw FichierImportInvalideException::fichierVide();
}
$columns = array_shift($lines);
$rows = [];
foreach ($lines as $line) {
if ($this->isEmptyLine($line)) {
continue;
}
$row = [];
foreach ($columns as $index => $column) {
$row[$column] = $line[$index] ?? '';
}
$rows[] = $row;
}
return new FileParseResult($columns, $rows);
} finally {
fclose($handle);
}
}
private function convertToUtf8(string $content): string
{
$encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
if ($encoding !== false && $encoding !== 'UTF-8') {
$converted = mb_convert_encoding($content, 'UTF-8', $encoding);
return $converted !== false ? $converted : $content;
}
return $content;
}
private function stripBom(string $content): string
{
if (str_starts_with($content, "\xEF\xBB\xBF")) {
return substr($content, 3);
}
return $content;
}
private function detectSeparator(string $content): string
{
$firstLine = strtok($content, "\n");
if ($firstLine === false) {
return ';';
}
$maxCount = 0;
$bestSeparator = ';';
foreach (self::SEPARATORS as $separator) {
$count = substr_count($firstLine, $separator);
if ($count > $maxCount) {
$maxCount = $count;
$bestSeparator = $separator;
}
}
return $bestSeparator;
}
/**
* @return list<list<string>>
*/
private function parseContent(string $content, string $separator): array
{
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
return [];
}
fwrite($stream, $content);
rewind($stream);
$lines = [];
while (($line = fgetcsv($stream, self::MAX_LINE_LENGTH, $separator, '"', '')) !== false) {
/** @var list<string> $sanitized */
$sanitized = array_map('strval', $line);
$lines[] = $sanitized;
}
fclose($stream);
return $lines;
}
/**
* @param list<string> $line
*/
private function isEmptyLine(array $line): bool
{
return count($line) === 1 && trim((string) $line[0]) === '';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use DateTimeImmutable;
/**
* Parse des dates dans les formats courants utilisés par les fichiers d'import.
*/
final class DateParser
{
private const array FORMATS = ['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'];
public static function parse(?string $date): ?DateTimeImmutable
{
if ($date === null || trim($date) === '') {
return null;
}
$trimmed = trim($date);
foreach (self::FORMATS as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
if ($parsed !== false && $parsed->format($format) === $trimmed) {
return $parsed;
}
}
return null;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use function array_slice;
use function count;
/**
* Résultat du parsing d'un fichier d'import.
*
* Contient les colonnes détectées et les données brutes extraites.
*/
final readonly class FileParseResult
{
/**
* @param list<string> $columns Noms des colonnes détectées
* @param list<array<string, string>> $rows Données brutes (colonne → valeur)
*/
public function __construct(
public array $columns,
public array $rows,
) {
}
public function totalRows(): int
{
return count($this->rows);
}
/**
* @return list<array<string, string>>
*/
public function preview(int $limit = 5): array
{
return array_slice($this->rows, 0, $limit);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
/**
* Détecte automatiquement le format d'import (Pronote, École Directe)
* à partir des noms de colonnes.
*/
final readonly class ImportFormatDetector
{
/**
* Colonnes caractéristiques de Pronote.
*/
private const array PRONOTE_COLUMNS = [
'Élèves',
'Né(e) le',
'Sexe',
'Classe de rattachement',
'Adresse E-mail',
];
/**
* Colonnes caractéristiques d'École Directe.
*/
private const array ECOLE_DIRECTE_COLUMNS = [
'NOM',
'PRENOM',
'CLASSE',
'DATE_NAISSANCE',
'SEXE',
];
/**
* @param list<string> $columns Colonnes détectées dans le fichier
*/
public function detecter(array $columns): KnownImportFormat
{
$normalizedColumns = array_map($this->normaliser(...), $columns);
if ($this->matchesPronote($normalizedColumns)) {
return KnownImportFormat::PRONOTE;
}
if ($this->matchesEcoleDirecte($normalizedColumns)) {
return KnownImportFormat::ECOLE_DIRECTE;
}
return KnownImportFormat::CUSTOM;
}
/**
* @param list<string> $normalizedColumns
*/
private function matchesPronote(array $normalizedColumns): bool
{
$pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS);
return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3);
}
/**
* @param list<string> $normalizedColumns
*/
private function matchesEcoleDirecte(array $normalizedColumns): bool
{
$ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS);
return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3);
}
/**
* @param list<string> $actualColumns
* @param list<string> $expectedColumns
*/
private function matchThreshold(array $actualColumns, array $expectedColumns, int $minMatches): bool
{
$matches = 0;
foreach ($expectedColumns as $expected) {
foreach ($actualColumns as $actual) {
if ($actual === $expected) {
++$matches;
break;
}
}
}
return $matches >= $minMatches;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use function count;
use function sprintf;
/**
* Rapport d'import généré après validation ou exécution.
*
* @see AC5: Rapport affiché : X élèves importés, Y erreurs ignorées
*/
final readonly class ImportReport
{
/**
* @param list<ImportRow> $validRows Lignes valides importées
* @param list<ImportRow> $errorRows Lignes en erreur
* @param list<string> $createdClasses Classes créées automatiquement
*/
public function __construct(
public int $totalRows,
public int $importedCount,
public int $errorCount,
public array $validRows,
public array $errorRows,
public array $createdClasses = [],
) {
}
/**
* @param list<ImportRow> $rows Toutes les lignes validées
* @param list<string> $createdClasses Classes créées automatiquement
*/
public static function fromValidatedRows(array $rows, array $createdClasses = []): self
{
$valid = [];
$errors = [];
foreach ($rows as $row) {
if ($row->estValide()) {
$valid[] = $row;
} else {
$errors[] = $row;
}
}
return new self(
totalRows: count($rows),
importedCount: count($valid),
errorCount: count($errors),
validRows: $valid,
errorRows: $errors,
createdClasses: $createdClasses,
);
}
/**
* Génère un résumé texte du rapport.
*
* @return list<string> Lignes du rapport
*/
public function lignesRapport(): array
{
$lines = [];
$lines[] = sprintf('Import terminé : %d élèves importés, %d erreurs', $this->importedCount, $this->errorCount);
if ($this->createdClasses !== []) {
$lines[] = sprintf('Classes créées automatiquement : %s', implode(', ', $this->createdClasses));
}
foreach ($this->errorRows as $row) {
foreach ($row->errors as $error) {
$lines[] = sprintf('Ligne %d, %s', $row->lineNumber, $error);
}
}
return $lines;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\StudentImportField;
use const FILTER_VALIDATE_EMAIL;
use function in_array;
use function sprintf;
/**
* Valide les lignes d'import après mapping.
*
* Vérifie que les champs obligatoires sont remplis,
* les formats sont corrects (email, dates), et les classes existent.
*
* @see AC4: Lignes valides en vert, lignes avec erreurs en rouge
*/
final readonly class ImportRowValidator
{
/**
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
*/
public function __construct(
private ?array $existingClassNames = null,
) {
}
public function valider(ImportRow $row): ImportRow
{
$row = $this->expanderNomComplet($row);
$errors = [];
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
$errors = [...$errors, ...$this->validerEmail($row)];
$errors = [...$errors, ...$this->validerDateNaissance($row)];
$errors = [...$errors, ...$this->validerClasse($row)];
if ($errors !== []) {
return $row->avecErreurs(...$errors);
}
return $row;
}
/**
* @param list<ImportRow> $rows
*
* @return list<ImportRow>
*/
public function validerTout(array $rows): array
{
return array_map($this->valider(...), $rows);
}
/**
* @return list<ImportRowError>
*/
private function validerChampsObligatoires(ImportRow $row): array
{
$errors = [];
foreach (StudentImportField::champsObligatoires() as $field) {
$value = $row->valeurChamp($field);
if ($value === null || trim($value) === '') {
$errors[] = new ImportRowError(
$field->value,
sprintf('Le champ "%s" est obligatoire.', $field->label()),
);
}
}
return $errors;
}
/**
* @return list<ImportRowError>
*/
private function validerEmail(ImportRow $row): array
{
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email === null || trim($email) === '') {
return [];
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return [new ImportRowError(
StudentImportField::EMAIL->value,
sprintf('L\'adresse email "%s" est invalide.', $email),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerDateNaissance(ImportRow $row): array
{
$date = $row->valeurChamp(StudentImportField::BIRTH_DATE);
if ($date === null || trim($date) === '') {
return [];
}
if (DateParser::parse($date) === null) {
return [new ImportRowError(
StudentImportField::BIRTH_DATE->value,
sprintf('La date "%s" est invalide. Formats acceptés : JJ/MM/AAAA, AAAA-MM-JJ.', $date),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerClasse(ImportRow $row): array
{
if ($this->existingClassNames === null) {
return [];
}
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
if ($className === null || trim($className) === '') {
return [];
}
if (!in_array(trim($className), $this->existingClassNames, true)) {
return [new ImportRowError(
StudentImportField::CLASS_NAME->value,
sprintf('La classe "%s" n\'existe pas.', $className),
)];
}
return [];
}
/**
* Si FULL_NAME est renseigné et que LAST_NAME/FIRST_NAME sont vides,
* on dérive nom et prénom depuis le nom complet (format "NOM Prénom").
*/
private function expanderNomComplet(ImportRow $row): ImportRow
{
$fullName = $row->valeurChamp(StudentImportField::FULL_NAME);
if ($fullName === null || trim($fullName) === '') {
return $row;
}
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
if (($lastName !== null && trim($lastName) !== '') || ($firstName !== null && trim($firstName) !== '')) {
return $row;
}
[$derivedLast, $derivedFirst] = self::splitFullName(trim($fullName));
$mappedData = $row->mappedData;
$mappedData[StudentImportField::LAST_NAME->value] = $derivedLast;
$mappedData[StudentImportField::FIRST_NAME->value] = $derivedFirst;
return new ImportRow($row->lineNumber, $row->rawData, $mappedData, $row->errors);
}
/**
* Sépare un nom complet au format "NOM Prénom" en [nom, prénom].
*
* Convention Pronote : le nom de famille est en majuscules, le prénom en casse mixte.
* Si la convention n'est pas détectable, on prend le premier mot comme nom.
*
* @return array{0: string, 1: string}
*/
public static function splitFullName(string $fullName): array
{
$parts = preg_split('/\s+/', trim($fullName));
if ($parts === false || $parts === []) {
return [$fullName, ''];
}
$uppercaseParts = [];
$rest = [];
$foundNonUpper = false;
foreach ($parts as $part) {
if (!$foundNonUpper && mb_strtoupper($part) === $part && preg_match('/\p{L}/u', $part)) {
$uppercaseParts[] = $part;
} else {
$foundNonUpper = true;
$rest[] = $part;
}
}
if ($uppercaseParts !== [] && $rest !== []) {
return [implode(' ', $uppercaseParts), implode(' ', $rest)];
}
$lastName = array_shift($parts);
return [$lastName ?? $fullName, implode(' ', $parts)];
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function in_array;
use InvalidArgumentException;
/**
* Orchestre la chaîne d'import d'élèves : parse → détection → mapping → validation.
*
* Extrait la logique métier du contrôleur pour respecter l'architecture hexagonale.
*/
final readonly class StudentImportOrchestrator
{
public function __construct(
private CsvParser $csvParser,
private XlsxParser $xlsxParser,
private ImportFormatDetector $formatDetector,
private ColumnMappingSuggester $mappingSuggester,
private ClassRepository $classRepository,
private ImportBatchRepository $importBatchRepository,
private SavedColumnMappingRepository $savedMappingRepository,
private Clock $clock,
) {
}
/**
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
* crée le batch et enregistre les lignes mappées.
*
* @return array{batch: StudentImportBatch, suggestedMapping: array<string, StudentImportField>}
*/
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
{
$parseResult = match ($extension) {
'csv', 'txt' => $this->csvParser->parse($filePath),
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
};
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
$batch = StudentImportBatch::creer(
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $parseResult->totalRows(),
detectedColumns: $parseResult->columns,
detectedFormat: $detectedFormat,
createdAt: $this->clock->now(),
);
$rows = $this->mapRows($parseResult, $suggestedMapping);
$batch->enregistrerLignes($rows);
$this->importBatchRepository->save($batch);
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
}
/**
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
*/
public function applyMapping(StudentImportBatch $batch, ColumnMapping $columnMapping): void
{
$batch->appliquerMapping($columnMapping);
$remapped = [];
foreach ($batch->lignes() as $row) {
$mappedData = [];
foreach ($columnMapping->mapping as $column => $field) {
$mappedData[$field->value] = $row->rawData[$column] ?? '';
}
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
}
$batch->enregistrerLignes($remapped);
$this->importBatchRepository->save($batch);
}
/**
* Valide les lignes du batch et retourne les résultats avec les classes inconnues.
*
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
*/
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId): array
{
$existingClasses = $this->getExistingClassNames($tenantId);
$validator = new ImportRowValidator($existingClasses);
$validatedRows = $validator->validerTout($batch->lignes());
$batch->enregistrerLignes($validatedRows);
$this->importBatchRepository->save($batch);
$report = ImportReport::fromValidatedRows($validatedRows);
$unknownClasses = $this->detectUnknownClasses($validatedRows, $existingClasses);
return [
'validatedRows' => $validatedRows,
'report' => $report,
'unknownClasses' => $unknownClasses,
];
}
/**
* Prépare le batch pour la confirmation : re-valide si nécessaire
* et filtre les lignes selon les options choisies par l'utilisateur.
*
* Quand createMissingClasses est activé, les erreurs de classe inconnue
* sont retirées en re-validant sans vérification de classe.
*/
public function prepareForConfirmation(
StudentImportBatch $batch,
bool $createMissingClasses,
bool $importValidOnly,
): void {
if ($createMissingClasses) {
$validator = new ImportRowValidator();
$revalidated = $validator->validerTout($batch->lignes());
$batch->enregistrerLignes($revalidated);
}
if ($importValidOnly) {
$batch->enregistrerLignes($batch->lignesValides());
}
if ($batch->mapping !== null) {
$this->savedMappingRepository->save(
$batch->tenantId,
$batch->mapping->format,
$batch->mapping->mapping,
);
}
$this->importBatchRepository->save($batch);
}
/**
* Suggère un mapping en priorité depuis les mappings sauvegardés,
* puis en fallback depuis la détection automatique.
*
* @param list<string> $columns
*
* @return array<string, StudentImportField>
*/
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
{
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
return $saved;
}
return $this->mappingSuggester->suggerer($columns, $format);
}
/**
* Vérifie que les colonnes du mapping sauvegardé correspondent
* aux colonnes détectées dans le fichier.
*
* @param array<string, StudentImportField> $mapping
* @param list<string> $columns
*/
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
{
foreach (array_keys($mapping) as $column) {
if (!in_array($column, $columns, true)) {
return false;
}
}
return true;
}
/**
* @param array<string, StudentImportField> $mapping
*
* @return list<ImportRow>
*/
private function mapRows(FileParseResult $parseResult, array $mapping): array
{
$rows = [];
$lineNumber = 1;
foreach ($parseResult->rows as $rawData) {
$mappedData = [];
foreach ($mapping as $column => $field) {
$mappedData[$field->value] = $rawData[$column] ?? '';
}
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
++$lineNumber;
}
return $rows;
}
/**
* @return list<string>
*/
private function getExistingClassNames(TenantId $tenantId): array
{
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($class) => (string) $class->name,
$classes,
));
}
/**
* @param list<ImportRow> $rows
* @param list<string> $existingClasses
*
* @return list<string>
*/
private function detectUnknownClasses(array $rows, array $existingClasses): array
{
$unknown = [];
foreach ($rows as $row) {
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
if ($className !== null
&& trim($className) !== ''
&& !in_array(trim($className), $existingClasses, true)
&& !in_array(trim($className), $unknown, true)
) {
$unknown[] = trim($className);
}
}
return $unknown;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Reader\Exception as SpreadsheetException;
/**
* Service de parsing de fichiers XLSX (Excel) via PhpSpreadsheet.
*/
final readonly class XlsxParser
{
public function parse(string $filePath): FileParseResult
{
try {
$spreadsheet = IOFactory::load($filePath);
} catch (SpreadsheetException $e) {
throw FichierImportInvalideException::formatInvalide($filePath, $e->getMessage());
}
$sheet = $spreadsheet->getActiveSheet();
$data = $sheet->toArray('', true, true, false);
if ($data === []) {
throw FichierImportInvalideException::fichierVide();
}
/** @var list<string|int|float|bool|null> $headerRow */
$headerRow = array_shift($data);
$columns = array_values(array_map(static fn (string|int|float|bool|null $v): string => (string) $v, $headerRow));
$rows = [];
foreach ($data as $line) {
/** @var list<mixed> $cells */
$cells = $line;
if ($this->isEmptyLine($cells)) {
continue;
}
$row = [];
foreach ($columns as $index => $column) {
/** @var string|int|float|bool|null $cellValue */
$cellValue = $cells[$index] ?? '';
$row[$column] = (string) $cellValue;
}
$rows[] = $row;
}
return new FileParseResult($columns, $rows);
}
/**
* @param list<mixed> $line
*/
private function isEmptyLine(array $line): bool
{
foreach ($line as $cell) {
if ($cell !== null && $cell !== '') {
return false;
}
}
return true;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Application\Command\ImportStudents\ImportStudentsCommand;
use App\Administration\Application\Service\Import\ImportReport;
use App\Administration\Application\Service\Import\StudentImportOrchestrator;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Exception\MappingIncompletException;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_slice;
use DateTimeInterface;
use function in_array;
use InvalidArgumentException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Endpoints pour le wizard d'import d'élèves via CSV/XLSX.
*
* @see Story 3.1 - Import élèves via CSV
* @see FR76: Import élèves via CSV
*/
#[Route('/api/import/students')]
final readonly class StudentImportController
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
public function __construct(
private Security $security,
private ImportBatchRepository $importBatchRepository,
private StudentImportOrchestrator $orchestrator,
private MessageBusInterface $commandBus,
private TenantContext $tenantContext,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* T7.1 : Upload d'un fichier CSV ou XLSX.
*
* Retourne les colonnes détectées, un aperçu et un mapping suggéré.
*/
#[Route('/upload', methods: ['POST'], name: 'api_import_students_upload')]
public function upload(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Un fichier CSV ou XLSX est requis.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('Le fichier dépasse la taille maximale de 10 Mo.');
}
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, ['csv', 'txt', 'xlsx', 'xls'], true)) {
throw new BadRequestHttpException('Extension non supportée. Utilisez CSV ou XLSX.');
}
$allowedMimeTypes = [
'text/csv', 'text/plain', 'application/csv',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
];
$mimeType = $file->getMimeType();
if ($mimeType === null || !in_array($mimeType, $allowedMimeTypes, true)) {
throw new BadRequestHttpException('Type de fichier non supporté. Utilisez CSV ou XLSX.');
}
try {
$result = $this->orchestrator->analyzeFile(
$file->getPathname(),
$extension,
$file->getClientOriginalName(),
$tenantId,
);
} catch (FichierImportInvalideException|InvalidArgumentException $e) {
throw new BadRequestHttpException($e->getMessage());
}
$batch = $result['batch'];
$suggestedMapping = $result['suggestedMapping'];
return new JsonResponse([
'id' => (string) $batch->id,
'filename' => $batch->originalFilename,
'totalRows' => $batch->totalRows,
'columns' => $batch->detectedColumns,
'detectedFormat' => ($batch->detectedFormat ?? KnownImportFormat::CUSTOM)->value,
'suggestedMapping' => $this->serializeMapping($suggestedMapping),
'preview' => $this->serializeRows(array_slice($batch->lignes(), 0, 5)),
], Response::HTTP_CREATED);
}
/**
* T7.2 : Valider et appliquer le mapping des colonnes.
*/
#[Route('/{id}/mapping', methods: ['POST'], name: 'api_import_students_mapping')]
public function mapping(string $id, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
$data = $request->toArray();
/** @var array<string, string> $mappingData */
$mappingData = $data['mapping'] ?? [];
/** @var string $formatValue */
$formatValue = $data['format'] ?? '';
$format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM;
/** @var array<string, StudentImportField> $mappingFields */
$mappingFields = [];
foreach ($mappingData as $column => $fieldValue) {
$field = StudentImportField::tryFrom($fieldValue);
if ($field !== null) {
$mappingFields[$column] = $field;
}
}
try {
$columnMapping = ColumnMapping::creer($mappingFields, $format);
} catch (MappingIncompletException $e) {
throw new BadRequestHttpException($e->getMessage());
}
$this->orchestrator->applyMapping($batch, $columnMapping);
return new JsonResponse([
'id' => (string) $batch->id,
'mapping' => $this->serializeMapping($columnMapping->mapping),
'totalRows' => $batch->totalRows,
]);
}
/**
* T7.3 : Preview avec validation et erreurs.
*/
#[Route('/{id}/preview', methods: ['GET'], name: 'api_import_students_preview')]
public function preview(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$batch = $this->getBatch($id, $tenantId);
$result = $this->orchestrator->generatePreview($batch, $tenantId);
return new JsonResponse([
'id' => (string) $batch->id,
'totalRows' => $result['report']->totalRows,
'validCount' => $result['report']->importedCount,
'errorCount' => $result['report']->errorCount,
'rows' => $this->serializeRows($result['validatedRows']),
'unknownClasses' => $result['unknownClasses'],
]);
}
/**
* T7.4 : Confirmer et lancer l'import.
*/
#[Route('/{id}/confirm', methods: ['POST'], name: 'api_import_students_confirm')]
public function confirm(string $id, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
$data = $request->toArray();
/** @var bool $createMissingClasses */
$createMissingClasses = $data['createMissingClasses'] ?? false;
/** @var bool $importValidOnly */
$importValidOnly = $data['importValidOnly'] ?? true;
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
$schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain;
$this->orchestrator->prepareForConfirmation($batch, $createMissingClasses, $importValidOnly);
$this->commandBus->dispatch(new ImportStudentsCommand(
batchId: (string) $batch->id,
tenantId: $user->tenantId(),
schoolName: $schoolName,
academicYearId: $academicYearId,
createMissingClasses: $createMissingClasses,
));
return new JsonResponse([
'id' => (string) $batch->id,
'status' => 'processing',
'message' => 'Import lancé. Suivez la progression via GET /status.',
], Response::HTTP_ACCEPTED);
}
/**
* T7.5 : Statut et progression.
*/
#[Route('/{id}/status', methods: ['GET'], name: 'api_import_students_status')]
public function status(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
return new JsonResponse([
'id' => (string) $batch->id,
'status' => $batch->status->value,
'totalRows' => $batch->totalRows,
'importedCount' => $batch->importedCount,
'errorCount' => $batch->errorCount,
'progression' => $batch->progression(),
'completedAt' => $batch->completedAt?->format(DateTimeInterface::ATOM),
]);
}
/**
* T7.6 : Télécharger le rapport.
*/
#[Route('/{id}/report', methods: ['GET'], name: 'api_import_students_report')]
public function report(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
$report = ImportReport::fromValidatedRows($batch->lignes());
return new JsonResponse([
'id' => (string) $batch->id,
'status' => $batch->status->value,
'totalRows' => $report->totalRows,
'importedCount' => $batch->importedCount,
'errorCount' => $batch->errorCount,
'report' => $report->lignesRapport(),
'errors' => array_map(
static fn (ImportRow $row) => [
'line' => $row->lineNumber,
'errors' => array_map(
static fn (ImportRowError $error) => [
'column' => $error->column,
'message' => $error->message,
],
$row->errors,
),
],
$report->errorRows,
),
]);
}
private function getSecurityUser(): SecurityUser
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException();
}
return $user;
}
private function getBatch(string $id, TenantId $tenantId): StudentImportBatch
{
try {
$batch = $this->importBatchRepository->get(ImportBatchId::fromString($id));
} catch (ImportBatchNotFoundException|InvalidArgumentException) {
throw new NotFoundHttpException('Import non trouvé.');
}
if ((string) $batch->tenantId !== (string) $tenantId) {
throw new NotFoundHttpException('Import non trouvé.');
}
return $batch;
}
/**
* @param array<string, StudentImportField> $mapping
*
* @return array<string, string>
*/
private function serializeMapping(array $mapping): array
{
$result = [];
foreach ($mapping as $column => $field) {
$result[$column] = $field->value;
}
return $result;
}
/**
* @param list<ImportRow> $rows
*
* @return list<array<string, mixed>>
*/
private function serializeRows(array $rows): array
{
return array_map(
static fn (ImportRow $row) => [
'line' => $row->lineNumber,
'data' => $row->mappedData,
'valid' => $row->estValide(),
'errors' => array_map(
static fn (ImportRowError $error) => [
'column' => $error->column,
'message' => $error->message,
],
$row->errors,
),
],
$rows,
);
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\ImportStatus;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineImportBatchRepository implements ImportBatchRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(StudentImportBatch $batch): void
{
$this->connection->executeStatement(
'INSERT INTO student_import_batches
(id, tenant_id, original_filename, total_rows, detected_columns, detected_format,
status, mapping_data, imported_count, error_count, rows_data, created_at, completed_at)
VALUES
(:id, :tenant_id, :original_filename, :total_rows, :detected_columns, :detected_format,
:status, :mapping_data, :imported_count, :error_count, :rows_data, :created_at, :completed_at)
ON CONFLICT (id) DO UPDATE SET
total_rows = EXCLUDED.total_rows,
status = EXCLUDED.status,
mapping_data = EXCLUDED.mapping_data,
imported_count = EXCLUDED.imported_count,
error_count = EXCLUDED.error_count,
rows_data = EXCLUDED.rows_data,
completed_at = EXCLUDED.completed_at',
[
'id' => (string) $batch->id,
'tenant_id' => (string) $batch->tenantId,
'original_filename' => $batch->originalFilename,
'total_rows' => $batch->totalRows,
'detected_columns' => json_encode($batch->detectedColumns, JSON_THROW_ON_ERROR),
'detected_format' => $batch->detectedFormat?->value,
'status' => $batch->status->value,
'mapping_data' => $batch->mapping !== null
? json_encode($this->serializeMapping($batch->mapping), JSON_THROW_ON_ERROR)
: null,
'imported_count' => $batch->importedCount,
'error_count' => $batch->errorCount,
'rows_data' => json_encode($this->serializeRows($batch->lignes()), JSON_THROW_ON_ERROR),
'created_at' => $batch->createdAt->format(DateTimeImmutable::ATOM),
'completed_at' => $batch->completedAt?->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function get(ImportBatchId $id): StudentImportBatch
{
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
}
#[Override]
public function findById(ImportBatchId $id): ?StudentImportBatch
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM student_import_batches WHERE id = :id',
['id' => (string) $id],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM student_import_batches WHERE tenant_id = :tenant_id ORDER BY created_at DESC',
['tenant_id' => (string) $tenantId],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): StudentImportBatch
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $originalFilename */
$originalFilename = $row['original_filename'];
/** @var string|int $totalRowsRaw */
$totalRowsRaw = $row['total_rows'];
$totalRows = (int) $totalRowsRaw;
/** @var string $detectedColumnsJson */
$detectedColumnsJson = $row['detected_columns'];
/** @var string|null $detectedFormat */
$detectedFormat = $row['detected_format'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $mappingJson */
$mappingJson = $row['mapping_data'];
/** @var string|int $importedCountRaw */
$importedCountRaw = $row['imported_count'];
$importedCount = (int) $importedCountRaw;
/** @var string|int $errorCountRaw */
$errorCountRaw = $row['error_count'];
$errorCount = (int) $errorCountRaw;
/** @var string $rowsJson */
$rowsJson = $row['rows_data'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string|null $completedAt */
$completedAt = $row['completed_at'];
/** @var list<string> $detectedColumns */
$detectedColumns = json_decode($detectedColumnsJson, true, 512, JSON_THROW_ON_ERROR);
$mapping = $mappingJson !== null ? $this->hydrateMapping($mappingJson) : null;
$batch = StudentImportBatch::reconstitute(
id: ImportBatchId::fromString($id),
tenantId: TenantId::fromString($tenantId),
originalFilename: $originalFilename,
totalRows: $totalRows,
detectedColumns: $detectedColumns,
detectedFormat: $detectedFormat !== null ? KnownImportFormat::from($detectedFormat) : null,
status: ImportStatus::from($status),
mapping: $mapping,
importedCount: $importedCount,
errorCount: $errorCount,
createdAt: new DateTimeImmutable($createdAt),
completedAt: $completedAt !== null ? new DateTimeImmutable($completedAt) : null,
);
$rows = $this->hydrateRows($rowsJson);
$batch->enregistrerLignes($rows);
return $batch;
}
private function hydrateMapping(string $json): ColumnMapping
{
/** @var array{mapping: array<string, string>, format: string} $data */
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
/** @var array<string, StudentImportField> $mapping */
$mapping = [];
foreach ($data['mapping'] as $column => $fieldValue) {
$mapping[$column] = StudentImportField::from($fieldValue);
}
return ColumnMapping::creer($mapping, KnownImportFormat::from($data['format']));
}
/**
* @return list<ImportRow>
*/
private function hydrateRows(string $json): array
{
/** @var list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}> $data */
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
return array_map(
static fn (array $rowData) => new ImportRow(
lineNumber: $rowData['lineNumber'],
rawData: $rowData['rawData'],
mappedData: $rowData['mappedData'],
errors: array_map(
static fn (array $err) => new ImportRowError($err['column'], $err['message']),
$rowData['errors'],
),
),
$data,
);
}
/**
* @return array{mapping: array<string, string>, format: string}
*/
private function serializeMapping(ColumnMapping $mapping): array
{
$serialized = [];
foreach ($mapping->mapping as $column => $field) {
$serialized[$column] = $field->value;
}
return [
'mapping' => $serialized,
'format' => $mapping->format->value,
];
}
/**
* @param list<ImportRow> $rows
*
* @return list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}>
*/
private function serializeRows(array $rows): array
{
return array_map(
static fn (ImportRow $row) => [
'lineNumber' => $row->lineNumber,
'rawData' => $row->rawData,
'mappedData' => $row->mappedData,
'errors' => array_map(
static fn (ImportRowError $error) => [
'column' => $error->column,
'message' => $error->message,
],
$row->errors,
),
],
$rows,
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineSavedColumnMappingRepository implements SavedColumnMappingRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
{
$serialized = [];
foreach ($mapping as $column => $field) {
$serialized[$column] = $field->value;
}
$this->connection->executeStatement(
'INSERT INTO saved_column_mappings (tenant_id, format, mapping_data, saved_at)
VALUES (:tenant_id, :format, :mapping_data, :saved_at)
ON CONFLICT (tenant_id, format) DO UPDATE SET
mapping_data = EXCLUDED.mapping_data,
saved_at = EXCLUDED.saved_at',
[
'tenant_id' => (string) $tenantId,
'format' => $format->value,
'mapping_data' => json_encode($serialized, JSON_THROW_ON_ERROR),
'saved_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT mapping_data FROM saved_column_mappings WHERE tenant_id = :tenant_id AND format = :format',
[
'tenant_id' => (string) $tenantId,
'format' => $format->value,
],
);
if ($row === false) {
return null;
}
/** @var string $json */
$json = $row['mapping_data'];
/** @var array<string, string> $data */
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$mapping = [];
foreach ($data as $column => $fieldValue) {
$field = StudentImportField::tryFrom($fieldValue);
if ($field !== null) {
$mapping[$column] = $field;
}
}
return $mapping !== [] ? $mapping : null;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryImportBatchRepository implements ImportBatchRepository
{
/** @var array<string, StudentImportBatch> */
private array $byId = [];
#[Override]
public function save(StudentImportBatch $batch): void
{
$this->byId[$batch->id->__toString()] = $batch;
}
#[Override]
public function get(ImportBatchId $id): StudentImportBatch
{
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
}
#[Override]
public function findById(ImportBatchId $id): ?StudentImportBatch
{
return $this->byId[$id->__toString()] ?? null;
}
#[Override]
public function findByTenant(TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (StudentImportBatch $batch): bool => $batch->tenantId->equals($tenantId),
));
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemorySavedColumnMappingRepository implements SavedColumnMappingRepository
{
/** @var array<string, array<string, StudentImportField>> */
private array $store = [];
#[Override]
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
{
$this->store[$this->key($tenantId, $format)] = $mapping;
}
#[Override]
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
{
return $this->store[$this->key($tenantId, $format)] ?? null;
}
private function key(TenantId $tenantId, KnownImportFormat $format): string
{
return (string) $tenantId . ':' . $format->value;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
Nom,Prénom,Classe
Dupont,Jean,6ème A
Martin,Marie,6ème B
1 Nom Prénom Classe
2 Dupont Jean 6ème A
3 Martin Marie 6ème B

View 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
1 NOM PRENOM CLASSE DATE_NAISSANCE SEXE
2 Dupont Jean 6A 2014-03-15 M
3 Martin Marie 6B 2014-07-22 F

View 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"
1 É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
2 BERTHE Alexandre 07/07/2011 Masculin alexandre.berthe@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
3 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
4 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
5 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
6 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
7 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
8 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
9 CHOPIN Elisa 24/02/2011 Féminin elisa.chopin@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
10 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
11 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
12 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
13 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
14 GRANGE Sabrina 16/01/2010 Féminin sabrina.grange@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
15 HUGUET Clara 11/01/2012 Féminin clara.huguet@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN EXTERNE LIBRE
16 IMBERT Vincent 28/02/2012 Masculin vincent.imbert@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
17 LAVIGNE Sandy 09/01/2012 Féminin sandy.lavigne@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
18 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
19 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
20 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
21 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
22 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
23 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
24 RUIZ Delphine 03/05/2011 Féminin delphine.ruiz@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
25 SALOMON Alexandre 14/05/2011 Masculin alexandre.salomon@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
26 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
27 SERRES Adeline 07/12/2010 Féminin adeline.serres@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN EXTERNE LIBRE
28 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

View 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;
1 Nom Prénom Classe Email
2 Dupont Jean 6ème A jean.dupont@email.com
3 Martin Marie 6ème B marie.martin@email.com
4 Bernard Pierre 5ème A