Compare commits

...

5 Commits

Author SHA1 Message Date
486247bf86 feat : bovine.reception FK + delete op + sécurités abaissées
Ajout d'une relation ManyToOne nullable vers Reception, d'un
SearchFilter exact, d'une opération DELETE et abaissement de la
sécurité Post/Patch/Delete de ROLE_ADMIN à ROLE_USER pour le flux
métier opérationnel d'entrée/sortie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:43:44 +02:00
43d7a2514b feat : reception.entryCompleted + relation inverse bovines + filtres
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:33:49 +02:00
6579bb72dd feat : migration entry_completed + bovine.reception_id
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:31:56 +02:00
7ecc5b6d2f docs : plan d'implémentation workflow entrée bovins
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:58:15 +02:00
4f6b6ff3c3 docs : spec workflow entrée/sortie bovins
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:49:01 +02:00
5 changed files with 1632 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
# Entrée / Sortie des bovins — Design
## Contexte
Aujourd'hui, l'application gère les **réceptions** (arrivée d'un camion) qui déclarent un nombre de bovins par race (ex : 5 charolais + 3 limousine + 2 autres). Une fois la réception terminée, ces déclarations sont des indicateurs imprécis et il manque l'étape de saisie individuelle des bovins (numéro national, poids, prix…).
L'objectif est d'introduire un **workflow d'entrée** qui transforme une réception bovins finie en saisies individuelles enrichies via EDNOTIF, et de poser les fondations pour un futur workflow de sortie symétrique.
Pour ce lot, **les sorties sont hors scope** mais l'écran liste prévoit déjà leur emplacement.
## Décisions structurantes
| Décision | Choix |
| --- | --- |
| Distinction "en attente" vs "terminée" | Flag explicite `entryCompleted: bool` sur `Reception` |
| Lien Bovine → Reception | FK 1-N, `Bovine.reception` ManyToOne **nullable** |
| Rendu de l'écran de saisie | UN formulaire (2 lignes) + tableau récap dessous |
| Bâtiment + Case | Choisis **par bovin** dans le formulaire |
| Persistance | Save individuel à chaque "Ajouter" (POST /bovines) |
| Enrichissement EDNOTIF | Au backend via le `BovineProcessor` existant (pas de lookup live) |
## Modèle de données
### `Reception` — modification
Nouveau champ :
- `entryCompleted: bool`, default `false`, non nullable.
- Pertinent uniquement quand `receptionType.code === 'BOVINS'`. Pour les autres types, reste `false` et ignoré côté UI.
- Inclus dans les groupes `reception:read` et `reception:write`.
Migration : `ALTER TABLE reception ADD COLUMN entry_completed BOOLEAN NOT NULL DEFAULT false`.
Ajout d'un `BooleanFilter` sur `entryCompleted` dans `#[ApiFilter]`.
### `Bovine` — modification
Nouveau champ :
- `reception: Reception` (ManyToOne, **nullable**).
- Inclus dans `bovine:read` et `bovine:write`.
Migration : `ALTER TABLE bovine ADD COLUMN reception_id INTEGER NULL` + index + FK contrainte. Bovins existants restent à `NULL` — aucune migration de données.
Ajout d'un `SearchFilter` exact sur `reception` dans `#[ApiFilter]` pour permettre `GET /bovines?reception={id}`.
### `Reception` — relation inverse pour le compteur
Pour permettre l'affichage du compteur "bovins saisis" dans la liste sans N+1 :
- Ajouter `bovines: Collection<Bovine>` côté `Reception` (OneToMany inverse, `mappedBy: 'reception'`, fetch lazy).
- Exposer un getter calculé `getRegisteredBovineCount(): int` dans le groupe `reception:read`.
- L'implémentation côté provider/list peut utiliser un `addSelect('COUNT(b.id) AS bovineCount')` via un `QueryExtension` API Platform si le N+1 devient un problème (à mesurer).
### Aucune autre entité
Pas de table de jointure (un bovin entre une seule fois via une réception unique). Pas de nouvelle entité `Entry` (la `Reception` joue ce rôle). Pas d'entité `Exit` pour ce lot — la symétrie sera traitée plus tard.
## Endpoints API
Tous les endpoints réutilisent les ressources existantes ; **aucun endpoint custom n'est créé**.
### Liste des entrées en attente
`GET /api/receptions?receptionType.code=BOVINS&isValid=true&entryCompleted=false`
### Validation finale d'une entrée
`PATCH /api/receptions/{id}` avec `{ entryCompleted: true }`.
### Création d'un bovin lié
`POST /api/bovines` (Content-Type `application/ld+json`) avec :
```json
{
"nationalNumber": "FR1234567890",
"receivedWeight": 368,
"pricePerKg": 5.7,
"arrivalDate": "2026-04-29",
"supplier": "/api/suppliers/12",
"reception": "/api/receptions/45",
"buildingCase": "/api/building_cases/8"
}
```
Le `BovineProcessor` enrichit automatiquement (workNumber, birthDate, race auto-créée via `BovineType`).
**Nettoyage en passant** : le `BovineProcessor` actuel appelle `setBreedCode()` qui n'existe plus (héritage avant la migration vers `BovineType` FK). À corriger pour qu'il fasse `setBovineType()` avec auto-create d'un `BovineType` si la race retournée par EDNOTIF n'existe pas en base.
### Suppression d'un bovin
`DELETE /api/bovines/{id}` — sécurité actuelle `ROLE_ADMIN` à abaisser à `ROLE_USER` pour permettre la correction immédiate depuis le tableau.
## Front-end
### Home (`pages/index.vue`)
- Card "CASES" → renommée "ENTRÉE / SORTIE" (multi-ligne `Entrée<br>Sortie`).
- Lien : `/entry-exit`.
- Icône : `mdi:swap-horizontal-bold` (à finaliser à l'implémentation).
### Page liste — `pages/entry-exit/index.vue`
Deux sections empilées :
**Entrées en attente**
- Composant : `UiDataTable`.
- Filtres serveur : `receptionType.code=BOVINS`, `isValid=true`, `entryCompleted=false`.
- Colonnes :
- Date réception
- Fournisseur (`supplier.name`)
- Total déclaré (calculé côté front : `sum(bovines_types.quantity) + parseInt(bovineDetail ?? '0')`)
- Bovins saisis (depuis `getRegisteredBovineCount` exposé sur Reception)
- Action (rangée cliquable)
- Click row → `/entry-exit/entry/{receptionId}`.
**Sorties en attente**
- Tableau placeholder vide avec message "À venir".
### Écran de saisie — `pages/entry-exit/entry/[id].vue`
**Header**
- Titre : "Entrée bovins #N-BR-XXXX — Fournisseur YYY"
- Sous-titre : "Bovins déclarés : 8 · Bovins saisis : 3"
- Icône retour à gauche.
**Formulaire (2 lignes)**
Ligne 1 : Numéro national · Poids à l'arrivée · Date d'arrivée · Vendeur (Supplier select)
Ligne 2 : Prix au kilo · Bâtiment (Building select) · Case (BuildingCase select dépendant du bâtiment) · Bouton **Ajouter**
**Pré-remplissage** (au chargement et après chaque add) :
- Date d'arrivée = `reception.receptionDate` (date seule, modifiable)
- Vendeur = `reception.supplier` (modifiable)
- Bâtiment = premier de `reception.buildings` si dispo, sinon vide
- Case = vide (à choisir explicitement)
- Numéro national, poids, prix : vides
**Comportement bouton "Ajouter"**
- Disabled si form invalide (n° national vide, poids ≤ 0, prix ≤ 0, building/case manquants).
- Click → `POST /api/bovines` avec `application/ld+json`.
- Succès → reload du tableau, reset form (en gardant les pré-remplissages), focus sur Numéro national.
- Erreur 409 (doublon n° national) → toast "Ce bovin existe déjà".
- Erreur EDNOTIF → bovin créé sans enrichissement (race/naissance vides), toast warning.
**Tableau récap (dessous)**
Colonnes : N° national · N° travail · Race · Sexe · Date naissance · Poids arrivée · Date arrivée · Prix/kg · Prix total · Bâtiment · Case · Action (icône poubelle).
Source : `GET /api/bovines?reception={id}` au mount + après chaque add/delete.
Suppression : `DELETE /api/bovines/{id}` avec `window.confirm`.
**Footer**
- Bouton **Valider l'entrée** (à droite).
- Si `bovins saisis < bovins déclarés``window.confirm("Vous n'avez saisi que X/Y bovins. Confirmer la fermeture ?")`.
- Disabled si 0 bovin saisi.
- Click → `PATCH /api/receptions/{id}` avec `{ entryCompleted: true }` → toast succès → redirection `/entry-exit`.
## Sécurité (rôles)
| Action | Rôle requis |
| --- | --- |
| Voir la page entrée/sortie | `ROLE_USER` |
| Ajouter un bovin (POST /bovines) | `ROLE_USER` (actuellement `ROLE_ADMIN` — à abaisser, ce flux est métier opérationnel) |
| Supprimer un bovin (DELETE /bovines) | `ROLE_USER` (idem, à abaisser) |
| Valider l'entrée (PATCH receptions) | `ROLE_USER` |
L'abaissement à `ROLE_USER` sur `Bovine::Post`, `Bovine::Patch` et `Bovine::Delete` est **délibéré** : ce flux fait partie des opérations métier quotidiennes, pas de l'administration. À confirmer pendant l'implémentation.
## Cas limites
- **Total saisi > déclaré** : autorisé (les déclarations en réception sont des indicateurs imprécis).
- **Doublon n° national** : la `UniqueConstraint` BDD le rejette → toast.
- **EDNOTIF indisponible** : bovin créé sans enrich, comportement actuel du processor.
- **Réception supprimée pendant la saisie** : impossible côté UI tant qu'on est dans l'écran. Si ça arrive (autre user), les `POST /bovines` suivants échoueront en 404 sur l'IRI reception → toast.
- **Sortie d'un bovin** : non géré dans ce lot. Le futur workflow de sortie viendra basculer `Bovine.exitedAt`.
## Critères d'acceptation
- [ ] Migration `entry_completed` sur Reception passe sans erreur.
- [ ] Migration `reception_id` sur Bovine passe sans erreur, bovins existants intacts.
- [ ] Card "CASES" sur home remplacée par "ENTRÉE / SORTIE".
- [ ] `/entry-exit` affiche les entrées en attente et un placeholder sorties.
- [ ] Click sur une entrée → écran saisie avec form pré-rempli.
- [ ] "Ajouter" → bovin créé, ligne au tableau, form reset (pré-remplissages restaurés).
- [ ] Suppression d'une ligne fonctionne avec confirmation.
- [ ] "Valider l'entrée" bascule `entryCompleted` et redirige.
- [ ] Une réception fermée disparaît de la liste.
- [ ] `BovineProcessor` corrigé pour utiliser `setBovineType()` avec auto-create.
- [ ] `make test` passe sans régression.
## Mode d'implémentation
Sur ce projet, l'utilisateur souhaite **valider chaque étape du plan** avant exécution. À chaque étape du plan d'implémentation, l'agent doit :
1. Présenter ce qu'il s'apprête à faire (fichiers, changements).
2. Attendre la validation explicite de l'utilisateur.
3. Exécuter, puis présenter l'étape suivante.
Cette discipline permet des retours en direct et des ajustements fins en cours de route.

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429073108 extends AbstractMigration
{
public function getDescription(): string
{
return 'Workflow entrée/sortie : ajout entry_completed sur reception et reception_id sur bovine.';
}
public function up(Schema $schema): void
{
// Reception : flag de fermeture d'une entrée bovins.
$this->addSql('ALTER TABLE reception ADD entry_completed BOOLEAN NOT NULL DEFAULT FALSE');
// Bovine : FK nullable vers la réception qui a fait entrer le bovin.
$this->addSql('ALTER TABLE bovine ADD reception_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_BOVINE_RECEPTION ON bovine (reception_id)');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_BOVINE_RECEPTION FOREIGN KEY (reception_id) REFERENCES reception (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_BOVINE_RECEPTION');
$this->addSql('DROP INDEX IDX_BOVINE_RECEPTION');
$this->addSql('ALTER TABLE bovine DROP reception_id');
$this->addSql('ALTER TABLE reception DROP entry_completed');
}
}

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
@@ -34,6 +35,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
'reception' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
@@ -50,16 +52,20 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
new Post(
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_USER')",
processor: BovineProcessor::class,
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_USER')",
processor: BovineProcessor::class,
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_USER')",
),
],
security: "is_granted('ROLE_USER')",
)]
@@ -94,6 +100,12 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne(inversedBy: 'bovines')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: false)]
private ?Reception $reception = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
@@ -211,6 +223,18 @@ class Bovine
return $this;
}
public function getReception(): ?Reception
{
return $this->reception;
}
public function setReception(?Reception $reception): static
{
$this->reception = $reception;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;

View File

@@ -31,13 +31,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.id' => 'exact',
'receptionType.code' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource(
@@ -110,6 +111,10 @@ class Reception
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $isValid = false;
#[ORM\Column(options: ['default' => false])]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $entryCompleted = false;
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context(
@@ -204,6 +209,12 @@ class Reception
#[Groups(['reception:read', 'reception:write'])]
private ?string $bovineDetail = null;
/**
* @var Collection<int, Bovine>
*/
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
private Collection $bovines;
public function __construct(
?DateTimeImmutable $receptionDate = null,
) {
@@ -212,6 +223,7 @@ class Reception
$this->buildings = new ArrayCollection();
$this->pelletBuildings = new ArrayCollection();
$this->bovines_types = new ArrayCollection();
$this->bovines = new ArrayCollection();
}
public function getId(): ?int
@@ -270,6 +282,25 @@ class Reception
return $this;
}
#[Groups(['reception:read'])]
public function isEntryCompleted(): bool
{
return $this->entryCompleted;
}
public function setEntryCompleted(bool $entryCompleted): self
{
$this->entryCompleted = $entryCompleted;
return $this;
}
#[Groups(['reception:read'])]
public function getRegisteredBovineCount(): int
{
return $this->bovines->count();
}
#[Groups(['reception:read'])]
public function getReceptionDate(): ?DateTimeImmutable
{