From 0390f5f1a271c17ca48e6ff6d51d2d9da33d902c Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 11:57:54 +0200 Subject: [PATCH 01/12] =?UTF-8?q?docs(fer-19)=20:=20spec=20health-check=20?= =?UTF-8?q?pont-bascule=20sur=20=C3=A9cran=20de=20pes=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-21-pont-bascule-healthcheck-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-pont-bascule-healthcheck-design.md 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`. -- 2.39.5 From d6992aa549c7a3725398867189b0f992db66c52c Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 13:56:22 +0200 Subject: [PATCH 02/12] docs(fer-19) : plan d'implementation health-check pont-bascule Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-21-pont-bascule-healthcheck.md | 647 ++++++++++++++++++ ...6-05-21-pont-bascule-healthcheck-design.md | 29 +- 2 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md diff --git a/docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md b/docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md new file mode 100644 index 0000000..fc7022b --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md @@ -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 +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 +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 + ['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 "` +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('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 `