Compare commits
18 Commits
754898da39
...
v0.0.107
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5987329d | ||
| cde30340a1 | |||
|
|
7e368505e4 | ||
| fd6aed8ae0 | |||
|
|
16ebede557 | ||
| 3f48568ae9 | |||
|
|
39f67b3c90 | ||
| eccb8e1fc6 | |||
|
|
de39207102 | ||
| 5da0003c4d | |||
|
|
88f19cbb59 | ||
| df3f86d33f | |||
|
|
5cc9b7855f | ||
| b61321c7b7 | |||
|
|
b130d44054 | ||
| dfa29ffc7a | |||
|
|
cde2c4fbb7 | ||
| 5552d98935 |
2
.env
2
.env
@@ -19,4 +19,4 @@ COOKIE_SECURE=
|
||||
DATABASE_URL=
|
||||
|
||||
PONT_BASCULE_BYPASS=
|
||||
PONT_BASCULE_URL=
|
||||
PONT_BASCULE_BASE_URL=
|
||||
|
||||
@@ -66,6 +66,11 @@ Ajouter dans le fichier .env du frontend
|
||||
* [#FER-17] Ecran d'ajout de bovin
|
||||
* [#FER-18] Mise à jour du tableau d'arrivage
|
||||
* [#FER-26] Passeport du bovin
|
||||
* [#FER-27] Fix export inventaire bovin
|
||||
* [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin
|
||||
* [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel
|
||||
* [#FER-30] Revoir l'affichage type bovin
|
||||
* [#FER-19] Ajouter le healthCheck du pont bascule
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
|
||||
App\Service\PontBasculeService:
|
||||
arguments:
|
||||
$endpoint: '%env(PONT_BASCULE_URL)%'
|
||||
$baseUrl: '%env(PONT_BASCULE_BASE_URL)%'
|
||||
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.98'
|
||||
app.version: '0.0.107'
|
||||
|
||||
647
docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md
Normal file
647
docs/superpowers/plans/2026-05-21-pont-bascule-healthcheck.md
Normal file
@@ -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
|
||||
<?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**
|
||||
|
||||
```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
|
||||
<?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
|
||||
<?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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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 `<script setup>`
|
||||
|
||||
- [ ] **Step 1 : Remplacer le texte hardcodé par l'affichage dynamique**
|
||||
|
||||
Dans `frontend/components/workflow/workflow-weight.vue`, remplacer la ligne 5 :
|
||||
|
||||
```html
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
<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`) :
|
||||
|
||||
```html
|
||||
<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'`) :
|
||||
|
||||
```ts
|
||||
import { onMounted, toRef } from 'vue'
|
||||
import { usePontBascule } from '~/composables/usePontBascule'
|
||||
```
|
||||
|
||||
puis, après la ligne `const entityRef = toRef(props, 'entity')`, ajouter :
|
||||
|
||||
```ts
|
||||
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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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 `PontBasculeHealthCheck` (`src/ApiResource/PontBasculeHealthCheck.php`)
|
||||
|
||||
- Classe carrier fine (vide) hébergeant l'opération `GET /pont_bascule/health`,
|
||||
sans état persistant, `output` = `PontBasculeHealth::class` (le DTO), `provider` =
|
||||
`PontBasculeHealthProvider`. Même montage que `/receptions/weigh` (host déclare
|
||||
l'opération, `output` = DTO, provider renvoie le DTO).
|
||||
- Nommée `PontBasculeHealthCheck` pour éviter la collision de nom court avec le DTO
|
||||
`Dto\PontBasculeHealth`.
|
||||
- Route conservée `/pont_bascule/health` (`pont_bascule` invariable).
|
||||
|
||||
### `PontBasculeHealthProvider` (`src/State/PontBasculeHealthProvider.php`)
|
||||
|
||||
- Appelle `PontBasculeService::checkHealth()` et renvoie le DTO `PontBasculeHealth`.
|
||||
- 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.
|
||||
|
||||
### Textes d'état (codés en dur)
|
||||
|
||||
Convention du projet : les textes d'UI dans les composants/pages sont **codés en dur en
|
||||
français** (le « Pont-bascule connecté » actuel l'est déjà). `fr.json` n'est consommé que
|
||||
par `useApi` pour les toasts. On reste cohérent : les trois libellés sont écrits directement
|
||||
dans `workflow-weight.vue`, **pas** dans `fr.json`.
|
||||
|
||||
- `checking` → « Vérification du pont-bascule… » (couleur primary)
|
||||
- `connected` → « Pont-bascule connecté » (vert)
|
||||
- `disconnected` → « Pont-bascule non connecté » (rouge)
|
||||
|
||||
## 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`.
|
||||
@@ -5,12 +5,10 @@
|
||||
@submit.prevent="goNext"
|
||||
>
|
||||
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
|
||||
<div
|
||||
class="flex flex-row gap-8 items-center w-full">
|
||||
<div class="grid grid-cols-4 gap-x-8 gap-y-6">
|
||||
<div
|
||||
v-for="type in bovineType"
|
||||
:key="type.id"
|
||||
class="mt-8 flex flex-row mb-2 w-full">
|
||||
:key="type.id">
|
||||
<UiNumberInput
|
||||
:id="type.id"
|
||||
:label="type.label"
|
||||
@@ -23,12 +21,11 @@
|
||||
wrapper-class="gap-3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-8 flex flex-row mb-2 gap-6">
|
||||
<div>
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="otherQuantity"
|
||||
class="max-w-[80px]"
|
||||
class="max-w-[150px]"
|
||||
wrapper-class="gap-3"
|
||||
/>
|
||||
</div>
|
||||
@@ -79,7 +76,7 @@ const totalBovines = computed(() => {
|
||||
const loadBovineType = async () => {
|
||||
isLoadingBovineType.value = true
|
||||
try {
|
||||
bovineType.value = await getBovineTypeList()
|
||||
bovineType.value = await getBovineTypeList({ display: true })
|
||||
} finally {
|
||||
isLoadingBovineType.value = false
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { getBovineTypeList } from '~/services/bovine-type'
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
|
||||
@@ -45,7 +45,18 @@ const emit = defineEmits<{
|
||||
(event: 'update:otherQuantity', value: number | null): void
|
||||
}>()
|
||||
|
||||
const bovineTypes = ref<BovineTypeData[]>([])
|
||||
// Types activés par l'admin (display=true), chargés depuis l'API.
|
||||
const displayedTypes = ref<BovineTypeData[]>([])
|
||||
// On affiche les types activés ET ceux déjà saisis sur la réception (même masqués),
|
||||
// pour ne pas faire disparaître/perdre une quantité existante.
|
||||
const bovineTypes = computed<BovineTypeData[]>(() => {
|
||||
const seen = new Set(displayedTypes.value.map((type) => type.id))
|
||||
const fromExisting = props.modelValue
|
||||
.map((entry) => entry.bovineType)
|
||||
.filter((type): type is BovineTypeData => Boolean(type) && !seen.has(type.id))
|
||||
|
||||
return [...displayedTypes.value, ...fromExisting]
|
||||
})
|
||||
const localQuantities = reactive<Record<string, number | null>>({})
|
||||
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
|
||||
// Verrou pour éviter les boucles props -> local -> emit -> props.
|
||||
@@ -154,8 +165,13 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Re-synchronise dès que la liste fusionnée change (chargement async des types).
|
||||
watch(bovineTypes, () => {
|
||||
syncLocalFromProps()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
bovineTypes.value = await getBovineTypeList()
|
||||
displayedTypes.value = await getBovineTypeList({ display: true })
|
||||
syncLocalFromProps()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
<p
|
||||
v-if="pontBasculeStatus === 'connected'"
|
||||
class="uppercase text-2xl mt-2 text-green-600">Pont-bascule connecté</p>
|
||||
<p
|
||||
v-else-if="pontBasculeStatus === 'disconnected'"
|
||||
class="uppercase text-2xl mt-2 text-red-600">Pont-bascule non connecté</p>
|
||||
<p
|
||||
v-else
|
||||
class="uppercase text-2xl mt-2 invisible">Pont-bascule connecté</p>
|
||||
<div
|
||||
v-if="!displayWeight"
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
||||
@@ -19,6 +27,7 @@
|
||||
<div class="flex justify-center mt-[54px]">
|
||||
<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>
|
||||
<UiButton
|
||||
@@ -35,7 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
import { onMounted, toRef } from 'vue'
|
||||
import { usePontBascule } from '~/composables/usePontBascule'
|
||||
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
@@ -55,6 +65,9 @@ const props = defineProps<{
|
||||
|
||||
const entityRef = toRef(props, 'entity')
|
||||
|
||||
const { status: pontBasculeStatus, checkHealth } = usePontBascule()
|
||||
onMounted(checkHealth)
|
||||
|
||||
const {
|
||||
displayWeight,
|
||||
title,
|
||||
|
||||
23
frontend/composables/usePontBascule.ts
Normal file
23
frontend/composables/usePontBascule.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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 }
|
||||
}
|
||||
@@ -14,10 +14,13 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
|
||||
<div class="grid grid-cols-2 items-start pt-7 mb-8 gap-x-[200px]">
|
||||
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
|
||||
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
|
||||
</div>
|
||||
<div class="mb-11">
|
||||
<UiCheckbox v-model="form.display" label="Afficher dans les réceptions" />
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
@@ -53,7 +56,8 @@ function resolveId(param: unknown) {
|
||||
|
||||
const form = reactive<BovinFormData>({
|
||||
label: '',
|
||||
code: ''
|
||||
code: '',
|
||||
display: false
|
||||
})
|
||||
|
||||
|
||||
@@ -64,6 +68,7 @@ const hydrateFromBovin = (bovin: BovineTypeData | null) => {
|
||||
isHydrating.value = true
|
||||
form.label = bovin.label ?? ''
|
||||
form.code = bovin.code ?? ''
|
||||
form.display = bovin.display ?? false
|
||||
isHydrating.value = false
|
||||
}
|
||||
|
||||
@@ -92,8 +97,8 @@ async function validate() {
|
||||
|
||||
const basePayload = {
|
||||
label: normalizedBovinLabel,
|
||||
code: normalizedBovinCode
|
||||
|
||||
code: normalizedBovinCode,
|
||||
display: form.display
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
<template #cell-display="{ item }">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
|
||||
:class="item.display ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
|
||||
>
|
||||
{{ item.display ? 'Oui' : 'Non' }}
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
@@ -58,7 +66,8 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: 'Nom' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'display', label: 'Affiché en réception' }
|
||||
]
|
||||
|
||||
const goToBovin = (bovin: BovineTypeData) => {
|
||||
|
||||
@@ -194,7 +194,6 @@ interface BovineMovementData {
|
||||
enteredAt: string
|
||||
leftAt: string | null
|
||||
buildingCase: BuildingCaseRef | null
|
||||
building: BuildingRef | null
|
||||
}
|
||||
|
||||
interface BovinePassportData {
|
||||
@@ -301,7 +300,7 @@ const movementRows = computed(() => {
|
||||
const list = bovine.value?.movements ?? []
|
||||
return list.map(m => ({
|
||||
id: m.id,
|
||||
building: m.buildingCase?.building?.label ?? m.building?.label ?? '—',
|
||||
building: m.buildingCase?.building?.label ?? '—',
|
||||
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
|
||||
enteredAt: formatDate(m.enteredAt),
|
||||
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="mt-6 space-y-3">
|
||||
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||
<div
|
||||
v-for="entry in buildingLayouts"
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Légende : survol d'un statut => atténue les autres cases -->
|
||||
<div class="py-4">
|
||||
<div class="">
|
||||
<div class="flex gap-6">
|
||||
<div
|
||||
v-for="statut in statutLegend"
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-buildingCase.building.label="{ item }">
|
||||
{{ item.effectiveBuilding?.label ?? '—' }}
|
||||
{{ item.buildingCase?.building?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-buildingCase.caseNumber="{ item }">
|
||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
|
||||
<div
|
||||
v-if="auth.isBureau"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||
title="Exporter en Excel"
|
||||
@click="exportReceptions"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
@@ -79,9 +88,35 @@ import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
const exporting = ref(false)
|
||||
|
||||
const exportReceptions = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.getBlob('receptions/export')
|
||||
const filename = `receptions_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
|
||||
<div
|
||||
v-if="auth.isBureau"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||
title="Exporter en Excel"
|
||||
@click="exportShipments"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
@@ -77,9 +86,35 @@ import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
const exporting = ref(false)
|
||||
|
||||
const exportShipments = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.getBlob('shipments/export')
|
||||
const filename = `expeditions_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
|
||||
@@ -5,9 +5,13 @@ export type BovineTypeListResponse =
|
||||
| BovineTypeData[]
|
||||
| { 'hydra:member'?: BovineTypeData[] }
|
||||
|
||||
export async function getBovineTypeList(): Promise<BovineTypeData[]> {
|
||||
export async function getBovineTypeList(filters: { display?: boolean } = {}): Promise<BovineTypeData[]> {
|
||||
const api = useApi()
|
||||
const response = await api.get<BovineTypeListResponse>('bovine_types', {}, {
|
||||
const query: Record<string, string> = {}
|
||||
if (filters.display !== undefined) {
|
||||
query.display = filters.display ? 'true' : 'false'
|
||||
}
|
||||
const response = await api.get<BovineTypeListResponse>('bovine_types', query, {
|
||||
toastErrorKey: 'errors.bovin.list'
|
||||
})
|
||||
|
||||
@@ -51,10 +55,12 @@ export async function updateBovin(id: number, payload: BovinPayload = {}): Promi
|
||||
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
code: item.code
|
||||
code: item.code,
|
||||
display: item.display ?? false
|
||||
})
|
||||
|
||||
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
|
||||
label: payload.label ?? undefined,
|
||||
code: payload.code ?? undefined
|
||||
code: payload.code ?? undefined,
|
||||
display: payload.display ?? undefined
|
||||
})
|
||||
|
||||
@@ -9,34 +9,3 @@ export async function createBovine(payload: BovinePayload) {
|
||||
toastSuccessKey: 'success.bovine.create'
|
||||
})
|
||||
}
|
||||
|
||||
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
|
||||
const created: BovineData[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const nationalNumber of nationalNumbers) {
|
||||
try {
|
||||
const bovine = await createBovine({ nationalNumber })
|
||||
if (bovine) {
|
||||
created.push(bovine)
|
||||
}
|
||||
} catch {
|
||||
errors.push(nationalNumber)
|
||||
}
|
||||
}
|
||||
|
||||
return { created, errors }
|
||||
}
|
||||
|
||||
export async function getBovine(id: number) {
|
||||
const api = useApi()
|
||||
return api.get<BovineData>(`bovines/${id}`)
|
||||
}
|
||||
|
||||
export async function updateBovine(id: number, payload: BovinePayload) {
|
||||
const api = useApi()
|
||||
return api.patch<BovineData>(`bovines/${id}`, payload, {
|
||||
toastErrorKey: 'errors.bovine.update',
|
||||
toastSuccessKey: 'success.bovine.update'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface BovineData {
|
||||
arrivalDate: string | null
|
||||
exitDate: string | null
|
||||
buildingCase: BovineBuildingCaseRef | null
|
||||
building: BovineBuildingRef | null
|
||||
effectiveBuilding: BovineBuildingRef | null
|
||||
supplier: string | null
|
||||
workNumber: string | null
|
||||
birthDate: string | null
|
||||
@@ -29,9 +27,5 @@ export interface BovineData {
|
||||
|
||||
export type BovinePayload = {
|
||||
nationalNumber?: string
|
||||
receivedWeight?: number | null
|
||||
pricePerKg?: number | null
|
||||
arrivalDate?: string | null
|
||||
buildingCase?: string | null
|
||||
supplier?: string | null
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ export interface BovineTypeData{
|
||||
id: number
|
||||
label: string
|
||||
code: string
|
||||
display: boolean
|
||||
}
|
||||
|
||||
export interface BovinFormData {
|
||||
label: string
|
||||
code: string
|
||||
display: boolean
|
||||
}
|
||||
|
||||
export type BovinPayload = {
|
||||
label?: string | null
|
||||
code?: string | null
|
||||
display?: boolean | null
|
||||
}
|
||||
|
||||
29
migrations/Version20260521092455.php
Normal file
29
migrations/Version20260521092455.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260521092455 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajoute la colonne display sur bovine_type (défaut false) pour piloter l\'affichage dans une réception';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine_type ADD display BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine_type DROP display');
|
||||
}
|
||||
}
|
||||
46
src/ApiResource/PontBasculeHealthCheck.php
Normal file
46
src/ApiResource/PontBasculeHealthCheck.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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\State\PontBasculeHealthProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[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']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: PontBasculeHealthProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class PontBasculeHealthCheck
|
||||
{
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public bool $healthy = false;
|
||||
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public bool $ok = false;
|
||||
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public bool $busy = false;
|
||||
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public bool $portConnected = false;
|
||||
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public ?string $portError = null;
|
||||
|
||||
#[Groups(['pont_bascule:health:read'])]
|
||||
public ?string $hostname = null;
|
||||
}
|
||||
32
src/ApiResource/ReceptionExport.php
Normal file
32
src/ApiResource/ReceptionExport.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Reception\ReceptionExportProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/receptions/export',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: 'Export Excel des réceptions terminées.',
|
||||
description: 'Retourne un fichier XLSX listant toutes les réceptions validées (isValid = true), triées par date décroissante.',
|
||||
tags: ['Receptions'],
|
||||
),
|
||||
security: "is_granted('ROLE_BUREAU')",
|
||||
output: false,
|
||||
provider: ReceptionExportProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class ReceptionExport
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
}
|
||||
32
src/ApiResource/ShipmentExport.php
Normal file
32
src/ApiResource/ShipmentExport.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Shipment\ShipmentExportProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/shipments/export',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: 'Export Excel des expéditions terminées.',
|
||||
description: 'Retourne un fichier XLSX listant toutes les expéditions validées (isValid = true), triées par date décroissante.',
|
||||
tags: ['Shipments'],
|
||||
),
|
||||
security: "is_granted('ROLE_BUREAU')",
|
||||
output: false,
|
||||
provider: ShipmentExportProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class ShipmentExport
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\BovineMovement;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function count;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:backfill-bovine-movements',
|
||||
description: 'Crée un mouvement initial pour chaque bovin ayant une case ou un bâtiment mais aucun mouvement enregistré.'
|
||||
)]
|
||||
class BackfillBovineMovementsCommand extends Command
|
||||
{
|
||||
private const FLUSH_EVERY = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bovines = $this->entityManager->createQueryBuilder()
|
||||
->select('b')
|
||||
->from(Bovine::class, 'b')
|
||||
->where('b.buildingCase IS NOT NULL OR b.building IS NOT NULL')
|
||||
->andWhere('NOT EXISTS (SELECT 1 FROM '.BovineMovement::class.' m WHERE m.bovine = b)')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$total = count($bovines);
|
||||
if (0 === $total) {
|
||||
$io->success('Aucun bovin à backfiller.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('%d bovin(s) à backfiller.', $total));
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
$created = 0;
|
||||
$fallback = 0;
|
||||
|
||||
foreach ($bovines as $i => $bovine) {
|
||||
$movement = new BovineMovement();
|
||||
$movement->setBovine($bovine);
|
||||
|
||||
if (null !== $bovine->getBuildingCase()) {
|
||||
$movement->setBuildingCase($bovine->getBuildingCase());
|
||||
} else {
|
||||
$movement->setBuilding($bovine->getBuilding());
|
||||
}
|
||||
|
||||
$enteredAt = $bovine->getArrivalDate();
|
||||
if (null === $enteredAt) {
|
||||
$enteredAt = $now;
|
||||
++$fallback;
|
||||
}
|
||||
$movement->setEnteredAt($enteredAt);
|
||||
|
||||
$this->entityManager->persist($movement);
|
||||
++$created;
|
||||
|
||||
if (0 === ($i + 1) % self::FLUSH_EVERY) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('%d mouvement(s) créé(s).', $created));
|
||||
if ($fallback > 0) {
|
||||
$io->warning(sprintf("%d bovin(s) sans date d'arrivée → enteredAt = maintenant.", $fallback));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\Building;
|
||||
use App\Entity\Supplier;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:feed-bovine-prices',
|
||||
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
|
||||
)]
|
||||
final class FeedBovinePricesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$file = (string) $input->getArgument('file');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
if (!file_exists($file)) {
|
||||
$io->error(sprintf('Fichier introuvable : %s', $file));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Feed bovins depuis '.basename($file));
|
||||
if ($dryRun) {
|
||||
$io->warning('Dry-run activé : aucune écriture en BDD.');
|
||||
}
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Impossible de lire le fichier : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
|
||||
$supplierByName = [];
|
||||
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
|
||||
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
|
||||
}
|
||||
|
||||
// Pré-chargement des bâtiments par code (insensible casse).
|
||||
$buildingByCode = [];
|
||||
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
|
||||
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
|
||||
}
|
||||
|
||||
$bovineRepo = $this->em->getRepository(Bovine::class);
|
||||
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'updated' => 0,
|
||||
'notFound' => 0,
|
||||
'invalid' => 0,
|
||||
'supplierMissing' => 0,
|
||||
'buildingMissing' => 0,
|
||||
];
|
||||
$missingNationalNumbers = [];
|
||||
$missingSuppliers = [];
|
||||
$missingBuildings = [];
|
||||
|
||||
$io->progressStart($highestRow);
|
||||
for ($row = 1; $row <= $highestRow; ++$row) {
|
||||
++$stats['total'];
|
||||
|
||||
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
|
||||
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
|
||||
$rawWeight = $sheet->getCell([3, $row])->getValue();
|
||||
$rawPrice = $sheet->getCell([4, $row])->getValue();
|
||||
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
|
||||
|
||||
$rawNationalNumber = trim($rawNationalNumber);
|
||||
if ('' === $rawNationalNumber) {
|
||||
++$stats['invalid'];
|
||||
$io->progressAdvance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
|
||||
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
|
||||
|
||||
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
|
||||
if (null === $bovine) {
|
||||
++$stats['notFound'];
|
||||
$missingNationalNumbers[] = $nationalNumber;
|
||||
$io->progressAdvance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lookup supplier (peut être null si introuvable ou colonne vide).
|
||||
$supplier = null;
|
||||
$supplierName = mb_strtoupper(trim($rawSupplier));
|
||||
if ('' !== $supplierName) {
|
||||
$supplier = $supplierByName[$supplierName] ?? null;
|
||||
if (null === $supplier) {
|
||||
++$stats['supplierMissing'];
|
||||
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
|
||||
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
|
||||
|
||||
if (null !== $weight) {
|
||||
$bovine->setReceivedWeight($weight);
|
||||
}
|
||||
if (null !== $price) {
|
||||
$bovine->setPricePerKg($price);
|
||||
}
|
||||
$bovine->setSupplier($supplier);
|
||||
|
||||
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
|
||||
$buildingCode = mb_strtoupper(trim($rawBuilding));
|
||||
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
|
||||
$building = $buildingByCode[$buildingCode] ?? null;
|
||||
if (null !== $building) {
|
||||
$bovine->setBuilding($building);
|
||||
} else {
|
||||
++$stats['buildingMissing'];
|
||||
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
++$stats['updated'];
|
||||
$io->progressAdvance();
|
||||
}
|
||||
$io->progressFinish();
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->section('Résultats');
|
||||
$io->table(
|
||||
['Métrique', 'Valeur'],
|
||||
[
|
||||
['Lignes totales', $stats['total']],
|
||||
['Bovins mis à jour', $stats['updated']],
|
||||
['Bovins introuvables', $stats['notFound']],
|
||||
['Lignes invalides', $stats['invalid']],
|
||||
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
|
||||
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
|
||||
]
|
||||
);
|
||||
|
||||
if ([] !== $missingNationalNumbers) {
|
||||
$preview = array_slice($missingNationalNumbers, 0, 10);
|
||||
$io->warning(sprintf(
|
||||
'%d bovin(s) introuvable(s). Aperçu : %s%s',
|
||||
count($missingNationalNumbers),
|
||||
implode(', ', $preview),
|
||||
count($missingNationalNumbers) > 10 ? '…' : '',
|
||||
));
|
||||
}
|
||||
|
||||
if ([] !== $missingSuppliers) {
|
||||
$list = [];
|
||||
foreach ($missingSuppliers as $name => $count) {
|
||||
$list[] = sprintf('%s (%d)', $name, $count);
|
||||
}
|
||||
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
|
||||
}
|
||||
|
||||
if ([] !== $missingBuildings) {
|
||||
$list = [];
|
||||
foreach ($missingBuildings as $code => $count) {
|
||||
$list[] = sprintf('%s (%d)', $code, $count);
|
||||
}
|
||||
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
|
||||
} else {
|
||||
$io->success('Feed terminé avec succès.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
49
src/Command/SyncBovineInventoryCommand.php
Normal file
49
src/Command/SyncBovineInventoryCommand.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\BovineInventorySyncer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:sync-bovine-inventory',
|
||||
description: "Synchronise l'inventaire bovin avec EDNOTIF (équivalent du bouton Rafraîchir de l'interface)."
|
||||
)]
|
||||
final class SyncBovineInventoryCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BovineInventorySyncer $syncer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$result = $this->syncer->sync();
|
||||
} catch (Throwable $e) {
|
||||
$io->error(sprintf('Échec de la synchronisation : %s', $e->getMessage()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Inventaire synchronisé · Créés : %d · Mis à jour : %d · Sortis : %d · Total EDNOTIF : %d',
|
||||
$result->created,
|
||||
$result->updated,
|
||||
$result->exited,
|
||||
$result->total,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
60
src/Dto/PontBasculeHealth.php
Normal file
60
src/Dto/PontBasculeHealth.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -96,11 +96,6 @@ class Bovine
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?BuildingCase $buildingCase = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?Building $building = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
private ?Supplier $supplier = null;
|
||||
@@ -244,28 +239,6 @@ class Bovine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuilding(): ?Building
|
||||
{
|
||||
return $this->building;
|
||||
}
|
||||
|
||||
public function setBuilding(?Building $building): static
|
||||
{
|
||||
$this->building = $building;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
|
||||
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
|
||||
*/
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
public function getEffectiveBuilding(): ?Building
|
||||
{
|
||||
return $this->buildingCase?->getIdBuilding() ?? $this->building;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?Supplier
|
||||
{
|
||||
return $this->supplier;
|
||||
|
||||
@@ -44,11 +44,6 @@ class BovineMovement
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?BuildingCase $buildingCase = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?Building $building = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
#[Groups(['bovine:read', 'bovine_movement:write'])]
|
||||
private DateTimeImmutable $enteredAt;
|
||||
@@ -86,18 +81,6 @@ class BovineMovement
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuilding(): ?Building
|
||||
{
|
||||
return $this->building;
|
||||
}
|
||||
|
||||
public function setBuilding(?Building $building): static
|
||||
{
|
||||
$this->building = $building;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnteredAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->enteredAt;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -19,6 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
'label' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['display'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
@@ -58,6 +60,15 @@ class BovineType
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
/**
|
||||
* Détermine si le type bovin est proposé à la sélection lors d'une réception.
|
||||
* Les types créés automatiquement par la synchro inventaire arrivent à false ;
|
||||
* seul un admin peut les activer.
|
||||
*/
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write'])]
|
||||
private bool $display = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -86,4 +97,16 @@ class BovineType
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDisplay(): bool
|
||||
{
|
||||
return $this->display;
|
||||
}
|
||||
|
||||
public function setDisplay(bool $display): static
|
||||
{
|
||||
$this->display = $display;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Repository\ReceptionRepository;
|
||||
use App\State\Reception\ReceptionReceiptProvider;
|
||||
use App\State\Reception\ReceptionWeighingProvider;
|
||||
use DateTimeImmutable;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: ReceptionRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
|
||||
@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Repository\ShipmentRepository;
|
||||
use App\State\Shipment\ShipmentReceiptProvider;
|
||||
use App\State\Shipment\ShipmentWeighingProvider;
|
||||
use DateTimeImmutable;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: ShipmentRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'shipment')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
|
||||
52
src/Repository/ReceptionRepository.php
Normal file
52
src/Repository/ReceptionRepository.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Reception;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Reception>
|
||||
*/
|
||||
final class ReceptionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Reception::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des réceptions validées pour l'export Excel (de la plus récente à la plus ancienne).
|
||||
*
|
||||
* @return list<Reception>
|
||||
*/
|
||||
public function findValidatedForExport(): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->leftJoin('r.supplier', 'supplier')->addSelect('supplier')
|
||||
->leftJoin('supplier.addresses', 'supplierAddresses')->addSelect('supplierAddresses')
|
||||
->leftJoin('r.address', 'address')->addSelect('address')
|
||||
->leftJoin('r.carrier', 'carrier')->addSelect('carrier')
|
||||
->leftJoin('r.driver', 'driver')->addSelect('driver')
|
||||
->leftJoin('r.truck', 'truck')->addSelect('truck')
|
||||
->leftJoin('r.user', 'user')->addSelect('user')
|
||||
->leftJoin('r.receptionType', 'receptionType')->addSelect('receptionType')
|
||||
->leftJoin('r.merchandiseType', 'merchandiseType')->addSelect('merchandiseType')
|
||||
->leftJoin('r.weights', 'weights')->addSelect('weights')
|
||||
->leftJoin('r.bovines_types', 'bovinesTypes')->addSelect('bovinesTypes')
|
||||
->leftJoin('bovinesTypes.bovineType', 'bovineType')->addSelect('bovineType')
|
||||
->leftJoin('r.pelletBuildings', 'pelletBuildings')->addSelect('pelletBuildings')
|
||||
->leftJoin('pelletBuildings.pelletType', 'pelletType')->addSelect('pelletType')
|
||||
->leftJoin('pelletBuildings.building', 'pelletBuilding')->addSelect('pelletBuilding')
|
||||
->where('r.isValid = :valid')
|
||||
->setParameter('valid', true)
|
||||
->orderBy('r.receptionDate', 'DESC')
|
||||
->addOrderBy('r.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
46
src/Repository/ShipmentRepository.php
Normal file
46
src/Repository/ShipmentRepository.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Shipment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Shipment>
|
||||
*/
|
||||
final class ShipmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Shipment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des expéditions validées pour l'export Excel (de la plus récente à la plus ancienne).
|
||||
*
|
||||
* @return list<Shipment>
|
||||
*/
|
||||
public function findValidatedForExport(): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->leftJoin('s.customer', 'customer')->addSelect('customer')
|
||||
->leftJoin('customer.addresses', 'customerAddresses')->addSelect('customerAddresses')
|
||||
->leftJoin('s.address', 'address')->addSelect('address')
|
||||
->leftJoin('s.carrier', 'carrier')->addSelect('carrier')
|
||||
->leftJoin('s.driver', 'driver')->addSelect('driver')
|
||||
->leftJoin('s.truck', 'truck')->addSelect('truck')
|
||||
->leftJoin('s.user', 'user')->addSelect('user')
|
||||
->leftJoin('s.shipmentType', 'shipmentType')->addSelect('shipmentType')
|
||||
->leftJoin('s.weights', 'weights')->addSelect('weights')
|
||||
->where('s.isValid = :valid')
|
||||
->setParameter('valid', true)
|
||||
->orderBy('s.shipmentDate', 'DESC')
|
||||
->addOrderBy('s.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
138
src/Service/BovineInventorySyncer.php
Normal file
138
src/Service/BovineInventorySyncer.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ApiResource\BovineSyncInventoryResult;
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\BovineType;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
||||
|
||||
final class BovineInventorySyncer
|
||||
{
|
||||
/**
|
||||
* @var array<string, BovineType>
|
||||
*/
|
||||
private array $bovineTypeCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly BovinApiInterface $bovinApi,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function sync(): BovineSyncInventoryResult
|
||||
{
|
||||
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
|
||||
|
||||
$result = new BovineSyncInventoryResult();
|
||||
$result->total = count($inventory->animals);
|
||||
|
||||
$this->bovineTypeCache = [];
|
||||
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
|
||||
if (null !== $bovineType->getCode()) {
|
||||
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
|
||||
}
|
||||
}
|
||||
|
||||
$existingByNationalNumber = [];
|
||||
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
|
||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
foreach ($inventory->animals as $animal) {
|
||||
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||
continue;
|
||||
}
|
||||
$seen[$nationalNumber] = true;
|
||||
|
||||
if (isset($existingByNationalNumber[$nationalNumber])) {
|
||||
$bovine = $existingByNationalNumber[$nationalNumber];
|
||||
++$result->updated;
|
||||
} else {
|
||||
$bovine = new Bovine();
|
||||
$bovine->setNationalNumber($nationalNumber);
|
||||
$this->em->persist($bovine);
|
||||
++$result->created;
|
||||
}
|
||||
|
||||
$this->applyEdnotifData($bovine, $animal);
|
||||
$bovine->setExitedAt(null);
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
|
||||
if (isset($seen[$nationalNumber])) {
|
||||
continue;
|
||||
}
|
||||
if (null !== $bovine->getExitedAt()) {
|
||||
continue;
|
||||
}
|
||||
$bovine->setExitedAt($now);
|
||||
++$result->exited;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
|
||||
{
|
||||
$identification = $animal->identification;
|
||||
if (null !== $identification) {
|
||||
$bovine->setSex($identification->sex);
|
||||
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||
$bovine->setWorkNumber($identification->workNumber);
|
||||
$bovine->setBirthDate($identification->birthDate?->date);
|
||||
|
||||
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
|
||||
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
|
||||
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
|
||||
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
|
||||
}
|
||||
|
||||
$latestEntry = null;
|
||||
$latestExit = null;
|
||||
foreach ($animal->presencePeriods as $period) {
|
||||
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
|
||||
$latestEntry = $period->entry->date;
|
||||
}
|
||||
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
|
||||
$latestExit = $period->exit->date;
|
||||
}
|
||||
}
|
||||
$bovine->setArrivalDate($latestEntry);
|
||||
$bovine->setExitDate($latestExit);
|
||||
$bovine->refreshAgeMonths();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un BovineType existant par code, sinon en crée un placeholder
|
||||
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
|
||||
*/
|
||||
private function resolveBovineType(?string $code): ?BovineType
|
||||
{
|
||||
if (null === $code || '' === $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($this->bovineTypeCache[$code])) {
|
||||
return $this->bovineTypeCache[$code];
|
||||
}
|
||||
|
||||
$bovineType = new BovineType();
|
||||
$bovineType->setCode($code);
|
||||
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
|
||||
$this->em->persist($bovineType);
|
||||
|
||||
$this->bovineTypeCache[$code] = $bovineType;
|
||||
|
||||
return $bovineType;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Dto\PontBasculeHealth;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Exception\PontBasculeException;
|
||||
use DateTimeImmutable;
|
||||
@@ -15,7 +16,7 @@ final class PontBasculeService
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly PontBasculePayloadDecoder $payloadDecoder,
|
||||
private readonly string $endpoint,
|
||||
private readonly string $baseUrl,
|
||||
private readonly bool $bypass,
|
||||
) {}
|
||||
|
||||
@@ -28,7 +29,7 @@ final class PontBasculeService
|
||||
$body = $this->getBypassPayload();
|
||||
} else {
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $this->endpoint);
|
||||
$response = $this->httpClient->request('POST', $this->buildUrl('/send/dsd'));
|
||||
$body = $response->getContent(false);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw PontBasculeException::transportFailure($exception->getMessage());
|
||||
@@ -44,6 +45,54 @@ final class PontBasculeService
|
||||
);
|
||||
}
|
||||
|
||||
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->buildUrl('/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,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildUrl(string $path): string
|
||||
{
|
||||
return rtrim($this->baseUrl, '/').$path;
|
||||
}
|
||||
|
||||
private function getBypassPayload(): string
|
||||
{
|
||||
return '{"ok":true,"busy":false,"mode":"serial","port":"/dev/ttyUSB0","baudrate":9600,"request_hex":"01 10 39 39 4D 0D 0A","response_hex":"01 02 30 34 30 32 30 30 02 30 31 30 30 31 34 32 30 2E 6B 67 20 02 30 32 30 30 30 30 30 30 2E 6B 67 20 02 30 33 30 30 31 34 32 30 2E 6B 67 20 02 39 39 30 30 31 32 31 0D 0A","response_ascii":"\u0001\u0002040200\u000201001420.kg \u000202000000.kg \u000203001420.kg \u00029900121"}';
|
||||
|
||||
@@ -18,13 +18,15 @@ use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Response>
|
||||
*/
|
||||
final class BovineInventoryExportProvider implements ProviderInterface
|
||||
final readonly class BovineInventoryExportProvider implements ProviderInterface
|
||||
{
|
||||
private const FARM_NAME = 'FERME SCEA LES NAUDS';
|
||||
|
||||
@@ -70,6 +72,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
private BovineRepository $bovineRepository,
|
||||
private RequestStack $requestStack,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -253,7 +256,16 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
// Lignes de données
|
||||
$rowNumber = 5;
|
||||
foreach ($bovines as $bovine) {
|
||||
$this->writeBovineRow($sheet, $rowNumber, $bovine);
|
||||
try {
|
||||
$this->writeBovineRow($sheet, $rowNumber, $bovine);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Export inventaire bovin : ligne ignorée suite à une erreur.', [
|
||||
'bovineId' => $bovine->getId(),
|
||||
'nationalNumber' => $bovine->getNationalNumber(),
|
||||
'row' => $rowNumber,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
++$rowNumber;
|
||||
}
|
||||
|
||||
@@ -276,7 +288,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
$type = $bovine->getBovineType();
|
||||
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
|
||||
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
|
||||
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
|
||||
$building = $bovine->getBuildingCase()?->getIdBuilding();
|
||||
$code = $building?->getCode();
|
||||
|
||||
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
|
||||
@@ -284,22 +296,25 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
? (int) $bovine->getWorkNumber()
|
||||
: ($bovine->getWorkNumber() ?? ''));
|
||||
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
|
||||
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
|
||||
$national = $bovine->getNationalNumber();
|
||||
$sheet->setCellValue('D'.$row, '' === $national ? '' : 'FR '.$national);
|
||||
$sheet->setCellValue('E'.$row, 'B1' === $code ? 'X' : '');
|
||||
$sheet->setCellValue('F'.$row, 'B2' === $code ? 'X' : '');
|
||||
$sheet->setCellValue('G'.$row, 'B3' === $code ? 'X' : '');
|
||||
$sheet->setCellValue('H'.$row, $bovine->getBuildingCase()?->getCaseNumber() ?? '');
|
||||
$sheet->setCellValue('I'.$row, $bovine->getSupplier()?->getName() ?? '');
|
||||
|
||||
$birth = $bovine->getBirthDate();
|
||||
$arrival = $bovine->getArrivalDate();
|
||||
if (null !== $birth) {
|
||||
$sheet->setCellValue('J'.$row, ExcelDate::PHPToExcel($birth));
|
||||
$birth = $bovine->getBirthDate();
|
||||
$arrival = $bovine->getArrivalDate();
|
||||
$birthExcel = $this->safePhpToExcel($birth);
|
||||
$arrivalExcel = $this->safePhpToExcel($arrival);
|
||||
if (null !== $birthExcel) {
|
||||
$sheet->setCellValue('J'.$row, $birthExcel);
|
||||
}
|
||||
if (null !== $arrival) {
|
||||
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
|
||||
if (null !== $arrivalExcel) {
|
||||
$sheet->setCellValue('K'.$row, $arrivalExcel);
|
||||
}
|
||||
if (null !== $birth && null !== $arrival) {
|
||||
if (null !== $birth && null !== $arrival && $birth <= $arrival) {
|
||||
$diff = $birth->diff($arrival);
|
||||
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
|
||||
}
|
||||
@@ -343,6 +358,24 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une date PHP en numéro de série Excel, ou null si la date est absente / hors plage Excel (< 1900).
|
||||
*/
|
||||
private function safePhpToExcel(?DateTimeImmutable $date): ?float
|
||||
{
|
||||
if (null === $date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = ExcelDate::PHPToExcel($date);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_float($value) ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sous-titre dynamique selon les tranches d'âge cochées.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,6 @@ final class BovineMovementProcessor implements ProcessorInterface
|
||||
$enteredAt = $data->hasEnteredAt() ? $data->getEnteredAt() : new DateTimeImmutable();
|
||||
$data->setEnteredAt($enteredAt);
|
||||
$data->setLeftAt(null);
|
||||
$data->setBuilding(null);
|
||||
|
||||
$bovine = $data->getBovine();
|
||||
|
||||
|
||||
@@ -7,26 +7,15 @@ namespace App\State\Bovin;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\BovineSyncInventoryResult;
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\BovineType;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
||||
use App\Service\BovineInventorySyncer;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
|
||||
*/
|
||||
final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string, BovineType>
|
||||
*/
|
||||
private array $bovineTypeCache = [];
|
||||
|
||||
public function __construct(
|
||||
private BovinApiInterface $bovinApi,
|
||||
private EntityManagerInterface $em,
|
||||
private BovineInventorySyncer $syncer,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -35,113 +24,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
array $uriVariables = [],
|
||||
array $context = [],
|
||||
): BovineSyncInventoryResult {
|
||||
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
|
||||
|
||||
$result = new BovineSyncInventoryResult();
|
||||
$result->total = count($inventory->animals);
|
||||
|
||||
$this->bovineTypeCache = [];
|
||||
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
|
||||
if (null !== $bovineType->getCode()) {
|
||||
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
|
||||
}
|
||||
}
|
||||
|
||||
$existingByNationalNumber = [];
|
||||
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
|
||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
foreach ($inventory->animals as $animal) {
|
||||
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||
continue;
|
||||
}
|
||||
$seen[$nationalNumber] = true;
|
||||
|
||||
if (isset($existingByNationalNumber[$nationalNumber])) {
|
||||
$bovine = $existingByNationalNumber[$nationalNumber];
|
||||
++$result->updated;
|
||||
} else {
|
||||
$bovine = new Bovine();
|
||||
$bovine->setNationalNumber($nationalNumber);
|
||||
$this->em->persist($bovine);
|
||||
++$result->created;
|
||||
}
|
||||
|
||||
$this->applyEdnotifData($bovine, $animal);
|
||||
$bovine->setExitedAt(null);
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
|
||||
if (isset($seen[$nationalNumber])) {
|
||||
continue;
|
||||
}
|
||||
if (null !== $bovine->getExitedAt()) {
|
||||
continue;
|
||||
}
|
||||
$bovine->setExitedAt($now);
|
||||
++$result->exited;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
|
||||
{
|
||||
$identification = $animal->identification;
|
||||
if (null !== $identification) {
|
||||
$bovine->setSex($identification->sex);
|
||||
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||
$bovine->setWorkNumber($identification->workNumber);
|
||||
$bovine->setBirthDate($identification->birthDate?->date);
|
||||
|
||||
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
|
||||
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
|
||||
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
|
||||
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
|
||||
}
|
||||
|
||||
$latestEntry = null;
|
||||
$latestExit = null;
|
||||
foreach ($animal->presencePeriods as $period) {
|
||||
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
|
||||
$latestEntry = $period->entry->date;
|
||||
}
|
||||
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
|
||||
$latestExit = $period->exit->date;
|
||||
}
|
||||
}
|
||||
$bovine->setArrivalDate($latestEntry);
|
||||
$bovine->setExitDate($latestExit);
|
||||
$bovine->refreshAgeMonths();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un BovineType existant par code, sinon en crée un placeholder
|
||||
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
|
||||
*/
|
||||
private function resolveBovineType(?string $code): ?BovineType
|
||||
{
|
||||
if (null === $code || '' === $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($this->bovineTypeCache[$code])) {
|
||||
return $this->bovineTypeCache[$code];
|
||||
}
|
||||
|
||||
$bovineType = new BovineType();
|
||||
$bovineType->setCode($code);
|
||||
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
|
||||
$this->em->persist($bovineType);
|
||||
|
||||
$this->bovineTypeCache[$code] = $bovineType;
|
||||
|
||||
return $bovineType;
|
||||
return $this->syncer->sync();
|
||||
}
|
||||
}
|
||||
|
||||
32
src/State/PontBasculeHealthProvider.php
Normal file
32
src/State/PontBasculeHealthProvider.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\PontBasculeHealthCheck;
|
||||
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 = []): PontBasculeHealthCheck
|
||||
{
|
||||
$health = $this->pontBasculeService->checkHealth();
|
||||
|
||||
$resource = new PontBasculeHealthCheck();
|
||||
$resource->healthy = $health->isHealthy();
|
||||
$resource->ok = $health->isOk();
|
||||
$resource->busy = $health->isBusy();
|
||||
$resource->portConnected = $health->isPortConnected();
|
||||
$resource->portError = $health->getPortError();
|
||||
$resource->hostname = $health->getHostname();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
358
src/State/Reception/ReceptionExportProvider.php
Normal file
358
src/State/Reception/ReceptionExportProvider.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Reception;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Address;
|
||||
use App\Entity\Reception;
|
||||
use App\Entity\Weight;
|
||||
use App\Repository\ReceptionRepository;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Response>
|
||||
*/
|
||||
final readonly class ReceptionExportProvider implements ProviderInterface
|
||||
{
|
||||
private const FARM_NAME = 'FERME SCEA LES NAUDS';
|
||||
|
||||
private const HEADER_FILL = 'FFCCECFF';
|
||||
|
||||
/**
|
||||
* Largeurs de colonnes (A à V).
|
||||
*/
|
||||
private const COLUMN_WIDTHS = [
|
||||
'A' => 12.0,
|
||||
'B' => 11.0,
|
||||
'C' => 7.0,
|
||||
'D' => 14.0,
|
||||
'E' => 12.0,
|
||||
'F' => 22.0,
|
||||
'G' => 30.0,
|
||||
'H' => 30.0,
|
||||
'I' => 18.0,
|
||||
'J' => 8.0,
|
||||
'K' => 18.0,
|
||||
'L' => 14.0,
|
||||
'M' => 12.0,
|
||||
'N' => 16.0,
|
||||
'O' => 22.0,
|
||||
'P' => 11.0,
|
||||
'Q' => 11.0,
|
||||
'R' => 11.0,
|
||||
'S' => 22.0,
|
||||
'T' => 26.0,
|
||||
'U' => 9.0,
|
||||
'V' => 26.0,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ReceptionRepository $receptionRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$receptions = $this->receptionRepository->findValidatedForExport();
|
||||
|
||||
$spreadsheet = $this->buildSpreadsheet($receptions);
|
||||
$body = $this->renderXlsx($spreadsheet);
|
||||
$filename = sprintf('receptions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
|
||||
|
||||
$response = new Response($body);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
$response->headers->set('Content-Length', (string) strlen($body));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Reception> $receptions
|
||||
*/
|
||||
private function buildSpreadsheet(array $receptions): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Receptions');
|
||||
|
||||
$pageSetup = $sheet->getPageSetup();
|
||||
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
|
||||
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
|
||||
$pageSetup->setFitToWidth(1);
|
||||
$pageSetup->setFitToHeight(0);
|
||||
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
|
||||
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
|
||||
|
||||
// Ligne 1 : titre + date
|
||||
$sheet->setCellValue('A1', sprintf('%s — RÉCEPTIONS TERMINÉES', self::FARM_NAME));
|
||||
$sheet->mergeCells('A1:U1');
|
||||
$sheet->getStyle('A1:U1')->applyFromArray([
|
||||
'font' => [
|
||||
'name' => 'Arial Black',
|
||||
'size' => 16,
|
||||
'bold' => true,
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_LEFT,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
]);
|
||||
$sheet->setCellValue('V1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
|
||||
$sheet->getStyle('V1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
$sheet->getStyle('V1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
|
||||
$sheet->getStyle('V1')->getFont()->setSize(12)->setBold(true);
|
||||
$sheet->getRowDimension(1)->setRowHeight(26.0);
|
||||
$sheet->getStyle('A1:V1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
|
||||
|
||||
// Ligne 2 : en-têtes
|
||||
$headers = [
|
||||
'A' => 'N° identification',
|
||||
'B' => 'Date',
|
||||
'C' => 'Heure',
|
||||
'D' => 'Type réception',
|
||||
'E' => 'Utilisateur',
|
||||
'F' => 'Fournisseur',
|
||||
'G' => 'Adresse fournisseur',
|
||||
'H' => 'Adresse réception',
|
||||
'I' => 'Transporteur',
|
||||
'J' => 'Code trans.',
|
||||
'K' => 'Chauffeur',
|
||||
'L' => 'Camion',
|
||||
'M' => 'Plaque',
|
||||
'N' => 'Type marchandise',
|
||||
'O' => 'Détail marchandise',
|
||||
'P' => 'Brut (kg)',
|
||||
'Q' => 'Tare (kg)',
|
||||
'R' => 'Net (kg)',
|
||||
'S' => 'Détail bovins',
|
||||
'T' => 'Bovins par type',
|
||||
'U' => 'Total bovins',
|
||||
'V' => 'Granulés / bâtiments',
|
||||
];
|
||||
foreach ($headers as $col => $value) {
|
||||
$sheet->setCellValue($col.'2', $value);
|
||||
}
|
||||
$sheet->getRowDimension(2)->setRowHeight(32.0);
|
||||
$sheet->getStyle('A2:V2')->applyFromArray([
|
||||
'font' => ['bold' => true],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => self::HEADER_FILL],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach (self::COLUMN_WIDTHS as $col => $width) {
|
||||
$sheet->getColumnDimension($col)->setWidth($width);
|
||||
}
|
||||
|
||||
// Données
|
||||
$row = 3;
|
||||
foreach ($receptions as $reception) {
|
||||
try {
|
||||
$this->writeReceptionRow($sheet, $row, $reception);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Export réceptions : ligne ignorée suite à une erreur.', [
|
||||
'receptionId' => $reception->getId(),
|
||||
'identificationNumber' => $reception->getIdentificationNumber(),
|
||||
'row' => $row,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
++$row;
|
||||
}
|
||||
|
||||
$lastRow = $row - 1;
|
||||
if ($lastRow >= 2) {
|
||||
$sheet->getStyle('A2:V'.$lastRow)->getBorders()->applyFromArray([
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||
]);
|
||||
}
|
||||
|
||||
$sheet->freezePane('A3');
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function writeReceptionRow(Worksheet $sheet, int $row, Reception $reception): void
|
||||
{
|
||||
$sheet->setCellValue('A'.$row, $reception->getIdentificationNumber() ?? '');
|
||||
|
||||
$date = $reception->getReceptionDate();
|
||||
if (null !== $date) {
|
||||
$excelDate = $this->safePhpToExcel($date);
|
||||
if (null !== $excelDate) {
|
||||
$sheet->setCellValue('B'.$row, $excelDate);
|
||||
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
}
|
||||
$sheet->setCellValue('C'.$row, $date->format('H:i'));
|
||||
}
|
||||
|
||||
$sheet->setCellValue('D'.$row, $reception->getReceptionType()?->getLabel() ?? '');
|
||||
$sheet->setCellValue('E'.$row, $reception->getUser()?->getUsername() ?? '');
|
||||
|
||||
$supplier = $reception->getSupplier();
|
||||
$sheet->setCellValue('F'.$row, $supplier?->getName() ?? '');
|
||||
$sheet->setCellValue('G'.$row, $this->formatAddresses($supplier?->getAddresses()));
|
||||
$sheet->setCellValue('H'.$row, $reception->getAddress()?->getFullAddress() ?? '');
|
||||
|
||||
$carrier = $reception->getCarrier();
|
||||
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
|
||||
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
|
||||
$sheet->setCellValue('K'.$row, $reception->getDriver()?->getName() ?? '');
|
||||
$sheet->setCellValue('L'.$row, $reception->getTruck()?->getName() ?? '');
|
||||
$sheet->setCellValue('M'.$row, $reception->getLicensePlate() ?? '');
|
||||
|
||||
$sheet->setCellValue('N'.$row, $reception->getMerchandiseType()?->getLabel() ?? '');
|
||||
$sheet->setCellValue('O'.$row, $reception->getMerchandiseDetail() ?? '');
|
||||
|
||||
$gross = $this->extractWeight($reception->getWeights(), 'gross');
|
||||
$tare = $this->extractWeight($reception->getWeights(), 'tare');
|
||||
if (null !== $gross) {
|
||||
$sheet->setCellValue('P'.$row, $gross);
|
||||
}
|
||||
if (null !== $tare) {
|
||||
$sheet->setCellValue('Q'.$row, $tare);
|
||||
}
|
||||
if (null !== $gross && null !== $tare) {
|
||||
$sheet->setCellValue('R'.$row, $gross - $tare);
|
||||
}
|
||||
$sheet->getStyle('P'.$row.':R'.$row)->getNumberFormat()->setFormatCode('#,##0');
|
||||
|
||||
$sheet->setCellValue('S'.$row, $reception->getBovineDetail() ?? '');
|
||||
|
||||
[$bovinesText, $bovinesTotal] = $this->formatBovineTypes($reception);
|
||||
$sheet->setCellValue('T'.$row, $bovinesText);
|
||||
if (null !== $bovinesTotal) {
|
||||
$sheet->setCellValue('U'.$row, $bovinesTotal);
|
||||
}
|
||||
$sheet->setCellValue('V'.$row, $this->formatPelletBuildings($reception));
|
||||
|
||||
// Alignements
|
||||
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('P'.$row.':R'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('U'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
|
||||
$sheet->getStyle('O'.$row)->getAlignment()->setWrapText(true);
|
||||
$sheet->getStyle('S'.$row.':V'.$row)->getAlignment()->setWrapText(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: ?int} [texte concaténé, total]
|
||||
*/
|
||||
private function formatBovineTypes(Reception $reception): array
|
||||
{
|
||||
$parts = [];
|
||||
$total = 0;
|
||||
$found = false;
|
||||
foreach ($reception->getBovinesTypes() as $rb) {
|
||||
$label = $rb->getBovineType()?->getLabel();
|
||||
$qty = $rb->getQuantity();
|
||||
if (null === $label && null === $qty) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = sprintf('%s : %d', $label ?? '—', $qty ?? 0);
|
||||
$total += $qty ?? 0;
|
||||
$found = true;
|
||||
}
|
||||
|
||||
return [implode(', ', $parts), $found ? $total : null];
|
||||
}
|
||||
|
||||
private function formatPelletBuildings(Reception $reception): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($reception->getPelletBuildings() as $pb) {
|
||||
$pellet = $pb->getPelletType()?->getLabel();
|
||||
$building = $pb->getBuilding()?->getLabel() ?? $pb->getBuilding()?->getCode();
|
||||
if (null === $pellet && null === $building) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = sprintf('%s (%s)', $pellet ?? '—', $building ?? '—');
|
||||
}
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|iterable<Address> $addresses
|
||||
*/
|
||||
private function formatAddresses(?iterable $addresses): string
|
||||
{
|
||||
if (null === $addresses) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($addresses as $address) {
|
||||
$full = $address->getFullAddress();
|
||||
if ('' !== $full) {
|
||||
$parts[] = $full;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ; ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Weight> $weights
|
||||
*/
|
||||
private function extractWeight(iterable $weights, string $type): ?int
|
||||
{
|
||||
foreach ($weights as $weight) {
|
||||
if ($weight->getType() === $type) {
|
||||
return $weight->getWeight();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function safePhpToExcel(?DateTimeImmutable $date): ?float
|
||||
{
|
||||
if (null === $date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = ExcelDate::PHPToExcel($date);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_float($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function renderXlsx(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||
ob_start();
|
||||
$writer->save('php://output');
|
||||
$body = ob_get_clean();
|
||||
|
||||
return false !== $body ? $body : '';
|
||||
}
|
||||
}
|
||||
299
src/State/Shipment/ShipmentExportProvider.php
Normal file
299
src/State/Shipment/ShipmentExportProvider.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Shipment;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Address;
|
||||
use App\Entity\Shipment;
|
||||
use App\Entity\Weight;
|
||||
use App\Repository\ShipmentRepository;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Response>
|
||||
*/
|
||||
final readonly class ShipmentExportProvider implements ProviderInterface
|
||||
{
|
||||
private const FARM_NAME = 'FERME SCEA LES NAUDS';
|
||||
|
||||
private const HEADER_FILL = 'FFCCECFF';
|
||||
|
||||
/**
|
||||
* Largeurs de colonnes (A à Q).
|
||||
*/
|
||||
private const COLUMN_WIDTHS = [
|
||||
'A' => 12.0,
|
||||
'B' => 11.0,
|
||||
'C' => 7.0,
|
||||
'D' => 16.0,
|
||||
'E' => 12.0,
|
||||
'F' => 22.0,
|
||||
'G' => 30.0,
|
||||
'H' => 30.0,
|
||||
'I' => 18.0,
|
||||
'J' => 8.0,
|
||||
'K' => 18.0,
|
||||
'L' => 14.0,
|
||||
'M' => 12.0,
|
||||
'N' => 11.0,
|
||||
'O' => 11.0,
|
||||
'P' => 11.0,
|
||||
'Q' => 13.0,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ShipmentRepository $shipmentRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$shipments = $this->shipmentRepository->findValidatedForExport();
|
||||
|
||||
$spreadsheet = $this->buildSpreadsheet($shipments);
|
||||
$body = $this->renderXlsx($spreadsheet);
|
||||
$filename = sprintf('expeditions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
|
||||
|
||||
$response = new Response($body);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
$response->headers->set('Content-Length', (string) strlen($body));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Shipment> $shipments
|
||||
*/
|
||||
private function buildSpreadsheet(array $shipments): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Expeditions');
|
||||
|
||||
$pageSetup = $sheet->getPageSetup();
|
||||
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
|
||||
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
|
||||
$pageSetup->setFitToWidth(1);
|
||||
$pageSetup->setFitToHeight(0);
|
||||
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
|
||||
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
|
||||
|
||||
// Ligne 1 : titre + date
|
||||
$sheet->setCellValue('A1', sprintf('%s — EXPÉDITIONS TERMINÉES', self::FARM_NAME));
|
||||
$sheet->mergeCells('A1:P1');
|
||||
$sheet->getStyle('A1:P1')->applyFromArray([
|
||||
'font' => [
|
||||
'name' => 'Arial Black',
|
||||
'size' => 16,
|
||||
'bold' => true,
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_LEFT,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
]);
|
||||
$sheet->setCellValue('Q1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
|
||||
$sheet->getStyle('Q1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
$sheet->getStyle('Q1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
|
||||
$sheet->getStyle('Q1')->getFont()->setSize(12)->setBold(true);
|
||||
$sheet->getRowDimension(1)->setRowHeight(26.0);
|
||||
$sheet->getStyle('A1:Q1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
|
||||
|
||||
// Ligne 2 : en-têtes
|
||||
$headers = [
|
||||
'A' => 'N° identification',
|
||||
'B' => 'Date',
|
||||
'C' => 'Heure',
|
||||
'D' => "Type d'expédition",
|
||||
'E' => 'Utilisateur',
|
||||
'F' => 'Client',
|
||||
'G' => 'Adresse client',
|
||||
'H' => 'Adresse expédition',
|
||||
'I' => 'Transporteur',
|
||||
'J' => 'Code trans.',
|
||||
'K' => 'Chauffeur',
|
||||
'L' => 'Camion',
|
||||
'M' => 'Plaque',
|
||||
'N' => 'Brut (kg)',
|
||||
'O' => 'Tare (kg)',
|
||||
'P' => 'Net (kg)',
|
||||
'Q' => 'Nb bovins',
|
||||
];
|
||||
foreach ($headers as $col => $value) {
|
||||
$sheet->setCellValue($col.'2', $value);
|
||||
}
|
||||
$sheet->getRowDimension(2)->setRowHeight(32.0);
|
||||
$sheet->getStyle('A2:Q2')->applyFromArray([
|
||||
'font' => ['bold' => true],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => self::HEADER_FILL],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach (self::COLUMN_WIDTHS as $col => $width) {
|
||||
$sheet->getColumnDimension($col)->setWidth($width);
|
||||
}
|
||||
|
||||
// Données
|
||||
$row = 3;
|
||||
foreach ($shipments as $shipment) {
|
||||
try {
|
||||
$this->writeShipmentRow($sheet, $row, $shipment);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Export expéditions : ligne ignorée suite à une erreur.', [
|
||||
'shipmentId' => $shipment->getId(),
|
||||
'identificationNumber' => $shipment->getIdentificationNumber(),
|
||||
'row' => $row,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
++$row;
|
||||
}
|
||||
|
||||
$lastRow = $row - 1;
|
||||
if ($lastRow >= 2) {
|
||||
$sheet->getStyle('A2:Q'.$lastRow)->getBorders()->applyFromArray([
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
|
||||
]);
|
||||
}
|
||||
|
||||
$sheet->freezePane('A3');
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function writeShipmentRow(Worksheet $sheet, int $row, Shipment $shipment): void
|
||||
{
|
||||
$sheet->setCellValue('A'.$row, $shipment->getIdentificationNumber() ?? '');
|
||||
|
||||
$date = $shipment->getShipmentDate();
|
||||
if (null !== $date) {
|
||||
$excelDate = $this->safePhpToExcel($date);
|
||||
if (null !== $excelDate) {
|
||||
$sheet->setCellValue('B'.$row, $excelDate);
|
||||
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
}
|
||||
$sheet->setCellValue('C'.$row, $date->format('H:i'));
|
||||
}
|
||||
|
||||
$sheet->setCellValue('D'.$row, $shipment->getShipmentType()?->getLabel() ?? '');
|
||||
$sheet->setCellValue('E'.$row, $shipment->getUser()?->getUsername() ?? '');
|
||||
|
||||
$customer = $shipment->getCustomer();
|
||||
$sheet->setCellValue('F'.$row, $customer?->getName() ?? '');
|
||||
$sheet->setCellValue('G'.$row, $this->formatAddresses($customer?->getAddresses()));
|
||||
$sheet->setCellValue('H'.$row, $shipment->getAddress()?->getFullAddress() ?? '');
|
||||
|
||||
$carrier = $shipment->getCarrier();
|
||||
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
|
||||
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
|
||||
$sheet->setCellValue('K'.$row, $shipment->getDriver()?->getName() ?? '');
|
||||
$sheet->setCellValue('L'.$row, $shipment->getTruck()?->getName() ?? '');
|
||||
$sheet->setCellValue('M'.$row, $shipment->getLicensePlate() ?? '');
|
||||
|
||||
$gross = $this->extractWeight($shipment->getWeights(), 'gross');
|
||||
$tare = $this->extractWeight($shipment->getWeights(), 'tare');
|
||||
if (null !== $gross) {
|
||||
$sheet->setCellValue('N'.$row, $gross);
|
||||
}
|
||||
if (null !== $tare) {
|
||||
$sheet->setCellValue('O'.$row, $tare);
|
||||
}
|
||||
if (null !== $gross && null !== $tare) {
|
||||
$sheet->setCellValue('P'.$row, $gross - $tare);
|
||||
}
|
||||
$sheet->getStyle('N'.$row.':P'.$row)->getNumberFormat()->setFormatCode('#,##0');
|
||||
|
||||
$sheet->setCellValue('Q'.$row, $shipment->getNbBovinSend());
|
||||
|
||||
// Alignements
|
||||
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('N'.$row.':P'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('Q'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|iterable<Address> $addresses
|
||||
*/
|
||||
private function formatAddresses(?iterable $addresses): string
|
||||
{
|
||||
if (null === $addresses) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($addresses as $address) {
|
||||
$full = $address->getFullAddress();
|
||||
if ('' !== $full) {
|
||||
$parts[] = $full;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ; ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Weight> $weights
|
||||
*/
|
||||
private function extractWeight(iterable $weights, string $type): ?int
|
||||
{
|
||||
foreach ($weights as $weight) {
|
||||
if ($weight->getType() === $type) {
|
||||
return $weight->getWeight();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function safePhpToExcel(?DateTimeImmutable $date): ?float
|
||||
{
|
||||
if (null === $date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = ExcelDate::PHPToExcel($date);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_float($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function renderXlsx(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||
ob_start();
|
||||
$writer->save('php://output');
|
||||
$body = ob_get_clean();
|
||||
|
||||
return false !== $body ? $body : '';
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
.sheet { width: auto; }
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 16px 0;
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
@@ -243,11 +243,23 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% set buildingNumber = buildingCase.idBuilding.label ?? '' %}
|
||||
{% set buildingNumber = buildingNumber|replace({'Bâtiment': '', 'BÂTIMENT': '', 'Batiment': '', 'BATIMENT': ''})|trim %}
|
||||
<div style="font-weight:700; text-align:left; font-size: 18px; margin-bottom: 16px;">
|
||||
BÂTIMENT N°{{ buildingNumber }} - CASE N°{{ buildingCase.caseNumber ?? '' }}
|
||||
</div>
|
||||
<table style="width:auto; border-collapse:collapse; margin-bottom: 8px; margin-top: 8px">
|
||||
<tr>
|
||||
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">BATIMENT N°</td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
<td style="border:0; width: 22px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
<td style="border:0; width: 22px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
<td style="border:0; width: 32px;"></td>
|
||||
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">CASE N°</td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
<td style="border:0; width: 22px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
<td style="border:0; width: 22px;"></td>
|
||||
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- =========================
|
||||
TABLEAU PRINCIPAL
|
||||
|
||||
@@ -47,7 +47,7 @@ final class PontBasculeServiceTest extends TestCase
|
||||
$httpClient
|
||||
->expects(self::once())
|
||||
->method('request')
|
||||
->with('POST', 'http://example.test')
|
||||
->with('POST', 'http://example.test/send/dsd')
|
||||
->willReturn($response)
|
||||
;
|
||||
|
||||
@@ -82,4 +82,103 @@ final class PontBasculeServiceTest extends TestCase
|
||||
|
||||
$service->fetch();
|
||||
}
|
||||
|
||||
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->createStub(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);
|
||||
}
|
||||
}
|
||||
|
||||
78
tests/State/PontBasculeHealthProviderTest.php
Normal file
78
tests/State/PontBasculeHealthProviderTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\ApiResource\PontBasculeHealthCheck;
|
||||
use App\Service\PontBasculePayloadDecoder;
|
||||
use App\Service\PontBasculeService;
|
||||
use App\State\PontBasculeHealthProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PontBasculeHealthProviderTest extends TestCase
|
||||
{
|
||||
public function testProvideMapsHealthyPayloadToResource(): void
|
||||
{
|
||||
$provider = $this->providerForHealthBody(json_encode([
|
||||
'ok' => true,
|
||||
'busy' => false,
|
||||
'port_connected' => true,
|
||||
'port_error' => null,
|
||||
'hostname' => 'liot-rasp-ferme-01',
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(PontBasculeHealthCheck::class, $result);
|
||||
self::assertTrue($result->healthy);
|
||||
self::assertTrue($result->ok);
|
||||
self::assertFalse($result->busy);
|
||||
self::assertTrue($result->portConnected);
|
||||
self::assertNull($result->portError);
|
||||
self::assertSame('liot-rasp-ferme-01', $result->hostname);
|
||||
}
|
||||
|
||||
public function testProvideMapsUnhealthyPayloadWithPortError(): void
|
||||
{
|
||||
$provider = $this->providerForHealthBody(json_encode([
|
||||
'ok' => true,
|
||||
'busy' => true,
|
||||
'port_connected' => false,
|
||||
'port_error' => 'device disconnected',
|
||||
'hostname' => 'liot-rasp-ferme-01',
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertFalse($result->healthy);
|
||||
self::assertTrue($result->ok);
|
||||
self::assertTrue($result->busy);
|
||||
self::assertFalse($result->portConnected);
|
||||
self::assertSame('device disconnected', $result->portError);
|
||||
}
|
||||
|
||||
private function providerForHealthBody(string $body): PontBasculeHealthProvider
|
||||
{
|
||||
$response = $this->createStub(ResponseInterface::class);
|
||||
$response->method('getContent')->willReturn($body);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient
|
||||
->expects(self::once())
|
||||
->method('request')
|
||||
->with('GET', 'http://example.test/health')
|
||||
->willReturn($response)
|
||||
;
|
||||
|
||||
$service = new PontBasculeService($httpClient, new PontBasculePayloadDecoder(), 'http://example.test', false);
|
||||
|
||||
return new PontBasculeHealthProvider($service);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user