3 Commits

Author SHA1 Message Date
8e8f955ff3 feat: ajout des notifications d'entrées/sorties (#5)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 38s
| 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
a60a6e46de feat : parser SerieBoucles dans EarTagSeriesDto typé (#4)
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build Release Artefact / build (push) Successful in 41s
| 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: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-22 09:11:53 +00:00
366143ce36 test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto (#3)
Some checks failed
Auto Tag Develop / tag (push) Failing after 16s
| 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: #3
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-21 15:52:24 +00:00
24 changed files with 3085 additions and 5 deletions

View File

@@ -7,6 +7,8 @@ use Malio\EdnotifBundle\Bovin\Api\BovinApi;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
@@ -64,6 +66,18 @@ return static function (ContainerConfigurator $container): void {
])
;
$services->set(CreateEntreeResponseMapper::class)
->args([
service(StandardResponseMapper::class),
])
;
$services->set(CreateSortieResponseMapper::class)
->args([
service(StandardResponseMapper::class),
])
;
$services->set(TokenProvider::class)
->args([
service('ednotif.soap.guichet'),
@@ -85,6 +99,8 @@ return static function (ContainerConfigurator $container): void {
service(InventoryMapper::class),
service(ReturnedDossiersMapper::class),
service(PresumedExitsMapper::class),
service(CreateEntreeResponseMapper::class),
service(CreateSortieResponseMapper::class),
service(ZipMessageDecoder::class),
'%ednotif.exploitation_country_code%',
'%ednotif.exploitation_number%',

View File

@@ -0,0 +1,375 @@
# Dette technique post-Phase 1
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Combler les deux trous de couverture flaggés par les reviews de Phase 1 avant de démarrer Phase 2 (écriture bovin), pour éviter que les nouveaux mappers empilent de la complexité sur des helpers non-pinnés.
**Architecture:** Deux fichiers de tests seulement. (1) Un nouveau `BovinNodeMappingTraitTest` qui couvre directement les 5 helpers scalaires du trait via une classe adapter anonyme — jusqu'ici ces helpers n'étaient testés qu'indirectement par les mappers, donc leurs edge cases dérivent sans qu'on s'en rende compte. (2) Des tests adversariaux ajoutés à `InventoryMapperTest` pour pinner les shapes dégradées qui peuvent arriver en prod (`StockBoucles` absent, `DateFin` manquant, `SerieBoucles` en liste, `NbBovins` non-numérique). Pas de changement de code de production — uniquement du test. Un petit docblock ajouté à `EarTagSeriesDto` pour documenter son statut Phase-1.
**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance.
---
## Task 1 — Tests directs des helpers scalaires du trait
**But** : pinner le comportement de `normalizeToList`, `toNullableString`, `toNullableInt`, `toNullableBool`, `toNullableDate` — les 5 helpers purs que chaque nouveau mapper va hériter via `use BovinNodeMappingTrait`.
**Approche** : une classe adapter anonyme, définie inline dans le test, qui `use` le trait et re-expose chaque helper en `public` pour l'appeler depuis les tests. Pas de nouvelle production class, pas de refacto du trait.
**Files:**
- Create: `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php`
### Steps
- [ ] **Step 1: Écrire le fichier de test**
Contenu complet de `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Mapper\BovinNodeMappingTrait;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversTrait(BovinNodeMappingTrait::class)]
final class BovinNodeMappingTraitTest extends TestCase
{
/**
* Adapter anonyme : re-expose chaque helper protected en public pour le test,
* sans jamais instancier un vrai mapper (ceux-ci ont des dépendances DI).
*/
private static function adapter(): object
{
return new class {
use BovinNodeMappingTrait {
normalizeToList as public;
toNullableString as public;
toNullableInt as public;
toNullableBool as public;
toNullableDate as public;
}
};
}
// ---------- normalizeToList ----------
public function testNormalizeToListWithNullReturnsEmptyList(): void
{
self::assertSame([], self::adapter()->normalizeToList(null));
}
public function testNormalizeToListWithScalarWrapsIntoList(): void
{
self::assertSame(['foo'], self::adapter()->normalizeToList('foo'));
self::assertSame([42], self::adapter()->normalizeToList(42));
}
public function testNormalizeToListWithObjectWrapsIntoList(): void
{
$obj = new stdClass();
$result = self::adapter()->normalizeToList($obj);
self::assertCount(1, $result);
self::assertSame($obj, $result[0]);
}
public function testNormalizeToListWithListReturnsListUnchanged(): void
{
$input = ['a', 'b', 'c'];
self::assertSame($input, self::adapter()->normalizeToList($input));
}
public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void
{
$input = ['x' => 'a', 'y' => 'b'];
$result = self::adapter()->normalizeToList($input);
self::assertSame(['a', 'b'], $result);
}
// ---------- toNullableString ----------
/** @return iterable<string,array{mixed, ?string}> */
public static function toNullableStringProvider(): iterable
{
yield 'null' => [null, null];
yield 'empty string' => ['', null];
yield 'whitespace only' => [' ', null];
yield 'plain' => ['abc', 'abc'];
yield 'trimmed' => [' abc ', 'abc'];
yield 'int coerced to string' => [42, '42'];
yield 'zero preserved' => ['0', '0'];
}
#[DataProvider('toNullableStringProvider')]
public function testToNullableString(mixed $input, ?string $expected): void
{
self::assertSame($expected, self::adapter()->toNullableString($input));
}
// ---------- toNullableInt ----------
/** @return iterable<string,array{mixed, ?int}> */
public static function toNullableIntProvider(): iterable
{
yield 'null' => [null, null];
yield 'int passthrough' => [42, 42];
yield 'zero int' => [0, 0];
yield 'numeric string' => ['42', 42];
yield 'negative string' => ['-7', -7];
yield 'float-like string' => ['3.14', 3];
yield 'non-numeric' => ['abc', null];
yield 'empty string' => ['', null];
}
#[DataProvider('toNullableIntProvider')]
public function testToNullableInt(mixed $input, ?int $expected): void
{
self::assertSame($expected, self::adapter()->toNullableInt($input));
}
// ---------- toNullableBool ----------
/** @return iterable<string,array{mixed, ?bool}> */
public static function toNullableBoolProvider(): iterable
{
yield 'null' => [null, null];
yield 'true' => [true, true];
yield 'false' => [false, false];
yield 'string 1' => ['1', true];
yield 'string 0' => ['0', false];
yield 'empty string' => ['', false];
yield 'int 1' => [1, true];
yield 'int 0' => [0, false];
}
#[DataProvider('toNullableBoolProvider')]
public function testToNullableBool(mixed $input, ?bool $expected): void
{
self::assertSame($expected, self::adapter()->toNullableBool($input));
}
// ---------- toNullableDate ----------
public function testToNullableDateFromIsoString(): void
{
$result = self::adapter()->toNullableDate('2026-04-21');
self::assertEquals(new DateTimeImmutable('2026-04-21'), $result);
}
public function testToNullableDateFromDateTimeString(): void
{
$result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00');
self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result);
}
/** @return iterable<string,array{mixed}> */
public static function toNullableDateFallbackProvider(): iterable
{
yield 'null' => [null];
yield 'empty string' => [''];
yield 'whitespace' => [' '];
yield 'non-string int' => [42];
yield 'non-string array' => [[]];
yield 'invalid date string' => ['not-a-date'];
}
#[DataProvider('toNullableDateFallbackProvider')]
public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void
{
self::assertNull(self::adapter()->toNullableDate($input));
}
}
```
**Notes importantes** :
- L'adapter anonyme utilise la syntaxe `use BovinNodeMappingTrait { ... as public; }` pour promouvoir chaque helper protected en public *seulement dans ce test*. Le trait lui-même reste inchangé (les helpers restent `protected` pour les vrais mappers).
- Le cas `'string false' => [...]` est **volontairement absent** de `toNullableBoolProvider` : le helper actuel retourne `true` pour `'false'` (string non-vide), ce qui est un bug latent mais pas dans le scope de ce task. Documenté ici pour que tu le retrouves si jamais ça remonte en prod.
- [ ] **Step 2: Lancer le test (doit passer immédiatement)**
Run:
```
make test FILES=tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
```
Expected : tous les tests passent du premier coup. Pas de RED phase ici — on teste du code existant qui marche déjà. Si un test échoue, c'est qu'on a découvert un bug : remonter avant de commit.
Compte attendu : environ **36 test cases** (PHPUnit compte chaque entrée de DataProvider comme un test distinct). L'ordre de grandeur compte plus que le chiffre exact — l'important est que 100% soient verts.
- [ ] **Step 3: Lancer la suite complète pour vérifier qu'il n'y a pas de régression**
Run:
```
make test
```
Expected : 12 tests préexistants + nouveaux tests de ce task, tous verts.
- [ ] **Step 4: Commit**
Run:
```
git add tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
git commit -m "test : pin des helpers scalaires de BovinNodeMappingTrait"
```
---
## Task 2 — Tests adversariaux `InventoryMapper` + docblock `EarTagSeriesDto`
**But** : pinner les shapes dégradées qui peuvent sortir de `ZipMessageDecoder` (message partiel, champ absent, liste à un seul élément venant sous forme d'objet, etc.). Ces cas ne sont pas exotiques : la roundtrip SimpleXML→JSON→stdClass change de shape selon le nombre d'enfants, et EDNOTIF peut omettre des nœuds optionnels. Également, ajouter un docblock sur `EarTagSeriesDto` pour documenter que le raw-node est un choix Phase-1 assumé.
**Files:**
- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php`
- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (ajout docblock uniquement, aucun changement de code)
### Steps
- [ ] **Step 1: Ajouter 5 tests à `InventoryMapperTest`**
Ouvrir `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` et ajouter les 5 méthodes suivantes **après** `testMapInventoryWithoutMessageZipReturnsEmptyLists`, **avant** les helpers privés (`makeSoapResponse`, `makeUnzippedMessage`, `makeAnimalNode`).
Les helpers privés existants sont réutilisés quand ils suffisent ; sinon on construit un message ad-hoc inline.
Code à insérer :
```php
public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-01-01';
// StockBoucles deliberately omitted
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertNull($inventory->includesEarTagStock);
}
public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->StockBoucles = '0';
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertFalse($inventory->includesEarTagStock);
}
public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-01-01';
// DateFin deliberately omitted
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertNull($inventory->endDate);
}
public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$serie1 = new stdClass();
$serie1->NumeroSerieDebut = 'A0001';
$serie2 = new stdClass();
$serie2->NumeroSerieDebut = 'B0001';
$message = new stdClass();
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = [$serie1, $serie2];
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertCount(2, $inventory->earTagSeries);
self::assertSame('A0001', $inventory->earTagSeries[0]->rawNode->NumeroSerieDebut);
self::assertSame('B0001', $inventory->earTagSeries[1]->rawNode->NumeroSerieDebut);
}
public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
// NbBovins deliberately omitted
$inventory = $mapper->map($soapResponse, null);
self::assertSame(0, $inventory->nbBovins);
}
```
**Rappel sur les imports** : le fichier importe déjà `DateTimeImmutable`, `InventoryDto`, `AnimalSummaryMapper`, `InventoryMapper`, `StandardResponseMapper`, `stdClass`, `TestCase`, `CoversClass`. **Aucun import supplémentaire nécessaire** pour ces 5 nouveaux tests.
- [ ] **Step 2: Ajouter le docblock à `EarTagSeriesDto`**
Ouvrir `src/Bovin/Dto/EarTagSeriesDto.php` et remplacer son contenu par :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
/**
* Wrapper minimal d'une entrée `SerieBoucles` du message de l'opération
* `IpBGetInventaire` (quand `includeEarTagStock: true`).
*
* Le noeud XSD `typeSerieBoucles` est riche (plages de numéros, fournisseur,
* statut, dates...). En Phase 1 on garde volontairement la structure brute
* via `$rawNode` : les consommateurs qui ont besoin d'un champ précis y
* accèdent par `$serie->rawNode->NomDuChamp`. Si un cas d'usage métier concret
* remonte (rebouclage, commande de boucles), on parsera dans un DTO dédié.
*/
final readonly class EarTagSeriesDto
{
public function __construct(
public object $rawNode,
) {}
}
```
- [ ] **Step 3: Lancer la suite complète**
Run:
```
make test
```
Expected : tous les tests verts, incluant les 5 nouveaux.
Compte attendu : `OK (N tests, M assertions)` avec `N` = total précédent de la suite + 5.
- [ ] **Step 4: Commit**
Run:
```
git add tests/Unit/Bovin/Mapper/InventoryMapperTest.php src/Bovin/Dto/EarTagSeriesDto.php
git commit -m "test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto"
```
---
## Checklist finale
- [ ] `make test` vert sur toute la suite (tous les nouveaux tests + les tests préexistants de Phase 1)
- [ ] 2 commits propres, un par task
- [ ] `BovinNodeMappingTrait` reste inchangé (seule une classe adapter de test le consomme avec re-exposition publique)
- [ ] `InventoryMapper` reste inchangé (uniquement de nouveaux tests qui exercent son code existant)
- [ ] `EarTagSeriesDto` gagne seulement un docblock, pas de changement de comportement

View File

@@ -0,0 +1,297 @@
# Parsing de `EarTagSeriesDto` — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transformer `EarTagSeriesDto` d'un wrapper `rawNode` vers un DTO plat à 3 champs (`countryCode`, `startNumber`, `quantity`) avec un helper calculé `endNumber()`, et adapter `InventoryMapper` pour peupler ces champs.
**Architecture:** Single commit atomique — on casse et on rétablit la compat des tests dans la même opération. Un nouveau fichier `EarTagSeriesDtoTest` pin le comportement de `endNumber()` ; les deux tests existants de `InventoryMapperTest` sont adaptés pour consommer les nouveaux champs au lieu de `rawNode`. Aucun autre fichier touché.
**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance.
Spec associée : `docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md`.
---
## File Structure
### À créer
```
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php 3 tests sur endNumber()
```
### À modifier
```
src/Bovin/Dto/EarTagSeriesDto.php réécriture complète (3 fields + helper)
src/Bovin/Mapper/InventoryMapper.php 1 bloc modifié (instanciation du DTO)
tests/Unit/Bovin/Mapper/InventoryMapperTest.php 2 méthodes adaptées + helpers ajustés
```
---
## Task 1 — DTO typé + mapper adapté + tests
**Exécution en une seule tâche** parce que DTO et mapper sont liés — les modifier séparément laisserait la suite rouge entre commits. On fait un seul commit atomique.
**Files:**
- Create: `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`
- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (réécriture)
- Modify: `src/Bovin/Mapper/InventoryMapper.php` (bloc d'instanciation `SerieBoucles`)
- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` (2 tests + 1 helper)
### Steps
- [ ] **Step 1: Écrire les 3 nouveaux tests sur le DTO (RED phase)**
Contenu complet de `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(EarTagSeriesDto::class)]
final class EarTagSeriesDtoTest extends TestCase
{
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 50,
);
self::assertSame('0012345727', $series->endNumber());
}
public function testEndNumberPreservesLeadingZeroPadding(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0000000001',
quantity: 5,
);
self::assertSame('0000000005', $series->endNumber());
}
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 1,
);
self::assertSame('0012345678', $series->endNumber());
}
}
```
- [ ] **Step 2: Lancer ce test seul (il doit échouer)**
Run :
```
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
```
Expected : les 3 tests échouent parce que le constructeur actuel de `EarTagSeriesDto` attend `rawNode: object` et pas les 3 nouveaux champs. Message d'erreur typique :
```
ArgumentCountError: Too few arguments to function ... 0 passed and exactly 1 expected
```
ou
```
TypeError: Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto::__construct(): Argument #1 ($rawNode) must be of type object, string given
```
L'important : on voit bien une erreur liée à la signature du constructeur, pas un problème de syntax/autoload. Si erreur différente, investiguer avant de continuer.
- [ ] **Step 3: Réécrire `EarTagSeriesDto`**
Remplacer intégralement le contenu de `src/Bovin/Dto/EarTagSeriesDto.php` par :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
/**
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
*
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
* dans le pays `countryCode`.
*
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
*/
final readonly class EarTagSeriesDto
{
public function __construct(
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}
```
- [ ] **Step 4: Vérifier les tests DTO (GREEN phase partielle)**
Run :
```
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
```
Expected : 3 tests passent, 3 assertions.
- [ ] **Step 5: Lancer la suite complète (la suite DOIT être rouge sur InventoryMapperTest)**
Run :
```
make test
```
Expected : au moins 2 tests en échec dans `InventoryMapperTest` (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) à cause de l'incompatibilité entre le mapper actuel qui fait `new EarTagSeriesDto(rawNode: $serieNode)` et la nouvelle signature. C'est attendu et temporaire — on va le corriger dans les steps suivants.
Si d'autres tests échouent, s'arrêter et investiguer avant de continuer.
- [ ] **Step 6: Adapter `InventoryMapper`**
Dans `src/Bovin/Mapper/InventoryMapper.php`, localiser la boucle qui construit `$earTagSeries`. Elle ressemble à :
```php
$seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
foreach ($this->normalizeToList($seriesNode) as $serieNode) {
if (!is_object($serieNode)) {
continue;
}
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
}
```
Remplacer la ligne d'instanciation par :
```php
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
```
Reste du mapper inchangé. Les imports actuels couvrent déjà `EarTagSeriesDto` et le trait fournit déjà `toNullableString` / `toNullableInt`.
- [ ] **Step 7: Lire `InventoryMapperTest` pour repérer les fixtures à adapter**
Run :
```
cat tests/Unit/Bovin/Mapper/InventoryMapperTest.php
```
Deux endroits utilisent aujourd'hui le champ `NumeroSerieDebut` (nom fictif, non-XSD — hérité d'une fixture approximative) :
1. La méthode privée `makeUnzippedMessage()` qui construit `$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';` (série unique en objet, pas en liste).
2. Le test `testMapInventoryWithSerieBouclesAsListPreservesAllEntries` qui construit 2 objets avec `NumeroSerieDebut = 'A0001'` et `'B0001'`.
Noter les lignes exactes avant de modifier.
- [ ] **Step 8: Adapter `makeUnzippedMessage()` — série unique**
Dans le helper privé `makeUnzippedMessage()` de `InventoryMapperTest`, remplacer :
```php
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
```
par :
```php
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->CodePays = 'FR';
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
$message->Boucles->SerieBoucles->Quantite = 50;
```
- [ ] **Step 9: Adapter `testMapFullInventory` — assertion sur la série**
Dans `testMapFullInventory`, trouver la ligne qui asserte une valeur sur le `rawNode` (s'il y en a une ; sinon juste `assertCount(1, $inventory->earTagSeries)` suffit). Si le test avait une assertion comme `$inventory->earTagSeries[0]->rawNode->NumeroSerieDebut`, la remplacer par :
```php
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
```
Si le test se contentait de `assertCount(1, ...)`, **ajouter** les 3 assertions ci-dessus pour renforcer la couverture post-refactor.
- [ ] **Step 10: Adapter `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`**
Localiser les 2 blocs qui construisent `$serie1` et `$serie2`. Les remplacer par :
```php
$serie1 = new stdClass();
$serie1->CodePays = 'FR';
$serie1->DebutSerie = '0012345678';
$serie1->Quantite = 10;
$serie2 = new stdClass();
$serie2->CodePays = 'FR';
$serie2->DebutSerie = '0055500000';
$serie2->Quantite = 25;
```
Puis remplacer les assertions qui lisaient `rawNode->NumeroSerieDebut` par :
```php
self::assertCount(2, $inventory->earTagSeries);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
```
(Le `assertCount(2, …)` doit être conservé si présent ; sinon l'ajouter au-dessus.)
- [ ] **Step 11: Lancer la suite complète (GREEN phase finale)**
Run :
```
make test
```
Expected : tous les tests verts. Compte attendu : **56 tests** (53 précédents + 3 nouveaux sur le DTO). Les 2 tests de `InventoryMapperTest` sont modifiés, pas ajoutés.
Si un test est rouge, investiguer. Causes possibles :
- `endNumber()` calcule mal le padding → vérifier le `str_pad` et la conversion `(int) $this->startNumber`.
- Une assertion de la fixture a été mal recopiée → vérifier les noms de champs XSD.
- Un import manquant dans un fichier modifié → vérifier les `use` statements.
- [ ] **Step 12: Commit**
```
git add src/Bovin/Dto/EarTagSeriesDto.php \
src/Bovin/Mapper/InventoryMapper.php \
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php \
tests/Unit/Bovin/Mapper/InventoryMapperTest.php
git commit -m "feat : parser SerieBoucles dans EarTagSeriesDto typé"
```
---
## Checklist finale
- [ ] `make test` vert, 56 tests au total
- [ ] Un seul commit atomique
- [ ] `EarTagSeriesDto` n'a plus de propriété `$rawNode`
- [ ] `InventoryMapper` instancie le DTO avec les 3 champs XSD (`CodePays`, `DebutSerie`, `Quantite`)
- [ ] Aucun autre fichier n'a été touché (pas de changement sur `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php`)

View File

@@ -0,0 +1,997 @@
# Phase 2 Lot 1 — `createEntree` + `createSortie` — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter 2 méthodes d'écriture bovin (`createEntree`, `createSortie`) à `BovinApiInterface`, appelant les opérations SOAP `IpBCreateEntree` / `IpBCreateSortie` de `wsIpBNotif`, avec requests/responses DTOs typés et enums métier.
**Architecture:** On ajoute 6 unités indépendantes (3 enums, 2 request DTOs, 2 response DTOs, 2 mappers) puis on câble le tout dans `BovinApi` + `config/services.php`. Les mappers réutilisent `StandardResponseMapper` (déjà existant) et le trait `BovinNodeMappingTrait` pour `mapIdentification`/`mapMovement`. Pas de validation client-side, pas de test d'intégration SOAP (les mappers couvrent 95% de la logique testable).
**Tech Stack:** PHP 8.4 (backed enums, readonly DTOs, named args), PHPUnit 12, Symfony 8 DI.
Spec de référence : `docs/superpowers/specs/2026-04-22-phase2-create-entree-sortie-design.md`
---
## File Structure
### À créer
```
src/Bovin/Enum/CauseEntree.php enum 3 cases (Achat/Naissance/PretOuPension)
src/Bovin/Enum/CauseSortie.php enum 6 cases
src/Bovin/Enum/CategorieBovinIPG.php enum 13 cases (code 2-lettres)
src/Bovin/Dto/CreateEntreeRequest.php request DTO
src/Bovin/Dto/CreateSortieRequest.php request DTO
src/Bovin/Dto/CreateEntreeResponseDto.php response DTO
src/Bovin/Dto/CreateSortieResponseDto.php response DTO
src/Bovin/Mapper/CreateEntreeResponseMapper.php mapper dédié
src/Bovin/Mapper/CreateSortieResponseMapper.php mapper dédié
tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php 2 tests
tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php 2 tests
```
### À modifier
```
src/Bovin/Api/BovinApiInterface.php +2 méthodes, +imports
src/Bovin/Api/BovinApi.php +2 méthodes, +2 constructor deps, +imports
config/services.php +2 mappers registered, +2 args on BovinApi
```
---
## Task 1 — Les 3 enums
Pure data, aucune dépendance. Un seul commit pour les trois.
**Files:**
- Create: `src/Bovin/Enum/CauseEntree.php`
- Create: `src/Bovin/Enum/CauseSortie.php`
- Create: `src/Bovin/Enum/CategorieBovinIPG.php`
### Steps
- [ ] **Step 1: Créer `CauseEntree`**
Contenu complet de `src/Bovin/Enum/CauseEntree.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*/
enum CauseEntree: string
{
/** Entrée par achat. */
case Achat = 'A';
/** Entrée par naissance. */
case Naissance = 'N';
/** Entrée par prêt ou pension. */
case PretOuPension = 'P';
}
```
- [ ] **Step 2: Créer `CauseSortie`**
Contenu complet de `src/Bovin/Enum/CauseSortie.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Cause d'une sortie de bovin de l'exploitation (opération `IpBCreateSortie`).
*
* Source : `resources/ednotif-ws/CauseSortie.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*
* Le code `H` porte ici le sens "Sortie pour prêt ou pension" (équivalent du `P`
* sur une entrée) ; le WSDL garantit que chaque code n'apparaît que dans son sens,
* pas d'ambiguïté à gérer côté consommateur.
*/
enum CauseSortie: string
{
/** Sortie pour boucherie. */
case Boucherie = 'B';
/** Sortie pour auto-consommation. */
case Consommation = 'C';
/** Sortie pour élevage ou vente. */
case Elevage = 'E';
/** Sortie pour mort. */
case Mort = 'M';
/** Sortie pour prêt ou pension. */
case PretOuPension = 'H';
/** Autre cause (réservée aux reprises / données historiques). */
case Autre = 'X';
}
```
- [ ] **Step 3: Créer `CategorieBovinIPG`**
Contenu complet de `src/Bovin/Enum/CategorieBovinIPG.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Catégorie IPG d'un bovin (champ optionnel de `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CategorieBovinIPG.XSD`.
* Le `.value` est le code IPG (2 lettres) transmis dans le payload SOAP.
* Les case names suivent le code XSD, les libellés sont en docblock.
*/
enum CategorieBovinIPG: string
{
/** Boeuf. */
case BO = 'BO';
/** Broutard. */
case BR = 'BR';
/** Femelle à l'engraissement. */
case FE = 'FE';
/** Génisse laitière. */
case GL = 'GL';
/** Génisse viande. */
case GV = 'GV';
/** Mâle. */
case MA = 'MA';
/** Mâle reproducteur. */
case MR = 'MR';
/** Taurillon. */
case TA = 'TA';
/** Vache allaitante. */
case VA = 'VA';
/** Veau de boucherie. */
case VB = 'VB';
/** Veau. */
case VE = 'VE';
/** Vache laitière. */
case VL = 'VL';
/** Vache de réforme. */
case VR = 'VR';
}
```
- [ ] **Step 4: Vérifier la suite (pas d'impact)**
Run :
```
make test
```
Expected : toujours 56 tests / 107 assertions verts (les enums ne sont pas encore référencés).
- [ ] **Step 5: Commit**
```
git add src/Bovin/Enum/
git commit -m "feat : enums CauseEntree, CauseSortie, CategorieBovinIPG"
```
---
## Task 2 — Les 2 request DTOs
Pure data, dépend des enums de Task 1 et de `BovinRef`/`ExploitationRef` existants.
**Files:**
- Create: `src/Bovin/Dto/CreateEntreeRequest.php`
- Create: `src/Bovin/Dto/CreateSortieRequest.php`
### Steps
- [ ] **Step 1: Créer `CreateEntreeRequest`**
Contenu complet de `src/Bovin/Dto/CreateEntreeRequest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CategorieBovinIPG;
use Malio\EdnotifBundle\Bovin\Enum\CauseEntree;
/**
* Paramètres d'une déclaration d'entrée bovin (opération `IpBCreateEntree`).
*
* `codeAtelier` suit le pattern XSD `[LABEM][1-9]` (L=Lait, A=Allaitant,
* B=Veaux de boucherie, E=Engraissement autre, M=Manade). Non validé côté
* bundle : EDNOTIF rejette la valeur malformée via `EdnotifException`.
*/
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,
) {}
}
```
- [ ] **Step 2: Créer `CreateSortieRequest`**
Contenu complet de `src/Bovin/Dto/CreateSortieRequest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CauseSortie;
/**
* Paramètres d'une déclaration de sortie bovin (opération `IpBCreateSortie`).
*/
final readonly class CreateSortieRequest
{
public function __construct(
public BovinRef $bovin,
public DateTimeInterface $date,
public CauseSortie $cause,
public ExploitationRef $destination,
) {}
}
```
- [ ] **Step 3: Vérifier la suite (pas d'impact)**
Run :
```
make test
```
Expected : 56 tests toujours verts.
- [ ] **Step 4: Commit**
```
git add src/Bovin/Dto/CreateEntreeRequest.php src/Bovin/Dto/CreateSortieRequest.php
git commit -m "feat : request DTOs pour createEntree et createSortie"
```
---
## Task 3 — `CreateEntreeResponseDto` + Mapper + Tests (TDD)
**Files:**
- Create: `src/Bovin/Dto/CreateEntreeResponseDto.php`
- Create: `src/Bovin/Mapper/CreateEntreeResponseMapper.php`
- Create: `tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php`
### Steps
- [ ] **Step 1: Écrire le test (RED)**
Contenu complet de `tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversClass(CreateEntreeResponseMapper::class)]
final class CreateEntreeResponseMapperTest extends TestCase
{
public function testMapPendingBdniValidation(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->AttenteValidationBDNi = true;
$dto = (new CreateEntreeResponseMapper(new StandardResponseMapper()))->map($soapResponse);
self::assertInstanceOf(CreateEntreeResponseDto::class, $dto);
self::assertTrue($dto->standardResponse->result);
self::assertTrue($dto->pendingBdniValidation);
self::assertNull($dto->identification);
self::assertNull($dto->entryMovement);
}
public function testMapValidatedEntry(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$validee = new stdClass();
$validee->IdentiteBovin = new stdClass();
$validee->IdentiteBovin->Sexe = 'F';
$validee->IdentiteBovin->Bovin = new stdClass();
$validee->IdentiteBovin->Bovin->CodePays = 'FR';
$validee->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';
$validee->MouvementEntreeBovin = new stdClass();
$validee->MouvementEntreeBovin->DateEntree = '2026-04-22';
$validee->MouvementEntreeBovin->CauseEntree = 'A';
$soapResponse->ReponseSpecifique->EntreeValidee = $validee;
$dto = (new CreateEntreeResponseMapper(new StandardResponseMapper()))->map($soapResponse);
self::assertFalse($dto->pendingBdniValidation);
self::assertNotNull($dto->identification);
self::assertSame('F', $dto->identification->sex);
self::assertSame('FR1234567890', $dto->identification->bovin?->nationalNumber);
self::assertNotNull($dto->entryMovement);
self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->entryMovement->date);
self::assertSame('A', $dto->entryMovement->cause);
}
}
```
- [ ] **Step 2: Lancer le test (doit échouer)**
Run :
```
make test FILES=tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php
```
Expected : erreur `Class ... not found` sur `CreateEntreeResponseDto` ou `CreateEntreeResponseMapper`.
- [ ] **Step 3: Créer `CreateEntreeResponseDto`**
Contenu complet de `src/Bovin/Dto/CreateEntreeResponseDto.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
/**
* Réponse de `IpBCreateEntree`.
*
* Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
* attend la validation asynchrone de la BDNi — `identification` et
* `entryMovement` sont `null`. Sinon, les deux sont populés avec les données
* validées du bovin et du mouvement d'entrée.
*/
final readonly class CreateEntreeResponseDto
{
public function __construct(
public StandardResponseDto $standardResponse,
public bool $pendingBdniValidation,
public ?BovinIdentificationDto $identification,
public ?MovementDto $entryMovement,
public ?object $rawSoapResponse,
) {}
}
```
- [ ] **Step 4: Créer `CreateEntreeResponseMapper`**
Contenu complet de `src/Bovin/Mapper/CreateEntreeResponseMapper.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class CreateEntreeResponseMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse): CreateEntreeResponseDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$specific = $soapResponse->ReponseSpecifique ?? null;
$pending = false;
$identification = null;
$entryMovement = null;
if (is_object($specific)) {
$pendingFlag = $specific->AttenteValidationBDNi ?? null;
if (null !== $pendingFlag) {
$pending = (bool) $this->toNullableBool($pendingFlag);
}
$validee = $specific->EntreeValidee ?? null;
if (is_object($validee)) {
$identityNode = $validee->IdentiteBovin ?? null;
if (is_object($identityNode)) {
$identification = $this->mapIdentification($identityNode);
}
$movementNode = $validee->MouvementEntreeBovin ?? null;
if (is_object($movementNode)) {
$entryMovement = $this->mapMovement($movementNode, 'entry');
}
}
}
return new CreateEntreeResponseDto(
standardResponse: $standardResponse,
pendingBdniValidation: $pending,
identification: $identification,
entryMovement: $entryMovement,
rawSoapResponse: $soapResponse,
);
}
}
```
- [ ] **Step 5: Vérifier GREEN**
Run :
```
make test FILES=tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php
```
Expected : 2 tests passent.
- [ ] **Step 6: Vérifier la suite complète**
Run :
```
make test
```
Expected : 58 tests verts (56 existants + 2 nouveaux).
- [ ] **Step 7: Commit**
```
git add src/Bovin/Dto/CreateEntreeResponseDto.php \
src/Bovin/Mapper/CreateEntreeResponseMapper.php \
tests/Unit/Bovin/Mapper/CreateEntreeResponseMapperTest.php
git commit -m "feat : DTO et mapper de réponse pour IpBCreateEntree"
```
---
## Task 4 — Câbler `createEntree` dans `BovinApi`
**Files:**
- Modify: `src/Bovin/Api/BovinApiInterface.php`
- Modify: `src/Bovin/Api/BovinApi.php`
- Modify: `config/services.php`
### Steps
- [ ] **Step 1: Ajouter la méthode à l'interface**
Dans `src/Bovin/Api/BovinApiInterface.php`, ajouter l'import et la méthode.
Ajouter ces imports en tête (après ceux déjà présents) :
```php
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
```
Ajouter la méthode juste après `getPresumedExits(): PresumedExitsDto;` :
```php
public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto;
```
- [ ] **Step 2: Étendre `BovinApi`**
Dans `src/Bovin/Api/BovinApi.php`, ajouter les imports suivants (alphabétiques) :
```php
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
```
Étendre le constructeur en ajoutant la nouvelle dépendance **juste après `$presumedExitsMapper`**, avant `$zipMessageDecoder` :
```php
private PresumedExitsMapper $presumedExitsMapper,
private CreateEntreeResponseMapper $createEntreeResponseMapper,
private ZipMessageDecoder $zipMessageDecoder,
```
Ajouter la méthode `createEntree` **juste après `getPresumedExits()`** :
```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 {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBCreateEntree', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBCreateEntree: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBCreateEntree');
return $this->createEntreeResponseMapper->map($soapResponse);
}
```
- [ ] **Step 3: Enregistrer le mapper dans `config/services.php`**
Ajouter l'import :
```php
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
```
Enregistrer le mapper **juste après `PresumedExitsMapper`** (lignes 61-65 dans le fichier actuel) :
```php
$services->set(CreateEntreeResponseMapper::class)
->args([
service(StandardResponseMapper::class),
])
;
```
Mettre à jour le bloc `BovinApi` en insérant le service après `PresumedExitsMapper` et avant `ZipMessageDecoder` :
```php
$services->set(BovinApi::class)
->args([
service(TokenProvider::class),
service('ednotif.soap.business'),
service(AnimalFileMapper::class),
service(InventoryMapper::class),
service(ReturnedDossiersMapper::class),
service(PresumedExitsMapper::class),
service(CreateEntreeResponseMapper::class),
service(ZipMessageDecoder::class),
'%ednotif.exploitation_country_code%',
'%ednotif.exploitation_number%',
])
;
```
- [ ] **Step 4: Vérifier la suite**
Run :
```
make test
```
Expected : 58 tests toujours verts (pas de nouveau test, mais pas de régression non plus).
- [ ] **Step 5: Commit**
```
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
git commit -m "feat : expose IpBCreateEntree via BovinApi::createEntree"
```
---
## Task 5 — `CreateSortieResponseDto` + Mapper + Tests (TDD)
**Files:**
- Create: `src/Bovin/Dto/CreateSortieResponseDto.php`
- Create: `src/Bovin/Mapper/CreateSortieResponseMapper.php`
- Create: `tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php`
### Steps
- [ ] **Step 1: Écrire le test (RED)**
Contenu complet de `tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversClass(CreateSortieResponseMapper::class)]
final class CreateSortieResponseMapperTest extends TestCase
{
public function testMapPendingBdniValidation(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->AttenteValidationBDNi = true;
$dto = (new CreateSortieResponseMapper(new StandardResponseMapper()))->map($soapResponse);
self::assertInstanceOf(CreateSortieResponseDto::class, $dto);
self::assertTrue($dto->pendingBdniValidation);
self::assertNull($dto->identification);
self::assertNull($dto->entryMovement);
self::assertNull($dto->exitMovement);
}
public function testMapValidatedExit(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$validee = new stdClass();
$validee->IdentiteBovin = new stdClass();
$validee->IdentiteBovin->Sexe = 'M';
$validee->IdentiteBovin->Bovin = new stdClass();
$validee->IdentiteBovin->Bovin->NumeroNational = 'FR9999999999';
$mouvement = new stdClass();
$mouvement->MouvementEntreeBovin = new stdClass();
$mouvement->MouvementEntreeBovin->DateEntree = '2024-01-10';
$mouvement->MouvementEntreeBovin->CauseEntree = 'A';
$mouvement->MouvementSortieBovin = new stdClass();
$mouvement->MouvementSortieBovin->DateSortie = '2026-04-22';
$mouvement->MouvementSortieBovin->CauseSortie = 'B';
$validee->MouvementBovin = $mouvement;
$soapResponse->ReponseSpecifique->SortieValidee = $validee;
$dto = (new CreateSortieResponseMapper(new StandardResponseMapper()))->map($soapResponse);
self::assertFalse($dto->pendingBdniValidation);
self::assertNotNull($dto->identification);
self::assertSame('M', $dto->identification->sex);
self::assertSame('FR9999999999', $dto->identification->bovin?->nationalNumber);
self::assertNotNull($dto->entryMovement);
self::assertEquals(new DateTimeImmutable('2024-01-10'), $dto->entryMovement->date);
self::assertSame('A', $dto->entryMovement->cause);
self::assertNotNull($dto->exitMovement);
self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->exitMovement->date);
self::assertSame('B', $dto->exitMovement->cause);
}
}
```
- [ ] **Step 2: Lancer le test (doit échouer)**
Run :
```
make test FILES=tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php
```
Expected : classe introuvable.
- [ ] **Step 3: Créer `CreateSortieResponseDto`**
Contenu complet de `src/Bovin/Dto/CreateSortieResponseDto.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
/**
* Réponse de `IpBCreateSortie`.
*
* Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
* attend la validation asynchrone de la BDNi — `identification`, `entryMovement`
* et `exitMovement` sont `null`. Sinon, EDNOTIF renvoie la **période de présence
* clôturée** : l'entrée initiale du bovin sur l'exploitation (`entryMovement`)
* **et** la sortie qui vient d'être déclarée (`exitMovement`).
*/
final readonly class CreateSortieResponseDto
{
public function __construct(
public StandardResponseDto $standardResponse,
public bool $pendingBdniValidation,
public ?BovinIdentificationDto $identification,
public ?MovementDto $entryMovement,
public ?MovementDto $exitMovement,
public ?object $rawSoapResponse,
) {}
}
```
- [ ] **Step 4: Créer `CreateSortieResponseMapper`**
Contenu complet de `src/Bovin/Mapper/CreateSortieResponseMapper.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class CreateSortieResponseMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse): CreateSortieResponseDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$specific = $soapResponse->ReponseSpecifique ?? null;
$pending = false;
$identification = null;
$entryMovement = null;
$exitMovement = null;
if (is_object($specific)) {
$pendingFlag = $specific->AttenteValidationBDNi ?? null;
if (null !== $pendingFlag) {
$pending = (bool) $this->toNullableBool($pendingFlag);
}
$validee = $specific->SortieValidee ?? null;
if (is_object($validee)) {
$identityNode = $validee->IdentiteBovin ?? null;
if (is_object($identityNode)) {
$identification = $this->mapIdentification($identityNode);
}
$mouvementBovin = $validee->MouvementBovin ?? null;
if (is_object($mouvementBovin)) {
$entryNode = $mouvementBovin->MouvementEntreeBovin ?? null;
if (is_object($entryNode)) {
$entryMovement = $this->mapMovement($entryNode, 'entry');
}
$exitNode = $mouvementBovin->MouvementSortieBovin ?? null;
if (is_object($exitNode)) {
$exitMovement = $this->mapMovement($exitNode, 'exit');
}
}
}
}
return new CreateSortieResponseDto(
standardResponse: $standardResponse,
pendingBdniValidation: $pending,
identification: $identification,
entryMovement: $entryMovement,
exitMovement: $exitMovement,
rawSoapResponse: $soapResponse,
);
}
}
```
- [ ] **Step 5: Vérifier GREEN**
Run :
```
make test FILES=tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php
```
Expected : 2 tests passent.
- [ ] **Step 6: Vérifier la suite complète**
Run :
```
make test
```
Expected : 60 tests verts (58 + 2).
- [ ] **Step 7: Commit**
```
git add src/Bovin/Dto/CreateSortieResponseDto.php \
src/Bovin/Mapper/CreateSortieResponseMapper.php \
tests/Unit/Bovin/Mapper/CreateSortieResponseMapperTest.php
git commit -m "feat : DTO et mapper de réponse pour IpBCreateSortie"
```
---
## Task 6 — Câbler `createSortie` dans `BovinApi`
**Files:**
- Modify: `src/Bovin/Api/BovinApiInterface.php`
- Modify: `src/Bovin/Api/BovinApi.php`
- Modify: `config/services.php`
### Steps
- [ ] **Step 1: Ajouter la méthode à l'interface**
Dans `src/Bovin/Api/BovinApiInterface.php`, ajouter les imports :
```php
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
```
Ajouter la méthode après `createEntree(...): CreateEntreeResponseDto;` :
```php
public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto;
```
- [ ] **Step 2: Étendre `BovinApi`**
Ajouter les imports dans `src/Bovin/Api/BovinApi.php` :
```php
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
```
Ajouter la dépendance au constructeur **juste après `$createEntreeResponseMapper`** :
```php
private CreateEntreeResponseMapper $createEntreeResponseMapper,
private CreateSortieResponseMapper $createSortieResponseMapper,
private ZipMessageDecoder $zipMessageDecoder,
```
Ajouter la méthode après `createEntree(...)` :
```php
public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto
{
$token = $this->tokenProvider->getToken();
$payload = [
'JetonAuthentification' => $token,
'Exploitation' => [
'CodePays' => $this->exploitationCountryCode,
'NumeroExploitation' => $this->exploitationNumber,
],
'Bovin' => [
'CodePays' => $request->bovin->countryCode,
'NumeroNational' => $request->bovin->nationalNumber,
],
'DateSortie' => $request->date->format('Y-m-d'),
'CauseSortie' => $request->cause->value,
'ExploitationDestination' => [
'CodePays' => $request->destination->countryCode,
'NumeroExploitation' => $request->destination->exploitationNumber,
],
];
try {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBCreateSortie', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBCreateSortie: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBCreateSortie');
return $this->createSortieResponseMapper->map($soapResponse);
}
```
- [ ] **Step 3: Enregistrer le mapper dans `config/services.php`**
Ajouter l'import :
```php
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
```
Enregistrer le mapper juste après `CreateEntreeResponseMapper` :
```php
$services->set(CreateSortieResponseMapper::class)
->args([
service(StandardResponseMapper::class),
])
;
```
Mettre à jour le bloc `BovinApi` en insérant le service après `CreateEntreeResponseMapper` :
```php
$services->set(BovinApi::class)
->args([
service(TokenProvider::class),
service('ednotif.soap.business'),
service(AnimalFileMapper::class),
service(InventoryMapper::class),
service(ReturnedDossiersMapper::class),
service(PresumedExitsMapper::class),
service(CreateEntreeResponseMapper::class),
service(CreateSortieResponseMapper::class),
service(ZipMessageDecoder::class),
'%ednotif.exploitation_country_code%',
'%ednotif.exploitation_number%',
])
;
```
- [ ] **Step 4: Vérifier la suite complète**
Run :
```
make test
```
Expected : 60 tests toujours verts. Le constructeur de `BovinApi` a maintenant 11 args.
- [ ] **Step 5: Commit**
```
git add src/Bovin/Api/BovinApiInterface.php src/Bovin/Api/BovinApi.php config/services.php
git commit -m "feat : expose IpBCreateSortie via BovinApi::createSortie"
```
---
## Checklist finale
- [ ] `make test` vert, 60 tests / ~115 assertions
- [ ] 6 commits propres, un par Task (aucune tâche ne laisse la suite rouge entre deux commits)
- [ ] `BovinApiInterface` expose 6 méthodes au total (4 reads + 2 writes)
- [ ] `BovinApi` constructeur : 11 args
- [ ] `config/services.php` : 11 services enregistrés dans le bloc `BovinApi->args()`
- [ ] Pas de validation client-side, pas de test SOAP mock — conforme au scope exclu

View File

@@ -0,0 +1,138 @@
# Parsing de `SerieBoucles` → `EarTagSeriesDto` typé
## Contexte
`EarTagSeriesDto` a été introduit en Phase 1 comme wrapper minimal (`$rawNode: object`) autour du noeud `SerieBoucles` retourné par `IpBGetInventaire` quand le consommateur passe `includeEarTagStock: true`. Le docblock actuel dit que le XSD `typeSerieBoucles` est « riche » et qu'on différera le parsing — c'est **faux**. Le XSD (`resources/ednotif-ws/IpBNotif_v1.xsd:1181-1197`) ne contient que 3 champs :
```xml
<xsd:complexType name="typeSerieBoucles">
<xsd:sequence>
<xsd:element name="CodePays" type="tns:typeCodePaysFr"/>
<xsd:element name="DebutSerie"> <!-- string, 10 digits, pattern 0[1-9][0-9]{8}|[1-9][0-9]{9} -->
<xsd:element name="Quantite" type="xsd:unsignedInt"/>
</xsd:sequence>
</xsd:complexType>
```
Sémantique : « j'ai `Quantite` boucles consécutives à partir du numéro `DebutSerie` dans le pays `CodePays` ». Une série = une plage de boucles physiques non utilisées, en stock chez l'éleveur.
## But
Remplacer le wrapper `rawNode` par un DTO typé à 3 champs, plus un helper calculé `endNumber()` qui rend l'usage UI immédiat (afficher « 50 boucles de 0012345678 à 0012345727 »).
## Scope
### Inclus
- `EarTagSeriesDto` plat : `countryCode: string`, `startNumber: string`, `quantity: int`.
- Une méthode `endNumber(): string` qui renvoie `startNumber + quantity - 1` avec zéro-padding à 10 chiffres.
- Adaptation du `InventoryMapper` : instancier le DTO avec les 3 champs lus depuis `$serieNode->CodePays`/`DebutSerie`/`Quantite` au lieu de passer le noeud brut.
- Tests unitaires sur le DTO (helper) et sur le mapper (les 2 tests existants pour `SerieBoucles` — full inventory + list — passent à travers les nouveaux champs).
- Mise à jour du docblock sur `EarTagSeriesDto` (le texte Phase 1 est à refaire).
### Exclus (YAGNI)
- `contains(string $tagNumber): bool` et `iterableNumbers(): Generator<string>` — à ajouter le jour où `IpBCreateRebouclage` ou une UI de sélection de boucle en aura vraiment besoin.
- Validation métier (vérifier que `startNumber` matche le pattern XSD `0[1-9][0-9]{8}|[1-9][0-9]{9}`) — EDNOTIF garantit déjà la forme, on n'ajoute pas une seconde ligne de défense.
- Changement du type de `$earTagSeries` dans `InventoryDto` — la liste reste `list<EarTagSeriesDto>`.
## Changement non-rétro-compatible assumé
Le remplacement de `$rawNode` par 3 champs casse l'API publique de `EarTagSeriesDto`. Comme cette DTO n'a **aucun consommateur connu à ce jour** (Ferme n'utilise pas `includeEarTagStock: true`), on fait le changement direct, sans transition. Si un consommateur tiers existait, il faudrait un cycle de dépréciation ; ce n'est pas le cas.
## Architecture
### DTO
```php
final readonly class EarTagSeriesDto
{
public function __construct(
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}
```
Décisions à documenter dans le code :
- **`startNumber` en string**, pas int : le XSD impose 10 chiffres avec zéro-padding possible. Convertir en int ferait perdre le format (`'0012345678'``12345678`). Laisser en string préserve le format d'affichage.
- **`endNumber()` renvoie aussi une string** zéro-paddée à 10 chiffres, via `str_pad`. Cohérent avec `startNumber`.
- **`quantity` en int** : xsd:unsignedInt se mappe naturellement sur int PHP (les 10 milliards de boucles possibles tiennent dans int64 sans problème).
### Mapper
Dans `InventoryMapper::map()`, remplacer :
```php
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
```
par :
```php
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
```
Valeurs par défaut (`''`, `0`) pour les cas où EDNOTIF renverrait un noeud partiel — plus simple qu'une DTO avec des nullable partout, et `quantity = 0` / `startNumber = ''` sont des valeurs sentinelles immédiatement repérables en debug.
### Docblock actualisé
Remplacer le texte Phase 1 sur `EarTagSeriesDto` par une description factuelle de la structure XSD et de la sémantique (plage de boucles en stock).
## Edge cases
- **Noeud `SerieBoucles` partiel** (un des 3 champs manque) : le DTO est construit avec les valeurs sentinelles `''` ou `0`. Pas de crash, pas d'exception. Responsabilité du consommateur de filtrer si besoin.
- **`quantity = 0`** : `endNumber()` renvoie `startNumber - 1` (zéro-padded). Techniquement absurde côté métier mais techniquement déterministe. Ne vaut pas la peine de documenter un `throw`.
- **`startNumber` vide** : `(int) '' === 0`, `endNumber()` renvoie `str_pad((string)(-1), ...)` = `'00000000-1'`. Déjà en terrain « shouldn't happen ». Pas de garde défensive ajoutée.
- **`CodePays` vide** : `endNumber()` ne dépend pas du pays, aucun impact.
## Tests
### DTO
Un fichier `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
- `testEndNumberComputesStartPlusQuantityMinusOne` — cas standard, ex : start `0012345678`, quantity 50, end `0012345727`.
- `testEndNumberPreservesLeadingZeroPadding` — cas avec leading zero, ex : start `0000000001`, quantity 5, end `0000000005`.
- `testEndNumberWithQuantityOne` — une seule boucle dans la série, end === start.
3 tests, ~15 assertions.
### Mapper
Les 2 tests existants (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) sont **modifiés** pour :
- Peupler `CodePays`, `DebutSerie`, `Quantite` dans les fixtures à la place de `NumeroSerieDebut`.
- Asserter sur `$inventory->earTagSeries[0]->startNumber` et `$inventory->earTagSeries[0]->quantity` au lieu de `rawNode->NumeroSerieDebut`.
Aucun nouveau test sur le mapper — la structure est déjà couverte.
## Impact sur les autres fichiers
- `src/Bovin/Dto/EarTagSeriesDto.php` — réécrit.
- `src/Bovin/Mapper/InventoryMapper.php` — un bloc modifié (instanciation de la DTO).
- `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` — fixtures `SerieBoucles` adaptées (2 tests impactés).
- Nouveau : `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`.
Aucun autre fichier touché. `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php` : inchangés.
## Comptage de tests attendu
Avant : 53 tests.
Après : 53 + 3 nouveaux sur le DTO = 56 tests (les 2 du mapper sont modifiés, pas ajoutés).

View File

@@ -0,0 +1,330 @@
# 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
}
```

View File

@@ -7,10 +7,16 @@ namespace Malio\EdnotifBundle\Bovin\Api;
use DateTimeInterface;
use Malio\EdnotifBundle\Auth\TokenProvider;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
@@ -29,6 +35,8 @@ final readonly class BovinApi implements BovinApiInterface
private InventoryMapper $inventoryMapper,
private ReturnedDossiersMapper $returnedDossiersMapper,
private PresumedExitsMapper $presumedExitsMapper,
private CreateEntreeResponseMapper $createEntreeResponseMapper,
private CreateSortieResponseMapper $createSortieResponseMapper,
private ZipMessageDecoder $zipMessageDecoder,
private string $exploitationCountryCode,
private string $exploitationNumber,
@@ -158,6 +166,80 @@ final readonly class BovinApi implements BovinApiInterface
return $this->presumedExitsMapper->map($soapResponse, $unzippedMessage);
}
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 {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBCreateEntree', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBCreateEntree: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBCreateEntree');
return $this->createEntreeResponseMapper->map($soapResponse);
}
public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto
{
$token = $this->tokenProvider->getToken();
$payload = [
'JetonAuthentification' => $token,
'Exploitation' => [
'CodePays' => $this->exploitationCountryCode,
'NumeroExploitation' => $this->exploitationNumber,
],
'Bovin' => [
'CodePays' => $request->bovin->countryCode,
'NumeroNational' => $request->bovin->nationalNumber,
],
'DateSortie' => $request->date->format('Y-m-d'),
'CauseSortie' => $request->cause->value,
'ExploitationDestination' => [
'CodePays' => $request->destination->countryCode,
'NumeroExploitation' => $request->destination->exploitationNumber,
],
];
try {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBCreateSortie', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBCreateSortie: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBCreateSortie');
return $this->createSortieResponseMapper->map($soapResponse);
}
private function assertSuccessfulResponse(object $soapResponse, string $operation): void
{
$standardResponseNode = $soapResponse->ReponseStandard ?? null;

View File

@@ -6,6 +6,10 @@ namespace Malio\EdnotifBundle\Bovin\Api;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieRequest;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
@@ -23,4 +27,8 @@ interface BovinApiInterface
public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto;
public function getPresumedExits(): PresumedExitsDto;
public function createEntree(CreateEntreeRequest $request): CreateEntreeResponseDto;
public function createSortie(CreateSortieRequest $request): CreateSortieResponseDto;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CategorieBovinIPG;
use Malio\EdnotifBundle\Bovin\Enum\CauseEntree;
/**
* Paramètres d'une déclaration d'entrée bovin (opération `IpBCreateEntree`).
*
* `codeAtelier` suit le pattern XSD `[LABEM][1-9]` (L=Lait, A=Allaitant,
* B=Veaux de boucherie, E=Engraissement autre, M=Manade). Non validé côté
* bundle : EDNOTIF rejette la valeur malformée via `EdnotifException`.
*/
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,
) {}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
/**
* Réponse de `IpBCreateEntree`.
*
* Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
* attend la validation asynchrone de la BDNi — `identification` et
* `entryMovement` sont `null`. Sinon, les deux sont populés avec les données
* validées du bovin et du mouvement d'entrée.
*/
final readonly class CreateEntreeResponseDto
{
public function __construct(
public StandardResponseDto $standardResponse,
public bool $pendingBdniValidation,
public ?BovinIdentificationDto $identification,
public ?MovementDto $entryMovement,
public ?object $rawSoapResponse,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Enum\CauseSortie;
/**
* Paramètres d'une déclaration de sortie bovin (opération `IpBCreateSortie`).
*/
final readonly class CreateSortieRequest
{
public function __construct(
public BovinRef $bovin,
public DateTimeInterface $date,
public CauseSortie $cause,
public ExploitationRef $destination,
) {}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
/**
* Réponse de `IpBCreateSortie`.
*
* Si `$pendingBdniValidation === true`, EDNOTIF a accepté la requête mais
* attend la validation asynchrone de la BDNi — `identification`, `entryMovement`
* et `exitMovement` sont `null`. Sinon, EDNOTIF renvoie la **période de présence
* clôturée** : l'entrée initiale du bovin sur l'exploitation (`entryMovement`)
* **et** la sortie qui vient d'être déclarée (`exitMovement`).
*/
final readonly class CreateSortieResponseDto
{
public function __construct(
public StandardResponseDto $standardResponse,
public bool $pendingBdniValidation,
public ?BovinIdentificationDto $identification,
public ?MovementDto $entryMovement,
public ?MovementDto $exitMovement,
public ?object $rawSoapResponse,
) {}
}

View File

@@ -4,9 +4,32 @@ declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
/**
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
*
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
* dans le pays `countryCode`.
*
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
*/
final readonly class EarTagSeriesDto
{
public function __construct(
public object $rawNode,
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Catégorie IPG d'un bovin (champ optionnel de `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CategorieBovinIPG.XSD`.
* Le `.value` est le code IPG (2 lettres) transmis dans le payload SOAP.
* Les case names suivent le code XSD, les libellés sont en docblock.
*/
enum CategorieBovinIPG: string
{
/** Boeuf. */
case BO = 'BO';
/** Broutard. */
case BR = 'BR';
/** Femelle à l'engraissement. */
case FE = 'FE';
/** Génisse laitière. */
case GL = 'GL';
/** Génisse viande. */
case GV = 'GV';
/** Mâle. */
case MA = 'MA';
/** Mâle reproducteur. */
case MR = 'MR';
/** Taurillon. */
case TA = 'TA';
/** Vache allaitante. */
case VA = 'VA';
/** Veau de boucherie. */
case VB = 'VB';
/** Veau. */
case VE = 'VE';
/** Vache laitière. */
case VL = 'VL';
/** Vache de réforme. */
case VR = 'VR';
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*/
enum CauseEntree: string
{
/** Entrée par achat. */
case Achat = 'A';
/** Entrée par naissance. */
case Naissance = 'N';
/** Entrée par prêt ou pension. */
case PretOuPension = 'P';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Enum;
/**
* Cause d'une sortie de bovin de l'exploitation (opération `IpBCreateSortie`).
*
* Source : `resources/ednotif-ws/CauseSortie.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*
* Le code `H` porte ici le sens "Sortie pour prêt ou pension" (équivalent du `P`
* sur une entrée) ; le WSDL garantit que chaque code n'apparaît que dans son sens,
* pas d'ambiguïté à gérer côté consommateur.
*/
enum CauseSortie: string
{
/** Sortie pour boucherie. */
case Boucherie = 'B';
/** Sortie pour auto-consommation. */
case Consommation = 'C';
/** Sortie pour élevage ou vente. */
case Elevage = 'E';
/** Sortie pour mort. */
case Mort = 'M';
/** Sortie pour prêt ou pension. */
case PretOuPension = 'H';
/** Autre cause (réservée aux reprises / données historiques). */
case Autre = 'X';
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class CreateEntreeResponseMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse): CreateEntreeResponseDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$specific = $soapResponse->ReponseSpecifique ?? null;
$pending = false;
$identification = null;
$entryMovement = null;
if (is_object($specific)) {
$pendingFlag = $specific->AttenteValidationBDNi ?? null;
if (null !== $pendingFlag) {
$pending = (bool) $this->toNullableBool($pendingFlag);
}
$validee = $specific->EntreeValidee ?? null;
if (is_object($validee)) {
$identityNode = $validee->IdentiteBovin ?? null;
if (is_object($identityNode)) {
$identification = $this->mapIdentification($identityNode);
}
$movementNode = $validee->MouvementEntreeBovin ?? null;
if (is_object($movementNode)) {
$entryMovement = $this->mapMovement($movementNode, 'entry');
}
}
}
return new CreateEntreeResponseDto(
standardResponse: $standardResponse,
pendingBdniValidation: $pending,
identification: $identification,
entryMovement: $entryMovement,
rawSoapResponse: $soapResponse,
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class CreateSortieResponseMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse): CreateSortieResponseDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$specific = $soapResponse->ReponseSpecifique ?? null;
$pending = false;
$identification = null;
$entryMovement = null;
$exitMovement = null;
if (is_object($specific)) {
$pendingFlag = $specific->AttenteValidationBDNi ?? null;
if (null !== $pendingFlag) {
$pending = (bool) $this->toNullableBool($pendingFlag);
}
$validee = $specific->SortieValidee ?? null;
if (is_object($validee)) {
$identityNode = $validee->IdentiteBovin ?? null;
if (is_object($identityNode)) {
$identification = $this->mapIdentification($identityNode);
}
$mouvementBovin = $validee->MouvementBovin ?? null;
if (is_object($mouvementBovin)) {
$entryNode = $mouvementBovin->MouvementEntreeBovin ?? null;
if (is_object($entryNode)) {
$entryMovement = $this->mapMovement($entryNode, 'entry');
}
$exitNode = $mouvementBovin->MouvementSortieBovin ?? null;
if (is_object($exitNode)) {
$exitMovement = $this->mapMovement($exitNode, 'exit');
}
}
}
}
return new CreateSortieResponseDto(
standardResponse: $standardResponse,
pendingBdniValidation: $pending,
identification: $identification,
entryMovement: $entryMovement,
exitMovement: $exitMovement,
rawSoapResponse: $soapResponse,
);
}
}

View File

@@ -50,7 +50,11 @@ final class InventoryMapper
if (!is_object($serieNode)) {
continue;
}
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
#[CoversClass(EarTagSeriesDto::class)]
final class EarTagSeriesDtoTest extends TestCase
{
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 50,
);
self::assertSame('0012345727', $series->endNumber());
}
public function testEndNumberPreservesLeadingZeroPadding(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0000000001',
quantity: 5,
);
self::assertSame('0000000005', $series->endNumber());
}
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 1,
);
self::assertSame('0012345678', $series->endNumber());
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Mapper\BovinNodeMappingTrait;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversTrait(BovinNodeMappingTrait::class)]
final class BovinNodeMappingTraitTest extends TestCase
{
// ---------- normalizeToList ----------
public function testNormalizeToListWithNullReturnsEmptyList(): void
{
self::assertSame([], self::adapter()->normalizeToList(null));
}
public function testNormalizeToListWithScalarWrapsIntoList(): void
{
self::assertSame(['foo'], self::adapter()->normalizeToList('foo'));
self::assertSame([42], self::adapter()->normalizeToList(42));
}
public function testNormalizeToListWithObjectWrapsIntoList(): void
{
$obj = new stdClass();
$result = self::adapter()->normalizeToList($obj);
self::assertCount(1, $result);
self::assertSame($obj, $result[0]);
}
public function testNormalizeToListWithListReturnsListUnchanged(): void
{
$input = ['a', 'b', 'c'];
self::assertSame($input, self::adapter()->normalizeToList($input));
}
public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void
{
$input = ['x' => 'a', 'y' => 'b'];
$result = self::adapter()->normalizeToList($input);
self::assertSame(['a', 'b'], $result);
}
#[DataProvider('toNullableStringProvider')]
public function testToNullableString(mixed $input, ?string $expected): void
{
self::assertSame($expected, self::adapter()->toNullableString($input));
}
// ---------- toNullableString ----------
/** @return iterable<string,array{mixed, ?string}> */
public static function toNullableStringProvider(): iterable
{
yield 'null' => [null, null];
yield 'empty string' => ['', null];
yield 'whitespace only' => [' ', null];
yield 'plain' => ['abc', 'abc'];
yield 'trimmed' => [' abc ', 'abc'];
yield 'int coerced to string' => [42, '42'];
yield 'zero preserved' => ['0', '0'];
}
#[DataProvider('toNullableIntProvider')]
public function testToNullableInt(mixed $input, ?int $expected): void
{
self::assertSame($expected, self::adapter()->toNullableInt($input));
}
// ---------- toNullableInt ----------
/** @return iterable<string,array{mixed, ?int}> */
public static function toNullableIntProvider(): iterable
{
yield 'null' => [null, null];
yield 'int passthrough' => [42, 42];
yield 'zero int' => [0, 0];
yield 'numeric string' => ['42', 42];
yield 'negative string' => ['-7', -7];
yield 'float-like string' => ['3.14', 3];
yield 'non-numeric' => ['abc', null];
yield 'empty string' => ['', null];
}
#[DataProvider('toNullableBoolProvider')]
public function testToNullableBool(mixed $input, ?bool $expected): void
{
self::assertSame($expected, self::adapter()->toNullableBool($input));
}
// ---------- toNullableBool ----------
/** @return iterable<string,array{mixed, ?bool}> */
public static function toNullableBoolProvider(): iterable
{
yield 'null' => [null, null];
yield 'true' => [true, true];
yield 'false' => [false, false];
yield 'string 1' => ['1', true];
yield 'string 0' => ['0', false];
yield 'empty string' => ['', false];
yield 'int 1' => [1, true];
yield 'int 0' => [0, false];
}
// ---------- toNullableDate ----------
public function testToNullableDateFromIsoString(): void
{
$result = self::adapter()->toNullableDate('2026-04-21');
self::assertEquals(new DateTimeImmutable('2026-04-21'), $result);
}
public function testToNullableDateFromDateTimeString(): void
{
$result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00');
self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result);
}
#[DataProvider('toNullableDateFallbackProvider')]
public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void
{
self::assertNull(self::adapter()->toNullableDate($input));
}
/** @return iterable<string,array{mixed}> */
public static function toNullableDateFallbackProvider(): iterable
{
yield 'null' => [null];
yield 'empty string' => [''];
yield 'whitespace' => [' '];
yield 'non-string int' => [42];
yield 'non-string array' => [[]];
yield 'invalid date string' => ['not-a-date'];
}
/**
* Adapter anonyme : re-expose chaque helper protected en public pour le test,
* sans jamais instancier un vrai mapper (ceux-ci ont des dépendances DI).
*/
private static function adapter(): object
{
return new class {
use BovinNodeMappingTrait {
normalizeToList as public;
toNullableString as public;
toNullableInt as public;
toNullableBool as public;
toNullableDate as public;
}
};
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateEntreeResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateEntreeResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(CreateEntreeResponseMapper::class)]
final class CreateEntreeResponseMapperTest extends TestCase
{
public function testMapPendingBdniValidation(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->AttenteValidationBDNi = true;
$dto = new CreateEntreeResponseMapper(new StandardResponseMapper())->map($soapResponse);
self::assertInstanceOf(CreateEntreeResponseDto::class, $dto);
self::assertTrue($dto->standardResponse->result);
self::assertTrue($dto->pendingBdniValidation);
self::assertNull($dto->identification);
self::assertNull($dto->entryMovement);
}
public function testMapValidatedEntry(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$validee = new stdClass();
$validee->IdentiteBovin = new stdClass();
$validee->IdentiteBovin->Sexe = 'F';
$validee->IdentiteBovin->Bovin = new stdClass();
$validee->IdentiteBovin->Bovin->CodePays = 'FR';
$validee->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';
$validee->MouvementEntreeBovin = new stdClass();
$validee->MouvementEntreeBovin->DateEntree = '2026-04-22';
$validee->MouvementEntreeBovin->CauseEntree = 'A';
$soapResponse->ReponseSpecifique->EntreeValidee = $validee;
$dto = new CreateEntreeResponseMapper(new StandardResponseMapper())->map($soapResponse);
self::assertFalse($dto->pendingBdniValidation);
self::assertNotNull($dto->identification);
self::assertSame('F', $dto->identification->sex);
self::assertSame('FR1234567890', $dto->identification->bovin?->nationalNumber);
self::assertNotNull($dto->entryMovement);
self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->entryMovement->date);
self::assertSame('A', $dto->entryMovement->cause);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\CreateSortieResponseDto;
use Malio\EdnotifBundle\Bovin\Mapper\CreateSortieResponseMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(CreateSortieResponseMapper::class)]
final class CreateSortieResponseMapperTest extends TestCase
{
public function testMapPendingBdniValidation(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->AttenteValidationBDNi = true;
$dto = new CreateSortieResponseMapper(new StandardResponseMapper())->map($soapResponse);
self::assertInstanceOf(CreateSortieResponseDto::class, $dto);
self::assertTrue($dto->pendingBdniValidation);
self::assertNull($dto->identification);
self::assertNull($dto->entryMovement);
self::assertNull($dto->exitMovement);
}
public function testMapValidatedExit(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$validee = new stdClass();
$validee->IdentiteBovin = new stdClass();
$validee->IdentiteBovin->Sexe = 'M';
$validee->IdentiteBovin->Bovin = new stdClass();
$validee->IdentiteBovin->Bovin->NumeroNational = 'FR9999999999';
$mouvement = new stdClass();
$mouvement->MouvementEntreeBovin = new stdClass();
$mouvement->MouvementEntreeBovin->DateEntree = '2024-01-10';
$mouvement->MouvementEntreeBovin->CauseEntree = 'A';
$mouvement->MouvementSortieBovin = new stdClass();
$mouvement->MouvementSortieBovin->DateSortie = '2026-04-22';
$mouvement->MouvementSortieBovin->CauseSortie = 'B';
$validee->MouvementBovin = $mouvement;
$soapResponse->ReponseSpecifique->SortieValidee = $validee;
$dto = new CreateSortieResponseMapper(new StandardResponseMapper())->map($soapResponse);
self::assertFalse($dto->pendingBdniValidation);
self::assertNotNull($dto->identification);
self::assertSame('M', $dto->identification->sex);
self::assertSame('FR9999999999', $dto->identification->bovin?->nationalNumber);
self::assertNotNull($dto->entryMovement);
self::assertEquals(new DateTimeImmutable('2024-01-10'), $dto->entryMovement->date);
self::assertSame('A', $dto->entryMovement->cause);
self::assertNotNull($dto->exitMovement);
self::assertEquals(new DateTimeImmutable('2026-04-22'), $dto->exitMovement->date);
self::assertSame('B', $dto->exitMovement->cause);
}
}

View File

@@ -34,6 +34,9 @@ final class InventoryMapperTest extends TestCase
self::assertCount(2, $inventory->animals);
self::assertCount(1, $inventory->earTagSeries);
self::assertSame('FR123', $inventory->animals[0]->identification?->bovin?->nationalNumber);
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
}
public function testMapInventoryWithoutMessageZipReturnsEmptyLists(): void
@@ -54,6 +57,89 @@ final class InventoryMapperTest extends TestCase
self::assertNull($inventory->startDate);
}
public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-01-01';
// StockBoucles deliberately omitted
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertNull($inventory->includesEarTagStock);
}
public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->StockBoucles = '0';
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertFalse($inventory->includesEarTagStock);
}
public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-01-01';
// DateFin deliberately omitted
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertNull($inventory->endDate);
}
public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$serie1 = new stdClass();
$serie1->CodePays = 'FR';
$serie1->DebutSerie = '0012345678';
$serie1->Quantite = 10;
$serie2 = new stdClass();
$serie2->CodePays = 'FR';
$serie2->DebutSerie = '0055500000';
$serie2->Quantite = 25;
$message = new stdClass();
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = [$serie1, $serie2];
$inventory = $mapper->map($this->makeSoapResponse(), $message);
self::assertCount(2, $inventory->earTagSeries);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
}
public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
// NbBovins deliberately omitted
$inventory = $mapper->map($soapResponse, null);
self::assertSame(0, $inventory->nbBovins);
}
private function makeSoapResponse(): object
{
$response = new stdClass();
@@ -77,9 +163,11 @@ final class InventoryMapperTest extends TestCase
$this->makeAnimalNode('FR123'),
$this->makeAnimalNode('FR456'),
];
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->CodePays = 'FR';
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
$message->Boucles->SerieBoucles->Quantite = 50;
return $message;
}