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, factoryunhealthy().src/ApiResource/PontBasculeHealthCheck.php— carrier API Platform hébergeantGET /pont_bascule/health,output: PontBasculeHealth::class.src/State/PontBasculeHealthProvider.php— provider qui appellePontBasculeService::checkHealth().
Backend (modifiés) :
src/Service/PontBasculeService.php—$endpoint→$baseUrl;fetch()POST{base}/send/dsd; nouvelle méthodecheckHealth().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 + testscheckHealth().
Frontend (créés) :
frontend/composables/usePontBascule.ts— étatstatus+checkHealth().
Frontend (modifiés) :
frontend/components/workflow/workflow-weight.vue— affichage dynamique de l'état +disabledsur « 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→$baseUrlet 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 +
outputsur ressource non-entité : si API Platform refuseoutput:sur la classe carrier videPontBasculeHealthCheck, repli sur le patternAppVersionpur — déplacer les champs (props publiques + Groups) directement dansPontBasculeHealthCheck, supprimeroutput:, et faire quePontBasculeHealthProvidermappe le DTOPontBasculeHealthvers une instance dePontBasculeHealthCheck. 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.