1341 lines
41 KiB
Markdown
1341 lines
41 KiB
Markdown
# 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<TIMESTAMP>.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<TIMESTAMP>.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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
final class Version<TIMESTAMP> 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 <TIMESTAMP> 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<TIMESTAMP>.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<int, Bovine>
|
|
*/
|
|
#[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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State\Bovin;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\Entity\Bovine;
|
|
use App\Entity\BovineType;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Throwable;
|
|
|
|
final class BovineProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private readonly BovinApiInterface $bovinApi,
|
|
private readonly EntityManagerInterface $em,
|
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
private readonly ProcessorInterface $persistProcessor,
|
|
) {}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
{
|
|
if ($data instanceof Bovine && '' !== $data->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":"<admin>","password":"<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<numéro réel pré-prod>"}'
|
|
```
|
|
|
|
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
|
|
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
|
```
|
|
|
|
Remplacer par :
|
|
|
|
```html
|
|
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-bold">
|
|
<template #label>
|
|
Entrée<br>Sortie
|
|
</template>
|
|
</card-link>
|
|
```
|
|
|
|
> 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
|
|
<template>
|
|
<div class="px-[86px]">
|
|
<div class="flex items-center justify-start gap-10 relative">
|
|
<Icon
|
|
@click="router.push('/')"
|
|
name="gg:arrow-left-o"
|
|
size="44"
|
|
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
/>
|
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
|
</div>
|
|
|
|
<section class="mt-8">
|
|
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
|
<UiDataTable
|
|
v-model:page="entryPage"
|
|
v-model:per-page="entryPerPage"
|
|
:columns="entryColumns"
|
|
:items="entries"
|
|
:total-items="totalEntries"
|
|
:loading="entriesLoading"
|
|
row-clickable
|
|
@row-click="goToEntry"
|
|
>
|
|
<template #cell-receptionDate="{ item }">
|
|
{{ formatDate(item.receptionDate) }}
|
|
</template>
|
|
<template #cell-declaredCount="{ item }">
|
|
{{ declaredCount(item) }}
|
|
</template>
|
|
<template #cell-registeredBovineCount="{ item }">
|
|
{{ item.registeredBovineCount ?? 0 }}
|
|
</template>
|
|
</UiDataTable>
|
|
</section>
|
|
|
|
<section class="mt-12 mb-16">
|
|
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
|
|
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
|
À venir
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
|
|
|
const router = useRouter()
|
|
|
|
const {
|
|
items: entries,
|
|
totalItems: totalEntries,
|
|
page: entryPage,
|
|
perPage: entryPerPage,
|
|
loading: entriesLoading,
|
|
reload
|
|
} = useDataTableServerState<ReceptionData>(
|
|
'receptions',
|
|
{
|
|
'isValid': 'true',
|
|
'entryCompleted': 'false',
|
|
'receptionType.code': 'BOVINS'
|
|
},
|
|
{ initialPerPage: 10 }
|
|
)
|
|
|
|
const entryColumns = [
|
|
{ key: 'receptionDate', label: 'Date réception', width: '160px' },
|
|
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
|
{ key: 'declaredCount', label: 'Bovins déclarés', width: '140px' },
|
|
{ key: 'registeredBovineCount', label: 'Bovins saisis', width: '140px' }
|
|
]
|
|
|
|
const declaredCount = (reception: ReceptionData): number => {
|
|
const fromTypes = (reception.bovinesTypes ?? []).reduce((sum: number, bt: any) => {
|
|
return sum + (typeof bt.quantity === 'number' ? bt.quantity : 0)
|
|
}, 0)
|
|
const fromOther = parseInt(reception.bovineDetail ?? '0', 10) || 0
|
|
return fromTypes + fromOther
|
|
}
|
|
|
|
const formatDate = (date: string | null) => {
|
|
if (!date) return '—'
|
|
const d = new Date(date.replace(' ', 'T'))
|
|
if (isNaN(d.getTime())) return date
|
|
return d.toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
const goToEntry = (reception: ReceptionData) => {
|
|
router.push(`/entry-exit/entry/${reception.id}`)
|
|
}
|
|
|
|
onMounted(() => {
|
|
reload()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
> 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
|
|
<template>
|
|
<div class="px-[86px]">
|
|
<div class="flex items-center justify-start gap-6 relative mb-8">
|
|
<Icon
|
|
@click="router.push('/entry-exit')"
|
|
name="gg:arrow-left-o"
|
|
size="44"
|
|
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
/>
|
|
<div>
|
|
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
|
Entrée bovins {{ reception?.identificationNumber ?? `#${receptionId}` }}
|
|
</h1>
|
|
<p class="text-sm text-slate-600 mt-1">
|
|
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovines.length }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
class="grid grid-cols-4 gap-4 mb-6"
|
|
@submit.prevent="addBovine"
|
|
>
|
|
<UiTextInput
|
|
v-model="form.nationalNumber"
|
|
label="Numéro national"
|
|
required
|
|
/>
|
|
<UiNumberInput
|
|
v-model="form.receivedWeight"
|
|
label="Poids à l'arrivée (kg)"
|
|
:min="1"
|
|
required
|
|
/>
|
|
<UiDateMaskedInput
|
|
v-model="form.arrivalDate"
|
|
label="Date d'arrivée"
|
|
required
|
|
/>
|
|
<UiSelect
|
|
v-model="form.supplierId"
|
|
label="Vendeur"
|
|
:options="supplierOptions"
|
|
required
|
|
/>
|
|
<UiNumberInput
|
|
v-model="form.pricePerKg"
|
|
label="Prix au kilo (€)"
|
|
:min="0"
|
|
:step="0.01"
|
|
required
|
|
/>
|
|
<UiSelect
|
|
v-model="form.buildingId"
|
|
label="Bâtiment"
|
|
:options="buildingOptions"
|
|
required
|
|
/>
|
|
<UiSelect
|
|
v-model="form.caseId"
|
|
label="Case"
|
|
:options="caseOptions"
|
|
:disabled="!form.buildingId"
|
|
required
|
|
/>
|
|
<UiButton
|
|
type="submit"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] self-end"
|
|
:disabled="!isFormValid || isAdding"
|
|
:loading="isAdding"
|
|
>
|
|
Ajouter
|
|
</UiButton>
|
|
</form>
|
|
|
|
<!-- Tableau récap (Task 10) -->
|
|
<div class="text-slate-400 italic">Tableau récap à venir</div>
|
|
|
|
<!-- Bouton Valider (Task 11) -->
|
|
<div class="flex justify-end mt-8 mb-16">
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
disabled
|
|
>
|
|
Valider l'entrée
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
import type { BovineData } from '~/services/dto/bovine-data'
|
|
import type { SupplierData } from '~/services/dto/supplier-data'
|
|
import type { BuildingData } from '~/services/dto/building-data'
|
|
import { getSupplierList } from '~/services/supplier'
|
|
import { getBuildingList } from '~/services/building'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const api = useApi()
|
|
|
|
const receptionId = computed(() => Number(route.params.id))
|
|
|
|
const reception = ref<ReceptionData | null>(null)
|
|
const suppliers = ref<SupplierData[]>([])
|
|
const buildings = ref<BuildingData[]>([])
|
|
const savedBovines = ref<BovineData[]>([])
|
|
|
|
const isAdding = ref(false)
|
|
|
|
interface FormState {
|
|
nationalNumber: string
|
|
receivedWeight: number | null
|
|
arrivalDate: string
|
|
supplierId: string | number | null
|
|
pricePerKg: number | null
|
|
buildingId: string | number | null
|
|
caseId: string | number | null
|
|
}
|
|
|
|
const initialForm = (): FormState => ({
|
|
nationalNumber: '',
|
|
receivedWeight: null,
|
|
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
|
supplierId: reception.value?.supplier?.id ?? null,
|
|
pricePerKg: null,
|
|
buildingId: reception.value?.buildings?.[0]?.id ?? null,
|
|
caseId: null
|
|
})
|
|
|
|
const form = reactive<FormState>(initialForm())
|
|
|
|
const supplierOptions = computed(() =>
|
|
suppliers.value.map(s => ({ value: s.id, label: s.name }))
|
|
)
|
|
|
|
const buildingOptions = computed(() =>
|
|
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
|
)
|
|
|
|
const caseOptions = computed(() => {
|
|
const building = buildings.value.find(b => b.id === Number(form.buildingId))
|
|
if (!building?.buildingCases) return []
|
|
return [...building.buildingCases]
|
|
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
|
.map(c => ({
|
|
value: c.id,
|
|
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
|
}))
|
|
})
|
|
|
|
watch(() => form.buildingId, (newVal, oldVal) => {
|
|
if (newVal !== oldVal) form.caseId = null
|
|
})
|
|
|
|
const declaredCount = computed(() => {
|
|
if (!reception.value) return 0
|
|
const fromTypes = (reception.value.bovinesTypes ?? []).reduce((sum: number, bt: any) => {
|
|
return sum + (typeof bt.quantity === 'number' ? bt.quantity : 0)
|
|
}, 0)
|
|
const fromOther = parseInt(reception.value.bovineDetail ?? '0', 10) || 0
|
|
return fromTypes + fromOther
|
|
})
|
|
|
|
const isFormValid = computed(() =>
|
|
form.nationalNumber.trim() !== ''
|
|
&& (form.receivedWeight ?? 0) > 0
|
|
&& (form.pricePerKg ?? 0) > 0
|
|
&& form.arrivalDate !== ''
|
|
&& form.supplierId !== null
|
|
&& form.buildingId !== null
|
|
&& form.caseId !== null
|
|
)
|
|
|
|
const resetForm = () => {
|
|
Object.assign(form, initialForm())
|
|
}
|
|
|
|
const loadReception = async () => {
|
|
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
|
|
resetForm()
|
|
}
|
|
|
|
const addBovine = async () => {
|
|
// implémenté en Task 9
|
|
}
|
|
|
|
onMounted(async () => {
|
|
[suppliers.value, buildings.value] = await Promise.all([
|
|
getSupplierList(),
|
|
getBuildingList()
|
|
])
|
|
await loadReception()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
> 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<BovineData>('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<HTMLInputElement>('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 (Task 10) -->
|
|
<div class="text-slate-400 italic">Tableau récap à venir</div>
|
|
```
|
|
|
|
par :
|
|
|
|
```html
|
|
<UiDataTable
|
|
:columns="recapColumns"
|
|
:items="savedBovines"
|
|
:total-items="savedBovines.length"
|
|
:show-actions="true"
|
|
:hide-pagination="true"
|
|
>
|
|
<template #cell-birthDate="{ item }">
|
|
{{ formatDate(item.birthDate) }}
|
|
</template>
|
|
<template #cell-arrivalDate="{ item }">
|
|
{{ formatDate(item.arrivalDate) }}
|
|
</template>
|
|
<template #cell-finalPrice="{ item }">
|
|
{{ formatPrice(item.finalPrice) }}
|
|
</template>
|
|
<template #cell-pricePerKg="{ item }">
|
|
{{ formatPrice(item.pricePerKg) }}
|
|
</template>
|
|
<template #cell-buildingCase.building.label="{ item }">
|
|
{{ item.effectiveBuilding?.label ?? '—' }}
|
|
</template>
|
|
<template #cell-buildingCase.caseNumber="{ item }">
|
|
{{ item.buildingCase?.caseNumber ?? '—' }}
|
|
</template>
|
|
<template #cell-bovineType.label="{ item }">
|
|
{{ item.bovineType?.label ?? '—' }}
|
|
</template>
|
|
<template #actions="{ item }">
|
|
<Icon
|
|
name="mdi:delete-outline"
|
|
size="24"
|
|
class="cursor-pointer text-red-500 hover:text-red-700"
|
|
@click="confirmDeleteBovine(item)"
|
|
/>
|
|
</template>
|
|
</UiDataTable>
|
|
```
|
|
|
|
- [ ] **Step 2: Définir les colonnes et helpers**
|
|
|
|
Dans le `<script setup>`, ajouter :
|
|
|
|
```ts
|
|
const recapColumns = [
|
|
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
|
{ key: 'workNumber', label: 'N° Travail', width: '90px' },
|
|
{ key: 'bovineType.label', label: 'Race', width: '110px' },
|
|
{ key: 'sex', label: 'Sexe', width: '60px' },
|
|
{ key: 'birthDate', label: 'Né le', width: '90px' },
|
|
{ key: 'receivedWeight', label: 'Poids', width: '70px' },
|
|
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
|
|
{ key: 'pricePerKg', label: 'Prix/kg', width: '80px' },
|
|
{ key: 'finalPrice', label: 'Prix total', width: '90px' },
|
|
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
|
{ key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }
|
|
]
|
|
|
|
const formatDate = (date: string | null | undefined) => {
|
|
if (!date) return '—'
|
|
const d = new Date(date.replace(' ', 'T'))
|
|
if (isNaN(d.getTime())) return date
|
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
const formatPrice = (price: number | null | undefined) => {
|
|
if (price === null || price === undefined) return '—'
|
|
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
|
}
|
|
|
|
const confirmDeleteBovine = async (bovine: BovineData) => {
|
|
const confirmed = window.confirm(
|
|
`Supprimer le bovin ${bovine.nationalNumber} ?`
|
|
)
|
|
if (!confirmed) return
|
|
|
|
await api.delete(`bovines/${bovine.id}`)
|
|
await loadSavedBovines()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke test**
|
|
|
|
- Ajouter quelques bovins → ils apparaissent dans le tableau avec race, sexe, naissance auto-fill (si EDNOTIF accessible).
|
|
- Click sur la poubelle d'une ligne → confirm → bovin disparaît, compteur décrémente.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/entry-exit/entry/[id].vue
|
|
git commit -m "feat(front): tableau récap des bovins saisis avec suppression"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 : Frontend — Bouton "Valider l'entrée"
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/entry-exit/entry/[id].vue`
|
|
|
|
- [ ] **Step 1: Activer le bouton et brancher la logique**
|
|
|
|
Remplacer :
|
|
|
|
```html
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
disabled
|
|
>
|
|
Valider l'entrée
|
|
</UiButton>
|
|
```
|
|
|
|
par :
|
|
|
|
```html
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
:disabled="savedBovines.length === 0 || isValidating"
|
|
:loading="isValidating"
|
|
@click="validateEntry"
|
|
>
|
|
Valider l'entrée
|
|
</UiButton>
|
|
```
|
|
|
|
- [ ] **Step 2: Implémenter `validateEntry`**
|
|
|
|
Dans le `<script setup>`, ajouter :
|
|
|
|
```ts
|
|
const isValidating = ref(false)
|
|
|
|
const validateEntry = async () => {
|
|
if (savedBovines.value.length === 0 || isValidating.value) return
|
|
|
|
if (savedBovines.value.length < declaredCount.value) {
|
|
const confirmed = window.confirm(
|
|
`Vous n'avez saisi que ${savedBovines.value.length}/${declaredCount.value} bovins. Confirmer la fermeture de l'entrée ?`
|
|
)
|
|
if (!confirmed) return
|
|
}
|
|
|
|
isValidating.value = true
|
|
try {
|
|
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
|
|
router.push('/entry-exit')
|
|
} finally {
|
|
isValidating.value = false
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke test complet**
|
|
|
|
Scénario "happy path" :
|
|
1. Aller sur `/entry-exit`.
|
|
2. Si la liste est vide, créer une réception bovin (via le workflow existant) avec ex. 2 charolais et la valider (`isValid=true`). Elle apparaît alors dans la liste.
|
|
3. Click sur la réception → écran de saisie.
|
|
4. Saisir 2 bovins (n° national + champs requis), click "Ajouter" pour chacun.
|
|
5. Click "Valider l'entrée".
|
|
6. Expected : redirect vers `/entry-exit`, la réception a disparu de la liste (filtrée par `entryCompleted=false`).
|
|
|
|
Scénario "fermeture incomplète" :
|
|
1. Reprendre une nouvelle réception déclarée avec 5 bovins.
|
|
2. Saisir 2 bovins puis cliquer "Valider".
|
|
3. Expected : `confirm()` affiche "2/5 bovins. Confirmer ?".
|
|
4. Annuler → reste sur l'écran. Confirmer → redirect.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/entry-exit/entry/[id].vue
|
|
git commit -m "feat(front): bouton 'Valider l'entrée' avec confirmation si saisies incomplètes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12 : QA finale + bump de version
|
|
|
|
**Files:**
|
|
- Modify: `frontend/composables/useAppVersion.ts` (ou le fichier de version, à confirmer)
|
|
|
|
- [ ] **Step 1: Re-run de la suite de tests backend**
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
Expected : tous les tests passent.
|
|
|
|
- [ ] **Step 2: Re-run du PHP CS Fixer sur les fichiers modifiés**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky FILES="src/Entity/Reception.php src/Entity/Bovine.php src/State/Bovin/BovineProcessor.php"
|
|
```
|
|
|
|
- [ ] **Step 3: Vérifier la liste vide**
|
|
|
|
Aller sur `/entry-exit`. Si toutes les entrées ont été validées pendant les smoke tests, la liste est vide → message "Aucune donnée" du `UiDataTable`.
|
|
|
|
Vérifier que la section "Sorties en attente" affiche le placeholder "À venir".
|
|
|
|
- [ ] **Step 4: Bump de version**
|
|
|
|
Identifier le fichier de version (probablement `frontend/composables/useAppVersion.ts` ou `composer.json`/`package.json` selon le pattern projet) et incrémenter la version mineure.
|
|
|
|
> Selon les commits récents (`chore: bump version to v0.0.93`), le pattern est de bumper après chaque feature. Bumper à `v0.0.94`.
|
|
|
|
- [ ] **Step 5: Commit du bump**
|
|
|
|
```bash
|
|
git add <fichier de version>
|
|
git commit -m "chore: bump version to v0.0.94"
|
|
```
|
|
|
|
- [ ] **Step 6: Push et PR**
|
|
|
|
```bash
|
|
git push -u origin feat/entree-sortie
|
|
gh pr create --title "feat: workflow entrée bovins" --body "$(cat <<'EOF'
|
|
## Summary
|
|
|
|
- Workflow d'entrée bovins : transformation d'une réception bovins finie en saisies individuelles enrichies via EDNOTIF.
|
|
- Card "CASES" remplacée par "Entrée / Sortie" sur la home, pointant vers `/entry-exit`.
|
|
- Page liste avec entrées en attente + placeholder sorties (à venir).
|
|
- Écran de saisie : 1 formulaire (2 lignes) + tableau récap, "Ajouter" persiste un bovin lié à la réception, "Valider" ferme l'entrée.
|
|
- Modèle : `Reception.entryCompleted` + `Bovine.reception` (FK 1-N nullable).
|
|
- Fix au passage : `BovineProcessor` utilisait `setBreedCode` obsolète, corrigé pour `setBovineType` avec auto-create.
|
|
- Sécurité abaissée à `ROLE_USER` sur Bovine Post/Patch/Delete (flux métier opérationnel).
|
|
|
|
## Test plan
|
|
|
|
- [ ] Migration `up` puis `down` sur une bdd de test
|
|
- [ ] Tests PHPUnit verts (`make test`)
|
|
- [ ] Smoke test happy path (saisie 2 bovins + validation)
|
|
- [ ] Smoke test fermeture incomplète (confirmation)
|
|
- [ ] Suppression d'un bovin depuis le tableau récap
|
|
- [ ] Bovins existants (sans `reception_id`) toujours visibles dans `/inventory`
|
|
|
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
### Couverture spec → tasks
|
|
|
|
| Section spec | Task |
|
|
| --- | --- |
|
|
| `Reception.entryCompleted` | T1 (migration) + T2 (entité) |
|
|
| `Bovine.reception` FK | T1 (migration) + T3 (entité) |
|
|
| Relation inverse + `getRegisteredBovineCount()` | T2 |
|
|
| BooleanFilter + SearchFilter | T2 + T3 |
|
|
| Endpoints (réutilisation existants) | Aucun fichier — implicite via T2/T3 |
|
|
| Fix `BovineProcessor` | T4 |
|
|
| Sécurité ROLE_USER | T3 |
|
|
| Home — renommer card | T6 |
|
|
| Page liste `/entry-exit` | T7 |
|
|
| Écran de saisie — header + form | T8 |
|
|
| Logique Ajouter | T9 |
|
|
| Tableau récap + delete | T10 |
|
|
| Bouton Valider | T11 |
|
|
| QA finale | T12 |
|
|
|
|
Toutes les sections couvertes.
|
|
|
|
### Placeholders
|
|
|
|
Aucun "TBD"/"TODO" dans les steps. Les `<TIMESTAMP>` dans le nom de migration sont remplacés par doctrine au step 1 de Task 1.
|
|
|
|
### Type consistency
|
|
|
|
- `entryCompleted` : `bool` côté Symfony, `boolean` côté TS — cohérent.
|
|
- `Bovine.reception` : `?Reception` côté Symfony (nullable), `string | null` (IRI) côté TS dans `BovinePayload` — cohérent (les IRI sont sérialisés en JSON-LD).
|
|
- `getRegisteredBovineCount()` : retourne `int`, exposé en `registeredBovineCount?: number` côté TS — cohérent.
|
|
- Helpers `formatDate` / `formatPrice` : signatures identiques entre les pages liste et saisie — DRY (pourrait être extrait, mais YAGNI ici).
|
|
|
|
### Notes pour l'implémenteur
|
|
|
|
- Avant chaque task, **présenter à l'utilisateur ce qui va être fait** et attendre validation explicite (cf. memory `feedback_step_by_step_validation`).
|
|
- Le projet n'a quasiment pas de tests frontend — la validation passe par smoke test navigateur. Les tests PHPUnit existants doivent rester verts (`make test`).
|
|
- Le pre-commit hook lance php-cs-fixer + phpunit automatiquement (cf. logs des commits précédents).
|