From dd87ea92169babed2e838c90f3a5857beb132769 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 21 Apr 2026 08:26:21 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation=20Phase?= =?UTF-8?q?=201=20(lectures=20bovin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-20-bovin-reads.md | 1978 +++++++++++++++++ 1 file changed, 1978 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-bovin-reads.md diff --git a/docs/superpowers/plans/2026-04-20-bovin-reads.md b/docs/superpowers/plans/2026-04-20-bovin-reads.md new file mode 100644 index 0000000..74458e8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-bovin-reads.md @@ -0,0 +1,1978 @@ +# 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=` 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 + + + + + tests/Unit + + + + + src + + + +``` + +- [ ] **Step 2: Créer `tests/bootstrap.php`** + +Contenu complet : +```php + (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 +' + .'' + .'2026-01-01' + .'F' + .''; + + $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 +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 +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 */ + 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 +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 +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 + $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 +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 +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 + $animals + * @param list $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 +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 +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 +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 + $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 +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 +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 + $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 +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