Compare commits

..

5 Commits

Author SHA1 Message Date
486247bf86 feat : bovine.reception FK + delete op + sécurités abaissées
Ajout d'une relation ManyToOne nullable vers Reception, d'un
SearchFilter exact, d'une opération DELETE et abaissement de la
sécurité Post/Patch/Delete de ROLE_ADMIN à ROLE_USER pour le flux
métier opérationnel d'entrée/sortie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:43:44 +02:00
43d7a2514b feat : reception.entryCompleted + relation inverse bovines + filtres
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:33:49 +02:00
6579bb72dd feat : migration entry_completed + bovine.reception_id
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:31:56 +02:00
7ecc5b6d2f docs : plan d'implémentation workflow entrée bovins
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:58:15 +02:00
4f6b6ff3c3 docs : spec workflow entrée/sortie bovins
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:49:01 +02:00
81 changed files with 2505 additions and 3593 deletions

View File

@@ -4,9 +4,7 @@
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)",
"Bash(pip3 install:*)",
"Bash(python3 -c \":*)",
"Bash(make cache-clear *)",
"Bash(make test *)"
"Bash(python3 -c \":*)"
]
}
}

2
.env
View File

@@ -19,4 +19,4 @@ COOKIE_SECURE=
DATABASE_URL=
PONT_BASCULE_BYPASS=
PONT_BASCULE_BASE_URL=
PONT_BASCULE_URL=

View File

@@ -1,15 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:f407a514-c6b4-4b26-9555-445a85892502&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:9cad43df-2147-4989-b7a4-443067034884&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

5
.idea/ferme.iml generated
View File

@@ -155,11 +155,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/complex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/matrix" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
<excludePattern pattern="reference.php" />
</content>
<orderEntry type="inheritedJdk" />

5
.idea/php.xml generated
View File

@@ -174,11 +174,6 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

145
.idea/workspace.xml generated
View File

@@ -4,16 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : label age bovin">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/db-forest-config.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/db-forest-config.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/BovineMovement.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovineMovement.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -45,7 +41,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feat/entree-sortie" />
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -217,11 +213,6 @@
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
</include_path>
</component>
<component name="ProjectColorInfo">{
@@ -241,9 +232,7 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feat/vie-du-bovin",
"git.auto.fetch.suggestion.counter": "3",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -285,7 +274,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" />
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
</set>
</attachedChunks>
</component>
@@ -338,16 +327,54 @@
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="33750000" />
<workItem from="1776755742205" duration="88521000" />
<workItem from="1777453284124" duration="86000" />
<workItem from="1777453433907" duration="337000" />
<workItem from="1777454070632" duration="17254000" />
<workItem from="1777540415843" duration="13205000" />
<workItem from="1777877316149" duration="29389000" />
<workItem from="1777982616362" duration="23909000" />
<workItem from="1778482021120" duration="1280000" />
<workItem from="1778656317630" duration="279000" />
<workItem from="1778664396844" duration="2576000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
<created>1769529522614</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
</task>
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
</task>
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<option name="closed" value="true" />
<created>1769700808988</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1769700808988</updated>
</task>
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
<option name="closed" value="true" />
<created>1769705141157</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1769705141157</updated>
</task>
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1769705240487</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1769705240487</updated>
</task>
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
<option name="closed" value="true" />
<created>1769760766200</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1769760766200</updated>
</task>
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
<option name="closed" value="true" />
@@ -693,55 +720,7 @@
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<task id="LOCAL-00086" summary="fix : update icon entrée/sortie">
<option name="closed" value="true" />
<created>1777896558092</created>
<option name="number" value="00086" />
<option name="presentableId" value="LOCAL-00086" />
<option name="project" value="LOCAL" />
<updated>1777896558092</updated>
</task>
<task id="LOCAL-00087" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983048277</created>
<option name="number" value="00087" />
<option name="presentableId" value="LOCAL-00087" />
<option name="project" value="LOCAL" />
<updated>1777983048278</updated>
</task>
<task id="LOCAL-00088" summary="fix : wording">
<option name="closed" value="true" />
<created>1777983581324</created>
<option name="number" value="00088" />
<option name="presentableId" value="LOCAL-00088" />
<option name="project" value="LOCAL" />
<updated>1777983581324</updated>
</task>
<task id="LOCAL-00089" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1778073247660</created>
<option name="number" value="00089" />
<option name="presentableId" value="LOCAL-00089" />
<option name="project" value="LOCAL" />
<updated>1778073247660</updated>
</task>
<task id="LOCAL-00090" summary="feat : amélioration du tableau bovin">
<option name="closed" value="true" />
<created>1778135981350</created>
<option name="number" value="00090" />
<option name="presentableId" value="LOCAL-00090" />
<option name="project" value="LOCAL" />
<updated>1778135981350</updated>
</task>
<task id="LOCAL-00091" summary="fix : label age bovin">
<option name="closed" value="true" />
<created>1778136373027</created>
<option name="number" value="00091" />
<option name="presentableId" value="LOCAL-00091" />
<option name="project" value="LOCAL" />
<updated>1778136373027</updated>
</task>
<option name="localTasksCounter" value="92" />
<option name="localTasksCounter" value="86" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -791,6 +770,10 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
@@ -809,14 +792,10 @@
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<MESSAGE value="fix : update icon entrée/sortie" />
<MESSAGE value="fix : wording" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : amélioration du tableau bovin" />
<MESSAGE value="fix : label age bovin" />
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -65,12 +65,6 @@ Ajouter dans le fichier .env du frontend
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#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

View File

@@ -24,7 +24,7 @@ services:
App\Service\PontBasculeService:
arguments:
$baseUrl: '%env(PONT_BASCULE_BASE_URL)%'
$endpoint: '%env(PONT_BASCULE_URL)%'
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
# add more service definitions when explicit configuration is needed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.107'
app.version: '0.0.93'

File diff suppressed because it is too large Load Diff

View File

@@ -1,647 +0,0 @@
# 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.

View File

@@ -0,0 +1,199 @@
# Entrée / Sortie des bovins — Design
## Contexte
Aujourd'hui, l'application gère les **réceptions** (arrivée d'un camion) qui déclarent un nombre de bovins par race (ex : 5 charolais + 3 limousine + 2 autres). Une fois la réception terminée, ces déclarations sont des indicateurs imprécis et il manque l'étape de saisie individuelle des bovins (numéro national, poids, prix…).
L'objectif est d'introduire un **workflow d'entrée** qui transforme une réception bovins finie en saisies individuelles enrichies via EDNOTIF, et de poser les fondations pour un futur workflow de sortie symétrique.
Pour ce lot, **les sorties sont hors scope** mais l'écran liste prévoit déjà leur emplacement.
## Décisions structurantes
| Décision | Choix |
| --- | --- |
| Distinction "en attente" vs "terminée" | Flag explicite `entryCompleted: bool` sur `Reception` |
| Lien Bovine → Reception | FK 1-N, `Bovine.reception` ManyToOne **nullable** |
| Rendu de l'écran de saisie | UN formulaire (2 lignes) + tableau récap dessous |
| Bâtiment + Case | Choisis **par bovin** dans le formulaire |
| Persistance | Save individuel à chaque "Ajouter" (POST /bovines) |
| Enrichissement EDNOTIF | Au backend via le `BovineProcessor` existant (pas de lookup live) |
## Modèle de données
### `Reception` — modification
Nouveau champ :
- `entryCompleted: bool`, default `false`, non nullable.
- Pertinent uniquement quand `receptionType.code === 'BOVINS'`. Pour les autres types, reste `false` et ignoré côté UI.
- Inclus dans les groupes `reception:read` et `reception:write`.
Migration : `ALTER TABLE reception ADD COLUMN entry_completed BOOLEAN NOT NULL DEFAULT false`.
Ajout d'un `BooleanFilter` sur `entryCompleted` dans `#[ApiFilter]`.
### `Bovine` — modification
Nouveau champ :
- `reception: Reception` (ManyToOne, **nullable**).
- Inclus dans `bovine:read` et `bovine:write`.
Migration : `ALTER TABLE bovine ADD COLUMN reception_id INTEGER NULL` + index + FK contrainte. Bovins existants restent à `NULL` — aucune migration de données.
Ajout d'un `SearchFilter` exact sur `reception` dans `#[ApiFilter]` pour permettre `GET /bovines?reception={id}`.
### `Reception` — relation inverse pour le compteur
Pour permettre l'affichage du compteur "bovins saisis" dans la liste sans N+1 :
- Ajouter `bovines: Collection<Bovine>` côté `Reception` (OneToMany inverse, `mappedBy: 'reception'`, fetch lazy).
- Exposer un getter calculé `getRegisteredBovineCount(): int` dans le groupe `reception:read`.
- L'implémentation côté provider/list peut utiliser un `addSelect('COUNT(b.id) AS bovineCount')` via un `QueryExtension` API Platform si le N+1 devient un problème (à mesurer).
### Aucune autre entité
Pas de table de jointure (un bovin entre une seule fois via une réception unique). Pas de nouvelle entité `Entry` (la `Reception` joue ce rôle). Pas d'entité `Exit` pour ce lot — la symétrie sera traitée plus tard.
## Endpoints API
Tous les endpoints réutilisent les ressources existantes ; **aucun endpoint custom n'est créé**.
### Liste des entrées en attente
`GET /api/receptions?receptionType.code=BOVINS&isValid=true&entryCompleted=false`
### Validation finale d'une entrée
`PATCH /api/receptions/{id}` avec `{ entryCompleted: true }`.
### Création d'un bovin lié
`POST /api/bovines` (Content-Type `application/ld+json`) avec :
```json
{
"nationalNumber": "FR1234567890",
"receivedWeight": 368,
"pricePerKg": 5.7,
"arrivalDate": "2026-04-29",
"supplier": "/api/suppliers/12",
"reception": "/api/receptions/45",
"buildingCase": "/api/building_cases/8"
}
```
Le `BovineProcessor` enrichit automatiquement (workNumber, birthDate, race auto-créée via `BovineType`).
**Nettoyage en passant** : le `BovineProcessor` actuel appelle `setBreedCode()` qui n'existe plus (héritage avant la migration vers `BovineType` FK). À corriger pour qu'il fasse `setBovineType()` avec auto-create d'un `BovineType` si la race retournée par EDNOTIF n'existe pas en base.
### Suppression d'un bovin
`DELETE /api/bovines/{id}` — sécurité actuelle `ROLE_ADMIN` à abaisser à `ROLE_USER` pour permettre la correction immédiate depuis le tableau.
## Front-end
### Home (`pages/index.vue`)
- Card "CASES" → renommée "ENTRÉE / SORTIE" (multi-ligne `Entrée<br>Sortie`).
- Lien : `/entry-exit`.
- Icône : `mdi:swap-horizontal-bold` (à finaliser à l'implémentation).
### Page liste — `pages/entry-exit/index.vue`
Deux sections empilées :
**Entrées en attente**
- Composant : `UiDataTable`.
- Filtres serveur : `receptionType.code=BOVINS`, `isValid=true`, `entryCompleted=false`.
- Colonnes :
- Date réception
- Fournisseur (`supplier.name`)
- Total déclaré (calculé côté front : `sum(bovines_types.quantity) + parseInt(bovineDetail ?? '0')`)
- Bovins saisis (depuis `getRegisteredBovineCount` exposé sur Reception)
- Action (rangée cliquable)
- Click row → `/entry-exit/entry/{receptionId}`.
**Sorties en attente**
- Tableau placeholder vide avec message "À venir".
### Écran de saisie — `pages/entry-exit/entry/[id].vue`
**Header**
- Titre : "Entrée bovins #N-BR-XXXX — Fournisseur YYY"
- Sous-titre : "Bovins déclarés : 8 · Bovins saisis : 3"
- Icône retour à gauche.
**Formulaire (2 lignes)**
Ligne 1 : Numéro national · Poids à l'arrivée · Date d'arrivée · Vendeur (Supplier select)
Ligne 2 : Prix au kilo · Bâtiment (Building select) · Case (BuildingCase select dépendant du bâtiment) · Bouton **Ajouter**
**Pré-remplissage** (au chargement et après chaque add) :
- Date d'arrivée = `reception.receptionDate` (date seule, modifiable)
- Vendeur = `reception.supplier` (modifiable)
- Bâtiment = premier de `reception.buildings` si dispo, sinon vide
- Case = vide (à choisir explicitement)
- Numéro national, poids, prix : vides
**Comportement bouton "Ajouter"**
- Disabled si form invalide (n° national vide, poids ≤ 0, prix ≤ 0, building/case manquants).
- Click → `POST /api/bovines` avec `application/ld+json`.
- Succès → reload du tableau, reset form (en gardant les pré-remplissages), focus sur Numéro national.
- Erreur 409 (doublon n° national) → toast "Ce bovin existe déjà".
- Erreur EDNOTIF → bovin créé sans enrichissement (race/naissance vides), toast warning.
**Tableau récap (dessous)**
Colonnes : N° national · N° travail · Race · Sexe · Date naissance · Poids arrivée · Date arrivée · Prix/kg · Prix total · Bâtiment · Case · Action (icône poubelle).
Source : `GET /api/bovines?reception={id}` au mount + après chaque add/delete.
Suppression : `DELETE /api/bovines/{id}` avec `window.confirm`.
**Footer**
- Bouton **Valider l'entrée** (à droite).
- Si `bovins saisis < bovins déclarés``window.confirm("Vous n'avez saisi que X/Y bovins. Confirmer la fermeture ?")`.
- Disabled si 0 bovin saisi.
- Click → `PATCH /api/receptions/{id}` avec `{ entryCompleted: true }` → toast succès → redirection `/entry-exit`.
## Sécurité (rôles)
| Action | Rôle requis |
| --- | --- |
| Voir la page entrée/sortie | `ROLE_USER` |
| Ajouter un bovin (POST /bovines) | `ROLE_USER` (actuellement `ROLE_ADMIN` — à abaisser, ce flux est métier opérationnel) |
| Supprimer un bovin (DELETE /bovines) | `ROLE_USER` (idem, à abaisser) |
| Valider l'entrée (PATCH receptions) | `ROLE_USER` |
L'abaissement à `ROLE_USER` sur `Bovine::Post`, `Bovine::Patch` et `Bovine::Delete` est **délibéré** : ce flux fait partie des opérations métier quotidiennes, pas de l'administration. À confirmer pendant l'implémentation.
## Cas limites
- **Total saisi > déclaré** : autorisé (les déclarations en réception sont des indicateurs imprécis).
- **Doublon n° national** : la `UniqueConstraint` BDD le rejette → toast.
- **EDNOTIF indisponible** : bovin créé sans enrich, comportement actuel du processor.
- **Réception supprimée pendant la saisie** : impossible côté UI tant qu'on est dans l'écran. Si ça arrive (autre user), les `POST /bovines` suivants échoueront en 404 sur l'IRI reception → toast.
- **Sortie d'un bovin** : non géré dans ce lot. Le futur workflow de sortie viendra basculer `Bovine.exitedAt`.
## Critères d'acceptation
- [ ] Migration `entry_completed` sur Reception passe sans erreur.
- [ ] Migration `reception_id` sur Bovine passe sans erreur, bovins existants intacts.
- [ ] Card "CASES" sur home remplacée par "ENTRÉE / SORTIE".
- [ ] `/entry-exit` affiche les entrées en attente et un placeholder sorties.
- [ ] Click sur une entrée → écran saisie avec form pré-rempli.
- [ ] "Ajouter" → bovin créé, ligne au tableau, form reset (pré-remplissages restaurés).
- [ ] Suppression d'une ligne fonctionne avec confirmation.
- [ ] "Valider l'entrée" bascule `entryCompleted` et redirige.
- [ ] Une réception fermée disparaît de la liste.
- [ ] `BovineProcessor` corrigé pour utiliser `setBovineType()` avec auto-create.
- [ ] `make test` passe sans régression.
## Mode d'implémentation
Sur ce projet, l'utilisateur souhaite **valider chaque étape du plan** avant exécution. À chaque étape du plan d'implémentation, l'agent doit :
1. Présenter ce qu'il s'apprête à faire (fichiers, changements).
2. Attendre la validation explicite de l'utilisateur.
3. Exécuter, puis présenter l'étape suivante.
Cette discipline permet des retours en direct et des ajustements fins en cours de route.

View File

@@ -1,174 +0,0 @@
# 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`.

View File

@@ -5,10 +5,12 @@
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div class="grid grid-cols-4 gap-x-8 gap-y-6">
<div
class="flex flex-row gap-8 items-center w-full">
<div
v-for="type in bovineType"
:key="type.id">
:key="type.id"
class="mt-8 flex flex-row mb-2 w-full">
<UiNumberInput
:id="type.id"
:label="type.label"
@@ -21,11 +23,12 @@
wrapper-class="gap-3"
/>
</div>
<div>
<div
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
class="max-w-[150px]"
class="max-w-[80px]"
wrapper-class="gap-3"
/>
</div>
@@ -76,7 +79,7 @@ const totalBovines = computed(() => {
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
bovineType.value = await getBovineTypeList({ display: true })
bovineType.value = await getBovineTypeList()
} finally {
isLoadingBovineType.value = false
}

View File

@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { 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,18 +45,7 @@ const emit = defineEmits<{
(event: 'update:otherQuantity', value: number | null): void
}>()
// 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 bovineTypes = ref<BovineTypeData[]>([])
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
@@ -165,13 +154,8 @@ watch(
{ deep: true }
)
// Re-synchronise dès que la liste fusionnée change (chargement async des types).
watch(bovineTypes, () => {
syncLocalFromProps()
})
onMounted(async () => {
displayedTypes.value = await getBovineTypeList({ display: true })
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps()
})
</script>

View File

@@ -1,35 +0,0 @@
<template>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
v-for="tab in tabs"
:key="tab.key"
class="font-bold text-3xl uppercase px-12 cursor-pointer"
:class="[
modelValue === tab.key
? 'border-b-[6px] border-primary-500 text-primary-500'
: 'text-primary-500/50',
tab.error ? '!text-red-500 !border-red-500' : ''
]"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
</h1>
</div>
</template>
<script setup lang="ts" generic="T extends string">
export interface UiTab<K extends string = string> {
key: K
label: string
error?: boolean
}
defineProps<{
modelValue: T
tabs: UiTab<T>[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: T): void
}>()
</script>

View File

@@ -2,15 +2,7 @@
<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
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>
<p class="text-primary-500 uppercase text-2xl mt-2">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]">
@@ -27,7 +19,6 @@
<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
@@ -44,8 +35,7 @@
</template>
<script setup lang="ts">
import { onMounted, toRef } from 'vue'
import { usePontBascule } from '~/composables/usePontBascule'
import { toRef } from 'vue'
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
import type { WeightData } from '~/services/dto/weight-data'
@@ -65,9 +55,6 @@ const props = defineProps<{
const entityRef = toRef(props, 'entity')
const { status: pontBasculeStatus, checkHealth } = usePontBascule()
onMounted(checkHealth)
const {
displayWeight,
title,

View File

@@ -1,23 +0,0 @@
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 }
}

View File

@@ -14,13 +14,10 @@
</h1>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-8 gap-x-[200px]">
<div class="grid grid-cols-2 items-start pt-7 mb-11 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"
@@ -35,8 +32,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Type de bovin' })
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()
@@ -56,8 +51,7 @@ function resolveId(param: unknown) {
const form = reactive<BovinFormData>({
label: '',
code: '',
display: false
code: ''
})
@@ -68,7 +62,6 @@ const hydrateFromBovin = (bovin: BovineTypeData | null) => {
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
form.display = bovin.display ?? false
isHydrating.value = false
}
@@ -97,8 +90,8 @@ async function validate() {
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode,
display: form.display
code: normalizedBovinCode
}
isLoading.value = true

View File

@@ -29,14 +29,6 @@
<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">
@@ -46,8 +38,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Types de bovins' })
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
@@ -66,8 +56,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
const columns = [
{ key: 'label', label: 'Nom' },
{ key: 'code', label: 'Code' },
{ key: 'display', label: 'Affiché en réception' }
{ key: 'code', label: 'Code' }
]
const goToBovin = (bovin: BovineTypeData) => {

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteur' })
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
import {computed} from "vue";

View File

@@ -34,8 +34,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteurs' })
import type { CarrierData } from '~/services/dto/carrier-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -96,8 +96,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Client' })
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"

View File

@@ -3,8 +3,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse client' })
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Clients' })
import type { CustomerData } from '~/services/dto/customer-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -97,8 +97,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseur' })
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"

View File

@@ -3,8 +3,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse fournisseur' })
import type {AddressData, AddressPayload} from "~/services/address";
import {createAddress, getAddress, updateAddress} from "~/services/address";
import {getSupplier, updateSupplier} from "~/services/supplier";

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseurs' })
import type { SupplierData } from '~/services/dto/supplier-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -74,8 +74,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateur' })
import { computed, reactive, ref, watch } from 'vue'
import { ROLE } from '~/utils/constants'
import { createUser, updateUser, getUser } from '~/services/auth'

View File

@@ -63,8 +63,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateurs' })
import type { UserData } from '~/services/dto/user-data'
import { ROLE } from '~/utils/constants'
import { useAuthStore } from '~/stores/auth'

View File

@@ -1,358 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative mb-10">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="tabs"
/>
<div v-if="auth.isBureau" v-show="activeTab === 'mouvement'">
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
<div class="flex flex-cols-3 justify-between mb-10">
<UiSelect
id="movement-building"
v-model="newMovementBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-[280px]"
required
/>
<UiSelect
id="movement-case"
v-model="newMovementCaseId"
label="Case"
:options="caseOptions"
:disabled="!newMovementBuildingId"
wrapper-class="w-[280px]"
required
/>
<UiDateInput
id="movement-date"
v-model="newMovementDate"
label="Date mouvement"
wrapper-class="w-[280px]"
required
/>
</div>
<div class="flex items-center justify-center mb-11">
<UiButton
type="submit"
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
:disabled="isSubmittingMovement"
:loading="isSubmittingMovement"
@click="movementSubmitted = true"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</form>
<UiDataTable
:columns="movementColumns"
:items="filteredMovementRows"
:per-page="10"
>
<template #header-building>
<UiTextInput
v-model="movementFilters.building"
placeholder="Bâtiment"
size="compact"
/>
</template>
<template #header-case>
<UiTextInput
v-model="movementFilters.case"
placeholder="Case"
size="compact"
/>
</template>
<template #header-enteredAt>
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
</template>
<template #header-leftAt>
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
</template>
<template #header-duration>
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
</template>
<template #cell-leftAt="{ item }">
<span v-if="item.leftAt">{{ item.leftAt }}</span>
<span v-else class="italic text-slate-500">En cours</span>
</template>
</UiDataTable>
</div>
<div v-show="activeTab === 'passeport'">
<div class="mt-6">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
</div>
</div>
</div>
<div v-show="activeTab === 'sante'">
<div class="border-2 border-dashed border-primary-500 rounded-md py-16 text-center text-primary-500 font-bold uppercase text-2xl">
À venir
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
import { useAuthStore } from '~/stores/auth'
useHead({ title: 'Vie du bovin' })
const auth = useAuthStore()
type BovineTab = 'mouvement' | 'passeport' | 'sante'
const tabs = computed(() => [
...(auth.isBureau ? [{ key: 'mouvement' as const, label: 'Mouvement' }] : []),
{ key: 'passeport' as const, label: 'Passeport bovin' },
{ key: 'sante' as const, label: 'Santé' }
])
const activeTab = ref<BovineTab>(auth.isBureau ? 'mouvement' : 'passeport')
interface BovineTypeRef {
id: number
label: string | null
code: string | null
}
interface BuildingRef {
label: string | null
}
interface BuildingCaseRef {
caseNumber: number | null
building: BuildingRef | null
}
interface BovineMovementData {
id: number
enteredAt: string
leftAt: string | null
buildingCase: BuildingCaseRef | null
}
interface BovinePassportData {
id: number
nationalNumber: string
workNumber: string | null
sex: string | null
birthDate: string | null
exitedAt: string | null
exitDate: string | null
bovineType: BovineTypeRef | null
motherNationalNumber: string | null
motherBovineType: BovineTypeRef | null
fatherNationalNumber: string | null
fatherBovineType: BovineTypeRef | null
movements: BovineMovementData[]
}
const router = useRouter()
const route = useRoute()
const api = useApi()
const goBack = () => {
if (window.history.state?.back) {
router.back()
} else {
router.push('/inventory')
}
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const bovine = ref<BovinePassportData | null>(null)
const buildings = ref<BuildingData[]>([])
const newMovementBuildingId = ref<string | number | null>(null)
const newMovementCaseId = ref<string | number | null>(null)
const newMovementDate = ref<string>(todayIso())
const isSubmittingMovement = ref(false)
const movementSubmitted = ref(false)
const movementFilters = ref({ building: '', case: '' })
const bovineId = computed(() => {
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const n = Number(raw)
return Number.isFinite(n) ? n : null
})
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
if (!nationalNumber) return null
return nationalNumber.slice(-4)
}
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(newMovementBuildingId.value))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(newMovementBuildingId, () => {
newMovementCaseId.value = null
})
const movementColumns = [
{ key: 'building', label: 'Bâtiment' },
{ key: 'case', label: 'Case' },
{ key: 'enteredAt', label: 'Du' },
{ key: 'leftAt', label: 'Au' },
{ key: 'duration', label: 'Durée' }
]
const movementEndDate = (movement: BovineMovementData): string | null => {
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
}
const formatDuration = (movement: BovineMovementData): string => {
const start = new Date(movement.enteredAt)
if (isNaN(start.getTime())) return '—'
const endRaw = movementEndDate(movement)
const end = endRaw ? new Date(endRaw) : new Date()
if (isNaN(end.getTime())) return '—'
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
return `${days} j`
}
const movementRows = computed(() => {
const list = bovine.value?.movements ?? []
return list.map(m => ({
id: m.id,
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,
duration: formatDuration(m)
}))
})
const filteredMovementRows = computed(() => {
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
const caseFilter = movementFilters.value.case.trim().toLowerCase()
return movementRows.value.filter(row => {
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
return true
})
})
const submitMovement = async () => {
if (!newMovementCaseId.value || !newMovementDate.value || bovineId.value === null) return
const buildingLabel = buildingOptions.value.find(o => o.value === Number(newMovementBuildingId.value))?.label ?? '—'
const caseLabel = caseOptions.value.find(o => o.value === Number(newMovementCaseId.value))?.label ?? '—'
const dateLabel = formatDate(newMovementDate.value)
const confirmed = window.confirm(
`Confirmer la création du mouvement ?\n\nBâtiment : ${buildingLabel}\nCase : ${caseLabel}\nDate : ${dateLabel}`
)
if (!confirmed) return
isSubmittingMovement.value = true
try {
await api.post('bovine_movements', {
bovine: `/api/bovines/${bovineId.value}`,
buildingCase: `/api/building_cases/${newMovementCaseId.value}`,
enteredAt: newMovementDate.value
}, { toastSuccessMessage: 'Mouvement enregistré' })
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
newMovementBuildingId.value = null
newMovementCaseId.value = null
newMovementDate.value = todayIso()
movementSubmitted.value = false
} finally {
isSubmittingMovement.value = false
}
}
onMounted(async () => {
if (bovineId.value === null) return
const [bovineData, buildingList] = await Promise.all([
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
getBuildingList()
])
bovine.value = bovineData
buildings.value = buildingList
})
</script>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
useHead({ title: 'Accueil' })
</script>
<template>
<div class="flex flex-wrap justify-center pb-16 gap-12">

View File

@@ -0,0 +1,180 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput
id="bovine-national-number"
v-model="form.nationalNumber"
label="Numéro national"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
required
/>
<UiNumberInput
id="bovine-received-weight"
v-model="form.receivedWeight"
label="Poids à l'arrivée (kg)"
:min="0"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px] flex-col"
label-class="font-bold uppercase"
/>
<UiDateInput
id="bovine-arrival-date"
v-model="form.arrivalDate"
label="Date d'arrivée"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
</div>
<div class="flex flex-cols-3 justify-between mb-11">
<UiSelect
id="bovine-supplier"
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
:loading="isLoadingSuppliers"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
<div class="w-[280px]" />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
type="submit"
:disabled="!auth.isAdmin || isLoading"
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
import type { BovinePayload } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const caseId = computed(() => {
const raw = Number(route.query.caseId)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const bovineId = computed(() => {
const raw = Number(route.query.id)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const isEdit = computed(() => bovineId.value !== null)
const form = reactive<{
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
supplierId: string
}>({
nationalNumber: '',
receivedWeight: null,
arrivalDate: null,
supplierId: ''
})
const isLoading = ref(false)
const submitted = ref(false)
const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false)
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
)
const backRoute = computed(() => ({
path: '/infrastructure/case',
query: caseId.value ? { id: String(caseId.value) } : {}
}))
const goBack = () => {
router.push(backRoute.value)
}
const loadSuppliers = async () => {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
const hydrate = async () => {
if (!isEdit.value || bovineId.value === null) {
return
}
isLoading.value = true
try {
const bovine = await getBovine(bovineId.value)
form.nationalNumber = bovine.nationalNumber ?? ''
form.receivedWeight = bovine.receivedWeight ?? null
form.arrivalDate = bovine.arrivalDate ?? null
if (bovine.supplier) {
const supplierId = bovine.supplier.replace(/.*\//, '')
form.supplierId = supplierId
}
} finally {
isLoading.value = false
}
}
const validate = async () => {
if (isLoading.value || !auth.isAdmin) return
if (!caseId.value) return
if (!form.nationalNumber.trim()) return
const payload: BovinePayload = {
nationalNumber: form.nationalNumber.trim(),
receivedWeight: form.receivedWeight,
arrivalDate: form.arrivalDate,
buildingCase: `/api/building_cases/${caseId.value}`,
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
}
isLoading.value = true
try {
if (isEdit.value && bovineId.value !== null) {
await updateBovine(bovineId.value, payload)
} else {
await createBovine(payload)
}
router.push(backRoute.value)
} finally {
isLoading.value = false
}
}
onMounted(loadSuppliers)
watch(bovineId, hydrate, { immediate: true })
</script>

View File

@@ -12,7 +12,7 @@
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
</div>
<div class="mt-6 space-y-3">
<div class="mt-6 space-y-6">
<!-- 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="">
<div class="py-4">
<div class="flex gap-6">
<div
v-for="statut in statutLegend"
@@ -80,8 +80,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Bâtiments' })
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"

View File

@@ -23,6 +23,14 @@
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId && auth.isAdmin"
:to="addBovineRoute"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="flex flex-wrap gap-3 mt-4">
@@ -48,7 +56,7 @@
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
:row-clickable="auth.isAdmin"
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
>
@@ -122,10 +130,9 @@
</template>
<script setup lang="ts">
useHead({ title: 'Cases' })
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
@@ -134,6 +141,7 @@ const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
@@ -223,6 +231,11 @@ const title = computed(() => {
return `${buildingLabel} case ${caseNumber}`.trim()
})
const addBovineRoute = computed(() => ({
path: '/infrastructure/bovine',
query: { caseId: String(caseId.value) }
}))
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
@@ -255,7 +268,11 @@ const printCaseReport = async () => {
}
const goToBovine = (bovine: BovineData) => {
router.push(`/bovine/${bovine.id}`)
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(bovine.id), caseId: String(caseId.value) }
})
}
watch(caseId, (id) => {

View File

@@ -57,8 +57,6 @@
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
>
<template #header-nationalNumber>
<UiTextInput
@@ -125,7 +123,7 @@
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
{{ item.effectiveBuilding?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
@@ -149,8 +147,6 @@
<script setup lang="ts">
useHead({ title: 'Inventaire' })
import type { BovineData } from '~/services/dto/bovine-data'
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
import { useAuthStore } from '~/stores/auth'

View File

@@ -53,8 +53,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Connexion' })
import type { UserData } from '~/services/dto/user-data'
import { getUsers } from '~/services/auth'
import { useAuthStore } from '~/stores/auth'

View File

@@ -54,8 +54,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réception' })
import { useReceptionStore } from '~/stores/reception'
import { storeToRefs } from 'pinia'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -1,16 +1,7 @@
<template>
<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>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
</div>
<div class="px-[86px]">
@@ -82,41 +73,13 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation réception' })
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 }))

View File

@@ -145,14 +145,38 @@
/>
</div>
<div v-if="formIsLoading">
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError },
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'merchandise', label: isMerchandise ? 'Marchandise' : 'Bovins', error: hasMerchandiseTabError }
]"
/>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weights'"
@@ -202,8 +226,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier réception' })
import { usePdfPrinter } from '#imports'
import { computed } from 'vue'
import UpdateBovin from '~/components/reception/update-bovin.vue'

View File

@@ -1,7 +1,7 @@
<template>
<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 en attente</h1>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
</div>
<div class="px-[86px]">
@@ -72,8 +72,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réceptions en attente' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { deleteReception } from '~/services/reception'

View File

@@ -125,8 +125,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Scanner' })
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'

View File

@@ -51,8 +51,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expédition' })
import { storeToRefs } from 'pinia'
import { useShipmentStore } from '~/stores/shipment'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -1,16 +1,7 @@
<template>
<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>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
</div>
<div class="px-[86px]">
@@ -80,41 +71,13 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation expédition' })
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 }))

View File

@@ -146,13 +146,28 @@
</div>
<div v-if="formIsLoading">
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
]"
/>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"
@@ -182,8 +197,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier expédition' })
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'

View File

@@ -1,7 +1,7 @@
<template>
<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 en attente</h1>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
</div>
<div class="px-[86px]">
@@ -84,8 +84,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expéditions en attente' })
import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { deleteShipment } from '~/services/shipment'

View File

@@ -5,13 +5,9 @@ export type BovineTypeListResponse =
| BovineTypeData[]
| { 'hydra:member'?: BovineTypeData[] }
export async function getBovineTypeList(filters: { display?: boolean } = {}): Promise<BovineTypeData[]> {
export async function getBovineTypeList(): Promise<BovineTypeData[]> {
const api = useApi()
const query: Record<string, string> = {}
if (filters.display !== undefined) {
query.display = filters.display ? 'true' : 'false'
}
const response = await api.get<BovineTypeListResponse>('bovine_types', query, {
const response = await api.get<BovineTypeListResponse>('bovine_types', {}, {
toastErrorKey: 'errors.bovin.list'
})
@@ -55,12 +51,10 @@ export async function updateBovin(id: number, payload: BovinPayload = {}): Promi
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code,
display: item.display ?? false
code: item.code
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined,
display: payload.display ?? undefined
code: payload.code ?? undefined
})

View File

@@ -9,3 +9,34 @@ 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'
})
}

View File

@@ -16,6 +16,8 @@ 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
@@ -27,5 +29,9 @@ export interface BovineData {
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
pricePerKg?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
}

View File

@@ -2,17 +2,14 @@ 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
}

View File

@@ -4,7 +4,7 @@ export const formatAgeLabel = (months: number | null | undefined): string => {
const remaining = months % 12
let label = ''
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} m`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
if (!label) label = '< 1 mois'
return label
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429073108 extends AbstractMigration
{
public function getDescription(): string
{
return 'Workflow entrée/sortie : ajout entry_completed sur reception et reception_id sur bovine.';
}
public function up(Schema $schema): void
{
// Reception : flag de fermeture d'une entrée bovins.
$this->addSql('ALTER TABLE reception ADD entry_completed BOOLEAN NOT NULL DEFAULT FALSE');
// Bovine : FK nullable vers la réception qui a fait entrer le bovin.
$this->addSql('ALTER TABLE bovine ADD reception_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_BOVINE_RECEPTION ON bovine (reception_id)');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_BOVINE_RECEPTION FOREIGN KEY (reception_id) REFERENCES reception (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_BOVINE_RECEPTION');
$this->addSql('DROP INDEX IDX_BOVINE_RECEPTION');
$this->addSql('ALTER TABLE bovine DROP reception_id');
$this->addSql('ALTER TABLE reception DROP entry_completed');
}
}

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504125011 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add mother/father national number and bovine type to bovine.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD mother_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD mother_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F14E5E9FB FOREIGN KEY (mother_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F53F12909 FOREIGN KEY (father_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2068337F14E5E9FB ON bovine (mother_bovine_type_id)');
$this->addSql('CREATE INDEX IDX_2068337F53F12909 ON bovine (father_bovine_type_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F14E5E9FB');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F53F12909');
$this->addSql('DROP INDEX IDX_2068337F14E5E9FB');
$this->addSql('DROP INDEX IDX_2068337F53F12909');
$this->addSql('ALTER TABLE bovine DROP mother_national_number');
$this->addSql('ALTER TABLE bovine DROP father_national_number');
$this->addSql('ALTER TABLE bovine DROP mother_bovine_type_id');
$this->addSql('ALTER TABLE bovine DROP father_bovine_type_id');
}
}

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506141455 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create bovine_movement table to track internal building/case history.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE bovine_movement (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bovine_id INT NOT NULL, building_case_id INT DEFAULT NULL, building_id INT DEFAULT NULL, entered_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, left_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_bovine_movement_bovine ON bovine_movement (bovine_id)');
$this->addSql('CREATE INDEX idx_bovine_movement_timeline ON bovine_movement (bovine_id, entered_at)');
$this->addSql('CREATE INDEX idx_bovine_movement_case ON bovine_movement (building_case_id)');
$this->addSql('CREATE INDEX idx_bovine_movement_building ON bovine_movement (building_id)');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_bovine FOREIGN KEY (bovine_id) REFERENCES bovine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_case FOREIGN KEY (building_case_id) REFERENCES building_case (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_building FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE bovine_movement');
}
}

View File

@@ -1,29 +0,0 @@
<?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');
}
}

View File

@@ -1,46 +0,0 @@
<?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;
}

View File

@@ -1,32 +0,0 @@
<?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';
}

View File

@@ -1,32 +0,0 @@
<?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';
}

View File

@@ -0,0 +1,215 @@
<?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;
}
}

View File

@@ -1,49 +0,0 @@
<?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;
}
}

View File

@@ -1,60 +0,0 @@
<?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;
}
}

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
@@ -17,8 +18,6 @@ use ApiPlatform\Metadata\Post;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -36,6 +35,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
'reception' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
@@ -52,16 +52,20 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
new Post(
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_USER')",
processor: BovineProcessor::class,
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_USER')",
processor: BovineProcessor::class,
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_USER')",
),
],
security: "is_granted('ROLE_USER')",
)]
@@ -96,6 +100,17 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne(inversedBy: 'bovines')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: false)]
private ?Reception $reception = 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;
@@ -132,37 +147,6 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $motherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $motherBovineType = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $fatherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $fatherBovineType = null;
/**
* @var Collection<int, BovineMovement>
*/
#[ORM\OneToMany(targetEntity: BovineMovement::class, mappedBy: 'bovine', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['enteredAt' => 'DESC'])]
#[Groups(['bovine:read'])]
private Collection $movements;
public function __construct()
{
$this->movements = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -239,6 +223,40 @@ class Bovine
return $this;
}
public function getReception(): ?Reception
{
return $this->reception;
}
public function setReception(?Reception $reception): static
{
$this->reception = $reception;
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;
@@ -335,79 +353,6 @@ class Bovine
return $this;
}
public function getMotherNationalNumber(): ?string
{
return $this->motherNationalNumber;
}
public function setMotherNationalNumber(?string $motherNationalNumber): static
{
$this->motherNationalNumber = $motherNationalNumber;
return $this;
}
public function getMotherBovineType(): ?BovineType
{
return $this->motherBovineType;
}
public function setMotherBovineType(?BovineType $motherBovineType): static
{
$this->motherBovineType = $motherBovineType;
return $this;
}
public function getFatherNationalNumber(): ?string
{
return $this->fatherNationalNumber;
}
public function setFatherNationalNumber(?string $fatherNationalNumber): static
{
$this->fatherNationalNumber = $fatherNationalNumber;
return $this;
}
public function getFatherBovineType(): ?BovineType
{
return $this->fatherBovineType;
}
public function setFatherBovineType(?BovineType $fatherBovineType): static
{
$this->fatherBovineType = $fatherBovineType;
return $this;
}
/**
* @return Collection<int, BovineMovement>
*/
public function getMovements(): Collection
{
return $this->movements;
}
public function addMovement(BovineMovement $movement): static
{
if (!$this->movements->contains($movement)) {
$this->movements->add($movement);
$movement->setBovine($this);
}
return $this;
}
public function removeMovement(BovineMovement $movement): static
{
$this->movements->removeElement($movement);
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Repository\BovineMovementRepository;
use App\State\Bovin\BovineMovementProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: BovineMovementRepository::class)]
#[ORM\Table(name: 'bovine_movement')]
#[ORM\Index(name: 'idx_bovine_movement_timeline', columns: ['bovine_id', 'entered_at'])]
#[ApiResource(
operations: [
new Post(
denormalizationContext: ['groups' => ['bovine_movement:write']],
normalizationContext: ['groups' => ['bovine:read']],
processor: BovineMovementProcessor::class,
),
],
security: "is_granted('ROLE_BUREAU')",
)]
class BovineMovement
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bovine:read'])]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'movements')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['bovine_movement:write'])]
private Bovine $bovine;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine_movement:write'])]
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['bovine:read', 'bovine_movement:write'])]
private DateTimeImmutable $enteredAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read'])]
private ?DateTimeImmutable $leftAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getBovine(): Bovine
{
return $this->bovine;
}
public function setBovine(Bovine $bovine): static
{
$this->bovine = $bovine;
return $this;
}
public function getBuildingCase(): ?BuildingCase
{
return $this->buildingCase;
}
public function setBuildingCase(?BuildingCase $buildingCase): static
{
$this->buildingCase = $buildingCase;
return $this;
}
public function getEnteredAt(): DateTimeImmutable
{
return $this->enteredAt;
}
public function hasEnteredAt(): bool
{
return isset($this->enteredAt);
}
public function setEnteredAt(DateTimeImmutable $enteredAt): static
{
$this->enteredAt = $enteredAt;
return $this;
}
public function getLeftAt(): ?DateTimeImmutable
{
return $this->leftAt;
}
public function setLeftAt(?DateTimeImmutable $leftAt): static
{
$this->leftAt = $leftAt;
return $this;
}
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -20,7 +19,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
'label' => 'ipartial',
'code' => 'ipartial',
])]
#[ApiFilter(BooleanFilter::class, properties: ['display'])]
#[ApiResource(
operations: [
new Get(
@@ -60,15 +58,6 @@ 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;
@@ -97,16 +86,4 @@ class BovineType
return $this;
}
public function isDisplay(): bool
{
return $this->display;
}
public function setDisplay(bool $display): static
{
$this->display = $display;
return $this;
}
}

View File

@@ -17,7 +17,6 @@ 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;
@@ -29,16 +28,17 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity(repositoryClass: ReceptionRepository::class)]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.id' => 'exact',
'receptionType.code' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource(
@@ -111,6 +111,10 @@ class Reception
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $isValid = false;
#[ORM\Column(options: ['default' => false])]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $entryCompleted = false;
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context(
@@ -205,6 +209,12 @@ class Reception
#[Groups(['reception:read', 'reception:write'])]
private ?string $bovineDetail = null;
/**
* @var Collection<int, Bovine>
*/
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
private Collection $bovines;
public function __construct(
?DateTimeImmutable $receptionDate = null,
) {
@@ -213,6 +223,7 @@ class Reception
$this->buildings = new ArrayCollection();
$this->pelletBuildings = new ArrayCollection();
$this->bovines_types = new ArrayCollection();
$this->bovines = new ArrayCollection();
}
public function getId(): ?int
@@ -271,6 +282,25 @@ class Reception
return $this;
}
#[Groups(['reception:read'])]
public function isEntryCompleted(): bool
{
return $this->entryCompleted;
}
public function setEntryCompleted(bool $entryCompleted): self
{
$this->entryCompleted = $entryCompleted;
return $this;
}
#[Groups(['reception:read'])]
public function getRegisteredBovineCount(): int
{
return $this->bovines->count();
}
#[Groups(['reception:read'])]
public function getReceptionDate(): ?DateTimeImmutable
{

View File

@@ -17,7 +17,6 @@ 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;
@@ -29,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity(repositoryClass: ShipmentRepository::class)]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bovine;
use App\Entity\BovineMovement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BovineMovement>
*/
final class BovineMovementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BovineMovement::class);
}
public function findOpenMovement(Bovine $bovine): ?BovineMovement
{
return $this->createQueryBuilder('m')
->where('m.bovine = :bovine')
->andWhere('m.leftAt IS NULL')
->setParameter('bovine', $bovine)
->orderBy('m.enteredAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -1,52 +0,0 @@
<?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()
;
}
}

View File

@@ -1,46 +0,0 @@
<?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()
;
}
}

View File

@@ -1,138 +0,0 @@
<?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;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Dto\PontBasculeHealth;
use App\Dto\PontBasculeReading;
use App\Exception\PontBasculeException;
use DateTimeImmutable;
@@ -16,7 +15,7 @@ final class PontBasculeService
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly PontBasculePayloadDecoder $payloadDecoder,
private readonly string $baseUrl,
private readonly string $endpoint,
private readonly bool $bypass,
) {}
@@ -29,7 +28,7 @@ final class PontBasculeService
$body = $this->getBypassPayload();
} else {
try {
$response = $this->httpClient->request('POST', $this->buildUrl('/send/dsd'));
$response = $this->httpClient->request('POST', $this->endpoint);
$body = $response->getContent(false);
} catch (TransportExceptionInterface $exception) {
throw PontBasculeException::transportFailure($exception->getMessage());
@@ -45,54 +44,6 @@ 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"}';

View File

@@ -18,15 +18,13 @@ 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 readonly class BovineInventoryExportProvider implements ProviderInterface
final class BovineInventoryExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
@@ -72,7 +70,6 @@ final readonly 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
@@ -256,16 +253,7 @@ final readonly class BovineInventoryExportProvider implements ProviderInterface
// Lignes de données
$rowNumber = 5;
foreach ($bovines as $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,
]);
}
$this->writeBovineRow($sheet, $rowNumber, $bovine);
++$rowNumber;
}
@@ -288,7 +276,7 @@ final readonly 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();
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
$code = $building?->getCode();
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
@@ -296,25 +284,22 @@ final readonly class BovineInventoryExportProvider implements ProviderInterface
? (int) $bovine->getWorkNumber()
: ($bovine->getWorkNumber() ?? ''));
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
$national = $bovine->getNationalNumber();
$sheet->setCellValue('D'.$row, '' === $national ? '' : 'FR '.$national);
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
$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();
$birthExcel = $this->safePhpToExcel($birth);
$arrivalExcel = $this->safePhpToExcel($arrival);
if (null !== $birthExcel) {
$sheet->setCellValue('J'.$row, $birthExcel);
$birth = $bovine->getBirthDate();
$arrival = $bovine->getArrivalDate();
if (null !== $birth) {
$sheet->setCellValue('J'.$row, ExcelDate::PHPToExcel($birth));
}
if (null !== $arrivalExcel) {
$sheet->setCellValue('K'.$row, $arrivalExcel);
if (null !== $arrival) {
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
}
if (null !== $birth && null !== $arrival && $birth <= $arrival) {
if (null !== $birth && null !== $arrival) {
$diff = $birth->diff($arrival);
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
}
@@ -358,24 +343,6 @@ final readonly 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.
*

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\BovineMovement;
use App\Repository\BovineMovementRepository;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class BovineMovementProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovineMovementRepository $movementRepository,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof BovineMovement) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$enteredAt = $data->hasEnteredAt() ? $data->getEnteredAt() : new DateTimeImmutable();
$data->setEnteredAt($enteredAt);
$data->setLeftAt(null);
$bovine = $data->getBovine();
$openMovement = $this->movementRepository->findOpenMovement($bovine);
if (null !== $openMovement) {
$openMovement->setLeftAt($enteredAt);
}
$bovine->setBuildingCase($data->getBuildingCase());
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -7,15 +7,26 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Service\BovineInventorySyncer;
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;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovineInventorySyncer $syncer,
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
) {}
public function process(
@@ -24,6 +35,108 @@ final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
return $this->syncer->sync();
$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);
}
$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;
}
}

View File

@@ -91,22 +91,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
];
}
usort($rows, static function (array $a, array $b): int {
$aw = (string) ($a['workNumber'] ?? '');
$bw = (string) ($b['workNumber'] ?? '');
if ('' === $aw && '' === $bw) {
return 0;
}
if ('' === $aw) {
return 1;
}
if ('' === $bw) {
return -1;
}
return (int) $aw <=> (int) $bw;
});
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
$dompdf = new Dompdf();

View File

@@ -1,32 +0,0 @@
<?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;
}
}

View File

@@ -1,358 +0,0 @@
<?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 : '';
}
}

View File

@@ -1,299 +0,0 @@
<?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 : '';
}
}

View File

@@ -25,7 +25,7 @@
.sheet { width: auto; }
h1 {
margin: 0 0 8px 0;
margin: 8px 0 16px 0;
padding: 0;
line-height: 1;
text-transform: uppercase;
@@ -139,10 +139,10 @@
}
.main .sub-title {
font-size: 13px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0;
padding: 4px;
padding: 8px;
}
.main .base {
@@ -203,61 +203,61 @@
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
Arrivage du {{ firstArrivalDate ?? '-' }}
</h1>
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 4px">
<colgroup>
{# 28 colonnes ≈ 3.571% chacune #}
{% for _ in 0..27 %}<col style="width:3.571%">{% endfor %}
</colgroup>
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 16px">
<tr>
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="4">PROVENANCE</td>
<td style="width:40%; vertical-align:top; padding-right:2mm; border:0;">
<table style="width:100%; border-collapse:collapse; table-layout:fixed;">
<tr>
<td style="border: 0; height: 20px"></td>
</tr>
<tr>
<td style="font-weight:700; text-align: left; border: none; font-size: 24px">CASE N° {{ buildingCase.caseNumber ?? '' }}</td>
</tr>
</table>
</td>
{# Paire 1 : chiffre + case vide #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">1</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 2 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">2</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 3 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">3</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
{# Paire 4 #}
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">4</td>
<td style="border:1px solid #2b2b2b;"></td>
{# Espacement entre PROVENANCE et RACE (1 col, RACE commence plus tôt) #}
<td style="border:0;"></td>
{# Bloc RACE #}
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="2">RACE</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">LIMOUSIN</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">CHAROLAIS</td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;">AUTRE</td>
<td style="border:1px solid #2b2b2b;"></td>
</tr>
</table>
<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>
<td style="width:60%; vertical-align:top; padding-left:2mm; border:0;">
<table class="header-right-free" style="width:100%; border-collapse:collapse; table-layout:fixed;">
<tr>
<td style="border:0; text-align:center; font-weight:700; height: 20px;" colspan="5"></td>
<td style="border:0;" colspan="2"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">1</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">2</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">3</td>
<td style="border:0; height: 20px;"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">4</td>
<td style="border:0;" colspan="2"></td>
</tr>
<tr>
<td style="border:0; text-align:left; font-weight:700; font-size: 24px; width:40%; height: 20px;" colspan="5">PROVENANCE</td>
<td style="border:0;" colspan="2"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border:0;"></td>
<td style="border:1px solid #2b2b2b;"></td>
<td style="border: 0; width: 20%;" colspan="2"></td>
</tr>
<tr>
<td style="border: 0; height: 20px" colspan="16"></td>
</tr>
<tr>
<td style="border: 0; text-align:left; font-weight:700; font-size: 24px" colspan="3">RACE</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">LIMOUSIN</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">CHAROLAIS</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">Autre</td>
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
</tr>
</table>
</td>
</tr>
</table>
@@ -267,29 +267,30 @@
<table class="main">
<thead>
<tr>
{% for month in monthHeaders|default([])|reverse %}
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
{% for month in monthHeaders|default([]) %}
<th class="month" style="width:6.58%">{{ month.name }}</th>
{% endfor %}
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
</tr>
<tr>
{% for month in monthHeaders|default([])|reverse %}
{% for month in monthHeaders|default([]) %}
<th class="days">{{ month.days }}</th>
{% endfor %}
</tr>
<tr>
<th class="days">Foin</th>
<th class="days">Foin</th>
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
<th class="days">Foin</th>
<th class="days">Foin</th>
</tr>
<tr>
{% for month in monthHeaders|default([])|reverse %}
{% for month in monthHeaders|default([]) %}
<th class="base">
{% if month.baseValue is defined %}
{{ month.baseValue|round(0, 'common') }} kg
@@ -302,28 +303,27 @@
</thead>
<tbody>
{# 13 lignes comme dans ton code (0..12) #}
{# 11 lignes comme dans ton code (0..10) #}
{% for i in 0..12 %}
{% set row = rows[i] ?? null %}
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
<tr class="data-row">
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
{% set reversedIdx = (monthCount - 1) - idx %}
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[reversedIdx] ?? null) : null %}
<td class="row-month"{% if reversedIdx < 4 %} style="background:#e0e0e0;"{% endif %}>
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
</td>
{% endfor %}
<td class="row-work"></td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-birth">
{% if row and row.birthDate %}
{% set birthParts = row.birthDate|split('/') %}
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
{% endif %}
</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-work"></td>
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[idx] ?? null) : null %}
<td class="row-month"{% if loop.index0 < 4 %} style="background:#e0e0e0;"{% endif %}>
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
@@ -331,89 +331,41 @@
<!-- =========================
FOOTER (traitements / vaccins)
========================= -->
<table style="width:100%; border:0; border-collapse:collapse; table-layout:fixed; margin-top: 12px">
<table class="footer" style="border-collapse:collapse; margin-top: 32px">
<tr>
<td style="border:0; padding:0; width:49%; vertical-align:top;">
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
<tr>
<td style="font-weight: 700; height: 20px" colspan="10">Traitements</td>
</tr>
<tr>
<td style="height: 20px" colspan="2">Date</td>
<td colspan="2"></td>
<td>Dose</td>
<td colspan="5">Observation</td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Grippe</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Antéro</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Antibiotiques</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2">Déparasitage</td>
<td></td>
<td colspan="5"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td colspan="2"></td>
<td></td>
<td colspan="5"></td>
</tr>
</table>
</td>
<td style="border:0; padding:0; width:2%;"></td>
<td style="border:0; padding:0; width:49%; vertical-align:top;">
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
<tr>
<td style="font-weight: 700; height: 20px" colspan="10">Rappel</td>
</tr>
<tr>
<td style="height: 20px" colspan="2">Date</td>
<td>Dose</td>
<td colspan="7">Observation</td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td></td>
<td colspan="7"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td></td>
<td colspan="7"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td></td>
<td colspan="7"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td></td>
<td colspan="7"></td>
</tr>
<tr>
<td style="height: 20px" colspan="2"></td>
<td></td>
<td colspan="7"></td>
</tr>
</table>
</td>
<td style="height: 20px; border: 0" colspan="4"></td>
<td style="font-weight: 700" colspan="2">Grippe</td>
<td style="font-weight: 700" colspan="2">Protivity</td>
</tr>
<tr>
<td style="height: 20px">Date</td>
<td>Antibiotique</td>
<td>Date</td>
<td>Antero</td>
<td>Date</td>
<td>Intranasale</td>
<td>Date</td>
<td>Rappel 30 jours</td>
</tr>
<tr>
<td style="height: 20px"></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td style="height: 20px"></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</table>
</div>

View File

@@ -47,7 +47,7 @@ final class PontBasculeServiceTest extends TestCase
$httpClient
->expects(self::once())
->method('request')
->with('POST', 'http://example.test/send/dsd')
->with('POST', 'http://example.test')
->willReturn($response)
;
@@ -82,103 +82,4 @@ 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);
}
}

View File

@@ -1,78 +0,0 @@
<?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);
}
}