Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde2c4fbb7 | ||
| 5552d98935 | |||
|
|
9e67a5e289 | ||
| 92289f9cb2 | |||
|
|
59418f2c66 | ||
| e1c9e25187 | |||
| 0b22574932 | |||
|
|
9115699f96 | ||
| 178b4e4eee | |||
|
|
fbc8365405 | ||
| 4561467532 | |||
| c441420522 | |||
| ba9ea2de71 | |||
|
|
961fa63f3d | ||
| bebfabcacc | |||
|
|
e208bcd893 | ||
| 3fe0bbf71e | |||
|
|
d566e5d9f7 | ||
| 5bb0aad620 | |||
|
|
19a29f854e | ||
| c21dcd1869 | |||
|
|
86cb3c276a | ||
| 08a17f91b3 | |||
|
|
7b722bdd17 | ||
| 9038d1726a | |||
| bde59bf9ee | |||
| 097eb39cb0 | |||
| 4cdff1200f | |||
| d5b372e243 | |||
| 2e72f93f29 |
@@ -82,7 +82,7 @@ frontend/
|
|||||||
|
|
||||||
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
|
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
|
||||||
- Les opérations API Platform sont définies directement sur les entités Doctrine.
|
- Les opérations API Platform sont définies directement sur les entités Doctrine.
|
||||||
- Pas de classes Repository custom : utiliser `EntityManagerInterface` avec les repos par défaut.
|
- Repository custom autorisé dès qu'on a une requête métier non-triviale (agrégations, jointures spécifiques, filtres multiples). Toujours via DQL/QueryBuilder, **jamais de SQL brut** (pas de `Connection::executeQuery`, `fetchAssociative`, etc.). Les CRUD basiques restent sur le repo Doctrine par défaut via `EntityManagerInterface`.
|
||||||
- `config/reference.php` est auto-généré — ne pas modifier à la main.
|
- `config/reference.php` est auto-généré — ne pas modifier à la main.
|
||||||
- Endpoints toujours au pluriel (convention API Platform).
|
- Endpoints toujours au pluriel (convention API Platform).
|
||||||
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
|
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -178,3 +178,77 @@ Pour suivre les logs en temps réel :
|
|||||||
|
|
||||||
- tail -f var/log/dev.log
|
- tail -f var/log/dev.log
|
||||||
- tail -f var/log/prod.log
|
- tail -f var/log/prod.log
|
||||||
|
|
||||||
|
## Feed des prix bovins
|
||||||
|
|
||||||
|
Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD.
|
||||||
|
|
||||||
|
### Format du fichier XLSX attendu
|
||||||
|
|
||||||
|
Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
|
||||||
|
|
||||||
|
| Colonne | Champ | Format | Exemple |
|
||||||
|
|---------|-------|--------|---------|
|
||||||
|
| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` |
|
||||||
|
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
|
||||||
|
| C | Poids à l'arrivée (kg) | Entier | `368` |
|
||||||
|
| D | Prix au kilo | Décimal | `5.7` |
|
||||||
|
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
|
||||||
|
|
||||||
|
### Comportement
|
||||||
|
|
||||||
|
- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle.
|
||||||
|
- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu.
|
||||||
|
- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
|
||||||
|
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
|
||||||
|
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
|
||||||
|
- La commande est **idempotente** : peut être relancée sans effet de bord.
|
||||||
|
|
||||||
|
### Lancement en dev
|
||||||
|
|
||||||
|
Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simulation sans écriture en BDD
|
||||||
|
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run
|
||||||
|
|
||||||
|
# Persistance effective
|
||||||
|
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lancement en prod
|
||||||
|
|
||||||
|
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
|
||||||
|
scp feed_bovin.xlsx <user>@<host>:/tmp/
|
||||||
|
|
||||||
|
# 2. SSH sur le serveur
|
||||||
|
ssh <user>@<host>
|
||||||
|
|
||||||
|
# 3. Se placer dans le dossier de l'app (pour bin/console)
|
||||||
|
cd /var/www/ferme
|
||||||
|
|
||||||
|
# 4. Dry-run pour vérifier sans rien écrire
|
||||||
|
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
|
||||||
|
|
||||||
|
# 5. Persistance effective
|
||||||
|
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
|
||||||
|
|
||||||
|
# 6. Cleanup
|
||||||
|
rm /tmp/feed_bovin.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
|
||||||
|
|
||||||
|
### Sortie attendue
|
||||||
|
|
||||||
|
À la fin, un tableau récapitule :
|
||||||
|
|
||||||
|
- Lignes totales lues
|
||||||
|
- Bovins mis à jour
|
||||||
|
- Bovins introuvables (avec aperçu des 10 premiers numéros)
|
||||||
|
- Lignes invalides (numéro national vide)
|
||||||
|
- Fournisseurs introuvables (avec liste et compte par nom)
|
||||||
|
- Bâtiments introuvables (avec liste des codes inconnus)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"malio/ednotif-bundle": ">=0.0.6",
|
"malio/ednotif-bundle": ">=0.0.6",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.7",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "fd62fc3833815b11aa058fd2759c1c79",
|
"content-hash": "34e7a0613ff6f36fd7375247ccf752d9",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -1156,6 +1156,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-16T13:22:15+00:00"
|
"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",
|
"name": "doctrine/collections",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -2704,6 +2783,84 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-20T17:47:00+00:00"
|
"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",
|
"name": "malio/ednotif-bundle",
|
||||||
"version": "v0.0.6",
|
"version": "v0.0.6",
|
||||||
@@ -2746,6 +2903,113 @@
|
|||||||
"description": "Client EDNOTIF (Guichet + wsIpBNotif) pour Symfony",
|
"description": "Client EDNOTIF (Guichet + wsIpBNotif) pour Symfony",
|
||||||
"time": "2026-04-21T08:14:37+00:00"
|
"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",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
@@ -3156,6 +3420,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-21T15:09:14+00:00"
|
"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",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@@ -3509,6 +3882,57 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/simple-cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/simple-cache.git",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\SimpleCache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interfaces for simple caching",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"caching",
|
||||||
|
"psr",
|
||||||
|
"psr-16",
|
||||||
|
"simple-cache"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sabberworm/php-css-parser",
|
"name": "sabberworm/php-css-parser",
|
||||||
"version": "v9.1.0",
|
"version": "v9.1.0",
|
||||||
@@ -8393,85 +8817,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2022-12-23T10:58:28+00:00"
|
"time": "2022-12-23T10:58:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "composer/pcre",
|
|
||||||
"version": "3.3.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/composer/pcre.git",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.4 || ^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"phpstan/phpstan": "<1.11.10"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
|
||||||
"phpunit/phpunit": "^8 || ^9"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"phpstan": {
|
|
||||||
"includes": [
|
|
||||||
"extension.neon"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Composer\\Pcre\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jordi Boggiano",
|
|
||||||
"email": "j.boggiano@seld.be",
|
|
||||||
"homepage": "http://seld.be"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
|
||||||
"keywords": [
|
|
||||||
"PCRE",
|
|
||||||
"preg",
|
|
||||||
"regex",
|
|
||||||
"regular expression"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/composer/pcre/issues",
|
|
||||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://packagist.com",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/composer",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2024-11-12T16:29:46+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "composer/semver",
|
"name": "composer/semver",
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
security:
|
security:
|
||||||
|
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
|
||||||
|
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
|
||||||
|
# front dans utils/roles.ts).
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_BUREAU: ROLE_USER
|
||||||
|
ROLE_ADMIN: ROLE_BUREAU
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
App\Entity\User: 'auto'
|
App\Entity\User: 'auto'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.0.88'
|
app.version: '0.0.99'
|
||||||
|
|||||||
@@ -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`
|
||||||
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
|
||||||
|
<p class="mb-5 text-sm text-slate-600">
|
||||||
|
Aucun filtre coché : export complet (tous les bovins actifs).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
|
||||||
|
Tranches d'âge
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="bucket in ageBuckets"
|
||||||
|
:key="bucket.value"
|
||||||
|
class="flex items-center gap-3 cursor-pointer text-primary-700"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="filters.ageRanges"
|
||||||
|
type="checkbox"
|
||||||
|
:value="bucket.value"
|
||||||
|
class="h-4 w-4 cursor-pointer accent-primary-500"
|
||||||
|
/>
|
||||||
|
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
|
||||||
|
{{ bucket.badge }}
|
||||||
|
</span>
|
||||||
|
<span>{{ bucket.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
|
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="loading"
|
||||||
|
name="mdi:loading"
|
||||||
|
size="20"
|
||||||
|
class="animate-spin"
|
||||||
|
/>
|
||||||
|
<Icon v-else name="mdi:file-excel-outline" size="20" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UiModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface InventoryExportFilters {
|
||||||
|
ageRanges: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}>(), {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'submit', filters: InventoryExportFilters): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ageBuckets = [
|
||||||
|
{ value: 'over24', label: '≥ 24 mois', badge: '24+', colorClass: 'bg-red-500' },
|
||||||
|
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
|
||||||
|
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const filters = reactive<InventoryExportFilters>({
|
||||||
|
ageRanges: []
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
filters.ageRanges = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
emit('submit', { ageRanges: [...filters.ageRanges] })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
96
frontend/components/ui/UiModal.vue
Normal file
96
frontend/components/ui/UiModal.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@mousedown.self="closeOnBackdrop"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full rounded-md bg-white shadow-2xl"
|
||||||
|
:class="maxWidth"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
|
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-slate-500 hover:text-primary-500 flex items-center"
|
||||||
|
aria-label="Fermer"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
class="border-t border-slate-200 px-6 py-4"
|
||||||
|
>
|
||||||
|
<slot name="footer" :close="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
closeOnBackdropClick?: boolean
|
||||||
|
maxWidth?: string
|
||||||
|
}>(), {
|
||||||
|
title: '',
|
||||||
|
closeOnBackdropClick: true,
|
||||||
|
maxWidth: 'max-w-lg'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const close = () => emit('update:modelValue', false)
|
||||||
|
|
||||||
|
const closeOnBackdrop = () => {
|
||||||
|
if (props.closeOnBackdropClick) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && props.modelValue) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.body.style.overflow = open ? 'hidden' : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
88
frontend/composables/useBovineColumns.ts
Normal file
88
frontend/composables/useBovineColumns.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
export interface BovineColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBovineColumnsOptions {
|
||||||
|
/**
|
||||||
|
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
|
||||||
|
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
|
||||||
|
* largeurs élargies pour combler l'espace.
|
||||||
|
*/
|
||||||
|
variant?: 'inventory' | 'case'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définition partagée des colonnes des tableaux bovins (inventory + case).
|
||||||
|
* 4 variants : avec/sans colonnes prix × inventory/case.
|
||||||
|
*
|
||||||
|
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
|
||||||
|
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
|
||||||
|
*/
|
||||||
|
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const withPricesInventory: BovineColumn[] = [
|
||||||
|
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||||
|
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||||
|
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||||
|
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||||
|
{ key: 'age', label: 'Age', width: '110px' },
|
||||||
|
{ key: 'bovineType.label', label: 'Race', width: '90px' },
|
||||||
|
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
||||||
|
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||||
|
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
|
||||||
|
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
|
||||||
|
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const withoutPricesInventory: BovineColumn[] = [
|
||||||
|
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||||
|
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||||
|
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||||
|
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||||
|
{ key: 'age', label: 'Age', width: '110px' },
|
||||||
|
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||||
|
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
|
||||||
|
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||||
|
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const withPricesCase: BovineColumn[] = [
|
||||||
|
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||||
|
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||||
|
{ key: 'sex', label: 'Sexe', width: '90px' },
|
||||||
|
{ key: 'birthDate', label: 'Né le', width: '100px' },
|
||||||
|
{ key: 'age', label: 'Age', width: '90px' },
|
||||||
|
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||||
|
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
|
||||||
|
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
|
||||||
|
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const withoutPricesCase: BovineColumn[] = [
|
||||||
|
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
|
||||||
|
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
|
||||||
|
{ key: 'sex', label: 'Sexe', width: '110px' },
|
||||||
|
{ key: 'birthDate', label: 'Né le', width: '140px' },
|
||||||
|
{ key: 'age', label: 'Age', width: '130px' },
|
||||||
|
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||||
|
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = computed<BovineColumn[]>(() => {
|
||||||
|
const isCase = options.variant === 'case'
|
||||||
|
const seePrice = auth.isBureau
|
||||||
|
|
||||||
|
if (isCase) {
|
||||||
|
return seePrice ? withPricesCase : withoutPricesCase
|
||||||
|
}
|
||||||
|
return seePrice ? withPricesInventory : withoutPricesInventory
|
||||||
|
})
|
||||||
|
|
||||||
|
return { columns }
|
||||||
|
}
|
||||||
27
frontend/middleware/admin-guard.global.ts
Normal file
27
frontend/middleware/admin-guard.global.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
|
||||||
|
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
|
||||||
|
* non-admin, et vers /login pour les non authentifiés.
|
||||||
|
*
|
||||||
|
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
|
||||||
|
* middleware évite l'affichage des pages vides / en erreur quand un user
|
||||||
|
* tape directement l'URL /admin/...
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (!to.path.startsWith('/admin')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
await auth.ensureSession()
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isAdmin) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Type de bovin' })
|
||||||
|
|
||||||
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
||||||
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-[86px]">
|
||||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<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
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToBovin"
|
@row-click="goToBovin"
|
||||||
>
|
>
|
||||||
<template #header-label>
|
<template #header-label>
|
||||||
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
|
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-code>
|
<template #header-code>
|
||||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</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.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Types de bovins' })
|
||||||
|
|
||||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
@@ -66,11 +66,6 @@ const goToBovin = (bovin: BovineTypeData) => {
|
|||||||
router.push(`/admin/bovin/${bovin.id}`)
|
router.push(`/admin/bovin/${bovin.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
|
||||||
if (auth.isAdmin) return
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) reload()
|
if (auth.isAdmin) reload()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Transporteur' })
|
||||||
|
|
||||||
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
||||||
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-[86px]">
|
||||||
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<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
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToCarrier"
|
@row-click="goToCarrier"
|
||||||
>
|
>
|
||||||
<template #header-name>
|
<template #header-name>
|
||||||
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
|
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-code>
|
<template #header-code>
|
||||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Transporteurs' })
|
||||||
|
|
||||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Client' })
|
||||||
|
|
||||||
import {computed, reactive, ref, watch} from "vue"
|
import {computed, reactive, ref, watch} from "vue"
|
||||||
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
||||||
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Adresse client' })
|
||||||
|
|
||||||
import type { AddressData, AddressPayload } from "~/services/address"
|
import type { AddressData, AddressPayload } from "~/services/address"
|
||||||
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
||||||
import { getCustomer, updateCustomer } from "~/services/customer"
|
import { getCustomer, updateCustomer } from "~/services/customer"
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-[86px]">
|
||||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<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
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToCustomer"
|
@row-click="goToCustomer"
|
||||||
>
|
>
|
||||||
<template #header-name>
|
<template #header-name>
|
||||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-phone>
|
<template #header-phone>
|
||||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-email>
|
<template #header-email>
|
||||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-createdBy.username>
|
<template #header-createdBy.username>
|
||||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</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.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Clients' })
|
||||||
|
|
||||||
import type { CustomerData } from '~/services/dto/customer-data'
|
import type { CustomerData } from '~/services/dto/customer-data'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
@@ -76,11 +76,6 @@ const goToCustomer = (customer: CustomerData) => {
|
|||||||
router.push(`/admin/customer/${customer.id}`)
|
router.push(`/admin/customer/${customer.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
|
||||||
if (auth.isAdmin) return
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) reload()
|
if (auth.isAdmin) reload()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,6 +97,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Fournisseur' })
|
||||||
|
|
||||||
import {computed, reactive, ref, watch} from "vue"
|
import {computed, reactive, ref, watch} from "vue"
|
||||||
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
||||||
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Adresse fournisseur' })
|
||||||
|
|
||||||
import type {AddressData, AddressPayload} from "~/services/address";
|
import type {AddressData, AddressPayload} from "~/services/address";
|
||||||
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
||||||
import {getSupplier, updateSupplier} from "~/services/supplier";
|
import {getSupplier, updateSupplier} from "~/services/supplier";
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-[86px]">
|
||||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<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
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToSupplier"
|
@row-click="goToSupplier"
|
||||||
>
|
>
|
||||||
<template #header-name>
|
<template #header-name>
|
||||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-phone>
|
<template #header-phone>
|
||||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-email>
|
<template #header-email>
|
||||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-createdBy.username>
|
<template #header-createdBy.username>
|
||||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</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.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Fournisseurs' })
|
||||||
|
|
||||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
@@ -76,11 +76,6 @@ const goToSupplier = (supplier: SupplierData) => {
|
|||||||
router.push(`/admin/supplier/${supplier.id}`)
|
router.push(`/admin/supplier/${supplier.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
|
||||||
if (auth.isAdmin) return
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) reload()
|
if (auth.isAdmin) reload()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,6 +74,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Utilisateur' })
|
||||||
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ROLE } from '~/utils/constants'
|
import { ROLE } from '~/utils/constants'
|
||||||
import { createUser, updateUser, getUser } from '~/services/auth'
|
import { createUser, updateUser, getUser } from '~/services/auth'
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-[86px]">
|
||||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<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
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToUser"
|
@row-click="goToUser"
|
||||||
>
|
>
|
||||||
<template #header-username>
|
<template #header-username>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.username"
|
v-model="filters.username"
|
||||||
placeholder="Utilisateur"
|
placeholder="Utilisateur"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-roles>
|
<template #header-roles>
|
||||||
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
|
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
|
||||||
</template>
|
</template>
|
||||||
<template #header-isLocked>
|
<template #header-isLocked>
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-model="filters.isLocked"
|
v-model="filters.isLocked"
|
||||||
placeholder="Statut"
|
placeholder="Statut"
|
||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-roles="{ item }">
|
<template #cell-roles="{ item }">
|
||||||
{{ getRoleLabels(item.roles) }}
|
{{ getRoleLabels(item.roles) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-isLocked="{ item }">
|
<template #cell-isLocked="{ item }">
|
||||||
<span
|
<span
|
||||||
v-if="item.isLocked"
|
v-if="item.isLocked"
|
||||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
||||||
>Verrouillé</span>
|
>Verrouillé</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
||||||
>Actif</span>
|
>Actif</span>
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</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.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Utilisateurs' })
|
||||||
|
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { ROLE } from '~/utils/constants'
|
import { ROLE } from '~/utils/constants'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
@@ -104,11 +104,6 @@ const goToUser = (user: UserData) => {
|
|||||||
router.push(`/admin/user/${user.id}`)
|
router.push(`/admin/user/${user.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
|
||||||
if (auth.isAdmin) return
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) reload()
|
if (auth.isAdmin) reload()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Accueil' })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Bovins' })
|
||||||
|
|
||||||
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
||||||
import type { BovinePayload } from '~/services/dto/bovine-data'
|
import type { BovinePayload } from '~/services/dto/bovine-data'
|
||||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen">
|
<div class="px-[86px]">
|
||||||
<!-- En-tête de page avec retour et titre -->
|
<div class="flex items-center justify-between relative">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<Icon
|
<Icon
|
||||||
@click="router.push('/')"
|
@click="router.push('/')"
|
||||||
name="gg:arrow-left-o"
|
name="gg:arrow-left-o"
|
||||||
size="44"
|
size="44"
|
||||||
class="cursor-pointer text-primary-500"
|
class="cursor-pointer text-primary-500"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
|
||||||
</div>
|
</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 -->
|
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||||
<div
|
<div
|
||||||
v-for="entry in buildingLayouts"
|
v-for="entry in buildingLayouts"
|
||||||
@@ -81,6 +80,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Bâtiments' })
|
||||||
|
|
||||||
import type {BuildingData} from "~/services/dto/building-data"
|
import type {BuildingData} from "~/services/dto/building-data"
|
||||||
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
||||||
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<h1 class="font-bold text-4xl text-primary-500 uppercase">
|
<h1 class="font-bold text-4xl text-primary-500 uppercase">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||||
<div
|
<div
|
||||||
v-if="hasCaseId"
|
v-if="hasCaseId"
|
||||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
|
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
|
||||||
@@ -32,7 +33,22 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 mb-16">
|
<div class="flex flex-wrap gap-3 mt-4">
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 mb-16">
|
||||||
<UiDataTable
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
@@ -47,25 +63,66 @@
|
|||||||
<template #header-nationalNumber>
|
<template #header-nationalNumber>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.nationalNumber"
|
v-model="filters.nationalNumber"
|
||||||
placeholder="Numéro national"
|
placeholder="N° National"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-receivedWeight>
|
<template #header-workNumber>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.receivedWeight"
|
v-model="filters.workNumber"
|
||||||
placeholder="Poids (kg)"
|
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-pricePerKg>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-finalPrice>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-bovineType.label>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="filters['bovineType.label']"
|
||||||
|
placeholder="Race"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-arrivalDate>
|
<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 }">
|
||||||
|
<span
|
||||||
|
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||||
|
:class="ageBadgeClass(item.ageMonths)"
|
||||||
|
>
|
||||||
|
{{ formatAgeLabel(item.ageMonths) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-arrivalDate="{ item }">
|
<template #cell-arrivalDate="{ item }">
|
||||||
{{ formatDate(item.arrivalDate) }}
|
{{ formatDate(item.arrivalDate) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-receivedWeight="{ item }">
|
<template #cell-pricePerKg="{ item }">
|
||||||
{{ item.receivedWeight ?? '—' }}
|
{{ formatPrice(item.pricePerKg) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-finalPrice="{ item }">
|
||||||
|
{{ formatPrice(item.finalPrice) }}
|
||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,10 +130,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Cases' })
|
||||||
|
|
||||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||||
|
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -89,43 +150,81 @@ const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value >
|
|||||||
|
|
||||||
const buildingCase = ref<BuildingCaseData | null>(null)
|
const buildingCase = ref<BuildingCaseData | null>(null)
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
if (!hasCaseId.value) {
|
||||||
|
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {
|
||||||
|
buildingCaseId: caseId.value
|
||||||
|
}, { toast: false })
|
||||||
|
} catch {
|
||||||
|
// silencieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||||
useDataTableServerState<BovineData>(
|
useDataTableServerState<BovineData>(
|
||||||
'bovines',
|
'bovines',
|
||||||
{
|
{
|
||||||
|
'exists[exitedAt]': 'false',
|
||||||
buildingCase: '',
|
buildingCase: '',
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
receivedWeight: '',
|
workNumber: '',
|
||||||
|
'bovineType.label': '',
|
||||||
|
sex: '',
|
||||||
'arrivalDate[after]': '',
|
'arrivalDate[after]': '',
|
||||||
'arrivalDate[strictly_before]': ''
|
'arrivalDate[strictly_before]': '',
|
||||||
|
'birthDate[after]': '',
|
||||||
|
'birthDate[strictly_before]': ''
|
||||||
},
|
},
|
||||||
{ initialPerPage: 10 }
|
{ initialPerPage: 10 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sexOptions = [
|
||||||
|
{ value: 'M', label: 'Mâle' },
|
||||||
|
{ value: 'F', label: 'Femelle' }
|
||||||
|
]
|
||||||
|
|
||||||
const addOneDay = (dateString: string): string => {
|
const addOneDay = (dateString: string): string => {
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||||
return next.toISOString().slice(0, 10)
|
return next.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrivalDateFilter = computed<string>({
|
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||||
get: () => (filters.value['arrivalDate[after]'] as string) ?? '',
|
computed<string>({
|
||||||
set: (value: string) => {
|
get: () => (filters.value[afterKey] as string) ?? '',
|
||||||
if (!value) {
|
set: (value: string) => {
|
||||||
filters.value['arrivalDate[after]'] = ''
|
if (!value) {
|
||||||
filters.value['arrivalDate[strictly_before]'] = ''
|
filters.value[afterKey] = ''
|
||||||
return
|
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 columns = [
|
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||||
{ key: 'nationalNumber', label: 'Numéro national' },
|
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||||
{ key: 'receivedWeight', label: "Poids à l'arrivée (kg)" },
|
|
||||||
{ key: 'arrivalDate', label: "Date d'arrivée" }
|
const { columns } = useBovineColumns({ variant: 'case' })
|
||||||
]
|
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (!buildingCase.value) return ''
|
if (!buildingCase.value) return ''
|
||||||
@@ -150,6 +249,12 @@ const formatDate = (date: string | null) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number | null) => {
|
||||||
|
if (price === null || price === undefined) return '—'
|
||||||
|
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const loadCase = async () => {
|
const loadCase = async () => {
|
||||||
if (!hasCaseId.value) {
|
if (!hasCaseId.value) {
|
||||||
buildingCase.value = null
|
buildingCase.value = null
|
||||||
@@ -180,6 +285,7 @@ watch(caseId, (id) => {
|
|||||||
}
|
}
|
||||||
filters.value.buildingCase = `/api/building_cases/${id}`
|
filters.value.buildingCase = `/api/building_cases/${id}`
|
||||||
loadCase()
|
loadCase()
|
||||||
|
loadStats()
|
||||||
reload()
|
reload()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,9 +9,21 @@
|
|||||||
class="cursor-pointer text-primary-500"
|
class="cursor-pointer text-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
<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
|
||||||
|
v-if="auth.isBureau"
|
||||||
|
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="showExportModal = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isBureau"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="syncing"
|
: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"
|
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"
|
||||||
@@ -22,7 +34,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 bg-red-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||||
|
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||||
|
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 mb-8">
|
||||||
<UiDataTable
|
<UiDataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
@@ -30,7 +57,6 @@
|
|||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:row-class="rowClass"
|
|
||||||
>
|
>
|
||||||
<template #header-nationalNumber>
|
<template #header-nationalNumber>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
@@ -57,9 +83,9 @@
|
|||||||
<template #header-birthDate>
|
<template #header-birthDate>
|
||||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-breedCode>
|
<template #header-bovineType.label>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
v-model="filters.breedCode"
|
v-model="filters['bovineType.label']"
|
||||||
placeholder="Race"
|
placeholder="Race"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
@@ -76,32 +102,59 @@
|
|||||||
<template #header-age>
|
<template #header-age>
|
||||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||||
</template>
|
</template>
|
||||||
|
<template #header-pricePerKg>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-finalPrice>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||||
|
</template>
|
||||||
<template #cell-birthDate="{ item }">
|
<template #cell-birthDate="{ item }">
|
||||||
{{ formatDate(item.birthDate) }}
|
{{ formatDate(item.birthDate) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-age="{ item }">
|
<template #cell-age="{ item }">
|
||||||
{{ formatAgeLabel(item.ageMonths) }}
|
<span
|
||||||
|
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||||
|
:class="ageBadgeClass(item.ageMonths)"
|
||||||
|
>
|
||||||
|
{{ formatAgeLabel(item.ageMonths) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-arrivalDate="{ item }">
|
<template #cell-arrivalDate="{ item }">
|
||||||
{{ formatDate(item.arrivalDate) }}
|
{{ formatDate(item.arrivalDate) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-buildingCase.building.label="{ item }">
|
<template #cell-buildingCase.building.label="{ item }">
|
||||||
{{ item.buildingCase?.building?.label ?? '—' }}
|
{{ item.effectiveBuilding?.label ?? '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-buildingCase.caseNumber="{ item }">
|
<template #cell-buildingCase.caseNumber="{ item }">
|
||||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #cell-pricePerKg="{ item }">
|
||||||
|
{{ formatPrice(item.pricePerKg) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-finalPrice="{ item }">
|
||||||
|
{{ formatPrice(item.finalPrice) }}
|
||||||
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InventoryExportModal
|
||||||
|
v-model="showExportModal"
|
||||||
|
:loading="exporting"
|
||||||
|
@submit="exportInventory"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Inventaire' })
|
||||||
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
|
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
import { formatAgeLabel } from '~/utils/bovine-age'
|
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||||
|
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -115,7 +168,58 @@ interface SyncResult {
|
|||||||
total: 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 syncing = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const showExportModal = ref(false)
|
||||||
|
|
||||||
|
const exportInventory = async (filters: InventoryExportFilters) => {
|
||||||
|
if (exporting.value) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const query: Record<string, unknown> = {}
|
||||||
|
if (filters.ageRanges.length > 0) {
|
||||||
|
query.ageRanges = filters.ageRanges.join(',')
|
||||||
|
}
|
||||||
|
const blob = await api.getBlob('bovines/inventory-export', query)
|
||||||
|
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)
|
||||||
|
showExportModal.value = false
|
||||||
|
} catch {
|
||||||
|
// toast déjà géré par useApi onResponseError
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const syncInventory = async () => {
|
const syncInventory = async () => {
|
||||||
if (syncing.value) return
|
if (syncing.value) return
|
||||||
@@ -132,6 +236,7 @@ const syncInventory = async () => {
|
|||||||
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||||||
})
|
})
|
||||||
reload()
|
reload()
|
||||||
|
loadStats()
|
||||||
} catch {
|
} catch {
|
||||||
// error toast already handled by useApi onResponseError
|
// error toast already handled by useApi onResponseError
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,7 +251,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
|||||||
'exists[exitedAt]': 'false',
|
'exists[exitedAt]': 'false',
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
workNumber: '',
|
workNumber: '',
|
||||||
breedCode: '',
|
'bovineType.label': '',
|
||||||
sex: '',
|
sex: '',
|
||||||
'arrivalDate[after]': '',
|
'arrivalDate[after]': '',
|
||||||
'arrivalDate[strictly_before]': '',
|
'arrivalDate[strictly_before]': '',
|
||||||
@@ -183,17 +288,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
|||||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||||
|
|
||||||
const columns = [
|
const { columns } = useBovineColumns()
|
||||||
{ 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) => {
|
const formatDate = (date: string | null) => {
|
||||||
if (!date) return '—'
|
if (!date) return '—'
|
||||||
@@ -206,12 +301,14 @@ const formatDate = (date: string | null) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowClass = (item: BovineData): string => {
|
const formatPrice = (price: number | null) => {
|
||||||
if (item.ageMonths === null || item.ageMonths === undefined) return ''
|
if (price === null || price === undefined) return '—'
|
||||||
if (item.ageMonths >= 24) return 'bg-red-100 hover:bg-red-200'
|
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||||
if (item.ageMonths >= 22) return 'bg-orange-100 hover:bg-orange-200'
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload)
|
|
||||||
|
onMounted(() => {
|
||||||
|
reload()
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Connexion' })
|
||||||
|
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { getUsers } from '~/services/auth'
|
import { getUsers } from '~/services/auth'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Réception' })
|
||||||
|
|
||||||
import { useReceptionStore } from '~/stores/reception'
|
import { useReceptionStore } from '~/stores/reception'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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">listes des réceptions finie</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Validation réception' })
|
||||||
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||||
import { getReceptionTypeList } from '~/services/reception-type'
|
import { getReceptionTypeList } from '~/services/reception-type'
|
||||||
|
|||||||
@@ -226,6 +226,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Modifier réception' })
|
||||||
|
|
||||||
import { usePdfPrinter } from '#imports'
|
import { usePdfPrinter } from '#imports'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import UpdateBovin from '~/components/reception/update-bovin.vue'
|
import UpdateBovin from '~/components/reception/update-bovin.vue'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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">listes des réceptions en attente</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions en attente</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
@@ -72,6 +72,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Réceptions en attente' })
|
||||||
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||||
import { deleteReception } from '~/services/reception'
|
import { deleteReception } from '~/services/reception'
|
||||||
|
|||||||
@@ -125,6 +125,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Scanner' })
|
||||||
|
|
||||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||||
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
||||||
import { createBovine } from '~/services/bovine'
|
import { createBovine } from '~/services/bovine'
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Expédition' })
|
||||||
|
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useShipmentStore } from '~/stores/shipment'
|
import { useShipmentStore } from '~/stores/shipment'
|
||||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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">listes des expéditions finie</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
@@ -71,6 +71,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Validation expédition' })
|
||||||
|
|
||||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||||
|
|||||||
@@ -197,6 +197,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Modifier expédition' })
|
||||||
|
|
||||||
import { usePdfPrinter } from '#imports'
|
import { usePdfPrinter } from '#imports'
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import UpdateWeight from '~/components/commun/update-weight.vue'
|
import UpdateWeight from '~/components/commun/update-weight.vue'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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">listes des expéditions en attente</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions en attente</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
@@ -84,6 +84,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Expéditions en attente' })
|
||||||
|
|
||||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||||
import { deleteShipment } from '~/services/shipment'
|
import { deleteShipment } from '~/services/shipment'
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
|
export interface BovineBuildingRef {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BovineBuildingCaseRef {
|
export interface BovineBuildingCaseRef {
|
||||||
caseNumber: number | null
|
caseNumber: number | null
|
||||||
building: { label: string } | null
|
building: BovineBuildingRef | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BovineData {
|
export interface BovineData {
|
||||||
id: number
|
id: number
|
||||||
nationalNumber: string
|
nationalNumber: string
|
||||||
receivedWeight: number | null
|
receivedWeight: number | null
|
||||||
|
pricePerKg: number | null
|
||||||
|
finalPrice: number | null
|
||||||
arrivalDate: string | null
|
arrivalDate: string | null
|
||||||
exitDate: string | null
|
exitDate: string | null
|
||||||
buildingCase: BovineBuildingCaseRef | null
|
buildingCase: BovineBuildingCaseRef | null
|
||||||
|
building: BovineBuildingRef | null
|
||||||
|
effectiveBuilding: BovineBuildingRef | null
|
||||||
supplier: string | null
|
supplier: string | null
|
||||||
workNumber: string | null
|
workNumber: string | null
|
||||||
birthDate: string | null
|
birthDate: string | null
|
||||||
breedCode: string | null
|
bovineType: { id: number; label: string; code: string } | null
|
||||||
sex: string | null
|
sex: string | null
|
||||||
ageMonths: number | null
|
ageMonths: number | null
|
||||||
exitedAt: string | null
|
exitedAt: string | null
|
||||||
@@ -22,6 +30,7 @@ export interface BovineData {
|
|||||||
export type BovinePayload = {
|
export type BovinePayload = {
|
||||||
nationalNumber?: string
|
nationalNumber?: string
|
||||||
receivedWeight?: number | null
|
receivedWeight?: number | null
|
||||||
|
pricePerKg?: number | null
|
||||||
arrivalDate?: string | null
|
arrivalDate?: string | null
|
||||||
buildingCase?: string | null
|
buildingCase?: string | null
|
||||||
supplier?: string | null
|
supplier?: string | null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
|
|||||||
import type {UserData} from '~/services/dto/user-data'
|
import type {UserData} from '~/services/dto/user-data'
|
||||||
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
||||||
import type {UserPayload} from "~/services/dto/user-data";
|
import type {UserPayload} from "~/services/dto/user-data";
|
||||||
import {ROLE} from '~/utils/constants'
|
import {userHasRole} from '~/utils/roles'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -12,7 +12,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
isAuthenticated: (state) => Boolean(state.user),
|
isAuthenticated: (state) => Boolean(state.user),
|
||||||
isAdmin: (state) => Boolean(state.user?.roles?.includes(ROLE[0].value))
|
hasRole: (state) => (role: string): boolean => userHasRole(state.user?.roles, role),
|
||||||
|
isAdmin: (state) => userHasRole(state.user?.roles, 'ROLE_ADMIN'),
|
||||||
|
isBureau: (state) => userHasRole(state.user?.roles, 'ROLE_BUREAU')
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
clearSession() {
|
clearSession() {
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ export const formatAgeLabel = (months: number | null | undefined): string => {
|
|||||||
if (!label) label = '< 1 mois'
|
if (!label) label = '< 1 mois'
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ageBadgeClass = (months: number | null | undefined): string => {
|
||||||
|
if (months === null || months === undefined) return ''
|
||||||
|
if (months >= 24) return 'bg-red-500 text-white'
|
||||||
|
if (months >= 22) return 'bg-orange-500 text-white'
|
||||||
|
if (months >= 20) return 'bg-yellow-500 text-white'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const MERCHANDISE_TYPE_CODES = {
|
|||||||
|
|
||||||
export const ROLE = [
|
export const ROLE = [
|
||||||
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
|
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
|
||||||
|
{ label: 'Bureau', value: 'ROLE_BUREAU' },
|
||||||
{ label: 'Utilisateur', value: 'ROLE_USER' }
|
{ label: 'Utilisateur', value: 'ROLE_USER' }
|
||||||
]
|
]
|
||||||
export const SUPPLIER_CODE = {
|
export const SUPPLIER_CODE = {
|
||||||
|
|||||||
38
frontend/utils/roles.ts
Normal file
38
frontend/utils/roles.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Hiérarchie des rôles côté front. Doit rester synchronisée avec
|
||||||
|
* `role_hierarchy` dans config/packages/security.yaml côté back.
|
||||||
|
*
|
||||||
|
* Pour ajouter un nouveau rôle :
|
||||||
|
* 1. Ajouter une entrée ici (son rôle parent dans la chaîne)
|
||||||
|
* 2. Ajouter `ROLE_X: ROLE_Y` dans security.yaml côté back
|
||||||
|
* 3. Ajouter le rôle dans `ROLE` (utils/constants.ts) pour le form admin
|
||||||
|
*/
|
||||||
|
export const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||||
|
ROLE_ADMIN: ['ROLE_BUREAU'],
|
||||||
|
ROLE_BUREAU: ['ROLE_USER'],
|
||||||
|
ROLE_USER: []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'ensemble des rôles effectifs en expansant la hiérarchie.
|
||||||
|
* Ex : ['ROLE_ADMIN'] → Set { 'ROLE_ADMIN', 'ROLE_BUREAU', 'ROLE_USER' }.
|
||||||
|
*/
|
||||||
|
export const expandRoles = (roles: string[]): Set<string> => {
|
||||||
|
const expanded = new Set<string>(roles)
|
||||||
|
const visit = (role: string): void => {
|
||||||
|
const parents = ROLE_HIERARCHY[role] ?? []
|
||||||
|
for (const parent of parents) {
|
||||||
|
if (!expanded.has(parent)) {
|
||||||
|
expanded.add(parent)
|
||||||
|
visit(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of roles) visit(r)
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userHasRole = (userRoles: string[] | null | undefined, role: string): boolean => {
|
||||||
|
if (!userRoles || userRoles.length === 0) return false
|
||||||
|
return expandRoles(userRoles).has(role)
|
||||||
|
}
|
||||||
31
migrations/Version20260424132554.php
Normal file
31
migrations/Version20260424132554.php
Normal 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 Version20260424132554 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 price_per_kg DOUBLE PRECISION 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 price_per_kg');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260427154952.php
Normal file
35
migrations/Version20260427154952.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260427154952 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajout de la colonne display_order sur building et seed des valeurs par code.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE building ADD display_order INT DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->addSql("UPDATE building SET display_order = 1 WHERE code = 'B3'");
|
||||||
|
$this->addSql("UPDATE building SET display_order = 2 WHERE code = 'B2'");
|
||||||
|
$this->addSql("UPDATE building SET display_order = 3 WHERE code = 'B1'");
|
||||||
|
$this->addSql("UPDATE building SET display_order = 4 WHERE code = 'ZT'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE building DROP display_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260428061801.php
Normal file
35
migrations/Version20260428061801.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260428061801 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 building_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F4D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_2068337F4D2A7E12 ON bovine (building_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F4D2A7E12');
|
||||||
|
$this->addSql('DROP INDEX IDX_2068337F4D2A7E12');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP building_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
50
migrations/Version20260428065800.php
Normal file
50
migrations/Version20260428065800.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bascule de Bovine.breed_code (string) vers une relation Bovine.bovine_type_id (FK).
|
||||||
|
* Ajoute au passage les BovineType manquants (Aubrac=14, Croisé=39, Blonde d'aquitaine=79).
|
||||||
|
*/
|
||||||
|
final class Version20260428065800 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Migration breedCode -> relation BovineType + ajout des races manquantes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Insertion des BovineType manquants (idempotent via NOT EXISTS).
|
||||||
|
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Aubrac', '14' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '14')");
|
||||||
|
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Croisé', '39' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '39')");
|
||||||
|
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Blonde d''aquitaine', '79' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '79')");
|
||||||
|
|
||||||
|
// 2. Ajout de la colonne FK + index.
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD bovine_type_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_2068337F7899F32E ON bovine (bovine_type_id)');
|
||||||
|
|
||||||
|
// 3. Backfill : associe chaque bovin à son BovineType via le code.
|
||||||
|
$this->addSql('UPDATE bovine SET bovine_type_id = (SELECT id FROM bovine_type WHERE bovine_type.code = bovine.breed_code) WHERE breed_code IS NOT NULL');
|
||||||
|
|
||||||
|
// 4. Contrainte de clé étrangère (après backfill pour éviter une violation transitoire).
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F7899F32E FOREIGN KEY (bovine_type_id) REFERENCES bovine_type (id)');
|
||||||
|
|
||||||
|
// 5. Drop de l'ancienne colonne string.
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP breed_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql('UPDATE bovine SET breed_code = (SELECT code FROM bovine_type WHERE bovine_type.id = bovine.bovine_type_id) WHERE bovine_type_id IS NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F7899F32E');
|
||||||
|
$this->addSql('DROP INDEX IDX_2068337F7899F32E');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP bovine_type_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
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_BUREAU')",
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use App\State\Bovin\BovineSyncInventoryProcessor;
|
|||||||
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
||||||
tags: ['Bovines'],
|
tags: ['Bovines'],
|
||||||
),
|
),
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_BUREAU')",
|
||||||
input: false,
|
input: false,
|
||||||
output: self::class,
|
output: self::class,
|
||||||
processor: BovineSyncInventoryProcessor::class,
|
processor: BovineSyncInventoryProcessor::class,
|
||||||
|
|||||||
215
src/Command/FeedBovinePricesCommand.php
Normal file
215
src/Command/FeedBovinePricesCommand.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Bovine;
|
||||||
|
use App\Entity\Building;
|
||||||
|
use App\Entity\Supplier;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:feed-bovine-prices',
|
||||||
|
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
|
||||||
|
)]
|
||||||
|
final class FeedBovinePricesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$file = (string) $input->getArgument('file');
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
$io->error(sprintf('Fichier introuvable : %s', $file));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->title('Feed bovins depuis '.basename($file));
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->warning('Dry-run activé : aucune écriture en BDD.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$spreadsheet = IOFactory::load($file);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$io->error('Impossible de lire le fichier : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$highestRow = $sheet->getHighestRow();
|
||||||
|
|
||||||
|
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
|
||||||
|
$supplierByName = [];
|
||||||
|
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
|
||||||
|
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pré-chargement des bâtiments par code (insensible casse).
|
||||||
|
$buildingByCode = [];
|
||||||
|
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
|
||||||
|
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bovineRepo = $this->em->getRepository(Bovine::class);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'notFound' => 0,
|
||||||
|
'invalid' => 0,
|
||||||
|
'supplierMissing' => 0,
|
||||||
|
'buildingMissing' => 0,
|
||||||
|
];
|
||||||
|
$missingNationalNumbers = [];
|
||||||
|
$missingSuppliers = [];
|
||||||
|
$missingBuildings = [];
|
||||||
|
|
||||||
|
$io->progressStart($highestRow);
|
||||||
|
for ($row = 1; $row <= $highestRow; ++$row) {
|
||||||
|
++$stats['total'];
|
||||||
|
|
||||||
|
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
|
||||||
|
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
|
||||||
|
$rawWeight = $sheet->getCell([3, $row])->getValue();
|
||||||
|
$rawPrice = $sheet->getCell([4, $row])->getValue();
|
||||||
|
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
|
||||||
|
|
||||||
|
$rawNationalNumber = trim($rawNationalNumber);
|
||||||
|
if ('' === $rawNationalNumber) {
|
||||||
|
++$stats['invalid'];
|
||||||
|
$io->progressAdvance();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
|
||||||
|
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
|
||||||
|
|
||||||
|
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
|
||||||
|
if (null === $bovine) {
|
||||||
|
++$stats['notFound'];
|
||||||
|
$missingNationalNumbers[] = $nationalNumber;
|
||||||
|
$io->progressAdvance();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup supplier (peut être null si introuvable ou colonne vide).
|
||||||
|
$supplier = null;
|
||||||
|
$supplierName = mb_strtoupper(trim($rawSupplier));
|
||||||
|
if ('' !== $supplierName) {
|
||||||
|
$supplier = $supplierByName[$supplierName] ?? null;
|
||||||
|
if (null === $supplier) {
|
||||||
|
++$stats['supplierMissing'];
|
||||||
|
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
|
||||||
|
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
|
||||||
|
|
||||||
|
if (null !== $weight) {
|
||||||
|
$bovine->setReceivedWeight($weight);
|
||||||
|
}
|
||||||
|
if (null !== $price) {
|
||||||
|
$bovine->setPricePerKg($price);
|
||||||
|
}
|
||||||
|
$bovine->setSupplier($supplier);
|
||||||
|
|
||||||
|
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
|
||||||
|
$buildingCode = mb_strtoupper(trim($rawBuilding));
|
||||||
|
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
|
||||||
|
$building = $buildingByCode[$buildingCode] ?? null;
|
||||||
|
if (null !== $building) {
|
||||||
|
$bovine->setBuilding($building);
|
||||||
|
} else {
|
||||||
|
++$stats['buildingMissing'];
|
||||||
|
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
++$stats['updated'];
|
||||||
|
$io->progressAdvance();
|
||||||
|
}
|
||||||
|
$io->progressFinish();
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Résultats');
|
||||||
|
$io->table(
|
||||||
|
['Métrique', 'Valeur'],
|
||||||
|
[
|
||||||
|
['Lignes totales', $stats['total']],
|
||||||
|
['Bovins mis à jour', $stats['updated']],
|
||||||
|
['Bovins introuvables', $stats['notFound']],
|
||||||
|
['Lignes invalides', $stats['invalid']],
|
||||||
|
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
|
||||||
|
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] !== $missingNationalNumbers) {
|
||||||
|
$preview = array_slice($missingNationalNumbers, 0, 10);
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'%d bovin(s) introuvable(s). Aperçu : %s%s',
|
||||||
|
count($missingNationalNumbers),
|
||||||
|
implode(', ', $preview),
|
||||||
|
count($missingNationalNumbers) > 10 ? '…' : '',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $missingSuppliers) {
|
||||||
|
$list = [];
|
||||||
|
foreach ($missingSuppliers as $name => $count) {
|
||||||
|
$list[] = sprintf('%s (%d)', $name, $count);
|
||||||
|
}
|
||||||
|
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $missingBuildings) {
|
||||||
|
$list = [];
|
||||||
|
foreach ($missingBuildings as $code => $count) {
|
||||||
|
$list[] = sprintf('%s (%d)', $code, $count);
|
||||||
|
}
|
||||||
|
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
|
||||||
|
} else {
|
||||||
|
$io->success('Feed terminé avec succès.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -476,9 +476,12 @@ class SeedCommand extends Command
|
|||||||
private function seedBovineTypes(): void
|
private function seedBovineTypes(): void
|
||||||
{
|
{
|
||||||
$bovineTypes = [
|
$bovineTypes = [
|
||||||
|
['label' => 'Aubrac', 'code' => '14'],
|
||||||
['label' => 'Limousine', 'code' => '34'],
|
['label' => 'Limousine', 'code' => '34'],
|
||||||
['label' => 'Charolaise', 'code' => '38'],
|
['label' => 'Charolaise', 'code' => '38'],
|
||||||
|
['label' => 'Croisé', 'code' => '39'],
|
||||||
['label' => 'Parthenaise', 'code' => '71'],
|
['label' => 'Parthenaise', 'code' => '71'],
|
||||||
|
['label' => "Blonde d'aquitaine", 'code' => '79'],
|
||||||
];
|
];
|
||||||
foreach ($bovineTypes as $type) {
|
foreach ($bovineTypes as $type) {
|
||||||
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {
|
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {
|
||||||
|
|||||||
@@ -77,9 +77,12 @@ class ReferenceFixtures extends Fixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
$bovineTypes = [
|
$bovineTypes = [
|
||||||
|
['label' => 'Aubrac', 'code' => '14'],
|
||||||
['label' => 'Limousine', 'code' => '34'],
|
['label' => 'Limousine', 'code' => '34'],
|
||||||
['label' => 'Charolaise', 'code' => '38'],
|
['label' => 'Charolaise', 'code' => '38'],
|
||||||
|
['label' => 'Croisé', 'code' => '39'],
|
||||||
['label' => 'Parthenaise', 'code' => '71'],
|
['label' => 'Parthenaise', 'code' => '71'],
|
||||||
|
['label' => "Blonde d'aquitaine", 'code' => '79'],
|
||||||
];
|
];
|
||||||
foreach ($bovineTypes as $type) {
|
foreach ($bovineTypes as $type) {
|
||||||
$bovineType = new BovineType()
|
$bovineType = new BovineType()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\BovineRepository;
|
||||||
use App\State\Bovin\BovineProcessor;
|
use App\State\Bovin\BovineProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -21,21 +22,23 @@ use Symfony\Component\Serializer\Attribute\Context;
|
|||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: BovineRepository::class)]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ORM\Table(name: 'bovine')]
|
#[ORM\Table(name: 'bovine')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
#[ApiFilter(SearchFilter::class, properties: [
|
||||||
'nationalNumber' => 'ipartial',
|
'nationalNumber' => 'ipartial',
|
||||||
'workNumber' => 'ipartial',
|
'workNumber' => 'ipartial',
|
||||||
'breedCode' => 'ipartial',
|
'bovineType.label' => 'ipartial',
|
||||||
'sex' => 'exact',
|
'bovineType.code' => 'ipartial',
|
||||||
'buildingCase' => 'exact',
|
'sex' => 'exact',
|
||||||
'receivedWeight' => 'exact',
|
'buildingCase' => 'exact',
|
||||||
|
'receivedWeight' => 'exact',
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
order: ['birthDate' => 'ASC'],
|
||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new Get(
|
||||||
requirements: ['id' => '\d+'],
|
requirements: ['id' => '\d+'],
|
||||||
@@ -76,6 +79,11 @@ class Bovine
|
|||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
private ?int $receivedWeight = null;
|
private ?int $receivedWeight = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
|
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||||
|
private ?float $pricePerKg = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
@@ -86,6 +94,11 @@ class Bovine
|
|||||||
#[ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
private ?BuildingCase $buildingCase = null;
|
private ?BuildingCase $buildingCase = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
|
private ?Building $building = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
private ?Supplier $supplier = null;
|
private ?Supplier $supplier = null;
|
||||||
@@ -99,9 +112,10 @@ class Bovine
|
|||||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
private ?DateTimeImmutable $birthDate = null;
|
private ?DateTimeImmutable $birthDate = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 20, nullable: true)]
|
#[ORM\ManyToOne]
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
private ?string $breedCode = null;
|
#[ApiProperty(readableLink: true)]
|
||||||
|
private ?BovineType $bovineType = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 1, nullable: true)]
|
#[ORM\Column(length: 1, nullable: true)]
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
@@ -150,6 +164,29 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPricePerKg(): ?float
|
||||||
|
{
|
||||||
|
return $this->pricePerKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPricePerKg(?float $pricePerKg): static
|
||||||
|
{
|
||||||
|
$this->pricePerKg = $pricePerKg;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||||
|
public function getFinalPrice(): ?float
|
||||||
|
{
|
||||||
|
if (null === $this->receivedWeight || null === $this->pricePerKg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->receivedWeight * $this->pricePerKg;
|
||||||
|
}
|
||||||
|
|
||||||
public function getArrivalDate(): ?DateTimeImmutable
|
public function getArrivalDate(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->arrivalDate;
|
return $this->arrivalDate;
|
||||||
@@ -174,6 +211,28 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBuilding(): ?Building
|
||||||
|
{
|
||||||
|
return $this->building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBuilding(?Building $building): static
|
||||||
|
{
|
||||||
|
$this->building = $building;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
|
||||||
|
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
|
||||||
|
*/
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
public function getEffectiveBuilding(): ?Building
|
||||||
|
{
|
||||||
|
return $this->buildingCase?->getIdBuilding() ?? $this->building;
|
||||||
|
}
|
||||||
|
|
||||||
public function getSupplier(): ?Supplier
|
public function getSupplier(): ?Supplier
|
||||||
{
|
{
|
||||||
return $this->supplier;
|
return $this->supplier;
|
||||||
@@ -210,14 +269,14 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBreedCode(): ?string
|
public function getBovineType(): ?BovineType
|
||||||
{
|
{
|
||||||
return $this->breedCode;
|
return $this->bovineType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setBreedCode(?string $breedCode): static
|
public function setBovineType(?BovineType $bovineType): static
|
||||||
{
|
{
|
||||||
$this->breedCode = $breedCode;
|
$this->bovineType = $bovineType;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ class BovineType
|
|||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
|
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 50)]
|
#[ORM\Column(length: 50)]
|
||||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
|
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table(name: 'building')]
|
#[ORM\Table(name: 'building')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
order: ['displayOrder' => 'ASC', 'id' => 'ASC'],
|
||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new Get(
|
||||||
requirements: ['id' => '\d+'],
|
requirements: ['id' => '\d+'],
|
||||||
@@ -43,6 +44,10 @@ class Building
|
|||||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
||||||
private string $code = '';
|
private string $code = '';
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'display_order', type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['building:read', 'building:summary'])]
|
||||||
|
private ?int $displayOrder = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, Reception>
|
* @var Collection<int, Reception>
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +106,18 @@ class Building
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDisplayOrder(): ?int
|
||||||
|
{
|
||||||
|
return $this->displayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDisplayOrder(?int $displayOrder): self
|
||||||
|
{
|
||||||
|
$this->displayOrder = $displayOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Reception>
|
* @return Collection<int, Reception>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
requirements: ['id' => '\d+'],
|
requirements: ['id' => '\d+'],
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/receptions/weigh',
|
uriTemplate: '/receptions/weigh',
|
||||||
@@ -511,14 +512,10 @@ class Reception
|
|||||||
$this->identificationNumber = $number;
|
$this->identificationNumber = $number;
|
||||||
|
|
||||||
$args->getObjectManager()
|
$args->getObjectManager()
|
||||||
->getConnection()
|
->createQuery(sprintf('UPDATE %s r SET r.identificationNumber = :number WHERE r.id = :id', self::class))
|
||||||
->executeStatement(
|
->setParameter('number', $number)
|
||||||
'UPDATE reception SET identification_number = :number WHERE id = :id',
|
->setParameter('id', $this->id)
|
||||||
[
|
->execute()
|
||||||
'number' => $number,
|
|
||||||
'id' => $this->id,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
requirements: ['id' => '\d+'],
|
requirements: ['id' => '\d+'],
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/shipments/weigh',
|
uriTemplate: '/shipments/weigh',
|
||||||
@@ -358,14 +359,10 @@ class Shipment
|
|||||||
$this->identificationNumber = $number;
|
$this->identificationNumber = $number;
|
||||||
|
|
||||||
$args->getObjectManager()
|
$args->getObjectManager()
|
||||||
->getConnection()
|
->createQuery(sprintf('UPDATE %s s SET s.identificationNumber = :number WHERE s.id = :id', self::class))
|
||||||
->executeStatement(
|
->setParameter('number', $number)
|
||||||
'UPDATE shipment SET identification_number = :number WHERE id = :id',
|
->setParameter('id', $this->id)
|
||||||
[
|
->execute()
|
||||||
'number' => $number,
|
|
||||||
'id' => $this->id,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
src/Repository/BovineRepository.php
Normal file
101
src/Repository/BovineRepository.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Bovine;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Bovine>
|
||||||
|
*/
|
||||||
|
final class BovineRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public const AGE_RANGE_OVER_24 = 'over24';
|
||||||
|
|
||||||
|
public const AGE_RANGE_BETWEEN_22_AND_24 = 'between22And24';
|
||||||
|
|
||||||
|
public const AGE_RANGE_BETWEEN_20_AND_22 = 'between20And22';
|
||||||
|
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Bovine::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des bovins actifs pour l'export inventaire.
|
||||||
|
*
|
||||||
|
* @param null|list<string> $ageRanges Si null/vide → tous. Sinon filtre OR sur les tranches d'âge demandées.
|
||||||
|
*
|
||||||
|
* @return list<Bovine>
|
||||||
|
*/
|
||||||
|
public function findActiveForInventoryExport(?array $ageRanges = null): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('b')
|
||||||
|
->where('b.exitedAt IS NULL')
|
||||||
|
->orderBy('b.birthDate', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null !== $ageRanges && [] !== $ageRanges) {
|
||||||
|
$orX = $qb->expr()->orX();
|
||||||
|
foreach ($ageRanges as $idx => $range) {
|
||||||
|
switch ($range) {
|
||||||
|
case self::AGE_RANGE_OVER_24:
|
||||||
|
$orX->add('b.ageMonths >= 24');
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case self::AGE_RANGE_BETWEEN_22_AND_24:
|
||||||
|
$orX->add($qb->expr()->andX('b.ageMonths >= 22', 'b.ageMonths < 24'));
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case self::AGE_RANGE_BETWEEN_20_AND_22:
|
||||||
|
$orX->add($qb->expr()->andX('b.ageMonths >= 20', 'b.ageMonths < 22'));
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($orX->count() > 0) {
|
||||||
|
$qb->andWhere($orX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compteurs des bovins actifs par tranche d'âge.
|
||||||
|
*
|
||||||
|
* @return array{total: int, over24: int, between22And24: int, between20And22: int}
|
||||||
|
*/
|
||||||
|
public function getInventoryStats(?int $buildingCaseId = null): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('b')
|
||||||
|
->select(
|
||||||
|
'COUNT(b.id) AS total',
|
||||||
|
'SUM(CASE WHEN b.ageMonths >= 24 THEN 1 ELSE 0 END) AS over24',
|
||||||
|
'SUM(CASE WHEN b.ageMonths >= 22 AND b.ageMonths < 24 THEN 1 ELSE 0 END) AS between22And24',
|
||||||
|
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
||||||
|
)
|
||||||
|
->where('b.exitedAt IS NULL')
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null !== $buildingCaseId) {
|
||||||
|
$qb->andWhere('b.buildingCase = :caseId')
|
||||||
|
->setParameter('caseId', $buildingCaseId)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $qb->getQuery()->getSingleResult();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (int) ($row['total'] ?? 0),
|
||||||
|
'over24' => (int) ($row['over24'] ?? 0),
|
||||||
|
'between22And24' => (int) ($row['between22And24'] ?? 0),
|
||||||
|
'between20And22' => (int) ($row['between20And22'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
431
src/State/Bovin/BovineInventoryExportProvider.php
Normal file
431
src/State/Bovin/BovineInventoryExportProvider.php
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State\Bovin;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\Bovine;
|
||||||
|
use App\Repository\BovineRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PhpOffice\PhpSpreadsheet\RichText\RichText;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<Response>
|
||||||
|
*/
|
||||||
|
final class BovineInventoryExportProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
private const FARM_NAME = 'FERME SCEA LES NAUDS';
|
||||||
|
|
||||||
|
private const HEADER_FILL = 'FFCCECFF';
|
||||||
|
|
||||||
|
private const SUBTITLE_TEXT_COLOR = 'FFFF0000';
|
||||||
|
|
||||||
|
// Couleurs pastel pour les lignes de données selon l'âge.
|
||||||
|
private const COLOR_RED = 'FFFCA5A5';
|
||||||
|
|
||||||
|
private const COLOR_ORANGE = 'FFFDBA74';
|
||||||
|
|
||||||
|
private const COLOR_YELLOW = 'FFFEF08A';
|
||||||
|
|
||||||
|
private const BREED_CODE_LIMOUSINE = '34';
|
||||||
|
|
||||||
|
private const BREED_CODE_CHAROLAISE = '38';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largeurs de colonnes (A à R).
|
||||||
|
*/
|
||||||
|
private const COLUMN_WIDTHS = [
|
||||||
|
'A' => 3.7,
|
||||||
|
'B' => 6.3,
|
||||||
|
'C' => 3.7,
|
||||||
|
'D' => 15.3,
|
||||||
|
'E' => 3.0,
|
||||||
|
'F' => 3.0,
|
||||||
|
'G' => 3.0,
|
||||||
|
'H' => 6.6,
|
||||||
|
'I' => 13.9,
|
||||||
|
'J' => 11.4,
|
||||||
|
'K' => 11.4,
|
||||||
|
'L' => 7.5,
|
||||||
|
'M' => 6.1,
|
||||||
|
'N' => 6.0,
|
||||||
|
'O' => 11.4,
|
||||||
|
'P' => 5.7,
|
||||||
|
'Q' => 5.0,
|
||||||
|
'R' => 14.4,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private BovineRepository $bovineRepository,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$raw = (string) ($request?->query->get('ageRanges') ?? '');
|
||||||
|
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
|
|
||||||
|
$bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges);
|
||||||
|
$bovines = $this->sortBovines($bovines);
|
||||||
|
|
||||||
|
$spreadsheet = $this->buildSpreadsheet($bovines, $ageRanges);
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tri par âge décroissant puis race (Limousine d'abord, puis Charolaise, puis autres).
|
||||||
|
*
|
||||||
|
* @param list<Bovine> $bovines
|
||||||
|
*
|
||||||
|
* @return list<Bovine>
|
||||||
|
*/
|
||||||
|
private function sortBovines(array $bovines): array
|
||||||
|
{
|
||||||
|
usort($bovines, function (Bovine $a, Bovine $b): int {
|
||||||
|
$ageDiff = ($b->getAgeMonths() ?? 0) <=> ($a->getAgeMonths() ?? 0);
|
||||||
|
if (0 !== $ageDiff) {
|
||||||
|
return $ageDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->breedRank($a) <=> $this->breedRank($b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($bovines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function breedRank(Bovine $bovine): int
|
||||||
|
{
|
||||||
|
$code = $bovine->getBovineType()?->getCode();
|
||||||
|
|
||||||
|
return match ($code) {
|
||||||
|
self::BREED_CODE_LIMOUSINE => 0,
|
||||||
|
self::BREED_CODE_CHAROLAISE => 1,
|
||||||
|
default => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Bovine> $bovines
|
||||||
|
* @param list<string> $ageRanges
|
||||||
|
*/
|
||||||
|
private function buildSpreadsheet(array $bovines, array $ageRanges): Spreadsheet
|
||||||
|
{
|
||||||
|
$spreadsheet = new Spreadsheet();
|
||||||
|
|
||||||
|
// Police par défaut sur tout le classeur (en-têtes + data)
|
||||||
|
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
|
||||||
|
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('Alerte_Taurillons');
|
||||||
|
|
||||||
|
// Configuration impression : A4 paysage, ajusté à 1 page de large,
|
||||||
|
// lignes 3 et 4 (sous-titre + en-têtes) répétées en haut de chaque page.
|
||||||
|
$pageSetup = $sheet->getPageSetup();
|
||||||
|
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
|
||||||
|
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
|
||||||
|
$pageSetup->setFitToWidth(1);
|
||||||
|
$pageSetup->setFitToHeight(0); // illimité en hauteur, on tient juste sur 1 page de large
|
||||||
|
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(3, 4);
|
||||||
|
$pageSetup->setHorizontalCentered(true);
|
||||||
|
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
|
||||||
|
|
||||||
|
$year = (int) new DateTimeImmutable()->format('Y');
|
||||||
|
|
||||||
|
// Ligne 1 : titre rich text (Arial Black 18 noir + Arial Black 20 rouge pour l'année)
|
||||||
|
$richTitle = new RichText();
|
||||||
|
$first = $richTitle->createTextRun(sprintf('%s - ', self::FARM_NAME));
|
||||||
|
$first->getFont()->setName('Arial Black')->setSize(18)->setBold(true);
|
||||||
|
$second = $richTitle->createTextRun(sprintf('TAURILLONS %d', $year));
|
||||||
|
$second->getFont()->setName('Arial Black')->setSize(20)->setBold(true)
|
||||||
|
->getColor()->setARGB('FFFF0000')
|
||||||
|
;
|
||||||
|
$sheet->getCell('A1')->setValue($richTitle);
|
||||||
|
$sheet->getRowDimension(1)->setRowHeight(32.25);
|
||||||
|
|
||||||
|
// Date du jour à droite
|
||||||
|
$sheet->setCellValue('R1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
|
||||||
|
$sheet->getStyle('R1')->getNumberFormat()->setFormatCode('m/d/yyyy');
|
||||||
|
$sheet->getStyle('R1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||||
|
$sheet->getStyle('R1')->getFont()->setSize(14)->setBold(true);
|
||||||
|
|
||||||
|
// Bordure épaisse en bas du bloc titre (toute la largeur du tableau)
|
||||||
|
$sheet->getStyle('A1:R1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
|
||||||
|
|
||||||
|
// Ligne 3 : sous-titre dynamique fusionné sur toute la largeur du tableau
|
||||||
|
$sheet->setCellValue('A3', $this->computeSubtitle($ageRanges));
|
||||||
|
$sheet->mergeCells('A3:R3');
|
||||||
|
$sheet->getStyle('A3:R3')->applyFromArray([
|
||||||
|
'font' => [
|
||||||
|
'size' => 18,
|
||||||
|
'bold' => true,
|
||||||
|
'color' => ['argb' => self::SUBTITLE_TEXT_COLOR],
|
||||||
|
],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['argb' => self::HEADER_FILL],
|
||||||
|
],
|
||||||
|
'alignment' => [
|
||||||
|
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||||
|
'vertical' => Alignment::VERTICAL_CENTER,
|
||||||
|
],
|
||||||
|
'borders' => [
|
||||||
|
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||||
|
'right' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||||
|
'left' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$sheet->getRowDimension(3)->setRowHeight(24.75);
|
||||||
|
|
||||||
|
// Ligne 4 : en-têtes
|
||||||
|
$headers = [
|
||||||
|
'A' => 'Limousin',
|
||||||
|
'B' => 'N° de travail',
|
||||||
|
'C' => 'Charolais',
|
||||||
|
'D' => "N°\nNational",
|
||||||
|
'E' => "Paturelle\n1 2 3",
|
||||||
|
'F' => '',
|
||||||
|
'G' => '',
|
||||||
|
'H' => 'Case',
|
||||||
|
'I' => 'Vendeur',
|
||||||
|
'J' => 'Date de naissance',
|
||||||
|
'K' => "Date\nentrée",
|
||||||
|
'L' => "Age\nentrée",
|
||||||
|
'M' => "Poids\n(kg)",
|
||||||
|
'N' => "Prix\ndu kg",
|
||||||
|
'O' => 'Total €',
|
||||||
|
'P' => "Age\ndu jour",
|
||||||
|
'Q' => 'Trpt',
|
||||||
|
'R' => 'Prix final',
|
||||||
|
];
|
||||||
|
foreach ($headers as $col => $value) {
|
||||||
|
$sheet->setCellValue($col.'4', $value);
|
||||||
|
}
|
||||||
|
$sheet->getRowDimension(4)->setRowHeight(43.5);
|
||||||
|
$sheet->getStyle('A4:R4')->applyFromArray([
|
||||||
|
'font' => ['bold' => true],
|
||||||
|
'alignment' => [
|
||||||
|
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||||
|
'vertical' => Alignment::VERTICAL_CENTER,
|
||||||
|
'wrapText' => true,
|
||||||
|
],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['argb' => self::HEADER_FILL],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
// Pseudo-merge "Paturelle 1 2 3" via centerContinuous sur E:G
|
||||||
|
$sheet->getStyle('E4:G4')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER_CONTINUOUS);
|
||||||
|
|
||||||
|
// Texte des en-têtes A/B/C en diagonale (60°) comme dans le template,
|
||||||
|
// sans retour à la ligne (le texte peut être tronqué visuellement par la
|
||||||
|
// largeur de colonne, c'est l'effet recherché).
|
||||||
|
$sheet->getStyle('A4:C4')->getAlignment()
|
||||||
|
->setTextRotation(60)
|
||||||
|
->setWrapText(false)
|
||||||
|
;
|
||||||
|
|
||||||
|
// Largeurs de colonnes
|
||||||
|
foreach (self::COLUMN_WIDTHS as $col => $width) {
|
||||||
|
$sheet->getColumnDimension($col)->setWidth($width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lignes de données
|
||||||
|
$rowNumber = 5;
|
||||||
|
foreach ($bovines as $bovine) {
|
||||||
|
$this->writeBovineRow($sheet, $rowNumber, $bovine);
|
||||||
|
++$rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bordures sur l'ensemble du tableau (header + data)
|
||||||
|
$lastDataRow = $rowNumber - 1;
|
||||||
|
if ($lastDataRow >= 4) {
|
||||||
|
$range = 'A4:R'.$lastDataRow;
|
||||||
|
$sheet->getStyle($range)->getBorders()->applyFromArray([
|
||||||
|
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||||
|
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||||
|
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $spreadsheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeBovineRow(Worksheet $sheet, int $row, Bovine $bovine): void
|
||||||
|
{
|
||||||
|
$type = $bovine->getBovineType();
|
||||||
|
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
|
||||||
|
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
|
||||||
|
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
|
||||||
|
$code = $building?->getCode();
|
||||||
|
|
||||||
|
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
|
||||||
|
$sheet->setCellValue('B'.$row, null !== $bovine->getWorkNumber() && ctype_digit($bovine->getWorkNumber())
|
||||||
|
? (int) $bovine->getWorkNumber()
|
||||||
|
: ($bovine->getWorkNumber() ?? ''));
|
||||||
|
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
|
||||||
|
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
|
||||||
|
$sheet->setCellValue('E'.$row, 'B1' === $code ? 'X' : '');
|
||||||
|
$sheet->setCellValue('F'.$row, 'B2' === $code ? 'X' : '');
|
||||||
|
$sheet->setCellValue('G'.$row, 'B3' === $code ? 'X' : '');
|
||||||
|
$sheet->setCellValue('H'.$row, $bovine->getBuildingCase()?->getCaseNumber() ?? '');
|
||||||
|
$sheet->setCellValue('I'.$row, $bovine->getSupplier()?->getName() ?? '');
|
||||||
|
|
||||||
|
$birth = $bovine->getBirthDate();
|
||||||
|
$arrival = $bovine->getArrivalDate();
|
||||||
|
if (null !== $birth) {
|
||||||
|
$sheet->setCellValue('J'.$row, ExcelDate::PHPToExcel($birth));
|
||||||
|
}
|
||||||
|
if (null !== $arrival) {
|
||||||
|
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
|
||||||
|
}
|
||||||
|
if (null !== $birth && null !== $arrival) {
|
||||||
|
$diff = $birth->diff($arrival);
|
||||||
|
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $bovine->getReceivedWeight()) {
|
||||||
|
$sheet->setCellValue('M'.$row, $bovine->getReceivedWeight());
|
||||||
|
}
|
||||||
|
if (null !== $bovine->getPricePerKg()) {
|
||||||
|
$sheet->setCellValue('N'.$row, $bovine->getPricePerKg());
|
||||||
|
}
|
||||||
|
if (null !== $bovine->getFinalPrice()) {
|
||||||
|
$sheet->setCellValue('O'.$row, $bovine->getFinalPrice());
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->setCellValue('P'.$row, $bovine->getAgeMonths() ?? '');
|
||||||
|
// Q (Tport) intentionnellement vide pour l'instant
|
||||||
|
// R = O - Q ; Q vide → R = O
|
||||||
|
if (null !== $bovine->getFinalPrice()) {
|
||||||
|
$sheet->setCellValue('R'.$row, $bovine->getFinalPrice());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats par colonne
|
||||||
|
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('0000');
|
||||||
|
$sheet->getStyle('J'.$row.':K'.$row)->getNumberFormat()->setFormatCode('m/d/yyyy');
|
||||||
|
$sheet->getStyle('M'.$row)->getNumberFormat()->setFormatCode('#,##0');
|
||||||
|
$sheet->getStyle('N'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
|
||||||
|
$sheet->getStyle('O'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
|
||||||
|
$sheet->getStyle('R'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
|
||||||
|
|
||||||
|
// Centrage : A, C, E, F, G, H, P (cellules avec X ou nombres courts)
|
||||||
|
foreach (['A', 'C', 'E', 'F', 'G', 'H', 'P'] as $col) {
|
||||||
|
$sheet->getStyle($col.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coloration uniquement de la cellule "Age mois Aujourd'hui" (P) selon l'âge
|
||||||
|
$color = $this->ageColor($bovine->getAgeMonths());
|
||||||
|
if (null !== $color) {
|
||||||
|
$sheet->getStyle('P'.$row)->getFill()->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()->setARGB($color)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sous-titre dynamique selon les tranches d'âge cochées.
|
||||||
|
*
|
||||||
|
* @param list<string> $ageRanges
|
||||||
|
*/
|
||||||
|
private function computeSubtitle(array $ageRanges): string
|
||||||
|
{
|
||||||
|
$selected = array_values(array_intersect(
|
||||||
|
$ageRanges,
|
||||||
|
[
|
||||||
|
BovineRepository::AGE_RANGE_BETWEEN_20_AND_22,
|
||||||
|
BovineRepository::AGE_RANGE_BETWEEN_22_AND_24,
|
||||||
|
BovineRepository::AGE_RANGE_OVER_24,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
|
||||||
|
$hasLow = in_array(BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, $selected, true);
|
||||||
|
$hasMid = in_array(BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, $selected, true);
|
||||||
|
$hasHigh = in_array(BovineRepository::AGE_RANGE_OVER_24, $selected, true);
|
||||||
|
|
||||||
|
if ([] === $selected) {
|
||||||
|
return 'Inventaire complet';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasLow && $hasMid && $hasHigh) {
|
||||||
|
return 'Âge SUPÉRIEUR ou ÉGAL à 20 MOIS';
|
||||||
|
}
|
||||||
|
if ($hasMid && $hasHigh && !$hasLow) {
|
||||||
|
return 'Âge SUPÉRIEUR ou ÉGAL à 22 MOIS';
|
||||||
|
}
|
||||||
|
if ($hasHigh && !$hasMid && !$hasLow) {
|
||||||
|
return 'Âge SUPÉRIEUR ou ÉGAL à 24 MOIS';
|
||||||
|
}
|
||||||
|
if ($hasLow && $hasMid && !$hasHigh) {
|
||||||
|
return 'Âge entre 20 et 24 MOIS';
|
||||||
|
}
|
||||||
|
if ($hasMid && !$hasLow && !$hasHigh) {
|
||||||
|
return 'Âge entre 22 et 24 MOIS';
|
||||||
|
}
|
||||||
|
if ($hasLow && !$hasMid && !$hasHigh) {
|
||||||
|
return 'Âge entre 20 et 22 MOIS';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection non contiguë (ex: low + high sans mid) → liste explicite
|
||||||
|
$parts = [];
|
||||||
|
if ($hasLow) {
|
||||||
|
$parts[] = '20 à 22 mois';
|
||||||
|
}
|
||||||
|
if ($hasMid) {
|
||||||
|
$parts[] = '22 à 24 mois';
|
||||||
|
}
|
||||||
|
if ($hasHigh) {
|
||||||
|
$parts[] = '≥ 24 mois';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Tranches d\'âge : '.implode(' / ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ageColor(?int $ageMonths): ?string
|
||||||
|
{
|
||||||
|
if (null === $ageMonths) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($ageMonths >= 24) {
|
||||||
|
return self::COLOR_RED;
|
||||||
|
}
|
||||||
|
if ($ageMonths >= 22) {
|
||||||
|
return self::COLOR_ORANGE;
|
||||||
|
}
|
||||||
|
if ($ageMonths >= 20) {
|
||||||
|
return self::COLOR_YELLOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/State/Bovin/BovineInventoryStatsProvider.php
Normal file
38
src/State/Bovin/BovineInventoryStatsProvider.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State\Bovin;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\BovineInventoryStats;
|
||||||
|
use App\Repository\BovineRepository;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<BovineInventoryStats>
|
||||||
|
*/
|
||||||
|
final class BovineInventoryStatsProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BovineRepository $bovineRepository,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BovineInventoryStats
|
||||||
|
{
|
||||||
|
$rawCaseId = $this->requestStack->getCurrentRequest()?->query->get('buildingCaseId');
|
||||||
|
$caseId = null !== $rawCaseId && ctype_digit((string) $rawCaseId) ? (int) $rawCaseId : null;
|
||||||
|
|
||||||
|
$row = $this->bovineRepository->getInventoryStats($caseId);
|
||||||
|
|
||||||
|
$stats = new BovineInventoryStats();
|
||||||
|
$stats->total = $row['total'];
|
||||||
|
$stats->over24 = $row['over24'];
|
||||||
|
$stats->between22And24 = $row['between22And24'];
|
||||||
|
$stats->between20And22 = $row['between20And22'];
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\BovineSyncInventoryResult;
|
use App\ApiResource\BovineSyncInventoryResult;
|
||||||
use App\Entity\Bovine;
|
use App\Entity\Bovine;
|
||||||
|
use App\Entity\BovineType;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||||
@@ -18,6 +19,11 @@ use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
|||||||
*/
|
*/
|
||||||
final class BovineSyncInventoryProcessor implements ProcessorInterface
|
final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, BovineType>
|
||||||
|
*/
|
||||||
|
private array $bovineTypeCache = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private BovinApiInterface $bovinApi,
|
private BovinApiInterface $bovinApi,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
@@ -34,6 +40,13 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
$result = new BovineSyncInventoryResult();
|
$result = new BovineSyncInventoryResult();
|
||||||
$result->total = count($inventory->animals);
|
$result->total = count($inventory->animals);
|
||||||
|
|
||||||
|
$this->bovineTypeCache = [];
|
||||||
|
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
|
||||||
|
if (null !== $bovineType->getCode()) {
|
||||||
|
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$existingByNationalNumber = [];
|
$existingByNationalNumber = [];
|
||||||
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
|
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
|
||||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||||
@@ -83,7 +96,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
$identification = $animal->identification;
|
$identification = $animal->identification;
|
||||||
if (null !== $identification) {
|
if (null !== $identification) {
|
||||||
$bovine->setSex($identification->sex);
|
$bovine->setSex($identification->sex);
|
||||||
$bovine->setBreedCode($identification->breedType);
|
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||||
$bovine->setWorkNumber($identification->workNumber);
|
$bovine->setWorkNumber($identification->workNumber);
|
||||||
$bovine->setBirthDate($identification->birthDate?->date);
|
$bovine->setBirthDate($identification->birthDate?->date);
|
||||||
}
|
}
|
||||||
@@ -102,4 +115,28 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
$bovine->setExitDate($latestExit);
|
$bovine->setExitDate($latestExit);
|
||||||
$bovine->refreshAgeMonths();
|
$bovine->refreshAgeMonths();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un BovineType existant par code, sinon en crée un placeholder
|
||||||
|
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
|
||||||
|
*/
|
||||||
|
private function resolveBovineType(?string $code): ?BovineType
|
||||||
|
{
|
||||||
|
if (null === $code || '' === $code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->bovineTypeCache[$code])) {
|
||||||
|
return $this->bovineTypeCache[$code];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bovineType = new BovineType();
|
||||||
|
$bovineType->setCode($code);
|
||||||
|
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
|
||||||
|
$this->em->persist($bovineType);
|
||||||
|
|
||||||
|
$this->bovineTypeCache[$code] = $bovineType;
|
||||||
|
|
||||||
|
return $bovineType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$breedCode = $bovine->getBreedCode();
|
$breedCode = $bovine->getBovineType()?->getCode();
|
||||||
if (null === $headerBreedCode && null !== $breedCode) {
|
if (null === $headerBreedCode && null !== $breedCode) {
|
||||||
$headerBreedCode = $breedCode;
|
$headerBreedCode = $breedCode;
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,22 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
$aw = (string) ($a['workNumber'] ?? '');
|
||||||
|
$bw = (string) ($b['workNumber'] ?? '');
|
||||||
|
if ('' === $aw && '' === $bw) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ('' === $aw) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ('' === $bw) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $aw <=> (int) $bw;
|
||||||
|
});
|
||||||
|
|
||||||
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
|
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
|
||||||
|
|
||||||
$dompdf = new Dompdf();
|
$dompdf = new Dompdf();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
.sheet { width: auto; }
|
.sheet { width: auto; }
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 8px 0 16px 0;
|
margin: 0 0 8px 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -139,10 +139,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main .sub-title {
|
.main .sub-title {
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
padding: 8px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main .base {
|
.main .base {
|
||||||
@@ -203,61 +203,61 @@
|
|||||||
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
|
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
|
||||||
Arrivage du {{ firstArrivalDate ?? '-' }}
|
Arrivage du {{ firstArrivalDate ?? '-' }}
|
||||||
</h1>
|
</h1>
|
||||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 16px">
|
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 4px">
|
||||||
|
<colgroup>
|
||||||
|
{# 28 colonnes ≈ 3.571% chacune #}
|
||||||
|
{% for _ in 0..27 %}<col style="width:3.571%">{% endfor %}
|
||||||
|
</colgroup>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:40%; vertical-align:top; padding-right:2mm; border:0;">
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="4">PROVENANCE</td>
|
||||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
|
||||||
<tr>
|
|
||||||
<td style="border: 0; height: 20px"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight:700; text-align: left; border: none; font-size: 24px">CASE N° {{ buildingCase.caseNumber ?? '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style="width:60%; vertical-align:top; padding-left:2mm; border:0;">
|
{# Paire 1 : chiffre + case vide #}
|
||||||
<table class="header-right-free" style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">1</td>
|
||||||
<tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:0; text-align:center; font-weight:700; height: 20px;" colspan="5"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Paire 2 #}
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">1</td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">2</td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">2</td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
{# Paire 3 #}
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">3</td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">3</td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">4</td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Paire 4 #}
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">4</td>
|
||||||
<tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:0; text-align:left; font-weight:700; font-size: 24px; width:40%; height: 20px;" colspan="5">PROVENANCE</td>
|
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Espacement entre PROVENANCE et RACE (1 col, RACE commence plus tôt) #}
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;"></td>
|
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
{# Bloc RACE #}
|
||||||
<td style="border:0;"></td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="2">RACE</td>
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">LIMOUSIN</td>
|
||||||
<td style="border:0;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border: 0; width: 20%;" colspan="2"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">CHAROLAIS</td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<tr>
|
<td style="border:0;"></td>
|
||||||
<td style="border: 0; height: 20px" colspan="16"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;">AUTRE</td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td style="border: 0; text-align:left; font-weight:700; font-size: 24px" colspan="3">RACE</td>
|
</table>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">LIMOUSIN</td>
|
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<table style="width:auto; border-collapse:collapse; margin-bottom: 8px; margin-top: 8px">
|
||||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
<tr>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">CHAROLAIS</td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">BATIMENT N°</td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:0; width: 22px;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">Autre</td>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:0; width: 22px;"></td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
</table>
|
<td style="border:0; width: 32px;"></td>
|
||||||
</td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">CASE N°</td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
|
<td style="border:0; width: 22px;"></td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
|
<td style="border:0; width: 22px;"></td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -267,30 +267,29 @@
|
|||||||
<table class="main">
|
<table class="main">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
|
||||||
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
|
|
||||||
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
|
|
||||||
|
|
||||||
{% for month in monthHeaders|default([]) %}
|
|
||||||
<th class="month" style="width:6.58%">{{ month.name }}</th>
|
<th class="month" style="width:6.58%">{{ month.name }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
|
||||||
|
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
|
||||||
|
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
||||||
|
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
{% for month in monthHeaders|default([]) %}
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th class="days">{{ month.days }}</th>
|
<th class="days">{{ month.days }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th class="days">Foin</th>
|
|
||||||
<th class="days">Foin</th>
|
|
||||||
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
|
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
|
||||||
|
<th class="days">Foin</th>
|
||||||
|
<th class="days">Foin</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
{% for month in monthHeaders|default([]) %}
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th class="base">
|
<th class="base">
|
||||||
{% if month.baseValue is defined %}
|
{% if month.baseValue is defined %}
|
||||||
{{ month.baseValue|round(0, 'common') }} kg
|
{{ month.baseValue|round(0, 'common') }} kg
|
||||||
@@ -303,27 +302,28 @@
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{# 11 lignes comme dans ton code (0..10) #}
|
{# 13 lignes comme dans ton code (0..12) #}
|
||||||
{% for i in 0..12 %}
|
{% for i in 0..12 %}
|
||||||
{% set row = rows[i] ?? null %}
|
{% set row = rows[i] ?? null %}
|
||||||
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
|
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
|
||||||
|
|
||||||
<tr class="data-row">
|
<tr class="data-row">
|
||||||
<td class="row-work"></td>
|
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
||||||
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
{% set reversedIdx = (monthCount - 1) - idx %}
|
||||||
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[reversedIdx] ?? null) : null %}
|
||||||
|
<td class="row-month"{% if reversedIdx < 4 %} style="background:#e0e0e0;"{% endif %}>
|
||||||
|
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
<td class="row-birth">
|
<td class="row-birth">
|
||||||
{% if row and row.birthDate %}
|
{% if row and row.birthDate %}
|
||||||
{% set birthParts = row.birthDate|split('/') %}
|
{% set birthParts = row.birthDate|split('/') %}
|
||||||
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
|
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
||||||
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[idx] ?? null) : null %}
|
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
||||||
<td class="row-month"{% if loop.index0 < 4 %} style="background:#e0e0e0;"{% endif %}>
|
<td class="row-work"></td>
|
||||||
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -331,41 +331,89 @@
|
|||||||
<!-- =========================
|
<!-- =========================
|
||||||
FOOTER (traitements / vaccins)
|
FOOTER (traitements / vaccins)
|
||||||
========================= -->
|
========================= -->
|
||||||
<table class="footer" style="border-collapse:collapse; margin-top: 32px">
|
<table style="width:100%; border:0; border-collapse:collapse; table-layout:fixed; margin-top: 12px">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="height: 20px; border: 0" colspan="4"></td>
|
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||||
<td style="font-weight: 700" colspan="2">Grippe</td>
|
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||||
<td style="font-weight: 700" colspan="2">Protivity</td>
|
<tr>
|
||||||
</tr>
|
<td style="font-weight: 700; height: 20px" colspan="10">Traitements</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td style="height: 20px">Date</td>
|
<tr>
|
||||||
<td>Antibiotique</td>
|
<td style="height: 20px" colspan="2">Date</td>
|
||||||
<td>Date</td>
|
<td colspan="2"></td>
|
||||||
<td>Antero</td>
|
<td>Dose</td>
|
||||||
<td>Date</td>
|
<td colspan="5">Observation</td>
|
||||||
<td>Intranasale</td>
|
</tr>
|
||||||
<td>Date</td>
|
<tr>
|
||||||
<td>Rappel 30 jours</td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
</tr>
|
<td colspan="2">Grippe</td>
|
||||||
<tr>
|
<td></td>
|
||||||
<td style="height: 20px"></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
<td></td>
|
<tr>
|
||||||
<td></td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td></td>
|
<td colspan="2">Antéro</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td style="height: 20px"></td>
|
<td colspan="2">Antibiotiques</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
<td></td>
|
<tr>
|
||||||
<td></td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td></td>
|
<td colspan="2">Déparasitage</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td colspan="5"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="5"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td style="border:0; padding:0; width:2%;"></td>
|
||||||
|
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||||
|
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: 700; height: 20px" colspan="10">Rappel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2">Date</td>
|
||||||
|
<td>Dose</td>
|
||||||
|
<td colspan="7">Observation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user