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

331 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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`
```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
}
```