Compare commits

...

33 Commits

Author SHA1 Message Date
gitea-actions
dd5987329d chore: bump version to v0.0.107
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m37s
2026-05-21 15:54:35 +00:00
cde30340a1 fix : UI building.vue
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-05-21 17:54:24 +02:00
gitea-actions
7e368505e4 chore: bump version to v0.0.106
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m39s
2026-05-21 12:38:41 +00:00
fd6aed8ae0 [#FER-19] Ajouter le healthCheck du pont bascule (!58)
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

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

Reviewed-on: #58
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 12:38:32 +00:00
gitea-actions
16ebede557 chore: bump version to v0.0.105
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-21 09:34:48 +00:00
3f48568ae9 [#FER-30] Revoir l'affichage type bovin (!57)
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: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 09:34:40 +00:00
gitea-actions
39f67b3c90 chore: bump version to v0.0.104
All checks were successful
Build Release Artefact / build (push) Successful in 1m37s
Auto Tag Develop / tag (push) Successful in 7s
2026-05-19 13:53:49 +00:00
eccb8e1fc6 [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel (!56)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| 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: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-19 13:49:33 +00:00
gitea-actions
de39207102 chore: bump version to v0.0.103
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-18 07:40:20 +00:00
5da0003c4d [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin (!55)
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

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

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-18 07:40:10 +00:00
gitea-actions
88f19cbb59 chore: bump version to v0.0.102
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-18 07:13:26 +00:00
df3f86d33f [#FER-27] Fix export inventaire bovin (!54)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Une ligne avec données absentes ou hors plage (date < 1900, n° national vide, dates incohérentes) ne fait plus tomber l'export entier en 500. Try/catch par ligne avec log warning, helper safePhpToExcel pour les dates Excel, garde birth <= arrival pour l'âge à l'entrée.

| 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: #54
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-18 07:12:36 +00:00
gitea-actions
5cc9b7855f chore: bump version to v0.0.101
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-05-13 15:50:34 +00:00
b61321c7b7 fix : wip cleanup
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-13 17:50:23 +02:00
gitea-actions
b130d44054 chore: bump version to v0.0.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-05-13 12:14:23 +00:00
dfa29ffc7a [#FER-26] Passeport du bovin (!53)
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
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #53
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 12:14:16 +00: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
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
86 changed files with 3689 additions and 890 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 *)"
]
}
}

2
.env
View File

@@ -19,4 +19,4 @@ COOKIE_SECURE=
DATABASE_URL=
PONT_BASCULE_BYPASS=
PONT_BASCULE_URL=
PONT_BASCULE_BASE_URL=

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,12 @@ 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
* [#FER-27] Fix export inventaire bovin
* [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin
* [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel
* [#FER-30] Revoir l'affichage type bovin
* [#FER-19] Ajouter le healthCheck du pont bascule
### Changed

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

@@ -24,7 +24,7 @@ services:
App\Service\PontBasculeService:
arguments:
$endpoint: '%env(PONT_BASCULE_URL)%'
$baseUrl: '%env(PONT_BASCULE_BASE_URL)%'
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
# add more service definitions when explicit configuration is needed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.92'
app.version: '0.0.107'

View File

@@ -0,0 +1,647 @@
# Health-check pont-bascule sur l'écran de pesée — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Brancher le vrai health-check du pont-bascule à l'arrivée sur l'écran de pesée, afficher l'état réel et désactiver le bouton « peser » tant que le pont n'est pas valide.
**Architecture:** Une URL de base unique (`PONT_BASCULE_BASE_URL`) ; le `PontBasculeService` construit `/send/dsd` et `/health`. Un nouvel endpoint générique `GET /pont_bascule/health` (carrier API Platform + DTO + provider) renvoie toujours `200 { healthy: bool, ... }`. Le front interroge cet endpoint une fois au montage de `WorkflowWeight`, affiche l'état et grise le bouton « peser » si non sain. Le bypass dev renvoie un état sain.
**Tech Stack:** Symfony 8 / API Platform 4 (PHP 8.4, PHPUnit, MockHttpClient) ; Nuxt 4 (Vue 3, composables).
**Spec:** `docs/superpowers/specs/2026-05-21-pont-bascule-healthcheck-design.md`
---
## File Structure
**Backend (créés) :**
- `src/Dto/PontBasculeHealth.php` — DTO domaine : `healthy` + champs informatifs, getters, Groups, factory `unhealthy()`.
- `src/ApiResource/PontBasculeHealthCheck.php` — carrier API Platform hébergeant `GET /pont_bascule/health`, `output: PontBasculeHealth::class`.
- `src/State/PontBasculeHealthProvider.php` — provider qui appelle `PontBasculeService::checkHealth()`.
**Backend (modifiés) :**
- `src/Service/PontBasculeService.php``$endpoint``$baseUrl` ; `fetch()` POST `{base}/send/dsd` ; nouvelle méthode `checkHealth()`.
- `config/services.yaml` — argument `$baseUrl``PONT_BASCULE_BASE_URL`.
- `.env`, `.env.local`, `.env.prod``PONT_BASCULE_URL``PONT_BASCULE_BASE_URL` (sans `/send/dsd`).
- `tests/Service/PontBasculeServiceTest.php` — adapter l'URL attendue + tests `checkHealth()`.
**Frontend (créés) :**
- `frontend/composables/usePontBascule.ts` — état `status` + `checkHealth()`.
**Frontend (modifiés) :**
- `frontend/components/workflow/workflow-weight.vue` — affichage dynamique de l'état + `disabled` sur « peser ».
---
## Task 1 : Refactor env vers une URL de base unique
**Files:**
- Modify: `src/Service/PontBasculeService.php:15-36`
- Modify: `config/services.yaml:25-28`
- Modify: `.env:21-22`, `.env.local:21-22`, `.env.prod:21-22`
- Test: `tests/Service/PontBasculeServiceTest.php`
- [ ] **Step 1 : Adapter le test existant à l'URL `/send/dsd`**
Dans `tests/Service/PontBasculeServiceTest.php`, méthode `testFetchUsesHttpClientWhenNotBypass`, remplacer l'attente d'URL :
```php
$httpClient
->expects(self::once())
->method('request')
->with('POST', 'http://example.test/send/dsd')
->willReturn($response)
;
```
(Les autres `'http://example.test'` passés au constructeur restent la **base** — inchangés.)
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `make test FILES=tests/Service/PontBasculeServiceTest.php`
Expected: FAIL sur `testFetchUsesHttpClientWhenNotBypass` (l'implémentation appelle encore `http://example.test`).
- [ ] **Step 3 : Renommer `$endpoint` → `$baseUrl` et construire `/send/dsd`**
Dans `src/Service/PontBasculeService.php`, constructeur et `fetch()` :
```php
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly PontBasculePayloadDecoder $payloadDecoder,
private readonly string $baseUrl,
private readonly bool $bypass,
) {}
```
et dans `fetch()` remplacer la ligne de requête :
```php
$response = $this->httpClient->request('POST', $this->baseUrl . '/send/dsd');
```
- [ ] **Step 4 : Lancer le test, vérifier le succès**
Run: `make test FILES=tests/Service/PontBasculeServiceTest.php`
Expected: PASS (3 tests).
- [ ] **Step 5 : Mettre à jour `config/services.yaml`**
Remplacer le bloc du service (lignes 25-28) :
```yaml
App\Service\PontBasculeService:
arguments:
$baseUrl: '%env(PONT_BASCULE_BASE_URL)%'
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
```
- [ ] **Step 6 : Mettre à jour les fichiers `.env`**
Dans `.env` (lignes 21-22), remplacer :
```
PONT_BASCULE_BYPASS=
PONT_BASCULE_BASE_URL=
```
Dans `.env.local` (lignes 21-22), remplacer :
```
PONT_BASCULE_BYPASS=true
PONT_BASCULE_BASE_URL="http://100.122.43.54:8000"
```
Dans `.env.prod` (lignes 21-22), remplacer :
```
PONT_BASCULE_BYPASS=true
PONT_BASCULE_BASE_URL="http://100.122.43.54:8000"
```
- [ ] **Step 7 : Vider le cache et relancer toute la suite**
Run: `make cache-clear && make test`
Expected: PASS (9 tests, comme avant).
- [ ] **Step 8 : Commit**
```bash
git add src/Service/PontBasculeService.php config/services.yaml .env .env.local .env.prod tests/Service/PontBasculeServiceTest.php
git commit -m "refactor(fer-19) : url de base unique pour le pont-bascule"
```
---
## Task 2 : DTO `PontBasculeHealth`
**Files:**
- Create: `src/Dto/PontBasculeHealth.php`
- [ ] **Step 1 : Créer le DTO**
`src/Dto/PontBasculeHealth.php` — mêmes conventions que `PontBasculeReading` (readonly, Groups sur les props promues, getters) :
```php
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class PontBasculeHealth
{
public function __construct(
#[Groups(['pont_bascule:health:read'])]
private bool $healthy,
#[Groups(['pont_bascule:health:read'])]
private bool $ok = false,
#[Groups(['pont_bascule:health:read'])]
private bool $busy = false,
#[Groups(['pont_bascule:health:read'])]
private bool $portConnected = false,
#[Groups(['pont_bascule:health:read'])]
private ?string $portError = null,
#[Groups(['pont_bascule:health:read'])]
private ?string $hostname = null,
) {}
public static function unhealthy(): self
{
return new self(false);
}
public function isHealthy(): bool
{
return $this->healthy;
}
public function isOk(): bool
{
return $this->ok;
}
public function isBusy(): bool
{
return $this->busy;
}
public function isPortConnected(): bool
{
return $this->portConnected;
}
public function getPortError(): ?string
{
return $this->portError;
}
public function getHostname(): ?string
{
return $this->hostname;
}
}
```
- [ ] **Step 2 : Vérifier que PHP parse le fichier**
Run: `make shell` puis `php -l src/Dto/PontBasculeHealth.php` — ou directement :
`docker exec -u www-data php-ferme-fpm php -l src/Dto/PontBasculeHealth.php`
Expected: `No syntax errors detected`.
- [ ] **Step 3 : Commit**
```bash
git add src/Dto/PontBasculeHealth.php
git commit -m "feat(fer-19) : dto PontBasculeHealth"
```
---
## Task 3 : Méthode `PontBasculeService::checkHealth()`
**Files:**
- Modify: `src/Service/PontBasculeService.php`
- Test: `tests/Service/PontBasculeServiceTest.php`
- [ ] **Step 1 : Écrire les tests `checkHealth()`**
Ajouter dans `tests/Service/PontBasculeServiceTest.php` (le `use App\Dto\PontBasculeHealth;` n'est pas requis, on lit via getters). Ajouter ces méthodes à la classe :
```php
public function testCheckHealthBypassIsHealthyWithoutHttpCall(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects(self::never())->method('request');
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', true);
$health = $service->checkHealth();
self::assertTrue($health->isHealthy());
}
public function testCheckHealthHealthyPayload(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => true,
'port_error' => null,
'hostname' => 'liot-rasp-ferme-01',
], JSON_THROW_ON_ERROR));
$health = $service->checkHealth();
self::assertTrue($health->isHealthy());
self::assertSame('liot-rasp-ferme-01', $health->getHostname());
}
public function testCheckHealthUnhealthyWhenPortError(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => true,
'port_error' => 'device disconnected',
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyWhenPortNotConnected(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => false,
'port_error' => null,
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyWhenBusy(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => true,
'port_connected' => true,
'port_error' => null,
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyOnTransportFailure(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient
->expects(self::once())
->method('request')
->willThrowException($this->createStub(TransportExceptionInterface::class))
;
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyOnInvalidJson(): void
{
self::assertFalse($this->serviceForHealthBody('not-json')->checkHealth()->isHealthy());
}
private function serviceForHealthBody(string $body): PontBasculeService
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getContent')->with(false)->willReturn($body);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient
->expects(self::once())
->method('request')
->with('GET', 'http://example.test/health')
->willReturn($response)
;
return new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
}
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `make test FILES=tests/Service/PontBasculeServiceTest.php`
Expected: FAIL (`checkHealth()` n'existe pas → Error).
- [ ] **Step 3 : Implémenter `checkHealth()`**
Dans `src/Service/PontBasculeService.php`, ajouter `use App\Dto\PontBasculeHealth;` en tête, puis ajouter la méthode après `fetch()` :
```php
public function checkHealth(): PontBasculeHealth
{
if ($this->bypass) {
return new PontBasculeHealth(
healthy: true,
ok: true,
busy: false,
portConnected: true,
portError: null,
hostname: 'bypass',
);
}
try {
$response = $this->httpClient->request('GET', $this->baseUrl . '/health');
$body = $response->getContent(false);
} catch (TransportExceptionInterface) {
return PontBasculeHealth::unhealthy();
}
$payload = json_decode($body, true);
if (!is_array($payload)) {
return PontBasculeHealth::unhealthy();
}
$ok = true === ($payload['ok'] ?? null);
$busy = true === ($payload['busy'] ?? null);
$portConnected = true === ($payload['port_connected'] ?? null);
$portError = $payload['port_error'] ?? null;
$hostname = $payload['hostname'] ?? null;
$healthy = $ok && $portConnected && !$busy && null === $portError;
return new PontBasculeHealth(
healthy: $healthy,
ok: $ok,
busy: $busy,
portConnected: $portConnected,
portError: is_string($portError) ? $portError : null,
hostname: is_string($hostname) ? $hostname : null,
);
}
```
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `make test FILES=tests/Service/PontBasculeServiceTest.php`
Expected: PASS (10 tests : 3 `fetch` + 7 `checkHealth`).
- [ ] **Step 5 : Commit**
```bash
git add src/Service/PontBasculeService.php tests/Service/PontBasculeServiceTest.php
git commit -m "feat(fer-19) : checkHealth sur le PontBasculeService"
```
---
## Task 4 : Endpoint `GET /pont_bascule/health`
**Files:**
- Create: `src/State/PontBasculeHealthProvider.php`
- Create: `src/ApiResource/PontBasculeHealthCheck.php`
- [ ] **Step 1 : Créer le provider**
`src/State/PontBasculeHealthProvider.php` (renvoie le DTO directement, comme `ReceptionWeighingProvider`, mais sans jamais lever d'exception) :
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\PontBasculeHealth;
use App\Service\PontBasculeService;
final readonly class PontBasculeHealthProvider implements ProviderInterface
{
public function __construct(
private PontBasculeService $pontBasculeService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): PontBasculeHealth
{
return $this->pontBasculeService->checkHealth();
}
}
```
- [ ] **Step 2 : Créer la ressource carrier**
`src/ApiResource/PontBasculeHealthCheck.php` (calque de `AppVersion` pour le style standalone, avec `output` séparé comme `/receptions/weigh`) :
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeHealth;
use App\State\PontBasculeHealthProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/pont_bascule/health',
openapi: new OpenApiOperation(
summary: 'Pont-bascule health check',
description: 'Returns the connection state of the pont-bascule. Always 200, even when unreachable.',
),
normalizationContext: ['groups' => ['pont_bascule:health:read']],
output: PontBasculeHealth::class,
provider: PontBasculeHealthProvider::class,
),
],
)]
final class PontBasculeHealthCheck {}
```
- [ ] **Step 3 : Vider le cache et vérifier que la route est déclarée**
Run: `make cache-clear && docker exec -u www-data php-ferme-fpm php bin/console debug:router | grep pont_bascule`
Expected: une ligne listant `GET /api/pont_bascule/health`.
- [ ] **Step 4 : Vérifier la réponse en bypass (sain)**
Le bypass est actif en dev (`.env.local`). Appeler l'endpoint authentifié n'étant pas trivial, vérifier d'abord sans auth que la route répond (401 attendu = route existe et sécurité globale `ROLE_USER` active) :
Run: `curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/pont_bascule/health`
Expected: `401` (route trouvée, auth requise) — **pas** `404`.
Si tu disposes d'un token JWT de dev, vérifier le corps :
Run: `curl -s http://localhost:8080/api/pont_bascule/health -H "Authorization: Bearer <token>"`
Expected: JSON contenant `"healthy":true` (bypass actif).
- [ ] **Step 5 : Commit**
```bash
git add src/State/PontBasculeHealthProvider.php src/ApiResource/PontBasculeHealthCheck.php
git commit -m "feat(fer-19) : endpoint GET /pont_bascule/health"
```
---
## Task 5 : Composable `usePontBascule`
**Files:**
- Create: `frontend/composables/usePontBascule.ts`
- [ ] **Step 1 : Créer le composable**
`frontend/composables/usePontBascule.ts` (mêmes conventions que `useAppVersion` : `useApi`, `toast: false` pour ne pas afficher d'erreur si le pont est down) :
```ts
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type PontBasculeStatus = 'checking' | 'connected' | 'disconnected'
export const usePontBascule = () => {
const api = useApi()
const status = ref<PontBasculeStatus>('checking')
const checkHealth = async () => {
status.value = 'checking'
try {
const res = await api.get<{ healthy: boolean }>('pont_bascule/health', {}, {
toast: false
})
status.value = res.healthy ? 'connected' : 'disconnected'
} catch {
status.value = 'disconnected'
}
}
return { status, checkHealth }
}
```
- [ ] **Step 2 : Commit**
```bash
git add frontend/composables/usePontBascule.ts
git commit -m "feat(fer-19) : composable usePontBascule"
```
---
## Task 6 : Affichage de l'état + désactivation du bouton dans `WorkflowWeight`
**Files:**
- Modify: `frontend/components/workflow/workflow-weight.vue:5` (texte) et `:19-23` (bouton peser) et `<script setup>`
- [ ] **Step 1 : Remplacer le texte hardcodé par l'affichage dynamique**
Dans `frontend/components/workflow/workflow-weight.vue`, remplacer la ligne 5 :
```html
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
```
par :
```html
<p
v-if="pontBasculeStatus === 'checking'"
class="uppercase text-2xl mt-2 text-primary-500">Vérification du pont-bascule…</p>
<p
v-else-if="pontBasculeStatus === 'connected'"
class="uppercase text-2xl mt-2 text-green-600">Pont-bascule connecté</p>
<p
v-else
class="uppercase text-2xl mt-2 text-red-600">Pont-bascule non connecté</p>
```
- [ ] **Step 2 : Désactiver le bouton « peser » tant que non connecté**
Toujours dans le template, ajouter `:disabled` sur le **premier** `UiButton` (celui qui déclenche `fetchWeight`) :
```html
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="pontBasculeStatus !== 'connected'"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
```
(Ne pas toucher aux boutons « Valider la pesée » / « Générer le bon ».)
- [ ] **Step 3 : Brancher le composable dans `<script setup>`**
Dans le bloc `<script setup lang="ts">`, ajouter l'import en tête (à côté de `import { toRef } from 'vue'`) :
```ts
import { onMounted, toRef } from 'vue'
import { usePontBascule } from '~/composables/usePontBascule'
```
puis, après la ligne `const entityRef = toRef(props, 'entity')`, ajouter :
```ts
const { status: pontBasculeStatus, checkHealth } = usePontBascule()
onMounted(checkHealth)
```
- [ ] **Step 4 : Build front pour vérifier qu'il n'y a pas d'erreur de compilation/type**
Run: `cd frontend && npm run build:dist`
Expected: build OK, aucune erreur TypeScript ni de template.
- [ ] **Step 5 : Vérification manuelle (bypass on = sain)**
Avec `PONT_BASCULE_BYPASS=true` (dev), lancer le front (`cd frontend && npm run dev`), aller sur une réception à l'étape de pesée :
- Au chargement : bref « Vérification du pont-bascule… », puis « Pont-bascule connecté » (vert).
- Le bouton « peser » est actif et fonctionne comme avant.
- [ ] **Step 6 : Vérification manuelle (état non sain = bouton grisé)**
Simuler un pont down sans toucher au backend : dans l'onglet réseau / via un override temporaire, forcer la réponse de `pont_bascule/health` à `{ "healthy": false }` (ou couper `PONT_BASCULE_BYPASS` et pointer une base URL injoignable). Recharger l'écran de pesée :
- Texte « Pont-bascule non connecté » (rouge).
- Bouton « peser » grisé (`opacity-60`, `cursor-not-allowed`) et non cliquable.
Rétablir `PONT_BASCULE_BYPASS=true` après le test.
- [ ] **Step 7 : Commit**
```bash
git add frontend/components/workflow/workflow-weight.vue
git commit -m "feat(fer-19) : affichage etat pont-bascule et desactivation du bouton peser"
```
---
## Vérification finale
- [ ] **Suite backend complète**
Run: `make test`
Expected: PASS (16 tests : 9 existants + 7 nouveaux `checkHealth`).
- [ ] **Style PHP**
Run: `make php-cs-fixer-allow-risky FILES=src/Service/PontBasculeService.php src/Dto/PontBasculeHealth.php src/ApiResource/PontBasculeHealthCheck.php src/State/PontBasculeHealthProvider.php`
Expected: fichiers conformes (aucune correction ou corrections appliquées puis re-commit).
- [ ] **Route présente**
Run: `docker exec -u www-data php-ferme-fpm php bin/console debug:router | grep pont_bascule`
Expected: `GET /api/pont_bascule/health`.
---
## Notes de risque
- **Carrier + `output` sur ressource non-entité :** si API Platform refuse `output:` sur la classe carrier vide `PontBasculeHealthCheck`, repli sur le pattern `AppVersion` pur — déplacer les champs (props publiques + Groups) directement dans `PontBasculeHealthCheck`, supprimer `output:`, et faire que `PontBasculeHealthProvider` mappe le DTO `PontBasculeHealth` vers une instance de `PontBasculeHealthCheck`. Le comportement HTTP reste identique. (Le repli ne change que le câblage interne, pas l'API ni le front.)
- **Check unique au montage (choix produit) :** si le pont est down à l'arrivée, le bouton reste grisé jusqu'au rechargement de la page. Conforme à la spec ; pas de polling ni de bouton « réessayer » dans ce périmètre.

View File

@@ -0,0 +1,174 @@
# Health-check pont-bascule branché sur l'écran de pesée
**Date:** 2026-05-21
**Ticket:** FER-19
**Statut:** Spec validée
## Contexte
Aujourd'hui, à l'arrivée sur l'écran de pesée (réception ou expédition), l'UI affiche
un texte hardcodé « Pont-bascule connecté » et un loader. C'est du faux : l'état réel
du pont-bascule n'est jamais vérifié.
Le pont-bascule (Raspberry sur le réseau, ex. `http://100.122.43.54:8000`) expose deux routes :
- `POST /send/dsd` — déclenche une pesée et renvoie le poids + le DSD. Déjà utilisée via
`GET /receptions/weigh` et `GET /shipments/weigh` (state providers → `PontBasculeService::fetch()`).
- `GET /health` — health-check, **non branchée aujourd'hui**.
Réponse type de `/health` :
```json
{
"ok": true,
"mode": "serial",
"busy": false,
"hostname": "liot-rasp-ferme-01",
"timestamp": 1779357080.6277,
"port": "/dev/ttyUSB0",
"baudrate": 9600,
"port_connected": true,
"port_error": null
}
```
Un bypass existe déjà côté backend (`PONT_BASCULE_BYPASS=true`) : il court-circuite l'appel
HTTP et renvoie un payload de test. Il est actif partout aujourd'hui (`.env.local`, `.env.prod`).
## Objectif
1. Brancher le vrai health-check du pont-bascule à l'arrivée sur l'écran de pesée.
2. Afficher le véritable état (connecté / non connecté) à la place du texte hardcodé.
3. Désactiver le bouton « peser » tant que le pont n'est pas valide.
4. Conserver le bypass : en bypass, l'état est « sain » pour ne pas casser le dev.
5. Ne pas dupliquer la configuration d'URL (une seule variable d'env de base).
## Décisions de design
- **Endpoint générique** `GET /pont_bascule/health` (ressource API Platform autonome).
Le health-check est agnostique de l'entité pesée — un seul endpoint partagé, pas un
par entité.
- **Check unique au montage** de l'écran de pesée (pas de polling). Si le pont est down
à l'arrivée, le bouton reste désactivé jusqu'au rechargement de la page. Conforme à la
demande.
- **Critère de validité** : `healthy = ok === true && port_connected === true && port_error === null && busy === false`.
(`busy` bloquant car une pesée déjà en cours met `busy` à `true`.)
- **Bypass** : `PONT_BASCULE_BYPASS=true` → health renvoie `healthy: true` sans appel réseau.
- **Pont injoignable = état normal** : le backend renvoie `200 { healthy: false }`
(jamais 500), pour ne pas déclencher de toast d'erreur côté `useApi`. `/weigh` garde
son comportement 500 actuel.
## Configuration env (suppression de la duplication)
- Remplacer `PONT_BASCULE_URL` (URL complète `.../send/dsd`) par **`PONT_BASCULE_BASE_URL`**
(URL de base sans chemin, ex. `http://100.122.43.54:8000`).
- Le service construit lui-même `{base}/send/dsd` et `{base}/health`.
- Fichiers à mettre à jour : `.env` (valeur vide), `.env.local`, `.env.prod`, et
`config/services.yaml` (argument `$baseUrl` au lieu de `$endpoint`).
- `PONT_BASCULE_BYPASS` inchangé.
## Backend
### `PontBasculeService` (`src/Service/PontBasculeService.php`)
- Renommer la dépendance `$endpoint``$baseUrl`.
- `fetch()` : POST sur `{baseUrl}/send/dsd` (comportement inchangé sinon).
- Nouvelle méthode `checkHealth(): PontBasculeHealth` :
- si `$bypass` → renvoie un `PontBasculeHealth` sain (`healthy = true`) sans appel réseau ;
- sinon → `GET {baseUrl}/health`, parse le JSON, calcule
`healthy = ok === true && port_connected === true && port_error === null && busy === false` ;
- si l'appel transport échoue **ou** le JSON est invalide / incomplet → renvoie
`PontBasculeHealth` avec `healthy = false` (aucune exception levée).
### DTO `PontBasculeHealth` (`src/Dto/PontBasculeHealth.php`)
Groupe de sérialisation `pont_bascule:health:read`. Champs :
- `healthy: bool` — le seul champ consommé par le front.
- Champs informatifs (debug / affichage futur) : `ok: bool`, `busy: bool`,
`portConnected: bool`, `portError: ?string`, `hostname: ?string`.
### Ressource API `PontBasculeHealthCheck` (`src/ApiResource/PontBasculeHealthCheck.php`)
- Classe carrier fine (vide) hébergeant l'opération `GET /pont_bascule/health`,
sans état persistant, `output` = `PontBasculeHealth::class` (le DTO), `provider` =
`PontBasculeHealthProvider`. Même montage que `/receptions/weigh` (host déclare
l'opération, `output` = DTO, provider renvoie le DTO).
- Nommée `PontBasculeHealthCheck` pour éviter la collision de nom court avec le DTO
`Dto\PontBasculeHealth`.
- Route conservée `/pont_bascule/health` (`pont_bascule` invariable).
### `PontBasculeHealthProvider` (`src/State/PontBasculeHealthProvider.php`)
- Appelle `PontBasculeService::checkHealth()` et renvoie le DTO `PontBasculeHealth`.
- Toujours `200`, même pont down (pas de `HttpException`).
## Frontend
### Couche service
- Nouveau composable **`usePontBascule`** (`composables/usePontBascule.ts`) exposant
`checkHealth()``api.get('pont_bascule/health')`, renvoie `{ healthy: boolean, ... }`.
Le health-check étant agnostique de l'entité, il vit dans son propre composable (et non
dans `workflow-service.ts` qui est un factory par entité) — une seule implémentation,
pas de duplication réception/expédition.
### `useWeighingStep.ts` (`composables/steps/`)
- Ajout d'un état réactif `pontBasculeStatus: 'checking' | 'connected' | 'disconnected'`
(initialisé à `'checking'`).
- Au `onMounted` : appel du health-check → `connected` si `healthy === true`, sinon `disconnected`.
- Exposer `pontBasculeStatus` au composant.
### `workflow-weight.vue` (`components/workflow/`)
- Le texte hardcodé ligne 5 (`Pont-bascule connecté`) devient dynamique selon
`pontBasculeStatus` :
- `checking` → « Vérification du pont-bascule… »
- `connected` → « Pont-bascule connecté » (vert, style actuel)
- `disconnected` → « Pont-bascule non connecté » (rouge)
- Le bouton **« peser »** reçoit `disabled` tant que `pontBasculeStatus !== 'connected'`
(état grisé). Les boutons « Valider la pesée » et « Générer le bon » restent inchangés.
### Textes d'état (codés en dur)
Convention du projet : les textes d'UI dans les composants/pages sont **codés en dur en
français** (le « Pont-bascule connecté » actuel l'est déjà). `fr.json` n'est consommé que
par `useApi` pour les toasts. On reste cohérent : les trois libellés sont écrits directement
dans `workflow-weight.vue`, **pas** dans `fr.json`.
- `checking` → « Vérification du pont-bascule… » (couleur primary)
- `connected` → « Pont-bascule connecté » (vert)
- `disconnected` → « Pont-bascule non connecté » (rouge)
## Error handling
- `/weigh` : comportement inchangé (500 + `PontBasculeException`).
- `/pont_bascule/health` : ne lève jamais d'erreur réseau vers le front. Pont injoignable
ou payload invalide = `200 { healthy: false }` → pas de toast d'erreur `useApi`.
## Tests
### Backend (PHPUnit, `MockHttpClient`)
Couvrir `PontBasculeService::checkHealth()` :
- bypass actif → `healthy = true`, aucun appel réseau ;
- payload sain (`ok`, `port_connected`, `port_error: null`, `busy: false`) → `healthy = true` ;
- `port_error` non null → `healthy = false` ;
- `port_connected: false``healthy = false` ;
- `busy: true``healthy = false` ;
- transport KO / JSON invalide → `healthy = false` (pas d'exception).
### Frontend
Pas de test auto existant pour ce flux. Validation manuelle :
- bypass on → bouton « peser » actif, texte « connecté » ;
- simuler un fail (`healthy: false`) → bouton grisé, texte « non connecté ».
## Hors périmètre
- Polling / rafraîchissement automatique de l'état (check unique au montage retenu).
- Bouton « réessayer » manuel.
- Traductions autres que `fr`.

View File

@@ -5,12 +5,10 @@
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div
class="flex flex-row gap-8 items-center w-full">
<div class="grid grid-cols-4 gap-x-8 gap-y-6">
<div
v-for="type in bovineType"
:key="type.id"
class="mt-8 flex flex-row mb-2 w-full">
:key="type.id">
<UiNumberInput
:id="type.id"
:label="type.label"
@@ -23,12 +21,11 @@
wrapper-class="gap-3"
/>
</div>
<div
class="mt-8 flex flex-row mb-2 gap-6">
<div>
<UiNumberInput
label="Autres"
v-model="otherQuantity"
class="max-w-[80px]"
class="max-w-[150px]"
wrapper-class="gap-3"
/>
</div>
@@ -79,7 +76,7 @@ const totalBovines = computed(() => {
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
bovineType.value = await getBovineTypeList()
bovineType.value = await getBovineTypeList({ display: true })
} finally {
isLoadingBovineType.value = false
}

View File

@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { getBovineTypeList } from '~/services/bovine-type'
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
@@ -45,7 +45,18 @@ const emit = defineEmits<{
(event: 'update:otherQuantity', value: number | null): void
}>()
const bovineTypes = ref<BovineTypeData[]>([])
// Types activés par l'admin (display=true), chargés depuis l'API.
const displayedTypes = ref<BovineTypeData[]>([])
// On affiche les types activés ET ceux déjà saisis sur la réception (même masqués),
// pour ne pas faire disparaître/perdre une quantité existante.
const bovineTypes = computed<BovineTypeData[]>(() => {
const seen = new Set(displayedTypes.value.map((type) => type.id))
const fromExisting = props.modelValue
.map((entry) => entry.bovineType)
.filter((type): type is BovineTypeData => Boolean(type) && !seen.has(type.id))
return [...displayedTypes.value, ...fromExisting]
})
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
@@ -154,8 +165,13 @@ watch(
{ deep: true }
)
// Re-synchronise dès que la liste fusionnée change (chargement async des types).
watch(bovineTypes, () => {
syncLocalFromProps()
})
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
displayedTypes.value = await getBovineTypeList({ display: true })
syncLocalFromProps()
})
</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

@@ -2,7 +2,15 @@
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<p
v-if="pontBasculeStatus === 'connected'"
class="uppercase text-2xl mt-2 text-green-600">Pont-bascule connecté</p>
<p
v-else-if="pontBasculeStatus === 'disconnected'"
class="uppercase text-2xl mt-2 text-red-600">Pont-bascule non connecté</p>
<p
v-else
class="uppercase text-2xl mt-2 invisible">Pont-bascule connecté</p>
<div
v-if="!displayWeight"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
@@ -19,6 +27,7 @@
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="pontBasculeStatus !== 'connected'"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
@@ -35,7 +44,8 @@
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { onMounted, toRef } from 'vue'
import { usePontBascule } from '~/composables/usePontBascule'
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
import type { WeightData } from '~/services/dto/weight-data'
@@ -55,6 +65,9 @@ const props = defineProps<{
const entityRef = toRef(props, 'entity')
const { status: pontBasculeStatus, checkHealth } = usePontBascule()
onMounted(checkHealth)
const {
displayWeight,
title,

View File

@@ -18,13 +18,15 @@ export interface UseBovineColumnsOptions {
/**
* Définition partagée des colonnes des tableaux bovins (inventory + case).
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de
* pouvoir ajuster les largeurs indépendamment.
* 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 adminColumnsInventory: BovineColumn[] = [
const withPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -38,7 +40,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
]
const userColumnsInventory: BovineColumn[] = [
const withoutPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -50,7 +52,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const adminColumnsCase: BovineColumn[] = [
const withPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
@@ -62,7 +64,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const userColumnsCase: BovineColumn[] = [
const withoutPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
@@ -73,10 +75,13 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
]
const columns = computed<BovineColumn[]>(() => {
if (options.variant === 'case') {
return auth.isAdmin ? adminColumnsCase : userColumnsCase
const isCase = options.variant === 'case'
const seePrice = auth.isBureau
if (isCase) {
return seePrice ? withPricesCase : withoutPricesCase
}
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
return seePrice ? withPricesInventory : withoutPricesInventory
})
return { columns }

View File

@@ -0,0 +1,23 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type PontBasculeStatus = 'checking' | 'connected' | 'disconnected'
export const usePontBascule = () => {
const api = useApi()
const status = ref<PontBasculeStatus>('checking')
const checkHealth = async () => {
status.value = 'checking'
try {
const res = await api.get<{ healthy: boolean }>('pont_bascule/health', {}, {
toast: false
})
status.value = res.healthy ? 'connected' : 'disconnected'
} catch {
status.value = 'disconnected'
}
}
return { status, checkHealth }
}

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

@@ -14,10 +14,13 @@
</h1>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<div class="grid grid-cols-2 items-start pt-7 mb-8 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div>
<div class="mb-11">
<UiCheckbox v-model="form.display" label="Afficher dans les réceptions" />
</div>
<div class="flex justify-center items-center">
<UiButton
type="submit"
@@ -32,6 +35,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()
@@ -51,7 +56,8 @@ function resolveId(param: unknown) {
const form = reactive<BovinFormData>({
label: '',
code: ''
code: '',
display: false
})
@@ -62,6 +68,7 @@ const hydrateFromBovin = (bovin: BovineTypeData | null) => {
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
form.display = bovin.display ?? false
isHydrating.value = false
}
@@ -90,8 +97,8 @@ async function validate() {
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
code: normalizedBovinCode,
display: form.display
}
isLoading.value = true

View File

@@ -29,6 +29,14 @@
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
<template #cell-display="{ item }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
:class="item.display ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
>
{{ item.display ? 'Oui' : 'Non' }}
</span>
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
@@ -38,6 +46,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'
@@ -56,7 +66,8 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
const columns = [
{ key: 'label', label: 'Nom' },
{ key: 'code', label: 'Code' }
{ key: 'code', label: 'Code' },
{ key: 'display', label: 'Affiché en réception' }
]
const goToBovin = (bovin: BovineTypeData) => {

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,358 @@
<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
}
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 ?? '—',
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

@@ -12,7 +12,7 @@
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
</div>
<div class="mt-6 space-y-6">
<div class="mt-6 space-y-3">
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
<div
v-for="entry in buildingLayouts"
@@ -56,7 +56,7 @@
</div>
<!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4">
<div class="">
<div class="flex gap-6">
<div
v-for="statut in statutLegend"
@@ -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,14 +23,6 @@
<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="flex flex-wrap gap-3 mt-4">
@@ -56,7 +48,7 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-clickable="auth.isAdmin"
row-clickable
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
>
@@ -130,9 +122,10 @@
</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 { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
@@ -141,7 +134,6 @@ 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)
@@ -231,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)
@@ -268,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) => {

View File

@@ -13,7 +13,7 @@
<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.isAdmin"
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"
@@ -23,7 +23,7 @@
</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"
@@ -57,6 +57,8 @@
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
>
<template #header-nationalNumber>
<UiTextInput
@@ -123,7 +125,7 @@
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }}
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
@@ -147,6 +149,8 @@
<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'

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,16 @@
<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
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="exportReceptions"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<div class="px-[86px]">
@@ -73,13 +82,41 @@
</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'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const receptionTypes = ref<ReceptionTypeData[]>([])
const exporting = ref(false)
const exportReceptions = async () => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('receptions/export')
const filename = `receptions_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// toast déjà géré par useApi onResponseError
} finally {
exporting.value = false
}
}
const receptionTypeOptions = computed(() =>
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))

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,16 @@
<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
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="exportShipments"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<div class="px-[86px]">
@@ -71,13 +80,41 @@
</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'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const shipmentTypes = ref<ShipmentTypeData[]>([])
const exporting = ref(false)
const exportShipments = async () => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('shipments/export')
const filename = `expeditions_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// toast déjà géré par useApi onResponseError
} finally {
exporting.value = false
}
}
const shipmentTypeOptions = computed(() =>
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))

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

@@ -5,9 +5,13 @@ export type BovineTypeListResponse =
| BovineTypeData[]
| { 'hydra:member'?: BovineTypeData[] }
export async function getBovineTypeList(): Promise<BovineTypeData[]> {
export async function getBovineTypeList(filters: { display?: boolean } = {}): Promise<BovineTypeData[]> {
const api = useApi()
const response = await api.get<BovineTypeListResponse>('bovine_types', {}, {
const query: Record<string, string> = {}
if (filters.display !== undefined) {
query.display = filters.display ? 'true' : 'false'
}
const response = await api.get<BovineTypeListResponse>('bovine_types', query, {
toastErrorKey: 'errors.bovin.list'
})
@@ -51,10 +55,12 @@ export async function updateBovin(id: number, payload: BovinPayload = {}): Promi
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code
code: item.code,
display: item.display ?? false
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined
code: payload.code ?? undefined,
display: payload.display ?? undefined
})

View File

@@ -9,34 +9,3 @@ export async function createBovine(payload: BovinePayload) {
toastSuccessKey: 'success.bovine.create'
})
}
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
const created: BovineData[] = []
const errors: string[] = []
for (const nationalNumber of nationalNumbers) {
try {
const bovine = await createBovine({ nationalNumber })
if (bovine) {
created.push(bovine)
}
} catch {
errors.push(nationalNumber)
}
}
return { created, errors }
}
export async function getBovine(id: number) {
const api = useApi()
return api.get<BovineData>(`bovines/${id}`)
}
export async function updateBovine(id: number, payload: BovinePayload) {
const api = useApi()
return api.patch<BovineData>(`bovines/${id}`, payload, {
toastErrorKey: 'errors.bovine.update',
toastSuccessKey: 'success.bovine.update'
})
}

View File

@@ -16,8 +16,6 @@ export interface BovineData {
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
@@ -29,9 +27,5 @@ 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,14 +2,17 @@ export interface BovineTypeData{
id: number
label: string
code: string
display: boolean
}
export interface BovinFormData {
label: string
code: string
display: boolean
}
export type BovinPayload = {
label?: string | null
code?: string | null
display?: boolean | 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,7 @@ 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
}

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

@@ -0,0 +1,29 @@
<?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 Version20260521092455 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajoute la colonne display sur bovine_type (défaut false) pour piloter l\'affichage dans une réception';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine_type ADD display BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine_type DROP display');
}
}

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_ADMIN')",
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,46 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\PontBasculeHealthProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/pont_bascule/health',
openapi: new OpenApiOperation(
summary: 'Pont-bascule health check',
description: 'Returns the connection state of the pont-bascule. Always 200, even when unreachable.',
),
normalizationContext: ['groups' => ['pont_bascule:health:read']],
security: "is_granted('ROLE_USER')",
provider: PontBasculeHealthProvider::class,
),
],
)]
final class PontBasculeHealthCheck
{
#[Groups(['pont_bascule:health:read'])]
public bool $healthy = false;
#[Groups(['pont_bascule:health:read'])]
public bool $ok = false;
#[Groups(['pont_bascule:health:read'])]
public bool $busy = false;
#[Groups(['pont_bascule:health:read'])]
public bool $portConnected = false;
#[Groups(['pont_bascule:health:read'])]
public ?string $portError = null;
#[Groups(['pont_bascule:health:read'])]
public ?string $hostname = null;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Reception\ReceptionExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/receptions/export',
openapi: new OpenApiOperation(
summary: 'Export Excel des réceptions terminées.',
description: 'Retourne un fichier XLSX listant toutes les réceptions validées (isValid = true), triées par date décroissante.',
tags: ['Receptions'],
),
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: ReceptionExportProvider::class,
),
]
)]
final class ReceptionExport
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Shipment\ShipmentExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/shipments/export',
openapi: new OpenApiOperation(
summary: 'Export Excel des expéditions terminées.',
description: 'Retourne un fichier XLSX listant toutes les expéditions validées (isValid = true), triées par date décroissante.',
tags: ['Shipments'],
),
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: ShipmentExportProvider::class,
),
]
)]
final class ShipmentExport
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
}

View File

@@ -1,215 +0,0 @@
<?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

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BovineInventorySyncer;
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 Throwable;
#[AsCommand(
name: 'app:sync-bovine-inventory',
description: "Synchronise l'inventaire bovin avec EDNOTIF (équivalent du bouton Rafraîchir de l'interface)."
)]
final class SyncBovineInventoryCommand extends Command
{
public function __construct(
private readonly BovineInventorySyncer $syncer,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->syncer->sync();
} catch (Throwable $e) {
$io->error(sprintf('Échec de la synchronisation : %s', $e->getMessage()));
return Command::FAILURE;
}
$io->success(sprintf(
'Inventaire synchronisé · Créés : %d · Mis à jour : %d · Sortis : %d · Total EDNOTIF : %d',
$result->created,
$result->updated,
$result->exited,
$result->total,
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class PontBasculeHealth
{
public function __construct(
#[Groups(['pont_bascule:health:read'])]
private bool $healthy,
#[Groups(['pont_bascule:health:read'])]
private bool $ok = false,
#[Groups(['pont_bascule:health:read'])]
private bool $busy = false,
#[Groups(['pont_bascule:health:read'])]
private bool $portConnected = false,
#[Groups(['pont_bascule:health:read'])]
private ?string $portError = null,
#[Groups(['pont_bascule:health:read'])]
private ?string $hostname = null,
) {}
public static function unhealthy(): self
{
return new self(false);
}
public function isHealthy(): bool
{
return $this->healthy;
}
public function isOk(): bool
{
return $this->ok;
}
public function isBusy(): bool
{
return $this->busy;
}
public function isPortConnected(): bool
{
return $this->portConnected;
}
public function getPortError(): ?string
{
return $this->portError;
}
public function getHostname(): ?string
{
return $this->hostname;
}
}

View File

@@ -17,6 +17,8 @@ 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;
@@ -81,7 +83,7 @@ class Bovine
#[ORM\Column(type: 'float', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
private ?float $pricePerKg = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
@@ -94,11 +96,6 @@ 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;
@@ -135,6 +132,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;
@@ -177,7 +205,7 @@ class Bovine
}
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
public function getFinalPrice(): ?float
{
if (null === $this->receivedWeight || null === $this->pricePerKg) {
@@ -211,28 +239,6 @@ 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;
@@ -329,6 +335,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,112 @@
<?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\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 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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
@@ -19,6 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
'label' => 'ipartial',
'code' => 'ipartial',
])]
#[ApiFilter(BooleanFilter::class, properties: ['display'])]
#[ApiResource(
operations: [
new Get(
@@ -58,6 +60,15 @@ class BovineType
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
private ?string $code = null;
/**
* Détermine si le type bovin est proposé à la sélection lors d'une réception.
* Les types créés automatiquement par la synchro inventaire arrivent à false ;
* seul un admin peut les activer.
*/
#[ORM\Column(options: ['default' => false])]
#[Groups(['bovine-type:read', 'bovine-type:write'])]
private bool $display = false;
public function getId(): ?int
{
return $this->id;
@@ -86,4 +97,16 @@ class BovineType
return $this;
}
public function isDisplay(): bool
{
return $this->display;
}
public function setDisplay(bool $display): static
{
$this->display = $display;
return $this;
}
}

View File

@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\Repository\ReceptionRepository;
use App\State\Reception\ReceptionReceiptProvider;
use App\State\Reception\ReceptionWeighingProvider;
use DateTimeImmutable;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: ReceptionRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
@@ -61,6 +62,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/receptions/weigh',

View File

@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\Repository\ShipmentRepository;
use App\State\Shipment\ShipmentReceiptProvider;
use App\State\Shipment\ShipmentWeighingProvider;
use DateTimeImmutable;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: ShipmentRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
@@ -61,6 +62,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/shipments/weigh',

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,52 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Reception;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Reception>
*/
final class ReceptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Reception::class);
}
/**
* Liste des réceptions validées pour l'export Excel (de la plus récente à la plus ancienne).
*
* @return list<Reception>
*/
public function findValidatedForExport(): array
{
return $this->createQueryBuilder('r')
->leftJoin('r.supplier', 'supplier')->addSelect('supplier')
->leftJoin('supplier.addresses', 'supplierAddresses')->addSelect('supplierAddresses')
->leftJoin('r.address', 'address')->addSelect('address')
->leftJoin('r.carrier', 'carrier')->addSelect('carrier')
->leftJoin('r.driver', 'driver')->addSelect('driver')
->leftJoin('r.truck', 'truck')->addSelect('truck')
->leftJoin('r.user', 'user')->addSelect('user')
->leftJoin('r.receptionType', 'receptionType')->addSelect('receptionType')
->leftJoin('r.merchandiseType', 'merchandiseType')->addSelect('merchandiseType')
->leftJoin('r.weights', 'weights')->addSelect('weights')
->leftJoin('r.bovines_types', 'bovinesTypes')->addSelect('bovinesTypes')
->leftJoin('bovinesTypes.bovineType', 'bovineType')->addSelect('bovineType')
->leftJoin('r.pelletBuildings', 'pelletBuildings')->addSelect('pelletBuildings')
->leftJoin('pelletBuildings.pelletType', 'pelletType')->addSelect('pelletType')
->leftJoin('pelletBuildings.building', 'pelletBuilding')->addSelect('pelletBuilding')
->where('r.isValid = :valid')
->setParameter('valid', true)
->orderBy('r.receptionDate', 'DESC')
->addOrderBy('r.id', 'DESC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Shipment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Shipment>
*/
final class ShipmentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Shipment::class);
}
/**
* Liste des expéditions validées pour l'export Excel (de la plus récente à la plus ancienne).
*
* @return list<Shipment>
*/
public function findValidatedForExport(): array
{
return $this->createQueryBuilder('s')
->leftJoin('s.customer', 'customer')->addSelect('customer')
->leftJoin('customer.addresses', 'customerAddresses')->addSelect('customerAddresses')
->leftJoin('s.address', 'address')->addSelect('address')
->leftJoin('s.carrier', 'carrier')->addSelect('carrier')
->leftJoin('s.driver', 'driver')->addSelect('driver')
->leftJoin('s.truck', 'truck')->addSelect('truck')
->leftJoin('s.user', 'user')->addSelect('user')
->leftJoin('s.shipmentType', 'shipmentType')->addSelect('shipmentType')
->leftJoin('s.weights', 'weights')->addSelect('weights')
->where('s.isValid = :valid')
->setParameter('valid', true)
->orderBy('s.shipmentDate', 'DESC')
->addOrderBy('s.id', 'DESC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
final class BovineInventorySyncer
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private readonly BovinApiInterface $bovinApi,
private readonly EntityManagerInterface $em,
) {}
public function sync(): BovineSyncInventoryResult
{
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$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;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->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;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
/**
* 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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Dto\PontBasculeHealth;
use App\Dto\PontBasculeReading;
use App\Exception\PontBasculeException;
use DateTimeImmutable;
@@ -15,7 +16,7 @@ final class PontBasculeService
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly PontBasculePayloadDecoder $payloadDecoder,
private readonly string $endpoint,
private readonly string $baseUrl,
private readonly bool $bypass,
) {}
@@ -28,7 +29,7 @@ final class PontBasculeService
$body = $this->getBypassPayload();
} else {
try {
$response = $this->httpClient->request('POST', $this->endpoint);
$response = $this->httpClient->request('POST', $this->buildUrl('/send/dsd'));
$body = $response->getContent(false);
} catch (TransportExceptionInterface $exception) {
throw PontBasculeException::transportFailure($exception->getMessage());
@@ -44,6 +45,54 @@ final class PontBasculeService
);
}
public function checkHealth(): PontBasculeHealth
{
if ($this->bypass) {
return new PontBasculeHealth(
healthy: true,
ok: true,
busy: false,
portConnected: true,
portError: null,
hostname: 'bypass',
);
}
try {
$response = $this->httpClient->request('GET', $this->buildUrl('/health'));
$body = $response->getContent(false);
} catch (TransportExceptionInterface) {
return PontBasculeHealth::unhealthy();
}
$payload = json_decode($body, true);
if (!is_array($payload)) {
return PontBasculeHealth::unhealthy();
}
$ok = true === ($payload['ok'] ?? null);
$busy = true === ($payload['busy'] ?? null);
$portConnected = true === ($payload['port_connected'] ?? null);
$portError = $payload['port_error'] ?? null;
$hostname = $payload['hostname'] ?? null;
$healthy = $ok && $portConnected && !$busy && null === $portError;
return new PontBasculeHealth(
healthy: $healthy,
ok: $ok,
busy: $busy,
portConnected: $portConnected,
portError: is_string($portError) ? $portError : null,
hostname: is_string($hostname) ? $hostname : null,
);
}
private function buildUrl(string $path): string
{
return rtrim($this->baseUrl, '/').$path;
}
private function getBypassPayload(): string
{
return '{"ok":true,"busy":false,"mode":"serial","port":"/dev/ttyUSB0","baudrate":9600,"request_hex":"01 10 39 39 4D 0D 0A","response_hex":"01 02 30 34 30 32 30 30 02 30 31 30 30 31 34 32 30 2E 6B 67 20 02 30 32 30 30 30 30 30 30 2E 6B 67 20 02 30 33 30 30 31 34 32 30 2E 6B 67 20 02 39 39 30 30 31 32 31 0D 0A","response_ascii":"\u0001\u0002040200\u000201001420.kg \u000202000000.kg \u000203001420.kg \u00029900121"}';

View File

@@ -18,13 +18,15 @@ use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final class BovineInventoryExportProvider implements ProviderInterface
final readonly class BovineInventoryExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
@@ -70,6 +72,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
public function __construct(
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -253,7 +256,16 @@ final class BovineInventoryExportProvider implements ProviderInterface
// Lignes de données
$rowNumber = 5;
foreach ($bovines as $bovine) {
$this->writeBovineRow($sheet, $rowNumber, $bovine);
try {
$this->writeBovineRow($sheet, $rowNumber, $bovine);
} catch (Throwable $e) {
$this->logger->warning('Export inventaire bovin : ligne ignorée suite à une erreur.', [
'bovineId' => $bovine->getId(),
'nationalNumber' => $bovine->getNationalNumber(),
'row' => $rowNumber,
'exception' => $e,
]);
}
++$rowNumber;
}
@@ -276,7 +288,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
$type = $bovine->getBovineType();
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
$building = $bovine->getBuildingCase()?->getIdBuilding();
$code = $building?->getCode();
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
@@ -284,22 +296,25 @@ final class BovineInventoryExportProvider implements ProviderInterface
? (int) $bovine->getWorkNumber()
: ($bovine->getWorkNumber() ?? ''));
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
$national = $bovine->getNationalNumber();
$sheet->setCellValue('D'.$row, '' === $national ? '' : 'FR '.$national);
$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));
$birth = $bovine->getBirthDate();
$arrival = $bovine->getArrivalDate();
$birthExcel = $this->safePhpToExcel($birth);
$arrivalExcel = $this->safePhpToExcel($arrival);
if (null !== $birthExcel) {
$sheet->setCellValue('J'.$row, $birthExcel);
}
if (null !== $arrival) {
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
if (null !== $arrivalExcel) {
$sheet->setCellValue('K'.$row, $arrivalExcel);
}
if (null !== $birth && null !== $arrival) {
if (null !== $birth && null !== $arrival && $birth <= $arrival) {
$diff = $birth->diff($arrival);
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
}
@@ -343,6 +358,24 @@ final class BovineInventoryExportProvider implements ProviderInterface
}
}
/**
* Convertit une date PHP en numéro de série Excel, ou null si la date est absente / hors plage Excel (< 1900).
*/
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
/**
* Sous-titre dynamique selon les tranches d'âge cochées.
*

View File

@@ -0,0 +1,43 @@
<?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);
$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

@@ -7,26 +7,15 @@ namespace App\State\Bovin;
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;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
use App\Service\BovineInventorySyncer;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
private BovineInventorySyncer $syncer,
) {}
public function process(
@@ -35,108 +24,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$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;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
/**
* 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;
return $this->syncer->sync();
}
}

View File

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

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\PontBasculeHealthCheck;
use App\Service\PontBasculeService;
final readonly class PontBasculeHealthProvider implements ProviderInterface
{
public function __construct(
private PontBasculeService $pontBasculeService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): PontBasculeHealthCheck
{
$health = $this->pontBasculeService->checkHealth();
$resource = new PontBasculeHealthCheck();
$resource->healthy = $health->isHealthy();
$resource->ok = $health->isOk();
$resource->busy = $health->isBusy();
$resource->portConnected = $health->isPortConnected();
$resource->portError = $health->getPortError();
$resource->hostname = $health->getHostname();
return $resource;
}
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\State\Reception;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Address;
use App\Entity\Reception;
use App\Entity\Weight;
use App\Repository\ReceptionRepository;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final readonly class ReceptionExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const HEADER_FILL = 'FFCCECFF';
/**
* Largeurs de colonnes (A à V).
*/
private const COLUMN_WIDTHS = [
'A' => 12.0,
'B' => 11.0,
'C' => 7.0,
'D' => 14.0,
'E' => 12.0,
'F' => 22.0,
'G' => 30.0,
'H' => 30.0,
'I' => 18.0,
'J' => 8.0,
'K' => 18.0,
'L' => 14.0,
'M' => 12.0,
'N' => 16.0,
'O' => 22.0,
'P' => 11.0,
'Q' => 11.0,
'R' => 11.0,
'S' => 22.0,
'T' => 26.0,
'U' => 9.0,
'V' => 26.0,
];
public function __construct(
private ReceptionRepository $receptionRepository,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$receptions = $this->receptionRepository->findValidatedForExport();
$spreadsheet = $this->buildSpreadsheet($receptions);
$body = $this->renderXlsx($spreadsheet);
$filename = sprintf('receptions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
$response = new Response($body);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Content-Length', (string) strlen($body));
return $response;
}
/**
* @param list<Reception> $receptions
*/
private function buildSpreadsheet(array $receptions): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Receptions');
$pageSetup = $sheet->getPageSetup();
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
$pageSetup->setFitToWidth(1);
$pageSetup->setFitToHeight(0);
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
// Ligne 1 : titre + date
$sheet->setCellValue('A1', sprintf('%s — RÉCEPTIONS TERMINÉES', self::FARM_NAME));
$sheet->mergeCells('A1:U1');
$sheet->getStyle('A1:U1')->applyFromArray([
'font' => [
'name' => 'Arial Black',
'size' => 16,
'bold' => true,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('V1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
$sheet->getStyle('V1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle('V1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle('V1')->getFont()->setSize(12)->setBold(true);
$sheet->getRowDimension(1)->setRowHeight(26.0);
$sheet->getStyle('A1:V1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
// Ligne 2 : en-têtes
$headers = [
'A' => 'N° identification',
'B' => 'Date',
'C' => 'Heure',
'D' => 'Type réception',
'E' => 'Utilisateur',
'F' => 'Fournisseur',
'G' => 'Adresse fournisseur',
'H' => 'Adresse réception',
'I' => 'Transporteur',
'J' => 'Code trans.',
'K' => 'Chauffeur',
'L' => 'Camion',
'M' => 'Plaque',
'N' => 'Type marchandise',
'O' => 'Détail marchandise',
'P' => 'Brut (kg)',
'Q' => 'Tare (kg)',
'R' => 'Net (kg)',
'S' => 'Détail bovins',
'T' => 'Bovins par type',
'U' => 'Total bovins',
'V' => 'Granulés / bâtiments',
];
foreach ($headers as $col => $value) {
$sheet->setCellValue($col.'2', $value);
}
$sheet->getRowDimension(2)->setRowHeight(32.0);
$sheet->getStyle('A2:V2')->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],
],
]);
foreach (self::COLUMN_WIDTHS as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// Données
$row = 3;
foreach ($receptions as $reception) {
try {
$this->writeReceptionRow($sheet, $row, $reception);
} catch (Throwable $e) {
$this->logger->warning('Export réceptions : ligne ignorée suite à une erreur.', [
'receptionId' => $reception->getId(),
'identificationNumber' => $reception->getIdentificationNumber(),
'row' => $row,
'exception' => $e,
]);
}
++$row;
}
$lastRow = $row - 1;
if ($lastRow >= 2) {
$sheet->getStyle('A2:V'.$lastRow)->getBorders()->applyFromArray([
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
}
$sheet->freezePane('A3');
return $spreadsheet;
}
private function writeReceptionRow(Worksheet $sheet, int $row, Reception $reception): void
{
$sheet->setCellValue('A'.$row, $reception->getIdentificationNumber() ?? '');
$date = $reception->getReceptionDate();
if (null !== $date) {
$excelDate = $this->safePhpToExcel($date);
if (null !== $excelDate) {
$sheet->setCellValue('B'.$row, $excelDate);
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
}
$sheet->setCellValue('C'.$row, $date->format('H:i'));
}
$sheet->setCellValue('D'.$row, $reception->getReceptionType()?->getLabel() ?? '');
$sheet->setCellValue('E'.$row, $reception->getUser()?->getUsername() ?? '');
$supplier = $reception->getSupplier();
$sheet->setCellValue('F'.$row, $supplier?->getName() ?? '');
$sheet->setCellValue('G'.$row, $this->formatAddresses($supplier?->getAddresses()));
$sheet->setCellValue('H'.$row, $reception->getAddress()?->getFullAddress() ?? '');
$carrier = $reception->getCarrier();
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
$sheet->setCellValue('K'.$row, $reception->getDriver()?->getName() ?? '');
$sheet->setCellValue('L'.$row, $reception->getTruck()?->getName() ?? '');
$sheet->setCellValue('M'.$row, $reception->getLicensePlate() ?? '');
$sheet->setCellValue('N'.$row, $reception->getMerchandiseType()?->getLabel() ?? '');
$sheet->setCellValue('O'.$row, $reception->getMerchandiseDetail() ?? '');
$gross = $this->extractWeight($reception->getWeights(), 'gross');
$tare = $this->extractWeight($reception->getWeights(), 'tare');
if (null !== $gross) {
$sheet->setCellValue('P'.$row, $gross);
}
if (null !== $tare) {
$sheet->setCellValue('Q'.$row, $tare);
}
if (null !== $gross && null !== $tare) {
$sheet->setCellValue('R'.$row, $gross - $tare);
}
$sheet->getStyle('P'.$row.':R'.$row)->getNumberFormat()->setFormatCode('#,##0');
$sheet->setCellValue('S'.$row, $reception->getBovineDetail() ?? '');
[$bovinesText, $bovinesTotal] = $this->formatBovineTypes($reception);
$sheet->setCellValue('T'.$row, $bovinesText);
if (null !== $bovinesTotal) {
$sheet->setCellValue('U'.$row, $bovinesTotal);
}
$sheet->setCellValue('V'.$row, $this->formatPelletBuildings($reception));
// Alignements
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('P'.$row.':R'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('U'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
$sheet->getStyle('O'.$row)->getAlignment()->setWrapText(true);
$sheet->getStyle('S'.$row.':V'.$row)->getAlignment()->setWrapText(true);
}
/**
* @return array{0: string, 1: ?int} [texte concaténé, total]
*/
private function formatBovineTypes(Reception $reception): array
{
$parts = [];
$total = 0;
$found = false;
foreach ($reception->getBovinesTypes() as $rb) {
$label = $rb->getBovineType()?->getLabel();
$qty = $rb->getQuantity();
if (null === $label && null === $qty) {
continue;
}
$parts[] = sprintf('%s : %d', $label ?? '—', $qty ?? 0);
$total += $qty ?? 0;
$found = true;
}
return [implode(', ', $parts), $found ? $total : null];
}
private function formatPelletBuildings(Reception $reception): string
{
$parts = [];
foreach ($reception->getPelletBuildings() as $pb) {
$pellet = $pb->getPelletType()?->getLabel();
$building = $pb->getBuilding()?->getLabel() ?? $pb->getBuilding()?->getCode();
if (null === $pellet && null === $building) {
continue;
}
$parts[] = sprintf('%s (%s)', $pellet ?? '—', $building ?? '—');
}
return implode(', ', $parts);
}
/**
* @param null|iterable<Address> $addresses
*/
private function formatAddresses(?iterable $addresses): string
{
if (null === $addresses) {
return '';
}
$parts = [];
foreach ($addresses as $address) {
$full = $address->getFullAddress();
if ('' !== $full) {
$parts[] = $full;
}
}
return implode(' ; ', $parts);
}
/**
* @param iterable<Weight> $weights
*/
private function extractWeight(iterable $weights, string $type): ?int
{
foreach ($weights as $weight) {
if ($weight->getType() === $type) {
return $weight->getWeight();
}
}
return null;
}
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
private function renderXlsx(Spreadsheet $spreadsheet): string
{
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
ob_start();
$writer->save('php://output');
$body = ob_get_clean();
return false !== $body ? $body : '';
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\State\Shipment;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Address;
use App\Entity\Shipment;
use App\Entity\Weight;
use App\Repository\ShipmentRepository;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final readonly class ShipmentExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const HEADER_FILL = 'FFCCECFF';
/**
* Largeurs de colonnes (A à Q).
*/
private const COLUMN_WIDTHS = [
'A' => 12.0,
'B' => 11.0,
'C' => 7.0,
'D' => 16.0,
'E' => 12.0,
'F' => 22.0,
'G' => 30.0,
'H' => 30.0,
'I' => 18.0,
'J' => 8.0,
'K' => 18.0,
'L' => 14.0,
'M' => 12.0,
'N' => 11.0,
'O' => 11.0,
'P' => 11.0,
'Q' => 13.0,
];
public function __construct(
private ShipmentRepository $shipmentRepository,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$shipments = $this->shipmentRepository->findValidatedForExport();
$spreadsheet = $this->buildSpreadsheet($shipments);
$body = $this->renderXlsx($spreadsheet);
$filename = sprintf('expeditions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
$response = new Response($body);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Content-Length', (string) strlen($body));
return $response;
}
/**
* @param list<Shipment> $shipments
*/
private function buildSpreadsheet(array $shipments): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Expeditions');
$pageSetup = $sheet->getPageSetup();
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
$pageSetup->setFitToWidth(1);
$pageSetup->setFitToHeight(0);
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
// Ligne 1 : titre + date
$sheet->setCellValue('A1', sprintf('%s — EXPÉDITIONS TERMINÉES', self::FARM_NAME));
$sheet->mergeCells('A1:P1');
$sheet->getStyle('A1:P1')->applyFromArray([
'font' => [
'name' => 'Arial Black',
'size' => 16,
'bold' => true,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('Q1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
$sheet->getStyle('Q1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle('Q1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle('Q1')->getFont()->setSize(12)->setBold(true);
$sheet->getRowDimension(1)->setRowHeight(26.0);
$sheet->getStyle('A1:Q1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
// Ligne 2 : en-têtes
$headers = [
'A' => 'N° identification',
'B' => 'Date',
'C' => 'Heure',
'D' => "Type d'expédition",
'E' => 'Utilisateur',
'F' => 'Client',
'G' => 'Adresse client',
'H' => 'Adresse expédition',
'I' => 'Transporteur',
'J' => 'Code trans.',
'K' => 'Chauffeur',
'L' => 'Camion',
'M' => 'Plaque',
'N' => 'Brut (kg)',
'O' => 'Tare (kg)',
'P' => 'Net (kg)',
'Q' => 'Nb bovins',
];
foreach ($headers as $col => $value) {
$sheet->setCellValue($col.'2', $value);
}
$sheet->getRowDimension(2)->setRowHeight(32.0);
$sheet->getStyle('A2:Q2')->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],
],
]);
foreach (self::COLUMN_WIDTHS as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// Données
$row = 3;
foreach ($shipments as $shipment) {
try {
$this->writeShipmentRow($sheet, $row, $shipment);
} catch (Throwable $e) {
$this->logger->warning('Export expéditions : ligne ignorée suite à une erreur.', [
'shipmentId' => $shipment->getId(),
'identificationNumber' => $shipment->getIdentificationNumber(),
'row' => $row,
'exception' => $e,
]);
}
++$row;
}
$lastRow = $row - 1;
if ($lastRow >= 2) {
$sheet->getStyle('A2:Q'.$lastRow)->getBorders()->applyFromArray([
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
}
$sheet->freezePane('A3');
return $spreadsheet;
}
private function writeShipmentRow(Worksheet $sheet, int $row, Shipment $shipment): void
{
$sheet->setCellValue('A'.$row, $shipment->getIdentificationNumber() ?? '');
$date = $shipment->getShipmentDate();
if (null !== $date) {
$excelDate = $this->safePhpToExcel($date);
if (null !== $excelDate) {
$sheet->setCellValue('B'.$row, $excelDate);
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
}
$sheet->setCellValue('C'.$row, $date->format('H:i'));
}
$sheet->setCellValue('D'.$row, $shipment->getShipmentType()?->getLabel() ?? '');
$sheet->setCellValue('E'.$row, $shipment->getUser()?->getUsername() ?? '');
$customer = $shipment->getCustomer();
$sheet->setCellValue('F'.$row, $customer?->getName() ?? '');
$sheet->setCellValue('G'.$row, $this->formatAddresses($customer?->getAddresses()));
$sheet->setCellValue('H'.$row, $shipment->getAddress()?->getFullAddress() ?? '');
$carrier = $shipment->getCarrier();
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
$sheet->setCellValue('K'.$row, $shipment->getDriver()?->getName() ?? '');
$sheet->setCellValue('L'.$row, $shipment->getTruck()?->getName() ?? '');
$sheet->setCellValue('M'.$row, $shipment->getLicensePlate() ?? '');
$gross = $this->extractWeight($shipment->getWeights(), 'gross');
$tare = $this->extractWeight($shipment->getWeights(), 'tare');
if (null !== $gross) {
$sheet->setCellValue('N'.$row, $gross);
}
if (null !== $tare) {
$sheet->setCellValue('O'.$row, $tare);
}
if (null !== $gross && null !== $tare) {
$sheet->setCellValue('P'.$row, $gross - $tare);
}
$sheet->getStyle('N'.$row.':P'.$row)->getNumberFormat()->setFormatCode('#,##0');
$sheet->setCellValue('Q'.$row, $shipment->getNbBovinSend());
// Alignements
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('N'.$row.':P'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('Q'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
}
/**
* @param null|iterable<Address> $addresses
*/
private function formatAddresses(?iterable $addresses): string
{
if (null === $addresses) {
return '';
}
$parts = [];
foreach ($addresses as $address) {
$full = $address->getFullAddress();
if ('' !== $full) {
$parts[] = $full;
}
}
return implode(' ; ', $parts);
}
/**
* @param iterable<Weight> $weights
*/
private function extractWeight(iterable $weights, string $type): ?int
{
foreach ($weights as $weight) {
if ($weight->getType() === $type) {
return $weight->getWeight();
}
}
return null;
}
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
private function renderXlsx(Spreadsheet $spreadsheet): string
{
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
ob_start();
$writer->save('php://output');
$body = ob_get_clean();
return false !== $body ? $body : '';
}
}

View File

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

View File

@@ -47,7 +47,7 @@ final class PontBasculeServiceTest extends TestCase
$httpClient
->expects(self::once())
->method('request')
->with('POST', 'http://example.test')
->with('POST', 'http://example.test/send/dsd')
->willReturn($response)
;
@@ -82,4 +82,103 @@ final class PontBasculeServiceTest extends TestCase
$service->fetch();
}
public function testCheckHealthBypassIsHealthyWithoutHttpCall(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects(self::never())->method('request');
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', true);
$health = $service->checkHealth();
self::assertTrue($health->isHealthy());
}
public function testCheckHealthHealthyPayload(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => true,
'port_error' => null,
'hostname' => 'liot-rasp-ferme-01',
], JSON_THROW_ON_ERROR));
$health = $service->checkHealth();
self::assertTrue($health->isHealthy());
self::assertSame('liot-rasp-ferme-01', $health->getHostname());
}
public function testCheckHealthUnhealthyWhenPortError(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => true,
'port_error' => 'device disconnected',
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyWhenPortNotConnected(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => false,
'port_error' => null,
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyWhenBusy(): void
{
$service = $this->serviceForHealthBody(json_encode([
'ok' => true,
'busy' => true,
'port_connected' => true,
'port_error' => null,
], JSON_THROW_ON_ERROR));
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyOnTransportFailure(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient
->expects(self::once())
->method('request')
->willThrowException($this->createStub(TransportExceptionInterface::class))
;
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
self::assertFalse($service->checkHealth()->isHealthy());
}
public function testCheckHealthUnhealthyOnInvalidJson(): void
{
self::assertFalse($this->serviceForHealthBody('not-json')->checkHealth()->isHealthy());
}
private function serviceForHealthBody(string $body): PontBasculeService
{
$response = $this->createStub(ResponseInterface::class);
$response->method('getContent')->with(false)->willReturn($body);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient
->expects(self::once())
->method('request')
->with('GET', 'http://example.test/health')
->willReturn($response)
;
return new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\ApiResource\PontBasculeHealthCheck;
use App\Service\PontBasculePayloadDecoder;
use App\Service\PontBasculeService;
use App\State\PontBasculeHealthProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @internal
*/
final class PontBasculeHealthProviderTest extends TestCase
{
public function testProvideMapsHealthyPayloadToResource(): void
{
$provider = $this->providerForHealthBody(json_encode([
'ok' => true,
'busy' => false,
'port_connected' => true,
'port_error' => null,
'hostname' => 'liot-rasp-ferme-01',
], JSON_THROW_ON_ERROR));
$result = $provider->provide(new Get());
self::assertInstanceOf(PontBasculeHealthCheck::class, $result);
self::assertTrue($result->healthy);
self::assertTrue($result->ok);
self::assertFalse($result->busy);
self::assertTrue($result->portConnected);
self::assertNull($result->portError);
self::assertSame('liot-rasp-ferme-01', $result->hostname);
}
public function testProvideMapsUnhealthyPayloadWithPortError(): void
{
$provider = $this->providerForHealthBody(json_encode([
'ok' => true,
'busy' => true,
'port_connected' => false,
'port_error' => 'device disconnected',
'hostname' => 'liot-rasp-ferme-01',
], JSON_THROW_ON_ERROR));
$result = $provider->provide(new Get());
self::assertFalse($result->healthy);
self::assertTrue($result->ok);
self::assertTrue($result->busy);
self::assertFalse($result->portConnected);
self::assertSame('device disconnected', $result->portError);
}
private function providerForHealthBody(string $body): PontBasculeHealthProvider
{
$response = $this->createStub(ResponseInterface::class);
$response->method('getContent')->willReturn($body);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient
->expects(self::once())
->method('request')
->with('GET', 'http://example.test/health')
->willReturn($response)
;
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
return new PontBasculeHealthProvider($service);
}
}