diff --git a/docs/superpowers/specs/2026-05-21-pont-bascule-healthcheck-design.md b/docs/superpowers/specs/2026-05-21-pont-bascule-healthcheck-design.md new file mode 100644 index 0000000..45902ce --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-pont-bascule-healthcheck-design.md @@ -0,0 +1,167 @@ +# 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 `PontBasculeHealth` (`src/ApiResource/PontBasculeHealth.php`) + +- Opération `GET /pont_bascule/health`, sans état persistant, `output` = DTO, + `provider` = `PontBasculeHealthProvider`. +- Endpoint au pluriel : `pont_bascule` est déjà invariable, conserver `/pont_bascule/health`. + +### `PontBasculeHealthProvider` (`src/State/`) + +- Appelle `PontBasculeService::checkHealth()` et renvoie le DTO. +- 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. + +### i18n (`frontend/i18n/locales/fr.json`) + +Nouvelles clés : + +- `pontBascule.checking` → « Vérification du pont-bascule… » +- `pontBascule.connected` → « Pont-bascule connecté » +- `pontBascule.disconnected` → « Pont-bascule non connecté » + +## 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`.