Files
ednotif-bundle/docs/superpowers/plans/2026-04-22-phase2-create-entree-sortie.md
tristan 8e8f955ff3
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 38s
feat: ajout des notifications d'entrées/sorties (#5)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #5
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-22 13:28:23 +00:00

31 KiB

Phase 2 Lot 1 — createEntree + createSortie — Implementation Plan

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 2 méthodes d'écriture bovin (createEntree, createSortie) à BovinApiInterface, appelant les opérations SOAP IpBCreateEntree / IpBCreateSortie de wsIpBNotif, avec requests/responses DTOs typés et enums métier.

Architecture: On ajoute 6 unités indépendantes (3 enums, 2 request DTOs, 2 response DTOs, 2 mappers) puis on câble le tout dans BovinApi + config/services.php. Les mappers réutilisent StandardResponseMapper (déjà existant) et le trait BovinNodeMappingTrait pour mapIdentification/mapMovement. Pas de validation client-side, pas de test d'intégration SOAP (les mappers couvrent 95% de la logique testable).

Tech Stack: PHP 8.4 (backed enums, readonly DTOs, named args), PHPUnit 12, Symfony 8 DI.

Spec de référence : docs/superpowers/specs/2026-04-22-phase2-create-entree-sortie-design.md


File Structure

À créer

src/Bovin/Enum/CauseEntree.php                              enum 3 cases (Achat/Naissance/PretOuPension)
src/Bovin/Enum/CauseSortie.php                              enum 6 cases
src/Bovin/Enum/CategorieBovinIPG.php                        enum 13 cases (code 2-lettres)
src/Bovin/Dto/CreateEntreeRequest.php                       request DTO
src/Bovin/Dto/CreateSortieRequest.php                       request DTO
src/Bovin/Dto/CreateEntreeResponseDto.php                   response DTO
src/Bovin/Dto/CreateSortieResponseDto.php                   response DTO
src/Bovin/Mapper/CreateEntreeResponseMapper.php             mapper dédié
src/Bovin/Mapper/CreateSortieResponseMapper.php             mapper dédié
tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php  2 tests
tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php  2 tests

À modifier

src/Bovin/Api/BovinApiInterface.php   +2 méthodes, +imports
src/Bovin/Api/BovinApi.php            +2 méthodes, +2 constructor deps, +imports
config/services.php                   +2 mappers registered, +2 args on BovinApi

Task 1 — Les 3 enums

Pure data, aucune dépendance. Un seul commit pour les trois.

Files:

  • Create: src/Bovin/Enum/CauseEntree.php
  • Create: src/Bovin/Enum/CauseSortie.php
  • Create: src/Bovin/Enum/CategorieBovinIPG.php

Steps

  • Step 1: Créer CauseEntree

Contenu complet de src/Bovin/Enum/CauseEntree.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Enum;

/**
 * Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
 *
 * Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
 * Le `.value` est le code IPG transmis dans le payload SOAP.
 */
enum CauseEntree: string
{
    /** Entrée par achat. */
    case Achat = 'A';

    /** Entrée par naissance. */
    case Naissance = 'N';

    /** Entrée par prêt ou pension. */
    case PretOuPension = 'P';
}
  • Step 2: Créer CauseSortie

Contenu complet de src/Bovin/Enum/CauseSortie.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Enum;

/**
 * Cause d'une sortie de bovin de l'exploitation (opération `IpBCreateSortie`).
 *
 * Source : `resources/ednotif-ws/CauseSortie.XSD` + doc IPG Table 9.
 * Le `.value` est le code IPG transmis dans le payload SOAP.
 *
 * Le code `H` porte ici le sens "Sortie pour prêt ou pension" (équivalent du `P`
 * sur une entrée) ; le WSDL garantit que chaque code n'apparaît que dans son sens,
 * pas d'ambiguïté à gérer côté consommateur.
 */
enum CauseSortie: string
{
    /** Sortie pour boucherie. */
    case Boucherie = 'B';

    /** Sortie pour auto-consommation. */
    case Consommation = 'C';

    /** Sortie pour élevage ou vente. */
    case Elevage = 'E';

    /** Sortie pour mort. */
    case Mort = 'M';

    /** Sortie pour prêt ou pension. */
    case PretOuPension = 'H';

    /** Autre cause (réservée aux reprises / données historiques). */
    case Autre = 'X';
}
  • Step 3: Créer CategorieBovinIPG

Contenu complet de src/Bovin/Enum/CategorieBovinIPG.php :

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Enum;

/**
 * Catégorie IPG d'un bovin (champ optionnel de `IpBCreateEntree`).
 *
 * Source : `resources/ednotif-ws/CategorieBovinIPG.XSD`.
 * Le `.value` est le code IPG (2 lettres) transmis dans le payload SOAP.
 * Les case names suivent le code XSD, les libellés sont en docblock.
 */
enum CategorieBovinIPG: string
{
    /** Boeuf. */
    case BO = 'BO';

    /** Broutard. */
    case BR = 'BR';

    /** Femelle à l'engraissement. */
    case FE = 'FE';

    /** Génisse laitière. */
    case GL = 'GL';

    /** Génisse viande. */
    case GV = 'GV';

    /** Mâle. */
    case MA = 'MA';

    /** Mâle reproducteur. */
    case MR = 'MR';

    /** Taurillon. */
    case TA = 'TA';

    /** Vache allaitante. */
    case VA = 'VA';

    /** Veau de boucherie. */
    case VB = 'VB';

    /** Veau. */
    case VE = 'VE';

    /** Vache laitière. */
    case VL = 'VL';

    /** Vache de réforme. */
    case VR = 'VR';
}
  • Step 4: Vérifier la suite (pas d'impact)

Run :

make test

Expected : toujours 56 tests / 107 assertions verts (les enums ne sont pas encore référencés).

  • Step 5: Commit
git add src/Bovin/Enum/
git commit -m "feat : enums CauseEntree, CauseSortie, CategorieBovinIPG"

Task 2 — Les 2 request DTOs

Pure data, dépend des enums de Task 1 et de BovinRef/ExploitationRef existants.

Files:

  • Create: src/Bovin/Dto/CreateEntreeRequest.php
  • Create: src/Bovin/Dto/CreateSortieRequest.php

Steps

  • Step 1: Créer CreateEntreeRequest

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CategorieBovinIPG;
use Malio\EdnotifBundle\Bovin\Enum\CauseEntree;

/**
 * Paramètres d'une déclaration d'entrée bovin (opération `IpBCreateEntree`).
 *
 * `codeAtelier` suit le pattern XSD `[LABEM][1-9]` (L=Lait, A=Allaitant,
 * B=Veaux de boucherie, E=Engraissement autre, M=Manade). Non validé côté
 * bundle : EDNOTIF rejette la valeur malformée via `EdnotifException`.
 */
final readonly class CreateEntreeRequest
{
    public function __construct(
        public BovinRef $bovin,
        public DateTimeInterface $date,
        public CauseEntree $cause,
        public ExploitationRef $provenance,
        public ?string $codeAtelier = null,
        public ?CategorieBovinIPG $codeCategorieBovin = null,
    ) {}
}
  • Step 2: Créer CreateSortieRequest

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CauseSortie;

/**
 * Paramètres d'une déclaration de sortie bovin (opération `IpBCreateSortie`).
 */
final readonly class CreateSortieRequest
{
    public function __construct(
        public BovinRef $bovin,
        public DateTimeInterface $date,
        public CauseSortie $cause,
        public ExploitationRef $destination,
    ) {}
}
  • Step 3: Vérifier la suite (pas d'impact)

Run :

make test

Expected : 56 tests toujours verts.

  • Step 4: Commit
git add src/Bovin/Dto/CreateEntreeRequest.php src/Bovin/Dto/CreateSortieRequest.php
git commit -m "feat : request DTOs pour createEntree et createSortie"

Task 3 — CreateEntreeResponseDto + Mapper + Tests (TDD)

Files:

  • Create: src/Bovin/Dto/CreateEntreeResponseDto.php
  • Create: src/Bovin/Mapper/CreateEntreeResponseMapper.php
  • Create: tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php

Steps

  • Step 1: Écrire le test (RED)

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

<?php

declare(strict_types=1);

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

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(CreateEntreeResponseMapper::class)]
final class CreateEntreeResponseMapperTest extends TestCase
{
    public function testMapPendingBdniValidation(): void
    {
        $soapResponse                                            = new stdClass();
        $soapResponse->ReponseStandard                           = new stdClass();
        $soapResponse->ReponseStandard->Resultat                 = true;
        $soapResponse->ReponseSpecifique                         = new stdClass();
        $soapResponse->ReponseSpecifique->AttenteValidationBDNi  = true;

        $dto = (new CreateEntreeResponseMapper(new StandardResponseMapper()))->map($soapResponse);

        self::assertInstanceOf(CreateEntreeResponseDto::class, $dto);
        self::assertTrue($dto->standardResponse->result);
        self::assertTrue($dto->pendingBdniValidation);
        self::assertNull($dto->identification);
        self::assertNull($dto->entryMovement);
    }

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

        $validee                                = new stdClass();
        $validee->IdentiteBovin                 = new stdClass();
        $validee->IdentiteBovin->Sexe           = 'F';
        $validee->IdentiteBovin->Bovin          = new stdClass();
        $validee->IdentiteBovin->Bovin->CodePays       = 'FR';
        $validee->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';

        $validee->MouvementEntreeBovin               = new stdClass();
        $validee->MouvementEntreeBovin->DateEntree   = '2026-04-22';
        $validee->MouvementEntreeBovin->CauseEntree  = 'A';

        $soapResponse->ReponseSpecifique->EntreeValidee = $validee;

        $dto = (new CreateEntreeResponseMapper(new StandardResponseMapper()))->map($soapResponse);

        self::assertFalse($dto->pendingBdniValidation);
        self::assertNotNull($dto->identification);
        self::assertSame('F', $dto->identification->sex);
        self::assertSame('FR1234567890', $dto->identification->bovin?->nationalNumber);
        self::assertNotNull($dto->entryMovement);
        self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->entryMovement->date);
        self::assertSame('A', $dto->entryMovement->cause);
    }
}
  • Step 2: Lancer le test (doit échouer)

Run :

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

Expected : erreur Class ... not found sur CreateEntreeResponseDto ou CreateEntreeResponseMapper.

  • Step 3: Créer CreateEntreeResponseDto

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

/**
 * Réponse de `IpBCreateEntree`.
 *
 * Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
 * attend la validation asynchrone de la BDNi — `identification` et
 * `entryMovement` sont `null`. Sinon, les deux sont populés avec les données
 * validées du bovin et du mouvement d'entrée.
 */
final readonly class CreateEntreeResponseDto
{
    public function __construct(
        public StandardResponseDto $standardResponse,
        public bool $pendingBdniValidation,
        public ?BovinIdentificationDto $identification,
        public ?MovementDto $entryMovement,
        public ?object $rawSoapResponse,
    ) {}
}
  • Step 4: Créer CreateEntreeResponseMapper

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;

final class CreateEntreeResponseMapper
{
    use BovinNodeMappingTrait;

    public function __construct(
        private readonly StandardResponseMapper $standardResponseMapper,
    ) {}

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

        $specific = $soapResponse->ReponseSpecifique ?? null;

        $pending        = false;
        $identification = null;
        $entryMovement  = null;

        if (is_object($specific)) {
            $pendingFlag = $specific->AttenteValidationBDNi ?? null;
            if (null !== $pendingFlag) {
                $pending = (bool) $this->toNullableBool($pendingFlag);
            }

            $validee = $specific->EntreeValidee ?? null;
            if (is_object($validee)) {
                $identityNode = $validee->IdentiteBovin ?? null;
                if (is_object($identityNode)) {
                    $identification = $this->mapIdentification($identityNode);
                }

                $movementNode = $validee->MouvementEntreeBovin ?? null;
                if (is_object($movementNode)) {
                    $entryMovement = $this->mapMovement($movementNode, 'entry');
                }
            }
        }

        return new CreateEntreeResponseDto(
            standardResponse: $standardResponse,
            pendingBdniValidation: $pending,
            identification: $identification,
            entryMovement: $entryMovement,
            rawSoapResponse: $soapResponse,
        );
    }
}
  • Step 5: Vérifier GREEN

Run :

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

Expected : 2 tests passent.

  • Step 6: Vérifier la suite complète

Run :

make test

Expected : 58 tests verts (56 existants + 2 nouveaux).

  • Step 7: Commit
git add src/Bovin/Dto/CreateEntreeResponseDto.php \
        src/Bovin/Mapper/CreateEntreeResponseMapper.php \
        tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php
git commit -m "feat : DTO et mapper de réponse pour IpBCreateEntree"

Task 4 — Câbler createEntree dans BovinApi

Files:

  • Modify: src/Bovin/Api/BovinApiInterface.php
  • Modify: src/Bovin/Api/BovinApi.php
  • Modify: config/services.php

Steps

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

Dans src/Bovin/Api/BovinApiInterface.php, ajouter l'import et la méthode.

Ajouter ces imports en tête (après ceux déjà présents) :

use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;

Ajouter la méthode juste après getPresumedExits(): PresumedExitsDto; :

    public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto;
  • Step 2: Étendre BovinApi

Dans src/Bovin/Api/BovinApi.php, ajouter les imports suivants (alphabétiques) :

use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;

Étendre le constructeur en ajoutant la nouvelle dépendance juste après $presumedExitsMapper, avant $zipMessageDecoder :

        private PresumedExitsMapper $presumedExitsMapper,
        private CreateEntreeResponseMapper $createEntreeResponseMapper,
        private ZipMessageDecoder $zipMessageDecoder,

Ajouter la méthode createEntree juste après getPresumedExits() :

    public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto
    {
        $token = $this->tokenProvider->getToken();

        $payload = [
            'JetonAuthentification' => $token,
            'Exploitation'          => [
                'CodePays'           => $this->exploitationCountryCode,
                'NumeroExploitation' => $this->exploitationNumber,
            ],
            'Bovin' => [
                'CodePays'       => $request->bovin->countryCode,
                'NumeroNational' => $request->bovin->nationalNumber,
            ],
            'DateEntree'  => $request->date->format('Y-m-d'),
            'CauseEntree' => $request->cause->value,
            'ExploitationProvenance' => [
                'CodePays'           => $request->provenance->countryCode,
                'NumeroExploitation' => $request->provenance->exploitationNumber,
            ],
        ];
        if (null !== $request->codeAtelier) {
            $payload['CodeAtelier'] = $request->codeAtelier;
        }
        if (null !== $request->codeCategorieBovin) {
            $payload['CodeCategorieBovin'] = $request->codeCategorieBovin->value;
        }

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

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

        return $this->createEntreeResponseMapper->map($soapResponse);
    }
  • Step 3: Enregistrer le mapper dans config/services.php

Ajouter l'import :

use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;

Enregistrer le mapper juste après PresumedExitsMapper (lignes 61-65 dans le fichier actuel) :

    $services->set(CreateEntreeResponseMapper::class)
        ->args([
            service(StandardResponseMapper::class),
        ])
    ;

Mettre à jour le bloc BovinApi en insérant le service après PresumedExitsMapper et avant ZipMessageDecoder :

    $services->set(BovinApi::class)
        ->args([
            service(TokenProvider::class),
            service('ednotif.soap.business'),
            service(AnimalFileMapper::class),
            service(InventoryMapper::class),
            service(ReturnedDossiersMapper::class),
            service(PresumedExitsMapper::class),
            service(CreateEntreeResponseMapper::class),
            service(ZipMessageDecoder::class),
            '%ednotif.exploitation_country_code%',
            '%ednotif.exploitation_number%',
        ])
    ;
  • Step 4: Vérifier la suite

Run :

make test

Expected : 58 tests toujours verts (pas de nouveau test, mais pas de régression non plus).

  • Step 5: Commit
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
git commit -m "feat : expose IpBCreateEntree via BovinApi::createEntree"

Task 5 — CreateSortieResponseDto + Mapper + Tests (TDD)

Files:

  • Create: src/Bovin/Dto/CreateSortieResponseDto.php
  • Create: src/Bovin/Mapper/CreateSortieResponseMapper.php
  • Create: tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php

Steps

  • Step 1: Écrire le test (RED)

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

<?php

declare(strict_types=1);

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

use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;

#[CoversClass(CreateSortieResponseMapper::class)]
final class CreateSortieResponseMapperTest extends TestCase
{
    public function testMapPendingBdniValidation(): void
    {
        $soapResponse                                            = new stdClass();
        $soapResponse->ReponseStandard                           = new stdClass();
        $soapResponse->ReponseStandard->Resultat                 = true;
        $soapResponse->ReponseSpecifique                         = new stdClass();
        $soapResponse->ReponseSpecifique->AttenteValidationBDNi  = true;

        $dto = (new CreateSortieResponseMapper(new StandardResponseMapper()))->map($soapResponse);

        self::assertInstanceOf(CreateSortieResponseDto::class, $dto);
        self::assertTrue($dto->pendingBdniValidation);
        self::assertNull($dto->identification);
        self::assertNull($dto->entryMovement);
        self::assertNull($dto->exitMovement);
    }

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

        $validee                                        = new stdClass();
        $validee->IdentiteBovin                         = new stdClass();
        $validee->IdentiteBovin->Sexe                   = 'M';
        $validee->IdentiteBovin->Bovin                  = new stdClass();
        $validee->IdentiteBovin->Bovin->NumeroNational  = 'FR9999999999';

        $mouvement                                       = new stdClass();
        $mouvement->MouvementEntreeBovin                 = new stdClass();
        $mouvement->MouvementEntreeBovin->DateEntree     = '2024-01-10';
        $mouvement->MouvementEntreeBovin->CauseEntree    = 'A';

        $mouvement->MouvementSortieBovin                 = new stdClass();
        $mouvement->MouvementSortieBovin->DateSortie     = '2026-04-22';
        $mouvement->MouvementSortieBovin->CauseSortie    = 'B';

        $validee->MouvementBovin = $mouvement;

        $soapResponse->ReponseSpecifique->SortieValidee = $validee;

        $dto = (new CreateSortieResponseMapper(new StandardResponseMapper()))->map($soapResponse);

        self::assertFalse($dto->pendingBdniValidation);
        self::assertNotNull($dto->identification);
        self::assertSame('M', $dto->identification->sex);
        self::assertSame('FR9999999999', $dto->identification->bovin?->nationalNumber);
        self::assertNotNull($dto->entryMovement);
        self::assertEquals(new DateTimeImmutable('2024-01-10'), $dto->entryMovement->date);
        self::assertSame('A', $dto->entryMovement->cause);
        self::assertNotNull($dto->exitMovement);
        self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->exitMovement->date);
        self::assertSame('B', $dto->exitMovement->cause);
    }
}
  • Step 2: Lancer le test (doit échouer)

Run :

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

Expected : classe introuvable.

  • Step 3: Créer CreateSortieResponseDto

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Dto;

use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;

/**
 * Réponse de `IpBCreateSortie`.
 *
 * Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
 * attend la validation asynchrone de la BDNi — `identification`, `entryMovement`
 * et `exitMovement` sont `null`. Sinon, EDNOTIF renvoie la **période de présence
 * clôturée** : l'entrée initiale du bovin sur l'exploitation (`entryMovement`)
 * **et** la sortie qui vient d'être déclarée (`exitMovement`).
 */
final readonly class CreateSortieResponseDto
{
    public function __construct(
        public StandardResponseDto $standardResponse,
        public bool $pendingBdniValidation,
        public ?BovinIdentificationDto $identification,
        public ?MovementDto $entryMovement,
        public ?MovementDto $exitMovement,
        public ?object $rawSoapResponse,
    ) {}
}
  • Step 4: Créer CreateSortieResponseMapper

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

<?php

declare(strict_types=1);

namespace Malio\EdnotifBundle\Bovin\Mapper;

use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;

final class CreateSortieResponseMapper
{
    use BovinNodeMappingTrait;

    public function __construct(
        private readonly StandardResponseMapper $standardResponseMapper,
    ) {}

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

        $specific = $soapResponse->ReponseSpecifique ?? null;

        $pending        = false;
        $identification = null;
        $entryMovement  = null;
        $exitMovement   = null;

        if (is_object($specific)) {
            $pendingFlag = $specific->AttenteValidationBDNi ?? null;
            if (null !== $pendingFlag) {
                $pending = (bool) $this->toNullableBool($pendingFlag);
            }

            $validee = $specific->SortieValidee ?? null;
            if (is_object($validee)) {
                $identityNode = $validee->IdentiteBovin ?? null;
                if (is_object($identityNode)) {
                    $identification = $this->mapIdentification($identityNode);
                }

                $mouvementBovin = $validee->MouvementBovin ?? null;
                if (is_object($mouvementBovin)) {
                    $entryNode = $mouvementBovin->MouvementEntreeBovin ?? null;
                    if (is_object($entryNode)) {
                        $entryMovement = $this->mapMovement($entryNode, 'entry');
                    }

                    $exitNode = $mouvementBovin->MouvementSortieBovin ?? null;
                    if (is_object($exitNode)) {
                        $exitMovement = $this->mapMovement($exitNode, 'exit');
                    }
                }
            }
        }

        return new CreateSortieResponseDto(
            standardResponse: $standardResponse,
            pendingBdniValidation: $pending,
            identification: $identification,
            entryMovement: $entryMovement,
            exitMovement: $exitMovement,
            rawSoapResponse: $soapResponse,
        );
    }
}
  • Step 5: Vérifier GREEN

Run :

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

Expected : 2 tests passent.

  • Step 6: Vérifier la suite complète

Run :

make test

Expected : 60 tests verts (58 + 2).

  • Step 7: Commit
git add src/Bovin/Dto/CreateSortieResponseDto.php \
        src/Bovin/Mapper/CreateSortieResponseMapper.php \
        tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php
git commit -m "feat : DTO et mapper de réponse pour IpBCreateSortie"

Task 6 — Câbler createSortie dans BovinApi

Files:

  • Modify: src/Bovin/Api/BovinApiInterface.php
  • Modify: src/Bovin/Api/BovinApi.php
  • Modify: config/services.php

Steps

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

Dans src/Bovin/Api/BovinApiInterface.php, ajouter les imports :

use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;

Ajouter la méthode après createEntree(...): CreateEntreeResponseDto; :

    public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto;
  • Step 2: Étendre BovinApi

Ajouter les imports dans src/Bovin/Api/BovinApi.php :

use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;

Ajouter la dépendance au constructeur juste après $createEntreeResponseMapper :

        private CreateEntreeResponseMapper $createEntreeResponseMapper,
        private CreateSortieResponseMapper $createSortieResponseMapper,
        private ZipMessageDecoder $zipMessageDecoder,

Ajouter la méthode après createEntree(...) :

    public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto
    {
        $token = $this->tokenProvider->getToken();

        $payload = [
            'JetonAuthentification' => $token,
            'Exploitation'          => [
                'CodePays'           => $this->exploitationCountryCode,
                'NumeroExploitation' => $this->exploitationNumber,
            ],
            'Bovin' => [
                'CodePays'       => $request->bovin->countryCode,
                'NumeroNational' => $request->bovin->nationalNumber,
            ],
            'DateSortie'  => $request->date->format('Y-m-d'),
            'CauseSortie' => $request->cause->value,
            'ExploitationDestination' => [
                'CodePays'           => $request->destination->countryCode,
                'NumeroExploitation' => $request->destination->exploitationNumber,
            ],
        ];

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

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

        return $this->createSortieResponseMapper->map($soapResponse);
    }
  • Step 3: Enregistrer le mapper dans config/services.php

Ajouter l'import :

use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;

Enregistrer le mapper juste après CreateEntreeResponseMapper :

    $services->set(CreateSortieResponseMapper::class)
        ->args([
            service(StandardResponseMapper::class),
        ])
    ;

Mettre à jour le bloc BovinApi en insérant le service après CreateEntreeResponseMapper :

    $services->set(BovinApi::class)
        ->args([
            service(TokenProvider::class),
            service('ednotif.soap.business'),
            service(AnimalFileMapper::class),
            service(InventoryMapper::class),
            service(ReturnedDossiersMapper::class),
            service(PresumedExitsMapper::class),
            service(CreateEntreeResponseMapper::class),
            service(CreateSortieResponseMapper::class),
            service(ZipMessageDecoder::class),
            '%ednotif.exploitation_country_code%',
            '%ednotif.exploitation_number%',
        ])
    ;
  • Step 4: Vérifier la suite complète

Run :

make test

Expected : 60 tests toujours verts. Le constructeur de BovinApi a maintenant 11 args.

  • Step 5: Commit
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
git commit -m "feat : expose IpBCreateSortie via BovinApi::createSortie"

Checklist finale

  • make test vert, 60 tests / ~115 assertions
  • 6 commits propres, un par Task (aucune tâche ne laisse la suite rouge entre deux commits)
  • BovinApiInterface expose 6 méthodes au total (4 reads + 2 writes)
  • BovinApi constructeur : 11 args
  • config/services.php : 11 services enregistrés dans le bloc BovinApi->args()
  • Pas de validation client-side, pas de test SOAP mock — conforme au scope exclu