# 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. ```php 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. ```php 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 `` 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` ```php 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` ```php 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` ```php 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` ```php 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` ```php 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` ```php public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto; public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto; ``` ### `BovinApi::createEntree` ```php 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 : ```php $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 } ```