docs : spec Phase 2 lot 1 - createEntree + createSortie
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
# 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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user