Files
ednotif-bundle/docs/superpowers/plans/2026-04-20-bovin-reads.md
tristan f757822f36
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 39s
[#ED-1] Ajout des API de lecture bovin (#2)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #2
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-21 08:14:37 +00:00

63 KiB

Phase 1 — Lectures bovin complémentaires

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ajouter au BovinApi les trois opérations de lecture manquantes (IpBGetInventaire, IpBGetRetourDossiers, IpBGetSortiesPresumees) exposées par wsIpBNotif.

Architecture: Ces trois opérations retournent un champ ReponseSpecifique.MessageZip (base64 décodé par ext-soap ⇒ archive ZIP contenant un unique fichier XML). On introduit un composant partagé ZipMessageDecoder qui transforme le binaire en stdClass (via simplexml_load_string + roundtrip json_encode/decode), ce qui permet aux nouveaux mappers de réutiliser les helpers existants de AnimalFileMapper via un trait. Chaque opération obtient son couple DTO + mapper dédié, BovinApi expose trois nouvelles méthodes et BovinApiInterface est étendue en conséquence.

Tech Stack: PHP 8.4, ext-soap, ext-zip, Symfony 8, PHPUnit 12.


File Structure

À créer

src/Shared/Soap/ZipMessageDecoder.php            décodeur binaire ZIP → stdClass
src/Bovin/Mapper/BovinNodeMappingTrait.php       helpers de mapping partagés
src/Bovin/Dto/AnimalSummaryDto.php               bovin simplifié (identification + présences)
src/Bovin/Dto/InventoryDto.php                   réponse IpBGetInventaire
src/Bovin/Dto/EarTagSeriesDto.php                série de boucles (StockBoucles)
src/Bovin/Dto/ReturnedDossiersDto.php            réponse IpBGetRetourDossiers
src/Bovin/Dto/PresumedExitDto.php                une sortie présumée
src/Bovin/Dto/PresumedExitsDto.php               réponse IpBGetSortiesPresumees
src/Bovin/Mapper/AnimalSummaryMapper.php         mapping noeud Bovin (IdentiteBovin + PeriodesPresences)
src/Bovin/Mapper/InventoryMapper.php
src/Bovin/Mapper/ReturnedDossiersMapper.php
src/Bovin/Mapper/PresumedExitsMapper.php
phpunit.xml.dist                                 config PHPUnit 12
tests/bootstrap.php                              autoload
tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
tests/Unit/Bovin/Mapper/InventoryMapperTest.php
tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php

À modifier

src/Bovin/Api/BovinApiInterface.php              +3 méthodes
src/Bovin/Api/BovinApi.php                       +3 méthodes + nouvelles dépendances
src/Bovin/Mapper/AnimalFileMapper.php            utilise le trait partagé
config/services.php                              enregistre les nouveaux services
makefile                                         ajoute une cible `test`

Conventions de test

Exécution : depuis la racine du repo.

make test                  # nouvelle cible : run PHPUnit dans le container

Si le container n'est pas démarré : make start puis make install d'abord.

Alternative sans docker (si PHP 8.4 est dispo localement) : vendor/bin/phpunit --colors=always.

Les instructions Run ci-dessous utilisent make test FILES=<chemin> pour cibler un fichier (la cible sera définie en Task 2).


Task 1 — Commit du catalogue des WS

But : mettre au propre docs/ws-catalog.md (untracked hérité de la PR précédente) avant de commencer le code.

Files:

  • Add: docs/ws-catalog.md

  • Step 1: Vérifier que le fichier existe et est bien untracked

Run:

git status --short docs/ws-catalog.md

Expected: ?? docs/ws-catalog.md

  • Step 2: Commit

Run:

git add docs/ws-catalog.md
git commit -m "docs : catalogue des WS EDNOTIF et recommandation de priorisation"

Expected: commit créé sur feat/bovin-reads.


Task 2 — Bootstrap PHPUnit

But : poser l'infra de tests inexistante (pas de tests/, pas de phpunit.xml.dist à ce jour).

Files:

  • Create: phpunit.xml.dist

  • Create: tests/bootstrap.php

  • Create: tests/Unit/.gitkeep

  • Modify: makefile

  • Step 1: Créer phpunit.xml.dist

Contenu complet :

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true"
         failOnWarning="true"
         failOnRisky="true"
         cacheDirectory=".phpunit.cache">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>
  • Step 2: Créer tests/bootstrap.php

Contenu complet :

<?php

declare(strict_types=1);

require __DIR__.'/../vendor/autoload.php';
  • Step 3: Créer tests/Unit/.gitkeep

Fichier vide. Nécessaire pour que git suive le répertoire avant le premier test.

  • Step 4: Ajouter la cible test au makefile

Ajouter à la fin du makefile :

# Lance la suite PHPUnit. Usage : make test                (tout)
#                                  make test FILES=<path>  (un fichier/dossier)
test:
	$(EXEC_PHP) php vendor/bin/phpunit $(FILES)
  • Step 5: Ajouter .phpunit.cache au .gitignore

Vérifier s'il existe déjà un .gitignore :

ls -la .gitignore

S'il existe, y ajouter la ligne .phpunit.cache. S'il n'existe pas, le créer avec :

vendor/
.phpunit.cache
composer.lock

Remarque : composer.lock est actuellement versionné — si c'est intentionnel, ne pas l'ajouter au .gitignore. Vérifier : git ls-files composer.lock.

  • Step 6: Vérifier que PHPUnit démarre

Run:

make test

Expected: No tests executed! (pas d'erreur de bootstrap). Si container pas lancé → make start && make install d'abord.

  • Step 7: Commit

Run:

git add phpunit.xml.dist tests/bootstrap.php tests/Unit/.gitkeep makefile .gitignore
git commit -m "chore : bootstrap infrastructure PHPUnit 12"

Task 3 — ZipMessageDecoder

But : composant partagé qui accepte le binaire ZIP renvoyé par SOAP et retourne un stdClass directement utilisable par les mappers existants (même API que le payload SoapClient).

Files:

  • Create: src/Shared/Soap/ZipMessageDecoder.php

  • Create: tests/Unit/Shared/Soap/ZipMessageDecoderTest.php

  • Step 1: Écrire le test en premier

Contenu complet de tests/Unit/Shared/Soap/ZipMessageDecoderTest.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Tests\Unit\Shared\Soap;

use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use ZipArchive;

#[CoversClass(ZipMessageDecoder::class)]
final class ZipMessageDecoderTest extends TestCase
{
    public function testDecodeReturnsObjectFromZippedXml(): void
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>'
            .'<MessageIpBNotifGetInventaire>'
            .'<InformationsMessage><DateDebut>2026-01-01</DateDebut></InformationsMessage>'
            .'<Bovins><Bovin><IdentiteBovin><Sexe>F</Sexe></IdentiteBovin></Bovin></Bovins>'
            .'</MessageIpBNotifGetInventaire>';

        $zipBinary = $this->makeZipBinary('message.xml', $xml);
        $decoded   = (new ZipMessageDecoder())->decode($zipBinary);

        self::assertIsObject($decoded);
        self::assertSame('2026-01-01', (string) $decoded->InformationsMessage->DateDebut);
        self::assertSame('F', (string) $decoded->Bovins->Bovin->IdentiteBovin->Sexe);
    }

    public function testDecodeThrowsOnEmptyBinary(): void
    {
        $this->expectException(RuntimeException::class);
        (new ZipMessageDecoder())->decode('');
    }

    public function testDecodeThrowsOnInvalidZip(): void
    {
        $this->expectException(RuntimeException::class);
        (new ZipMessageDecoder())->decode('not a zip');
    }

    private function makeZipBinary(string $innerFile, string $content): string
    {
        $tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
        self::assertIsString($tempFile);
        $zip = new ZipArchive();
        self::assertTrue(true === $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE));
        self::assertTrue($zip->addFromString($innerFile, $content));
        self::assertTrue($zip->close());
        $binary = file_get_contents($tempFile);
        @unlink($tempFile);
        self::assertIsString($binary);

        return $binary;
    }
}
  • Step 2: Vérifier que le test échoue (classe absente)

Run:

make test FILES=tests/Unit/Shared/Soap/ZipMessageDecoderTest.php

Expected: erreur Class "Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder" not found.

  • Step 3: Créer le décodeur

Contenu complet de src/Shared/Soap/ZipMessageDecoder.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Shared\Soap;

use RuntimeException;
use ZipArchive;

final class ZipMessageDecoder
{
    /**
     * Décode le binaire `MessageZip` retourné par les opérations EDNOTIF de type
     * `Get*` (Inventaire / RetourDossiers / SortiesPresumees) : le contenu est déjà
     * décodé par ext-soap depuis base64, il reste à dézipper et parser le XML.
     */
    public function decode(string $zipBinary): object
    {
        if ('' === $zipBinary) {
            throw new RuntimeException('ZipMessageDecoder: binaire ZIP vide.');
        }

        $tempFile = tempnam(sys_get_temp_dir(), 'ednotif_zip_');
        if (false === $tempFile) {
            throw new RuntimeException('ZipMessageDecoder: impossible de créer un fichier temporaire.');
        }

        try {
            if (false === file_put_contents($tempFile, $zipBinary)) {
                throw new RuntimeException('ZipMessageDecoder: impossible d\'écrire le fichier temporaire.');
            }

            $xml = $this->readFirstEntry($tempFile);
        } finally {
            @unlink($tempFile);
        }

        $previous  = libxml_use_internal_errors(true);
        $simpleXml = simplexml_load_string($xml);
        libxml_use_internal_errors($previous);

        if (false === $simpleXml) {
            throw new RuntimeException('ZipMessageDecoder: XML invalide dans l\'archive ZIP.');
        }

        $json = json_encode($simpleXml);
        if (false === $json) {
            throw new RuntimeException('ZipMessageDecoder: échec de l\'encodage JSON intermédiaire.');
        }

        $decoded = json_decode($json, false);
        if (!is_object($decoded)) {
            throw new RuntimeException('ZipMessageDecoder: décodage JSON non objet.');
        }

        return $decoded;
    }

    private function readFirstEntry(string $filePath): string
    {
        $zip        = new ZipArchive();
        $openResult = $zip->open($filePath, ZipArchive::RDONLY);
        if (true !== $openResult) {
            throw new RuntimeException(sprintf('ZipMessageDecoder: ouverture ZIP impossible (code %s).', (string) $openResult));
        }

        try {
            if (0 === $zip->numFiles) {
                throw new RuntimeException('ZipMessageDecoder: archive ZIP vide.');
            }

            $xml = $zip->getFromIndex(0);
            if (false === $xml) {
                throw new RuntimeException('ZipMessageDecoder: lecture de l\'entrée ZIP impossible.');
            }
        } finally {
            $zip->close();
        }

        return $xml;
    }
}
  • Step 4: Vérifier que les tests passent

Run:

make test FILES=tests/Unit/Shared/Soap/ZipMessageDecoderTest.php

Expected: 3 tests, 3 passent.

  • Step 5: Commit

Run:

git add src/Shared/Soap/ZipMessageDecoder.php tests/Unit/Shared/Soap/ZipMessageDecoderTest.php
git commit -m "feat : décodeur ZIP+XML partagé pour les réponses Get* bovin"

Task 4 — Trait de mapping partagé + refactor AnimalFileMapper

But : extraire d'AnimalFileMapper les helpers génériques (type-conversion + mapping d'IdentiteBovin / PresencePeriode / BovinRef / ExploitationRef / Movement) dans un trait réutilisable par les nouveaux mappers, sans casser la signature publique de AnimalFileMapper::map().

Files:

  • Create: src/Bovin/Mapper/BovinNodeMappingTrait.php

  • Modify: src/Bovin/Mapper/AnimalFileMapper.php

  • Step 1: Créer le trait

Contenu complet de src/Bovin/Mapper/BovinNodeMappingTrait.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\BovinIdentificationDto;
use Malio\EdnotifBundle\Bovin\Dto\BovinRef;
use Malio\EdnotifBundle\Bovin\Dto\DateValueDto;
use Malio\EdnotifBundle\Bovin\Dto\ExploitationRef;
use Malio\EdnotifBundle\Bovin\Dto\MovementDto;
use Malio\EdnotifBundle\Bovin\Dto\ParentInfoDto;
use Malio\EdnotifBundle\Bovin\Dto\PresencePeriodDto;
use Throwable;

/**
 * Helpers partagés par les mappers travaillant sur des noeuds `Bovin` issus
 * d'EDNOTIF, que la source soit une réponse SOAP directe (stdClass via ext-soap)
 * ou un message XML zippé (stdClass via ZipMessageDecoder).
 */
trait BovinNodeMappingTrait
{
    protected function mapIdentification(object $identificationNode): BovinIdentificationDto
    {
        $birthDate     = null;
        $birthDateNode = $identificationNode->DateNaissance ?? null;
        if (is_object($birthDateNode)) {
            $birthDate = new DateValueDto(
                date: $this->toNullableDate($birthDateNode->Date ?? null),
                completenessFlag: $this->toNullableString($birthDateNode->TemoinCompletude ?? null),
            );
        }

        return new BovinIdentificationDto(
            bovin: $this->mapBovinRef($identificationNode->Bovin ?? null),
            sex: $this->toNullableString($identificationNode->Sexe ?? null),
            breedType: $this->toNullableString($identificationNode->TypeRacial ?? null),
            birthDate: $birthDate,
            workNumber: $this->toNullableString($identificationNode->NumeroTravail ?? null),
            isFilie: $this->toNullableBool($identificationNode->StatutFilie ?? null),
            motherCarrier: $this->mapParentInfo($identificationNode->MerePorteuse ?? null),
            fatherIpg: $this->mapParentInfo($identificationNode->PereIPG ?? null),
            birthExploitation: $this->mapExploitationRef($identificationNode->ExploitationNaissance ?? null),
        );
    }

    protected function mapPresencePeriod(object $presencePeriodNode): PresencePeriodDto
    {
        $entryNode = $presencePeriodNode->Entree ?? null;
        $exitNode  = $presencePeriodNode->Sortie ?? null;

        return new PresencePeriodDto(
            entry: is_object($entryNode) ? $this->mapMovement($entryNode, 'entry') : null,
            exit: is_object($exitNode) ? $this->mapMovement($exitNode, 'exit') : null,
        );
    }

    protected function mapMovement(object $movementNode, string $direction): MovementDto
    {
        if ('entry' === $direction) {
            $dateValue  = $movementNode->DateEntree ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
            $causeValue = $movementNode->CauseEntree ?? null;
        } else {
            $dateValue  = $movementNode->DateSortie ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
            $causeValue = $movementNode->CauseSortie ?? null;
        }

        return new MovementDto(
            date: $this->toNullableDate($dateValue),
            cause: $this->toNullableString($causeValue),
            exploitation: $this->mapExploitationRef($movementNode->Exploitation ?? null),
        );
    }

    protected function mapParentInfo(mixed $parentNode): ?ParentInfoDto
    {
        if (!is_object($parentNode)) {
            return null;
        }

        return new ParentInfoDto(
            bovin: $this->mapBovinRef($parentNode->Bovin ?? null),
            breedType: $this->toNullableString($parentNode->TypeRacial ?? null),
        );
    }

    protected function mapBovinRef(mixed $bovinNode): ?BovinRef
    {
        if (!is_object($bovinNode)) {
            return null;
        }

        return new BovinRef(
            countryCode: $this->toNullableString($bovinNode->CodePays ?? null),
            nationalNumber: $this->toNullableString($bovinNode->NumeroNational ?? null),
        );
    }

    protected function mapExploitationRef(mixed $exploitationNode): ?ExploitationRef
    {
        if (!is_object($exploitationNode)) {
            return null;
        }

        return new ExploitationRef(
            countryCode: $this->toNullableString($exploitationNode->CodePays ?? null),
            exploitationNumber: $this->toNullableString($exploitationNode->NumeroExploitation ?? null),
        );
    }

    /** @return list<mixed> */
    protected function normalizeToList(mixed $value): array
    {
        if (null === $value) {
            return [];
        }

        return is_array($value) ? array_values($value) : [$value];
    }

    protected function toNullableString(mixed $value): ?string
    {
        if (null === $value) {
            return null;
        }
        $stringValue = trim((string) $value);

        return '' === $stringValue ? null : $stringValue;
    }

    protected function toNullableInt(mixed $value): ?int
    {
        if (null === $value) {
            return null;
        }
        if (is_int($value)) {
            return $value;
        }
        if (is_numeric($value)) {
            return (int) $value;
        }

        return null;
    }

    protected function toNullableBool(mixed $value): ?bool
    {
        if (null === $value) {
            return null;
        }

        return (bool) $value;
    }

    protected function toNullableDate(mixed $value): ?DateTimeImmutable
    {
        if (!is_string($value) || '' === trim($value)) {
            return null;
        }

        try {
            return new DateTimeImmutable($value);
        } catch (Throwable) {
            return null;
        }
    }
}
  • Step 2: Refactorer AnimalFileMapper pour utiliser le trait

Remplacer intégralement src/Bovin/Mapper/AnimalFileMapper.php par :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final class AnimalFileMapper
{
    use BovinNodeMappingTrait;

    public function map(object $soapResponse): AnimalFileDto
    {
        $standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);

        $specificResponseNode = $soapResponse->ReponseSpecifique ?? null;
        $bovinNode            = is_object($specificResponseNode) ? ($specificResponseNode->Bovin ?? null) : null;

        $identification  = null;
        $presencePeriods = [];

        if (is_object($bovinNode)) {
            $identificationNode = $bovinNode->IdentiteBovin ?? null;
            if (is_object($identificationNode)) {
                $identification = $this->mapIdentification($identificationNode);
            }

            $presencePeriodsNode = $bovinNode->PeriodesPresences->PeriodePresence ?? null;
            foreach ($this->normalizeToList($presencePeriodsNode) as $presencePeriodNode) {
                if (!is_object($presencePeriodNode)) {
                    continue;
                }
                $presencePeriods[] = $this->mapPresencePeriod($presencePeriodNode);
            }
        }

        return new AnimalFileDto(
            standardResponse: $standardResponse,
            identification: $identification,
            presencePeriods: $presencePeriods,
            rawSoapResponse: $soapResponse
        );
    }

    private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
    {
        $result = (bool) ($standardResponseNode->Resultat ?? false);

        $anomalyNode = $standardResponseNode->Anomalie ?? null;
        $anomaly     = null;

        if (is_object($anomalyNode)) {
            $anomaly = new AnomalyDto(
                code: $this->toNullableString($anomalyNode->Code ?? null),
                severity: $this->toNullableInt($anomalyNode->Severite ?? null),
                message: $this->toNullableString($anomalyNode->Message ?? null),
            );
        }

        return new StandardResponseDto($result, $anomaly);
    }
}
  • Step 3: Vérifier qu'aucun test ne régresse

Run:

make test

Expected: 3 tests PASS (les tests ZipMessageDecoder de Task 3), aucune erreur de chargement.

  • Step 4: Commit

Run:

git add src/Bovin/Mapper/BovinNodeMappingTrait.php src/Bovin/Mapper/AnimalFileMapper.php
git commit -m "refactor : extraire les helpers de mapping bovin dans un trait partagé"

Task 5 — AnimalSummaryDto + AnimalSummaryMapper

But : modéliser un bovin « résumé » (identification + périodes de présence, sans l'enveloppe StandardResponse qui n'existe pas pour les listes) et son mapper. Consommé par InventoryMapper et ReturnedDossiersMapper.

Files:

  • Create: src/Bovin/Dto/AnimalSummaryDto.php

  • Create: src/Bovin/Mapper/AnimalSummaryMapper.php

  • Create: tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php

  • Step 1: Écrire le test

Contenu complet de tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(AnimalSummaryMapper::class)]
final class AnimalSummaryMapperTest extends TestCase
{
    public function testMapReturnsIdentificationAndPresencePeriods(): void
    {
        $node = $this->makeBovinNode();

        $summary = (new AnimalSummaryMapper())->map($node);

        self::assertInstanceOf(AnimalSummaryDto::class, $summary);
        self::assertNotNull($summary->identification);
        self::assertSame('FR1234567890', $summary->identification->bovin?->nationalNumber);
        self::assertSame('F', $summary->identification->sex);
        self::assertCount(2, $summary->presencePeriods);
    }

    public function testMapHandlesMissingOptionalNodes(): void
    {
        $summary = (new AnimalSummaryMapper())->map(new stdClass());

        self::assertNull($summary->identification);
        self::assertSame([], $summary->presencePeriods);
    }

    private function makeBovinNode(): object
    {
        $node                      = new stdClass();
        $node->IdentiteBovin       = new stdClass();
        $node->IdentiteBovin->Sexe = 'F';
        $node->IdentiteBovin->Bovin                 = new stdClass();
        $node->IdentiteBovin->Bovin->CodePays       = 'FR';
        $node->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';

        $node->PeriodesPresences                  = new stdClass();
        $node->PeriodesPresences->PeriodePresence = [
            $this->makePresencePeriod('2024-01-10', '2024-06-01'),
            $this->makePresencePeriod('2024-06-02', null),
        ];

        return $node;
    }

    private function makePresencePeriod(string $entryDate, ?string $exitDate): object
    {
        $period                     = new stdClass();
        $period->Entree             = new stdClass();
        $period->Entree->DateEntree = $entryDate;
        if (null !== $exitDate) {
            $period->Sortie             = new stdClass();
            $period->Sortie->DateSortie = $exitDate;
        }

        return $period;
    }
}
  • Step 2: Lancer le test (il doit échouer)

Run:

make test FILES=tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php

Expected: erreur Class "Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto" not found (ou mapper).

  • Step 3: Créer le DTO

Contenu complet de src/Bovin/Dto/AnimalSummaryDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

final readonly class AnimalSummaryDto
{
    /**
     * @param list<PresencePeriodDto> $presencePeriods
     */
    public function __construct(
        public ?BovinIdentificationDto $identification,
        public array $presencePeriods,
    ) {}
}
  • Step 4: Créer le mapper

Contenu complet de src/Bovin/Mapper/AnimalSummaryMapper.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;

final class AnimalSummaryMapper
{
    use BovinNodeMappingTrait;

    public function map(object $bovinNode): AnimalSummaryDto
    {
        $identificationNode = $bovinNode->IdentiteBovin ?? null;
        $identification     = is_object($identificationNode) ? $this->mapIdentification($identificationNode) : null;

        $presencePeriods     = [];
        $presencePeriodsNode = $bovinNode->PeriodesPresences->PeriodePresence ?? null;
        foreach ($this->normalizeToList($presencePeriodsNode) as $presencePeriodNode) {
            if (!is_object($presencePeriodNode)) {
                continue;
            }
            $presencePeriods[] = $this->mapPresencePeriod($presencePeriodNode);
        }

        return new AnimalSummaryDto(
            identification: $identification,
            presencePeriods: $presencePeriods,
        );
    }
}
  • Step 5: Vérifier que les tests passent

Run:

make test FILES=tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php

Expected: 2 tests PASS.

  • Step 6: Commit

Run:

git add src/Bovin/Dto/AnimalSummaryDto.php src/Bovin/Mapper/AnimalSummaryMapper.php tests/Unit/Bovin/Mapper/AnimalSummaryMapperTest.php
git commit -m "feat : DTO et mapper pour un bovin résumé (identification + présences)"

Task 6 — DTOs et mapper Inventaire

But : modéliser la réponse complète de IpBGetInventaire (entête InformationsMessage + liste de bovins + optionnellement séries de boucles).

Files:

  • Create: src/Bovin/Dto/EarTagSeriesDto.php
  • Create: src/Bovin/Dto/InventoryDto.php
  • Create: src/Bovin/Mapper/InventoryMapper.php
  • Create: tests/Unit/Bovin/Mapper/InventoryMapperTest.php

Rappel XSD du message dézippé :

MessageIpBNotifGetInventaire
├── InformationsMessage
│   ├── DateHeureGeneration (xsd:dateTime)
│   ├── Exploitation        (CodePays, NumeroExploitation)
│   ├── DateDebut           (xsd:date)
│   ├── DateFin?            (xsd:date)
│   └── StockBoucles        (xsd:boolean)
├── Bovins/Bovin[]          (IdentiteBovin + PeriodesPresences)
└── Boucles/SerieBoucles[]  (structure détaillée volontairement non mappée → raw)
  • Step 1: Écrire le test

Contenu complet de tests/Unit/Bovin/Mapper/InventoryMapperTest.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(InventoryMapper::class)]
final class InventoryMapperTest extends TestCase
{
    public function testMapFullInventory(): void
    {
        $mapper = new InventoryMapper(new AnimalSummaryMapper());

        $inventory = $mapper->map($this->makeSoapResponse(), $this->makeUnzippedMessage());

        self::assertInstanceOf(InventoryDto::class, $inventory);
        self::assertTrue($inventory->standardResponse->result);
        self::assertSame(2, $inventory->nbBovins);
        self::assertEquals(new DateTimeImmutable('2026-01-01'), $inventory->startDate);
        self::assertEquals(new DateTimeImmutable('2026-01-31'), $inventory->endDate);
        self::assertTrue($inventory->includesEarTagStock);
        self::assertCount(2, $inventory->animals);
        self::assertCount(1, $inventory->earTagSeries);
        self::assertSame('FR123', $inventory->animals[0]->identification?->bovin?->nationalNumber);
    }

    public function testMapInventoryWithoutMessageZipReturnsEmptyLists(): void
    {
        $mapper = new InventoryMapper(new AnimalSummaryMapper());

        $soapResponse                                = new stdClass();
        $soapResponse->ReponseStandard               = new stdClass();
        $soapResponse->ReponseStandard->Resultat     = true;
        $soapResponse->ReponseSpecifique             = new stdClass();
        $soapResponse->ReponseSpecifique->NbBovins   = 0;

        $inventory = $mapper->map($soapResponse, null);

        self::assertSame(0, $inventory->nbBovins);
        self::assertSame([], $inventory->animals);
        self::assertSame([], $inventory->earTagSeries);
        self::assertNull($inventory->startDate);
    }

    private function makeSoapResponse(): object
    {
        $response                                = new stdClass();
        $response->ReponseStandard               = new stdClass();
        $response->ReponseStandard->Resultat     = true;
        $response->ReponseSpecifique             = new stdClass();
        $response->ReponseSpecifique->NbBovins   = 2;

        return $response;
    }

    private function makeUnzippedMessage(): object
    {
        $message                                           = new stdClass();
        $message->InformationsMessage                      = new stdClass();
        $message->InformationsMessage->DateDebut           = '2026-01-01';
        $message->InformationsMessage->DateFin             = '2026-01-31';
        $message->InformationsMessage->StockBoucles        = '1';
        $message->Bovins                                   = new stdClass();
        $message->Bovins->Bovin                            = [
            $this->makeAnimalNode('FR123'),
            $this->makeAnimalNode('FR456'),
        ];
        $message->Boucles                                  = new stdClass();
        $message->Boucles->SerieBoucles                    = new stdClass();
        $message->Boucles->SerieBoucles->NumeroSerieDebut  = 'A0001';

        return $message;
    }

    private function makeAnimalNode(string $nationalNumber): object
    {
        $node                                       = new stdClass();
        $node->IdentiteBovin                        = new stdClass();
        $node->IdentiteBovin->Bovin                 = new stdClass();
        $node->IdentiteBovin->Bovin->NumeroNational = $nationalNumber;
        $node->PeriodesPresences                    = new stdClass();
        $node->PeriodesPresences->PeriodePresence   = new stdClass();
        $node->PeriodesPresences->PeriodePresence->Entree = new stdClass();
        $node->PeriodesPresences->PeriodePresence->Entree->DateEntree = '2025-05-01';

        return $node;
    }
}
  • Step 2: Lancer le test (doit échouer)

Run:

make test FILES=tests/Unit/Bovin/Mapper/InventoryMapperTest.php

Expected: erreur de classe non trouvée.

  • Step 3: Créer EarTagSeriesDto

Contenu complet de src/Bovin/Dto/EarTagSeriesDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

final readonly class EarTagSeriesDto
{
    public function __construct(
        public object $rawNode,
    ) {}
}

Note: la structure XSD de typeSerieBoucles est riche (numéros, plages, fournisseur…). On garde le noeud brut en Phase 1 et on l'affinera si un besoin concret apparaît (YAGNI).

  • Step 4: Créer InventoryDto

Contenu complet de src/Bovin/Dto/InventoryDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use DateTimeImmutable;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final readonly class InventoryDto
{
    /**
     * @param list<AnimalSummaryDto> $animals
     * @param list<EarTagSeriesDto>  $earTagSeries
     */
    public function __construct(
        public StandardResponseDto $standardResponse,
        public int $nbBovins,
        public ?DateTimeImmutable $startDate,
        public ?DateTimeImmutable $endDate,
        public bool $includesEarTagStock,
        public array $animals,
        public array $earTagSeries,
        public ?object $rawSoapResponse,
    ) {}
}
  • Step 5: Créer InventoryMapper

Contenu complet de src/Bovin/Mapper/InventoryMapper.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final class InventoryMapper
{
    use BovinNodeMappingTrait;

    public function __construct(
        private readonly AnimalSummaryMapper $animalSummaryMapper,
    ) {}

    public function map(object $soapResponse, ?object $unzippedMessage): InventoryDto
    {
        $standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);

        $nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;

        $startDate           = null;
        $endDate             = null;
        $includesEarTagStock = false;
        $animals             = [];
        $earTagSeries        = [];

        if (is_object($unzippedMessage)) {
            $infoNode = $unzippedMessage->InformationsMessage ?? null;
            if (is_object($infoNode)) {
                $startDate           = $this->toNullableDate($infoNode->DateDebut ?? null);
                $endDate             = $this->toNullableDate($infoNode->DateFin ?? null);
                $includesEarTagStock = (bool) $this->toNullableBool($infoNode->StockBoucles ?? null);
            }

            $bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
            foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
                if (!is_object($bovinNode)) {
                    continue;
                }
                $animals[] = $this->animalSummaryMapper->map($bovinNode);
            }

            $seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
            foreach ($this->normalizeToList($seriesNode) as $serieNode) {
                if (!is_object($serieNode)) {
                    continue;
                }
                $earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
            }
        }

        return new InventoryDto(
            standardResponse: $standardResponse,
            nbBovins: $nbBovins,
            startDate: $startDate,
            endDate: $endDate,
            includesEarTagStock: $includesEarTagStock,
            animals: $animals,
            earTagSeries: $earTagSeries,
            rawSoapResponse: $soapResponse,
        );
    }

    private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
    {
        $result      = (bool) ($standardResponseNode->Resultat ?? false);
        $anomalyNode = $standardResponseNode->Anomalie ?? null;
        $anomaly     = null;

        if (is_object($anomalyNode)) {
            $anomaly = new AnomalyDto(
                code: $this->toNullableString($anomalyNode->Code ?? null),
                severity: $this->toNullableInt($anomalyNode->Severite ?? null),
                message: $this->toNullableString($anomalyNode->Message ?? null),
            );
        }

        return new StandardResponseDto($result, $anomaly);
    }
}
  • Step 6: Vérifier que les tests passent

Run:

make test FILES=tests/Unit/Bovin/Mapper/InventoryMapperTest.php

Expected: 2 tests PASS.

  • Step 7: Commit

Run:

git add src/Bovin/Dto/EarTagSeriesDto.php src/Bovin/Dto/InventoryDto.php src/Bovin/Mapper/InventoryMapper.php tests/Unit/Bovin/Mapper/InventoryMapperTest.php
git commit -m "feat : DTOs et mapper pour IpBGetInventaire"

Task 7 — Méthode BovinApi::getInventory

But : exposer l'appel SOAP IpBGetInventaire via l'API publique.

Files:

  • Modify: src/Bovin/Api/BovinApiInterface.php

  • Modify: src/Bovin/Api/BovinApi.php

  • Step 1: Ajouter la méthode à l'interface

Remplacer intégralement src/Bovin/Api/BovinApiInterface.php par :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Api;

use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;

interface BovinApiInterface
{
    public function getAnimalFile(string $nationalNumber, string $countryCode = 'FR'): AnimalFileDto;

    public function getInventory(
        DateTimeInterface $startDate,
        ?DateTimeInterface $endDate = null,
        bool $includeEarTagStock = false,
    ): InventoryDto;
}
  • Step 2: Implémenter la méthode dans BovinApi

Dans src/Bovin/Api/BovinApi.php :

2a. Ajouter les imports en haut du fichier (après les imports existants) :

use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;

2b. Étendre le constructeur pour injecter les deux nouvelles dépendances. Remplacer le bloc __construct actuel (lignes 17-23) par :

    public function __construct(
        private TokenProvider $tokenProvider,
        private SoapClient $businessClient,
        private AnimalFileMapper $bovinDossierMapper,
        private InventoryMapper $inventoryMapper,
        private ZipMessageDecoder $zipMessageDecoder,
        private string $exploitationCountryCode,
        private string $exploitationNumber,
    ) {}

2c. Ajouter la méthode getInventory juste après getAnimalFile :

    public function getInventory(
        DateTimeInterface $startDate,
        ?DateTimeInterface $endDate = null,
        bool $includeEarTagStock = false,
    ): InventoryDto {
        $token = $this->tokenProvider->getToken();

        $payload = [
            'JetonAuthentification' => $token,
            'Exploitation'          => [
                'CodePays'           => $this->exploitationCountryCode,
                'NumeroExploitation' => $this->exploitationNumber,
            ],
            'DateDebut'    => $startDate->format('Y-m-d'),
            'StockBoucles' => $includeEarTagStock,
        ];
        if (null !== $endDate) {
            $payload['DateFin'] = $endDate->format('Y-m-d');
        }

        try {
            /** @var object $soapResponse */
            $soapResponse = $this->businessClient->__soapCall('IpBGetInventaire', [$payload]);
        } catch (SoapFault $soapFault) {
            throw new RuntimeException('SOAP Fault on IpBGetInventaire: '.$soapFault->getMessage(), 0, $soapFault);
        }

        $this->assertSuccessfulResponse($soapResponse, 'IpBGetInventaire');

        $messageZip      = $soapResponse->ReponseSpecifique->MessageZip ?? null;
        $unzippedMessage = is_string($messageZip) && '' !== $messageZip
            ? $this->zipMessageDecoder->decode($messageZip)
            : null;

        return $this->inventoryMapper->map($soapResponse, $unzippedMessage);
    }

2d. Factoriser la vérification ReponseStandard en méthode privée. Extraire le bloc existant (lignes 49-60 environ) dans src/Bovin/Api/BovinApi.php. Ajouter en fin de classe :

    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 :

        $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 :

    $services->set(ZipMessageDecoder::class);
    $services->set(AnimalSummaryMapper::class);
    $services->set(InventoryMapper::class);

Et ajouter les imports en haut du fichier :

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 :

    $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

declare(strict_types=1);

namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(ReturnedDossiersMapper::class)]
final class ReturnedDossiersMapperTest extends TestCase
{
    public function testMapReturnsAnimalsAndStartDate(): void
    {
        $mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper());

        $soapResponse                              = new stdClass();
        $soapResponse->ReponseStandard             = new stdClass();
        $soapResponse->ReponseStandard->Resultat   = true;
        $soapResponse->ReponseSpecifique           = new stdClass();
        $soapResponse->ReponseSpecifique->NbBovins = 1;

        $message                                         = new stdClass();
        $message->InformationsMessage                    = new stdClass();
        $message->InformationsMessage->DateDebut         = '2026-03-01';
        $message->Bovins                                 = new stdClass();
        $message->Bovins->Bovin                          = new stdClass();
        $message->Bovins->Bovin->IdentiteBovin           = new stdClass();
        $message->Bovins->Bovin->IdentiteBovin->Bovin    = new stdClass();
        $message->Bovins->Bovin->IdentiteBovin->Bovin->NumeroNational = 'FR789';
        $message->Bovins->Bovin->PeriodesPresences       = new stdClass();
        $message->Bovins->Bovin->PeriodesPresences->PeriodePresence = new stdClass();

        $dto = $mapper->map($soapResponse, $message);

        self::assertInstanceOf(ReturnedDossiersDto::class, $dto);
        self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
        self::assertSame(1, $dto->nbBovins);
        self::assertCount(1, $dto->animals);
        self::assertSame('FR789', $dto->animals[0]->identification?->bovin?->nationalNumber);
    }

    public function testMapWithoutMessageReturnsEmptyAnimals(): void
    {
        $mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper());

        $soapResponse                              = new stdClass();
        $soapResponse->ReponseStandard             = new stdClass();
        $soapResponse->ReponseStandard->Resultat   = true;
        $soapResponse->ReponseSpecifique           = new stdClass();
        $soapResponse->ReponseSpecifique->NbBovins = 0;

        $dto = $mapper->map($soapResponse, null);

        self::assertSame(0, $dto->nbBovins);
        self::assertSame([], $dto->animals);
    }
}
  • Step 2: Lancer le test (doit échouer)

Run:

make test FILES=tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php

Expected: classe introuvable.

  • Step 3: Créer ReturnedDossiersDto

Contenu complet de src/Bovin/Dto/ReturnedDossiersDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use DateTimeImmutable;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final readonly class ReturnedDossiersDto
{
    /**
     * @param list<AnimalSummaryDto> $animals
     */
    public function __construct(
        public StandardResponseDto $standardResponse,
        public int $nbBovins,
        public ?DateTimeImmutable $startDate,
        public array $animals,
        public ?object $rawSoapResponse,
    ) {}
}
  • Step 4: Créer ReturnedDossiersMapper

Contenu complet de src/Bovin/Mapper/ReturnedDossiersMapper.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final class ReturnedDossiersMapper
{
    use BovinNodeMappingTrait;

    public function __construct(
        private readonly AnimalSummaryMapper $animalSummaryMapper,
    ) {}

    public function map(object $soapResponse, ?object $unzippedMessage): ReturnedDossiersDto
    {
        $standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
        $nbBovins         = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;

        $startDate = null;
        $animals   = [];

        if (is_object($unzippedMessage)) {
            $infoNode = $unzippedMessage->InformationsMessage ?? null;
            if (is_object($infoNode)) {
                $startDate = $this->toNullableDate($infoNode->DateDebut ?? null);
            }

            $bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
            foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
                if (!is_object($bovinNode)) {
                    continue;
                }
                $animals[] = $this->animalSummaryMapper->map($bovinNode);
            }
        }

        return new ReturnedDossiersDto(
            standardResponse: $standardResponse,
            nbBovins: $nbBovins,
            startDate: $startDate,
            animals: $animals,
            rawSoapResponse: $soapResponse,
        );
    }

    private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
    {
        $result      = (bool) ($standardResponseNode->Resultat ?? false);
        $anomalyNode = $standardResponseNode->Anomalie ?? null;
        $anomaly     = null;

        if (is_object($anomalyNode)) {
            $anomaly = new AnomalyDto(
                code: $this->toNullableString($anomalyNode->Code ?? null),
                severity: $this->toNullableInt($anomalyNode->Severite ?? null),
                message: $this->toNullableString($anomalyNode->Message ?? null),
            );
        }

        return new StandardResponseDto($result, $anomaly);
    }
}
  • Step 5: Vérifier les tests

Run:

make test FILES=tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php

Expected: 2 tests PASS.

  • Step 6: Commit

Run:

git add src/Bovin/Dto/ReturnedDossiersDto.php src/Bovin/Mapper/ReturnedDossiersMapper.php tests/Unit/Bovin/Mapper/ReturnedDossiersMapperTest.php
git commit -m "feat : DTO et mapper pour IpBGetRetourDossiers"

Task 9 — Méthode BovinApi::getReturnedDossiers

Files:

  • Modify: src/Bovin/Api/BovinApiInterface.php

  • Modify: src/Bovin/Api/BovinApi.php

  • Modify: config/services.php

  • Step 1: Ajouter la méthode à l'interface

Dans src/Bovin/Api/BovinApiInterface.php, ajouter l'import use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto; puis ajouter la méthode en fin d'interface :

    public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto;
  • Step 2: Étendre le constructeur de BovinApi

Ajouter les imports :

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 :

    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 :

    $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

declare(strict_types=1);

namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(PresumedExitsMapper::class)]
final class PresumedExitsMapperTest extends TestCase
{
    public function testMapWithExits(): void
    {
        $soapResponse                              = new stdClass();
        $soapResponse->ReponseStandard             = new stdClass();
        $soapResponse->ReponseStandard->Resultat   = true;
        $soapResponse->ReponseSpecifique           = new stdClass();
        $soapResponse->ReponseSpecifique->NbBovins = 2;

        $message                                   = new stdClass();
        $message->SortiesPresumees                 = new stdClass();
        $message->SortiesPresumees->SortiePresumee = [
            $this->makeExit('FR111', '2026-02-15'),
            $this->makeExit('FR222', null),
        ];

        $dto = (new PresumedExitsMapper())->map($soapResponse, $message);

        self::assertInstanceOf(PresumedExitsDto::class, $dto);
        self::assertSame(2, $dto->nbBovins);
        self::assertCount(2, $dto->presumedExits);
        self::assertSame('FR111', $dto->presumedExits[0]->bovin?->nationalNumber);
        self::assertEquals(new DateTimeImmutable('2026-02-15'), $dto->presumedExits[0]->exitDate);
        self::assertNull($dto->presumedExits[1]->exitDate);
    }

    public function testMapWithoutMessageReturnsEmpty(): void
    {
        $soapResponse                              = new stdClass();
        $soapResponse->ReponseStandard             = new stdClass();
        $soapResponse->ReponseStandard->Resultat   = true;
        $soapResponse->ReponseSpecifique           = new stdClass();
        $soapResponse->ReponseSpecifique->NbBovins = 0;

        $dto = (new PresumedExitsMapper())->map($soapResponse, null);

        self::assertSame(0, $dto->nbBovins);
        self::assertSame([], $dto->presumedExits);
    }

    private function makeExit(string $nationalNumber, ?string $exitDate): object
    {
        $node                         = new stdClass();
        $node->Bovin                  = new stdClass();
        $node->Bovin->CodePays        = 'FR';
        $node->Bovin->NumeroNational  = $nationalNumber;
        if (null !== $exitDate) {
            $node->DateSortie = $exitDate;
        }

        return $node;
    }
}
  • Step 2: Lancer le test (doit échouer)

Run:

make test FILES=tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php

Expected: classes introuvables.

  • Step 3: Créer PresumedExitDto

Contenu complet de src/Bovin/Dto/PresumedExitDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use DateTimeImmutable;

final readonly class PresumedExitDto
{
    public function __construct(
        public ?BovinRef $bovin,
        public ?DateTimeImmutable $exitDate,
    ) {}
}
  • Step 4: Créer PresumedExitsDto

Contenu complet de src/Bovin/Dto/PresumedExitsDto.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final readonly class PresumedExitsDto
{
    /**
     * @param list<PresumedExitDto> $presumedExits
     */
    public function __construct(
        public StandardResponseDto $standardResponse,
        public int $nbBovins,
        public array $presumedExits,
        public ?object $rawSoapResponse,
    ) {}
}
  • Step 5: Créer PresumedExitsMapper

Contenu complet de src/Bovin/Mapper/PresumedExitsMapper.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\PresumedExitDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

final class PresumedExitsMapper
{
    use BovinNodeMappingTrait;

    public function map(object $soapResponse, ?object $unzippedMessage): PresumedExitsDto
    {
        $standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
        $nbBovins         = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;

        $presumedExits = [];

        if (is_object($unzippedMessage)) {
            $exitsNode = $unzippedMessage->SortiesPresumees->SortiePresumee ?? null;
            foreach ($this->normalizeToList($exitsNode) as $exitNode) {
                if (!is_object($exitNode)) {
                    continue;
                }
                $presumedExits[] = new PresumedExitDto(
                    bovin: $this->mapBovinRef($exitNode->Bovin ?? null),
                    exitDate: $this->toNullableDate($exitNode->DateSortie ?? null),
                );
            }
        }

        return new PresumedExitsDto(
            standardResponse: $standardResponse,
            nbBovins: $nbBovins,
            presumedExits: $presumedExits,
            rawSoapResponse: $soapResponse,
        );
    }

    private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
    {
        $result      = (bool) ($standardResponseNode->Resultat ?? false);
        $anomalyNode = $standardResponseNode->Anomalie ?? null;
        $anomaly     = null;

        if (is_object($anomalyNode)) {
            $anomaly = new AnomalyDto(
                code: $this->toNullableString($anomalyNode->Code ?? null),
                severity: $this->toNullableInt($anomalyNode->Severite ?? null),
                message: $this->toNullableString($anomalyNode->Message ?? null),
            );
        }

        return new StandardResponseDto($result, $anomaly);
    }
}
  • Step 6: Vérifier les tests

Run:

make test FILES=tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php

Expected: 2 tests PASS.

  • Step 7: Commit

Run:

git add src/Bovin/Dto/PresumedExitDto.php src/Bovin/Dto/PresumedExitsDto.php src/Bovin/Mapper/PresumedExitsMapper.php tests/Unit/Bovin/Mapper/PresumedExitsMapperTest.php
git commit -m "feat : DTOs et mapper pour IpBGetSortiesPresumees"

Task 11 — Méthode BovinApi::getPresumedExits

Files:

  • Modify: src/Bovin/Api/BovinApiInterface.php

  • Modify: src/Bovin/Api/BovinApi.php

  • Modify: config/services.php

  • Step 1: Ajouter la méthode à l'interface

Dans src/Bovin/Api/BovinApiInterface.php, ajouter l'import use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto; puis ajouter la méthode :

    public function getPresumedExits(): PresumedExitsDto;
  • Step 2: Étendre BovinApi

Ajouter les imports :

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 :

    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 :

    $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 :

## 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