Files
Ferme/docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md
2026-05-21 13:56:22 +02:00

22 KiB

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 $baseUrlPONT_BASCULE_BASE_URL.
  • .env, .env.local, .env.prodPONT_BASCULE_URLPONT_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 :

        $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() :

    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 :

                $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) :

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

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

    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() :

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

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

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
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) :

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

            <p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>

par :

            <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) :

        <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') :

import { onMounted, toRef } from 'vue'
import { usePontBascule } from '~/composables/usePontBascule'

puis, après la ligne const entityRef = toRef(props, 'entity'), ajouter :

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