Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b722bdd17 | ||
| 9038d1726a | |||
| bde59bf9ee | |||
| 097eb39cb0 | |||
| 4cdff1200f | |||
| d5b372e243 | |||
| 2e72f93f29 |
@@ -17,6 +17,7 @@
|
||||
"malio/ednotif-bundle": ">=0.0.6",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
|
||||
505
composer.lock
generated
505
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": "fd62fc3833815b11aa058fd2759c1c79",
|
||||
"content-hash": "34e7a0613ff6f36fd7375247ccf752d9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -1156,6 +1156,85 @@
|
||||
},
|
||||
"time": "2026-01-16T13:22:15+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",
|
||||
@@ -2704,6 +2783,84 @@
|
||||
],
|
||||
"time": "2025-12-20T17:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"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.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "malio/ednotif-bundle",
|
||||
"version": "v0.0.6",
|
||||
@@ -2746,6 +2903,113 @@
|
||||
"description": "Client EDNOTIF (Guichet + wsIpBNotif) pour Symfony",
|
||||
"time": "2026-04-21T08:14:37+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": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
@@ -3156,6 +3420,115 @@
|
||||
},
|
||||
"time": "2025-11-21T15:09:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||
"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.7.0"
|
||||
},
|
||||
"time": "2026-04-20T02:42:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.1",
|
||||
@@ -3509,6 +3882,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": "sabberworm/php-css-parser",
|
||||
"version": "v9.1.0",
|
||||
@@ -8393,85 +8817,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",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.88'
|
||||
app.version: '0.0.89'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Export Excel de l'inventaire bovin — Design Spec
|
||||
|
||||
Bouton sur la page `/inventory` qui télécharge un XLSX listant tous les bovins actuellement présents sur l'exploitation.
|
||||
|
||||
## Contexte
|
||||
|
||||
Le métier veut un Excel exportable depuis l'écran inventaire. Ferme n'a aujourd'hui aucun outil d'export Excel (uniquement PDF via dompdf). On choisit `phpoffice/phpspreadsheet` côté serveur, en suivant le même pattern que la génération PDF actuelle (endpoint qui streame le fichier, front qui télécharge via blob).
|
||||
|
||||
Périmètre : tous les bovins actifs (`exitedAt IS NULL`), ordre `birthDate ASC`, ignore les filtres UI. Pas de modale de sélection (à voir si le métier en demande une plus tard).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
**Dépendance** : `composer require phpoffice/phpspreadsheet`
|
||||
|
||||
**Nouveau resource** : `src/ApiResource/BovineInventoryExport.php`
|
||||
- `#[ApiResource]` avec une seule opération `Get` :
|
||||
- `uriTemplate: '/bovines/inventory-export'`
|
||||
- `output: false`
|
||||
- `provider: BovineInventoryExportProvider::class`
|
||||
- `security: "is_granted('ROLE_USER')"` (cohérent avec la page `/inventory`)
|
||||
- OpenApi tag `Bovines`
|
||||
|
||||
**Nouveau provider** : `src/State/Bovin/BovineInventoryExportProvider.php`
|
||||
- Injecte `EntityManagerInterface`
|
||||
- Query Doctrine : `WHERE exitedAt IS NULL ORDER BY birthDate ASC`
|
||||
- Construit le `Spreadsheet` avec PhpSpreadsheet
|
||||
- Retourne une `Symfony\Component\HttpFoundation\Response` avec :
|
||||
- `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- `Content-Disposition: attachment; filename="inventaire_bovins_YYYY-MM-DD.xlsx"`
|
||||
- Body = `IOFactory::createWriter($spreadsheet, 'Xlsx')->save('php://output')` capturé via `ob_*`
|
||||
|
||||
### Frontend
|
||||
|
||||
**Page** : `frontend/pages/inventory.vue`
|
||||
- Nouveau bouton "Exporter Excel" à droite du titre, à côté de "Rafraîchir"
|
||||
- Style : même que "Rafraîchir" (bg-primary-500, h-[50px], icône `mdi:file-excel-outline`)
|
||||
- Visible pour tout user authentifié (pas de gate admin)
|
||||
- Au clic : appelle `useApi().getBlob('bovines/inventory-export')`, crée un blob URL, déclenche un `<a download>` synthétique avec le filename retourné par le backend (lu depuis le header `Content-Disposition`)
|
||||
|
||||
## Génération XLSX — détails
|
||||
|
||||
**Fichier** :
|
||||
- 1 seule feuille `Inventaire`
|
||||
- Filename : `inventaire_bovins_YYYY-MM-DD.xlsx` (date du jour serveur)
|
||||
|
||||
**En-têtes (ligne 1)** :
|
||||
- 9 colonnes dans l'ordre : `N° National`, `N° Travail`, `Sexe`, `Né le`, `Age (mois)`, `Race`, `Bâtiment`, `Case`, `Entrée le`
|
||||
- Style : gras, fond `#f1f5f9` (slate-100), bordure noire fine, alignement centré
|
||||
- Auto-filter activé sur la plage des en-têtes (Excel ajoute les boutons de filtre natifs)
|
||||
- Freeze pane : ligne 2 figée
|
||||
|
||||
**Lignes de données (à partir de la ligne 2)** :
|
||||
- Ordre `birthDate ASC` (plus vieux en haut, NULL à la fin via `NULLS LAST` natif Postgres)
|
||||
- Largeurs de colonnes :
|
||||
- N° National : 18
|
||||
- N° Travail : 12
|
||||
- Sexe : 10
|
||||
- Né le : 12
|
||||
- Age : 12
|
||||
- Race : 12
|
||||
- Bâtiment : 30
|
||||
- Case : 8
|
||||
- Entrée le : 12
|
||||
|
||||
**Mapping des valeurs** :
|
||||
- Sexe : `M` → `Mâle`, `F` → `Femelle`, autre / null → vide
|
||||
- Né le, Entrée le : format `JJ/MM/AAAA`, vide si null
|
||||
- Age : entier (mois), vide si null
|
||||
- Bâtiment, Case : valeurs nestées via `bovine.buildingCase.building.label` et `bovine.buildingCase.caseNumber`, vide si null
|
||||
|
||||
**Couleurs des lignes** (basées sur `ageMonths`, mêmes seuils que l'UI) :
|
||||
| Tranche | Hex | Tailwind |
|
||||
|--------|-----|----------|
|
||||
| 24+ mois | `#ddd6fe` | violet-200 |
|
||||
| 22-24 mois | `#fecaca` | red-200 |
|
||||
| 20-22 mois | `#fed7aa` | orange-200 |
|
||||
| < 20 mois ou NULL | `#ffffff` | blanc |
|
||||
|
||||
Le fond est appliqué sur toute la ligne (9 cellules).
|
||||
|
||||
## Flux d'erreur
|
||||
|
||||
- Exception PhpSpreadsheet (création buffer) → propage en 500 standard API Platform
|
||||
- Pas d'utilisateur (token expiré) → 401 standard via la sécurité
|
||||
|
||||
## Performance
|
||||
|
||||
- 936 lignes × 9 colonnes : génération en mémoire < 1s, fichier < 100 KB
|
||||
- Pas de pagination, pas de streaming row-by-row (overkill pour ce volume)
|
||||
|
||||
## Tests
|
||||
|
||||
Optionnel ce lot : test PHPUnit du provider qui vérifie que :
|
||||
- Status 200
|
||||
- Content-Type XLSX
|
||||
- Header `Content-Disposition: attachment; filename=...xlsx`
|
||||
- Body non vide
|
||||
Mock simple de l'`EntityManagerInterface` pour retourner 2 bovins fictifs.
|
||||
|
||||
À faire en follow-up si on veut couvrir.
|
||||
|
||||
## Verification manuelle
|
||||
|
||||
1. `make composer-install` (après avoir ajouté la dep)
|
||||
2. Recharger `/inventory`
|
||||
3. Clic sur le bouton "Exporter Excel"
|
||||
4. Vérifier le téléchargement : nom de fichier = `inventaire_bovins_2026-04-24.xlsx`
|
||||
5. Ouvrir dans Excel/LibreOffice :
|
||||
- 9 colonnes attendues
|
||||
- En-tête figé en scrollant
|
||||
- Auto-filter natif Excel
|
||||
- Lignes colorées selon âge (violet/rouge/orange)
|
||||
- Tri par date de naissance croissante
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] L'export contient 100 % des bovins actifs (count = `SELECT COUNT(*) FROM bovine WHERE exited_at IS NULL`)
|
||||
- [ ] Le filename inclut la date du jour
|
||||
- [ ] Les couleurs correspondent aux seuils d'âge
|
||||
- [ ] L'ordre matche l'UI (`birthDate ASC`)
|
||||
- [ ] Pas de régression sur les autres endpoints `/api/bovines`
|
||||
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/bovin"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -22,20 +31,9 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/bovin"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,11 +64,6 @@ const goToBovin = (bovin: BovineTypeData) => {
|
||||
router.push(`/admin/bovin/${bovin.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
||||
<NuxtLink
|
||||
to="/admin/carrier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 mb-11">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -22,15 +30,6 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/carrier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/customer"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -28,20 +37,9 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/customer"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,11 +74,6 @@ const goToCustomer = (customer: CustomerData) => {
|
||||
router.push(`/admin/customer/${customer.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/supplier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -28,20 +37,9 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/supplier"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,11 +74,6 @@ const goToSupplier = (supplier: SupplierData) => {
|
||||
router.push(`/admin/supplier/${supplier.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/user"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -47,20 +56,9 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/user"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,11 +102,6 @@ const goToUser = (user: UserData) => {
|
||||
router.push(`/admin/user/${user.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- En-tête de page avec retour et titre -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-10">
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px] space-y-6">
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||
<div
|
||||
v-for="entry in buildingLayouts"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<h1 class="font-bold text-4xl text-primary-500 uppercase">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<div
|
||||
v-if="hasCaseId"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
|
||||
@@ -40,6 +41,7 @@
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:row-class="rowClass"
|
||||
:row-clickable="auth.isAdmin"
|
||||
empty-message="Aucun bovin dans cette case."
|
||||
@row-click="goToBovine"
|
||||
@@ -47,25 +49,61 @@
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="Numéro national"
|
||||
placeholder="N° National"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receivedWeight>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.receivedWeight"
|
||||
placeholder="Poids (kg)"
|
||||
v-model="filters.workNumber"
|
||||
placeholder="N° Travail"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiSelect
|
||||
v-model="filters.sex"
|
||||
placeholder="Sexe"
|
||||
:options="sexOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||
</template>
|
||||
<template #header-age>
|
||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-breedCode>
|
||||
<UiTextInput
|
||||
v-model="filters.breedCode"
|
||||
placeholder="Race"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-buildingCase.building.label>
|
||||
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-buildingCase.caseNumber>
|
||||
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Date d'arrivée" size="compact" />
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-age="{ item }">
|
||||
{{ formatAgeLabel(item.ageMonths) }}
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-receivedWeight="{ item }">
|
||||
{{ item.receivedWeight ?? '—' }}
|
||||
<template #cell-buildingCase.building.label="{ item }">
|
||||
{{ item.buildingCase?.building?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-buildingCase.caseNumber="{ item }">
|
||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
@@ -77,6 +115,7 @@ import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { formatAgeLabel } from '~/utils/bovine-age'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -93,38 +132,58 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
buildingCase: '',
|
||||
nationalNumber: '',
|
||||
receivedWeight: '',
|
||||
workNumber: '',
|
||||
breedCode: '',
|
||||
sex: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
'arrivalDate[strictly_before]': '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const sexOptions = [
|
||||
{ value: 'M', label: 'Mâle' },
|
||||
{ value: 'F', label: 'Femelle' }
|
||||
]
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (filters.value['arrivalDate[after]'] as string) ?? '',
|
||||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||
computed<string>({
|
||||
get: () => (filters.value[afterKey] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['arrivalDate[after]'] = ''
|
||||
filters.value['arrivalDate[strictly_before]'] = ''
|
||||
filters.value[afterKey] = ''
|
||||
filters.value[beforeKey] = ''
|
||||
return
|
||||
}
|
||||
filters.value['arrivalDate[after]'] = value
|
||||
filters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
filters.value[afterKey] = value
|
||||
filters.value[beforeKey] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||
|
||||
const columns = [
|
||||
{ key: 'nationalNumber', label: 'Numéro national' },
|
||||
{ key: 'receivedWeight', label: "Poids à l'arrivée (kg)" },
|
||||
{ key: 'arrivalDate', label: "Date d'arrivée" }
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '120px' },
|
||||
{ key: 'age', label: 'Age', width: '110px' },
|
||||
{ key: 'breedCode', label: 'Race' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
|
||||
]
|
||||
|
||||
const title = computed(() => {
|
||||
@@ -150,6 +209,14 @@ const formatDate = (date: string | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
const rowClass = (item: BovineData): string => {
|
||||
if (item.ageMonths === null || item.ageMonths === undefined) return ''
|
||||
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
|
||||
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
|
||||
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
|
||||
return ''
|
||||
}
|
||||
|
||||
const loadCase = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
buildingCase.value = null
|
||||
|
||||
@@ -9,7 +9,18 @@
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<div
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||
title="Exporter en Excel"
|
||||
@click="exportInventory"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="auth.isAdmin"
|
||||
type="button"
|
||||
@@ -22,7 +33,22 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-16">
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-3 rounded-md border-2 border-violet-300 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-violet-700">{{ stats.over24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-violet-700">≥ 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md border-2 border-red-300 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-red-700">{{ stats.between22And24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-red-700">22 – 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md border-2 border-orange-300 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-orange-700">{{ stats.between20And22 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-orange-700">20 – 22 mois</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-8">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@@ -115,7 +141,52 @@ interface SyncResult {
|
||||
total: number
|
||||
}
|
||||
|
||||
interface InventoryStats {
|
||||
total: number
|
||||
over24: number
|
||||
between22And24: number
|
||||
between20And22: number
|
||||
}
|
||||
|
||||
const stats = ref<InventoryStats>({
|
||||
total: 0,
|
||||
over24: 0,
|
||||
between22And24: 0,
|
||||
between20And22: 0
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {}, { toast: false })
|
||||
} catch {
|
||||
// silencieux : l'écran reste utilisable sans la légende
|
||||
}
|
||||
}
|
||||
|
||||
const syncing = ref(false)
|
||||
const exporting = ref(false)
|
||||
|
||||
const exportInventory = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.getBlob('bovines/inventory-export')
|
||||
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const syncInventory = async () => {
|
||||
if (syncing.value) return
|
||||
@@ -132,6 +203,7 @@ const syncInventory = async () => {
|
||||
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||||
})
|
||||
reload()
|
||||
loadStats()
|
||||
} catch {
|
||||
// error toast already handled by useApi onResponseError
|
||||
} finally {
|
||||
@@ -208,10 +280,14 @@ const formatDate = (date: string | null) => {
|
||||
|
||||
const rowClass = (item: BovineData): string => {
|
||||
if (item.ageMonths === null || item.ageMonths === undefined) return ''
|
||||
if (item.ageMonths >= 24) return 'bg-red-100 hover:bg-red-200'
|
||||
if (item.ageMonths >= 22) return 'bg-orange-100 hover:bg-orange-200'
|
||||
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
|
||||
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
|
||||
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
onMounted(() => {
|
||||
reload()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
32
src/ApiResource/BovineInventoryExport.php
Normal file
32
src/ApiResource/BovineInventoryExport.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovineInventoryExportProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovines/inventory-export',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: "Export Excel de l'inventaire bovin actuel.",
|
||||
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
|
||||
tags: ['Bovines'],
|
||||
),
|
||||
security: "is_granted('ROLE_USER')",
|
||||
output: false,
|
||||
provider: BovineInventoryExportProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovineInventoryExport
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
}
|
||||
39
src/ApiResource/BovineInventoryStats.php
Normal file
39
src/ApiResource/BovineInventoryStats.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovineInventoryStatsProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovines/inventory-stats',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: "Compteurs de l'inventaire bovin par tranche d'âge.",
|
||||
description: "Renvoie le nombre total de bovins actifs et la répartition par tranche d'âge (>= 24 mois, 22-24, 20-22).",
|
||||
tags: ['Bovines'],
|
||||
),
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: BovineInventoryStatsProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovineInventoryStats
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
|
||||
public int $total = 0;
|
||||
|
||||
public int $over24 = 0;
|
||||
|
||||
public int $between22And24 = 0;
|
||||
|
||||
public int $between20And22 = 0;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||
#[ApiResource(
|
||||
order: ['birthDate' => 'ASC'],
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
|
||||
206
src/State/Bovin/BovineInventoryExportProvider.php
Normal file
206
src/State/Bovin/BovineInventoryExportProvider.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Bovin;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Bovine;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Response>
|
||||
*/
|
||||
final class BovineInventoryExportProvider implements ProviderInterface
|
||||
{
|
||||
private const HEADER_FILL = 'FFF1F5F9';
|
||||
|
||||
private const COLOR_VIOLET = 'FFC4B5FD';
|
||||
|
||||
private const COLOR_RED = 'FFFCA5A5';
|
||||
|
||||
private const COLOR_ORANGE = 'FFFDBA74';
|
||||
|
||||
private const HEADERS = [
|
||||
'N° National',
|
||||
'N° Travail',
|
||||
'Sexe',
|
||||
'Né le',
|
||||
'Age (mois)',
|
||||
'Race',
|
||||
'Bâtiment',
|
||||
'Case',
|
||||
'Entrée le',
|
||||
];
|
||||
|
||||
private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$bovines = $this->em->createQueryBuilder()
|
||||
->select('b')
|
||||
->from(Bovine::class, 'b')
|
||||
->where('b.exitedAt IS NULL')
|
||||
->orderBy('b.birthDate', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$spreadsheet = $this->buildSpreadsheet($bovines);
|
||||
$body = $this->renderXlsx($spreadsheet);
|
||||
$filename = sprintf('inventaire_bovins_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
|
||||
|
||||
$response = new Response($body);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
$response->headers->set('Content-Length', (string) strlen($body));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Bovine> $bovines
|
||||
*/
|
||||
private function buildSpreadsheet(array $bovines): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Inventaire');
|
||||
|
||||
// Header row
|
||||
foreach (self::HEADERS as $index => $label) {
|
||||
$sheet->setCellValue([$index + 1, 1], $label);
|
||||
}
|
||||
|
||||
$lastColumn = $sheet->getHighestColumn();
|
||||
$headerRange = sprintf('A1:%s1', $lastColumn);
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => self::HEADER_FILL],
|
||||
],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||
],
|
||||
]);
|
||||
|
||||
// Column widths
|
||||
foreach (self::COLUMN_WIDTHS as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
// N° National et N° Travail : valeurs numériques mais conservées en string
|
||||
// (leading zeros, format métier) — on force le format texte pour éviter
|
||||
// l'avertissement Excel "nombre stocké sous forme de texte".
|
||||
$sheet->getStyle('A')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
|
||||
$sheet->getStyle('B')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
|
||||
|
||||
// Data rows
|
||||
$rowNumber = 2;
|
||||
foreach ($bovines as $bovine) {
|
||||
$values = $this->formatRow($bovine);
|
||||
foreach ($values as $colIndex => $value) {
|
||||
$sheet->setCellValue([$colIndex + 1, $rowNumber], $value);
|
||||
}
|
||||
|
||||
$color = $this->ageColor($bovine->getAgeMonths());
|
||||
if (null !== $color) {
|
||||
$rowRange = sprintf('A%d:%s%d', $rowNumber, $lastColumn, $rowNumber);
|
||||
$sheet->getStyle($rowRange)->applyFromArray([
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => $color],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
++$rowNumber;
|
||||
}
|
||||
|
||||
// Freeze header row + auto-filter
|
||||
$sheet->freezePane('A2');
|
||||
if ($rowNumber > 2) {
|
||||
$sheet->setAutoFilter(sprintf('%s:%s%d', 'A1', $lastColumn, $rowNumber - 1));
|
||||
} else {
|
||||
$sheet->setAutoFilter($headerRange);
|
||||
}
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<null|int|string>
|
||||
*/
|
||||
private function formatRow(Bovine $bovine): array
|
||||
{
|
||||
return [
|
||||
$bovine->getNationalNumber(),
|
||||
$bovine->getWorkNumber(),
|
||||
$this->formatSex($bovine->getSex()),
|
||||
$this->formatDate($bovine->getBirthDate()),
|
||||
$bovine->getAgeMonths(),
|
||||
$bovine->getBreedCode(),
|
||||
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
|
||||
$bovine->getBuildingCase()?->getCaseNumber(),
|
||||
$this->formatDate($bovine->getArrivalDate()),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatSex(?string $sex): ?string
|
||||
{
|
||||
return match ($sex) {
|
||||
'M' => 'Mâle',
|
||||
'F' => 'Femelle',
|
||||
default => $sex,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatDate(?DateTimeImmutable $date): ?string
|
||||
{
|
||||
return $date?->format('d/m/Y');
|
||||
}
|
||||
|
||||
private function ageColor(?int $ageMonths): ?string
|
||||
{
|
||||
if (null === $ageMonths) {
|
||||
return null;
|
||||
}
|
||||
if ($ageMonths >= 24) {
|
||||
return self::COLOR_VIOLET;
|
||||
}
|
||||
if ($ageMonths >= 22) {
|
||||
return self::COLOR_RED;
|
||||
}
|
||||
if ($ageMonths >= 20) {
|
||||
return self::COLOR_ORANGE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function renderXlsx(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||
ob_start();
|
||||
$writer->save('php://output');
|
||||
$body = ob_get_clean();
|
||||
|
||||
return false !== $body ? $body : '';
|
||||
}
|
||||
}
|
||||
41
src/State/Bovin/BovineInventoryStatsProvider.php
Normal file
41
src/State/Bovin/BovineInventoryStatsProvider.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Bovin;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BovineInventoryStats;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<BovineInventoryStats>
|
||||
*/
|
||||
final class BovineInventoryStatsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BovineInventoryStats
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(<<<'SQL'
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE age_months >= 24) AS over_24,
|
||||
COUNT(*) FILTER (WHERE age_months >= 22 AND age_months < 24) AS between_22_and_24,
|
||||
COUNT(*) FILTER (WHERE age_months >= 20 AND age_months < 22) AS between_20_and_22
|
||||
FROM bovine
|
||||
WHERE exited_at IS NULL
|
||||
SQL);
|
||||
|
||||
$stats = new BovineInventoryStats();
|
||||
$stats->total = (int) ($row['total'] ?? 0);
|
||||
$stats->over24 = (int) ($row['over_24'] ?? 0);
|
||||
$stats->between22And24 = (int) ($row['between_22_and_24'] ?? 0);
|
||||
$stats->between20And22 = (int) ($row['between_20_and_22'] ?? 0);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user