| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #2 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
1979 lines
63 KiB
Markdown
1979 lines
63 KiB
Markdown
# Phase 1 — Lectures bovin complémentaires
|
|
|
|
> **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:** Ajouter au `BovinApi` les trois opérations de lecture manquantes (`IpBGetInventaire`, `IpBGetRetourDossiers`, `IpBGetSortiesPresumees`) exposées par `wsIpBNotif`.
|
|
|
|
**Architecture:** Ces trois opérations retournent un champ `ReponseSpecifique.MessageZip` (base64 décodé par ext-soap ⇒ archive ZIP contenant un unique fichier XML). On introduit un composant partagé `ZipMessageDecoder` qui transforme le binaire en `stdClass` (via `simplexml_load_string` + roundtrip `json_encode/decode`), ce qui permet aux nouveaux mappers de réutiliser les helpers existants de `AnimalFileMapper` via un trait. Chaque opération obtient son couple DTO + mapper dédié, `BovinApi` expose trois nouvelles méthodes et `BovinApiInterface` est étendue en conséquence.
|
|
|
|
**Tech Stack:** PHP 8.4, ext-soap, ext-zip, Symfony 8, PHPUnit 12.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### À créer
|
|
|
|
```
|
|
src/Shared/Soap/ZipMessageDecoder.php décodeur binaire ZIP → stdClass
|
|
src/Bovin/Mapper/BovinNodeMappingTrait.php helpers de mapping partagés
|
|
src/Bovin/Dto/AnimalSummaryDto.php bovin simplifié (identification + présences)
|
|
src/Bovin/Dto/InventoryDto.php réponse IpBGetInventaire
|
|
src/Bovin/Dto/EarTagSeriesDto.php série de boucles (StockBoucles)
|
|
src/Bovin/Dto/ReturnedDossiersDto.php réponse IpBGetRetourDossiers
|
|
src/Bovin/Dto/PresumedExitDto.php une sortie présumée
|
|
src/Bovin/Dto/PresumedExitsDto.php réponse IpBGetSortiesPresumees
|
|
src/Bovin/Mapper/AnimalSummaryMapper.php mapping noeud Bovin (IdentiteBovin + PeriodesPresences)
|
|
src/Bovin/Mapper/InventoryMapper.php
|
|
src/Bovin/Mapper/ReturnedDossiersMapper.php
|
|
src/Bovin/Mapper/PresumedExitsMapper.php
|
|
phpunit.xml.dist config PHPUnit 12
|
|
tests/bootstrap.php autoload
|
|
tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
|
|
tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
|
|
tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
|
tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
|
|
tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php
|
|
```
|
|
|
|
### À modifier
|
|
|
|
```
|
|
src/Bovin/Api/BovinApiInterface.php +3 méthodes
|
|
src/Bovin/Api/BovinApi.php +3 méthodes + nouvelles dépendances
|
|
src/Bovin/Mapper/AnimalFileMapper.php utilise le trait partagé
|
|
config/services.php enregistre les nouveaux services
|
|
makefile ajoute une cible `test`
|
|
```
|
|
|
|
---
|
|
|
|
## Conventions de test
|
|
|
|
**Exécution** : depuis la racine du repo.
|
|
```
|
|
make test # nouvelle cible : run PHPUnit dans le container
|
|
```
|
|
Si le container n'est pas démarré : `make start` puis `make install` d'abord.
|
|
|
|
**Alternative sans docker** (si PHP 8.4 est dispo localement) : `vendor/bin/phpunit --colors=always`.
|
|
|
|
Les instructions `Run` ci-dessous utilisent `make test FILES=<chemin>` pour cibler un fichier (la cible sera définie en Task 2).
|
|
|
|
---
|
|
|
|
## Task 1 — Commit du catalogue des WS
|
|
|
|
**But** : mettre au propre `docs/ws-catalog.md` (untracked hérité de la PR précédente) avant de commencer le code.
|
|
|
|
**Files:**
|
|
- Add: `docs/ws-catalog.md`
|
|
|
|
- [ ] **Step 1: Vérifier que le fichier existe et est bien untracked**
|
|
|
|
Run:
|
|
```
|
|
git status --short docs/ws-catalog.md
|
|
```
|
|
Expected: `?? docs/ws-catalog.md`
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add docs/ws-catalog.md
|
|
git commit -m "docs : catalogue des WS EDNOTIF et recommandation de priorisation"
|
|
```
|
|
Expected: commit créé sur `feat/bovin-reads`.
|
|
|
|
---
|
|
|
|
## Task 2 — Bootstrap PHPUnit
|
|
|
|
**But** : poser l'infra de tests inexistante (pas de `tests/`, pas de `phpunit.xml.dist` à ce jour).
|
|
|
|
**Files:**
|
|
- Create: `phpunit.xml.dist`
|
|
- Create: `tests/bootstrap.php`
|
|
- Create: `tests/Unit/.gitkeep`
|
|
- Modify: `makefile`
|
|
|
|
- [ ] **Step 1: Créer `phpunit.xml.dist`**
|
|
|
|
Contenu complet :
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
bootstrap="tests/bootstrap.php"
|
|
colors="true"
|
|
failOnWarning="true"
|
|
failOnRisky="true"
|
|
cacheDirectory=".phpunit.cache">
|
|
<testsuites>
|
|
<testsuite name="Unit">
|
|
<directory>tests/Unit</directory>
|
|
</testsuite>
|
|
</testsuites>
|
|
<source>
|
|
<include>
|
|
<directory>src</directory>
|
|
</include>
|
|
</source>
|
|
</phpunit>
|
|
```
|
|
|
|
- [ ] **Step 2: Créer `tests/bootstrap.php`**
|
|
|
|
Contenu complet :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__.'/../vendor/autoload.php';
|
|
```
|
|
|
|
- [ ] **Step 3: Créer `tests/Unit/.gitkeep`**
|
|
|
|
Fichier vide. Nécessaire pour que `git` suive le répertoire avant le premier test.
|
|
|
|
- [ ] **Step 4: Ajouter la cible `test` au `makefile`**
|
|
|
|
Ajouter à la fin du makefile :
|
|
```makefile
|
|
# Lance la suite PHPUnit. Usage : make test (tout)
|
|
# make test FILES=<path> (un fichier/dossier)
|
|
test:
|
|
$(EXEC_PHP) php vendor/bin/phpunit $(FILES)
|
|
```
|
|
|
|
- [ ] **Step 5: Ajouter `.phpunit.cache` au `.gitignore`**
|
|
|
|
Vérifier s'il existe déjà un `.gitignore` :
|
|
```
|
|
ls -la .gitignore
|
|
```
|
|
S'il existe, y ajouter la ligne `.phpunit.cache`. S'il n'existe pas, le créer avec :
|
|
```
|
|
vendor/
|
|
.phpunit.cache
|
|
composer.lock
|
|
```
|
|
Remarque : `composer.lock` est actuellement versionné — si c'est intentionnel, ne pas l'ajouter au `.gitignore`. Vérifier : `git ls-files composer.lock`.
|
|
|
|
- [ ] **Step 6: Vérifier que PHPUnit démarre**
|
|
|
|
Run:
|
|
```
|
|
make test
|
|
```
|
|
Expected: `No tests executed!` (pas d'erreur de bootstrap). Si container pas lancé → `make start && make install` d'abord.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add phpunit.xml.dist tests/bootstrap.php tests/Unit/.gitkeep makefile .gitignore
|
|
git commit -m "chore : bootstrap infrastructure PHPUnit 12"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 — `ZipMessageDecoder`
|
|
|
|
**But** : composant partagé qui accepte le binaire ZIP renvoyé par SOAP et retourne un `stdClass` directement utilisable par les mappers existants (même API que le payload SoapClient).
|
|
|
|
**Files:**
|
|
- Create: `src/Shared/Soap/ZipMessageDecoder.php`
|
|
- Create: `tests/Unit/Shared/Soap/ZipMessageDecoderTest.php`
|
|
|
|
- [ ] **Step 1: Écrire le test en premier**
|
|
|
|
Contenu complet de `tests/Unit/Shared/Soap/ZipMessageDecoderTest.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Tests\Unit\Shared\Soap;
|
|
|
|
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use RuntimeException;
|
|
use ZipArchive;
|
|
|
|
#[CoversClass(ZipMessageDecoder::class)]
|
|
final class ZipMessageDecoderTest extends TestCase
|
|
{
|
|
public function testDecodeReturnsObjectFromZippedXml(): void
|
|
{
|
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
|
|
.'<MessageIpBNotifGetInventaire>'
|
|
.'<InformationsMessage><DateDebut>2026-01-01</DateDebut></InformationsMessage>'
|
|
.'<Bovins><Bovin><IdentiteBovin><Sexe>F</Sexe></IdentiteBovin></Bovin></Bovins>'
|
|
.'</MessageIpBNotifGetInventaire>';
|
|
|
|
$zipBinary = $this->makeZipBinary('message.xml', $xml);
|
|
$decoded = (new ZipMessageDecoder())->decode($zipBinary);
|
|
|
|
self::assertIsObject($decoded);
|
|
self::assertSame('2026-01-01', (string) $decoded->InformationsMessage->DateDebut);
|
|
self::assertSame('F', (string) $decoded->Bovins->Bovin->IdentiteBovin->Sexe);
|
|
}
|
|
|
|
public function testDecodeThrowsOnEmptyBinary(): void
|
|
{
|
|
$this->expectException(RuntimeException::class);
|
|
(new ZipMessageDecoder())->decode('');
|
|
}
|
|
|
|
public function testDecodeThrowsOnInvalidZip(): void
|
|
{
|
|
$this->expectException(RuntimeException::class);
|
|
(new ZipMessageDecoder())->decode('not a zip');
|
|
}
|
|
|
|
private function makeZipBinary(string $innerFile, string $content): string
|
|
{
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
|
|
self::assertIsString($tempFile);
|
|
$zip = new ZipArchive();
|
|
self::assertTrue(true === $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE));
|
|
self::assertTrue($zip->addFromString($innerFile, $content));
|
|
self::assertTrue($zip->close());
|
|
$binary = file_get_contents($tempFile);
|
|
@unlink($tempFile);
|
|
self::assertIsString($binary);
|
|
|
|
return $binary;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Vérifier que le test échoue (classe absente)**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
|
|
```
|
|
Expected: erreur `Class "Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder" not found`.
|
|
|
|
- [ ] **Step 3: Créer le décodeur**
|
|
|
|
Contenu complet de `src/Shared/Soap/ZipMessageDecoder.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Shared\Soap;
|
|
|
|
use RuntimeException;
|
|
use ZipArchive;
|
|
|
|
final class ZipMessageDecoder
|
|
{
|
|
/**
|
|
* Décode le binaire `MessageZip` retourné par les opérations EDNOTIF de type
|
|
* `Get*` (Inventaire / RetourDossiers / SortiesPresumees) : le contenu est déjà
|
|
* décodé par ext-soap depuis base64, il reste à dézipper et parser le XML.
|
|
*/
|
|
public function decode(string $zipBinary): object
|
|
{
|
|
if ('' === $zipBinary) {
|
|
throw new RuntimeException('ZipMessageDecoder: binaire ZIP vide.');
|
|
}
|
|
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'ednotif_zip_');
|
|
if (false === $tempFile) {
|
|
throw new RuntimeException('ZipMessageDecoder: impossible de créer un fichier temporaire.');
|
|
}
|
|
|
|
try {
|
|
if (false === file_put_contents($tempFile, $zipBinary)) {
|
|
throw new RuntimeException('ZipMessageDecoder: impossible d\'écrire le fichier temporaire.');
|
|
}
|
|
|
|
$xml = $this->readFirstEntry($tempFile);
|
|
} finally {
|
|
@unlink($tempFile);
|
|
}
|
|
|
|
$previous = libxml_use_internal_errors(true);
|
|
$simpleXml = simplexml_load_string($xml);
|
|
libxml_use_internal_errors($previous);
|
|
|
|
if (false === $simpleXml) {
|
|
throw new RuntimeException('ZipMessageDecoder: XML invalide dans l\'archive ZIP.');
|
|
}
|
|
|
|
$json = json_encode($simpleXml);
|
|
if (false === $json) {
|
|
throw new RuntimeException('ZipMessageDecoder: échec de l\'encodage JSON intermédiaire.');
|
|
}
|
|
|
|
$decoded = json_decode($json, false);
|
|
if (!is_object($decoded)) {
|
|
throw new RuntimeException('ZipMessageDecoder: décodage JSON non objet.');
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function readFirstEntry(string $filePath): string
|
|
{
|
|
$zip = new ZipArchive();
|
|
$openResult = $zip->open($filePath, ZipArchive::RDONLY);
|
|
if (true !== $openResult) {
|
|
throw new RuntimeException(sprintf('ZipMessageDecoder: ouverture ZIP impossible (code %s).', (string) $openResult));
|
|
}
|
|
|
|
try {
|
|
if (0 === $zip->numFiles) {
|
|
throw new RuntimeException('ZipMessageDecoder: archive ZIP vide.');
|
|
}
|
|
|
|
$xml = $zip->getFromIndex(0);
|
|
if (false === $xml) {
|
|
throw new RuntimeException('ZipMessageDecoder: lecture de l\'entrée ZIP impossible.');
|
|
}
|
|
} finally {
|
|
$zip->close();
|
|
}
|
|
|
|
return $xml;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Vérifier que les tests passent**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
|
|
```
|
|
Expected: 3 tests, 3 passent.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Shared/Soap/ZipMessageDecoder.php tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
|
|
git commit -m "feat : décodeur ZIP+XML partagé pour les réponses Get* bovin"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4 — Trait de mapping partagé + refactor `AnimalFileMapper`
|
|
|
|
**But** : extraire d'`AnimalFileMapper` les helpers génériques (type-conversion + mapping d'`IdentiteBovin` / `PresencePeriode` / `BovinRef` / `ExploitationRef` / `Movement`) dans un trait réutilisable par les nouveaux mappers, sans casser la signature publique de `AnimalFileMapper::map()`.
|
|
|
|
**Files:**
|
|
- Create: `src/Bovin/Mapper/BovinNodeMappingTrait.php`
|
|
- Modify: `src/Bovin/Mapper/AnimalFileMapper.php`
|
|
|
|
- [ ] **Step 1: Créer le trait**
|
|
|
|
Contenu complet de `src/Bovin/Mapper/BovinNodeMappingTrait.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Bovin\Dto\BovinIdentificationDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\BovinRef;
|
|
use Malio\EdnotifBundle\Bovin\Dto\DateValueDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\ExploitationRef;
|
|
use Malio\EdnotifBundle\Bovin\Dto\MovementDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\ParentInfoDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\PresencePeriodDto;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Helpers partagés par les mappers travaillant sur des noeuds `Bovin` issus
|
|
* d'EDNOTIF, que la source soit une réponse SOAP directe (stdClass via ext-soap)
|
|
* ou un message XML zippé (stdClass via ZipMessageDecoder).
|
|
*/
|
|
trait BovinNodeMappingTrait
|
|
{
|
|
protected function mapIdentification(object $identificationNode): BovinIdentificationDto
|
|
{
|
|
$birthDate = null;
|
|
$birthDateNode = $identificationNode->DateNaissance ?? null;
|
|
if (is_object($birthDateNode)) {
|
|
$birthDate = new DateValueDto(
|
|
date: $this->toNullableDate($birthDateNode->Date ?? null),
|
|
completenessFlag: $this->toNullableString($birthDateNode->TemoinCompletude ?? null),
|
|
);
|
|
}
|
|
|
|
return new BovinIdentificationDto(
|
|
bovin: $this->mapBovinRef($identificationNode->Bovin ?? null),
|
|
sex: $this->toNullableString($identificationNode->Sexe ?? null),
|
|
breedType: $this->toNullableString($identificationNode->TypeRacial ?? null),
|
|
birthDate: $birthDate,
|
|
workNumber: $this->toNullableString($identificationNode->NumeroTravail ?? null),
|
|
isFilie: $this->toNullableBool($identificationNode->StatutFilie ?? null),
|
|
motherCarrier: $this->mapParentInfo($identificationNode->MerePorteuse ?? null),
|
|
fatherIpg: $this->mapParentInfo($identificationNode->PereIPG ?? null),
|
|
birthExploitation: $this->mapExploitationRef($identificationNode->ExploitationNaissance ?? null),
|
|
);
|
|
}
|
|
|
|
protected function mapPresencePeriod(object $presencePeriodNode): PresencePeriodDto
|
|
{
|
|
$entryNode = $presencePeriodNode->Entree ?? null;
|
|
$exitNode = $presencePeriodNode->Sortie ?? null;
|
|
|
|
return new PresencePeriodDto(
|
|
entry: is_object($entryNode) ? $this->mapMovement($entryNode, 'entry') : null,
|
|
exit: is_object($exitNode) ? $this->mapMovement($exitNode, 'exit') : null,
|
|
);
|
|
}
|
|
|
|
protected function mapMovement(object $movementNode, string $direction): MovementDto
|
|
{
|
|
if ('entry' === $direction) {
|
|
$dateValue = $movementNode->DateEntree ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
|
|
$causeValue = $movementNode->CauseEntree ?? null;
|
|
} else {
|
|
$dateValue = $movementNode->DateSortie ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
|
|
$causeValue = $movementNode->CauseSortie ?? null;
|
|
}
|
|
|
|
return new MovementDto(
|
|
date: $this->toNullableDate($dateValue),
|
|
cause: $this->toNullableString($causeValue),
|
|
exploitation: $this->mapExploitationRef($movementNode->Exploitation ?? null),
|
|
);
|
|
}
|
|
|
|
protected function mapParentInfo(mixed $parentNode): ?ParentInfoDto
|
|
{
|
|
if (!is_object($parentNode)) {
|
|
return null;
|
|
}
|
|
|
|
return new ParentInfoDto(
|
|
bovin: $this->mapBovinRef($parentNode->Bovin ?? null),
|
|
breedType: $this->toNullableString($parentNode->TypeRacial ?? null),
|
|
);
|
|
}
|
|
|
|
protected function mapBovinRef(mixed $bovinNode): ?BovinRef
|
|
{
|
|
if (!is_object($bovinNode)) {
|
|
return null;
|
|
}
|
|
|
|
return new BovinRef(
|
|
countryCode: $this->toNullableString($bovinNode->CodePays ?? null),
|
|
nationalNumber: $this->toNullableString($bovinNode->NumeroNational ?? null),
|
|
);
|
|
}
|
|
|
|
protected function mapExploitationRef(mixed $exploitationNode): ?ExploitationRef
|
|
{
|
|
if (!is_object($exploitationNode)) {
|
|
return null;
|
|
}
|
|
|
|
return new ExploitationRef(
|
|
countryCode: $this->toNullableString($exploitationNode->CodePays ?? null),
|
|
exploitationNumber: $this->toNullableString($exploitationNode->NumeroExploitation ?? null),
|
|
);
|
|
}
|
|
|
|
/** @return list<mixed> */
|
|
protected function normalizeToList(mixed $value): array
|
|
{
|
|
if (null === $value) {
|
|
return [];
|
|
}
|
|
|
|
return is_array($value) ? array_values($value) : [$value];
|
|
}
|
|
|
|
protected function toNullableString(mixed $value): ?string
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
$stringValue = trim((string) $value);
|
|
|
|
return '' === $stringValue ? null : $stringValue;
|
|
}
|
|
|
|
protected function toNullableInt(mixed $value): ?int
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
if (is_int($value)) {
|
|
return $value;
|
|
}
|
|
if (is_numeric($value)) {
|
|
return (int) $value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function toNullableBool(mixed $value): ?bool
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
|
|
return (bool) $value;
|
|
}
|
|
|
|
protected function toNullableDate(mixed $value): ?DateTimeImmutable
|
|
{
|
|
if (!is_string($value) || '' === trim($value)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new DateTimeImmutable($value);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Refactorer `AnimalFileMapper` pour utiliser le trait**
|
|
|
|
Remplacer intégralement `src/Bovin/Mapper/AnimalFileMapper.php` par :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final class AnimalFileMapper
|
|
{
|
|
use BovinNodeMappingTrait;
|
|
|
|
public function map(object $soapResponse): AnimalFileDto
|
|
{
|
|
$standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
|
|
|
|
$specificResponseNode = $soapResponse->ReponseSpecifique ?? null;
|
|
$bovinNode = is_object($specificResponseNode) ? ($specificResponseNode->Bovin ?? null) : null;
|
|
|
|
$identification = null;
|
|
$presencePeriods = [];
|
|
|
|
if (is_object($bovinNode)) {
|
|
$identificationNode = $bovinNode->IdentiteBovin ?? null;
|
|
if (is_object($identificationNode)) {
|
|
$identification = $this->mapIdentification($identificationNode);
|
|
}
|
|
|
|
$presencePeriodsNode = $bovinNode->PeriodesPresences->PeriodePresence ?? null;
|
|
foreach ($this->normalizeToList($presencePeriodsNode) as $presencePeriodNode) {
|
|
if (!is_object($presencePeriodNode)) {
|
|
continue;
|
|
}
|
|
$presencePeriods[] = $this->mapPresencePeriod($presencePeriodNode);
|
|
}
|
|
}
|
|
|
|
return new AnimalFileDto(
|
|
standardResponse: $standardResponse,
|
|
identification: $identification,
|
|
presencePeriods: $presencePeriods,
|
|
rawSoapResponse: $soapResponse
|
|
);
|
|
}
|
|
|
|
private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
|
|
{
|
|
$result = (bool) ($standardResponseNode->Resultat ?? false);
|
|
|
|
$anomalyNode = $standardResponseNode->Anomalie ?? null;
|
|
$anomaly = null;
|
|
|
|
if (is_object($anomalyNode)) {
|
|
$anomaly = new AnomalyDto(
|
|
code: $this->toNullableString($anomalyNode->Code ?? null),
|
|
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
|
|
message: $this->toNullableString($anomalyNode->Message ?? null),
|
|
);
|
|
}
|
|
|
|
return new StandardResponseDto($result, $anomaly);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Vérifier qu'aucun test ne régresse**
|
|
|
|
Run:
|
|
```
|
|
make test
|
|
```
|
|
Expected: 3 tests PASS (les tests ZipMessageDecoder de Task 3), aucune erreur de chargement.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Mapper/BovinNodeMappingTrait.php src/Bovin/Mapper/AnimalFileMapper.php
|
|
git commit -m "refactor : extraire les helpers de mapping bovin dans un trait partagé"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5 — `AnimalSummaryDto` + `AnimalSummaryMapper`
|
|
|
|
**But** : modéliser un bovin « résumé » (identification + périodes de présence, sans l'enveloppe `StandardResponse` qui n'existe pas pour les listes) et son mapper. Consommé par `InventoryMapper` et `ReturnedDossiersMapper`.
|
|
|
|
**Files:**
|
|
- Create: `src/Bovin/Dto/AnimalSummaryDto.php`
|
|
- Create: `src/Bovin/Mapper/AnimalSummaryMapper.php`
|
|
- Create: `tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php`
|
|
|
|
- [ ] **Step 1: Écrire le test**
|
|
|
|
Contenu complet de `tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
|
|
#[CoversClass(AnimalSummaryMapper::class)]
|
|
final class AnimalSummaryMapperTest extends TestCase
|
|
{
|
|
public function testMapReturnsIdentificationAndPresencePeriods(): void
|
|
{
|
|
$node = $this->makeBovinNode();
|
|
|
|
$summary = (new AnimalSummaryMapper())->map($node);
|
|
|
|
self::assertInstanceOf(AnimalSummaryDto::class, $summary);
|
|
self::assertNotNull($summary->identification);
|
|
self::assertSame('FR1234567890', $summary->identification->bovin?->nationalNumber);
|
|
self::assertSame('F', $summary->identification->sex);
|
|
self::assertCount(2, $summary->presencePeriods);
|
|
}
|
|
|
|
public function testMapHandlesMissingOptionalNodes(): void
|
|
{
|
|
$summary = (new AnimalSummaryMapper())->map(new stdClass());
|
|
|
|
self::assertNull($summary->identification);
|
|
self::assertSame([], $summary->presencePeriods);
|
|
}
|
|
|
|
private function makeBovinNode(): object
|
|
{
|
|
$node = new stdClass();
|
|
$node->IdentiteBovin = new stdClass();
|
|
$node->IdentiteBovin->Sexe = 'F';
|
|
$node->IdentiteBovin->Bovin = new stdClass();
|
|
$node->IdentiteBovin->Bovin->CodePays = 'FR';
|
|
$node->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';
|
|
|
|
$node->PeriodesPresences = new stdClass();
|
|
$node->PeriodesPresences->PeriodePresence = [
|
|
$this->makePresencePeriod('2024-01-10', '2024-06-01'),
|
|
$this->makePresencePeriod('2024-06-02', null),
|
|
];
|
|
|
|
return $node;
|
|
}
|
|
|
|
private function makePresencePeriod(string $entryDate, ?string $exitDate): object
|
|
{
|
|
$period = new stdClass();
|
|
$period->Entree = new stdClass();
|
|
$period->Entree->DateEntree = $entryDate;
|
|
if (null !== $exitDate) {
|
|
$period->Sortie = new stdClass();
|
|
$period->Sortie->DateSortie = $exitDate;
|
|
}
|
|
|
|
return $period;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (il doit échouer)**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
|
|
```
|
|
Expected: erreur `Class "Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto" not found` (ou mapper).
|
|
|
|
- [ ] **Step 3: Créer le DTO**
|
|
|
|
Contenu complet de `src/Bovin/Dto/AnimalSummaryDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
final readonly class AnimalSummaryDto
|
|
{
|
|
/**
|
|
* @param list<PresencePeriodDto> $presencePeriods
|
|
*/
|
|
public function __construct(
|
|
public ?BovinIdentificationDto $identification,
|
|
public array $presencePeriods,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Créer le mapper**
|
|
|
|
Contenu complet de `src/Bovin/Mapper/AnimalSummaryMapper.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
|
|
|
final class AnimalSummaryMapper
|
|
{
|
|
use BovinNodeMappingTrait;
|
|
|
|
public function map(object $bovinNode): AnimalSummaryDto
|
|
{
|
|
$identificationNode = $bovinNode->IdentiteBovin ?? null;
|
|
$identification = is_object($identificationNode) ? $this->mapIdentification($identificationNode) : null;
|
|
|
|
$presencePeriods = [];
|
|
$presencePeriodsNode = $bovinNode->PeriodesPresences->PeriodePresence ?? null;
|
|
foreach ($this->normalizeToList($presencePeriodsNode) as $presencePeriodNode) {
|
|
if (!is_object($presencePeriodNode)) {
|
|
continue;
|
|
}
|
|
$presencePeriods[] = $this->mapPresencePeriod($presencePeriodNode);
|
|
}
|
|
|
|
return new AnimalSummaryDto(
|
|
identification: $identification,
|
|
presencePeriods: $presencePeriods,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Vérifier que les tests passent**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
|
|
```
|
|
Expected: 2 tests PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Dto/AnimalSummaryDto.php src/Bovin/Mapper/AnimalSummaryMapper.php tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
|
|
git commit -m "feat : DTO et mapper pour un bovin résumé (identification + présences)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6 — DTOs et mapper Inventaire
|
|
|
|
**But** : modéliser la réponse complète de `IpBGetInventaire` (entête `InformationsMessage` + liste de bovins + optionnellement séries de boucles).
|
|
|
|
**Files:**
|
|
- Create: `src/Bovin/Dto/EarTagSeriesDto.php`
|
|
- Create: `src/Bovin/Dto/InventoryDto.php`
|
|
- Create: `src/Bovin/Mapper/InventoryMapper.php`
|
|
- Create: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php`
|
|
|
|
**Rappel XSD du message dézippé** :
|
|
```
|
|
MessageIpBNotifGetInventaire
|
|
├── InformationsMessage
|
|
│ ├── DateHeureGeneration (xsd:dateTime)
|
|
│ ├── Exploitation (CodePays, NumeroExploitation)
|
|
│ ├── DateDebut (xsd:date)
|
|
│ ├── DateFin? (xsd:date)
|
|
│ └── StockBoucles (xsd:boolean)
|
|
├── Bovins/Bovin[] (IdentiteBovin + PeriodesPresences)
|
|
└── Boucles/SerieBoucles[] (structure détaillée volontairement non mappée → raw)
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le test**
|
|
|
|
Contenu complet de `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
|
|
#[CoversClass(InventoryMapper::class)]
|
|
final class InventoryMapperTest extends TestCase
|
|
{
|
|
public function testMapFullInventory(): void
|
|
{
|
|
$mapper = new InventoryMapper(new AnimalSummaryMapper());
|
|
|
|
$inventory = $mapper->map($this->makeSoapResponse(), $this->makeUnzippedMessage());
|
|
|
|
self::assertInstanceOf(InventoryDto::class, $inventory);
|
|
self::assertTrue($inventory->standardResponse->result);
|
|
self::assertSame(2, $inventory->nbBovins);
|
|
self::assertEquals(new DateTimeImmutable('2026-01-01'), $inventory->startDate);
|
|
self::assertEquals(new DateTimeImmutable('2026-01-31'), $inventory->endDate);
|
|
self::assertTrue($inventory->includesEarTagStock);
|
|
self::assertCount(2, $inventory->animals);
|
|
self::assertCount(1, $inventory->earTagSeries);
|
|
self::assertSame('FR123', $inventory->animals[0]->identification?->bovin?->nationalNumber);
|
|
}
|
|
|
|
public function testMapInventoryWithoutMessageZipReturnsEmptyLists(): void
|
|
{
|
|
$mapper = new InventoryMapper(new AnimalSummaryMapper());
|
|
|
|
$soapResponse = new stdClass();
|
|
$soapResponse->ReponseStandard = new stdClass();
|
|
$soapResponse->ReponseStandard->Resultat = true;
|
|
$soapResponse->ReponseSpecifique = new stdClass();
|
|
$soapResponse->ReponseSpecifique->NbBovins = 0;
|
|
|
|
$inventory = $mapper->map($soapResponse, null);
|
|
|
|
self::assertSame(0, $inventory->nbBovins);
|
|
self::assertSame([], $inventory->animals);
|
|
self::assertSame([], $inventory->earTagSeries);
|
|
self::assertNull($inventory->startDate);
|
|
}
|
|
|
|
private function makeSoapResponse(): object
|
|
{
|
|
$response = new stdClass();
|
|
$response->ReponseStandard = new stdClass();
|
|
$response->ReponseStandard->Resultat = true;
|
|
$response->ReponseSpecifique = new stdClass();
|
|
$response->ReponseSpecifique->NbBovins = 2;
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function makeUnzippedMessage(): object
|
|
{
|
|
$message = new stdClass();
|
|
$message->InformationsMessage = new stdClass();
|
|
$message->InformationsMessage->DateDebut = '2026-01-01';
|
|
$message->InformationsMessage->DateFin = '2026-01-31';
|
|
$message->InformationsMessage->StockBoucles = '1';
|
|
$message->Bovins = new stdClass();
|
|
$message->Bovins->Bovin = [
|
|
$this->makeAnimalNode('FR123'),
|
|
$this->makeAnimalNode('FR456'),
|
|
];
|
|
$message->Boucles = new stdClass();
|
|
$message->Boucles->SerieBoucles = new stdClass();
|
|
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
|
|
|
|
return $message;
|
|
}
|
|
|
|
private function makeAnimalNode(string $nationalNumber): object
|
|
{
|
|
$node = new stdClass();
|
|
$node->IdentiteBovin = new stdClass();
|
|
$node->IdentiteBovin->Bovin = new stdClass();
|
|
$node->IdentiteBovin->Bovin->NumeroNational = $nationalNumber;
|
|
$node->PeriodesPresences = new stdClass();
|
|
$node->PeriodesPresences->PeriodePresence = new stdClass();
|
|
$node->PeriodesPresences->PeriodePresence->Entree = new stdClass();
|
|
$node->PeriodesPresences->PeriodePresence->Entree->DateEntree = '2025-05-01';
|
|
|
|
return $node;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (doit échouer)**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
|
```
|
|
Expected: erreur de classe non trouvée.
|
|
|
|
- [ ] **Step 3: Créer `EarTagSeriesDto`**
|
|
|
|
Contenu complet de `src/Bovin/Dto/EarTagSeriesDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
final readonly class EarTagSeriesDto
|
|
{
|
|
public function __construct(
|
|
public object $rawNode,
|
|
) {}
|
|
}
|
|
```
|
|
Note: la structure XSD de `typeSerieBoucles` est riche (numéros, plages, fournisseur…). On garde le noeud brut en Phase 1 et on l'affinera si un besoin concret apparaît (YAGNI).
|
|
|
|
- [ ] **Step 4: Créer `InventoryDto`**
|
|
|
|
Contenu complet de `src/Bovin/Dto/InventoryDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final readonly class InventoryDto
|
|
{
|
|
/**
|
|
* @param list<AnimalSummaryDto> $animals
|
|
* @param list<EarTagSeriesDto> $earTagSeries
|
|
*/
|
|
public function __construct(
|
|
public StandardResponseDto $standardResponse,
|
|
public int $nbBovins,
|
|
public ?DateTimeImmutable $startDate,
|
|
public ?DateTimeImmutable $endDate,
|
|
public bool $includesEarTagStock,
|
|
public array $animals,
|
|
public array $earTagSeries,
|
|
public ?object $rawSoapResponse,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Créer `InventoryMapper`**
|
|
|
|
Contenu complet de `src/Bovin/Mapper/InventoryMapper.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final class InventoryMapper
|
|
{
|
|
use BovinNodeMappingTrait;
|
|
|
|
public function __construct(
|
|
private readonly AnimalSummaryMapper $animalSummaryMapper,
|
|
) {}
|
|
|
|
public function map(object $soapResponse, ?object $unzippedMessage): InventoryDto
|
|
{
|
|
$standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
|
|
|
|
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
|
|
|
|
$startDate = null;
|
|
$endDate = null;
|
|
$includesEarTagStock = false;
|
|
$animals = [];
|
|
$earTagSeries = [];
|
|
|
|
if (is_object($unzippedMessage)) {
|
|
$infoNode = $unzippedMessage->InformationsMessage ?? null;
|
|
if (is_object($infoNode)) {
|
|
$startDate = $this->toNullableDate($infoNode->DateDebut ?? null);
|
|
$endDate = $this->toNullableDate($infoNode->DateFin ?? null);
|
|
$includesEarTagStock = (bool) $this->toNullableBool($infoNode->StockBoucles ?? null);
|
|
}
|
|
|
|
$bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
|
|
foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
|
|
if (!is_object($bovinNode)) {
|
|
continue;
|
|
}
|
|
$animals[] = $this->animalSummaryMapper->map($bovinNode);
|
|
}
|
|
|
|
$seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
|
|
foreach ($this->normalizeToList($seriesNode) as $serieNode) {
|
|
if (!is_object($serieNode)) {
|
|
continue;
|
|
}
|
|
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
|
|
}
|
|
}
|
|
|
|
return new InventoryDto(
|
|
standardResponse: $standardResponse,
|
|
nbBovins: $nbBovins,
|
|
startDate: $startDate,
|
|
endDate: $endDate,
|
|
includesEarTagStock: $includesEarTagStock,
|
|
animals: $animals,
|
|
earTagSeries: $earTagSeries,
|
|
rawSoapResponse: $soapResponse,
|
|
);
|
|
}
|
|
|
|
private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
|
|
{
|
|
$result = (bool) ($standardResponseNode->Resultat ?? false);
|
|
$anomalyNode = $standardResponseNode->Anomalie ?? null;
|
|
$anomaly = null;
|
|
|
|
if (is_object($anomalyNode)) {
|
|
$anomaly = new AnomalyDto(
|
|
code: $this->toNullableString($anomalyNode->Code ?? null),
|
|
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
|
|
message: $this->toNullableString($anomalyNode->Message ?? null),
|
|
);
|
|
}
|
|
|
|
return new StandardResponseDto($result, $anomaly);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Vérifier que les tests passent**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
|
```
|
|
Expected: 2 tests PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Dto/EarTagSeriesDto.php src/Bovin/Dto/InventoryDto.php src/Bovin/Mapper/InventoryMapper.php tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
|
git commit -m "feat : DTOs et mapper pour IpBGetInventaire"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7 — Méthode `BovinApi::getInventory`
|
|
|
|
**But** : exposer l'appel SOAP `IpBGetInventaire` via l'API publique.
|
|
|
|
**Files:**
|
|
- Modify: `src/Bovin/Api/BovinApiInterface.php`
|
|
- Modify: `src/Bovin/Api/BovinApi.php`
|
|
|
|
- [ ] **Step 1: Ajouter la méthode à l'interface**
|
|
|
|
Remplacer intégralement `src/Bovin/Api/BovinApiInterface.php` par :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Api;
|
|
|
|
use DateTimeInterface;
|
|
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
|
|
|
|
interface BovinApiInterface
|
|
{
|
|
public function getAnimalFile(string $nationalNumber, string $countryCode = 'FR'): AnimalFileDto;
|
|
|
|
public function getInventory(
|
|
DateTimeInterface $startDate,
|
|
?DateTimeInterface $endDate = null,
|
|
bool $includeEarTagStock = false,
|
|
): InventoryDto;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implémenter la méthode dans `BovinApi`**
|
|
|
|
Dans `src/Bovin/Api/BovinApi.php` :
|
|
|
|
**2a.** Ajouter les imports en haut du fichier (après les imports existants) :
|
|
```php
|
|
use DateTimeInterface;
|
|
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
|
|
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
|
|
```
|
|
|
|
**2b.** Étendre le constructeur pour injecter les deux nouvelles dépendances. Remplacer le bloc `__construct` actuel (lignes 17-23) par :
|
|
```php
|
|
public function __construct(
|
|
private TokenProvider $tokenProvider,
|
|
private SoapClient $businessClient,
|
|
private AnimalFileMapper $bovinDossierMapper,
|
|
private InventoryMapper $inventoryMapper,
|
|
private ZipMessageDecoder $zipMessageDecoder,
|
|
private string $exploitationCountryCode,
|
|
private string $exploitationNumber,
|
|
) {}
|
|
```
|
|
|
|
**2c.** Ajouter la méthode `getInventory` juste après `getAnimalFile` :
|
|
```php
|
|
public function getInventory(
|
|
DateTimeInterface $startDate,
|
|
?DateTimeInterface $endDate = null,
|
|
bool $includeEarTagStock = false,
|
|
): InventoryDto {
|
|
$token = $this->tokenProvider->getToken();
|
|
|
|
$payload = [
|
|
'JetonAuthentification' => $token,
|
|
'Exploitation' => [
|
|
'CodePays' => $this->exploitationCountryCode,
|
|
'NumeroExploitation' => $this->exploitationNumber,
|
|
],
|
|
'DateDebut' => $startDate->format('Y-m-d'),
|
|
'StockBoucles' => $includeEarTagStock,
|
|
];
|
|
if (null !== $endDate) {
|
|
$payload['DateFin'] = $endDate->format('Y-m-d');
|
|
}
|
|
|
|
try {
|
|
/** @var object $soapResponse */
|
|
$soapResponse = $this->businessClient->__soapCall('IpBGetInventaire', [$payload]);
|
|
} catch (SoapFault $soapFault) {
|
|
throw new RuntimeException('SOAP Fault on IpBGetInventaire: '.$soapFault->getMessage(), 0, $soapFault);
|
|
}
|
|
|
|
$this->assertSuccessfulResponse($soapResponse, 'IpBGetInventaire');
|
|
|
|
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
|
|
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
|
|
? $this->zipMessageDecoder->decode($messageZip)
|
|
: null;
|
|
|
|
return $this->inventoryMapper->map($soapResponse, $unzippedMessage);
|
|
}
|
|
```
|
|
|
|
**2d.** Factoriser la vérification `ReponseStandard` en méthode privée. Extraire le bloc existant (lignes 49-60 environ) dans `src/Bovin/Api/BovinApi.php`. Ajouter en fin de classe :
|
|
```php
|
|
private function assertSuccessfulResponse(object $soapResponse, string $operation): void
|
|
{
|
|
$standardResponseNode = $soapResponse->ReponseStandard ?? null;
|
|
$isOk = is_object($standardResponseNode) && (($standardResponseNode->Resultat ?? false) === true);
|
|
|
|
if ($isOk) {
|
|
return;
|
|
}
|
|
|
|
$anomalyNode = is_object($standardResponseNode) ? ($standardResponseNode->Anomalie ?? null) : null;
|
|
|
|
throw new EdnotifException(
|
|
codeAnomalie: (string) ($anomalyNode->Code ?? 'UNKNOWN'),
|
|
severite: (int) ($anomalyNode->Severite ?? 1),
|
|
message: (string) ($anomalyNode->Message ?? $operation.' : EDNOTIF error')
|
|
);
|
|
}
|
|
```
|
|
|
|
Et remplacer dans `getAnimalFile` le bloc `if (!$isOk) { … throw new EdnotifException(…); }` par :
|
|
```php
|
|
$this->assertSuccessfulResponse($soapResponse, 'IpBGetDossierAnimal');
|
|
```
|
|
(en supprimant la variable `$standardResponseNode` et le bloc conditionnel devenus inutiles).
|
|
|
|
- [ ] **Step 3: Mettre à jour `config/services.php`**
|
|
|
|
Juste avant la ligne `$services->set(BovinApi::class)` (actuellement ligne 50), insérer :
|
|
```php
|
|
$services->set(ZipMessageDecoder::class);
|
|
$services->set(AnimalSummaryMapper::class);
|
|
$services->set(InventoryMapper::class);
|
|
```
|
|
|
|
Et ajouter les imports en haut du fichier :
|
|
```php
|
|
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
|
|
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
|
|
```
|
|
|
|
Modifier le bloc `$services->set(BovinApi::class)` pour inclure les nouvelles dépendances :
|
|
```php
|
|
$services->set(BovinApi::class)
|
|
->args([
|
|
service(TokenProvider::class),
|
|
service('ednotif.soap.business'),
|
|
service(AnimalFileMapper::class),
|
|
service(InventoryMapper::class),
|
|
service(ZipMessageDecoder::class),
|
|
'%ednotif.exploitation_country_code%',
|
|
'%ednotif.exploitation_number%',
|
|
])
|
|
;
|
|
```
|
|
|
|
- [ ] **Step 4: Vérifier la suite complète**
|
|
|
|
Run:
|
|
```
|
|
make test
|
|
```
|
|
Expected: tous les tests passent (ZipMessageDecoder + AnimalSummaryMapper + InventoryMapper).
|
|
|
|
- [ ] **Step 5: Vérifier le container Symfony (optionnel mais utile)**
|
|
|
|
Si le projet consommateur est sous la main (en path composer), relancer `bin/console cache:clear` et vérifier qu'aucune erreur DI n'apparaît. Sinon, passer au commit.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
|
|
git commit -m "feat : expose IpBGetInventaire via BovinApi::getInventory"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8 — DTO et mapper `RetourDossiers`
|
|
|
|
**But** : modéliser la réponse de `IpBGetRetourDossiers`. Même structure XML que l'inventaire côté bovins, mais sans `DateFin` ni `Boucles` et avec `DateDebut` requis dans l'entête.
|
|
|
|
**Files:**
|
|
- Create: `src/Bovin/Dto/ReturnedDossiersDto.php`
|
|
- Create: `src/Bovin/Mapper/ReturnedDossiersMapper.php`
|
|
- Create: `tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php`
|
|
|
|
- [ ] **Step 1: Écrire le test**
|
|
|
|
Contenu complet de `tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
|
|
#[CoversClass(ReturnedDossiersMapper::class)]
|
|
final class ReturnedDossiersMapperTest extends TestCase
|
|
{
|
|
public function testMapReturnsAnimalsAndStartDate(): void
|
|
{
|
|
$mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper());
|
|
|
|
$soapResponse = new stdClass();
|
|
$soapResponse->ReponseStandard = new stdClass();
|
|
$soapResponse->ReponseStandard->Resultat = true;
|
|
$soapResponse->ReponseSpecifique = new stdClass();
|
|
$soapResponse->ReponseSpecifique->NbBovins = 1;
|
|
|
|
$message = new stdClass();
|
|
$message->InformationsMessage = new stdClass();
|
|
$message->InformationsMessage->DateDebut = '2026-03-01';
|
|
$message->Bovins = new stdClass();
|
|
$message->Bovins->Bovin = new stdClass();
|
|
$message->Bovins->Bovin->IdentiteBovin = new stdClass();
|
|
$message->Bovins->Bovin->IdentiteBovin->Bovin = new stdClass();
|
|
$message->Bovins->Bovin->IdentiteBovin->Bovin->NumeroNational = 'FR789';
|
|
$message->Bovins->Bovin->PeriodesPresences = new stdClass();
|
|
$message->Bovins->Bovin->PeriodesPresences->PeriodePresence = new stdClass();
|
|
|
|
$dto = $mapper->map($soapResponse, $message);
|
|
|
|
self::assertInstanceOf(ReturnedDossiersDto::class, $dto);
|
|
self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
|
|
self::assertSame(1, $dto->nbBovins);
|
|
self::assertCount(1, $dto->animals);
|
|
self::assertSame('FR789', $dto->animals[0]->identification?->bovin?->nationalNumber);
|
|
}
|
|
|
|
public function testMapWithoutMessageReturnsEmptyAnimals(): void
|
|
{
|
|
$mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper());
|
|
|
|
$soapResponse = new stdClass();
|
|
$soapResponse->ReponseStandard = new stdClass();
|
|
$soapResponse->ReponseStandard->Resultat = true;
|
|
$soapResponse->ReponseSpecifique = new stdClass();
|
|
$soapResponse->ReponseSpecifique->NbBovins = 0;
|
|
|
|
$dto = $mapper->map($soapResponse, null);
|
|
|
|
self::assertSame(0, $dto->nbBovins);
|
|
self::assertSame([], $dto->animals);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (doit échouer)**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
|
|
```
|
|
Expected: classe introuvable.
|
|
|
|
- [ ] **Step 3: Créer `ReturnedDossiersDto`**
|
|
|
|
Contenu complet de `src/Bovin/Dto/ReturnedDossiersDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final readonly class ReturnedDossiersDto
|
|
{
|
|
/**
|
|
* @param list<AnimalSummaryDto> $animals
|
|
*/
|
|
public function __construct(
|
|
public StandardResponseDto $standardResponse,
|
|
public int $nbBovins,
|
|
public ?DateTimeImmutable $startDate,
|
|
public array $animals,
|
|
public ?object $rawSoapResponse,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Créer `ReturnedDossiersMapper`**
|
|
|
|
Contenu complet de `src/Bovin/Mapper/ReturnedDossiersMapper.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final class ReturnedDossiersMapper
|
|
{
|
|
use BovinNodeMappingTrait;
|
|
|
|
public function __construct(
|
|
private readonly AnimalSummaryMapper $animalSummaryMapper,
|
|
) {}
|
|
|
|
public function map(object $soapResponse, ?object $unzippedMessage): ReturnedDossiersDto
|
|
{
|
|
$standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
|
|
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
|
|
|
|
$startDate = null;
|
|
$animals = [];
|
|
|
|
if (is_object($unzippedMessage)) {
|
|
$infoNode = $unzippedMessage->InformationsMessage ?? null;
|
|
if (is_object($infoNode)) {
|
|
$startDate = $this->toNullableDate($infoNode->DateDebut ?? null);
|
|
}
|
|
|
|
$bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
|
|
foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
|
|
if (!is_object($bovinNode)) {
|
|
continue;
|
|
}
|
|
$animals[] = $this->animalSummaryMapper->map($bovinNode);
|
|
}
|
|
}
|
|
|
|
return new ReturnedDossiersDto(
|
|
standardResponse: $standardResponse,
|
|
nbBovins: $nbBovins,
|
|
startDate: $startDate,
|
|
animals: $animals,
|
|
rawSoapResponse: $soapResponse,
|
|
);
|
|
}
|
|
|
|
private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
|
|
{
|
|
$result = (bool) ($standardResponseNode->Resultat ?? false);
|
|
$anomalyNode = $standardResponseNode->Anomalie ?? null;
|
|
$anomaly = null;
|
|
|
|
if (is_object($anomalyNode)) {
|
|
$anomaly = new AnomalyDto(
|
|
code: $this->toNullableString($anomalyNode->Code ?? null),
|
|
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
|
|
message: $this->toNullableString($anomalyNode->Message ?? null),
|
|
);
|
|
}
|
|
|
|
return new StandardResponseDto($result, $anomaly);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Vérifier les tests**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
|
|
```
|
|
Expected: 2 tests PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Dto/ReturnedDossiersDto.php src/Bovin/Mapper/ReturnedDossiersMapper.php tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
|
|
git commit -m "feat : DTO et mapper pour IpBGetRetourDossiers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9 — Méthode `BovinApi::getReturnedDossiers`
|
|
|
|
**Files:**
|
|
- Modify: `src/Bovin/Api/BovinApiInterface.php`
|
|
- Modify: `src/Bovin/Api/BovinApi.php`
|
|
- Modify: `config/services.php`
|
|
|
|
- [ ] **Step 1: Ajouter la méthode à l'interface**
|
|
|
|
Dans `src/Bovin/Api/BovinApiInterface.php`, ajouter l'import `use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;` puis ajouter la méthode en fin d'interface :
|
|
```php
|
|
public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto;
|
|
```
|
|
|
|
- [ ] **Step 2: Étendre le constructeur de `BovinApi`**
|
|
|
|
Ajouter les imports :
|
|
```php
|
|
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
|
|
```
|
|
|
|
Ajouter au constructeur la nouvelle dépendance `private ReturnedDossiersMapper $returnedDossiersMapper` (la placer juste après `$inventoryMapper`).
|
|
|
|
- [ ] **Step 3: Ajouter la méthode**
|
|
|
|
À la suite de `getInventory` :
|
|
```php
|
|
public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto
|
|
{
|
|
$token = $this->tokenProvider->getToken();
|
|
|
|
$payload = [
|
|
'JetonAuthentification' => $token,
|
|
'Exploitation' => [
|
|
'CodePays' => $this->exploitationCountryCode,
|
|
'NumeroExploitation' => $this->exploitationNumber,
|
|
],
|
|
'DateDebut' => $startDate->format('Y-m-d'),
|
|
];
|
|
|
|
try {
|
|
/** @var object $soapResponse */
|
|
$soapResponse = $this->businessClient->__soapCall('IpBGetRetourDossiers', [$payload]);
|
|
} catch (SoapFault $soapFault) {
|
|
throw new RuntimeException('SOAP Fault on IpBGetRetourDossiers: '.$soapFault->getMessage(), 0, $soapFault);
|
|
}
|
|
|
|
$this->assertSuccessfulResponse($soapResponse, 'IpBGetRetourDossiers');
|
|
|
|
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
|
|
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
|
|
? $this->zipMessageDecoder->decode($messageZip)
|
|
: null;
|
|
|
|
return $this->returnedDossiersMapper->map($soapResponse, $unzippedMessage);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Mettre à jour `config/services.php`**
|
|
|
|
Ajouter l'import `use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;`.
|
|
|
|
Juste après `$services->set(InventoryMapper::class);` (ajouté en Task 7), ajouter :
|
|
```php
|
|
$services->set(ReturnedDossiersMapper::class);
|
|
```
|
|
|
|
Dans les args de `BovinApi`, ajouter `service(ReturnedDossiersMapper::class)` juste après `service(InventoryMapper::class)`.
|
|
|
|
- [ ] **Step 5: Vérifier la suite**
|
|
|
|
Run:
|
|
```
|
|
make test
|
|
```
|
|
Expected: tous les tests passent.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
|
|
git commit -m "feat : expose IpBGetRetourDossiers via BovinApi::getReturnedDossiers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10 — DTOs et mapper `SortiesPresumees`
|
|
|
|
**Files:**
|
|
- Create: `src/Bovin/Dto/PresumedExitDto.php`
|
|
- Create: `src/Bovin/Dto/PresumedExitsDto.php`
|
|
- Create: `src/Bovin/Mapper/PresumedExitsMapper.php`
|
|
- Create: `tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php`
|
|
|
|
**Rappel XSD** :
|
|
```
|
|
MessageIpBNotifGetSortiesPresumees
|
|
├── InformationsMessage (DateHeureGeneration, Exploitation)
|
|
└── SortiesPresumees/SortiePresumee[]
|
|
├── Bovin (CodePays, NumeroNational)
|
|
└── DateSortie? (xsd:date)
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le test**
|
|
|
|
Contenu complet de `tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
|
|
|
use DateTimeImmutable;
|
|
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
|
|
#[CoversClass(PresumedExitsMapper::class)]
|
|
final class PresumedExitsMapperTest extends TestCase
|
|
{
|
|
public function testMapWithExits(): void
|
|
{
|
|
$soapResponse = new stdClass();
|
|
$soapResponse->ReponseStandard = new stdClass();
|
|
$soapResponse->ReponseStandard->Resultat = true;
|
|
$soapResponse->ReponseSpecifique = new stdClass();
|
|
$soapResponse->ReponseSpecifique->NbBovins = 2;
|
|
|
|
$message = new stdClass();
|
|
$message->SortiesPresumees = new stdClass();
|
|
$message->SortiesPresumees->SortiePresumee = [
|
|
$this->makeExit('FR111', '2026-02-15'),
|
|
$this->makeExit('FR222', null),
|
|
];
|
|
|
|
$dto = (new PresumedExitsMapper())->map($soapResponse, $message);
|
|
|
|
self::assertInstanceOf(PresumedExitsDto::class, $dto);
|
|
self::assertSame(2, $dto->nbBovins);
|
|
self::assertCount(2, $dto->presumedExits);
|
|
self::assertSame('FR111', $dto->presumedExits[0]->bovin?->nationalNumber);
|
|
self::assertEquals(new DateTimeImmutable('2026-02-15'), $dto->presumedExits[0]->exitDate);
|
|
self::assertNull($dto->presumedExits[1]->exitDate);
|
|
}
|
|
|
|
public function testMapWithoutMessageReturnsEmpty(): void
|
|
{
|
|
$soapResponse = new stdClass();
|
|
$soapResponse->ReponseStandard = new stdClass();
|
|
$soapResponse->ReponseStandard->Resultat = true;
|
|
$soapResponse->ReponseSpecifique = new stdClass();
|
|
$soapResponse->ReponseSpecifique->NbBovins = 0;
|
|
|
|
$dto = (new PresumedExitsMapper())->map($soapResponse, null);
|
|
|
|
self::assertSame(0, $dto->nbBovins);
|
|
self::assertSame([], $dto->presumedExits);
|
|
}
|
|
|
|
private function makeExit(string $nationalNumber, ?string $exitDate): object
|
|
{
|
|
$node = new stdClass();
|
|
$node->Bovin = new stdClass();
|
|
$node->Bovin->CodePays = 'FR';
|
|
$node->Bovin->NumeroNational = $nationalNumber;
|
|
if (null !== $exitDate) {
|
|
$node->DateSortie = $exitDate;
|
|
}
|
|
|
|
return $node;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (doit échouer)**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php
|
|
```
|
|
Expected: classes introuvables.
|
|
|
|
- [ ] **Step 3: Créer `PresumedExitDto`**
|
|
|
|
Contenu complet de `src/Bovin/Dto/PresumedExitDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
use DateTimeImmutable;
|
|
|
|
final readonly class PresumedExitDto
|
|
{
|
|
public function __construct(
|
|
public ?BovinRef $bovin,
|
|
public ?DateTimeImmutable $exitDate,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Créer `PresumedExitsDto`**
|
|
|
|
Contenu complet de `src/Bovin/Dto/PresumedExitsDto.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Dto;
|
|
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final readonly class PresumedExitsDto
|
|
{
|
|
/**
|
|
* @param list<PresumedExitDto> $presumedExits
|
|
*/
|
|
public function __construct(
|
|
public StandardResponseDto $standardResponse,
|
|
public int $nbBovins,
|
|
public array $presumedExits,
|
|
public ?object $rawSoapResponse,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Créer `PresumedExitsMapper`**
|
|
|
|
Contenu complet de `src/Bovin/Mapper/PresumedExitsMapper.php` :
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Malio\EdnotifBundle\Bovin\Mapper;
|
|
|
|
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitDto;
|
|
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
|
|
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
|
|
|
|
final class PresumedExitsMapper
|
|
{
|
|
use BovinNodeMappingTrait;
|
|
|
|
public function map(object $soapResponse, ?object $unzippedMessage): PresumedExitsDto
|
|
{
|
|
$standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
|
|
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
|
|
|
|
$presumedExits = [];
|
|
|
|
if (is_object($unzippedMessage)) {
|
|
$exitsNode = $unzippedMessage->SortiesPresumees->SortiePresumee ?? null;
|
|
foreach ($this->normalizeToList($exitsNode) as $exitNode) {
|
|
if (!is_object($exitNode)) {
|
|
continue;
|
|
}
|
|
$presumedExits[] = new PresumedExitDto(
|
|
bovin: $this->mapBovinRef($exitNode->Bovin ?? null),
|
|
exitDate: $this->toNullableDate($exitNode->DateSortie ?? null),
|
|
);
|
|
}
|
|
}
|
|
|
|
return new PresumedExitsDto(
|
|
standardResponse: $standardResponse,
|
|
nbBovins: $nbBovins,
|
|
presumedExits: $presumedExits,
|
|
rawSoapResponse: $soapResponse,
|
|
);
|
|
}
|
|
|
|
private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
|
|
{
|
|
$result = (bool) ($standardResponseNode->Resultat ?? false);
|
|
$anomalyNode = $standardResponseNode->Anomalie ?? null;
|
|
$anomaly = null;
|
|
|
|
if (is_object($anomalyNode)) {
|
|
$anomaly = new AnomalyDto(
|
|
code: $this->toNullableString($anomalyNode->Code ?? null),
|
|
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
|
|
message: $this->toNullableString($anomalyNode->Message ?? null),
|
|
);
|
|
}
|
|
|
|
return new StandardResponseDto($result, $anomaly);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Vérifier les tests**
|
|
|
|
Run:
|
|
```
|
|
make test FILES=tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php
|
|
```
|
|
Expected: 2 tests PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Dto/PresumedExitDto.php src/Bovin/Dto/PresumedExitsDto.php src/Bovin/Mapper/PresumedExitsMapper.php tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php
|
|
git commit -m "feat : DTOs et mapper pour IpBGetSortiesPresumees"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 — Méthode `BovinApi::getPresumedExits`
|
|
|
|
**Files:**
|
|
- Modify: `src/Bovin/Api/BovinApiInterface.php`
|
|
- Modify: `src/Bovin/Api/BovinApi.php`
|
|
- Modify: `config/services.php`
|
|
|
|
- [ ] **Step 1: Ajouter la méthode à l'interface**
|
|
|
|
Dans `src/Bovin/Api/BovinApiInterface.php`, ajouter l'import `use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;` puis ajouter la méthode :
|
|
```php
|
|
public function getPresumedExits(): PresumedExitsDto;
|
|
```
|
|
|
|
- [ ] **Step 2: Étendre `BovinApi`**
|
|
|
|
Ajouter les imports :
|
|
```php
|
|
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
|
|
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
|
|
```
|
|
|
|
Ajouter la dépendance `private PresumedExitsMapper $presumedExitsMapper` au constructeur (après `$returnedDossiersMapper`).
|
|
|
|
Ajouter la méthode à la suite de `getReturnedDossiers` :
|
|
```php
|
|
public function getPresumedExits(): PresumedExitsDto
|
|
{
|
|
$token = $this->tokenProvider->getToken();
|
|
|
|
$payload = [
|
|
'JetonAuthentification' => $token,
|
|
'Exploitation' => [
|
|
'CodePays' => $this->exploitationCountryCode,
|
|
'NumeroExploitation' => $this->exploitationNumber,
|
|
],
|
|
];
|
|
|
|
try {
|
|
/** @var object $soapResponse */
|
|
$soapResponse = $this->businessClient->__soapCall('IpBGetSortiesPresumees', [$payload]);
|
|
} catch (SoapFault $soapFault) {
|
|
throw new RuntimeException('SOAP Fault on IpBGetSortiesPresumees: '.$soapFault->getMessage(), 0, $soapFault);
|
|
}
|
|
|
|
$this->assertSuccessfulResponse($soapResponse, 'IpBGetSortiesPresumees');
|
|
|
|
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
|
|
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
|
|
? $this->zipMessageDecoder->decode($messageZip)
|
|
: null;
|
|
|
|
return $this->presumedExitsMapper->map($soapResponse, $unzippedMessage);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Mettre à jour `config/services.php`**
|
|
|
|
Ajouter l'import `use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;`.
|
|
|
|
Juste après `$services->set(ReturnedDossiersMapper::class);`, ajouter :
|
|
```php
|
|
$services->set(PresumedExitsMapper::class);
|
|
```
|
|
|
|
Dans les args de `BovinApi`, ajouter `service(PresumedExitsMapper::class)` après `service(ReturnedDossiersMapper::class)`.
|
|
|
|
- [ ] **Step 4: Vérifier la suite complète**
|
|
|
|
Run:
|
|
```
|
|
make test
|
|
```
|
|
Expected: tous les tests passent (5 fichiers de tests, 11 tests au total).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
|
|
git commit -m "feat : expose IpBGetSortiesPresumees via BovinApi::getPresumedExits"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12 — Documentation utilisateur et PR
|
|
|
|
**But** : documenter l'API publique mise à jour dans le README et ouvrir la PR de la phase 1.
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
- Update: `docs/ws-catalog.md` (statuts)
|
|
|
|
- [ ] **Step 1: Mettre à jour `docs/ws-catalog.md`**
|
|
|
|
Dans la section `1. wsIpBNotif — Notifications IPG Bovin / Lecture`, passer le statut des trois opérations de `À faire` à `Implémenté` :
|
|
- `IpBGetInventaire` → Implémenté
|
|
- `IpBGetRetourDossiers` → Implémenté
|
|
- `IpBGetSortiesPresumees` → Implémenté
|
|
|
|
- [ ] **Step 2: Ajouter une section « Utilisation » au `README.md`**
|
|
|
|
Ajouter à la fin du fichier :
|
|
|
|
````markdown
|
|
## Utilisation
|
|
|
|
Le bundle expose `Malio\EdnotifBundle\Bovin\Api\BovinApiInterface`. Injection standard par autowiring.
|
|
|
|
```php
|
|
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
|
|
|
final class MyController
|
|
{
|
|
public function __construct(private BovinApiInterface $ednotif) {}
|
|
|
|
public function example(): void
|
|
{
|
|
// Dossier d'un bovin
|
|
$file = $this->ednotif->getAnimalFile('FR1234567890');
|
|
|
|
// Inventaire du cheptel à une date
|
|
$inventory = $this->ednotif->getInventory(
|
|
startDate: new \DateTimeImmutable('2026-01-01'),
|
|
includeEarTagStock: true,
|
|
);
|
|
|
|
// Retours de notifications depuis une date
|
|
$returns = $this->ednotif->getReturnedDossiers(new \DateTimeImmutable('2026-03-01'));
|
|
|
|
// Sorties présumées par l'IPG (flux de rapprochement)
|
|
$presumed = $this->ednotif->getPresumedExits();
|
|
}
|
|
}
|
|
```
|
|
|
|
Toutes les méthodes lèvent `Malio\EdnotifBundle\Shared\Exception\EdnotifException` en cas de `Resultat=false` côté EDNOTIF.
|
|
````
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
Run:
|
|
```
|
|
git add README.md docs/ws-catalog.md
|
|
git commit -m "docs : documente les 3 nouvelles lectures bovin"
|
|
```
|
|
|
|
- [ ] **Step 4: Pousser la branche et ouvrir la PR**
|
|
|
|
Run:
|
|
```
|
|
git push -u origin feat/bovin-reads
|
|
gh pr create --base develop --title "feat : lectures bovin (inventaire, retours, sorties présumées)" --body "$(cat <<'EOF'
|
|
## Summary
|
|
- Ajoute `getInventory`, `getReturnedDossiers`, `getPresumedExits` au `BovinApi`
|
|
- Introduit `ZipMessageDecoder` (base64 → ZIP → XML → stdClass)
|
|
- Factorise les helpers de mapping bovin dans `BovinNodeMappingTrait`
|
|
- Bootstrap de PHPUnit 12 (infra de tests inexistante avant cette PR)
|
|
|
|
## Test plan
|
|
- [ ] `make test` vert (≥ 11 tests unitaires)
|
|
- [ ] Smoke test dans le projet consommateur : appeler chaque nouvelle méthode et vérifier que la réponse est cohérente avec l'IPG
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist finale
|
|
|
|
Avant de marquer la phase 1 comme terminée :
|
|
|
|
- [ ] Tous les tests passent : `make test`
|
|
- [ ] Le consommateur peut appeler les 3 nouvelles méthodes sans erreur DI
|
|
- [ ] Un smoke test réel (via le projet consommateur, si possible) confirme la structure attendue pour au moins `getInventory`
|
|
- [ ] PR créée et revue
|