Files
ednotif-bundle/docs/superpowers/specs/2026-04-22-phase2-create-entree-sortie-design.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

14 KiB
Raw Permalink Blame History

Phase 2 — Lot 1 : IpBCreateEntree + IpBCreateSortie

Contexte

Phase 1 a livré les 4 lectures bovin (getAnimalFile, getInventory, getReturnedDossiers, getPresumedExits). Phase 2 démarre les opérations d'écriture : Ferme a besoin de déclarer ses entrées et sorties d'animaux auprès de l'IPG pour la saison à venir.

Ces 2 opérations partagent 80% de l'infrastructure (pattern SOAP, enveloppe ReponseStandard, factorisation déjà faite via StandardResponseMapper et BovinNodeMappingTrait), donc on les traite dans un spec commun.

But

Ajouter 2 méthodes à BovinApiInterface : createEntree(CreateEntreeRequest) et createSortie(CreateSortieRequest), qui appellent les opérations SOAP IpBCreateEntree / IpBCreateSortie du WS wsIpBNotif et retournent des DTOs typés.

Décisions d'ergonomie (validées en brainstorming)

Axe Décision Raison
API d'appel Request DTOs dédiés (un par op) Testable, futur-compatible avec buffering de drafts.
Codes métier (CauseEntree, CauseSortie) Enums backed-by-string, case names = libellés métier, .value = code IPG Lecture explicite côté consommateur, SOAP reçoit le code via .value.
CategorieBovinIPG Enum backed-by-string, case names = codes 2-lettres IPG 13 cases avec libellés XSD courts, pas de gain à les renommer.
Code atelier ?string free-form Pattern [LABEM][1-9] = 45 combinaisons, enum serait trop lourd.
Réponse (choice BDNi pending / validée) DTO plat avec bool $pendingBdniValidation + nullable fields Cohérent avec les DTOs existants du bundle.
Validation client-side Aucune EDNOTIF rejette via EdnotifException ; valeurs arrivent déjà validées en amont.
Scope 2 ops en un seul spec/plan Partage d'infra, priorités métier identiques.

Architecture — fichiers

À créer

src/Bovin/Enum/CauseEntree.php                  enum 3 cases (P, A, N)
src/Bovin/Enum/CauseSortie.php                  enum 6 cases (H, C, M, B, E, X)
src/Bovin/Enum/CategorieBovinIPG.php            enum 13 cases (BO, BR, FE, ...)
src/Bovin/Dto/CreateEntreeRequest.php           request DTO
src/Bovin/Dto/CreateEntreeResponseDto.php       response DTO
src/Bovin/Dto/CreateSortieRequest.php           request DTO
src/Bovin/Dto/CreateSortieResponseDto.php       response DTO
src/Bovin/Mapper/CreateEntreeResponseMapper.php mapper de la réponse IpBCreateEntree
src/Bovin/Mapper/CreateSortieResponseMapper.php mapper de la réponse IpBCreateSortie
tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php  2 tests (pending + validée)
tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php  2 tests (pending + validée)

À modifier

src/Bovin/Api/BovinApiInterface.php   +2 méthodes
src/Bovin/Api/BovinApi.php            +2 méthodes, +2 mapper deps dans le constructeur
config/services.php                    enregistrer les 2 mappers + updater BovinApi args

Enums

Conventions :

  • Backed-by-string : .value = code IPG exact (ce qui part dans le payload SOAP).
  • Case names = libellés métier pour CauseEntree/CauseSortie (lisibles côté consommateur).
  • Case names = codes 2-lettres pour CategorieBovinIPG (13 cases, libellés XSD courts, pas de gain à les renommer).
  • Docblock sur chaque case pour rappeler la correspondance.
  • Pas de méthode libelle() ni de values() — YAGNI, à ajouter si un besoin métier concret remonte (I18N, UI de sélection).

Note sur les codes P/H/X : la Table 9 IPG marque ces codes comme ambigus (entrée ET sortie selon le contexte). Côté WSDL EDNOTIF, cette ambiguïté n'existe pas : chaque op a son propre enum XSD restrictif (CauseEntreeType = {P, A, N}, CauseSortieType = {H, C, M, B, E, X}). Le sens est porté par l'op appelée, pas par le code. Le bundle n'a donc rien à faire de particulier à ce sujet.

Malio\EdnotifBundle\Bovin\Enum\CauseEntree

Source : CauseEntree.XSD + doc IPG Table 9.

enum CauseEntree: string
{
    case Achat         = 'A';  // Entrée par achat
    case Naissance     = 'N';  // Entrée par naissance
    case PretOuPension = 'P';  // Entrée par prêt ou pension
}

Malio\EdnotifBundle\Bovin\Enum\CauseSortie

Source : CauseSortie.XSD + doc IPG Table 9.

enum CauseSortie: string
{
    case Boucherie     = 'B';  // Sortie pour boucherie
    case Consommation  = 'C';  // Sortie pour auto-consommation
    case Elevage       = 'E';  // Sortie pour élevage ou vente
    case Mort          = 'M';  // Sortie pour mort
    case PretOuPension = 'H';  // Sortie pour prêt ou pension (H sur sortie = équivalent du P sur entrée)
    case Autre         = 'X';  // Autre cause (réservée reprise / données historiques)
}

Malio\EdnotifBundle\Bovin\Enum\CategorieBovinIPG

Source : CategorieBovinIPG.XSD. 13 cases documentés depuis les <documentation> du XSD :

Case Libellé XSD
BO Boeuf
BR Broutard
FE Femelle à l'engraissement
GL Génisse laitière
GV Génisse viande
MA Mâle
MR Mâle reproducteur
TA Taurillon
VA Vache allaitante
VB Veau de boucherie
VE Veau
VL Vache laitière
VR Vache de réforme

Request DTOs

CreateEntreeRequest

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,
    ) {}
}
  • bovin / provenance réutilisent les DTOs existants (BovinRef, ExploitationRef).
  • codeAtelier : string libre, pattern [LABEM][1-9] documenté en phpdoc.
  • Les 2 derniers sont optionnels comme dans le XSD (minOccurs="0").

CreateSortieRequest

final readonly class CreateSortieRequest
{
    public function __construct(
        public BovinRef $bovin,
        public DateTimeInterface $date,
        public CauseSortie $cause,
        public ExploitationRef $destination,
    ) {}
}

Pas de champs optionnels côté Sortie (tous requis dans le XSD).

Response DTOs

CreateEntreeResponseDto

final readonly class CreateEntreeResponseDto
{
    public function __construct(
        public StandardResponseDto $standardResponse,
        public bool $pendingBdniValidation,
        public ?BovinIdentificationDto $identification,
        public ?MovementDto $entryMovement,
        public ?object $rawSoapResponse,
    ) {}
}

Invariant : si $pendingBdniValidation === true, alors $identification === null et $entryMovement === null. Inversement, si $pendingBdniValidation === false, les 2 autres sont populés.

CreateSortieResponseDto

final readonly class CreateSortieResponseDto
{
    public function __construct(
        public StandardResponseDto $standardResponse,
        public bool $pendingBdniValidation,
        public ?BovinIdentificationDto $identification,
        public ?MovementDto $entryMovement,    // première entrée de la période clôturée
        public ?MovementDto $exitMovement,     // sortie elle-même
        public ?object $rawSoapResponse,
    ) {}
}

La réponse SortieValidee renvoie la période de présence complète (entrée + sortie de l'animal sur l'exploitation), d'où les 2 MovementDto.

Mappers

CreateEntreeResponseMapper

final class CreateEntreeResponseMapper
{
    use BovinNodeMappingTrait;

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

    public function map(object $soapResponse): CreateEntreeResponseDto
    {
        // 1. mapStandardResponse
        // 2. Lire ReponseSpecifique.AttenteValidationBDNi (bool) ou .EntreeValidee (struct)
        //    Le choice XSD garantit une seule des deux présente.
        // 3. Si pending : retourne le DTO avec pending=true, identification/entryMovement=null
        //    Sinon : mapIdentification (trait) + mapMovement (trait) sur EntreeValidee
    }
}

CreateSortieResponseMapper

Même pattern, mais la branche validée lit SortieValidee.MouvementBovin.MouvementEntreeBovin et SortieValidee.MouvementBovin.MouvementSortieBovin. Deux appels à mapMovement au lieu d'un.

Pourquoi 2 mappers séparés

Les shapes de EntreeValidee et SortieValidee diffèrent : entrée plate (juste l'entrée), sortie imbriquée (entrée + sortie). Factoriser maintenant obligerait à introduire des paramètres de configuration abstraits (noms de champs, nombre de movements attendus) qui n'apportent rien pour 2 mappers. Reconsidérer si un 3ᵉ Create* arrive avec une shape similaire.

API methods

BovinApiInterface

public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto;

public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto;

BovinApi::createEntree

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 {
        $soapResponse = $this->businessClient->__soapCall('IpBCreateEntree', [$payload]);
    } catch (SoapFault $e) {
        throw new RuntimeException('SOAP Fault on IpBCreateEntree: '.$e->getMessage(), 0, $e);
    }

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

    return $this->createEntreeResponseMapper->map($soapResponse);
}

BovinApi::createSortie

Pattern identique. Payload plus simple (4 champs métier), op IpBCreateSortie, mapper dédié.

Injection

Constructeur BovinApi passe de 9 à 11 args (ajout de CreateEntreeResponseMapper et CreateSortieResponseMapper entre PresumedExitsMapper et ZipMessageDecoder).

config/services.php :

  • Enregistrer CreateEntreeResponseMapper et CreateSortieResponseMapper avec service(StandardResponseMapper::class) en dep.
  • Updater BovinApi->args() pour refléter les 11 args.

Tests

Scope

  • Mappers : 2 tests chacun × 2 mappers = 4 tests (cas pending + cas validée).
  • Enums : aucun test dédié. Les enums sont auto-testés par leur usage dans les request DTOs.
  • Request DTOs : aucun test dédié. Constructeur simple, pas de logique.
  • Response DTOs : aucun test dédié. Même raison.
  • API methods (createEntree, createSortie) : pas testés en l'état. Le bundle n'a pas d'infrastructure de mock SoapClient. Les mappers couvrent 95% de la logique, le reste étant du payload-shaping trivial. Si un bug apparaît plus tard côté appel SOAP, on ajoutera un test avec un mock.

Fixtures attendues (résumé)

  • CreateEntreeResponseMapperTest::testMapPendingValidation — réponse avec AttenteValidationBDNi=true, assert que pendingBdniValidation === true et que les 2 fields bovin sont null.
  • CreateEntreeResponseMapperTest::testMapValidatedEntry — réponse avec EntreeValidee populée, assert pendingBdniValidation === false, identification + entryMovement bien mappés.
  • Idem pour CreateSortieResponseMapperTest, avec la shape imbriquée SortieValidee.MouvementBovin.

Compte attendu post-implémentation : 56 + 4 = 60 tests.

Gestion d'erreurs

  • Resultat=false côté EDNOTIF : assertSuccessfulResponse() lève EdnotifException — même pattern que les reads, pas de nouveau code.
  • SoapFault : remonté en RuntimeException wrapping.
  • Réponse "validée" mais champs absents (shouldn't happen selon XSD) : mapper renvoie un DTO avec les fields null. Pas d'exception levée — l'anomalie remonte via la lecture logique par le consommateur.

Périmètre EXCLU (explicite)

  • Validation client-side des inputs (pattern nationalNumber = 10 chiffres, codeAtelier = [LABEM][1-9], etc.)
  • Factorisation d'un CreateResponseMapper abstrait/trait — à reconsidérer quand le 3ᵉ Create* arrivera.
  • Gestion métier du statut pendingBdniValidation côté bundle (polling, callback). C'est la responsabilité de Ferme — le bundle expose juste l'info.
  • Tests d'intégration SOAP avec mock client.
  • Les 8 autres opérations IpBCreate* (naissance, mort-né, rebouclage, échange, import, insémination, commande boucles). Elles feront l'objet de spec/plan séparés en Phase 2 Lot 2+.

Impact sur le consommateur (Ferme)

Aucune breaking change — les 4 méthodes de lecture existantes gardent leur signature. Les 2 nouvelles méthodes sont additives sur BovinApiInterface.

Mise à jour côté Ferme : composer update malio/ednotif-bundle une fois la PR mergée, puis usage direct :

$response = $this->ednotif->createEntree(new CreateEntreeRequest(
    bovin: new BovinRef('FR', 'FR1234567890'),
    date: new DateTimeImmutable('2026-04-22'),
    cause: CauseEntree::Achat,
    provenance: new ExploitationRef('FR', '12345678'),
));

if ($response->pendingBdniValidation) {
    // planifier un getReturnedDossiers dans quelques jours
} else {
    // l'animal est dans le cheptel : $response->identification->bovin->nationalNumber
}