From 7ecc5b6d2f74bd69891ee8527a20e8115dda02b6 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 29 Apr 2026 08:58:15 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation=20workf?= =?UTF-8?q?low=20entr=C3=A9e=20bovins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-29-bovine-entry-exit.md | 1340 +++++++++++++++++ 1 file changed, 1340 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-bovine-entry-exit.md diff --git a/docs/superpowers/plans/2026-04-29-bovine-entry-exit.md b/docs/superpowers/plans/2026-04-29-bovine-entry-exit.md new file mode 100644 index 0000000..f924694 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-bovine-entry-exit.md @@ -0,0 +1,1340 @@ +# Workflow Entrée / Sortie Bovins — Plan d'implémentation + +> **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. +> +> **Mode utilisateur :** L'utilisateur souhaite valider chaque étape avant exécution (cf. memory `feedback_step_by_step_validation`). Avant chaque task, présenter ce qui va être fait et attendre OK explicite. + +**Goal:** Permettre la saisie individuelle des bovins issus d'une réception bovins finie, via un workflow d'entrée enrichi par EDNOTIF, et préparer la place pour les sorties (hors scope ce lot). + +**Architecture:** Flag `entryCompleted` sur `Reception` pour le statut "en attente / terminée". FK 1-N nullable `Bovine.reception` pour matérialiser le lien. UN formulaire (2 lignes) + tableau récap pour la saisie ; chaque "Ajouter" persiste un bovin et l'enrichit via le `BovineProcessor` existant (corrigé en passant). Aucun nouvel endpoint API : tout passe par les ressources existantes. + +**Tech Stack:** Symfony 8 + API Platform 4 (PHP 8.4, Doctrine ORM, PostgreSQL) ; Nuxt 4 + Vue 3 + Pinia + Tailwind. + +**Spec source:** `docs/superpowers/specs/2026-04-29-bovine-entry-exit-design.md` + +**Branche de travail:** `feat/entree-sortie` (déjà créée). + +--- + +## Synthèse du file-mapping + +| Fichier | Type | Responsabilité | +| --- | --- | --- | +| `migrations/Version.php` | Create | Migration combinée : `reception.entry_completed` + `bovine.reception_id` | +| `src/Entity/Reception.php` | Modify | Ajout `entryCompleted` + relation inverse `bovines` + `getRegisteredBovineCount()` + filtres | +| `src/Entity/Bovine.php` | Modify | Ajout FK `reception` + filter + sécurités abaissées | +| `src/State/Bovin/BovineProcessor.php` | Modify | Fix `setBreedCode` → `setBovineType` avec auto-create | +| `frontend/services/dto/reception-data.ts` | Modify | Ajout `entryCompleted` + `registeredBovineCount` au `ReceptionData` et `ReceptionPayload` | +| `frontend/services/dto/bovine-data.ts` | Modify | Ajout `reception` à `BovineData` et `BovinePayload` | +| `frontend/pages/index.vue` | Modify | Renommer card CASES → ENTRÉE/SORTIE | +| `frontend/pages/entry-exit/index.vue` | Create | Page liste : entrées en attente + placeholder sorties | +| `frontend/pages/entry-exit/entry/[id].vue` | Create | Écran de saisie : header + form + tableau + bouton Valider | + +Pas de nouveaux composants UI — tout réutilise `UiDataTable`, `UiTextInput`, `UiNumberInput`, `UiSelect`, `UiButton`. + +--- + +## Task 1 : Migration combinée `reception.entry_completed` + `bovine.reception_id` + +**Files:** +- Create: `migrations/Version.php` (nom généré par doctrine) + +- [ ] **Step 1: Générer le squelette de migration** + +```bash +make shell +php bin/console doctrine:migrations:generate +exit +``` + +Repérer le fichier créé (le plus récent dans `migrations/`). + +- [ ] **Step 2: Remplir la migration** + +```php + 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'); + } +} +``` + +> `ON DELETE SET NULL` sur le FK : si on supprime une réception, ses bovins persistent (tracés en BDD mais sans lien). C'est le comportement souhaité — ça ne casse pas le bovin existant. + +- [ ] **Step 3: Lancer la migration en dev** + +```bash +make migration-migrate +``` + +Expected: `Migration migrated, took ...`. Pas d'erreur. + +- [ ] **Step 4: Vérifier le schéma** + +```bash +docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "\d reception" | grep entry_completed +docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "\d bovine" | grep reception_id +``` + +Expected : les deux colonnes apparaissent. + +- [ ] **Step 5: Commit** + +```bash +git add migrations/Version.php +git commit -m "feat: migration entry_completed + bovine.reception_id" +``` + +--- + +## Task 2 : Reception — `entryCompleted`, relation inverse `bovines`, `getRegisteredBovineCount()`, filtre + +**Files:** +- Modify: `src/Entity/Reception.php` + +- [ ] **Step 1: Ajouter le champ `entryCompleted`** + +Dans `Reception.php`, dans la section des colonnes (juste après `isValid`), ajouter : + +```php +#[ORM\Column(options: ['default' => false])] +#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])] +private bool $entryCompleted = false; +``` + +Et les accesseurs (placés après `setIsValid`) : + +```php +#[Groups(['reception:read'])] +public function isEntryCompleted(): bool +{ + return $this->entryCompleted; +} + +public function setEntryCompleted(bool $entryCompleted): self +{ + $this->entryCompleted = $entryCompleted; + + return $this; +} +``` + +- [ ] **Step 2: Ajouter le filtre Boolean sur `entryCompleted`** + +Modifier l'attribut `#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]` en : + +```php +#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])] +``` + +- [ ] **Step 3: Ajouter le filtre Search sur `receptionType.code`** + +Vérifier dans la liste des `SearchFilter` si `receptionType.code` est présent (déjà pour `receptionType.id`). Si non, l'ajouter — la liste front filtre par `receptionType.code=BOVINS` (lisible). Modifier : + +```php +#[ApiFilter(SearchFilter::class, properties: [ + 'identificationNumber' => 'ipartial', + 'supplier.name' => 'ipartial', + 'carrier.name' => 'ipartial', + 'licensePlate' => 'ipartial', + 'receptionType.id' => 'exact', + 'receptionType.code' => 'exact', +])] +``` + +- [ ] **Step 4: Ajouter la relation inverse `bovines`** + +Dans la section des collections (à côté de `$weights`, `$buildings`, etc.) : + +```php +/** + * @var Collection + */ +#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')] +private Collection $bovines; +``` + +Initialiser dans le constructeur : + +```php +public function __construct( + ?DateTimeImmutable $receptionDate = null, +) { + $this->receptionDate = $receptionDate; + $this->weights = new ArrayCollection(); + $this->buildings = new ArrayCollection(); + $this->pelletBuildings = new ArrayCollection(); + $this->bovines_types = new ArrayCollection(); + $this->bovines = new ArrayCollection(); +} +``` + +- [ ] **Step 5: Ajouter le getter calculé `getRegisteredBovineCount()`** + +Après `setEntryCompleted` : + +```php +#[Groups(['reception:read'])] +public function getRegisteredBovineCount(): int +{ + return $this->bovines->count(); +} +``` + +> `count()` sur une `Collection` Doctrine déclenche un `COUNT(*)` SQL si la collection n'est pas chargée (extra_lazy serait encore mieux mais pas indispensable ici, le volume est faible). + +- [ ] **Step 6: Vérifier la compilation** + +```bash +make shell +php bin/console cache:clear +exit +``` + +Expected: pas d'erreur. + +- [ ] **Step 7: Commit** + +```bash +git add src/Entity/Reception.php +git commit -m "feat: reception.entryCompleted + relation inverse bovines + filtres" +``` + +--- + +## Task 3 : Bovine — FK `reception`, filtre, sécurités abaissées + +**Files:** +- Modify: `src/Entity/Bovine.php` + +- [ ] **Step 1: Ajouter la propriété `reception`** + +Dans `Bovine.php`, après le bloc `$buildingCase` : + +```php +#[ORM\ManyToOne(inversedBy: 'bovines')] +#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] +#[Groups(['bovine:read', 'bovine:write'])] +#[ApiProperty(readableLink: false)] +private ?Reception $reception = null; +``` + +Accesseurs (après `setBuildingCase`) : + +```php +public function getReception(): ?Reception +{ + return $this->reception; +} + +public function setReception(?Reception $reception): static +{ + $this->reception = $reception; + + return $this; +} +``` + +- [ ] **Step 2: Ajouter le filtre exact sur `reception`** + +Dans la liste `SearchFilter` : + +```php +#[ApiFilter(SearchFilter::class, properties: [ + 'nationalNumber' => 'ipartial', + 'workNumber' => 'ipartial', + 'bovineType.label' => 'ipartial', + 'bovineType.code' => 'ipartial', + 'sex' => 'exact', + 'buildingCase' => 'exact', + 'receivedWeight' => 'exact', + 'reception' => 'exact', +])] +``` + +- [ ] **Step 3: Abaisser la sécurité Post / Patch** + +Sur les opérations `Post` et `Patch` de `#[ApiResource(operations: [...])]`, remplacer : +```php +security: "is_granted('ROLE_ADMIN')", +``` +par : +```php +security: "is_granted('ROLE_USER')", +``` + +> Note : la décision est délibérée (cf. spec). C'est un point que l'utilisateur peut vouloir revoir — confirmer avant le commit. + +- [ ] **Step 4: Ajouter une opération Delete avec sécurité ROLE_USER** + +Aujourd'hui il n'y a pas d'op `Delete` sur `Bovine`. Ajouter : + +```php +new Delete( + requirements: ['id' => '\d+'], + security: "is_granted('ROLE_USER')", +), +``` + +Ajouter l'import en haut : +```php +use ApiPlatform\Metadata\Delete; +``` + +- [ ] **Step 5: Cache clear + smoke test API** + +```bash +make cache-clear +``` + +Vérifier que `/api/docs` répond et que `Bovine` a bien une opération DELETE : + +```bash +curl -s http://localhost:8080/api/docs.json | jq '.paths."/bovines/{id}".delete' +``` + +Expected : non null (présence du DELETE). + +- [ ] **Step 6: Commit** + +```bash +git add src/Entity/Bovine.php +git commit -m "feat: bovine.reception FK + delete op + sécurités abaissées" +``` + +--- + +## Task 4 : Fix `BovineProcessor` (setBreedCode obsolète → setBovineType avec auto-create) + +**Files:** +- Modify: `src/State/Bovin/BovineProcessor.php` + +**Contexte :** Le processor actuel appelle `$bovine->setBreedCode(...)` qui n'existe plus depuis la migration vers `BovineType` FK. Le pattern d'auto-create existe déjà dans `BovineSyncInventoryProcessor::resolveBovineType` — on duplique la logique en simple ici (pas de cache, single-shot). + +- [ ] **Step 1: Réécrire `BovineProcessor.php`** + +Remplacer entièrement le contenu par : + +```php +getNationalNumber()) { + $this->enrichFromEdnotif($data); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + private function enrichFromEdnotif(Bovine $bovine): void + { + try { + $animalFile = $this->bovinApi->getAnimalFile( + nationalNumber: $bovine->getNationalNumber(), + countryCode: 'FR', + ); + + $identification = $animalFile->identification; + if (null === $identification) { + return; + } + + $bovine->setSex($identification->sex); + $bovine->setWorkNumber($identification->workNumber); + $bovine->setBirthDate($identification->birthDate?->date); + $bovine->setBovineType($this->resolveBovineType($identification->breedType)); + } catch (Throwable) { + // External service unavailable — persist bovine without enrichment. + } + } + + /** + * Trouve un BovineType par code, sinon en crée un placeholder + * (l'admin pourra le renommer ensuite dans /admin/bovin/bovin-list). + */ + private function resolveBovineType(?string $code): ?BovineType + { + if (null === $code || '' === $code) { + return null; + } + + $existing = $this->em->getRepository(BovineType::class)->findOneBy(['code' => $code]); + if (null !== $existing) { + return $existing; + } + + $bovineType = new BovineType(); + $bovineType->setCode($code); + $bovineType->setLabel(sprintf('À renommer (%s)', $code)); + $this->em->persist($bovineType); + + return $bovineType; + } +} +``` + +> Note : `setSex` ajouté en passant — c'était oublié dans la version précédente. Cohérent avec `BovineSyncInventoryProcessor::applyEdnotifData`. + +- [ ] **Step 2: Vérifier que ça compile** + +```bash +make cache-clear +``` + +- [ ] **Step 3: Lancer les tests** + +```bash +make test +``` + +Expected : tous les tests passent (pas de nouveau test ajouté ici, mais on vérifie qu'on n'a rien cassé). + +- [ ] **Step 4: Smoke test manuel** + +Créer un bovin via curl avec un n° national de pré-prod EDNOTIF (déjà utilisé dans le scan) : + +```bash +TOKEN=$(curl -s -c - -X POST http://localhost:8080/api/login_check \ + -H "Content-Type: application/json" \ + -d '{"username":"","password":""}' | grep BEARER | awk '{print $7}') + +curl -X POST http://localhost:8080/api/bovines \ + -H "Content-Type: application/ld+json" \ + -H "Cookie: BEARER=$TOKEN" \ + -d '{"nationalNumber":"FR"}' +``` + +Expected : 201 Created, payload renvoyé avec `bovineType` non null si le n° existe en pré-prod EDNOTIF. + +> Si on n'a pas d'accès EDNOTIF en local, sauter ce step et faire le smoke test plus tard côté UI (Task 11). + +- [ ] **Step 5: Commit** + +```bash +git add src/State/Bovin/BovineProcessor.php +git commit -m "fix: BovineProcessor utilise setBovineType avec auto-create (au lieu de setBreedCode obsolète)" +``` + +--- + +## Task 5 : Frontend — DTOs et services + +**Files:** +- Modify: `frontend/services/dto/reception-data.ts` +- Modify: `frontend/services/dto/bovine-data.ts` + +- [ ] **Step 1: `ReceptionData` — ajouter `entryCompleted` et `registeredBovineCount`** + +Dans `frontend/services/dto/reception-data.ts`, dans l'interface `ReceptionData`, ajouter : + +```ts +entryCompleted?: boolean +registeredBovineCount?: number +``` + +(à insérer juste après `isValid: boolean`). + +Et dans `ReceptionPayload`, ajouter : + +```ts +entryCompleted?: boolean +``` + +- [ ] **Step 2: `BovineData` — ajouter `reception`** + +Dans `frontend/services/dto/bovine-data.ts`, dans l'interface `BovineData`, ajouter : + +```ts +reception?: string | null +``` + +Et dans `BovinePayload`, ajouter : + +```ts +reception?: string | null +``` + +- [ ] **Step 3: Vérifier la compilation TS du front** + +```bash +make shell +cd frontend && npx vue-tsc --noEmit 2>&1 | head -40 +exit +``` + +Expected : pas d'erreur (ou erreurs uniquement sur des fichiers non touchés). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/reception-data.ts frontend/services/dto/bovine-data.ts +git commit -m "feat(front): ajout des champs entryCompleted, registeredBovineCount, bovine.reception aux DTOs" +``` + +--- + +## Task 6 : Frontend — Renommer la card CASES sur la home + +**Files:** +- Modify: `frontend/pages/index.vue` + +- [ ] **Step 1: Remplacer la card CASES** + +Dans `frontend/pages/index.vue`, repérer : + +```html + +``` + +Remplacer par : + +```html + + + +``` + +> L'icône `mdi:swap-horizontal-bold` exprime un flux entrant/sortant. On peut ajuster en pratique si visuellement ça ne plait pas. + +- [ ] **Step 2: Vérifier dans le navigateur** + +Lancer le front si ce n'est pas déjà fait : + +```bash +make dev-nuxt +``` + +Ouvrir `http://localhost:3000` et vérifier que la card "Entrée / Sortie" apparaît à la place de "CASES" et pointe vers une URL `/entry-exit` (la page n'existe pas encore → 404, c'est normal). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/index.vue +git commit -m "feat(front): renomme card CASES en Entrée/Sortie sur la home" +``` + +--- + +## Task 7 : Frontend — Page liste `pages/entry-exit/index.vue` + +**Files:** +- Create: `frontend/pages/entry-exit/index.vue` + +- [ ] **Step 1: Créer la page** + +```vue + + + +``` + +> Note : `bovinesTypes` doit contenir un sous-objet `quantity`. Le DTO actuel le type comme `BovineTypeData[]` (incorrect, mais existant — voir Bovin reception payload). Le calcul `declaredCount` traite ça en `any` car le typage actuel est imprécis. **Option** : si le typage strict bloque, ajouter `as any` localement. À mesurer pendant le développement. + +- [ ] **Step 2: Tester dans le navigateur** + +Aller sur `/entry-exit`. Vérifier que : +- Le titre "Entrée / Sortie" apparaît. +- Le tableau "Entrées en attente" se charge (peut être vide si aucune réception bovin valide & non terminée n'existe). +- La section "Sorties en attente" affiche le placeholder "À venir". +- Click sur une ligne → redirection vers `/entry-exit/entry/{id}` (page 404 attendue si pas encore créée). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/entry-exit/index.vue +git commit -m "feat(front): page liste entrée/sortie avec entrées en attente" +``` + +--- + +## Task 8 : Frontend — Écran de saisie, layout (header + form, sans logique d'add) + +**Files:** +- Create: `frontend/pages/entry-exit/entry/[id].vue` + +L'objectif de cette task est d'avoir l'écran qui s'affiche correctement avec ses pré-remplissages. La logique "Ajouter" et "Valider" sera ajoutée dans Task 9 et 11. + +- [ ] **Step 1: Créer le squelette** + +```vue + + + +``` + +> Le fichier compile mais `addBovine()` est vide pour l'instant. Le tableau récap et le bouton Valider sont ajoutés en Task 10/11. + +- [ ] **Step 2: Smoke test navigateur** + +Cliquer sur une entrée depuis `/entry-exit`. Vérifier : +- Page se charge. +- Header affiche identifNumber + nom fournisseur + compteur "Bovins déclarés : N · Bovins saisis : 0". +- Form affiche les 8 champs sur 2 lignes (4 par ligne). +- Date arrivée pré-remplie avec receptionDate. +- Vendeur pré-rempli avec supplier. +- Bâtiment pré-rempli avec premier building si dispo. +- Case vide. +- Bouton "Ajouter" disabled tant que form invalide. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/entry-exit/entry/[id].vue +git commit -m "feat(front): écran saisie entrée — layout header + formulaire" +``` + +--- + +## Task 9 : Frontend — Logique "Ajouter" du formulaire + +**Files:** +- Modify: `frontend/pages/entry-exit/entry/[id].vue` + +- [ ] **Step 1: Implémenter `addBovine`** + +Remplacer le stub `addBovine` par : + +```ts +const addBovine = async () => { + if (!isFormValid.value || isAdding.value) return + + isAdding.value = true + try { + const payload = { + nationalNumber: form.nationalNumber.trim(), + receivedWeight: form.receivedWeight, + pricePerKg: form.pricePerKg, + arrivalDate: form.arrivalDate, + supplier: `/api/suppliers/${form.supplierId}`, + buildingCase: `/api/building_cases/${form.caseId}`, + reception: `/api/receptions/${receptionId.value}` + } + + await api.post('bovines', payload, { + headers: { 'Content-Type': 'application/ld+json' }, + toast: false + }) + + await loadSavedBovines() + resetForm() + await nextTick() + focusFirstField() + } finally { + isAdding.value = false + } +} + +const focusFirstField = () => { + const el = document.querySelector('form input[type="text"]') + el?.focus() +} + +const loadSavedBovines = async () => { + const response = await api.get<{ 'hydra:member'?: BovineData[] } | BovineData[]>( + `bovines?reception=${receptionId.value}`, + {}, + { toast: false } + ) + savedBovines.value = Array.isArray(response) + ? response + : (response['hydra:member'] ?? []) +} +``` + +- [ ] **Step 2: Charger les bovins déjà saisis au mount** + +Modifier le `onMounted` : + +```ts +onMounted(async () => { + [suppliers.value, buildings.value] = await Promise.all([ + getSupplierList(), + getBuildingList() + ]) + await loadReception() + await loadSavedBovines() +}) +``` + +- [ ] **Step 3: Smoke test navigateur** + +- Saisir n° national, poids, prix, sélectionner case. +- Click "Ajouter". +- Vérifier : + - Pas d'erreur, toast succès. + - `savedBovines.length` augmente de 1 (visible dans le compteur du header "Bovins saisis : N+1"). + - Form reset (champs N° national, poids, prix vidés ; date/vendeur/bâtiment restaurés). + - Focus revient sur N° national. + +- [ ] **Step 4: Test erreur — doublon** + +- Saisir le même n° national qu'à l'étape précédente. +- Click "Ajouter". +- Expected : toast erreur ("Ce bovin existe déjà" — si pas de message i18n défini, le toast par défaut de useApi onResponseError). + +> Si la traduction manque, on peut l'ajouter dans `frontend/i18n/locales/fr.json` plus tard. Pour l'instant le toast par défaut suffit. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/pages/entry-exit/entry/[id].vue +git commit -m "feat(front): logique 'Ajouter' un bovin sur écran de saisie" +``` + +--- + +## Task 10 : Frontend — Tableau récap + suppression + +**Files:** +- Modify: `frontend/pages/entry-exit/entry/[id].vue` + +- [ ] **Step 1: Remplacer le placeholder par un `UiDataTable`** + +Remplacer : + +```html + +
Tableau récap à venir
+``` + +par : + +```html + + + + + + + + + + +``` + +- [ ] **Step 2: Définir les colonnes et helpers** + +Dans le `