Files
ednotif-bundle/docs/superpowers/specs/2026-04-22-phase2-create-entree-sortie-design.md
2026-04-22 14:21:45 +02:00

314 lines
13 KiB
Markdown
Raw 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, CategorieBovinIPG) | Enums backed-by-string, case names = codes IPG | Type-safe sans ambiguïté de traduction ; libellés documentés en phpdoc. |
| 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** avec case name == valeur (ex : `case A = 'A'`).
- Docblock sur chaque case avec le libellé issu du XSD (à titre informatif, pas exposé en runtime).
- Pas de méthode `libelle()` ni de `values()` — YAGNI, à ajouter si un besoin métier concret remonte (I18N, UI de sélection).
### `Malio\EdnotifBundle\Bovin\Enum\CauseEntree`
```php
enum CauseEntree: string
{
case P = 'P'; // libellé à confirmer côté métier (Prêt ?)
case A = 'A'; // libellé à confirmer côté métier (Achat ?)
case N = 'N'; // libellé à confirmer côté métier (Naissance/Nouveau ?)
}
```
Note : les libellés exacts ne sont pas dans le XSD (pas de `<documentation>` sur les enumerations de `CauseEntree.XSD`). Laissés en "à confirmer côté métier" dans le docblock pour que Ferme complète ; la correctness de l'appel SOAP ne dépend que du code.
### `Malio\EdnotifBundle\Bovin\Enum\CauseSortie`
Même structure, 6 cases : `H, C, M, B, E, X`. Libellés à confirmer.
### `Malio\EdnotifBundle\Bovin\Enum\CategorieBovinIPG`
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::A,
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
}
```