Files
Ferme/docs/superpowers/plans/2026-05-04-bovine-info-saisie.md
2026-05-04 11:29:13 +02:00

20 KiB

Saisie information bovin (post-EDNOTIF) — 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: Ajouter un écran de saisie post-EDNOTIF (poids, prix/kg, bâtiment, case) accessible depuis le tableau "Entrées validées", structuré en accordéons-par-bovin.

Architecture: Un nouveau composant UiAccordion réutilisable. Une nouvelle page Nuxt entry-exit/bovine-info/[id].vue qui charge la réception et ses bovins, instancie un accordéon par bovin et délègue la saisie à un sous-composant bovine-info-form.vue. Pas de nouvel endpoint, pas de migration : on PATCH les Bovine existants (receivedWeight, pricePerKg, buildingCase). Mini ajustement backend : exposer les ids de BuildingCase et Building dans le groupe de sérialisation bovine:read, sinon on n'a pas de quoi pré-remplir les selectors.

Tech Stack: Symfony 8 + API Platform 4 (annotations Groups) ; Nuxt 4 + Vue 3 + Tailwind ; pas de tests automatisés (cohérent avec le reste de la feature entry-exit, cf. spec).

Spec source: docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md

Branche de travail: feat/entree-sortie (déjà créée).


Synthèse du file-mapping

Fichier Type Responsabilité
src/Entity/BuildingCase.php Modify Ajouter bovine:read au groupe de id
src/Entity/Building.php Modify Ajouter bovine:read au groupe de id
frontend/services/dto/bovine-data.ts Modify Ajouter id à BovineBuildingRef et BovineBuildingCaseRef
frontend/components/ui/UiAccordion.vue Create Composant réutilisable, header en slot, body en slot, v-model boolean
frontend/components/entry-exit/bovine-info-form.vue Create Sous-composant : 4 champs + bouton Valider, émet saved
frontend/pages/entry-exit/bovine-info/[id].vue Create Page : header, fetch, tri, état d'ouverture, rendu liste d'accordéons
frontend/pages/entry-exit/index.vue Modify Ajouter row-clickable + @row-click au tableau "Entrées validées"

Task 1 : Exposer les ids BuildingCase et Building dans bovine:read

Contexte : Quand l'API normalise un Bovine avec le groupe bovine:read, l'embedded buildingCase ne contient que caseNumber et building.label. Pas d'ids → pas de pré-remplissage possible. On ajoute le groupe bovine:read aux deux propriétés id concernées (zéro changement de schéma, juste un attribut PHP).

Files:

  • Modify: src/Entity/BuildingCase.php:42

  • Modify: src/Entity/Building.php:36

  • Step 1 : Patch BuildingCase.id

// src/Entity/BuildingCase.php — remplacer
#[Groups(['building:read', 'building_case:read'])]
private ?int $id = null;

// par
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
private ?int $id = null;
  • Step 2 : Patch Building.id
// src/Entity/Building.php — remplacer
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null;

// par
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private ?int $id = null;
  • Step 3 : Vider le cache (les groupes sont compilés)
make cache-clear
  • Step 4 : Vérifier que les tests existants passent
make test

Attendu : 9/9 tests OK (aucun changement de comportement, juste une exposition supplémentaire).

  • Step 5 : Vérification manuelle rapide
curl -s -H "Authorization: Bearer $TOKEN" \
  'http://localhost:8080/api/bovines/1' | jq '.buildingCase'

Attendu : la réponse contient id (numérique) en plus de caseNumber, et buildingCase.building contient id en plus de label. Si le bovin n'a pas de buildingCase, ce sera null — prendre un id de bovin qui en a un (sinon ignorer cette étape).

  • Step 6 : Commit
git add src/Entity/BuildingCase.php src/Entity/Building.php
git commit -m "feat(api) : exposer BuildingCase.id et Building.id dans bovine:read"

Task 2 : Compléter le DTO frontend BovineData

Files:

  • Modify: frontend/services/dto/bovine-data.ts

  • Step 1 : Ajouter id aux deux interfaces de référence

Remplacer le bloc en haut du fichier :

export interface BovineBuildingRef {
    id: number
    label: string
}

export interface BovineBuildingCaseRef {
    id: number
    caseNumber: number | null
    building: BovineBuildingRef | null
}
  • Step 2 : Vérifier que TypeScript ne casse pas
cd frontend && npx vue-tsc --noEmit 2>&1 | head -40

Attendu : pas d'erreur (les autres pages qui consomment BovineData ne lisaient pas l'id depuis ces sous-objets ; ajouter un champ ne casse rien).

Si erreurs inattendues, les corriger en touchant seulement les call-sites pointés par tsc.

  • Step 3 : Commit
git add frontend/services/dto/bovine-data.ts
git commit -m "feat(front) : id dans BovineBuildingRef et BovineBuildingCaseRef"

Task 3 : Créer UiAccordion

Files:

  • Create: frontend/components/ui/UiAccordion.vue

  • Step 1 : Écrire le composant

<template>
    <div class="overflow-hidden">
        <button
            type="button"
            class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
            @click="toggle"
        >
            <span class="flex-1">
                <slot name="header" />
            </span>
            <Icon
                name="mdi:chevron-down"
                size="24"
                class="shrink-0 transition-transform"
                :class="{ 'rotate-180': modelValue }"
            />
        </button>
        <div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
            <slot />
        </div>
    </div>
</template>

<script setup lang="ts">
const props = defineProps<{
    modelValue: boolean
}>()

const emit = defineEmits<{
    'update:modelValue': [value: boolean]
}>()

const toggle = () => emit('update:modelValue', !props.modelValue)
</script>
  • Step 2 : Vérifier l'auto-import

Nuxt auto-importe les composants de components/ui/ avec le préfixe Ui (cf. CLAUDE.md). Donc <UiAccordion /> sera utilisable sans import explicite. Pas d'action ici, juste validation mentale.

  • Step 3 : Commit
git add frontend/components/ui/UiAccordion.vue
git commit -m "feat(front) : composant UiAccordion réutilisable"

Task 4 : Créer bovine-info-form.vue (sous-composant)

Contexte : Encapsule l'état local et le formulaire d'un bovin. Reçoit le bovin et la liste de bâtiments, émet saved avec le bovin mis à jour. Permet à la page parent de rester lisible.

Files:

  • Create: frontend/components/entry-exit/bovine-info-form.vue

  • Step 1 : Écrire le composant

<template>
    <form class="space-y-6" :class="{ submitted }" @submit.prevent="submit">
        <div class="grid grid-cols-2 gap-x-12 gap-y-6">
            <UiNumberInput
                v-model="form.receivedWeight"
                label="Poids d'arrivée (kg)"
                :min="0"
                :step="1"
                required
            />
            <UiNumberInput
                v-model="form.pricePerKg"
                label="Prix d'achat (kg)"
                :min="0"
                :step="0.01"
                required
            />
            <UiSelect
                v-model="form.buildingId"
                label="Bâtiment"
                :options="buildingOptions"
                required
            />
            <UiSelect
                v-model="form.buildingCaseId"
                label="Case"
                :options="caseOptions"
                :disabled="form.buildingId === null"
                required
            />
        </div>

        <div class="flex justify-center">
            <UiButton
                type="submit"
                class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
                :disabled="isSaving"
                :loading="isSaving"
            >
                Valider
            </UiButton>
        </div>
    </form>
</template>

<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'

const props = defineProps<{
    bovine: BovineData
    buildings: BuildingData[]
}>()

const emit = defineEmits<{
    saved: [bovine: BovineData]
}>()

const api = useApi()

interface FormState {
    receivedWeight: number | null
    pricePerKg: number | null
    buildingId: number | null
    buildingCaseId: number | null
}

const form = reactive<FormState>({
    receivedWeight: props.bovine.receivedWeight,
    pricePerKg: props.bovine.pricePerKg,
    buildingId: props.bovine.buildingCase?.building?.id
        ?? props.bovine.effectiveBuilding?.id
        ?? null,
    buildingCaseId: props.bovine.buildingCase?.id ?? null
})

const submitted = ref(false)
const isSaving = ref(false)

const buildingOptions = computed(() =>
    props.buildings.map(b => ({ value: b.id, label: b.label }))
)

const caseOptions = computed(() => {
    if (form.buildingId === null) return []
    const building = props.buildings.find(b => b.id === form.buildingId)
    if (!building?.buildingCases) return []
    return building.buildingCases.map(c => ({
        value: c.id,
        label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
    }))
})

watch(() => form.buildingId, (newId) => {
    if (form.buildingCaseId === null) return
    const building = props.buildings.find(b => b.id === newId)
    const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
    if (!caseStillValid) {
        form.buildingCaseId = null
    }
})

const submit = async () => {
    submitted.value = true
    if (
        form.receivedWeight === null
        || form.pricePerKg === null
        || form.buildingId === null
        || form.buildingCaseId === null
    ) {
        return
    }

    isSaving.value = true
    try {
        const updated = await api.patch<BovineData>(
            `bovines/${props.bovine.id}`,
            {
                receivedWeight: form.receivedWeight,
                pricePerKg: form.pricePerKg,
                buildingCase: `/api/building_cases/${form.buildingCaseId}`
            },
            { headers: { 'Content-Type': 'application/merge-patch+json' } }
        )
        emit('saved', updated)
    } finally {
        isSaving.value = false
    }
}
</script>

Note : on utilise application/merge-patch+json comme content-type côté API Platform pour les PATCH (la convention par défaut). useApi.patch a déjà ce content-type par défaut — la ligne headers est ici à supprimer si useApi.patch le pose déjà. Vérifier dans composables/useApi.ts à l'étape suivante.

  • Step 2 : Vérifier le content-type par défaut de useApi.patch
grep -n "patch" frontend/composables/useApi.ts | head -10
  • Si useApi.patch injecte déjà application/merge-patch+json, retirer le bloc headers du composant ci-dessus.

  • Sinon, le garder.

  • Step 3 : Commit

git add frontend/components/entry-exit/bovine-info-form.vue
git commit -m "feat(front) : sous-composant bovine-info-form (4 champs + valider)"

Task 5 : Créer la page bovine-info/[id].vue

Files:

  • Create: frontend/pages/entry-exit/bovine-info/[id].vue

  • Step 1 : Écrire la page

<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]"
            />
            <h1 class="font-bold text-3xl uppercase text-primary-500">
                Saisie information bovin {{ reception?.identificationNumber ?? '' }}
            </h1>
        </div>

        <div v-if="loading" class="text-center text-slate-500">Chargement</div>

        <div v-else class="space-y-3">
            <UiAccordion
                v-for="bovine in sortedBovines"
                :key="bovine.id"
                :model-value="openId === bovine.id"
                @update:model-value="onToggle(bovine.id, $event)"
            >
                <template #header>
                    <span class="flex items-center gap-3 normal-case">
                        <span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
                        <span
                            v-if="isSaisi(bovine)"
                            class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
                        >
                            Saisie
                        </span>
                        <span
                            v-else
                            class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
                        >
                            Attente saisie
                        </span>
                    </span>
                </template>
                <BovineInfoForm
                    :bovine="bovine"
                    :buildings="buildings"
                    @saved="onSaved"
                />
            </UiAccordion>
        </div>
    </div>
</template>

<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionData } from '~/services/dto/reception-data'
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 bovines = ref<BovineData[]>([])
const buildings = ref<BuildingData[]>([])
const loading = ref(true)
const openId = ref<number | null>(null)

useHead({
    title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
})

const isSaisi = (bovine: BovineData) =>
    bovine.receivedWeight !== null
    && bovine.pricePerKg !== null
    && bovine.buildingCase !== null

const sortedBovines = computed(() => {
    const pending = bovines.value.filter(b => !isSaisi(b))
    const done = bovines.value.filter(b => isSaisi(b))
    return [...pending, ...done]
})

const onToggle = (bovineId: number, value: boolean) => {
    openId.value = value ? bovineId : null
}

const onSaved = (updated: BovineData) => {
    const idx = bovines.value.findIndex(b => b.id === updated.id)
    if (idx === -1) return
    bovines.value[idx] = updated

    // Ouvrir le prochain non-saisi dans la nouvelle liste triée
    const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
    openId.value = next?.id ?? null
}

const loadBovines = async () => {
    type Hydra = { 'hydra:member'?: BovineData[] }
    const response = await api.get<BovineData[] | Hydra>(
        'bovines',
        { reception: receptionId.value, itemsPerPage: 200 }
    )
    if (Array.isArray(response)) {
        bovines.value = response
    } else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
        bovines.value = response['hydra:member']
    } else {
        bovines.value = []
    }
}

onMounted(async () => {
    try {
        const [r, , b] = await Promise.all([
            api.get<ReceptionData>(`receptions/${receptionId.value}`),
            loadBovines(),
            getBuildingList()
        ])
        reception.value = r
        buildings.value = b

        const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
        openId.value = firstPending?.id ?? null
    } finally {
        loading.value = false
    }
})
</script>

Note de style : BovineInfoForm est référencé sans import — Nuxt auto-importe les composants components/entry-exit/*.vue avec un PascalCase basé sur le nom de fichier (à confirmer ; sinon, ajouter import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue').

  • Step 2 : Vérifier l'auto-import
cd frontend && npm run dev

Aller sur http://localhost:3000/entry-exit/bovine-info/<id> (id d'une réception validée). Si erreur "BovineInfoForm is not defined", ajouter l'import explicite. Si rendu OK, continuer.

  • Step 3 : Commit
git add frontend/pages/entry-exit/bovine-info/'[id].vue'
git commit -m "feat(front) : page saisie information bovin (accordéons)"

Task 6 : Câbler la navigation depuis le tableau "Entrées validées"

Files:

  • Modify: frontend/pages/entry-exit/index.vue

  • Step 1 : Ajouter la fonction de navigation

Dans le <script setup>, sous le goToEntry existant, ajouter :

const goToBovineInfo = (reception: ReceptionData) => {
    router.push(`/entry-exit/bovine-info/${reception.id}`)
}
  • Step 2 : Activer le clic sur la table validée

Dans le <template>, sur le <UiDataTable> du bloc "Entrées validées" (celui avec v-model:page="validatedPage"), ajouter les deux props :

<UiDataTable
    v-model:page="validatedPage"
    v-model:per-page="validatedPerPage"
    :columns="validatedColumns"
    :items="validated"
    :total-items="totalValidated"
    :loading="validatedLoading"
    row-clickable
    @row-click="goToBovineInfo"
>
  • Step 3 : Smoke test manuel

Dev server toujours en marche. Sur /entry-exit, cliquer sur une ligne dans "Entrées validées" → la page /entry-exit/bovine-info/{id} s'ouvre, titre correct, premier accordéon non-saisi ouvert.

  • Step 4 : Commit
git add frontend/pages/entry-exit/index.vue
git commit -m "feat(front) : clic sur entrée validée → page saisie info bovin"

Task 7 : Vérification fonctionnelle complète

Pas de code. Juste un parcours manuel en mode admin.

  • Step 1 : Cas non-saisi → saisi

    • Aller sur /entry-exit, cliquer sur une entrée validée avec au moins un bovin non saisi.
    • Vérifier : premier non-saisi ouvert, badge jaune pour les non-saisis, badge vert pour les déjà-saisis.
    • Saisir les 4 champs, cliquer Valider.
    • Vérifier : accordéon se ferme, badge passe vert, l'accordéon suivant non-saisi s'ouvre.
  • Step 2 : Champs invalides

    • Ouvrir un accordéon vide, cliquer Valider sans rien remplir.
    • Vérifier : bordures rouges, pas de requête réseau (DevTools).
  • Step 3 : Bâtiment change → case reset

    • Choisir un bâtiment, choisir une case, changer de bâtiment.
    • Vérifier : la case repasse à vide.
  • Step 4 : Reload sur saisie partielle

    • Saisir les 4 champs d'un bovin, valider (badge vert).
    • Recharger la page (F5).
    • Vérifier : ce bovin a un badge vert au chargement, il est positionné en bas, fermé. Le premier non-saisi suivant est ouvert.
  • Step 5 : Tout saisi

    • Saisir tous les bovins.
    • Vérifier : tous fermés, tous verts, pas d'accordéon ouvert. Pas de toast / pas de redirection.
  • Step 6 : Bouton retour

    • Cliquer la flèche retour : retour à /entry-exit.

Si un point échoue, debug puis corriger. Une fois OK, le boulot est fini — pas de commit supplémentaire (chaque task a déjà été commitée).


Hors plan (à faire si bug remonté)

  • Pagination des bovins si une réception en a > 200 (pour l'instant itemsPerPage=200 couvre largement le besoin métier).
  • Animation d'ouverture/fermeture de l'accordéon (pour l'instant v-if brut, sans transition).
  • Tests unitaires Vitest (cohérent avec l'absence de tests frontend dans le repo).