# 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