Compare commits

...

31 Commits

Author SHA1 Message Date
b390dd51de Merge branch 'develop' into feat/vie-du-bovin 2026-05-13 14:13:34 +02:00
754898da39 fix : ajout d'une date de mouvement et protection sur le rôle Bureau 2026-05-13 14:10:54 +02:00
5b24d642bb fix : label age bovin 2026-05-07 08:46:11 +02:00
gitea-actions
cde2c4fbb7 chore: bump version to v0.0.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
2026-05-07 06:39:49 +00:00
5552d98935 feat : amélioration du tableau bovin
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-07 08:39:39 +02:00
b932798a87 feat : update CHANGELOG.md 2026-05-06 15:14:05 +02:00
ee766311e3 refactor(bovine) : redirige page case vers Vie du bovin et supprime l'édition front
- infrastructure/case : clic ligne → /bovine/{id}, bouton Ajouter retiré, row-clickable ouvert à tous
- infrastructure/bovine.vue supprimée (création/édition de bovin gérée via EDNOTIF)
- bouton précédent de la page Vie du bovin : router.back avec fallback /inventory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:08:03 +02:00
2f8aa1dd32 feat(bovine) : placeholder onglet Santé
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:50:41 +02:00
de76a77120 feat(bovine) : suivi des mouvements internes (bâtiment/case)
- Entité BovineMovement (bovine, buildingCase|building, enteredAt, leftAt) + relation OneToMany sur Bovine ordonnée DESC
- Endpoint POST /api/bovine_movements via BovineMovementProcessor : ferme le mouvement courant, ouvre le nouveau, synchronise bovine.buildingCase
- Commande idempotente app:backfill-bovine-movements pour initialiser l'historique des bovins existants
- Onglet Mouvement de la page Vie du bovin : form 3 colonnes (style admin) + UiDataTable avec filtres header (Bâtiment, Case actifs ; Du/Au/Durée désactivés)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:45:35 +02:00
642ee43c53 feat(bovine) : page Vie du bovin + tabs réutilisables + parents EDNOTIF
- Nouvelle page /bovine/[id] avec tabs Mouvement / Passeport bovin / Santé
- Composant UiTabs partagé, réutilisé sur réception et expédition
- Champs père/mère (numéro national + type de race) sur Bovine, alimentés via la sync EDNOTIF
- Inventaire : ligne cliquable vers le passeport

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:59:23 +02:00
gitea-actions
9e67a5e289 chore: bump version to v0.0.98
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m26s
2026-05-06 09:45:33 +00:00
92289f9cb2 feat(pdf) : ajout bâtiment dans en-tête rapport poids case
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:43:17 +02:00
gitea-actions
59418f2c66 chore: bump version to v0.0.97
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Failing after 8m13s
2026-05-05 12:19:55 +00:00
e1c9e25187 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-05 14:19:47 +02:00
0b22574932 fix : wording 2026-05-05 14:19:38 +02:00
gitea-actions
9115699f96 chore: bump version to v0.0.96
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m28s
2026-05-05 12:10:57 +00:00
178b4e4eee fix : wording
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-05 14:10:31 +02:00
gitea-actions
fbc8365405 chore: bump version to v0.0.95
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m24s
2026-05-05 09:27:04 +00:00
4561467532 feat(pdf) : refonte en-tête rapport poids case - étape 3
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Footer scindé en deux tableaux Traitements (Date/Antibiotique/Dose/Observation
avec lignes Grippe/Antéro/Antibiotiques/Déparasitage) et Rappel (Date/Dose/
Observation), espacés. Sous-titre POIDS PAR MOIS et marge sous le bandeau
PROVENANCE/RACE réduits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:26:31 +02:00
c441420522 feat(pdf) : refonte en-tête rapport poids case - étape 2
Inversion du sens de lecture du tableau principal : colonnes mois en ordre
inverse, fixes (date naissance, poids, n° travail) à droite. Tri des bovins
par n° de travail croissant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:53:25 +02:00
ba9ea2de71 feat(pdf) : refonte en-tête rapport poids case - étape 1
PROVENANCE et RACE sur la même ligne, chiffres à gauche des cases vides,
CASE N° XX en dessous. Polices ajustées à 18px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:34:15 +02:00
gitea-actions
961fa63f3d chore: bump version to v0.0.94
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m43s
2026-04-30 15:55:26 +00:00
bebfabcacc feat(front) : meta title sur chaque page
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:24:33 +02:00
gitea-actions
e208bcd893 chore: bump version to v0.0.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-04-28 11:52:26 +00:00
3fe0bbf71e feat: modification de la gestion des rôles + ajout rôle d'un bureau (!52)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 11:52:18 +00:00
gitea-actions
d566e5d9f7 chore: bump version to v0.0.92
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-04-28 10:03:56 +00:00
5bb0aad620 feat: amélioration de l'export inventaire bovin (!51)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 10:03:50 +00:00
gitea-actions
19a29f854e chore: bump version to v0.0.91
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
2026-04-28 07:37:20 +00:00
c21dcd1869 docs : étapes détaillées pour le feed des prix bovins en prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Passe par /tmp (le user SSH n'a pas les droits sur /var/www/ferme)
- 6 étapes numérotées (scp, ssh, cd, dry-run, run, rm)
- Note sur chmod 644 si www-data ne peut pas lire

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:36:57 +02:00
gitea-actions
86cb3c276a chore: bump version to v0.0.90
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m34s
2026-04-28 07:25:39 +00:00
08a17f91b3 feat: ajout du prix au kilo et de l'age moyen bovin + feed bovin via xlsx (!50)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 07:25:31 +00:00
74 changed files with 2767 additions and 663 deletions

View File

@@ -4,7 +4,9 @@
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)",
"Bash(pip3 install:*)",
"Bash(python3 -c \":*)"
"Bash(python3 -c \":*)",
"Bash(make cache-clear *)",
"Bash(make test *)"
]
}
}

View File

@@ -1,5 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:f407a514-c6b4-4b26-9555-445a85892502&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:9cad43df-2147-4989-b7a4-443067034884&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

5
.idea/ferme.iml generated
View File

@@ -155,6 +155,11 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/complex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/matrix" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
<excludePattern pattern="reference.php" />
</content>
<orderEntry type="inheritedJdk" />

5
.idea/php.xml generated
View File

@@ -174,6 +174,11 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

145
.idea/workspace.xml generated
View File

@@ -4,12 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : label age bovin">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/db-forest-config.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/db-forest-config.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/BovineMovement.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovineMovement.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -41,7 +45,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
<entry key="$PROJECT_DIR$" value="feat/entree-sortie" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -213,6 +217,11 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="ProjectColorInfo">{
@@ -232,7 +241,9 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feat/vie-du-bovin",
"git.auto.fetch.suggestion.counter": "3",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -274,7 +285,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
<option value="bundled-php-predefined-a98d8de5180a-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" />
</set>
</attachedChunks>
</component>
@@ -327,54 +338,16 @@
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
<created>1769529522614</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
</task>
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
</task>
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<option name="closed" value="true" />
<created>1769700808988</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1769700808988</updated>
</task>
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
<option name="closed" value="true" />
<created>1769705141157</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1769705141157</updated>
</task>
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1769705240487</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1769705240487</updated>
</task>
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
<option name="closed" value="true" />
<created>1769760766200</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1769760766200</updated>
<workItem from="1776755742205" duration="88521000" />
<workItem from="1777453284124" duration="86000" />
<workItem from="1777453433907" duration="337000" />
<workItem from="1777454070632" duration="17254000" />
<workItem from="1777540415843" duration="13205000" />
<workItem from="1777877316149" duration="29389000" />
<workItem from="1777982616362" duration="23909000" />
<workItem from="1778482021120" duration="1280000" />
<workItem from="1778656317630" duration="279000" />
<workItem from="1778664396844" duration="2576000" />
</task>
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
<option name="closed" value="true" />
@@ -720,7 +693,55 @@
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<task id="LOCAL-00086" summary="fix : update icon entrée/sortie">
<option name="closed" value="true" />
<created>1777896558092</created>
<option name="number" value="00086" />
<option name="presentableId" value="LOCAL-00086" />
<option name="project" value="LOCAL" />
<updated>1777896558092</updated>
</task>
<task id="LOCAL-00087" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983048277</created>
<option name="number" value="00087" />
<option name="presentableId" value="LOCAL-00087" />
<option name="project" value="LOCAL" />
<updated>1777983048278</updated>
</task>
<task id="LOCAL-00088" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983581324</created>
<option name="number" value="00088" />
<option name="presentableId" value="LOCAL-00088" />
<option name="project" value="LOCAL" />
<updated>1777983581324</updated>
</task>
<task id="LOCAL-00089" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1778073247660</created>
<option name="number" value="00089" />
<option name="presentableId" value="LOCAL-00089" />
<option name="project" value="LOCAL" />
<updated>1778073247660</updated>
</task>
<task id="LOCAL-00090" summary="feat : amélioration du tableau bovin">
<option name="closed" value="true" />
<created>1778135981350</created>
<option name="number" value="00090" />
<option name="presentableId" value="LOCAL-00090" />
<option name="project" value="LOCAL" />
<updated>1778135981350</updated>
</task>
<task id="LOCAL-00091" summary="fix : label age bovin">
<option name="closed" value="true" />
<created>1778136373027</created>
<option name="number" value="00091" />
<option name="presentableId" value="LOCAL-00091" />
<option name="project" value="LOCAL" />
<updated>1778136373027</updated>
</task>
<option name="localTasksCounter" value="92" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -770,10 +791,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
@@ -792,10 +809,14 @@
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<MESSAGE value="fix : update icon entrée/sortie" />
<MESSAGE value="fix : wording" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : amélioration du tableau bovin" />
<MESSAGE value="fix : label age bovin" />
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -65,6 +65,7 @@ Ajouter dans le fichier .env du frontend
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
* [#FER-26] Passeport du bovin
### Changed

View File

@@ -82,7 +82,7 @@ frontend/
- 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.
- 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.
- Endpoints toujours au pluriel (convention API Platform).
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.

View File

@@ -178,3 +178,77 @@ Pour suivre les logs en temps réel :
- tail -f var/log/dev.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)

View File

@@ -1,4 +1,11 @@
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
password_hashers:
App\Entity\User: 'auto'

View File

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

View 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é&nbsp;: 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>

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

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
v-for="tab in tabs"
:key="tab.key"
class="font-bold text-3xl uppercase px-12 cursor-pointer"
:class="[
modelValue === tab.key
? 'border-b-[6px] border-primary-500 text-primary-500'
: 'text-primary-500/50',
tab.error ? '!text-red-500 !border-red-500' : ''
]"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
</h1>
</div>
</template>
<script setup lang="ts" generic="T extends string">
export interface UiTab<K extends string = string> {
key: K
label: string
error?: boolean
}
defineProps<{
modelValue: T
tabs: UiTab<T>[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: T): void
}>()
</script>

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

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

View File

@@ -32,6 +32,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Type de bovin' })
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()

View File

@@ -38,6 +38,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Types de bovins' })
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -44,6 +44,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteur' })
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
import {computed} from "vue";

View File

@@ -34,6 +34,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteurs' })
import type { CarrierData } from '~/services/dto/carrier-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -96,6 +96,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Client' })
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"

View File

@@ -3,6 +3,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse client' })
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"

View File

@@ -44,6 +44,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Clients' })
import type { CustomerData } from '~/services/dto/customer-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -97,6 +97,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseur' })
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"

View File

@@ -3,6 +3,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse fournisseur' })
import type {AddressData, AddressPayload} from "~/services/address";
import {createAddress, getAddress, updateAddress} from "~/services/address";
import {getSupplier, updateSupplier} from "~/services/supplier";

View File

@@ -44,6 +44,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseurs' })
import type { SupplierData } from '~/services/dto/supplier-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -74,6 +74,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateur' })
import { computed, reactive, ref, watch } from 'vue'
import { ROLE } from '~/utils/constants'
import { createUser, updateUser, getUser } from '~/services/auth'

View File

@@ -63,6 +63,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateurs' })
import type { UserData } from '~/services/dto/user-data'
import { ROLE } from '~/utils/constants'
import { useAuthStore } from '~/stores/auth'

View File

@@ -0,0 +1,359 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative mb-10">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="tabs"
/>
<div v-if="auth.isBureau" v-show="activeTab === 'mouvement'">
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
<div class="flex flex-cols-3 justify-between mb-10">
<UiSelect
id="movement-building"
v-model="newMovementBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-[280px]"
required
/>
<UiSelect
id="movement-case"
v-model="newMovementCaseId"
label="Case"
:options="caseOptions"
:disabled="!newMovementBuildingId"
wrapper-class="w-[280px]"
required
/>
<UiDateInput
id="movement-date"
v-model="newMovementDate"
label="Date mouvement"
wrapper-class="w-[280px]"
required
/>
</div>
<div class="flex items-center justify-center mb-11">
<UiButton
type="submit"
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
:disabled="isSubmittingMovement"
:loading="isSubmittingMovement"
@click="movementSubmitted = true"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</form>
<UiDataTable
:columns="movementColumns"
:items="filteredMovementRows"
:per-page="10"
>
<template #header-building>
<UiTextInput
v-model="movementFilters.building"
placeholder="Bâtiment"
size="compact"
/>
</template>
<template #header-case>
<UiTextInput
v-model="movementFilters.case"
placeholder="Case"
size="compact"
/>
</template>
<template #header-enteredAt>
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
</template>
<template #header-leftAt>
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
</template>
<template #header-duration>
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
</template>
<template #cell-leftAt="{ item }">
<span v-if="item.leftAt">{{ item.leftAt }}</span>
<span v-else class="italic text-slate-500">En cours</span>
</template>
</UiDataTable>
</div>
<div v-show="activeTab === 'passeport'">
<div class="mt-6">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
</div>
</div>
</div>
<div v-show="activeTab === 'sante'">
<div class="border-2 border-dashed border-primary-500 rounded-md py-16 text-center text-primary-500 font-bold uppercase text-2xl">
À venir
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
import { useAuthStore } from '~/stores/auth'
useHead({ title: 'Vie du bovin' })
const auth = useAuthStore()
type BovineTab = 'mouvement' | 'passeport' | 'sante'
const tabs = computed(() => [
...(auth.isBureau ? [{ key: 'mouvement' as const, label: 'Mouvement' }] : []),
{ key: 'passeport' as const, label: 'Passeport bovin' },
{ key: 'sante' as const, label: 'Santé' }
])
const activeTab = ref<BovineTab>(auth.isBureau ? 'mouvement' : 'passeport')
interface BovineTypeRef {
id: number
label: string | null
code: string | null
}
interface BuildingRef {
label: string | null
}
interface BuildingCaseRef {
caseNumber: number | null
building: BuildingRef | null
}
interface BovineMovementData {
id: number
enteredAt: string
leftAt: string | null
buildingCase: BuildingCaseRef | null
building: BuildingRef | null
}
interface BovinePassportData {
id: number
nationalNumber: string
workNumber: string | null
sex: string | null
birthDate: string | null
exitedAt: string | null
exitDate: string | null
bovineType: BovineTypeRef | null
motherNationalNumber: string | null
motherBovineType: BovineTypeRef | null
fatherNationalNumber: string | null
fatherBovineType: BovineTypeRef | null
movements: BovineMovementData[]
}
const router = useRouter()
const route = useRoute()
const api = useApi()
const goBack = () => {
if (window.history.state?.back) {
router.back()
} else {
router.push('/inventory')
}
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const bovine = ref<BovinePassportData | null>(null)
const buildings = ref<BuildingData[]>([])
const newMovementBuildingId = ref<string | number | null>(null)
const newMovementCaseId = ref<string | number | null>(null)
const newMovementDate = ref<string>(todayIso())
const isSubmittingMovement = ref(false)
const movementSubmitted = ref(false)
const movementFilters = ref({ building: '', case: '' })
const bovineId = computed(() => {
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const n = Number(raw)
return Number.isFinite(n) ? n : null
})
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
if (!nationalNumber) return null
return nationalNumber.slice(-4)
}
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(newMovementBuildingId.value))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(newMovementBuildingId, () => {
newMovementCaseId.value = null
})
const movementColumns = [
{ key: 'building', label: 'Bâtiment' },
{ key: 'case', label: 'Case' },
{ key: 'enteredAt', label: 'Du' },
{ key: 'leftAt', label: 'Au' },
{ key: 'duration', label: 'Durée' }
]
const movementEndDate = (movement: BovineMovementData): string | null => {
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
}
const formatDuration = (movement: BovineMovementData): string => {
const start = new Date(movement.enteredAt)
if (isNaN(start.getTime())) return '—'
const endRaw = movementEndDate(movement)
const end = endRaw ? new Date(endRaw) : new Date()
if (isNaN(end.getTime())) return '—'
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
return `${days} j`
}
const movementRows = computed(() => {
const list = bovine.value?.movements ?? []
return list.map(m => ({
id: m.id,
building: m.buildingCase?.building?.label ?? m.building?.label ?? '—',
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
enteredAt: formatDate(m.enteredAt),
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
duration: formatDuration(m)
}))
})
const filteredMovementRows = computed(() => {
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
const caseFilter = movementFilters.value.case.trim().toLowerCase()
return movementRows.value.filter(row => {
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
return true
})
})
const submitMovement = async () => {
if (!newMovementCaseId.value || !newMovementDate.value || bovineId.value === null) return
const buildingLabel = buildingOptions.value.find(o => o.value === Number(newMovementBuildingId.value))?.label ?? '—'
const caseLabel = caseOptions.value.find(o => o.value === Number(newMovementCaseId.value))?.label ?? '—'
const dateLabel = formatDate(newMovementDate.value)
const confirmed = window.confirm(
`Confirmer la création du mouvement ?\n\nBâtiment : ${buildingLabel}\nCase : ${caseLabel}\nDate : ${dateLabel}`
)
if (!confirmed) return
isSubmittingMovement.value = true
try {
await api.post('bovine_movements', {
bovine: `/api/bovines/${bovineId.value}`,
buildingCase: `/api/building_cases/${newMovementCaseId.value}`,
enteredAt: newMovementDate.value
}, { toastSuccessMessage: 'Mouvement enregistré' })
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
newMovementBuildingId.value = null
newMovementCaseId.value = null
newMovementDate.value = todayIso()
movementSubmitted.value = false
} finally {
isSubmittingMovement.value = false
}
}
onMounted(async () => {
if (bovineId.value === null) return
const [bovineData, buildingList] = await Promise.all([
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
getBuildingList()
])
bovine.value = bovineData
buildings.value = buildingList
})
</script>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
useHead({ title: 'Accueil' })
</script>
<template>
<div class="flex flex-wrap justify-center pb-16 gap-12">

View File

@@ -1,180 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput
id="bovine-national-number"
v-model="form.nationalNumber"
label="Numéro national"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
required
/>
<UiNumberInput
id="bovine-received-weight"
v-model="form.receivedWeight"
label="Poids à l'arrivée (kg)"
:min="0"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px] flex-col"
label-class="font-bold uppercase"
/>
<UiDateInput
id="bovine-arrival-date"
v-model="form.arrivalDate"
label="Date d'arrivée"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
</div>
<div class="flex flex-cols-3 justify-between mb-11">
<UiSelect
id="bovine-supplier"
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
:loading="isLoadingSuppliers"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
<div class="w-[280px]" />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
type="submit"
:disabled="!auth.isAdmin || isLoading"
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
import type { BovinePayload } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const caseId = computed(() => {
const raw = Number(route.query.caseId)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const bovineId = computed(() => {
const raw = Number(route.query.id)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const isEdit = computed(() => bovineId.value !== null)
const form = reactive<{
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
supplierId: string
}>({
nationalNumber: '',
receivedWeight: null,
arrivalDate: null,
supplierId: ''
})
const isLoading = ref(false)
const submitted = ref(false)
const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false)
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
)
const backRoute = computed(() => ({
path: '/infrastructure/case',
query: caseId.value ? { id: String(caseId.value) } : {}
}))
const goBack = () => {
router.push(backRoute.value)
}
const loadSuppliers = async () => {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
const hydrate = async () => {
if (!isEdit.value || bovineId.value === null) {
return
}
isLoading.value = true
try {
const bovine = await getBovine(bovineId.value)
form.nationalNumber = bovine.nationalNumber ?? ''
form.receivedWeight = bovine.receivedWeight ?? null
form.arrivalDate = bovine.arrivalDate ?? null
if (bovine.supplier) {
const supplierId = bovine.supplier.replace(/.*\//, '')
form.supplierId = supplierId
}
} finally {
isLoading.value = false
}
}
const validate = async () => {
if (isLoading.value || !auth.isAdmin) return
if (!caseId.value) return
if (!form.nationalNumber.trim()) return
const payload: BovinePayload = {
nationalNumber: form.nationalNumber.trim(),
receivedWeight: form.receivedWeight,
arrivalDate: form.arrivalDate,
buildingCase: `/api/building_cases/${caseId.value}`,
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
}
isLoading.value = true
try {
if (isEdit.value && bovineId.value !== null) {
await updateBovine(bovineId.value, payload)
} else {
await createBovine(payload)
}
router.push(backRoute.value)
} finally {
isLoading.value = false
}
}
onMounted(loadSuppliers)
watch(bovineId, hydrate, { immediate: true })
</script>

View File

@@ -80,6 +80,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Bâtiments' })
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"

View File

@@ -23,17 +23,24 @@
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId && auth.isAdmin"
:to="addBovineRoute"
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-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
v-model:page="page"
v-model:per-page="perPage"
@@ -41,8 +48,7 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
:row-clickable="auth.isAdmin"
row-clickable
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
>
@@ -74,19 +80,19 @@
<template #header-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</template>
<template #header-breedCode>
<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.breedCode"
v-model="filters['bovineType.label']"
placeholder="Race"
size="compact"
/>
</template>
<template #header-buildingCase.building.label>
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
</template>
<template #header-buildingCase.caseNumber>
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template>
@@ -94,16 +100,21 @@
{{ formatDate(item.birthDate) }}
</template>
<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 #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
</UiDataTable>
</div>
@@ -111,23 +122,52 @@
</template>
<script setup lang="ts">
useHead({ title: 'Cases' })
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { formatAgeLabel } from '~/utils/bovine-age'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
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 } =
useDataTableServerState<BovineData>(
'bovines',
@@ -136,7 +176,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
buildingCase: '',
nationalNumber: '',
workNumber: '',
breedCode: '',
'bovineType.label': '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
@@ -174,17 +214,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const columns = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const { columns } = useBovineColumns({ variant: 'case' })
const title = computed(() => {
if (!buildingCase.value) return ''
@@ -193,11 +223,6 @@ const title = computed(() => {
return `${buildingLabel} case ${caseNumber}`.trim()
})
const addBovineRoute = computed(() => ({
path: '/infrastructure/bovine',
query: { caseId: String(caseId.value) }
}))
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
@@ -209,14 +234,12 @@ const formatDate = (date: string | null) => {
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
return ''
const formatPrice = (price: number | null) => {
if (price === null || price === undefined) return ''
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
const loadCase = async () => {
if (!hasCaseId.value) {
buildingCase.value = null
@@ -232,11 +255,7 @@ const printCaseReport = async () => {
}
const goToBovine = (bovine: BovineData) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(bovine.id), caseId: String(caseId.value) }
})
router.push(`/bovine/${bovine.id}`)
}
watch(caseId, (id) => {
@@ -247,6 +266,7 @@ watch(caseId, (id) => {
}
filters.value.buildingCase = `/api/building_cases/${id}`
loadCase()
loadStats()
reload()
}, { immediate: true })
</script>

View File

@@ -13,16 +13,17 @@
<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="exportInventory"
@click="showExportModal = true"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<button
v-if="auth.isAdmin"
v-if="auth.isBureau"
type="button"
:disabled="syncing"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
@@ -34,17 +35,17 @@
</div>
<div class="flex flex-wrap gap-3 mt-4">
<div class="flex items-center gap-3 rounded-md border-2 border-violet-300 px-4 py-2">
<span class="text-2xl font-bold text-violet-700">{{ stats.over24 }}</span>
<span class="text-sm uppercase tracking-wide text-violet-700"> 24 mois</span>
<div 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 border-2 border-red-300 px-4 py-2">
<span class="text-2xl font-bold text-red-700">{{ stats.between22And24 }}</span>
<span class="text-sm uppercase tracking-wide text-red-700">22 24 mois</span>
<div 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 border-2 border-orange-300 px-4 py-2">
<span class="text-2xl font-bold text-orange-700">{{ stats.between20And22 }}</span>
<span class="text-sm uppercase tracking-wide text-orange-700">20 22 mois</span>
<div 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>
@@ -56,7 +57,8 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
row-clickable
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
>
<template #header-nationalNumber>
<UiTextInput
@@ -83,9 +85,9 @@
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder=" le" />
</template>
<template #header-breedCode>
<template #header-bovineType.label>
<UiTextInput
v-model="filters.breedCode"
v-model="filters['bovineType.label']"
placeholder="Race"
size="compact"
/>
@@ -102,32 +104,59 @@
<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 #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<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 #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
{{ item.effectiveBuilding?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
</UiDataTable>
</div>
<InventoryExportModal
v-model="showExportModal"
:loading="exporting"
@submit="exportInventory"
/>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Inventaire' })
import type { BovineData } from '~/services/dto/bovine-data'
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
import { useAuthStore } from '~/stores/auth'
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 auth = useAuthStore()
@@ -165,12 +194,17 @@ const loadStats = async () => {
const syncing = ref(false)
const exporting = ref(false)
const showExportModal = ref(false)
const exportInventory = async () => {
const exportInventory = async (filters: InventoryExportFilters) => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('bovines/inventory-export')
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')
@@ -181,6 +215,7 @@ const exportInventory = async () => {
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
showExportModal.value = false
} catch {
// toast déjà géré par useApi onResponseError
} finally {
@@ -218,7 +253,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'exists[exitedAt]': 'false',
nationalNumber: '',
workNumber: '',
breedCode: '',
'bovineType.label': '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
@@ -255,17 +290,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const columns = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const { columns } = useBovineColumns()
const formatDate = (date: string | null) => {
if (!date) return '—'
@@ -278,14 +303,12 @@ const formatDate = (date: string | null) => {
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
return ''
const formatPrice = (price: number | null) => {
if (price === null || price === undefined) return ''
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
onMounted(() => {
reload()
loadStats()

View File

@@ -53,6 +53,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Connexion' })
import type { UserData } from '~/services/dto/user-data'
import { getUsers } from '~/services/auth'
import { useAuthStore } from '~/stores/auth'

View File

@@ -54,6 +54,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réception' })
import { useReceptionStore } from '~/stores/reception'
import { storeToRefs } from 'pinia'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -1,7 +1,7 @@
<template>
<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"/>
<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 class="px-[86px]">
@@ -73,6 +73,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation réception' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'

View File

@@ -145,38 +145,14 @@
/>
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError },
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'merchandise', label: isMerchandise ? 'Marchandise' : 'Bovins', error: hasMerchandiseTabError }
]"
/>
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weights'"
@@ -226,6 +202,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier réception' })
import { usePdfPrinter } from '#imports'
import { computed } from 'vue'
import UpdateBovin from '~/components/reception/update-bovin.vue'

View File

@@ -1,7 +1,7 @@
<template>
<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"/>
<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 class="px-[86px]">
@@ -72,6 +72,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réceptions en attente' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { deleteReception } from '~/services/reception'

View File

@@ -125,6 +125,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Scanner' })
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'

View File

@@ -51,6 +51,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expédition' })
import { storeToRefs } from 'pinia'
import { useShipmentStore } from '~/stores/shipment'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -1,7 +1,7 @@
<template>
<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"/>
<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 class="px-[86px]">
@@ -71,6 +71,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation expédition' })
import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'

View File

@@ -146,28 +146,13 @@
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
]"
/>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"
@@ -197,6 +182,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier expédition' })
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'

View File

@@ -1,7 +1,7 @@
<template>
<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"/>
<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 class="px-[86px]">
@@ -84,6 +84,8 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expéditions en attente' })
import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { deleteShipment } from '~/services/shipment'

View File

@@ -1,19 +1,27 @@
export interface BovineBuildingRef {
label: string
}
export interface BovineBuildingCaseRef {
caseNumber: number | null
building: { label: string } | null
building: BovineBuildingRef | null
}
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
pricePerKg: number | null
finalPrice: number | null
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
breedCode: string | null
bovineType: { id: number; label: string; code: string } | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
@@ -22,6 +30,7 @@ export interface BovineData {
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
pricePerKg?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null

View File

@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
import type {UserData} from '~/services/dto/user-data'
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
import type {UserPayload} from "~/services/dto/user-data";
import {ROLE} from '~/utils/constants'
import {userHasRole} from '~/utils/roles'
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -12,7 +12,9 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
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: {
clearSession() {

View File

@@ -4,7 +4,15 @@ export const formatAgeLabel = (months: number | null | undefined): string => {
const remaining = months % 12
let label = ''
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} m`
if (!label) label = '< 1 mois'
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 ''
}

View File

@@ -10,6 +10,7 @@ export const MERCHANDISE_TYPE_CODES = {
export const ROLE = [
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
{ label: 'Bureau', value: 'ROLE_BUREAU' },
{ label: 'Utilisateur', value: 'ROLE_USER' }
]
export const SUPPLIER_CODE = {

38
frontend/utils/roles.ts Normal file
View 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)
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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');
}
}

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

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

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

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504125011 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add mother/father national number and bovine type to bovine.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD mother_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD mother_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F14E5E9FB FOREIGN KEY (mother_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F53F12909 FOREIGN KEY (father_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2068337F14E5E9FB ON bovine (mother_bovine_type_id)');
$this->addSql('CREATE INDEX IDX_2068337F53F12909 ON bovine (father_bovine_type_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F14E5E9FB');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F53F12909');
$this->addSql('DROP INDEX IDX_2068337F14E5E9FB');
$this->addSql('DROP INDEX IDX_2068337F53F12909');
$this->addSql('ALTER TABLE bovine DROP mother_national_number');
$this->addSql('ALTER TABLE bovine DROP father_national_number');
$this->addSql('ALTER TABLE bovine DROP mother_bovine_type_id');
$this->addSql('ALTER TABLE bovine DROP father_bovine_type_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506141455 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create bovine_movement table to track internal building/case history.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE bovine_movement (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bovine_id INT NOT NULL, building_case_id INT DEFAULT NULL, building_id INT DEFAULT NULL, entered_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, left_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_bovine_movement_bovine ON bovine_movement (bovine_id)');
$this->addSql('CREATE INDEX idx_bovine_movement_timeline ON bovine_movement (bovine_id, entered_at)');
$this->addSql('CREATE INDEX idx_bovine_movement_case ON bovine_movement (building_case_id)');
$this->addSql('CREATE INDEX idx_bovine_movement_building ON bovine_movement (building_id)');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_bovine FOREIGN KEY (bovine_id) REFERENCES bovine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_case FOREIGN KEY (building_case_id) REFERENCES building_case (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_building FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE bovine_movement');
}
}

View File

@@ -19,7 +19,7 @@ use App\State\Bovin\BovineInventoryExportProvider;
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
tags: ['Bovines'],
),
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: BovineInventoryExportProvider::class,
),

View File

@@ -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.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_BUREAU')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use App\Entity\BovineMovement;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function count;
#[AsCommand(
name: 'app:backfill-bovine-movements',
description: 'Crée un mouvement initial pour chaque bovin ayant une case ou un bâtiment mais aucun mouvement enregistré.'
)]
class BackfillBovineMovementsCommand extends Command
{
private const FLUSH_EVERY = 100;
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bovines = $this->entityManager->createQueryBuilder()
->select('b')
->from(Bovine::class, 'b')
->where('b.buildingCase IS NOT NULL OR b.building IS NOT NULL')
->andWhere('NOT EXISTS (SELECT 1 FROM '.BovineMovement::class.' m WHERE m.bovine = b)')
->getQuery()
->getResult()
;
$total = count($bovines);
if (0 === $total) {
$io->success('Aucun bovin à backfiller.');
return Command::SUCCESS;
}
$io->info(sprintf('%d bovin(s) à backfiller.', $total));
$now = new DateTimeImmutable();
$created = 0;
$fallback = 0;
foreach ($bovines as $i => $bovine) {
$movement = new BovineMovement();
$movement->setBovine($bovine);
if (null !== $bovine->getBuildingCase()) {
$movement->setBuildingCase($bovine->getBuildingCase());
} else {
$movement->setBuilding($bovine->getBuilding());
}
$enteredAt = $bovine->getArrivalDate();
if (null === $enteredAt) {
$enteredAt = $now;
++$fallback;
}
$movement->setEnteredAt($enteredAt);
$this->entityManager->persist($movement);
++$created;
if (0 === ($i + 1) % self::FLUSH_EVERY) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$io->success(sprintf('%d mouvement(s) créé(s).', $created));
if ($fallback > 0) {
$io->warning(sprintf("%d bovin(s) sans date d'arrivée → enteredAt = maintenant.", $fallback));
}
return Command::SUCCESS;
}
}

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

View File

@@ -476,9 +476,12 @@ class SeedCommand extends Command
private function seedBovineTypes(): void
{
$bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
];
foreach ($bovineTypes as $type) {
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {

View File

@@ -77,9 +77,12 @@ class ReferenceFixtures extends Fixture
}
$bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
];
foreach ($bovineTypes as $type) {
$bovineType = new BovineType()

View File

@@ -14,24 +14,28 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: BovineRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'bovineType.label' => 'ipartial',
'bovineType.code' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
@@ -77,6 +81,11 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
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)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
@@ -87,6 +96,11 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
@@ -100,9 +114,10 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\Column(length: 20, nullable: true)]
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
#[ApiProperty(readableLink: true)]
private ?BovineType $bovineType = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
@@ -122,6 +137,37 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $motherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $motherBovineType = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $fatherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $fatherBovineType = null;
/**
* @var Collection<int, BovineMovement>
*/
#[ORM\OneToMany(targetEntity: BovineMovement::class, mappedBy: 'bovine', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['enteredAt' => 'DESC'])]
#[Groups(['bovine:read'])]
private Collection $movements;
public function __construct()
{
$this->movements = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -151,6 +197,29 @@ class Bovine
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
{
return $this->arrivalDate;
@@ -175,6 +244,28 @@ class Bovine
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
{
return $this->supplier;
@@ -211,14 +302,14 @@ class Bovine
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;
}
@@ -271,6 +362,79 @@ class Bovine
return $this;
}
public function getMotherNationalNumber(): ?string
{
return $this->motherNationalNumber;
}
public function setMotherNationalNumber(?string $motherNationalNumber): static
{
$this->motherNationalNumber = $motherNationalNumber;
return $this;
}
public function getMotherBovineType(): ?BovineType
{
return $this->motherBovineType;
}
public function setMotherBovineType(?BovineType $motherBovineType): static
{
$this->motherBovineType = $motherBovineType;
return $this;
}
public function getFatherNationalNumber(): ?string
{
return $this->fatherNationalNumber;
}
public function setFatherNationalNumber(?string $fatherNationalNumber): static
{
$this->fatherNationalNumber = $fatherNationalNumber;
return $this;
}
public function getFatherBovineType(): ?BovineType
{
return $this->fatherBovineType;
}
public function setFatherBovineType(?BovineType $fatherBovineType): static
{
$this->fatherBovineType = $fatherBovineType;
return $this;
}
/**
* @return Collection<int, BovineMovement>
*/
public function getMovements(): Collection
{
return $this->movements;
}
public function addMovement(BovineMovement $movement): static
{
if (!$this->movements->contains($movement)) {
$this->movements->add($movement);
$movement->setBovine($this);
}
return $this;
}
public function removeMovement(BovineMovement $movement): static
{
$this->movements->removeElement($movement);
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Repository\BovineMovementRepository;
use App\State\Bovin\BovineMovementProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: BovineMovementRepository::class)]
#[ORM\Table(name: 'bovine_movement')]
#[ORM\Index(name: 'idx_bovine_movement_timeline', columns: ['bovine_id', 'entered_at'])]
#[ApiResource(
operations: [
new Post(
denormalizationContext: ['groups' => ['bovine_movement:write']],
normalizationContext: ['groups' => ['bovine:read']],
processor: BovineMovementProcessor::class,
),
],
security: "is_granted('ROLE_BUREAU')",
)]
class BovineMovement
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bovine:read'])]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'movements')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['bovine_movement:write'])]
private Bovine $bovine;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine_movement:write'])]
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['bovine:read', 'bovine_movement:write'])]
private DateTimeImmutable $enteredAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read'])]
private ?DateTimeImmutable $leftAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getBovine(): Bovine
{
return $this->bovine;
}
public function setBovine(Bovine $bovine): static
{
$this->bovine = $bovine;
return $this;
}
public function getBuildingCase(): ?BuildingCase
{
return $this->buildingCase;
}
public function setBuildingCase(?BuildingCase $buildingCase): static
{
$this->buildingCase = $buildingCase;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
}
public function setBuilding(?Building $building): static
{
$this->building = $building;
return $this;
}
public function getEnteredAt(): DateTimeImmutable
{
return $this->enteredAt;
}
public function hasEnteredAt(): bool
{
return isset($this->enteredAt);
}
public function setEnteredAt(DateTimeImmutable $enteredAt): static
{
$this->enteredAt = $enteredAt;
return $this;
}
public function getLeftAt(): ?DateTimeImmutable
{
return $this->leftAt;
}
public function setLeftAt(?DateTimeImmutable $leftAt): static
{
$this->leftAt = $leftAt;
return $this;
}
}

View File

@@ -51,11 +51,11 @@ class BovineType
private ?int $id = null;
#[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;
#[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;
public function getId(): ?int

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ORM\Table(name: 'building')]
#[ApiResource(
order: ['displayOrder' => 'ASC', 'id' => 'ASC'],
operations: [
new Get(
requirements: ['id' => '\d+'],
@@ -43,6 +44,10 @@ class Building
#[Groups(['building:read', 'building:summary', 'reception:read'])]
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>
*/
@@ -101,6 +106,18 @@ class Building
return $this;
}
public function getDisplayOrder(): ?int
{
return $this->displayOrder;
}
public function setDisplayOrder(?int $displayOrder): self
{
$this->displayOrder = $displayOrder;
return $this;
}
/**
* @return Collection<int, Reception>
*/

View File

@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/receptions/weigh',
@@ -511,14 +512,10 @@ class Reception
$this->identificationNumber = $number;
$args->getObjectManager()
->getConnection()
->executeStatement(
'UPDATE reception SET identification_number = :number WHERE id = :id',
[
'number' => $number,
'id' => $this->id,
]
)
->createQuery(sprintf('UPDATE %s r SET r.identificationNumber = :number WHERE r.id = :id', self::class))
->setParameter('number', $number)
->setParameter('id', $this->id)
->execute()
;
}

View File

@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/shipments/weigh',
@@ -358,14 +359,10 @@ class Shipment
$this->identificationNumber = $number;
$args->getObjectManager()
->getConnection()
->executeStatement(
'UPDATE shipment SET identification_number = :number WHERE id = :id',
[
'number' => $number,
'id' => $this->id,
]
)
->createQuery(sprintf('UPDATE %s s SET s.identificationNumber = :number WHERE s.id = :id', self::class))
->setParameter('number', $number)
->setParameter('id', $this->id)
->execute()
;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bovine;
use App\Entity\BovineMovement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BovineMovement>
*/
final class BovineMovementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BovineMovement::class);
}
public function findOpenMovement(Bovine $bovine): ?BovineMovement
{
return $this->createQueryBuilder('m')
->where('m.bovine = :bovine')
->andWhere('m.leftAt IS NULL')
->setParameter('bovine', $bovine)
->orderBy('m.enteredAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

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

View File

@@ -7,15 +7,18 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Bovine;
use App\Repository\BovineRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
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\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -23,44 +26,62 @@ use Symfony\Component\HttpFoundation\Response;
*/
final class BovineInventoryExportProvider implements ProviderInterface
{
private const HEADER_FILL = 'FFF1F5F9';
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const COLOR_VIOLET = 'FFC4B5FD';
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 HEADERS = [
'N° National',
'N° Travail',
'Sexe',
'Né le',
'Age (mois)',
'Race',
'Bâtiment',
'Case',
'Entrée le',
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,
];
private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
public function __construct(
private EntityManagerInterface $em,
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$bovines = $this->em->createQueryBuilder()
->select('b')
->from(Bovine::class, 'b')
->where('b.exitedAt IS NULL')
->orderBy('b.birthDate', 'ASC')
->getQuery()
->getResult()
;
$request = $this->requestStack->getCurrentRequest();
$raw = (string) ($request?->query->get('ageRanges') ?? '');
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw))));
$spreadsheet = $this->buildSpreadsheet($bovines);
$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'));
@@ -73,107 +94,311 @@ final class BovineInventoryExportProvider implements ProviderInterface
}
/**
* Tri par âge décroissant puis race (Limousine d'abord, puis Charolaise, puis autres).
*
* @param list<Bovine> $bovines
*
* @return list<Bovine>
*/
private function buildSpreadsheet(array $bovines): Spreadsheet
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();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Inventaire');
// Header row
foreach (self::HEADERS as $index => $label) {
$sheet->setCellValue([$index + 1, 1], $label);
}
// Police par défaut sur tout le classeur (en-têtes + data)
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
$lastColumn = $sheet->getHighestColumn();
$headerRange = sprintf('A1:%s1', $lastColumn);
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'fill' => [
$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' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
'right' => ['borderStyle' => Border::BORDER_MEDIUM],
'left' => ['borderStyle' => Border::BORDER_MEDIUM],
],
]);
$sheet->getRowDimension(3)->setRowHeight(24.75);
// Column widths
foreach (self::COLUMN_WIDTHS as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
// Ligne 4 : en-têtes
$headers = [
'A' => 'Limousin',
'B' => 'N° de travail',
'C' => 'Charolais',
'D' => "\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);
}
// N° National et N° Travail : valeurs numériques mais conservées en string
// (leading zeros, format métier) — on force le format texte pour éviter
// l'avertissement Excel "nombre stocké sous forme de texte".
$sheet->getStyle('A')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$sheet->getStyle('B')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
// Data rows
$rowNumber = 2;
// Lignes de données
$rowNumber = 5;
foreach ($bovines as $bovine) {
$values = $this->formatRow($bovine);
foreach ($values as $colIndex => $value) {
$sheet->setCellValue([$colIndex + 1, $rowNumber], $value);
}
$color = $this->ageColor($bovine->getAgeMonths());
if (null !== $color) {
$rowRange = sprintf('A%d:%s%d', $rowNumber, $lastColumn, $rowNumber);
$sheet->getStyle($rowRange)->applyFromArray([
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => $color],
],
]);
}
$this->writeBovineRow($sheet, $rowNumber, $bovine);
++$rowNumber;
}
// Freeze header row + auto-filter
$sheet->freezePane('A2');
if ($rowNumber > 2) {
$sheet->setAutoFilter(sprintf('%s:%s%d', 'A1', $lastColumn, $rowNumber - 1));
} else {
$sheet->setAutoFilter($headerRange);
// 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)
;
}
}
/**
* @return list<null|int|string>
* Sous-titre dynamique selon les tranches d'âge cochées.
*
* @param list<string> $ageRanges
*/
private function formatRow(Bovine $bovine): array
private function computeSubtitle(array $ageRanges): string
{
return [
$bovine->getNationalNumber(),
$bovine->getWorkNumber(),
$this->formatSex($bovine->getSex()),
$this->formatDate($bovine->getBirthDate()),
$bovine->getAgeMonths(),
$bovine->getBreedCode(),
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
$bovine->getBuildingCase()?->getCaseNumber(),
$this->formatDate($bovine->getArrivalDate()),
];
}
$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,
]
));
private function formatSex(?string $sex): ?string
{
return match ($sex) {
'M' => 'Mâle',
'F' => 'Femelle',
default => $sex,
};
}
$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);
private function formatDate(?DateTimeImmutable $date): ?string
{
return $date?->format('d/m/Y');
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
@@ -182,14 +407,14 @@ final class BovineInventoryExportProvider implements ProviderInterface
return null;
}
if ($ageMonths >= 24) {
return self::COLOR_VIOLET;
}
if ($ageMonths >= 22) {
return self::COLOR_RED;
}
if ($ageMonths >= 20) {
if ($ageMonths >= 22) {
return self::COLOR_ORANGE;
}
if ($ageMonths >= 20) {
return self::COLOR_YELLOW;
}
return null;
}

View File

@@ -7,7 +7,8 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BovineInventoryStats;
use Doctrine\DBAL\Connection;
use App\Repository\BovineRepository;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @implements ProviderInterface<BovineInventoryStats>
@@ -15,26 +16,22 @@ use Doctrine\DBAL\Connection;
final class BovineInventoryStatsProvider implements ProviderInterface
{
public function __construct(
private Connection $connection,
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BovineInventoryStats
{
$row = $this->connection->fetchAssociative(<<<'SQL'
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE age_months >= 24) AS over_24,
COUNT(*) FILTER (WHERE age_months >= 22 AND age_months < 24) AS between_22_and_24,
COUNT(*) FILTER (WHERE age_months >= 20 AND age_months < 22) AS between_20_and_22
FROM bovine
WHERE exited_at IS NULL
SQL);
$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 = (int) ($row['total'] ?? 0);
$stats->over24 = (int) ($row['over_24'] ?? 0);
$stats->between22And24 = (int) ($row['between_22_and_24'] ?? 0);
$stats->between20And22 = (int) ($row['between_20_and_22'] ?? 0);
$stats->total = $row['total'];
$stats->over24 = $row['over24'];
$stats->between22And24 = $row['between22And24'];
$stats->between20And22 = $row['between20And22'];
return $stats;
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\BovineMovement;
use App\Repository\BovineMovementRepository;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class BovineMovementProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovineMovementRepository $movementRepository,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof BovineMovement) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$enteredAt = $data->hasEnteredAt() ? $data->getEnteredAt() : new DateTimeImmutable();
$data->setEnteredAt($enteredAt);
$data->setLeftAt(null);
$data->setBuilding(null);
$bovine = $data->getBovine();
$openMovement = $this->movementRepository->findOpenMovement($bovine);
if (null !== $openMovement) {
$openMovement->setLeftAt($enteredAt);
}
$bovine->setBuildingCase($data->getBuildingCase());
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
@@ -18,6 +19,11 @@ use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
@@ -34,6 +40,13 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$result = new BovineSyncInventoryResult();
$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 = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
@@ -83,9 +96,14 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBreedCode($identification->breedType);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;
@@ -102,4 +120,28 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setExitDate($latestExit);
$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;
}
}

View File

@@ -65,7 +65,7 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue;
}
$breedCode = $bovine->getBreedCode();
$breedCode = $bovine->getBovineType()?->getCode();
if (null === $headerBreedCode && null !== $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);
$dompdf = new Dompdf();

View File

@@ -25,7 +25,7 @@
.sheet { width: auto; }
h1 {
margin: 8px 0 16px 0;
margin: 0 0 8px 0;
padding: 0;
line-height: 1;
text-transform: uppercase;
@@ -139,10 +139,10 @@
}
.main .sub-title {
font-size: 16px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
padding: 8px;
padding: 4px;
}
.main .base {
@@ -203,61 +203,61 @@
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
Arrivage du {{ firstArrivalDate ?? '-' }}
</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>
<td style="width:40%; vertical-align:top; padding-right:2mm; border:0;">
<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="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="4">PROVENANCE</td>
<td style="width:60%; vertical-align:top; padding-left:2mm; border:0;">
<table class="header-right-free" style="width:100%; border-collapse:collapse; table-layout:fixed;">
<tr>
<td style="border:0; text-align:center; font-weight:700; height: 20px;" colspan="5"></td>
<td style="border:0;" colspan="2"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">1</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">2</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">3</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">4</td>
<td style="border:0;" colspan="2"></td>
</tr>
<tr>
<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>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border: 0; width: 20%;" colspan="2"></td>
</tr>
<tr>
<td style="border: 0; height: 20px" colspan="16"></td>
</tr>
<tr>
<td style="border: 0; text-align:left; font-weight:700; font-size: 24px" colspan="3">RACE</td>
<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>
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">CHAROLAIS</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">Autre</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
</tr>
</table>
</td>
{# Paire 1 : chiffre + case vide #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">1</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 2 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">2</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 3 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">3</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 4 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">4</td>
<td style="border:1px solid #2b2b2b;"></td>
{# Espacement entre PROVENANCE et RACE (1 col, RACE commence plus tôt) #}
<td style="border:0;"></td>
{# Bloc RACE #}
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="2">RACE</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">LIMOUSIN</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">CHAROLAIS</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;">AUTRE</td>
<td style="border:1px solid #2b2b2b;"></td>
</tr>
</table>
<table style="width:auto; border-collapse:collapse; margin-bottom: 8px; margin-top: 8px">
<tr>
<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; 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>
<td style="border:0; width: 32px;"></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>
</table>
@@ -267,30 +267,29 @@
<table class="main">
<thead>
<tr>
<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>
<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([]) %}
{% for month in monthHeaders|default([])|reverse %}
<th class="month" style="width:6.58%">{{ month.name }}</th>
{% 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>
{% for month in monthHeaders|default([]) %}
{% for month in monthHeaders|default([])|reverse %}
<th class="days">{{ month.days }}</th>
{% endfor %}
</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 class="days">Foin</th>
<th class="days">Foin</th>
</tr>
<tr>
{% for month in monthHeaders|default([]) %}
{% for month in monthHeaders|default([])|reverse %}
<th class="base">
{% if month.baseValue is defined %}
{{ month.baseValue|round(0, 'common') }} kg
@@ -303,27 +302,28 @@
</thead>
<tbody>
{# 11 lignes comme dans ton code (0..10) #}
{# 13 lignes comme dans ton code (0..12) #}
{% for i in 0..12 %}
{% set row = rows[i] ?? null %}
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
<tr class="data-row">
<td class="row-work"></td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td>
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
{% set reversedIdx = (monthCount - 1) - idx %}
{% 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">
{% if row and row.birthDate %}
{% set birthParts = row.birthDate|split('/') %}
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
{% endif %}
</td>
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[idx] ?? null) : null %}
<td class="row-month"{% if loop.index0 < 4 %} style="background:#e0e0e0;"{% endif %}>
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
</td>
{% endfor %}
<td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-work"></td>
</tr>
{% endfor %}
</tbody>
@@ -331,41 +331,89 @@
<!-- =========================
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>
<td style="height: 20px; border: 0" colspan="4"></td>
<td style="font-weight: 700" colspan="2">Grippe</td>
<td style="font-weight: 700" colspan="2">Protivity</td>
</tr>
<tr>
<td style="height: 20px">Date</td>
<td>Antibiotique</td>
<td>Date</td>
<td>Antero</td>
<td>Date</td>
<td>Intranasale</td>
<td>Date</td>
<td>Rappel 30 jours</td>
</tr>
<tr>
<td style="height: 20px"></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td style="height: 20px"></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></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">Traitements</td>
</tr>
<tr>
<td style="height: 20px" colspan="2">Date</td>
<td colspan="2"></td>
<td>Dose</td>
<td colspan="5">Observation</td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Grippe</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Antéro</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Antibiotiques</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Déparasitage</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>
</table>
</div>