Files
Ferme/docs/superpowers/plans/2026-04-29-bovine-entry-exit.md
2026-04-29 08:58:15 +02:00

41 KiB

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 setBreedCodesetBovineType 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

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

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
make migration-migrate

Expected: Migration <TIMESTAMP> migrated, took .... Pas d'erreur.

  • Step 4: Vérifier le schéma
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
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 :

#[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) :

#[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 :

#[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 :

#[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.) :

/**
 * @var Collection<int, Bovine>
 */
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
private Collection $bovines;

Initialiser dans le constructeur :

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 :

#[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
make shell
php bin/console cache:clear
exit

Expected: pas d'erreur.

  • Step 7: Commit
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 :

#[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) :

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 :

#[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 :

security: "is_granted('ROLE_ADMIN')",

par :

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 :

new Delete(
    requirements: ['id' => '\d+'],
    security: "is_granted('ROLE_USER')",
),

Ajouter l'import en haut :

use ApiPlatform\Metadata\Delete;
  • Step 5: Cache clear + smoke test API
make cache-clear

Vérifier que /api/docs répond et que Bovine a bien une opération DELETE :

curl -s http://localhost:8080/api/docs.json | jq '.paths."/bovines/{id}".delete'

Expected : non null (présence du DELETE).

  • Step 6: Commit
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

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
make cache-clear
  • Step 3: Lancer les tests
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) :

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
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 :

entryCompleted?: boolean
registeredBovineCount?: number

(à insérer juste après isValid: boolean).

Et dans ReceptionPayload, ajouter :

entryCompleted?: boolean
  • Step 2: BovineData — ajouter reception

Dans frontend/services/dto/bovine-data.ts, dans l'interface BovineData, ajouter :

reception?: string | null

Et dans BovinePayload, ajouter :

reception?: string | null
  • Step 3: Vérifier la compilation TS du front
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
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 :

<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />

Remplacer par :

<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 :

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
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

<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

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
<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

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 :

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 :

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
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 :

<!-- Tableau récap (Task 10) -->
<div class="text-slate-400 italic">Tableau récap à venir</div>

par :

<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 :

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

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 :

<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 :

<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 :

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
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

make test

Expected : tous les tests passent.

  • Step 2: Re-run du PHP CS Fixer sur les fichiers modifiés
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
git add <fichier de version>
git commit -m "chore: bump version to v0.0.94"
  • Step 6: Push et PR
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).