From f757822f366bd5f55588aa89e0ec5a5d0e811f1f Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 21 Apr 2026 08:14:37 +0000 Subject: [PATCH] [#ED-1] Ajout des API de lecture bovin (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: https://gitea.malio.fr/MALIO-DEV/ednotif-bundle/pulls/2 Co-authored-by: tristan Co-committed-by: tristan --- README.md | 33 + config/services.php | 36 +- .../plans/2026-04-20-bovin-reads.md | 1978 +++++++++++++++++ docs/ws-catalog.md | 188 ++ makefile | 7 +- phpunit.xml.dist | 19 + src/Bovin/Api/BovinApi.php | 140 +- src/Bovin/Api/BovinApiInterface.php | 14 + src/Bovin/Dto/AnimalSummaryDto.php | 16 + src/Bovin/Dto/EarTagSeriesDto.php | 12 + src/Bovin/Dto/InventoryDto.php | 26 + src/Bovin/Dto/PresumedExitDto.php | 15 + src/Bovin/Dto/PresumedExitsDto.php | 20 + src/Bovin/Dto/ReturnedDossiersDto.php | 22 + src/Bovin/Mapper/AnimalFileMapper.php | 201 +- src/Bovin/Mapper/AnimalSummaryMapper.php | 32 + src/Bovin/Mapper/BovinNodeMappingTrait.php | 168 ++ src/Bovin/Mapper/InventoryMapper.php | 68 + src/Bovin/Mapper/PresumedExitsMapper.php | 46 + src/Bovin/Mapper/ReturnedDossiersMapper.php | 50 + src/Shared/Mapper/StandardResponseMapper.php | 53 + src/Shared/Soap/ZipMessageDecoder.php | 87 + tests/Unit/.gitkeep | 0 .../Bovin/Mapper/AnimalSummaryMapperTest.php | 70 + .../Unit/Bovin/Mapper/InventoryMapperTest.php | 100 + .../Bovin/Mapper/PresumedExitsMapperTest.php | 72 + .../Mapper/ReturnedDossiersMapperTest.php | 67 + .../Shared/Soap/ZipMessageDecoderTest.php | 79 + tests/bootstrap.php | 5 + 29 files changed, 3416 insertions(+), 208 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-20-bovin-reads.md create mode 100644 docs/ws-catalog.md create mode 100644 phpunit.xml.dist create mode 100644 src/Bovin/Dto/AnimalSummaryDto.php create mode 100644 src/Bovin/Dto/EarTagSeriesDto.php create mode 100644 src/Bovin/Dto/InventoryDto.php create mode 100644 src/Bovin/Dto/PresumedExitDto.php create mode 100644 src/Bovin/Dto/PresumedExitsDto.php create mode 100644 src/Bovin/Dto/ReturnedDossiersDto.php create mode 100644 src/Bovin/Mapper/AnimalSummaryMapper.php create mode 100644 src/Bovin/Mapper/BovinNodeMappingTrait.php create mode 100644 src/Bovin/Mapper/InventoryMapper.php create mode 100644 src/Bovin/Mapper/PresumedExitsMapper.php create mode 100644 src/Bovin/Mapper/ReturnedDossiersMapper.php create mode 100644 src/Shared/Mapper/StandardResponseMapper.php create mode 100644 src/Shared/Soap/ZipMessageDecoder.php create mode 100644 tests/Unit/.gitkeep create mode 100644 tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php create mode 100644 tests/Unit/Bovin/Mapper/InventoryMapperTest.php create mode 100644 tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php create mode 100644 tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php create mode 100644 tests/Unit/Shared/Soap/ZipMessageDecoderTest.php create mode 100644 tests/bootstrap.php diff --git a/README.md b/README.md index c313705..f16e6d7 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,36 @@ Dans le docker-composer.yaml volumes: - ../ednotif-bundle:/var/www/html/ednotif-bundle ``` + +## 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. diff --git a/config/services.php b/config/services.php index 4b95bf7..36dd385 100644 --- a/config/services.php +++ b/config/services.php @@ -6,7 +6,13 @@ use Malio\EdnotifBundle\Auth\TokenProvider; use Malio\EdnotifBundle\Bovin\Api\BovinApi; use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface; use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper; +use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper; +use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper; +use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper; +use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper; +use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper; use Malio\EdnotifBundle\Shared\Soap\SoapClientFactory; +use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -22,6 +28,8 @@ return static function (ContainerConfigurator $container): void { ->arg('$soapOptions', '%ednotif.soap_options%') ; + $services->set(StandardResponseMapper::class); + $services->set('ednotif.soap.guichet', SoapClient::class) ->factory([service(SoapClientFactory::class), 'create']) ->args(['%ednotif.guichet_wsdl%']) @@ -32,7 +40,29 @@ return static function (ContainerConfigurator $container): void { ->args(['%ednotif.metier_wsdl%']) ; - $services->set(AnimalFileMapper::class); + $services->set(AnimalFileMapper::class)->args([service(StandardResponseMapper::class)]); + + $services->set(ZipMessageDecoder::class); + $services->set(AnimalSummaryMapper::class); + $services->set(InventoryMapper::class) + ->args([ + service(AnimalSummaryMapper::class), + service(StandardResponseMapper::class), + ]) + ; + + $services->set(ReturnedDossiersMapper::class) + ->args([ + service(AnimalSummaryMapper::class), + service(StandardResponseMapper::class), + ]) + ; + + $services->set(PresumedExitsMapper::class) + ->args([ + service(StandardResponseMapper::class), + ]) + ; $services->set(TokenProvider::class) ->args([ @@ -52,6 +82,10 @@ return static function (ContainerConfigurator $container): void { service(TokenProvider::class), service('ednotif.soap.business'), service(AnimalFileMapper::class), + service(InventoryMapper::class), + service(ReturnedDossiersMapper::class), + service(PresumedExitsMapper::class), + service(ZipMessageDecoder::class), '%ednotif.exploitation_country_code%', '%ednotif.exploitation_number%', ]) 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 diff --git a/docs/ws-catalog.md b/docs/ws-catalog.md new file mode 100644 index 0000000..3f7713b --- /dev/null +++ b/docs/ws-catalog.md @@ -0,0 +1,188 @@ +# Catalogue des WebServices EDNOTIF + +Inventaire des opérations exposées par les WSDL embarqués dans +`resources/ednotif-ws/`, avec le statut d'implémentation et une +recommandation de priorisation. + +## Légende statut + +- **Implémenté** — opération couverte par le bundle +- **À faire** — opération pertinente non encore implémentée +- **Optionnel** — opération hors périmètre probable, à confirmer selon le consommateur + +--- + +## 1. wsIpBNotif — Notifications IPG Bovin + +WS métier principal : déclarations réglementaires d'un cheptel bovin auprès de l'IPG. + +### Lecture + +| Opération | Statut | Description probable | +|---|---|---| +| `IpBGetDossierAnimal` | Implémenté | Dossier complet d'un bovin (identifications, mouvements, parents…) | +| `IpBGetInventaire` | Implémenté | Inventaire des animaux présents sur l'exploitation | +| `IpBGetRetourDossiers` | Implémenté | Retours de traitement des notifications envoyées | +| `IpBGetSortiesPresumees` | Implémenté | Animaux sortis selon l'IPG mais non déclarés par l'éleveur | + +### Écriture + +| Opération | Statut | Description probable | +|---|---|---| +| `IpBCreateEntree` | À faire | Déclaration d'entrée d'un bovin sur l'exploitation | +| `IpBCreateSortie` | À faire | Déclaration de sortie (vente, mort, abattage…) | +| `IpBCreateNaissance` | À faire | Déclaration de naissance | +| `IpBCreateMortNe` | À faire | Déclaration de mort-né | +| `IpBCreateAnimalEchange` | À faire | Échange intra-UE | +| `IpBCreateAnimalImporte` | À faire | Import pays tiers | +| `IpBCreateAvisAnimalImporte` | À faire | Avis d'import | +| `IpBCreateRebouclage` | À faire | Rebouclage / remplacement de boucle | +| `IpBCreateCommandeBoucles` | À faire | Commande de boucles | +| `IpBCreateInsemination` | À faire | Déclaration d'insémination | + +--- + +## 2. wsDmB\* — Déclarations de Mouvement Bovin + +Famille orientée gestion des mouvements / transporteurs. + +### wsDmBConsultation +- `DmBConsultationGetListeDonneesIT` — consultation de données (IT = identifiants traces ?) +- `DmBConsultationGetListeStatutDeplacement` — statuts de déplacement + +### wsDmBGestion +- `DmBGestionCreateDroitAccesListeAnimalActeur` — gestion droits d'accès +- `DmBGestionCreateListeICA` — création listes ICA (information chaîne alimentaire) + +### wsDmBListe +- `DmBListeCreateListeBovins` / `DmBListeGetListeBovins` — listes de bovins (lots) + +### wsDmBTransport +- `DmBTransportCreateChargement` — chargement camion +- `DmBTransportCreateDechargement` — déchargement camion + +--- + +## 3. wsMdBEdel — Maîtrise des Données Bovin (Edel) + +Consultation en lecture seule : génétique, lactation, IA, races. + +| Opération | Rôle | +|---|---| +| `MdBGetDonneesGenetiquesAnimales` | Données génétiques d'animaux | +| `MdBGetDonneesMalesPublics` | Catalogue mâles reproducteurs publics | +| `MdBGetDonneesOrganismeHabilite` | Référentiel organismes habilités | +| `MdBGetDonneesOrganismeTiers` | Référentiel organismes tiers | +| `ClBGetDonneesCL` | Contrôle laitier | +| `CpBGetDonneesCPB` | Contrôle de performances bouchères | +| `IaBGetDonneesIA` | Données d'insémination | +| `OsBGetDonneesRAC` | Données race (RAC = race certifiée ?) | +| `TkBGetDonneesTE` | Transfert embryonnaire | +| `VaBGetDonneesCPV` | Contrôle performances veaux (?) | + +--- + +## 4. wsMdCEdel — Maîtrise des Données Caprin + +Équivalent caprin : CRUD + reproduction + lactation. 15 opérations (`MdCCreate*` pour l'écriture, `MdCGet*` avec variantes `MAJ` pour les deltas). + +| Groupe | Opérations | +|---|---| +| Caprin | `MdCCreateCaprin`, `MdCGetDonneesCaprin`, `MdCGetDonneesCaprinMAJ` | +| Reproduction | `MdCCreateSaillie`, `MdCCreateFinGestation`, `MdCGetFinGestation[/MAJ]`, `MdCGetEvenementReproduction[/MAJ]` | +| Mouvement | `MdCCreateMouvement` | +| Contrôle laitier | `MdCGetCLDonneesBrutes[/MAJ]`, `MdCGetCLDonneesElaborees[/MAJ]` | +| Contrats | `MdCGetContratsExploitation` | + +--- + +## 5. wsIpEdel — Identification Pérenne Edel + +- `IpGetDonneesExploitation` — données descriptives de l'exploitation + +--- + +## 6. wsMrAde — Échanges ICAR + +Conforme aux standards ICAR (flux laitiers internationaux). + +- `GetHerdList` +- `UpdateAnimal` +- `UpdateDevice` +- `UpdateLivestockLocation` +- `UpdateMilkingResults` + +--- + +## 7. WsAnnuaire — Annuaire Guichet + +Métadonnées techniques du guichet (pas du métier). + +- `tkGetServices` — liste des WS disponibles +- `tkGetVersionsService` — versions d'un WS +- `tkGetOperationsServiceVersion` — opérations d'une version +- `tkGetUrl` — URL d'un service + +--- + +## Recommandation de priorisation + +Proposition d'ordre, à valider selon le périmètre réel du consommateur. + +### Phase 1 — Compléter le bovin (priorité haute) +Continuer sur **wsIpBNotif**, en commençant par la **lecture** : + +1. `IpBGetInventaire` — donne immédiatement la liste du cheptel, utile pour toute UI +2. `IpBGetRetourDossiers` — indispensable pour savoir si les notifs passent côté IPG +3. `IpBGetSortiesPresumees` — flux de rapprochement éleveur ↔ IPG + +**Raison** : le dossier animal seul est peu utile sans l'inventaire qui permet de savoir *pour quels animaux* appeler `getAnimalFile`. Et sans `RetourDossiers`, toute écriture future est aveugle. + +### Phase 2 — Écriture bovin (notifications obligatoires) +Implémenter les déclarations **dans l'ordre des cycles de vie d'un animal** : + +4. `IpBCreateNaissance` +5. `IpBCreateEntree` / `IpBCreateSortie` +6. `IpBCreateMortNe` +7. `IpBCreateRebouclage` / `IpBCreateCommandeBoucles` +8. `IpBCreateAnimalEchange` / `IpBCreateAnimalImporte` / `IpBCreateAvisAnimalImporte` (si imports/échanges dans le périmètre) +9. `IpBCreateInsemination` (si non couvert par un autre outil) + +### Phase 3 — Mouvements / transport +Si le consommateur gère du transport ou des lots : + +10. `wsDmBListe` (lots bovins) +11. `wsDmBTransport` (chargement/déchargement) +12. `wsDmBConsultation` et `wsDmBGestion` selon besoin + +### Phase 4 — Référentiels génétiques (optionnel) +Si le consommateur fait de la sélection / génétique : + +13. `wsMdBEdel` — lectures ponctuelles, ne justifient une implémentation que s'il y a un usage métier concret + +### Phase 5 — Caprin / ICAR (optionnel) +À activer uniquement si multi-espèces ou conformité ICAR requise. + +### Hors priorité +- **wsIpEdel** : 1 op, à implémenter *en passant* si besoin ponctuel +- **WsAnnuaire** : utile pour du diagnostic / supervision, pas pour le métier + +--- + +## Découpage structurel proposé + +Pour garder un code cohérent, reproduire le pattern existant (`src/Bovin/`) par domaine : + +``` +src/ +├── Auth/ (existant) +├── Bovin/ (IpBNotif + IpEdel exploitation) +├── Mouvement/ (DmB*) +├── Genetique/ (MdBEdel, optionnel) +├── Caprin/ (MdCEdel, optionnel) +├── Icar/ (MrAde, optionnel) +├── Annuaire/ (WsAnnuaire, optionnel) +└── Shared/ (existant) +``` + +Chaque domaine expose une `*ApiInterface` publique + une implémentation `readonly`, avec ses DTOs et mappers dédiés. Le `TokenProvider` et `SoapClientFactory` restent partagés via `Shared/`. diff --git a/makefile b/makefile index e1db21b..3592ae5 100644 --- a/makefile +++ b/makefile @@ -86,4 +86,9 @@ php-cs-fixer-allow-risky: $(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES) wait: - sleep 10 \ No newline at end of file + sleep 10 + +# Lance la suite PHPUnit. Usage : make test (tout) +# make test FILES= (un fichier/dossier) +test: + $(EXEC_PHP) php vendor/bin/phpunit $(FILES) \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..08d857e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/src/Bovin/Api/BovinApi.php b/src/Bovin/Api/BovinApi.php index 9b9ff67..8fda161 100644 --- a/src/Bovin/Api/BovinApi.php +++ b/src/Bovin/Api/BovinApi.php @@ -4,10 +4,18 @@ declare(strict_types=1); namespace Malio\EdnotifBundle\Bovin\Api; +use DateTimeInterface; use Malio\EdnotifBundle\Auth\TokenProvider; use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto; +use Malio\EdnotifBundle\Bovin\Dto\InventoryDto; +use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto; +use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto; use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper; +use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper; +use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper; +use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper; use Malio\EdnotifBundle\Shared\Exception\EdnotifException; +use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder; use RuntimeException; use SoapClient; use SoapFault; @@ -18,6 +26,10 @@ final readonly class BovinApi implements BovinApiInterface private TokenProvider $tokenProvider, private SoapClient $businessClient, private AnimalFileMapper $bovinDossierMapper, + private InventoryMapper $inventoryMapper, + private ReturnedDossiersMapper $returnedDossiersMapper, + private PresumedExitsMapper $presumedExitsMapper, + private ZipMessageDecoder $zipMessageDecoder, private string $exploitationCountryCode, private string $exploitationNumber, ) {} @@ -45,20 +57,122 @@ final readonly class BovinApi implements BovinApiInterface throw new RuntimeException('SOAP Fault on IpBGetDossierAnimal: '.$soapFault->getMessage(), 0, $soapFault); } - // Throw uniquement si Resultat=false (erreur métier) - $standardResponseNode = $soapResponse->ReponseStandard ?? null; - $isOk = is_object($standardResponseNode) && (($standardResponseNode->Resultat ?? false) === true); - - if (!$isOk) { - $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 ?? 'EDNOTIF error') - ); - } + $this->assertSuccessfulResponse($soapResponse, 'IpBGetDossierAnimal'); return $this->bovinDossierMapper->map($soapResponse); } + + public function getInventory( + DateTimeInterface $startDate, + ?DateTimeInterface $endDate = null, + bool $includeEarTagStock = false, + ): InventoryDto { + $token = $this->tokenProvider->getToken(); + + $payload = [ + 'JetonAuthentification' => $token, + 'Exploitation' => [ + 'CodePays' => $this->exploitationCountryCode, + 'NumeroExploitation' => $this->exploitationNumber, + ], + 'DateDebut' => $startDate->format('Y-m-d'), + 'StockBoucles' => $includeEarTagStock, + ]; + if (null !== $endDate) { + $payload['DateFin'] = $endDate->format('Y-m-d'); + } + + try { + /** @var object $soapResponse */ + $soapResponse = $this->businessClient->__soapCall('IpBGetInventaire', [$payload]); + } catch (SoapFault $soapFault) { + throw new RuntimeException('SOAP Fault on IpBGetInventaire: '.$soapFault->getMessage(), 0, $soapFault); + } + + $this->assertSuccessfulResponse($soapResponse, 'IpBGetInventaire'); + + $messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null; + $unzippedMessage = is_string($messageZip) && '' !== $messageZip + ? $this->zipMessageDecoder->decode($messageZip) + : null; + + return $this->inventoryMapper->map($soapResponse, $unzippedMessage); + } + + 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); + } + + 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); + } + + 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') + ); + } } diff --git a/src/Bovin/Api/BovinApiInterface.php b/src/Bovin/Api/BovinApiInterface.php index f162fda..e8131c8 100644 --- a/src/Bovin/Api/BovinApiInterface.php +++ b/src/Bovin/Api/BovinApiInterface.php @@ -4,9 +4,23 @@ declare(strict_types=1); namespace Malio\EdnotifBundle\Bovin\Api; +use DateTimeInterface; use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto; +use Malio\EdnotifBundle\Bovin\Dto\InventoryDto; +use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto; +use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto; interface BovinApiInterface { public function getAnimalFile(string $nationalNumber, string $countryCode = 'FR'): AnimalFileDto; + + public function getInventory( + DateTimeInterface $startDate, + ?DateTimeInterface $endDate = null, + bool $includeEarTagStock = false, + ): InventoryDto; + + public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto; + + public function getPresumedExits(): PresumedExitsDto; } diff --git a/src/Bovin/Dto/AnimalSummaryDto.php b/src/Bovin/Dto/AnimalSummaryDto.php new file mode 100644 index 0000000..b6a81d9 --- /dev/null +++ b/src/Bovin/Dto/AnimalSummaryDto.php @@ -0,0 +1,16 @@ + $presencePeriods + */ + public function __construct( + public ?BovinIdentificationDto $identification, + public array $presencePeriods, + ) {} +} diff --git a/src/Bovin/Dto/EarTagSeriesDto.php b/src/Bovin/Dto/EarTagSeriesDto.php new file mode 100644 index 0000000..bbadc7e --- /dev/null +++ b/src/Bovin/Dto/EarTagSeriesDto.php @@ -0,0 +1,12 @@ + $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, + ) {} +} diff --git a/src/Bovin/Dto/PresumedExitDto.php b/src/Bovin/Dto/PresumedExitDto.php new file mode 100644 index 0000000..638434e --- /dev/null +++ b/src/Bovin/Dto/PresumedExitDto.php @@ -0,0 +1,15 @@ + $presumedExits + */ + public function __construct( + public StandardResponseDto $standardResponse, + public int $nbBovins, + public array $presumedExits, + public ?object $rawSoapResponse, + ) {} +} diff --git a/src/Bovin/Dto/ReturnedDossiersDto.php b/src/Bovin/Dto/ReturnedDossiersDto.php new file mode 100644 index 0000000..1822847 --- /dev/null +++ b/src/Bovin/Dto/ReturnedDossiersDto.php @@ -0,0 +1,22 @@ + $animals + */ + public function __construct( + public StandardResponseDto $standardResponse, + public int $nbBovins, + public ?DateTimeImmutable $startDate, + public array $animals, + public ?object $rawSoapResponse, + ) {} +} diff --git a/src/Bovin/Mapper/AnimalFileMapper.php b/src/Bovin/Mapper/AnimalFileMapper.php index 231762c..c0c6646 100644 --- a/src/Bovin/Mapper/AnimalFileMapper.php +++ b/src/Bovin/Mapper/AnimalFileMapper.php @@ -4,24 +4,20 @@ declare(strict_types=1); namespace Malio\EdnotifBundle\Bovin\Mapper; -use DateTimeImmutable; use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto; -use Malio\EdnotifBundle\Bovin\Dto\BovinIdentificationDto; -use Malio\EdnotifBundle\Bovin\Dto\BovinRef; -use Malio\EdnotifBundle\Bovin\Dto\DateValueDto; -use Malio\EdnotifBundle\Bovin\Dto\ExploitationRef; -use Malio\EdnotifBundle\Bovin\Dto\MovementDto; -use Malio\EdnotifBundle\Bovin\Dto\ParentInfoDto; -use Malio\EdnotifBundle\Bovin\Dto\PresencePeriodDto; -use Malio\EdnotifBundle\Shared\Dto\AnomalyDto; -use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto; -use Throwable; +use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper; final class AnimalFileMapper { + use BovinNodeMappingTrait; + + public function __construct( + private readonly StandardResponseMapper $standardResponseMapper, + ) {} + public function map(object $soapResponse): AnimalFileDto { - $standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null); + $standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null); $specificResponseNode = $soapResponse->ReponseSpecifique ?? null; $bovinNode = is_object($specificResponseNode) ? ($specificResponseNode->Bovin ?? null) : null; @@ -51,185 +47,4 @@ final class AnimalFileMapper 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); - } - - private function mapIdentification(object $identificationNode): BovinIdentificationDto - { - $bovinRef = $this->mapBovinRef($identificationNode->Bovin ?? null); - - $birthDate = null; - $birthDateNode = $identificationNode->DateNaissance ?? null; - if (is_object($birthDateNode)) { - $birthDate = new DateValueDto( - date: $this->toNullableDate($birthDateNode->Date ?? null), - completenessFlag: $this->toNullableString($birthDateNode->TemoinCompletude ?? null), - ); - } - - $motherCarrier = $this->mapParentInfo($identificationNode->MerePorteuse ?? null); - $fatherIpg = $this->mapParentInfo($identificationNode->PereIPG ?? null); - $birthExploitation = $this->mapExploitationRef($identificationNode->ExploitationNaissance ?? null); - - return new BovinIdentificationDto( - bovin: $bovinRef, - 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: $motherCarrier, - fatherIpg: $fatherIpg, - birthExploitation: $birthExploitation, - ); - } - - private function mapPresencePeriod(object $presencePeriodNode): PresencePeriodDto - { - $entryNode = $presencePeriodNode->Entree ?? null; - $exitNode = $presencePeriodNode->Sortie ?? null; - - $entryMovement = is_object($entryNode) ? $this->mapMovement($entryNode, 'entry') : null; - $exitMovement = is_object($exitNode) ? $this->mapMovement($exitNode, 'exit') : null; - - return new PresencePeriodDto( - entry: $entryMovement, - exit: $exitMovement, - ); - } - - private function mapMovement(object $movementNode, string $direction): MovementDto - { - $dateValue = null; - $causeValue = null; - - if ('entry' === $direction) { - // SOAP: DateEntree / CauseEntree - $dateValue = $movementNode->DateEntree ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null)); - $causeValue = $movementNode->CauseEntree ?? null; - } else { - // SOAP (souvent): DateSortie / CauseSortie - $dateValue = $movementNode->DateSortie ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null)); - $causeValue = $movementNode->CauseSortie ?? null; - } - - $exploitationRef = $this->mapExploitationRef($movementNode->Exploitation ?? null); - - return new MovementDto( - date: $this->toNullableDate($dateValue), - cause: $this->toNullableString($causeValue), - exploitation: $exploitationRef, - ); - } - - private function mapParentInfo(mixed $parentNode): ?ParentInfoDto - { - if (!is_object($parentNode)) { - return null; - } - - $bovinRef = $this->mapBovinRef($parentNode->Bovin ?? null); - - return new ParentInfoDto( - bovin: $bovinRef, - breedType: $this->toNullableString($parentNode->TypeRacial ?? null), - ); - } - - private 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), - ); - } - - private 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 */ - private function normalizeToList(mixed $value): array - { - if (null === $value) { - return []; - } - - return is_array($value) ? $value : [$value]; - } - - private function toNullableString(mixed $value): ?string - { - if (null === $value) { - return null; - } - $stringValue = trim((string) $value); - - return '' === $stringValue ? null : $stringValue; - } - - private 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; - } - - private function toNullableBool(mixed $value): ?bool - { - if (null === $value) { - return null; - } - - return (bool) $value; - } - - private function toNullableDate(mixed $value): ?DateTimeImmutable - { - if (!is_string($value) || '' === trim($value)) { - return null; - } - - try { - return new DateTimeImmutable($value); - } catch (Throwable) { - return null; - } - } } diff --git a/src/Bovin/Mapper/AnimalSummaryMapper.php b/src/Bovin/Mapper/AnimalSummaryMapper.php new file mode 100644 index 0000000..7bedb34 --- /dev/null +++ b/src/Bovin/Mapper/AnimalSummaryMapper.php @@ -0,0 +1,32 @@ +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, + ); + } +} diff --git a/src/Bovin/Mapper/BovinNodeMappingTrait.php b/src/Bovin/Mapper/BovinNodeMappingTrait.php new file mode 100644 index 0000000..5c48b87 --- /dev/null +++ b/src/Bovin/Mapper/BovinNodeMappingTrait.php @@ -0,0 +1,168 @@ +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; + } + } +} diff --git a/src/Bovin/Mapper/InventoryMapper.php b/src/Bovin/Mapper/InventoryMapper.php new file mode 100644 index 0000000..3787ec1 --- /dev/null +++ b/src/Bovin/Mapper/InventoryMapper.php @@ -0,0 +1,68 @@ +standardResponseMapper->map($soapResponse->ReponseStandard ?? null); + + $nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0; + + $startDate = null; + $endDate = null; + $includesEarTagStock = null; + $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 = $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, + ); + } +} diff --git a/src/Bovin/Mapper/PresumedExitsMapper.php b/src/Bovin/Mapper/PresumedExitsMapper.php new file mode 100644 index 0000000..babaacb --- /dev/null +++ b/src/Bovin/Mapper/PresumedExitsMapper.php @@ -0,0 +1,46 @@ +standardResponseMapper->map($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, + ); + } +} diff --git a/src/Bovin/Mapper/ReturnedDossiersMapper.php b/src/Bovin/Mapper/ReturnedDossiersMapper.php new file mode 100644 index 0000000..29b2af5 --- /dev/null +++ b/src/Bovin/Mapper/ReturnedDossiersMapper.php @@ -0,0 +1,50 @@ +standardResponseMapper->map($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, + ); + } +} diff --git a/src/Shared/Mapper/StandardResponseMapper.php b/src/Shared/Mapper/StandardResponseMapper.php new file mode 100644 index 0000000..6c1b3f5 --- /dev/null +++ b/src/Shared/Mapper/StandardResponseMapper.php @@ -0,0 +1,53 @@ +Resultat ?? false); + $anomalyNode = is_object($standardResponseNode) ? ($standardResponseNode->Anomalie ?? null) : 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); + } + + private function toNullableString(mixed $value): ?string + { + if (null === $value) { + return null; + } + $stringValue = trim((string) $value); + + return '' === $stringValue ? null : $stringValue; + } + + private 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; + } +} diff --git a/src/Shared/Soap/ZipMessageDecoder.php b/src/Shared/Soap/ZipMessageDecoder.php new file mode 100644 index 0000000..332b196 --- /dev/null +++ b/src/Shared/Soap/ZipMessageDecoder.php @@ -0,0 +1,87 @@ +readFirstEntry($tempFile); + } finally { + @unlink($tempFile); + } + + $previous = libxml_use_internal_errors(true); + + try { + $simpleXml = simplexml_load_string($xml); + } finally { + libxml_clear_errors(); + 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; + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php b/tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php new file mode 100644 index 0000000..e5e1da3 --- /dev/null +++ b/tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/tests/Unit/Bovin/Mapper/InventoryMapperTest.php b/tests/Unit/Bovin/Mapper/InventoryMapperTest.php new file mode 100644 index 0000000..76dac82 --- /dev/null +++ b/tests/Unit/Bovin/Mapper/InventoryMapperTest.php @@ -0,0 +1,100 @@ +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(), new StandardResponseMapper()); + + $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; + } +} diff --git a/tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php b/tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php new file mode 100644 index 0000000..7f08f01 --- /dev/null +++ b/tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php @@ -0,0 +1,72 @@ +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(new StandardResponseMapper())->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(new StandardResponseMapper())->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; + } +} diff --git a/tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php b/tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php new file mode 100644 index 0000000..1158bd2 --- /dev/null +++ b/tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php @@ -0,0 +1,67 @@ +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(), new StandardResponseMapper()); + + $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); + } +} diff --git a/tests/Unit/Shared/Soap/ZipMessageDecoderTest.php b/tests/Unit/Shared/Soap/ZipMessageDecoderTest.php new file mode 100644 index 0000000..4a7b33c --- /dev/null +++ b/tests/Unit/Shared/Soap/ZipMessageDecoderTest.php @@ -0,0 +1,79 @@ +' + .'' + .'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 testDecodeProducesArrayForMultiChildNodes(): void + { + $xml = '' + .'' + .'' + .'F' + .'M' + .'' + .''; + + $decoded = new ZipMessageDecoder()->decode($this->makeZipBinary('message.xml', $xml)); + + self::assertIsArray($decoded->Bovins->Bovin); + self::assertCount(2, $decoded->Bovins->Bovin); + self::assertSame('F', (string) $decoded->Bovins->Bovin[0]->IdentiteBovin->Sexe); + self::assertSame('M', (string) $decoded->Bovins->Bovin[1]->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; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e9dd915 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +