Compare commits

..

9 Commits

Author SHA1 Message Date
gitea-actions
7b722bdd17 chore: bump version to v0.0.89
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m30s
2026-04-24 13:31:09 +00:00
9038d1726a feat : export Excel et stats par tranche d'âge sur l'inventaire bovin
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Dépendance phpoffice/phpspreadsheet
- Endpoint GET /bovines/inventory-export : XLSX coloré, header figé, auto-filter, tri birthDate ASC
- Endpoint GET /bovines/inventory-stats : comptes par tranche d'âge (>=24, 22-24, 20-22)
- Bouton Excel à gauche du titre (style icône-only, même design que le bouton impression)
- Légende visuelle avec cartes bordées coloriées
- Ajustement seuils couleurs des lignes en -300 (base) / -400 (hover)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:19:57 +02:00
bde59bf9ee docs : spec export Excel inventaire bovin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:23:26 +02:00
097eb39cb0 feat : tri par défaut des bovins par date de naissance (plus vieux en premier)
birthDate ASC sur l'ApiResource Bovine, impacte automatiquement les pages
inventaire et case. Choix ASC plutôt que ageMonths DESC pour bénéficier du
NULLS LAST natif de Postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:14:54 +02:00
4cdff1200f feat : affichage du compteur de bovins et ajustement des seuils de couleur
- Ajout (X bovins) à côté du titre sur les pages inventaire et case
- Seuils par âge : 20-22 mois orange, 22-24 mois rouge, 24+ violet
- Couleurs renforcées en -200 / hover -300

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:08:19 +02:00
d5b372e243 feat : bouton Ajouter sur les pages admin en haut à droite
Alignement sur le pattern case.vue : titre et bouton Ajouter sur la même ligne,
wrapper px-[86px] commun, suppression du bouton bottom-centered

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:00:20 +02:00
2e72f93f29 feat : harmonisation des pages infrastructure avec le pattern UiDataTable
- Page case : mêmes colonnes, filtres et alertes âge que la page inventaire
- Page building : header aligné sur le pattern case.vue (wrapper px-86, arrow en absolute)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:57:25 +02:00
gitea-actions
79077c7bbd chore: bump version to v0.0.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
2026-04-24 07:53:13 +00:00
f05fcc5c15 feat: inventaire bovins (!49)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-24 07:53:06 +00:00
33 changed files with 1946 additions and 362 deletions

View File

@@ -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
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "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",

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.87'
app.version: '0.0.89'

View File

@@ -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`

View File

@@ -19,7 +19,10 @@
v-for="(item, index) in paginatedItems"
:key="item.id ?? index"
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
:class="rowClickable ? 'hover:bg-slate-50 cursor-pointer' : ''"
:class="[
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
rowClass ? rowClass(item) : ''
]"
:style="{ gridTemplateColumns: gridCols }"
:role="rowClickable ? 'button' : undefined"
:tabindex="rowClickable ? 0 : undefined"
@@ -83,12 +86,12 @@
<nav aria-label="Pagination" class="flex items-center gap-1">
<button
type="button"
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage <= 1"
aria-label="Page précédente"
@click="goToPage(currentPage - 1)"
>
Prev
Précédent
</button>
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
@@ -113,12 +116,12 @@
<button
type="button"
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage >= totalPages"
aria-label="Page suivante"
@click="goToPage(currentPage + 1)"
>
Next
Suivant
</button>
</nav>
</div>
@@ -145,6 +148,7 @@ const props = withDefaults(defineProps<{
showActions?: boolean
emptyMessage?: string
loading?: boolean
rowClass?: (item: T) => string | undefined
}>(), {
totalItems: undefined,
page: 1,
@@ -153,7 +157,8 @@ const props = withDefaults(defineProps<{
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',
loading: false
loading: false,
rowClass: undefined
})
const emit = defineEmits<{

View File

@@ -0,0 +1,108 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
v-maska="'##/##/####'"
type="text"
inputmode="numeric"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
:class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
import { vMaska } from 'maska/vue'
import { computed, ref, useAttrs, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
label?: string
modelValue: string | null | undefined
placeholder?: string
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
placeholder: 'JJ/MM/AAAA',
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const toDisplay = (iso: string | null | undefined): string => {
if (!iso) return ''
const parts = iso.split('-')
if (parts.length !== 3) return ''
const [year, month, day] = parts
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
return `${day}/${month}/${year}`
}
const toIso = (display: string): string | null => {
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return null
const [, day, month, year] = match
return `${year}-${month}-${day}`
}
const displayValue = ref(toDisplay(props.modelValue))
watch(() => props.modelValue, (newIso) => {
const expected = toDisplay(newIso)
if (expected !== displayValue.value) {
displayValue.value = expected
}
})
const isEmpty = computed(() => !displayValue.value)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
displayValue.value = target.value
if (target.value === '') {
emit('update:modelValue', '')
return
}
const iso = toIso(target.value)
emit('update:modelValue', iso ?? '')
}
</script>

View File

@@ -1,41 +1,39 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
</div>
<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">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToBovin"
>
<template #header-label>
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 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 v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToBovin"
>
<template #header-label>
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
<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>
</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()
})

View File

@@ -1,36 +1,35 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
</div>
<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">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCarrier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</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 class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCarrier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
</div>
</template>

View File

@@ -1,47 +1,45 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
</div>
<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">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCustomer"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 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 v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCustomer"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<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>
</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()
})

View File

@@ -1,47 +1,45 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
</div>
<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">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToSupplier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 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 v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToSupplier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<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>
</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()
})

View File

@@ -1,66 +1,64 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
</div>
<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">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToUser"
>
<template #header-username>
<UiTextInput
v-model="filters.username"
placeholder="Utilisateur"
size="compact"
/>
</template>
<template #header-roles>
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
</template>
<template #header-isLocked>
<UiSelect
v-model="filters.isLocked"
placeholder="Statut"
:options="statusOptions"
size="compact"
/>
</template>
<template #cell-roles="{ item }">
{{ getRoleLabels(item.roles) }}
</template>
<template #cell-isLocked="{ item }">
<span
v-if="item.isLocked"
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
>Actif</span>
</template>
</UiDataTable>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 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 v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToUser"
>
<template #header-username>
<UiTextInput
v-model="filters.username"
placeholder="Utilisateur"
size="compact"
/>
</template>
<template #header-roles>
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
</template>
<template #header-isLocked>
<UiSelect
v-model="filters.isLocked"
placeholder="Statut"
:options="statusOptions"
size="compact"
/>
</template>
<template #cell-roles="{ item }">
{{ getRoleLabels(item.roles) }}
</template>
<template #cell-isLocked="{ item }">
<span
v-if="item.isLocked"
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
>Actif</span>
</template>
</UiDataTable>
</div>
<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>
</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()
})

View File

@@ -18,9 +18,9 @@
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/" iconName="mdi:cow">
<card-link link="/inventory" iconName="mdi:cow">
<template #label>
PASSEPORT<br>DU BOVIN
INVENTAIRE<br>BOVINS
</template>
</card-link>
</div>

View File

@@ -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"
/>
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
</div>
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
</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"

View File

@@ -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>
<UiDateInput v-model="arrivalDateFilter" 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) ?? '',
set: (value: string) => {
if (!value) {
filters.value['arrivalDate[after]'] = ''
filters.value['arrivalDate[strictly_before]'] = ''
return
const singleDateFilter = (afterKey: string, beforeKey: string) =>
computed<string>({
get: () => (filters.value[afterKey] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value[afterKey] = ''
filters.value[beforeKey] = ''
return
}
filters.value[afterKey] = value
filters.value[beforeKey] = addOneDay(value)
}
filters.value['arrivalDate[after]'] = value
filters.value['arrivalDate[strictly_before]'] = 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

View File

@@ -0,0 +1,293 @@
<template>
<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>
<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"
:disabled="syncing"
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 disabled:cursor-not-allowed disabled:opacity-60"
@click="syncInventory"
>
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
Rafraîchir
</button>
</div>
<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"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
>
<template #header-nationalNumber>
<UiTextInput
v-model="filters.nationalNumber"
placeholder="N° National"
size="compact"
/>
</template>
<template #header-workNumber>
<UiTextInput
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-breedCode>
<UiTextInput
v-model="filters.breedCode"
placeholder="Race"
size="compact"
/>
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</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-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</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-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts">
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 router = useRouter()
const auth = useAuthStore()
const api = useApi()
const toast = useToast()
interface SyncResult {
created: number
updated: number
exited: number
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
const confirmed = window.confirm(
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
)
if (!confirmed) return
syncing.value = true
try {
const result = await api.post<SyncResult>('bovines/sync-inventory')
toast.success({
title: 'Inventaire synchronisé',
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 {
syncing.value = false
}
}
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
{
'exists[exitedAt]': 'false',
nationalNumber: '',
workNumber: '',
breedCode: '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
'birthDate[after]': '',
'birthDate[strictly_before]': ''
}
)
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 singleDateFilter = (afterKey: string, beforeKey: string) =>
computed<string>({
get: () => (filters.value[afterKey] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value[afterKey] = ''
filters.value[beforeKey] = ''
return
}
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: '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 formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
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 ''
}
onMounted(() => {
reload()
loadStats()
})
</script>

View File

@@ -24,8 +24,9 @@
/>
</template>
<template #header-receptionDate>
<UiDateInput
<UiDateMaskedInput
v-model="receptionDateFilter"
placeholder="Date"
size="compact"
/>
</template>
@@ -119,7 +120,7 @@ const receptionDateFilter = computed<string>({
const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
{ key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },

View File

@@ -18,7 +18,7 @@
@row-click="goToReception"
>
<template #header-receptionDate>
<UiDateInput v-model="receptionDateFilter" size="compact" />
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput
@@ -122,7 +122,7 @@ const receptionDateFilter = computed<string>({
})
const columns = [
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
{ key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },

View File

@@ -24,7 +24,7 @@
/>
</template>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" size="compact" />
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput

View File

@@ -18,7 +18,7 @@
@row-click="goToShipment"
>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" size="compact" />
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput
@@ -134,7 +134,7 @@ const shipmentDateFilter = computed<string>({
})
const columns = [
{ key: 'shipmentDate', label: 'Date et heure', width: '120px' },
{ key: 'shipmentDate', label: 'Date', width: '120px' },
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },

View File

@@ -1,10 +1,22 @@
export interface BovineBuildingCaseRef {
caseNumber: number | null
building: { label: string } | null
}
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
breedCode: string | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
}
export type BovinePayload = {

View File

@@ -0,0 +1,10 @@
export const formatAgeLabel = (months: number | null | undefined): string => {
if (months === null || months === undefined) return '—'
const years = Math.floor(months / 12)
const remaining = months % 12
let label = ''
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
if (!label) label = '< 1 mois'
return label
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260422155300 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD sex VARCHAR(1) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD exited_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP sex');
$this->addSql('ALTER TABLE bovine DROP exited_at');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260423062250 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD exit_date DATE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP exit_date');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260424074454 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD age_months INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP age_months');
}
}

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

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

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovineSyncInventoryProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/bovines/sync-inventory',
openapi: new OpenApiOperation(
summary: "Synchronise l'inventaire bovin local avec EDNOTIF.",
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,
read: false,
),
]
)]
final class BovineSyncInventoryResult
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $created = 0;
public int $updated = 0;
public int $exited = 0;
public int $total = 0;
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
@@ -20,15 +22,21 @@ use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate'])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiResource(
order: ['birthDate' => 'ASC'],
operations: [
new Get(
requirements: ['id' => '\d+'],
@@ -76,6 +84,7 @@ class Bovine
#[ORM\ManyToOne(inversedBy: 'bovines')]
#[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
@@ -95,6 +104,24 @@ class Bovine
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $sex = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?int $ageMonths = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitDate = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -195,4 +222,66 @@ class Bovine
return $this;
}
public function getSex(): ?string
{
return $this->sex;
}
public function setSex(?string $sex): static
{
$this->sex = $sex;
return $this;
}
public function getExitDate(): ?DateTimeImmutable
{
return $this->exitDate;
}
public function setExitDate(?DateTimeImmutable $exitDate): static
{
$this->exitDate = $exitDate;
return $this;
}
public function getExitedAt(): ?DateTimeImmutable
{
return $this->exitedAt;
}
public function setExitedAt(?DateTimeImmutable $exitedAt): static
{
$this->exitedAt = $exitedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;
}
public function setAgeMonths(?int $ageMonths): static
{
$this->ageMonths = $ageMonths;
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void
{
if (null === $this->birthDate) {
$this->ageMonths = null;
return;
}
$diff = $this->birthDate->diff(new DateTimeImmutable());
$this->ageMonths = ($diff->y * 12) + $diff->m;
}
}

View File

@@ -36,7 +36,7 @@ class Building
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private string $label = '';
#[ORM\Column(length: 50)]

View File

@@ -43,7 +43,7 @@ class BuildingCase
private ?int $id = null;
#[ORM\Column]
#[Groups(['building:read', 'building_case:read'])]
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
#[SerializedName('caseNumber')]
private ?int $case_number = null;
@@ -62,7 +62,7 @@ class BuildingCase
private Collection $id_case_position;
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
#[Groups(['building_case:read'])]
#[Groups(['building_case:read', 'bovine:read'])]
#[SerializedName('building')]
private ?Building $id_building = null;

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

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

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
}