# 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 `