Compare commits
10 Commits
cfbe96ccf8
...
1db8a7a0b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db8a7a0b2 | |||
| 23dd7177f2 | |||
| ce05207c64 | |||
| be1b0b60a6 | |||
| de5880e25e | |||
| f2f57bb999 | |||
| 2420e35492 | |||
| 560b941821 | |||
| e5203097ef | |||
| 6fd084063f |
@@ -13,7 +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 \
|
||||
@@ -21,7 +25,11 @@ 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
|
||||
|
||||
# Install AMQP extension for RabbitMQ
|
||||
RUN pecl install amqp && docker-php-ext-enable amqp
|
||||
|
||||
@@ -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
505
backend/composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -39,6 +39,12 @@ framework:
|
||||
adapter: cache.adapter.filesystem
|
||||
default_lifetime: 604800 # 7 jours
|
||||
|
||||
# Pool dédié au cache des requêtes paginées (1h TTL, tag-aware)
|
||||
paginated_queries.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
default_lifetime: 3600 # 1 heure
|
||||
tags: true
|
||||
|
||||
# Test environment uses Redis to avoid filesystem cache timing issues in E2E tests
|
||||
# (CLI creates tokens, FrankenPHP must see them immediately)
|
||||
when@test:
|
||||
@@ -73,6 +79,10 @@ when@test:
|
||||
adapter: cache.adapter.redis
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 604800
|
||||
paginated_queries.cache:
|
||||
adapter: cache.adapter.redis_tag_aware
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 3600
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
@@ -110,3 +120,7 @@ when@prod:
|
||||
adapter: cache.adapter.redis
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 604800 # 7 jours
|
||||
paginated_queries.cache:
|
||||
adapter: cache.adapter.redis_tag_aware
|
||||
provider: '%env(REDIS_URL)%'
|
||||
default_lifetime: 3600 # 1 heure
|
||||
|
||||
@@ -25,6 +25,7 @@ framework:
|
||||
middleware:
|
||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||
- App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware
|
||||
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware
|
||||
|
||||
transports:
|
||||
@@ -52,3 +53,11 @@ framework:
|
||||
App\Administration\Domain\Event\MotDePasseChange: async
|
||||
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
|
||||
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
|
||||
# Parent invitation events → async (email sending)
|
||||
App\Administration\Domain\Event\InvitationParentEnvoyee: async
|
||||
App\Administration\Domain\Event\InvitationParentActivee: async
|
||||
# Notification enseignants journée pédagogique → async (envoi d'emails)
|
||||
App\Administration\Domain\Event\JourneePedagogiqueAjoutee: async
|
||||
# Import élèves/enseignants → async (batch processing, peut être long)
|
||||
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
||||
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
||||
|
||||
@@ -31,3 +31,10 @@ framework:
|
||||
limit: 10
|
||||
interval: '1 hour'
|
||||
cache_pool: cache.rate_limiter
|
||||
|
||||
# Limite les tentatives d'activation par IP (protection contre DoS via bcrypt)
|
||||
parent_activation_by_ip:
|
||||
policy: sliding_window
|
||||
limit: 10
|
||||
interval: '15 minutes'
|
||||
cache_pool: cache.rate_limiter
|
||||
|
||||
@@ -54,7 +54,7 @@ security:
|
||||
jwt: ~
|
||||
provider: super_admin_provider
|
||||
api_public:
|
||||
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$)
|
||||
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|parent-invitations/activate|docs)(/|$)
|
||||
stateless: true
|
||||
security: false
|
||||
api:
|
||||
@@ -78,6 +78,8 @@ security:
|
||||
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/parent-invitations/activate, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/import, roles: ROLE_ADMIN }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache'
|
||||
# Bind student guardians cache pool (no TTL - persistent data)
|
||||
Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache'
|
||||
# Bind paginated queries cache pool (1h TTL, tag-aware)
|
||||
Symfony\Contracts\Cache\TagAwareCacheInterface $paginatedQueriesCache: '@paginated_queries.cache'
|
||||
# Bind named message buses
|
||||
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
||||
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
||||
@@ -158,6 +160,10 @@ services:
|
||||
App\Administration\Domain\Repository\GradingConfigurationRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository
|
||||
|
||||
# Class Assignment (Story 3.0 - Affectation élèves aux classes)
|
||||
App\Administration\Domain\Repository\ClassAssignmentRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassAssignmentRepository
|
||||
|
||||
# Teacher Assignment (Story 2.8 - Affectation enseignants)
|
||||
App\Administration\Domain\Repository\TeacherAssignmentRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository
|
||||
@@ -187,6 +193,44 @@ services:
|
||||
arguments:
|
||||
$dataDirectory: '%kernel.project_dir%/var/data/calendar'
|
||||
|
||||
# School Branding (Story 2.13 - Personnalisation visuelle)
|
||||
App\Administration\Domain\Model\SchoolBranding\ContrastValidator:
|
||||
autowire: true
|
||||
|
||||
App\Administration\Domain\Repository\SchoolBrandingRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository
|
||||
|
||||
App\Administration\Application\Port\LogoStorage:
|
||||
alias: App\Administration\Infrastructure\Storage\LocalLogoStorage
|
||||
|
||||
App\Administration\Infrastructure\Storage\LocalLogoStorage:
|
||||
arguments:
|
||||
$uploadDir: '%kernel.project_dir%/public/uploads'
|
||||
$publicPath: '/uploads'
|
||||
|
||||
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
|
||||
|
||||
# Teacher Import Batch Repository (Story 3.2 - Import enseignants via CSV)
|
||||
App\Administration\Domain\Repository\TeacherImportBatchRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherImportBatchRepository
|
||||
|
||||
# Saved Teacher Column Mapping Repository (Story 3.2 - Réutilisation des mappings enseignants)
|
||||
App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedTeacherColumnMappingRepository
|
||||
|
||||
# Parent Invitation Repository (Story 3.3 - Invitation parents)
|
||||
App\Administration\Domain\Repository\ParentInvitationRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineParentInvitationRepository
|
||||
|
||||
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
|
||||
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
||||
arguments:
|
||||
@@ -195,6 +239,25 @@ services:
|
||||
App\Administration\Domain\Repository\StudentGuardianRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository
|
||||
|
||||
# Paginated Read Model Ports
|
||||
App\Administration\Application\Port\PaginatedUsersReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedUsersReader
|
||||
|
||||
App\Administration\Application\Port\PaginatedClassesReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedClassesReader
|
||||
|
||||
App\Administration\Application\Port\PaginatedSubjectsReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedSubjectsReader
|
||||
|
||||
App\Administration\Application\Port\PaginatedAssignmentsReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedAssignmentsReader
|
||||
|
||||
App\Administration\Application\Port\PaginatedParentInvitationsReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedParentInvitationsReader
|
||||
|
||||
App\Administration\Application\Port\PaginatedStudentImageRightsReader:
|
||||
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedStudentImageRightsReader
|
||||
|
||||
# GradeExistenceChecker (stub until Notes module exists)
|
||||
App\Administration\Application\Port\GradeExistenceChecker:
|
||||
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
||||
@@ -213,6 +276,11 @@ services:
|
||||
$passwordResetByEmailLimiter: '@limiter.password_reset_by_email'
|
||||
$passwordResetByIpLimiter: '@limiter.password_reset_by_ip'
|
||||
|
||||
# Parent Activation Processor with rate limiter
|
||||
App\Administration\Infrastructure\Api\Processor\ActivateParentInvitationProcessor:
|
||||
arguments:
|
||||
$parentActivationByIpLimiter: '@limiter.parent_activation_by_ip'
|
||||
|
||||
# Login handlers
|
||||
App\Administration\Infrastructure\Security\LoginSuccessHandler:
|
||||
tags:
|
||||
|
||||
42
backend/migrations/Version20260220071333.php
Normal file
42
backend/migrations/Version20260220071333.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260220071333 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create school_branding table for visual identity customization';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE school_branding (
|
||||
school_id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
logo_url TEXT,
|
||||
logo_updated_at TIMESTAMPTZ,
|
||||
primary_color VARCHAR(7),
|
||||
secondary_color VARCHAR(7),
|
||||
accent_color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE INDEX idx_branding_tenant ON school_branding(tenant_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS school_branding');
|
||||
}
|
||||
}
|
||||
49
backend/migrations/Version20260221093719.php
Normal file
49
backend/migrations/Version20260221093719.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260221093719 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create class_assignments table and extend users for student enrollment';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
school_class_id UUID NOT NULL,
|
||||
academic_year_id UUID NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, academic_year_id),
|
||||
CONSTRAINT fk_class_assignments_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
CONSTRAINT fk_class_assignments_class FOREIGN KEY (school_class_id) REFERENCES school_classes(id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_class_assignments_class ON class_assignments(school_class_id)');
|
||||
$this->addSql('CREATE INDEX idx_class_assignments_tenant ON class_assignments(tenant_id)');
|
||||
|
||||
$this->addSql('ALTER TABLE users ALTER COLUMN email DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE users ADD COLUMN student_number VARCHAR(11)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE class_assignments');
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN student_number');
|
||||
$this->addSql("UPDATE users SET email = 'removed-' || id || '@placeholder.local' WHERE email IS NULL");
|
||||
$this->addSql('ALTER TABLE users ALTER COLUMN email SET NOT NULL');
|
||||
}
|
||||
}
|
||||
44
backend/migrations/Version20260224143000.php
Normal file
44
backend/migrations/Version20260224143000.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260224143000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create student_import_batches table for CSV/XLSX student import wizard';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE student_import_batches (
|
||||
id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
total_rows INT NOT NULL DEFAULT 0,
|
||||
detected_columns JSONB NOT NULL DEFAULT \'[]\',
|
||||
detected_format VARCHAR(50) DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT \'pending\',
|
||||
mapping_data JSONB DEFAULT NULL,
|
||||
imported_count INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
rows_data JSONB NOT NULL DEFAULT \'[]\',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
completed_at TIMESTAMPTZ DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)');
|
||||
|
||||
$this->addSql('CREATE INDEX idx_student_import_batches_tenant ON student_import_batches (tenant_id)');
|
||||
$this->addSql('CREATE INDEX idx_student_import_batches_status ON student_import_batches (status)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE student_import_batches');
|
||||
}
|
||||
}
|
||||
34
backend/migrations/Version20260224214219.php
Normal file
34
backend/migrations/Version20260224214219.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260224214219 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajouter la table saved_column_mappings pour réutiliser les mappings d\'import';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE saved_column_mappings (
|
||||
tenant_id UUID NOT NULL,
|
||||
format VARCHAR(50) NOT NULL,
|
||||
mapping_data JSONB NOT NULL,
|
||||
saved_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (tenant_id, format)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE saved_column_mappings');
|
||||
}
|
||||
}
|
||||
53
backend/migrations/Version20260225211435.php
Normal file
53
backend/migrations/Version20260225211435.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260225211435 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create teacher_import_batches and saved_teacher_column_mappings tables for CSV/XLSX teacher import wizard';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE teacher_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_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)');
|
||||
$this->addSql('CREATE INDEX idx_teacher_import_batches_status ON teacher_import_batches (status)');
|
||||
|
||||
$this->addSql('CREATE TABLE saved_teacher_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)
|
||||
)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE saved_teacher_column_mappings');
|
||||
$this->addSql('DROP TABLE teacher_import_batches');
|
||||
}
|
||||
}
|
||||
28
backend/migrations/Version20260226141803.php
Normal file
28
backend/migrations/Version20260226141803.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260226141803 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add composite index on teacher_import_batches (tenant_id, created_at DESC)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE INDEX idx_teacher_import_batches_tenant_created ON teacher_import_batches (tenant_id, created_at DESC)');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE INDEX idx_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant_created');
|
||||
}
|
||||
}
|
||||
47
backend/migrations/Version20260227162304.php
Normal file
47
backend/migrations/Version20260227162304.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227162304 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create parent_invitations table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE parent_invitations (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
student_id UUID NOT NULL,
|
||||
parent_email VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(64) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
created_by UUID NOT NULL,
|
||||
sent_at TIMESTAMPTZ,
|
||||
activated_at TIMESTAMPTZ,
|
||||
activated_user_id UUID
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_parent_invitations_tenant ON parent_invitations (tenant_id)');
|
||||
$this->addSql('CREATE INDEX idx_parent_invitations_code ON parent_invitations (code)');
|
||||
$this->addSql('CREATE INDEX idx_parent_invitations_status ON parent_invitations (status)');
|
||||
$this->addSql('CREATE INDEX idx_parent_invitations_student ON parent_invitations (student_id)');
|
||||
$this->addSql('CREATE INDEX idx_parent_invitations_expires ON parent_invitations (status, expires_at)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS parent_invitations');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateParentInvitation;
|
||||
|
||||
final readonly class ActivateParentInvitationCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public string $password,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateParentInvitation;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
|
||||
use App\Administration\Domain\Model\Invitation\InvitationCode;
|
||||
use App\Administration\Domain\Repository\ParentInvitationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ActivateParentInvitationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ParentInvitationRepository $invitationRepository,
|
||||
private PasswordHasher $passwordHasher,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the invitation code and prepares activation data.
|
||||
* Actual user creation, activation, and linking is done in the Processor.
|
||||
*
|
||||
* @throws ParentInvitationNotFoundException if code is invalid
|
||||
*/
|
||||
public function __invoke(ActivateParentInvitationCommand $command): ActivateParentInvitationResult
|
||||
{
|
||||
$code = new InvitationCode($command->code);
|
||||
|
||||
$invitation = $this->invitationRepository->findByCode($code);
|
||||
if ($invitation === null) {
|
||||
throw ParentInvitationNotFoundException::withCode($code);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Validate only - does not change state
|
||||
$invitation->validerPourActivation($now);
|
||||
|
||||
$hashedPassword = $this->passwordHasher->hash($command->password);
|
||||
|
||||
return new ActivateParentInvitationResult(
|
||||
invitationId: (string) $invitation->id,
|
||||
studentId: (string) $invitation->studentId,
|
||||
parentEmail: (string) $invitation->parentEmail,
|
||||
tenantId: $invitation->tenantId,
|
||||
hashedPassword: $hashedPassword,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateParentInvitation;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
final readonly class ActivateParentInvitationResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $invitationId,
|
||||
public string $studentId,
|
||||
public string $parentEmail,
|
||||
public TenantId $tenantId,
|
||||
public string $hashedPassword,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
final readonly class AssignStudentToClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class AssignStudentToClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AssignStudentToClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence des entités référencées et leur tenant
|
||||
$student = $this->userRepository->get($studentId);
|
||||
|
||||
if (!$student->tenantId->equals($tenantId)) {
|
||||
throw UserNotFoundException::withId($studentId);
|
||||
}
|
||||
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire
|
||||
$existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($existing !== null) {
|
||||
throw EleveDejaAffecteException::pourAnneeScolaire($studentId);
|
||||
}
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
final readonly class ChangeStudentClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $newClassId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ChangeStudentClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ChangeStudentClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$newClassId = ClassId::fromString($command->newClassId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la nouvelle classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($newClassId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
// Trouver l'affectation existante
|
||||
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($assignment === null) {
|
||||
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
|
||||
}
|
||||
|
||||
$assignment->changerClasse($newClassId, $this->clock->now());
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
final readonly class CreateStudentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
public ?string $email = null,
|
||||
public ?string $dateNaissance = null,
|
||||
public ?string $studentNumber = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
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\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateStudentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateStudentCommand $command): User
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Vérifier l'unicité de l'email si fourni
|
||||
if ($command->email !== null) {
|
||||
$email = new Email($command->email);
|
||||
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
// Créer l'utilisateur
|
||||
$user = $command->email !== null
|
||||
? User::inviter(
|
||||
email: new Email($command->email),
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
invitedAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
)
|
||||
: User::inscrire(
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
inscritAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Affecter à la classe
|
||||
$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;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\DeleteLogo;
|
||||
|
||||
final readonly class DeleteLogoCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\DeleteLogo;
|
||||
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DeleteLogoHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private LogoUploader $logoUploader,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(DeleteLogoCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
|
||||
$branding = $this->brandingRepository->get($schoolId, $tenantId);
|
||||
|
||||
if ($branding->logoUrl !== null) {
|
||||
$this->logoUploader->deleteByUrl($branding->logoUrl);
|
||||
}
|
||||
|
||||
$branding->supprimerLogo($this->clock->now());
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportStudents;
|
||||
|
||||
/**
|
||||
* Commande pour lancer l'import d'élèves en batch.
|
||||
*
|
||||
* Dispatchée de manière asynchrone via le event bus.
|
||||
*/
|
||||
final readonly class ImportStudentsCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $academicYearId,
|
||||
public bool $createMissingClasses = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportStudents;
|
||||
|
||||
use App\Administration\Application\Service\Import\DateParser;
|
||||
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use DomainException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handler pour l'import d'élèves en batch.
|
||||
*
|
||||
* Traite les lignes valides du batch, crée les élèves et les affecte aux classes.
|
||||
*
|
||||
* @see AC5: Import validé → élèves créés en base
|
||||
* @see NFR-SC6: 500 élèves importés en < 2 minutes
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ImportStudentsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ImportBatchRepository $importBatchRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ImportStudentsCommand $command): void
|
||||
{
|
||||
$batchId = ImportBatchId::fromString($command->batchId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$batch = $this->importBatchRepository->get($batchId);
|
||||
|
||||
$batch->demarrer($now);
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
$lignes = $batch->lignes();
|
||||
$importedCount = 0;
|
||||
$errorCount = 0;
|
||||
$processedCount = 0;
|
||||
$createdClasses = [];
|
||||
|
||||
/** @var array<string, ClassId> */
|
||||
$classCache = [];
|
||||
|
||||
foreach ($lignes as $row) {
|
||||
try {
|
||||
$className = trim($row->valeurChamp(StudentImportField::CLASS_NAME) ?? '');
|
||||
|
||||
if (!isset($classCache[$className])) {
|
||||
$classCache[$className] = $this->resolveClassId(
|
||||
$className,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$command->schoolName,
|
||||
$command->createMissingClasses,
|
||||
$now,
|
||||
$createdClasses,
|
||||
);
|
||||
}
|
||||
|
||||
$classId = $classCache[$className];
|
||||
|
||||
$firstName = trim($row->valeurChamp(StudentImportField::FIRST_NAME) ?? '');
|
||||
$lastName = trim($row->valeurChamp(StudentImportField::LAST_NAME) ?? '');
|
||||
|
||||
if ($firstName === '' && $lastName === '') {
|
||||
$fullName = trim($row->valeurChamp(StudentImportField::FULL_NAME) ?? '');
|
||||
if ($fullName !== '') {
|
||||
[$lastName, $firstName] = ImportRowValidator::splitFullName($fullName);
|
||||
}
|
||||
}
|
||||
$emailRaw = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
$birthDate = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
$trimmedStudentNumber = $studentNumber !== null && trim($studentNumber) !== '' ? trim($studentNumber) : null;
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
if ($emailRaw !== null && trim($emailRaw) !== '') {
|
||||
$emailVO = new Email(trim($emailRaw));
|
||||
|
||||
if ($this->userRepository->findByEmail($emailVO, $tenantId) !== null) {
|
||||
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', trim($emailRaw)));
|
||||
}
|
||||
|
||||
$user = User::inviter(
|
||||
email: $emailVO,
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $now,
|
||||
dateNaissance: DateParser::parse($birthDate),
|
||||
studentNumber: $trimmedStudentNumber,
|
||||
);
|
||||
} else {
|
||||
$user = User::inscrire(
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
inscritAt: $now,
|
||||
dateNaissance: DateParser::parse($birthDate),
|
||||
studentNumber: $trimmedStudentNumber,
|
||||
);
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $user->id,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $now,
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
++$importedCount;
|
||||
} catch (DomainException $e) {
|
||||
$this->logger->warning('Import ligne {line} échouée : {message}', [
|
||||
'line' => $row->lineNumber,
|
||||
'message' => $e->getMessage(),
|
||||
'batch_id' => $command->batchId,
|
||||
]);
|
||||
++$errorCount;
|
||||
}
|
||||
|
||||
++$processedCount;
|
||||
if ($processedCount % 50 === 0) {
|
||||
$batch->mettreAJourProgression($importedCount, $errorCount);
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->terminer($importedCount, $errorCount, $this->clock->now());
|
||||
$this->importBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $createdClasses
|
||||
*/
|
||||
private function resolveClassId(
|
||||
string $className,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
string $schoolName,
|
||||
bool $createMissingClasses,
|
||||
DateTimeImmutable $now,
|
||||
array &$createdClasses,
|
||||
): ClassId {
|
||||
$classNameVO = new ClassName($className);
|
||||
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
|
||||
|
||||
if ($class !== null) {
|
||||
return $class->id;
|
||||
}
|
||||
|
||||
if (!$createMissingClasses) {
|
||||
throw new DomainException(sprintf('La classe "%s" n\'existe pas.', $className));
|
||||
}
|
||||
|
||||
$newClass = SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId)),
|
||||
academicYearId: $academicYearId,
|
||||
name: $classNameVO,
|
||||
level: null,
|
||||
capacity: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->classRepository->save($newClass);
|
||||
$createdClasses[] = $className;
|
||||
|
||||
return $newClass->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportTeachers;
|
||||
|
||||
/**
|
||||
* Commande pour lancer l'import d'enseignants en batch.
|
||||
*
|
||||
* Dispatchée de manière asynchrone via le command bus.
|
||||
*/
|
||||
final readonly class ImportTeachersCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $academicYearId,
|
||||
public bool $createMissingSubjects = false,
|
||||
public bool $updateExisting = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportTeachers;
|
||||
|
||||
use App\Administration\Application\Service\Import\MultiValueParser;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
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\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
|
||||
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\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
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 function in_array;
|
||||
use function mb_strlen;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
use function strtoupper;
|
||||
use function substr;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Throwable;
|
||||
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Handler pour l'import d'enseignants en batch.
|
||||
*
|
||||
* Traite les lignes valides du batch, crée les enseignants et les affecte
|
||||
* aux matières/classes via TeacherAssignment.
|
||||
*
|
||||
* @see AC4: Import validé → enseignants créés
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ImportTeachersHandler
|
||||
{
|
||||
private MultiValueParser $multiValueParser;
|
||||
|
||||
public function __construct(
|
||||
private TeacherImportBatchRepository $teacherImportBatchRepository,
|
||||
private UserRepository $userRepository,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private TeacherAssignmentRepository $teacherAssignmentRepository,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
private LoggerInterface $logger,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
$this->multiValueParser = new MultiValueParser();
|
||||
}
|
||||
|
||||
public function __invoke(ImportTeachersCommand $command): void
|
||||
{
|
||||
$batchId = ImportBatchId::fromString($command->batchId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($command->tenantId));
|
||||
$now = $this->clock->now();
|
||||
|
||||
$batch = $this->teacherImportBatchRepository->get($batchId);
|
||||
|
||||
$batch->demarrer($now);
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
$lignes = $batch->lignes();
|
||||
$importedCount = 0;
|
||||
$errorCount = 0;
|
||||
$processedCount = 0;
|
||||
|
||||
try {
|
||||
/** @var array<string, SubjectId> $subjectCache */
|
||||
$subjectCache = [];
|
||||
/** @var list<string> $existingSubjectCodes */
|
||||
$existingSubjectCodes = [];
|
||||
/** @var list<string> $newlyCreatedCodes */
|
||||
$newlyCreatedCodes = [];
|
||||
|
||||
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
|
||||
$subjectCache[(string) $subject->name] = $subject->id;
|
||||
$existingSubjectCodes[] = (string) $subject->code;
|
||||
}
|
||||
|
||||
/** @var array<string, ClassId> */
|
||||
$classCache = [];
|
||||
|
||||
foreach ($lignes as $row) {
|
||||
try {
|
||||
$firstName = trim($row->mappedData[TeacherImportField::FIRST_NAME->value] ?? '');
|
||||
$lastName = trim($row->mappedData[TeacherImportField::LAST_NAME->value] ?? '');
|
||||
$emailRaw = trim($row->mappedData[TeacherImportField::EMAIL->value] ?? '');
|
||||
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? '';
|
||||
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? '';
|
||||
|
||||
$emailVO = new Email($emailRaw);
|
||||
|
||||
$existingUser = $this->userRepository->findByEmail($emailVO, $tenantId);
|
||||
|
||||
if ($existingUser !== null && !$command->updateExisting) {
|
||||
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', $emailRaw));
|
||||
}
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$subjects = $this->multiValueParser->parse($subjectsRaw);
|
||||
$classes = $this->multiValueParser->parse($classesRaw);
|
||||
|
||||
$resolvedSubjectIds = $this->resolveSubjectIds(
|
||||
$subjects,
|
||||
$tenantId,
|
||||
$schoolId,
|
||||
$command->createMissingSubjects,
|
||||
$now,
|
||||
$subjectCache,
|
||||
$existingSubjectCodes,
|
||||
$newlyCreatedCodes,
|
||||
);
|
||||
|
||||
$resolvedClassIds = $this->resolveClassIds(
|
||||
$classes,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$classCache,
|
||||
);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$existingUser->mettreAJourInfos($firstName, $lastName);
|
||||
$this->userRepository->save($existingUser);
|
||||
|
||||
$this->addMissingAssignments(
|
||||
$existingUser,
|
||||
$resolvedSubjectIds,
|
||||
$resolvedClassIds,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$now,
|
||||
);
|
||||
|
||||
$this->connection->commit();
|
||||
} else {
|
||||
$user = User::inviter(
|
||||
email: $emailVO,
|
||||
role: Role::PROF,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $now,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
foreach ($resolvedSubjectIds as $subjectId) {
|
||||
foreach ($resolvedClassIds as $classId) {
|
||||
$assignment = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $user->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->teacherAssignmentRepository->save($assignment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->commit();
|
||||
|
||||
foreach ($user->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
++$importedCount;
|
||||
} catch (DomainException $e) {
|
||||
$this->logger->warning('Import enseignant 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->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->terminer($importedCount, $errorCount, $this->clock->now());
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
} catch (Throwable $e) {
|
||||
$batch->echouer($errorCount, $this->clock->now());
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute les affectations manquantes pour un enseignant existant.
|
||||
*
|
||||
* @param list<SubjectId> $subjectIds
|
||||
* @param list<ClassId> $classIds
|
||||
*/
|
||||
private function addMissingAssignments(
|
||||
User $teacher,
|
||||
array $subjectIds,
|
||||
array $classIds,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
DateTimeImmutable $now,
|
||||
): void {
|
||||
foreach ($subjectIds as $subjectId) {
|
||||
foreach ($classIds as $classId) {
|
||||
$existing = $this->teacherAssignmentRepository->findByTeacherClassSubject(
|
||||
$teacher->id,
|
||||
$classId,
|
||||
$subjectId,
|
||||
$academicYearId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignment = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $teacher->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->teacherAssignmentRepository->save($assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $subjectNames
|
||||
* @param array<string, SubjectId> $cache
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*
|
||||
* @return list<SubjectId>
|
||||
*/
|
||||
private function resolveSubjectIds(
|
||||
array $subjectNames,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
bool $createMissing,
|
||||
DateTimeImmutable $now,
|
||||
array &$cache,
|
||||
array &$existingCodes,
|
||||
array &$newlyCreatedCodes,
|
||||
): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ($subjectNames as $name) {
|
||||
if (isset($cache[$name])) {
|
||||
$ids[] = $cache[$name];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($createMissing) {
|
||||
$subjectId = $this->createSubject($name, $tenantId, $schoolId, $now, $existingCodes, $newlyCreatedCodes);
|
||||
$cache[$name] = $subjectId;
|
||||
$ids[] = $subjectId;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $classNames
|
||||
* @param array<string, ClassId> $cache
|
||||
*
|
||||
* @return list<ClassId>
|
||||
*/
|
||||
private function resolveClassIds(
|
||||
array $classNames,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
array &$cache,
|
||||
): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ($classNames as $name) {
|
||||
if (isset($cache[$name])) {
|
||||
$ids[] = $cache[$name];
|
||||
continue;
|
||||
}
|
||||
|
||||
$classNameVO = new ClassName($name);
|
||||
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
|
||||
|
||||
if ($class !== null) {
|
||||
$cache[$name] = $class->id;
|
||||
$ids[] = $class->id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*/
|
||||
private function createSubject(
|
||||
string $name,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
DateTimeImmutable $now,
|
||||
array &$existingCodes,
|
||||
array &$newlyCreatedCodes,
|
||||
): SubjectId {
|
||||
if (trim($name) === '') {
|
||||
throw new DomainException('Le nom de la matière ne peut pas être vide.');
|
||||
}
|
||||
|
||||
$code = $this->generateUniqueSubjectCode($name, $existingCodes, $newlyCreatedCodes);
|
||||
|
||||
if ($code === '') {
|
||||
throw new DomainException(sprintf('Impossible de générer un code pour la matière "%s".', $name));
|
||||
}
|
||||
|
||||
$subject = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName($name),
|
||||
code: new SubjectCode($code),
|
||||
color: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->subjectRepository->save($subject);
|
||||
$newlyCreatedCodes[] = $code;
|
||||
|
||||
return $subject->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*/
|
||||
private function generateUniqueSubjectCode(string $name, array $existingCodes, array $newlyCreatedCodes): string
|
||||
{
|
||||
$base = strtoupper(substr(trim($name), 0, 4));
|
||||
|
||||
if (mb_strlen($base) < 2) {
|
||||
$base .= 'XX';
|
||||
}
|
||||
|
||||
$allCodes = [...$existingCodes, ...$newlyCreatedCodes];
|
||||
|
||||
if (!in_array($base, $allCodes, true)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
for ($i = 2; $i <= 99; ++$i) {
|
||||
$candidate = $base . $i;
|
||||
if (!in_array($candidate, $allCodes, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ResendParentInvitation;
|
||||
|
||||
final readonly class ResendParentInvitationCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $invitationId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ResendParentInvitation;
|
||||
|
||||
use App\Administration\Application\Service\InvitationCodeGenerator;
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitation;
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
|
||||
use App\Administration\Domain\Repository\ParentInvitationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ResendParentInvitationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ParentInvitationRepository $invitationRepository,
|
||||
private InvitationCodeGenerator $codeGenerator,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ResendParentInvitationCommand $command): ParentInvitation
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$invitationId = ParentInvitationId::fromString($command->invitationId);
|
||||
|
||||
$invitation = $this->invitationRepository->get($invitationId, $tenantId);
|
||||
|
||||
$newCode = $this->codeGenerator->generate();
|
||||
$invitation->renvoyer($newCode, $this->clock->now());
|
||||
|
||||
$this->invitationRepository->save($invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\SendParentInvitation;
|
||||
|
||||
final readonly class SendParentInvitationCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $parentEmail,
|
||||
public string $createdBy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\SendParentInvitation;
|
||||
|
||||
use App\Administration\Application\Service\InvitationCodeGenerator;
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitation;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ParentInvitationRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DomainException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class SendParentInvitationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ParentInvitationRepository $invitationRepository,
|
||||
private UserRepository $userRepository,
|
||||
private InvitationCodeGenerator $codeGenerator,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(SendParentInvitationCommand $command): ParentInvitation
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$parentEmail = new Email($command->parentEmail);
|
||||
$createdBy = UserId::fromString($command->createdBy);
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Verify student exists and is actually a student
|
||||
$student = $this->userRepository->findById($studentId);
|
||||
if ($student === null || !$student->aLeRole(Role::ELEVE)) {
|
||||
throw new DomainException('L\'élève spécifié n\'existe pas.');
|
||||
}
|
||||
|
||||
$code = $this->codeGenerator->generate();
|
||||
|
||||
$invitation = ParentInvitation::creer(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
parentEmail: $parentEmail,
|
||||
code: $code,
|
||||
createdAt: $now,
|
||||
createdBy: $createdBy,
|
||||
);
|
||||
|
||||
$invitation->envoyer($now);
|
||||
$this->invitationRepository->save($invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateBranding;
|
||||
|
||||
final readonly class UpdateBrandingCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public ?string $primaryColor,
|
||||
public ?string $secondaryColor,
|
||||
public ?string $accentColor,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateBrandingHandler
|
||||
{
|
||||
private const string WHITE = '#FFFFFF';
|
||||
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private ContrastValidator $contrastValidator,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateBrandingCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$primaryColor = $command->primaryColor !== null
|
||||
? new BrandColor($command->primaryColor)
|
||||
: null;
|
||||
|
||||
if ($primaryColor !== null) {
|
||||
$result = $this->contrastValidator->validate($primaryColor, new BrandColor(self::WHITE));
|
||||
|
||||
if (!$result->passesAA) {
|
||||
throw ContrasteInsuffisantException::pourRatio($result->ratio, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
$secondaryColor = $command->secondaryColor !== null
|
||||
? new BrandColor($command->secondaryColor)
|
||||
: null;
|
||||
$accentColor = $command->accentColor !== null
|
||||
? new BrandColor($command->accentColor)
|
||||
: null;
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: $secondaryColor,
|
||||
accentColor: $accentColor,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UploadLogo;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final readonly class UploadLogoCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public UploadedFile $file,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UploadLogo;
|
||||
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UploadLogoHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private LogoUploader $logoUploader,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UploadLogoCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$logoUrl = $this->logoUploader->upload($command->file, $tenantId, $branding->logoUrl);
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour le traitement d'images (redimensionnement).
|
||||
*/
|
||||
interface ImageProcessor
|
||||
{
|
||||
/**
|
||||
* Redimensionne une image en respectant les proportions.
|
||||
*
|
||||
* @param string $sourcePath Chemin vers le fichier source
|
||||
* @param int $maxWidth Largeur maximale
|
||||
* @param int $maxHeight Hauteur maximale
|
||||
*
|
||||
* @return string Contenu binaire de l'image redimensionnée (PNG)
|
||||
*/
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string;
|
||||
}
|
||||
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal file
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour le stockage des logos d'établissement.
|
||||
*/
|
||||
interface LogoStorage
|
||||
{
|
||||
/**
|
||||
* Stocke un logo et retourne son URL publique.
|
||||
*
|
||||
* @param string $content Contenu binaire du fichier
|
||||
* @param string $key Clé de stockage (chemin)
|
||||
* @param string $contentType Type MIME du fichier
|
||||
*
|
||||
* @return string URL publique du fichier stocké
|
||||
*/
|
||||
public function store(string $content, string $key, string $contentType): string;
|
||||
|
||||
/**
|
||||
* Supprime un fichier du stockage.
|
||||
*/
|
||||
public function delete(string $key): void;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated assignment queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<AssignmentWithNamesDto>
|
||||
*/
|
||||
interface PaginatedAssignmentsReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<AssignmentWithNamesDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetClasses\ClassDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated class queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<ClassDto>
|
||||
*/
|
||||
interface PaginatedClassesReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<ClassDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
string $academicYearId,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated parent invitation queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<ParentInvitationDto>
|
||||
*/
|
||||
interface PaginatedParentInvitationsReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<ParentInvitationDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
?string $status,
|
||||
?string $studentId,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated student image rights queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<StudentImageRightsDto>
|
||||
*/
|
||||
interface PaginatedStudentImageRightsReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<StudentImageRightsDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
?string $status,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
|
||||
/**
|
||||
* Returns all students (no pagination) for export purposes.
|
||||
*
|
||||
* @return StudentImageRightsDto[]
|
||||
*/
|
||||
public function findAll(
|
||||
string $tenantId,
|
||||
?string $status,
|
||||
): array;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated subject queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<SubjectDto>
|
||||
*/
|
||||
interface PaginatedSubjectsReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<SubjectDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
string $schoolId,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Query\GetUsers\UserDto;
|
||||
|
||||
/**
|
||||
* Read-model port for paginated user queries (CQRS read side).
|
||||
*
|
||||
* @phpstan-type Result = PaginatedResult<UserDto>
|
||||
*/
|
||||
interface PaginatedUsersReader
|
||||
{
|
||||
/**
|
||||
* @return PaginatedResult<UserDto>
|
||||
*/
|
||||
public function findPaginated(
|
||||
string $tenantId,
|
||||
?string $role,
|
||||
?string $statut,
|
||||
?string $search,
|
||||
int $page,
|
||||
int $limit,
|
||||
): PaginatedResult;
|
||||
}
|
||||
@@ -5,27 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Application\Query\GetAllAssignments;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Administration\Application\Port\PaginatedAssignmentsReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetAllAssignmentsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherAssignmentRepository $assignmentRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private LoggerInterface $logger,
|
||||
private PaginatedAssignmentsReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler
|
||||
*/
|
||||
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
// Build lookup maps for users, classes, and subjects
|
||||
$users = $this->userRepository->findAllByTenant($tenantId);
|
||||
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
|
||||
$userNames = [];
|
||||
foreach ($users as $user) {
|
||||
$userNames[(string) $user->id] = [
|
||||
'firstName' => $user->firstName,
|
||||
'lastName' => $user->lastName,
|
||||
];
|
||||
}
|
||||
|
||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||
/** @var array<string, string> $classNames */
|
||||
$classNames = [];
|
||||
foreach ($classes as $class) {
|
||||
$classNames[(string) $class->id] = (string) $class->name;
|
||||
}
|
||||
|
||||
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
|
||||
/** @var array<string, string> $subjectNames */
|
||||
$subjectNames = [];
|
||||
foreach ($subjects as $subject) {
|
||||
$subjectNames[(string) $subject->id] = (string) $subject->name;
|
||||
}
|
||||
|
||||
$dtos = [];
|
||||
foreach ($assignments as $assignment) {
|
||||
$teacherId = (string) $assignment->teacherId;
|
||||
$classId = (string) $assignment->classId;
|
||||
$subjectId = (string) $assignment->subjectId;
|
||||
|
||||
if (!isset($userNames[$teacherId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'teacherId' => $teacherId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isset($classNames[$classId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'classId' => $classId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isset($subjectNames[$subjectId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'subjectId' => $subjectId,
|
||||
]);
|
||||
}
|
||||
|
||||
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
|
||||
|
||||
$dtos[] = new AssignmentWithNamesDto(
|
||||
id: (string) $assignment->id,
|
||||
teacherId: $teacherId,
|
||||
teacherFirstName: $teacher['firstName'],
|
||||
teacherLastName: $teacher['lastName'],
|
||||
classId: $classId,
|
||||
className: $classNames[$classId] ?? '',
|
||||
subjectId: $subjectId,
|
||||
subjectName: $subjectNames[$subjectId] ?? '',
|
||||
academicYearId: (string) $assignment->academicYearId,
|
||||
status: $assignment->status->value,
|
||||
startDate: $assignment->startDate,
|
||||
endDate: $assignment->endDate,
|
||||
createdAt: $assignment->createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$dtos = array_values(array_filter(
|
||||
$dtos,
|
||||
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->className), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
|
||||
));
|
||||
}
|
||||
|
||||
$total = count($dtos);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($dtos, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: $items,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
/* @var PaginatedResult<AssignmentWithNamesDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'assignments',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetAllAssignmentsQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use App\Administration\Application\Port\PaginatedClassesReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetClassesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
private PaginatedClassesReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,33 +23,31 @@ final readonly class GetClassesHandler
|
||||
*/
|
||||
public function __invoke(GetClassesQuery $query): PaginatedResult
|
||||
{
|
||||
$classes = $this->classRepository->findActiveByTenantAndYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$classes = array_filter(
|
||||
$classes,
|
||||
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|
||||
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
|
||||
);
|
||||
$classes = array_values($classes);
|
||||
}
|
||||
|
||||
$total = count($classes);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($classes, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
static fn ($class) => ClassDto::fromDomain($class),
|
||||
$items,
|
||||
/* @var PaginatedResult<ClassDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'classes',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
academicYearId: $query->academicYearId,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetClassesQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'academic_year_id' => $query->academicYearId,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetParentInvitations;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetParentInvitationsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PaginatedParentInvitationsReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<ParentInvitationDto>
|
||||
*/
|
||||
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
|
||||
{
|
||||
/* @var PaginatedResult<ParentInvitationDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'parent_invitations',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
status: $query->status,
|
||||
studentId: $query->studentId,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetParentInvitationsQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'status' => $query->status,
|
||||
'student_id' => $query->studentId,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetParentInvitations;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetParentInvitationsQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $status = null,
|
||||
public ?string $studentId = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetParentInvitations;
|
||||
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitation;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ParentInvitationDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $studentId,
|
||||
public string $parentEmail,
|
||||
public string $status,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $expiresAt,
|
||||
public ?DateTimeImmutable $sentAt,
|
||||
public ?DateTimeImmutable $activatedAt,
|
||||
public ?string $activatedUserId,
|
||||
public ?string $studentFirstName = null,
|
||||
public ?string $studentLastName = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(ParentInvitation $invitation, ?string $studentFirstName = null, ?string $studentLastName = null): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $invitation->id,
|
||||
studentId: (string) $invitation->studentId,
|
||||
parentEmail: (string) $invitation->parentEmail,
|
||||
status: $invitation->status->value,
|
||||
createdAt: $invitation->createdAt,
|
||||
expiresAt: $invitation->expiresAt,
|
||||
sentAt: $invitation->sentAt,
|
||||
activatedAt: $invitation->activatedAt,
|
||||
activatedUserId: $invitation->activatedUserId !== null ? (string) $invitation->activatedUserId : null,
|
||||
studentFirstName: $studentFirstName,
|
||||
studentLastName: $studentLastName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,50 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
||||
|
||||
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentsImageRightsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private PaginatedStudentImageRightsReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return StudentImageRightsDto[]
|
||||
* @return PaginatedResult<StudentImageRightsDto>
|
||||
*/
|
||||
public function __invoke(GetStudentsImageRightsQuery $query): array
|
||||
public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult
|
||||
{
|
||||
$students = $this->userRepository->findStudentsByTenant(
|
||||
TenantId::fromString($query->tenantId),
|
||||
/* @var PaginatedResult<StudentImageRightsDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'students_image_rights',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
status: $query->status,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($query->status !== null) {
|
||||
$filterStatus = ImageRightsStatus::tryFrom($query->status);
|
||||
if ($filterStatus !== null) {
|
||||
$students = array_filter(
|
||||
$students,
|
||||
static fn ($user) => $user->imageRightsStatus === $filterStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($user) => StudentImageRightsDto::fromDomain($user),
|
||||
$students,
|
||||
));
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetStudentsImageRightsQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'status' => $query->status,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetStudentsImageRightsQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $status = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentsWithClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<StudentWithClassDto>
|
||||
*/
|
||||
public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult
|
||||
{
|
||||
$params = [
|
||||
'tenant_id' => $query->tenantId,
|
||||
'academic_year_id' => $query->academicYearId,
|
||||
'role' => json_encode([Role::ELEVE->value]),
|
||||
];
|
||||
|
||||
$whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$whereClause .= ' AND ca.school_class_id = :class_id';
|
||||
$params['class_id'] = $query->classId;
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)';
|
||||
$params['search'] = '%' . mb_strtolower($query->search) . '%';
|
||||
}
|
||||
|
||||
// Count total
|
||||
$countSql = <<<SQL
|
||||
SELECT COUNT(*)
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
WHERE {$whereClause}
|
||||
SQL;
|
||||
|
||||
/** @var int|string|false $totalRaw */
|
||||
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||
$total = (int) $totalRaw;
|
||||
|
||||
// Fetch paginated results
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
|
||||
$selectSql = <<<SQL
|
||||
SELECT
|
||||
u.id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
u.statut,
|
||||
u.student_number,
|
||||
u.date_naissance,
|
||||
ca.school_class_id AS class_id,
|
||||
sc.name AS class_name,
|
||||
sc.level AS class_level
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||
WHERE {$whereClause}
|
||||
ORDER BY u.last_name ASC, u.first_name ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL;
|
||||
|
||||
$params['limit'] = $query->limit;
|
||||
$params['offset'] = $offset;
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||
|
||||
$items = array_map(static function (array $row): StudentWithClassDto {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
/** @var string|null $email */
|
||||
$email = $row['email'];
|
||||
/** @var string $statut */
|
||||
$statut = $row['statut'];
|
||||
/** @var string|null $studentNumber */
|
||||
$studentNumber = $row['student_number'];
|
||||
/** @var string|null $dateNaissance */
|
||||
$dateNaissance = $row['date_naissance'];
|
||||
/** @var string|null $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string|null $className */
|
||||
$className = $row['class_name'];
|
||||
/** @var string|null $classLevel */
|
||||
$classLevel = $row['class_level'];
|
||||
|
||||
return new StudentWithClassDto(
|
||||
id: $id,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
email: $email,
|
||||
statut: $statut,
|
||||
studentNumber: $studentNumber,
|
||||
dateNaissance: $dateNaissance,
|
||||
classId: $classId,
|
||||
className: $className,
|
||||
classLevel: $classLevel,
|
||||
);
|
||||
}, $rows);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: $items,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetStudentsWithClassQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public ?string $classId = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
final readonly class StudentWithClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $email,
|
||||
public string $statut,
|
||||
public ?string $studentNumber,
|
||||
public ?string $dateNaissance,
|
||||
public ?string $classId,
|
||||
public ?string $className,
|
||||
public ?string $classLevel,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use App\Administration\Application\Port\PaginatedSubjectsReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetSubjectsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
private PaginatedSubjectsReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,37 +23,31 @@ final readonly class GetSubjectsHandler
|
||||
*/
|
||||
public function __invoke(GetSubjectsQuery $query): PaginatedResult
|
||||
{
|
||||
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
|
||||
TenantId::fromString($query->tenantId),
|
||||
SchoolId::fromString($query->schoolId),
|
||||
);
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$subjects = array_filter(
|
||||
$subjects,
|
||||
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|
||||
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
|
||||
);
|
||||
$subjects = array_values($subjects);
|
||||
}
|
||||
|
||||
$total = count($subjects);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($subjects, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
static fn ($subject) => SubjectDto::fromDomain(
|
||||
$subject,
|
||||
teacherCount: 0,
|
||||
classCount: 0,
|
||||
),
|
||||
$items,
|
||||
/* @var PaginatedResult<SubjectDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'subjects',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
schoolId: $query->schoolId,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetSubjectsQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'school_id' => $query->schoolId,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Application\Query\GetUsers;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use App\Administration\Application\Port\PaginatedUsersReader;
|
||||
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetUsersHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
private PaginatedUsersReader $reader,
|
||||
private PaginatedQueryCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -30,54 +23,33 @@ final readonly class GetUsersHandler
|
||||
*/
|
||||
public function __invoke(GetUsersQuery $query): PaginatedResult
|
||||
{
|
||||
$users = $this->userRepository->findAllByTenant(
|
||||
TenantId::fromString($query->tenantId),
|
||||
);
|
||||
|
||||
if ($query->role !== null) {
|
||||
$filterRole = Role::tryFrom($query->role);
|
||||
if ($filterRole !== null) {
|
||||
$users = array_filter(
|
||||
$users,
|
||||
static fn ($user) => $user->aLeRole($filterRole),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($query->statut !== null) {
|
||||
$filterStatut = StatutCompte::tryFrom($query->statut);
|
||||
if ($filterStatut !== null) {
|
||||
$users = array_filter(
|
||||
$users,
|
||||
static fn ($user) => $user->statut === $filterStatut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$users = array_filter(
|
||||
$users,
|
||||
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|
||||
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|
||||
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
$users = array_values($users);
|
||||
$total = count($users);
|
||||
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($users, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
fn ($user) => UserDto::fromDomain($user, $this->clock),
|
||||
$items,
|
||||
/* @var PaginatedResult<UserDto> */
|
||||
return $this->cache->getOrLoad(
|
||||
'users',
|
||||
$query->tenantId,
|
||||
$this->cacheParams($query),
|
||||
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||
tenantId: $query->tenantId,
|
||||
role: $query->role,
|
||||
statut: $query->statut,
|
||||
search: $query->search,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cacheParams(GetUsersQuery $query): array
|
||||
{
|
||||
return [
|
||||
'page' => $query->page,
|
||||
'limit' => $query->limit,
|
||||
'role' => $query->role,
|
||||
'statut' => $query->statut,
|
||||
'search' => $query->search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
|
||||
* Pour l'instant, retourne toujours 0 (aucun élève).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasStudentsInClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(HasStudentsInClassQuery $query): int
|
||||
{
|
||||
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
|
||||
// Pour l'instant, retourne 0 (permet l'archivage)
|
||||
return 0;
|
||||
return $this->classAssignmentRepository->countByClass(
|
||||
ClassId::fromString($query->classId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Cache;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use function ksort;
|
||||
use function md5;
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||
|
||||
/**
|
||||
* Service cache-aside pour les requêtes paginées.
|
||||
*
|
||||
* Chaque entrée est taguée par type d'entité + tenant,
|
||||
* permettant une invalidation ciblée lors de mutations.
|
||||
*/
|
||||
final readonly class PaginatedQueryCache
|
||||
{
|
||||
public function __construct(
|
||||
private TagAwareCacheInterface $paginatedQueriesCache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param array<string, mixed> $params Filtres + page + limit
|
||||
* @param callable(): PaginatedResult<T> $loader Fonction qui exécute la requête SQL
|
||||
*
|
||||
* @return PaginatedResult<T>
|
||||
*/
|
||||
public function getOrLoad(
|
||||
string $entityType,
|
||||
string $tenantId,
|
||||
array $params,
|
||||
callable $loader,
|
||||
): PaginatedResult {
|
||||
$key = $this->buildKey($entityType, $tenantId, $params);
|
||||
$tag = sprintf('query_%s_%s', $entityType, $tenantId);
|
||||
|
||||
/* @var PaginatedResult<T> */
|
||||
return $this->paginatedQueriesCache->get(
|
||||
$key,
|
||||
static function (ItemInterface $item) use ($tag, $loader): PaginatedResult {
|
||||
$item->tag([$tag]);
|
||||
|
||||
return $loader();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidate(string $entityType, string $tenantId): void
|
||||
{
|
||||
$this->paginatedQueriesCache->invalidateTags(
|
||||
[sprintf('query_%s_%s', $entityType, $tenantId)],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function buildKey(string $entityType, string $tenantId, array $params): string
|
||||
{
|
||||
ksort($params);
|
||||
|
||||
return sprintf('query_%s_%s_%s', $entityType, $tenantId, md5(json_encode($params, JSON_THROW_ON_ERROR)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Suggère un mapping automatique des colonnes basé sur les noms de colonnes
|
||||
* et le format détecté.
|
||||
*
|
||||
* @see AC3: Mapping automatique proposé basé sur noms de colonnes
|
||||
*/
|
||||
final readonly class ColumnMappingSuggester
|
||||
{
|
||||
/**
|
||||
* Mappings pré-configurés pour le format Pronote.
|
||||
*
|
||||
* @var array<string, StudentImportField>
|
||||
*/
|
||||
private const array PRONOTE_MAPPING = [
|
||||
'élèves' => StudentImportField::FULL_NAME,
|
||||
'eleves' => StudentImportField::FULL_NAME,
|
||||
'né(e) le' => StudentImportField::BIRTH_DATE,
|
||||
'ne(e) le' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'classe de rattachement' => StudentImportField::CLASS_NAME,
|
||||
'adresse e mail' => StudentImportField::EMAIL,
|
||||
];
|
||||
|
||||
private const array ECOLE_DIRECTE_MAPPING = [
|
||||
'nom' => StudentImportField::LAST_NAME,
|
||||
'prenom' => StudentImportField::FIRST_NAME,
|
||||
'classe' => StudentImportField::CLASS_NAME,
|
||||
'date naissance' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'email' => StudentImportField::EMAIL,
|
||||
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping générique par mots-clés.
|
||||
*
|
||||
* @var array<string, StudentImportField>
|
||||
*/
|
||||
private const array GENERIC_KEYWORDS = [
|
||||
'élèves' => StudentImportField::FULL_NAME,
|
||||
'eleves' => StudentImportField::FULL_NAME,
|
||||
'nom' => StudentImportField::LAST_NAME,
|
||||
'last' => StudentImportField::LAST_NAME,
|
||||
'family' => StudentImportField::LAST_NAME,
|
||||
'surname' => StudentImportField::LAST_NAME,
|
||||
'prénom' => StudentImportField::FIRST_NAME,
|
||||
'prenom' => StudentImportField::FIRST_NAME,
|
||||
'first' => StudentImportField::FIRST_NAME,
|
||||
'given' => StudentImportField::FIRST_NAME,
|
||||
'classe' => StudentImportField::CLASS_NAME,
|
||||
'class' => StudentImportField::CLASS_NAME,
|
||||
'groupe' => StudentImportField::CLASS_NAME,
|
||||
'email' => StudentImportField::EMAIL,
|
||||
'mail' => StudentImportField::EMAIL,
|
||||
'courriel' => StudentImportField::EMAIL,
|
||||
'naissance' => StudentImportField::BIRTH_DATE,
|
||||
'birth' => StudentImportField::BIRTH_DATE,
|
||||
'date' => StudentImportField::BIRTH_DATE,
|
||||
'sexe' => StudentImportField::GENDER,
|
||||
'genre' => StudentImportField::GENDER,
|
||||
'gender' => StudentImportField::GENDER,
|
||||
'numéro' => StudentImportField::STUDENT_NUMBER,
|
||||
'numero' => StudentImportField::STUDENT_NUMBER,
|
||||
'number' => StudentImportField::STUDENT_NUMBER,
|
||||
'matricule' => StudentImportField::STUDENT_NUMBER,
|
||||
];
|
||||
|
||||
/**
|
||||
* Suggère un mapping pour les colonnes données.
|
||||
*
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
* @param KnownImportFormat $detectedFormat Format détecté
|
||||
*
|
||||
* @return array<string, StudentImportField> Mapping suggéré (colonne → champ)
|
||||
*/
|
||||
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
|
||||
{
|
||||
return match ($detectedFormat) {
|
||||
KnownImportFormat::PRONOTE => $this->mapperAvecReference($columns, self::PRONOTE_MAPPING),
|
||||
KnownImportFormat::ECOLE_DIRECTE => $this->mapperAvecReference($columns, self::ECOLE_DIRECTE_MAPPING),
|
||||
KnownImportFormat::CUSTOM => $this->mapperGenerique($columns),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
* @param array<string, StudentImportField> $reference
|
||||
*
|
||||
* @return array<string, StudentImportField>
|
||||
*/
|
||||
private function mapperAvecReference(array $columns, array $reference): array
|
||||
{
|
||||
$normalizedReference = [];
|
||||
foreach ($reference as $key => $field) {
|
||||
$normalizedReference[$this->normaliser($key)] = $field;
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
if (isset($normalizedReference[$normalized]) && !in_array($normalizedReference[$normalized], $usedFields, true)) {
|
||||
$mapping[$column] = $normalizedReference[$normalized];
|
||||
$usedFields[] = $normalizedReference[$normalized];
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, StudentImportField>
|
||||
*/
|
||||
private function mapperGenerique(array $columns): array
|
||||
{
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
|
||||
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
|
||||
$mapping[$column] = $field;
|
||||
$usedFields[] = $field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
|
||||
use function count;
|
||||
use function fclose;
|
||||
use function fgetcsv;
|
||||
use function fopen;
|
||||
use function mb_convert_encoding;
|
||||
use function mb_detect_encoding;
|
||||
|
||||
/**
|
||||
* Service de parsing de fichiers CSV avec détection d'encoding UTF-8.
|
||||
*
|
||||
* Supporte les séparateurs courants (virgule, point-virgule, tabulation).
|
||||
*/
|
||||
final readonly class CsvParser
|
||||
{
|
||||
private const int MAX_LINE_LENGTH = 0;
|
||||
private const array SEPARATORS = [';', ',', "\t"];
|
||||
|
||||
public function parse(string $filePath): FileParseResult
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw FichierImportInvalideException::fichierIllisible($filePath);
|
||||
}
|
||||
|
||||
$content = $this->convertToUtf8($content);
|
||||
$content = $this->stripBom($content);
|
||||
|
||||
$separator = $this->detectSeparator($content);
|
||||
|
||||
$lines = $this->parseContent($content, $separator);
|
||||
|
||||
if ($lines === []) {
|
||||
throw FichierImportInvalideException::fichierVide();
|
||||
}
|
||||
|
||||
$columns = array_shift($lines);
|
||||
|
||||
$rows = [];
|
||||
foreach ($lines as $line) {
|
||||
if ($this->isEmptyLine($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $index => $column) {
|
||||
$row[$column] = $line[$index] ?? '';
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return new FileParseResult($columns, $rows);
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertToUtf8(string $content): string
|
||||
{
|
||||
$encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||
|
||||
if ($encoding !== false && $encoding !== 'UTF-8') {
|
||||
$converted = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||||
|
||||
return $converted !== false ? $converted : $content;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function stripBom(string $content): string
|
||||
{
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
return substr($content, 3);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function detectSeparator(string $content): string
|
||||
{
|
||||
$firstLine = strtok($content, "\n");
|
||||
if ($firstLine === false) {
|
||||
return ';';
|
||||
}
|
||||
|
||||
$maxCount = 0;
|
||||
$bestSeparator = ';';
|
||||
|
||||
foreach (self::SEPARATORS as $separator) {
|
||||
$count = substr_count($firstLine, $separator);
|
||||
if ($count > $maxCount) {
|
||||
$maxCount = $count;
|
||||
$bestSeparator = $separator;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<list<string>>
|
||||
*/
|
||||
private function parseContent(string $content, string $separator): array
|
||||
{
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
if ($stream === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
$lines = [];
|
||||
while (($line = fgetcsv($stream, self::MAX_LINE_LENGTH, $separator, '"', '')) !== false) {
|
||||
/** @var list<string> $sanitized */
|
||||
$sanitized = array_map('strval', $line);
|
||||
$lines[] = $sanitized;
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $line
|
||||
*/
|
||||
private function isEmptyLine(array $line): bool
|
||||
{
|
||||
return count($line) === 1 && trim((string) $line[0]) === '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Parse des dates dans les formats courants utilisés par les fichiers d'import.
|
||||
*/
|
||||
final class DateParser
|
||||
{
|
||||
private const array FORMATS = ['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'];
|
||||
|
||||
public static function parse(?string $date): ?DateTimeImmutable
|
||||
{
|
||||
if ($date === null || trim($date) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($date);
|
||||
|
||||
foreach (self::FORMATS as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
|
||||
if ($parsed !== false && $parsed->format($format) === $trimmed) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?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 function mb_strtolower;
|
||||
use function sprintf;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Détecte les doublons parmi les lignes d'import :
|
||||
* - contre les élèves existants en base (email, numéro élève, nom+prénom+classe)
|
||||
* - au sein du fichier lui-même (lignes identiques).
|
||||
*/
|
||||
final readonly class DuplicateDetector
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}> $existingStudents
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function detecter(array $rows, array $existingStudents): array
|
||||
{
|
||||
$byEmail = [];
|
||||
$byStudentNumber = [];
|
||||
$byNameClass = [];
|
||||
|
||||
foreach ($existingStudents as $student) {
|
||||
if ($student['email'] !== null && trim($student['email']) !== '') {
|
||||
$byEmail[mb_strtolower(trim($student['email']))] = true;
|
||||
}
|
||||
if ($student['studentNumber'] !== null && trim($student['studentNumber']) !== '') {
|
||||
$byStudentNumber[trim($student['studentNumber'])] = true;
|
||||
}
|
||||
$key = $this->nameClassKey(
|
||||
$student['firstName'],
|
||||
$student['lastName'],
|
||||
$student['className'] ?? '',
|
||||
);
|
||||
if ($key !== null) {
|
||||
$byNameClass[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$match = $this->findMatch($row, $byEmail, $byStudentNumber, $byNameClass);
|
||||
|
||||
if ($match !== null) {
|
||||
$row = $row->avecErreurs(new ImportRowError(
|
||||
'_duplicate',
|
||||
sprintf('Cet élève existe déjà (correspondance : %s).', $match),
|
||||
));
|
||||
} else {
|
||||
$this->indexRow($row, $byEmail, $byStudentNumber, $byNameClass);
|
||||
}
|
||||
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $byEmail
|
||||
* @param array<string, true> $byStudentNumber
|
||||
* @param array<string, true> $byNameClass
|
||||
*/
|
||||
private function findMatch(
|
||||
ImportRow $row,
|
||||
array $byEmail,
|
||||
array $byStudentNumber,
|
||||
array $byNameClass,
|
||||
): ?string {
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$normalized = mb_strtolower(trim($email));
|
||||
if (isset($byEmail[$normalized])) {
|
||||
return 'email';
|
||||
}
|
||||
}
|
||||
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
if ($studentNumber !== null && trim($studentNumber) !== '') {
|
||||
if (isset($byStudentNumber[trim($studentNumber)])) {
|
||||
return 'numéro élève';
|
||||
}
|
||||
}
|
||||
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
|
||||
|
||||
if ($key !== null && isset($byNameClass[$key])) {
|
||||
return 'nom + classe';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $byEmail
|
||||
* @param array<string, true> $byStudentNumber
|
||||
* @param array<string, true> $byNameClass
|
||||
*/
|
||||
private function indexRow(
|
||||
ImportRow $row,
|
||||
array &$byEmail,
|
||||
array &$byStudentNumber,
|
||||
array &$byNameClass,
|
||||
): void {
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$byEmail[mb_strtolower(trim($email))] = true;
|
||||
}
|
||||
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
if ($studentNumber !== null && trim($studentNumber) !== '') {
|
||||
$byStudentNumber[trim($studentNumber)] = true;
|
||||
}
|
||||
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
|
||||
|
||||
if ($key !== null) {
|
||||
$byNameClass[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function nameClassKey(string $firstName, string $lastName, string $className): ?string
|
||||
{
|
||||
$first = mb_strtolower(trim($firstName));
|
||||
$last = mb_strtolower(trim($lastName));
|
||||
$class = mb_strtolower(trim($className));
|
||||
|
||||
if ($first === '' || $last === '' || $class === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $last . '|' . $first . '|' . $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Charge les élèves existants d'un tenant pour une année scolaire,
|
||||
* avec leur affectation de classe, afin de détecter les doublons à l'import.
|
||||
*/
|
||||
final readonly class ExistingStudentFinder
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}>
|
||||
*/
|
||||
public function findAllForTenant(TenantId $tenantId, AcademicYearId $academicYearId): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT u.first_name, u.last_name, u.email, u.student_number, sc.name AS class_name
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||
WHERE u.tenant_id = :tenant_id
|
||||
AND u.roles::jsonb @> :role
|
||||
SQL;
|
||||
|
||||
/** @var list<array{first_name: string, last_name: string, email: ?string, student_number: ?string, class_name: ?string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative($sql, [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'role' => '"ROLE_ELEVE"',
|
||||
]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => [
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'email' => $row['email'],
|
||||
'studentNumber' => $row['student_number'],
|
||||
'className' => $row['class_name'],
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Charge les enseignants existants d'un tenant
|
||||
* afin de détecter les doublons à l'import.
|
||||
*/
|
||||
final readonly class ExistingTeacherFinder
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{firstName: string, lastName: string, email: string}>
|
||||
*/
|
||||
public function findAllForTenant(TenantId $tenantId): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT u.first_name, u.last_name, u.email
|
||||
FROM users u
|
||||
WHERE u.tenant_id = :tenant_id
|
||||
AND u.roles::jsonb @> :role
|
||||
SQL;
|
||||
|
||||
/** @var list<array{first_name: string, last_name: string, email: string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative($sql, [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'role' => '"ROLE_PROF"',
|
||||
]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => [
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'email' => $row['email'],
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* Résultat du parsing d'un fichier d'import.
|
||||
*
|
||||
* Contient les colonnes détectées et les données brutes extraites.
|
||||
*/
|
||||
final readonly class FileParseResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $columns Noms des colonnes détectées
|
||||
* @param list<array<string, string>> $rows Données brutes (colonne → valeur)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $columns,
|
||||
public array $rows,
|
||||
) {
|
||||
}
|
||||
|
||||
public function totalRows(): int
|
||||
{
|
||||
return count($this->rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, string>>
|
||||
*/
|
||||
public function preview(int $limit = 5): array
|
||||
{
|
||||
return array_slice($this->rows, 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
|
||||
/**
|
||||
* Détecte automatiquement le format d'import (Pronote, École Directe)
|
||||
* à partir des noms de colonnes.
|
||||
*/
|
||||
final readonly class ImportFormatDetector
|
||||
{
|
||||
/**
|
||||
* Colonnes caractéristiques de Pronote.
|
||||
*/
|
||||
private const array PRONOTE_COLUMNS = [
|
||||
'Élèves',
|
||||
'Né(e) le',
|
||||
'Sexe',
|
||||
'Classe de rattachement',
|
||||
'Adresse E-mail',
|
||||
];
|
||||
|
||||
/**
|
||||
* Colonnes caractéristiques d'École Directe.
|
||||
*/
|
||||
private const array ECOLE_DIRECTE_COLUMNS = [
|
||||
'NOM',
|
||||
'PRENOM',
|
||||
'CLASSE',
|
||||
'DATE_NAISSANCE',
|
||||
'SEXE',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
*/
|
||||
public function detecter(array $columns): KnownImportFormat
|
||||
{
|
||||
$normalizedColumns = array_map($this->normaliser(...), $columns);
|
||||
|
||||
if ($this->matchesPronote($normalizedColumns)) {
|
||||
return KnownImportFormat::PRONOTE;
|
||||
}
|
||||
|
||||
if ($this->matchesEcoleDirecte($normalizedColumns)) {
|
||||
return KnownImportFormat::ECOLE_DIRECTE;
|
||||
}
|
||||
|
||||
return KnownImportFormat::CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $normalizedColumns
|
||||
*/
|
||||
private function matchesPronote(array $normalizedColumns): bool
|
||||
{
|
||||
$pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS);
|
||||
|
||||
return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $normalizedColumns
|
||||
*/
|
||||
private function matchesEcoleDirecte(array $normalizedColumns): bool
|
||||
{
|
||||
$ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS);
|
||||
|
||||
return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $actualColumns
|
||||
* @param list<string> $expectedColumns
|
||||
*/
|
||||
private function matchThreshold(array $actualColumns, array $expectedColumns, int $minMatches): bool
|
||||
{
|
||||
$matches = 0;
|
||||
|
||||
foreach ($expectedColumns as $expected) {
|
||||
foreach ($actualColumns as $actual) {
|
||||
if ($actual === $expected) {
|
||||
++$matches;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matches >= $minMatches;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
|
||||
use function count;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Rapport d'import généré après validation ou exécution.
|
||||
*
|
||||
* @see AC5: Rapport affiché : X élèves importés, Y erreurs ignorées
|
||||
*/
|
||||
final readonly class ImportReport
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $validRows Lignes valides importées
|
||||
* @param list<ImportRow> $errorRows Lignes en erreur
|
||||
* @param list<string> $createdClasses Classes créées automatiquement
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalRows,
|
||||
public int $importedCount,
|
||||
public int $errorCount,
|
||||
public array $validRows,
|
||||
public array $errorRows,
|
||||
public array $createdClasses = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows Toutes les lignes validées
|
||||
* @param list<string> $createdClasses Classes créées automatiquement
|
||||
*/
|
||||
public static function fromValidatedRows(array $rows, array $createdClasses = []): self
|
||||
{
|
||||
$valid = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row->estValide()) {
|
||||
$valid[] = $row;
|
||||
} else {
|
||||
$errors[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
totalRows: count($rows),
|
||||
importedCount: count($valid),
|
||||
errorCount: count($errors),
|
||||
validRows: $valid,
|
||||
errorRows: $errors,
|
||||
createdClasses: $createdClasses,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un résumé texte du rapport.
|
||||
*
|
||||
* @return list<string> Lignes du rapport
|
||||
*/
|
||||
public function lignesRapport(): array
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = sprintf('Import terminé : %d élèves importés, %d erreurs', $this->importedCount, $this->errorCount);
|
||||
|
||||
if ($this->createdClasses !== []) {
|
||||
$lines[] = sprintf('Classes créées automatiquement : %s', implode(', ', $this->createdClasses));
|
||||
}
|
||||
|
||||
foreach ($this->errorRows as $row) {
|
||||
foreach ($row->errors as $error) {
|
||||
$lines[] = sprintf('Ligne %d, %s', $row->lineNumber, $error);
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Valide les lignes d'import après mapping.
|
||||
*
|
||||
* Vérifie que les champs obligatoires sont remplis,
|
||||
* les formats sont corrects (email, dates), et les classes existent.
|
||||
*
|
||||
* @see AC4: Lignes valides en vert, lignes avec erreurs en rouge
|
||||
*/
|
||||
final readonly class ImportRowValidator
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
|
||||
*/
|
||||
public function __construct(
|
||||
private ?array $existingClassNames = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function valider(ImportRow $row): ImportRow
|
||||
{
|
||||
$row = $this->expanderNomComplet($row);
|
||||
|
||||
$errors = [];
|
||||
|
||||
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
|
||||
$errors = [...$errors, ...$this->validerEmail($row)];
|
||||
$errors = [...$errors, ...$this->validerDateNaissance($row)];
|
||||
$errors = [...$errors, ...$this->validerClasse($row)];
|
||||
|
||||
if ($errors !== []) {
|
||||
return $row->avecErreurs(...$errors);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function validerTout(array $rows): array
|
||||
{
|
||||
return array_map($this->valider(...), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerChampsObligatoires(ImportRow $row): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach (StudentImportField::champsObligatoires() as $field) {
|
||||
$value = $row->valeurChamp($field);
|
||||
|
||||
if ($value === null || trim($value) === '') {
|
||||
$errors[] = new ImportRowError(
|
||||
$field->value,
|
||||
sprintf('Le champ "%s" est obligatoire.', $field->label()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerEmail(ImportRow $row): array
|
||||
{
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
|
||||
if ($email === null || trim($email) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::EMAIL->value,
|
||||
sprintf('L\'adresse email "%s" est invalide.', $email),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerDateNaissance(ImportRow $row): array
|
||||
{
|
||||
$date = $row->valeurChamp(StudentImportField::BIRTH_DATE);
|
||||
|
||||
if ($date === null || trim($date) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (DateParser::parse($date) === null) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::BIRTH_DATE->value,
|
||||
sprintf('La date "%s" est invalide. Formats acceptés : JJ/MM/AAAA, AAAA-MM-JJ.', $date),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerClasse(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingClassNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
|
||||
if ($className === null || trim($className) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!in_array(trim($className), $this->existingClassNames, true)) {
|
||||
return [new ImportRowError(
|
||||
StudentImportField::CLASS_NAME->value,
|
||||
sprintf('La classe "%s" n\'existe pas.', $className),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Si FULL_NAME est renseigné et que LAST_NAME/FIRST_NAME sont vides,
|
||||
* on dérive nom et prénom depuis le nom complet (format "NOM Prénom").
|
||||
*/
|
||||
private function expanderNomComplet(ImportRow $row): ImportRow
|
||||
{
|
||||
$fullName = $row->valeurChamp(StudentImportField::FULL_NAME);
|
||||
|
||||
if ($fullName === null || trim($fullName) === '') {
|
||||
return $row;
|
||||
}
|
||||
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
|
||||
if (($lastName !== null && trim($lastName) !== '') || ($firstName !== null && trim($firstName) !== '')) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
[$derivedLast, $derivedFirst] = self::splitFullName(trim($fullName));
|
||||
|
||||
$mappedData = $row->mappedData;
|
||||
$mappedData[StudentImportField::LAST_NAME->value] = $derivedLast;
|
||||
$mappedData[StudentImportField::FIRST_NAME->value] = $derivedFirst;
|
||||
|
||||
return new ImportRow($row->lineNumber, $row->rawData, $mappedData, $row->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sépare un nom complet au format "NOM Prénom" en [nom, prénom].
|
||||
*
|
||||
* Convention Pronote : le nom de famille est en majuscules, le prénom en casse mixte.
|
||||
* Si la convention n'est pas détectable, on prend le premier mot comme nom.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
public static function splitFullName(string $fullName): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($fullName));
|
||||
if ($parts === false || $parts === []) {
|
||||
return [$fullName, ''];
|
||||
}
|
||||
|
||||
$uppercaseParts = [];
|
||||
$rest = [];
|
||||
$foundNonUpper = false;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!$foundNonUpper && mb_strtoupper($part) === $part && preg_match('/\p{L}/u', $part)) {
|
||||
$uppercaseParts[] = $part;
|
||||
} else {
|
||||
$foundNonUpper = true;
|
||||
$rest[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uppercaseParts !== [] && $rest !== []) {
|
||||
return [implode(' ', $uppercaseParts), implode(' ', $rest)];
|
||||
}
|
||||
|
||||
$lastName = array_shift($parts);
|
||||
|
||||
return [$lastName ?? $fullName, implode(' ', $parts)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function explode;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Parse une valeur multi-éléments séparés par un délimiteur.
|
||||
*
|
||||
* Utilisé pour les champs matières et classes dans l'import enseignants
|
||||
* où une cellule CSV peut contenir "Mathématiques, Physique".
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final readonly class MultiValueParser
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $separator
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function parse(string $value, string $separator = ','): array
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(
|
||||
static fn (string $item): string => trim($item),
|
||||
explode($separator, $value),
|
||||
),
|
||||
static fn (string $item): bool => $item !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?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\Model\SchoolClass\AcademicYearId;
|
||||
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 count;
|
||||
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 ExistingStudentFinder $existingStudentFinder,
|
||||
private DuplicateDetector $duplicateDetector,
|
||||
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, ?AcademicYearId $academicYearId = null): array
|
||||
{
|
||||
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||
$validator = new ImportRowValidator($existingClasses);
|
||||
|
||||
$validatedRows = $validator->validerTout($batch->lignes());
|
||||
|
||||
if ($academicYearId !== null) {
|
||||
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
|
||||
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingStudents);
|
||||
}
|
||||
|
||||
$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.
|
||||
* La détection de doublons est ré-appliquée après re-validation
|
||||
* pour ne pas perdre les erreurs _duplicate.
|
||||
*/
|
||||
public function prepareForConfirmation(
|
||||
StudentImportBatch $batch,
|
||||
bool $createMissingClasses,
|
||||
bool $importValidOnly,
|
||||
TenantId $tenantId,
|
||||
?AcademicYearId $academicYearId = null,
|
||||
): void {
|
||||
if ($createMissingClasses) {
|
||||
$validator = new ImportRowValidator();
|
||||
// Strip old errors before re-validating — the previous validation
|
||||
// may have added className errors that we no longer want.
|
||||
$cleanRows = array_map(
|
||||
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
|
||||
$batch->lignes(),
|
||||
);
|
||||
$revalidated = $validator->validerTout($cleanRows);
|
||||
|
||||
if ($academicYearId !== null) {
|
||||
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
|
||||
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingStudents);
|
||||
}
|
||||
|
||||
$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 le mapping sauvegardé correspond exactement aux colonnes du fichier.
|
||||
*
|
||||
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
|
||||
* mais ne le sont pas par le mapping sauvegardé.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
if (count($autoMapping) > count($mapping)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, StudentImportField> $mapping
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
private function mapRows(FileParseResult $parseResult, array $mapping): array
|
||||
{
|
||||
$rows = [];
|
||||
$lineNumber = 1;
|
||||
|
||||
foreach ($parseResult->rows as $rawData) {
|
||||
$mappedData = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$mappedData[$field->value] = $rawData[$column] ?? '';
|
||||
}
|
||||
|
||||
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
|
||||
++$lineNumber;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getExistingClassNames(TenantId $tenantId): array
|
||||
{
|
||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($class) => (string) $class->name,
|
||||
$classes,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<string> $existingClasses
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function detectUnknownClasses(array $rows, array $existingClasses): array
|
||||
{
|
||||
$unknown = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
if ($className !== null
|
||||
&& trim($className) !== ''
|
||||
&& !in_array(trim($className), $existingClasses, true)
|
||||
&& !in_array(trim($className), $unknown, true)
|
||||
) {
|
||||
$unknown[] = trim($className);
|
||||
}
|
||||
}
|
||||
|
||||
return $unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Suggère un mapping automatique des colonnes pour l'import enseignants.
|
||||
*
|
||||
* @see AC2: Mapping spécifique enseignants
|
||||
*/
|
||||
final readonly class TeacherColumnMappingSuggester
|
||||
{
|
||||
/**
|
||||
* Mapping générique par mots-clés pour les enseignants.
|
||||
*
|
||||
* @var array<string, TeacherImportField>
|
||||
*/
|
||||
private const array GENERIC_KEYWORDS = [
|
||||
'nom' => TeacherImportField::LAST_NAME,
|
||||
'last' => TeacherImportField::LAST_NAME,
|
||||
'family' => TeacherImportField::LAST_NAME,
|
||||
'surname' => TeacherImportField::LAST_NAME,
|
||||
'prénom' => TeacherImportField::FIRST_NAME,
|
||||
'prenom' => TeacherImportField::FIRST_NAME,
|
||||
'first' => TeacherImportField::FIRST_NAME,
|
||||
'given' => TeacherImportField::FIRST_NAME,
|
||||
'email' => TeacherImportField::EMAIL,
|
||||
'mail' => TeacherImportField::EMAIL,
|
||||
'courriel' => TeacherImportField::EMAIL,
|
||||
'matière' => TeacherImportField::SUBJECTS,
|
||||
'matiere' => TeacherImportField::SUBJECTS,
|
||||
'matières' => TeacherImportField::SUBJECTS,
|
||||
'matieres' => TeacherImportField::SUBJECTS,
|
||||
'subject' => TeacherImportField::SUBJECTS,
|
||||
'discipline' => TeacherImportField::SUBJECTS,
|
||||
'classe' => TeacherImportField::CLASSES,
|
||||
'classes' => TeacherImportField::CLASSES,
|
||||
'class' => TeacherImportField::CLASSES,
|
||||
'groupe' => TeacherImportField::CLASSES,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
* @param KnownImportFormat $detectedFormat Format détecté
|
||||
*
|
||||
* @return array<string, TeacherImportField> Mapping suggéré (colonne → champ)
|
||||
*/
|
||||
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
|
||||
{
|
||||
return $this->mapperGenerique($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, TeacherImportField>
|
||||
*/
|
||||
private function mapperGenerique(array $columns): array
|
||||
{
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
|
||||
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
|
||||
$mapping[$column] = $field;
|
||||
$usedFields[] = $field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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\TeacherImportField;
|
||||
|
||||
use function mb_strtolower;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Détecte les doublons parmi les lignes d'import enseignants :
|
||||
* - contre les enseignants existants en base (par email)
|
||||
* - au sein du fichier lui-même (lignes avec le même email).
|
||||
*/
|
||||
final readonly class TeacherDuplicateDetector
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<array{firstName: string, lastName: string, email: string}> $existingTeachers
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function detecter(array $rows, array $existingTeachers): array
|
||||
{
|
||||
/** @var array<string, true> $byEmail */
|
||||
$byEmail = [];
|
||||
|
||||
foreach ($existingTeachers as $teacher) {
|
||||
if (trim($teacher['email']) !== '') {
|
||||
$byEmail[mb_strtolower(trim($teacher['email']))] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
|
||||
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$normalized = mb_strtolower(trim($email));
|
||||
|
||||
if (isset($byEmail[$normalized])) {
|
||||
$row = $row->avecErreurs(new ImportRowError(
|
||||
'_duplicate',
|
||||
'Cet enseignant existe déjà (correspondance : email).',
|
||||
));
|
||||
} else {
|
||||
$byEmail[$normalized] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Orchestre la chaîne d'import d'enseignants : parse → détection → mapping → validation.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final readonly class TeacherImportOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private CsvParser $csvParser,
|
||||
private XlsxParser $xlsxParser,
|
||||
private ImportFormatDetector $formatDetector,
|
||||
private TeacherColumnMappingSuggester $mappingSuggester,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private TeacherImportBatchRepository $teacherImportBatchRepository,
|
||||
private SavedTeacherColumnMappingRepository $savedMappingRepository,
|
||||
private ExistingTeacherFinder $existingTeacherFinder,
|
||||
private TeacherDuplicateDetector $duplicateDetector,
|
||||
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: TeacherImportBatch, suggestedMapping: array<string, TeacherImportField>}
|
||||
*/
|
||||
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 = TeacherImportBatch::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->teacherImportBatchRepository->save($batch);
|
||||
|
||||
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
|
||||
*/
|
||||
public function applyMapping(TeacherImportBatch $batch, TeacherColumnMapping $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->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les lignes du batch et retourne les résultats avec les matières et classes inconnues.
|
||||
*
|
||||
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownSubjects: list<string>, unknownClasses: list<string>}
|
||||
*/
|
||||
public function generatePreview(TeacherImportBatch $batch, TenantId $tenantId): array
|
||||
{
|
||||
$existingSubjects = $this->getExistingSubjectNames($tenantId);
|
||||
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||
$validator = new TeacherImportRowValidator($existingSubjects, $existingClasses);
|
||||
|
||||
$validatedRows = $validator->validerTout($batch->lignes());
|
||||
|
||||
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
|
||||
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingTeachers);
|
||||
|
||||
$batch->enregistrerLignes($validatedRows);
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
$report = ImportReport::fromValidatedRows($validatedRows);
|
||||
$unknownSubjects = $this->detectUnknownValues($validatedRows, TeacherImportField::SUBJECTS, $existingSubjects);
|
||||
$unknownClasses = $this->detectUnknownValues($validatedRows, TeacherImportField::CLASSES, $existingClasses);
|
||||
|
||||
return [
|
||||
'validatedRows' => $validatedRows,
|
||||
'report' => $report,
|
||||
'unknownSubjects' => $unknownSubjects,
|
||||
'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.
|
||||
*
|
||||
* La détection de doublons est ré-appliquée après re-validation
|
||||
* pour ne pas perdre les erreurs _duplicate.
|
||||
*/
|
||||
public function prepareForConfirmation(
|
||||
TeacherImportBatch $batch,
|
||||
bool $createMissingSubjects,
|
||||
bool $importValidOnly,
|
||||
TenantId $tenantId,
|
||||
): void {
|
||||
if ($createMissingSubjects) {
|
||||
$validator = new TeacherImportRowValidator();
|
||||
// Strip old errors before re-validating — the previous validation
|
||||
// may have added subject/class errors that we no longer want.
|
||||
$cleanRows = array_map(
|
||||
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
|
||||
$batch->lignes(),
|
||||
);
|
||||
$revalidated = $validator->validerTout($cleanRows);
|
||||
|
||||
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
|
||||
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingTeachers);
|
||||
|
||||
$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->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, TeacherImportField>
|
||||
*/
|
||||
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 le mapping sauvegardé correspond exactement aux colonnes du fichier.
|
||||
*
|
||||
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
|
||||
* mais ne le sont pas par le mapping sauvegardé (ex: colonne « Matières » absente
|
||||
* d'un mapping sauvegardé à 3 colonnes).
|
||||
*
|
||||
* @param array<string, TeacherImportField> $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;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject saved mapping if file has more columns than the mapping covers:
|
||||
// the auto-detection might map them and the user expects to see them.
|
||||
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
if (count($autoMapping) > count($mapping)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $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 getExistingSubjectNames(TenantId $tenantId): array
|
||||
{
|
||||
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($subject) => (string) $subject->name,
|
||||
$subjects,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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> $existingValues
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function detectUnknownValues(array $rows, TeacherImportField $field, array $existingValues): array
|
||||
{
|
||||
$parser = new MultiValueParser();
|
||||
$unknown = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row->mappedData[$field->value] ?? null;
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($parser->parse($raw) as $value) {
|
||||
if (!in_array($value, $existingValues, true)
|
||||
&& !in_array($value, $unknown, true)
|
||||
) {
|
||||
$unknown[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?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\TeacherImportField;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Valide les lignes d'import enseignants après mapping.
|
||||
*
|
||||
* Vérifie les champs obligatoires (nom, prénom, email),
|
||||
* le format email, et l'existence des matières/classes référencées.
|
||||
*
|
||||
* @see AC2: Mapping spécifique enseignants
|
||||
* @see AC3: Gestion matières inexistantes
|
||||
* @see AC5: Gestion doublons email
|
||||
*/
|
||||
final readonly class TeacherImportRowValidator
|
||||
{
|
||||
private MultiValueParser $multiValueParser;
|
||||
|
||||
/**
|
||||
* @param list<string>|null $existingSubjectNames Noms des matières existantes. null = pas de vérification.
|
||||
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
|
||||
*/
|
||||
public function __construct(
|
||||
private ?array $existingSubjectNames = null,
|
||||
private ?array $existingClassNames = null,
|
||||
) {
|
||||
$this->multiValueParser = new MultiValueParser();
|
||||
}
|
||||
|
||||
public function valider(ImportRow $row): ImportRow
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
|
||||
$errors = [...$errors, ...$this->validerEmail($row)];
|
||||
$errors = [...$errors, ...$this->validerMatieres($row)];
|
||||
$errors = [...$errors, ...$this->validerClasses($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 (TeacherImportField::champsObligatoires() as $field) {
|
||||
$value = $row->mappedData[$field->value] ?? null;
|
||||
|
||||
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->mappedData[TeacherImportField::EMAIL->value] ?? null;
|
||||
|
||||
if ($email === null || trim($email) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::EMAIL->value,
|
||||
sprintf('L\'adresse email "%s" est invalide.', $email),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerMatieres(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingSubjectNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? null;
|
||||
|
||||
if ($subjectsRaw === null || trim($subjectsRaw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjects = $this->multiValueParser->parse($subjectsRaw);
|
||||
$unknown = [];
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
if (!in_array($subject, $this->existingSubjectNames, true)) {
|
||||
$unknown[] = $subject;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknown !== []) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::SUBJECTS->value,
|
||||
sprintf('Matière(s) inexistante(s) : %s', implode(', ', $unknown)),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerClasses(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingClassNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? null;
|
||||
|
||||
if ($classesRaw === null || trim($classesRaw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classes = $this->multiValueParser->parse($classesRaw);
|
||||
$unknown = [];
|
||||
|
||||
foreach ($classes as $class) {
|
||||
if (!in_array($class, $this->existingClassNames, true)) {
|
||||
$unknown[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknown !== []) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::CLASSES->value,
|
||||
sprintf('Classe(s) inexistante(s) : %s', implode(', ', $unknown)),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Exception as SpreadsheetException;
|
||||
|
||||
/**
|
||||
* Service de parsing de fichiers XLSX (Excel) via PhpSpreadsheet.
|
||||
*/
|
||||
final readonly class XlsxParser
|
||||
{
|
||||
public function parse(string $filePath): FileParseResult
|
||||
{
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
} catch (SpreadsheetException $e) {
|
||||
throw FichierImportInvalideException::formatInvalide($filePath, $e->getMessage());
|
||||
}
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$data = $sheet->toArray('', true, true, false);
|
||||
|
||||
if ($data === []) {
|
||||
throw FichierImportInvalideException::fichierVide();
|
||||
}
|
||||
|
||||
/** @var list<string|int|float|bool|null> $headerRow */
|
||||
$headerRow = array_shift($data);
|
||||
$columns = array_values(array_map(static fn (string|int|float|bool|null $v): string => (string) $v, $headerRow));
|
||||
|
||||
$rows = [];
|
||||
foreach ($data as $line) {
|
||||
/** @var list<mixed> $cells */
|
||||
$cells = $line;
|
||||
if ($this->isEmptyLine($cells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $index => $column) {
|
||||
/** @var string|int|float|bool|null $cellValue */
|
||||
$cellValue = $cells[$index] ?? '';
|
||||
$row[$column] = (string) $cellValue;
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return new FileParseResult($columns, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $line
|
||||
*/
|
||||
private function isEmptyLine(array $line): bool
|
||||
{
|
||||
foreach ($line as $cell) {
|
||||
if ($cell !== null && $cell !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\Invitation\InvitationCode;
|
||||
|
||||
use function bin2hex;
|
||||
use function random_bytes;
|
||||
|
||||
final readonly class InvitationCodeGenerator
|
||||
{
|
||||
/**
|
||||
* Generates a cryptographically secure 32-character hexadecimal invitation code.
|
||||
*/
|
||||
public function generate(): InvitationCode
|
||||
{
|
||||
$code = bin2hex(random_bytes(16));
|
||||
|
||||
return new InvitationCode($code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
use App\Administration\Domain\Exception\LogoFormatInvalideException;
|
||||
use App\Administration\Domain\Exception\LogoTropGrosException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function bin2hex;
|
||||
use function in_array;
|
||||
use function random_bytes;
|
||||
use function strpos;
|
||||
use function substr;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Service applicatif pour l'upload et le traitement des logos.
|
||||
*
|
||||
* Responsabilités :
|
||||
* - Validation du fichier (type MIME, taille)
|
||||
* - Redimensionnement via port ImageProcessor (max 200x200px)
|
||||
* - Stockage via port LogoStorage
|
||||
* - Suppression des anciens fichiers lors du remplacement
|
||||
*/
|
||||
final readonly class LogoUploader
|
||||
{
|
||||
private const int MAX_SIZE = 2 * 1024 * 1024; // 2 Mo
|
||||
private const int MAX_DIMENSION = 200;
|
||||
/** @var string[] */
|
||||
private const array ALLOWED_TYPES = ['image/png', 'image/jpeg'];
|
||||
private const string KEY_PREFIX = 'logos/';
|
||||
|
||||
public function __construct(
|
||||
private LogoStorage $storage,
|
||||
private ImageProcessor $imageProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file, TenantId $tenantId, ?LogoUrl $oldLogoUrl = null): LogoUrl
|
||||
{
|
||||
$this->validerFichier($file);
|
||||
|
||||
$content = $this->imageProcessor->resize(
|
||||
$file->getPathname(),
|
||||
self::MAX_DIMENSION,
|
||||
self::MAX_DIMENSION,
|
||||
);
|
||||
|
||||
$key = self::KEY_PREFIX . $tenantId . '/' . bin2hex(random_bytes(8)) . '.png';
|
||||
$url = $this->storage->store($content, $key, 'image/png');
|
||||
|
||||
if ($oldLogoUrl !== null) {
|
||||
$this->deleteByUrl($oldLogoUrl);
|
||||
}
|
||||
|
||||
return new LogoUrl($url);
|
||||
}
|
||||
|
||||
public function deleteByUrl(LogoUrl $logoUrl): void
|
||||
{
|
||||
$url = $logoUrl->value;
|
||||
$pos = strpos($url, self::KEY_PREFIX);
|
||||
|
||||
if ($pos !== false) {
|
||||
$this->storage->delete(substr($url, $pos));
|
||||
}
|
||||
}
|
||||
|
||||
private function validerFichier(UploadedFile $file): void
|
||||
{
|
||||
$size = $file->getSize();
|
||||
if ($size > self::MAX_SIZE) {
|
||||
throw LogoTropGrosException::pourTaille($size, self::MAX_SIZE);
|
||||
}
|
||||
|
||||
$mimeType = $file->getMimeType() ?? 'unknown';
|
||||
if (!in_array($mimeType, self::ALLOWED_TYPES, true)) {
|
||||
throw LogoFormatInvalideException::pourType($mimeType, self::ALLOWED_TYPES);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
@@ -20,6 +21,7 @@ final readonly class AffectationRetiree implements DomainEvent
|
||||
public UserId $teacherId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
38
backend/src/Administration/Domain/Event/BrandingModifie.php
Normal file
38
backend/src/Administration/Domain/Event/BrandingModifie.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la modification du branding d'un établissement.
|
||||
*/
|
||||
final readonly class BrandingModifie implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public SchoolId $schoolId,
|
||||
public TenantId $tenantId,
|
||||
public string $champ,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->schoolId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class EleveAffecteAClasse implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassAssignmentId $assignmentId,
|
||||
public UserId $studentId,
|
||||
public ClassId $classId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->assignmentId->value;
|
||||
}
|
||||
}
|
||||
36
backend/src/Administration/Domain/Event/EleveInscrit.php
Normal file
36
backend/src/Administration/Domain/Event/EleveInscrit.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class EleveInscrit implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public UserId $userId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->userId->value;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
@@ -20,6 +21,7 @@ final readonly class EnseignantAffecte implements DomainEvent
|
||||
public UserId $teacherId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'élèves échoue.
|
||||
*/
|
||||
final readonly class ImportElevesEchoue implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'élèves est lancé.
|
||||
*/
|
||||
final readonly class ImportElevesLance implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $totalRows,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'élèves est terminé avec succès.
|
||||
*/
|
||||
final readonly class ImportElevesTermine implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $importedCount,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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'enseignants échoue.
|
||||
*/
|
||||
final readonly class ImportEnseignantsEchoue implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'enseignants est lancé.
|
||||
*/
|
||||
final readonly class ImportEnseignantsLance implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $totalRows,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'enseignants est terminé avec succès.
|
||||
*/
|
||||
final readonly class ImportEnseignantsTermine implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $importedCount,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class InvitationParentActivee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ParentInvitationId $invitationId,
|
||||
public UserId $studentId,
|
||||
public UserId $parentUserId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->invitationId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class InvitationParentEnvoyee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ParentInvitationId $invitationId,
|
||||
public UserId $studentId,
|
||||
public Email $parentEmail,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->invitationId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class AffectationEleveNonTrouveeException extends DomainException
|
||||
{
|
||||
public static function pourEleve(UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class BrandColorInvalideException extends DomainException
|
||||
{
|
||||
public static function pourFormat(string $value): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ContrasteInsuffisantException extends DomainException
|
||||
{
|
||||
public static function pourRatio(float $ratio, float $minimum): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le contraste de %.1f:1 est insuffisant. Le minimum requis pour la conformité WCAG AA est de %.1f:1. Choisissez une couleur plus foncée.',
|
||||
$ratio,
|
||||
$minimum,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class EleveDejaAffecteException extends DomainException
|
||||
{
|
||||
public static function pourAnneeScolaire(UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'élève "%s" est déjà affecté à une classe pour cette année scolaire.',
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class FichierImportInvalideException extends DomainException
|
||||
{
|
||||
public static function fichierIllisible(string $path): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le fichier "%s" ne peut pas être lu.',
|
||||
$path,
|
||||
));
|
||||
}
|
||||
|
||||
public static function fichierVide(): self
|
||||
{
|
||||
return new self('Le fichier est vide ou ne contient aucune donnée.');
|
||||
}
|
||||
|
||||
public static function formatInvalide(string $path, string $reason): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le fichier "%s" a un format invalide : %s',
|
||||
$path,
|
||||
$reason,
|
||||
));
|
||||
}
|
||||
|
||||
public static function fichierTropGros(int $sizeBytes, int $maxBytes): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le fichier fait %d Mo mais la limite est de %d Mo.',
|
||||
(int) ($sizeBytes / 1024 / 1024),
|
||||
(int) ($maxBytes / 1024 / 1024),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ImportBatchNotFoundException extends DomainException
|
||||
{
|
||||
public static function withId(ImportBatchId $id): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le lot d\'import "%s" n\'existe pas.',
|
||||
$id,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ImportDejaEnCoursException extends DomainException
|
||||
{
|
||||
public static function pourBatch(ImportBatchId $batchId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'import "%s" est déjà en cours de traitement.',
|
||||
$batchId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ImportNonDemarrableException extends DomainException
|
||||
{
|
||||
public static function pourStatut(ImportBatchId $batchId, ImportStatus $status): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'import "%s" ne peut pas être démarré depuis le statut "%s".',
|
||||
$batchId,
|
||||
$status->value,
|
||||
));
|
||||
}
|
||||
|
||||
public static function mappingManquant(ImportBatchId $batchId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'import "%s" ne peut pas être démarré sans mapping de colonnes.',
|
||||
$batchId,
|
||||
));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user