Compare commits

..

5 Commits

Author SHA1 Message Date
fcb6f742af feat : ordre d'affichage configurable sur les bâtiments
- Colonne display_order ajoutée à Building avec migration
- Seed des valeurs par code : B3 -> 1, B2 -> 2, B1 -> 3, ZT -> 4
- ApiResource trie par displayOrder ASC puis id (NULLs en fin de liste)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:51:27 +02:00
aa401f48f9 refactor : remplacement du SQL brut par du DQL UPDATE dans les hooks PostPersist
- Reception et Shipment utilisent createQuery au lieu de executeStatement
- Bypass toujours l'unit of work, même comportement, mais via Doctrine
- Mise à jour de la règle CLAUDE.md : repository custom autorisé, SQL brut interdit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:10:37 +02:00
569d3b373f feat : refonte de l'affichage des âges et restriction des prix aux admins
- Repository BovineRepository avec getInventoryStats en DQL
- Sécurité ApiProperty ROLE_ADMIN sur pricePerKg et finalPrice
- Endpoint inventory-export passe en ROLE_ADMIN
- Composable useBovineColumns mutualisé entre inventory et case (admin/user séparés)
- Stats par tranche d'âge filtrables par buildingCaseId
- Légende avec cartes colorées pleines + texte blanc
- Coloration de la cellule Age (badge) au lieu de toute la ligne
- Décalage couleurs : red ≥ 24, orange 22-24, yellow 20-22

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:09:13 +02:00
b3b7746bc5 feat : commande de feed des prix bovins depuis un XLSX
- app:feed-bovine-prices <file> [--dry-run]
- Met à jour receivedWeight, pricePerKg et supplier sur les bovins existants (pas de création)
- Strip défensif du préfixe FR
- Supplier introuvable -> bovin updaté avec supplier=null + warning
- Préload des suppliers pour lookup O(1)
- Documentation ajoutée au README

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:16:31 +02:00
7bf6a36b73 feat : WIP prix au kilo et prix total sur les bovins
- Champ pricePerKg persisté sur Bovine + migration
- Getter calculé finalPrice = receivedWeight * pricePerKg
- Colonnes Prix/kg et Prix total sur inventory et case
- Ajustements de largeurs pour rentrer dans le layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:01:48 +02:00
74 changed files with 188 additions and 4672 deletions

View File

@@ -193,14 +193,12 @@ Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
| C | Poids à l'arrivée (kg) | Entier | `368` |
| D | Prix au kilo | Décimal | `5.7` |
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
### Comportement
- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle.
- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu.
- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
- La commande est **idempotente** : peut être relancée sans effet de bord.
@@ -218,30 +216,18 @@ docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bo
### Lancement en prod
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
```bash
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
scp feed_bovin.xlsx <user>@<host>:/tmp/
# 1. Envoyer le fichier sur le serveur
scp feed_bovin.xlsx ferme-prod:/tmp/
# 2. SSH sur le serveur
ssh <user>@<host>
# 3. Se placer dans le dossier de l'app (pour bin/console)
# 2. SSH sur le serveur et lancer la commande dans le dossier de l'app
ssh ferme-prod
cd /var/www/ferme
# 4. Dry-run pour vérifier sans rien écrire
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
# 5. Persistance effective
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
# 6. Cleanup
rm /tmp/feed_bovin.xlsx
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run # vérification
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx # exécution
rm /tmp/feed_bovin.xlsx # nettoyage
```
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
### Sortie attendue
À la fin, un tableau récapitule :
@@ -251,4 +237,3 @@ rm /tmp/feed_bovin.xlsx
- Bovins introuvables (avec aperçu des 10 premiers numéros)
- Lignes invalides (numéro national vide)
- Fournisseurs introuvables (avec liste et compte par nom)
- Bâtiments introuvables (avec liste des codes inconnus)

View File

@@ -1,11 +1,4 @@
security:
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
# front dans utils/roles.ts).
role_hierarchy:
ROLE_BUREAU: ROLE_USER
ROLE_ADMIN: ROLE_BUREAU
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
App\Entity\User: 'auto'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.94'
app.version: '0.0.89'

File diff suppressed because it is too large Load Diff

View File

@@ -1,598 +0,0 @@
# 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`**
```php
// 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`**
```php
// 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)**
```bash
make cache-clear
```
- [ ] **Step 4 : Vérifier que les tests existants passent**
```bash
make test
```
Attendu : 9/9 tests OK (aucun changement de comportement, juste une exposition supplémentaire).
- [ ] **Step 5 : Vérification manuelle rapide**
```bash
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**
```bash
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 :
```ts
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**
```bash
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**
```bash
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**
```vue
<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**
```bash
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**
```vue
<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`**
```bash
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**
```bash
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**
```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]"
/>
<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**
```bash
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**
```bash
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 :
```ts
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 :
```vue
<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**
```bash
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).

View File

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

View File

@@ -1,187 +0,0 @@
# Saisie information bovin (post-EDNOTIF)
## Contexte
Sur la page `/entry-exit`, le tableau "Entrées validées" liste les receptions
dont les bovins sont tous confirmés EDNOTIF (`reception.validatedAt` non null).
Une fois cette validation acquise, l'utilisateur doit encore renseigner pour
chaque bovin quatre informations métier qui ne viennent pas d'EDNOTIF :
- poids d'arrivée
- prix d'achat au kg
- bâtiment
- case
Cette spec décrit l'écran de saisie et le composant accordéon qu'il introduit.
## Périmètre
- Nouvelle page accessible uniquement via clic sur une ligne d'"Entrées
validées" — pas d'entrée dans la nav globale.
- Aucun changement d'entité Doctrine, aucune migration : les quatre champs
existent déjà sur `Bovine` (`receivedWeight`, `pricePerKg`, `buildingCase`).
- Le champ `building` (legacy XLSX) n'est pas écrit. Côté affichage,
`getEffectiveBuilding()` continue de dériver le bâtiment effectif depuis
`buildingCase`.
- `pricePerKg` reste protégé par `ROLE_BUREAU` côté API. La page exige
`ROLE_ADMIN` ; la hiérarchie Symfony fait que `ROLE_ADMIN` hérite
`ROLE_BUREAU`, donc pas de cas particulier.
- Pas de gestion de "session interrompue" : chaque accordéon validé
individuellement est persisté immédiatement.
## Routing & navigation
- Page : `frontend/pages/entry-exit/bovine-info/[id].vue``[id]` est le
`receptionId`.
- Sur `frontend/pages/entry-exit/index.vue`, le tableau "Entrées validées"
reçoit `row-clickable` et `@row-click="goToBovineInfo"`. Le handler pousse
vers `/entry-exit/bovine-info/{reception.id}`.
- Le bouton flèche-retour de la page renvoie vers `/entry-exit`.
## Composant `UiAccordion`
Fichier : `frontend/components/ui/UiAccordion.vue`. Réutilisable, sans logique
métier.
**Props**
- `modelValue: boolean` — état ouvert/fermé, supporte `v-model`.
**Slots**
- `#header` — contenu libre du header (badge, titre, etc., aligné à gauche).
- default — corps de l'accordéon, rendu uniquement quand ouvert.
**Comportement**
- Click sur le header → emit `update:modelValue` avec la valeur inversée.
- Header : `bg-slate-100`, padding identique aux headers `UiDataTable`
(`px-4 py-3`), texte semi-bold uppercase.
- Chevron à droite (`mdi:chevron-down`), rotation 180° quand ouvert,
transition CSS courte.
- Pas d'animation de hauteur au déploiement (pour rester simple) — on rend ou
pas via `v-if`.
## Page `bovine-info/[id].vue`
**Layout** — copie du pattern `entry-exit/entry/[id].vue` :
`<div class="px-[86px]">` + bandeau titre avec flèche retour absolue, titre
`<h1>Saisie information bovin {{ reception.identificationNumber }}</h1>`.
Pas de sous-titre.
**Chargement (`onMounted`)**
1. `GET receptions/{id}` → alimente le titre.
2. `GET bovines?reception={id}&itemsPerPage=200` (pas de pagination — on
suppose qu'une réception a au plus quelques dizaines de bovins).
3. `GET buildings` — la réponse contient `buildingCases` imbriqués
(`BuildingData.buildingCases`). On dérive de là à la fois la liste de
bâtiments (selector "Bâtiment") et l'index des cases par bâtiment.
**État local par bovin** (`Map<bovineId, FormState>`) :
```ts
type FormState = {
receivedWeight: number | null
pricePerKg: number | null
buildingId: number | null // UI-only, drive le filtre Case
buildingCaseId: number | null
submitted: boolean // pour les borders rouges au submit
isSaving: boolean
}
```
Initialisé depuis l'API : `receivedWeight`, `pricePerKg` directement,
`buildingCaseId = bovine.buildingCase?.id`,
`buildingId = bovine.effectiveBuilding?.id`.
**Source de vérité du badge** : `bovine.receivedWeight != null
&& bovine.pricePerKg != null && bovine.buildingCase != null` — donc calculé
sur les valeurs *persistées*, pas sur le `FormState` en cours d'édition. Vert
"Saisie" si les trois sont non-null (le bâtiment est dérivé de la case),
sinon jaune "Attente saisie".
> Note : on garde 4 champs côté UI mais 3 conditions backend, parce que
> `building` n'est pas persisté indépendamment.
**Tri** — non-saisis (badge jaune) en haut puis saisis (badge vert) en bas,
ordre d'API préservé à l'intérieur de chaque groupe. Le tri est calculé
- au chargement initial,
- après chaque PATCH OK (le bovin qui vient d'être saisi descend dans le
groupe vert).
Il ne se recompute pas pendant qu'un accordéon est ouvert et en cours
d'édition — sinon les bovins sauteraient de position au moindre changement
de l'état "saisi/non-saisi", ce qui ne se produit ici que sur un PATCH
réussi.
**Open state**`ref<number | null>` qui contient l'id du bovin
actuellement ouvert. Un seul accordéon ouvert à la fois.
- Initialisation : id du premier bovin non-saisi de la liste triée, ou
`null` si tout est déjà saisi.
- Click sur un header autre que celui ouvert → ferme l'ouvert, ouvre le
cliqué.
- Click sur le header ouvert → ferme (open = `null`).
- Validation OK d'un accordéon → ferme l'actuel, ouvre l'id du prochain
non-saisi de la liste (recalculée). Si plus de non-saisi → `null`.
## Formulaire par accordéon
Tous les champs `required`, validation au submit (pattern `submitted` flag +
`.submitted :invalid` du CSS global).
| Champ | Composant | Type / format |
| ------------------ | -------------------- | ------------------------ |
| Poids d'arrivée | `UiNumberInput` | entier kg |
| Prix d'achat (kg) | `UiNumberInput` | float, step 0.01 |
| Bâtiment | `UiSelect` | options = liste building |
| Case | `UiSelect` | options = cases du building sélectionné |
- Watch sur `buildingId` : si l'utilisateur change le bâtiment et que la case
actuellement sélectionnée n'appartient pas au nouveau, on remet
`buildingCaseId = null`.
- Bouton `Valider` centré, `bg-primary-500`, désactivé pendant `isSaving`.
**Soumission**
```
PATCH /bovines/{id}
{
receivedWeight,
pricePerKg,
buildingCase: `/api/building_cases/${buildingCaseId}`
}
Content-Type: application/ld+json
```
À la réponse OK, on remplace le bovin dans la liste locale par la version
retournée par l'API (qui contient `buildingCase` hydraté pour recomputer le
badge), puis on déclenche la transition d'état (resort + open suivant).
En cas d'erreur HTTP, le toast par défaut de `useApi` suffit ; on garde
l'accordéon ouvert et le `FormState` intact.
## Hors périmètre
- Pas de bulk-save (pas de "Tout valider").
- Pas de tracking "modifié non sauvé" / warning au unload — chaque accordéon
est validé explicitement, pas d'autosave.
- Pas de tests automatisés ajoutés dans ce lot (cohérent avec le reste de la
feature entry-exit).
- Pas d'exposition de cet écran ailleurs que via le tableau "Entrées
validées".
## Critères d'acceptation
- Cliquer sur une ligne du tableau "Entrées validées" ouvre la page
`/entry-exit/bovine-info/{id}`.
- La page liste tous les bovins de la réception, non-saisis en haut.
- Au chargement, un seul accordéon est ouvert : le premier non-saisi (ou
aucun si tout est déjà saisi).
- Cliquer sur un autre header ferme l'ouvert et ouvre le cliqué.
- Soumettre un accordéon avec un champ vide affiche les borders rouges
(`submitted` flag) et bloque la requête.
- Soumettre un accordéon valide PATCH le bovin et, après réponse OK, ferme
l'accordéon, met le badge en vert et ouvre le suivant non-saisi.
- Recharger la page après une saisie partielle réaffiche les valeurs
pré-remplies et le bon badge pour chaque bovin.
- `php-cs-fixer` et `make test` restent verts (pas de code backend modifié,
donc rien à régresser).

View File

@@ -1,127 +0,0 @@
<template>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid grid-cols-4 gap-x-12 gap-y-6">
<UiNumberInput
v-model="form.receivedWeight"
label="Poids d'arrivée (kg)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="1"
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix au kg"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="0.01"
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
/>
<UiSelect
v-model="form.buildingCaseId"
label="Case"
:options="caseOptions"
:disabled="form.buildingId === null"
/>
</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 ?? null,
pricePerKg: props.bovine.pricePerKg ?? null,
buildingId: props.bovine.buildingCase?.building?.id
?? props.bovine.effectiveBuilding?.id
?? null,
buildingCaseId: props.bovine.buildingCase?.id ?? null
})
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 () => {
const payload: Record<string, unknown> = {}
if (form.receivedWeight != null) payload.receivedWeight = form.receivedWeight
if (form.pricePerKg != null) payload.pricePerKg = form.pricePerKg
if (form.buildingCaseId != null) {
payload.buildingCase = `/api/building_cases/${form.buildingCaseId}`
}
if (Object.keys(payload).length === 0) {
emit('saved', props.bovine)
return
}
isSaving.value = true
try {
const updated = await api.patch<BovineData>(
`bovines/${props.bovine.id}`,
payload,
{ toastSuccessMessage: `Bovin ${props.bovine.nationalNumber} enregistré.` }
)
emit('saved', updated)
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -1,96 +0,0 @@
<template>
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
<p class="mb-5 text-sm text-slate-600">
Aucun filtre coché&nbsp;: export complet (tous les bovins actifs).
</p>
<div class="mb-5">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
Tranches d'âge
</h3>
<div class="flex flex-col gap-2">
<label
v-for="bucket in ageBuckets"
:key="bucket.value"
class="flex items-center gap-3 cursor-pointer text-primary-700"
>
<input
v-model="filters.ageRanges"
type="checkbox"
:value="bucket.value"
class="h-4 w-4 cursor-pointer accent-primary-500"
/>
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
{{ bucket.badge }}
</span>
<span>{{ bucket.label }}</span>
</label>
</div>
</div>
<template #footer>
<div class="flex justify-center">
<button
type="button"
:disabled="loading"
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
@click="onSubmit"
>
<Icon
v-if="loading"
name="mdi:loading"
size="20"
class="animate-spin"
/>
<Icon v-else name="mdi:file-excel-outline" size="20" />
Exporter
</button>
</div>
</template>
</UiModal>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
export interface InventoryExportFilters {
ageRanges: string[]
}
const props = withDefaults(defineProps<{
modelValue: boolean
loading?: boolean
}>(), {
loading: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit', filters: InventoryExportFilters): void
}>()
const open = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const ageBuckets = [
{ value: 'over24', label: ' 24 mois', badge: '24+', colorClass: 'bg-red-500' },
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
]
const filters = reactive<InventoryExportFilters>({
ageRanges: []
})
watch(open, (isOpen) => {
if (isOpen) {
filters.ageRanges = []
}
})
const onSubmit = () => {
emit('submit', { ageRanges: [...filters.ageRanges] })
}
</script>

View File

@@ -1,34 +0,0 @@
<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>

View File

@@ -153,7 +153,7 @@ const props = withDefaults(defineProps<{
totalItems: undefined,
page: 1,
perPage: 10,
perPageOptions: () => [5, 10, 25, 50],
perPageOptions: () => [10, 25, 50],
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',

View File

@@ -1,96 +0,0 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
@mousedown.self="closeOnBackdrop"
>
<div
class="w-full rounded-md bg-white shadow-2xl"
:class="maxWidth"
@mousedown.stop
>
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
<button
type="button"
class="text-slate-500 hover:text-primary-500 flex items-center"
aria-label="Fermer"
@click="close"
>
<Icon name="mdi:close" size="24" />
</button>
</div>
<div class="px-6 py-5">
<slot />
</div>
<div
v-if="$slots.footer"
class="border-t border-slate-200 px-6 py-4"
>
<slot name="footer" :close="close" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, watch } from 'vue'
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
closeOnBackdropClick?: boolean
maxWidth?: string
}>(), {
title: '',
closeOnBackdropClick: true,
maxWidth: 'max-w-lg'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const close = () => emit('update:modelValue', false)
const closeOnBackdrop = () => {
if (props.closeOnBackdropClick) close()
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.modelValue) close()
}
watch(() => props.modelValue, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('keydown', onKeydown)
}
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
}
})
</script>

View File

@@ -108,9 +108,7 @@ const onInput = (event: Event) => {
numeric = Math.min(max, numeric)
}
if (numeric !== parsed) {
target.value = String(numeric)
}
target.value = String(numeric)
emit('update:modelValue', numeric)
}

View File

@@ -7,82 +7,41 @@ export interface BovineColumn {
width?: string
}
export interface UseBovineColumnsOptions {
/**
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
* largeurs élargies pour combler l'espace.
*/
variant?: 'inventory' | 'case'
}
/**
* Définition partagée des colonnes des tableaux bovins (inventory + case).
* 4 variants : avec/sans colonnes prix × inventory/case.
*
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
* Deux définitions distinctes admin/user pour pouvoir ajuster les largeurs
* indépendamment selon le contexte.
*/
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
export const useBovineColumns = () => {
const auth = useAuthStore()
const withPricesInventory: BovineColumn[] = [
const adminColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '90px' },
{ key: 'breedCode', label: 'Race', width: '70px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
{ key: 'finalPrice', label: 'Prix total', width: '100px' }
]
const withoutPricesInventory: BovineColumn[] = [
const userColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
{ key: 'breedCode', label: 'Race', width: '70px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const withPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
{ key: 'birthDate', label: 'Né le', width: '100px' },
{ key: 'age', label: 'Age', width: '90px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const withoutPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
{ key: 'birthDate', label: 'Né le', width: '140px' },
{ key: 'age', label: 'Age', width: '130px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
]
const columns = computed<BovineColumn[]>(() => {
const isCase = options.variant === 'case'
const seePrice = auth.isBureau
if (isCase) {
return seePrice ? withPricesCase : withoutPricesCase
}
return seePrice ? withPricesInventory : withoutPricesInventory
})
const columns = computed<BovineColumn[]>(() => auth.isAdmin ? adminColumns : userColumns)
return { columns }
}

View File

@@ -1,27 +0,0 @@
import { useAuthStore } from '~/stores/auth'
/**
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
* non-admin, et vers /login pour les non authentifiés.
*
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
* middleware évite l'affichage des pages vides / en erreur quand un user
* tape directement l'URL /admin/...
*/
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/admin')) {
return
}
const auth = useAuthStore()
await auth.ensureSession()
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
if (!auth.isAdmin) {
return navigateTo('/')
}
})

View File

@@ -32,8 +32,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Type de bovin' })
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()

View File

@@ -38,8 +38,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Types de bovins' })
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteur' })
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
import {computed} from "vue";

View File

@@ -34,8 +34,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Transporteurs' })
import type { CarrierData } from '~/services/dto/carrier-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -96,8 +96,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Client' })
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"

View File

@@ -3,8 +3,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse client' })
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Clients' })
import type { CustomerData } from '~/services/dto/customer-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -97,8 +97,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseur' })
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"

View File

@@ -3,8 +3,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Adresse fournisseur' })
import type {AddressData, AddressPayload} from "~/services/address";
import {createAddress, getAddress, updateAddress} from "~/services/address";
import {getSupplier, updateSupplier} from "~/services/supplier";

View File

@@ -44,8 +44,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Fournisseurs' })
import type { SupplierData } from '~/services/dto/supplier-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'

View File

@@ -74,8 +74,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateur' })
import { computed, reactive, ref, watch } from 'vue'
import { ROLE } from '~/utils/constants'
import { createUser, updateUser, getUser } from '~/services/auth'

View File

@@ -63,8 +63,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Utilisateurs' })
import type { UserData } from '~/services/dto/user-data'
import { ROLE } from '~/utils/constants'
import { useAuthStore } from '~/stores/auth'

View File

@@ -1,157 +0,0 @@
<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>
<div class="mb-4 max-w-[200px]">
<UiTextInput
v-model="searchQuery"
placeholder="N° national"
size="compact"
inputmode="numeric"
pattern="[0-9]*"
inputClass="text-xl"
/>
</div>
<div class="space-y-3">
<UiAccordion
v-for="bovine in filteredBovines"
: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>
</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'
import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'
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)
const searchQueryRaw = ref('')
const searchQuery = computed<string>({
get: () => searchQueryRaw.value,
set: (value) => {
searchQueryRaw.value = value.replace(/\D/g, '')
}
})
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 filteredBovines = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return sortedBovines.value
return sortedBovines.value.filter(b =>
b.nationalNumber.toLowerCase().includes(query)
)
})
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
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>

View File

@@ -1,377 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
<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 ?? '' }}
</h1>
</div>
</div>
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
{{ reception?.supplier?.name ?? '' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
</p>
<template v-if="!isConsultationMode">
<form
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
:class="{ submitted }"
@submit.prevent="addBovine"
>
<UiTextInput
v-model="form.nationalNumber"
label="Numéro national"
required
/>
<UiSelect
v-model="form.entryCause"
label="Cause d'entrée"
:options="entryCauseOptions"
required
/>
<UiDateMaskedInput
v-model="form.arrivalDate"
label="Date d'entrée"
required
/>
<UiSelect
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
required
/>
</form>
<div class="flex justify-center mb-12">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="isAdding"
:loading="isAdding"
@click="addBovine"
>
Ajouter
</UiButton>
</div>
</template>
<UiDataTable
v-model:page="recapPage"
v-model:per-page="recapPerPage"
:columns="recapColumns"
:items="savedBovines"
:total-items="savedBovinesTotal"
:loading="savedBovinesLoading"
:show-actions="!isConsultationMode"
>
<template #header-nationalNumber>
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
</template>
<template #header-workNumber>
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
</template>
<template #header-bovineType.label>
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
</template>
<template #header-sex>
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
</template>
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
</template>
<template #header-entryCause>
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
</template>
<template #header-ednotifConfirmedAt>
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
</template>
<template #header-actions>
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
</template>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-bovineType.label="{ item }">
{{ item.bovineType?.label ?? '—' }}
</template>
<template #cell-supplier.name="{ item }">
{{ supplierName(item.supplier) }}
</template>
<template #cell-entryCause="{ item }">
{{ entryCauseLabel(item.entryCause) }}
</template>
<template #cell-ednotifConfirmedAt="{ item }">
<span
v-if="item.ednotifConfirmedAt"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Validé
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
>
En attente
</span>
</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>
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="savedBovinesTotal === 0 || isValidating"
:loading="isValidating"
@click="validateEntry"
>
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 { getSupplierList } from '~/services/supplier'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
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 {
items: savedBovines,
totalItems: savedBovinesTotal,
page: recapPage,
perPage: recapPerPage,
filters: recapFilters,
loading: savedBovinesLoading,
reload: reloadSavedBovines
} = useDataTableServerState<BovineData>(
'bovines',
{
reception: receptionId.value,
nationalNumber: '',
workNumber: '',
'bovineType.label': '',
sex: '',
'birthDate[after]': '',
'birthDate[strictly_before]': '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const birthDateFilter = computed<string>({
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
recapFilters.value['birthDate[after]'] = ''
recapFilters.value['birthDate[strictly_before]'] = ''
return
}
recapFilters.value['birthDate[after]'] = value
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
}
})
const arrivalDateFilter = computed<string>({
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
recapFilters.value['arrivalDate[after]'] = ''
recapFilters.value['arrivalDate[strictly_before]'] = ''
return
}
recapFilters.value['arrivalDate[after]'] = value
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
}
})
const isAdding = ref(false)
const isValidating = ref(false)
const submitted = ref(false)
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
const recapColumns = computed(() => {
const cols: Array<{ key: string; label: string; width: string }> = [
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '75px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
{ key: 'entryCause', label: 'Cause', width: '100px' }
]
if (isConsultationMode.value) {
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
}
return cols
})
const entryCauseLabel = (code: string | null | undefined) => {
if (!code) return '—'
return entryCauseOptions.find(o => o.value === code)?.label ?? code
}
const supplierName = (supplier: BovineData['supplier']) => {
if (supplier && typeof supplier === 'object') return supplier.name
return '—'
}
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 confirmDeleteBovine = async (bovine: BovineData) => {
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
if (!confirmed) return
await api.delete(`bovines/${bovine.id}`)
reloadSavedBovines()
}
const validateEntry = async () => {
if (savedBovinesTotal.value === 0 || isValidating.value) return
const message = savedBovinesTotal.value !== declaredCount.value
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
: `Êtes-vous sûr de vouloir valider l'entrée ?`
if (!window.confirm(message)) return
isValidating.value = true
try {
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
router.push('/entry-exit')
} finally {
isValidating.value = false
}
}
type EntryCause = 'A' | 'N' | 'P'
interface FormState {
nationalNumber: string
entryCause: EntryCause
arrivalDate: string
supplierId: string | number | null
}
const entryCauseOptions = [
{ value: 'A', label: 'Achat' },
{ value: 'N', label: 'Naissance' },
{ value: 'P', label: 'Prêt ou pension' }
]
const initialForm = (): FormState => ({
nationalNumber: '',
entryCause: 'A',
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
supplierId: reception.value?.supplier?.id ?? null
})
const form = reactive<FormState>(initialForm())
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: s.id, label: s.name }))
)
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
const isFormValid = computed(() =>
form.nationalNumber.trim() !== ''
&& !!form.entryCause
&& !!form.arrivalDate
&& form.supplierId !== null
)
const resetForm = () => {
Object.assign(form, initialForm())
}
const loadReception = async () => {
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
resetForm()
}
const focusFirstField = () => {
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
el?.focus()
}
const addBovine = async () => {
submitted.value = true
if (!isFormValid.value || isAdding.value) return
isAdding.value = true
try {
const payload = {
nationalNumber: form.nationalNumber.trim(),
entryCause: form.entryCause,
arrivalDate: form.arrivalDate,
supplier: `/api/suppliers/${form.supplierId}`,
reception: `/api/receptions/${receptionId.value}`
}
await api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' }
})
reloadSavedBovines()
resetForm()
submitted.value = false
await nextTick()
focusFirstField()
} finally {
isAdding.value = false
}
}
onMounted(async () => {
suppliers.value = await getSupplierList()
await loadReception()
reloadSavedBovines()
})
</script>

View File

@@ -1,265 +0,0 @@
<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>
<div class="mt-8 grid grid-cols-2 gap-8">
<section>
<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 #header-identificationNumber>
<UiTextInput
v-model="entryFilters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-declaredCount>
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
</template>
<template #header-registeredBovineCount>
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
</template>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-declaredCount="{ item }">
{{ item.declaredBovineCount ?? 0 }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-status="{ item }">
<span
v-if="!item.entryCompleted"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
Attente saisie
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
>
Attente EDNOTIF
</span>
</template>
</UiDataTable>
</section>
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
<UiDataTable
v-model:page="validatedPage"
v-model:per-page="validatedPerPage"
:columns="validatedColumns"
:items="validated"
:total-items="totalValidated"
:loading="validatedLoading"
row-clickable
@row-click="goToBovineInfo"
>
<template #header-identificationNumber>
<UiTextInput
v-model="validatedFilters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-registeredBovineCount>
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
</template>
<template #header-validatedAt>
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
</template>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-validatedAt="{ item }">
{{ formatDate(item.validatedAt) }}
</template>
<template #cell-status>
<span
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Validée
</span>
</template>
</UiDataTable>
</section>
</div>
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
<section>
<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>
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
À venir
</div>
</section>
</div>
</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,
filters: entryFilters,
loading: entriesLoading,
reload
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'exists[validatedAt]': 'false',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 5 }
)
const {
items: validated,
totalItems: totalValidated,
page: validatedPage,
perPage: validatedPerPage,
filters: validatedFilters,
loading: validatedLoading,
reload: reloadValidated
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'exists[validatedAt]': 'true',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 5 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const entryDateFilter = computed<string>({
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
entryFilters.value['receptionDate[after]'] = ''
entryFilters.value['receptionDate[strictly_before]'] = ''
return
}
entryFilters.value['receptionDate[after]'] = value
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const validatedDateFilter = computed<string>({
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
validatedFilters.value['receptionDate[after]'] = ''
validatedFilters.value['receptionDate[strictly_before]'] = ''
return
}
validatedFilters.value['receptionDate[after]'] = value
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const entryColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date', width: '75px' },
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
{ key: 'status', label: 'Statut', width: '1fr' }
]
const validatedColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date', width: '75px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
{ key: 'status', label: 'Statut', width: '1fr' }
]
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 goToEntry = (reception: ReceptionData) => {
router.push(`/entry-exit/entry/${reception.id}`)
}
const goToBovineInfo = (reception: ReceptionData) => {
router.push(`/entry-exit/bovine-info/${reception.id}`)
}
onMounted(() => {
reload()
reloadValidated()
})
</script>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
useHead({ title: 'Accueil' })
</script>
<template>
<div class="flex flex-wrap justify-center pb-16 gap-12">
@@ -16,11 +15,7 @@ useHead({ title: 'Accueil' })
EXPÉDITIONS<br>EN ATTENTE
</template>
</card-link>
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-circle-outline">
<template #label>
Entrée<br>Sortie
</template>
</card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/inventory" iconName="mdi:cow">

View File

@@ -69,8 +69,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Bovins' })
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
import type { BovinePayload } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'

View File

@@ -80,8 +80,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Bâtiments' })
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"

View File

@@ -94,13 +94,19 @@
<template #header-finalPrice>
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
</template>
<template #header-bovineType.label>
<template #header-breedCode>
<UiTextInput
v-model="filters['bovineType.label']"
v-model="filters.breedCode"
placeholder="Race"
size="compact"
/>
</template>
<template #header-buildingCase.building.label>
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
</template>
<template #header-buildingCase.caseNumber>
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template>
@@ -118,6 +124,12 @@
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
@@ -130,8 +142,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Cases' })
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
@@ -186,7 +196,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
buildingCase: '',
nationalNumber: '',
workNumber: '',
'bovineType.label': '',
breedCode: '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
@@ -224,7 +234,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const { columns } = useBovineColumns({ variant: 'case' })
const { columns } = useBovineColumns()
const title = computed(() => {
if (!buildingCase.value) return ''

View File

@@ -13,17 +13,17 @@
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
<div
v-if="auth.isBureau"
v-if="auth.isAdmin"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
title="Exporter en Excel"
@click="showExportModal = true"
@click="exportInventory"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<button
v-if="auth.isBureau"
v-if="auth.isAdmin"
type="button"
:disabled="syncing"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
@@ -83,9 +83,9 @@
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
</template>
<template #header-bovineType.label>
<template #header-breedCode>
<UiTextInput
v-model="filters['bovineType.label']"
v-model="filters.breedCode"
placeholder="Race"
size="compact"
/>
@@ -123,7 +123,7 @@
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }}
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
@@ -136,21 +136,12 @@
</template>
</UiDataTable>
</div>
<InventoryExportModal
v-model="showExportModal"
:loading="exporting"
@submit="exportInventory"
/>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Inventaire' })
import type { BovineData } from '~/services/dto/bovine-data'
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useBovineColumns } from '~/composables/useBovineColumns'
@@ -192,17 +183,12 @@ const loadStats = async () => {
const syncing = ref(false)
const exporting = ref(false)
const showExportModal = ref(false)
const exportInventory = async (filters: InventoryExportFilters) => {
const exportInventory = async () => {
if (exporting.value) return
exporting.value = true
try {
const query: Record<string, unknown> = {}
if (filters.ageRanges.length > 0) {
query.ageRanges = filters.ageRanges.join(',')
}
const blob = await api.getBlob('bovines/inventory-export', query)
const blob = await api.getBlob('bovines/inventory-export')
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -213,7 +199,6 @@ const exportInventory = async (filters: InventoryExportFilters) => {
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
showExportModal.value = false
} catch {
// toast déjà géré par useApi onResponseError
} finally {
@@ -249,10 +234,9 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'bovines',
{
'exists[exitedAt]': 'false',
'exists[ednotifConfirmedAt]': 'true',
nationalNumber: '',
workNumber: '',
'bovineType.label': '',
breedCode: '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',

View File

@@ -53,8 +53,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Connexion' })
import type { UserData } from '~/services/dto/user-data'
import { getUsers } from '~/services/auth'
import { useAuthStore } from '~/stores/auth'

View File

@@ -54,8 +54,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réception' })
import { useReceptionStore } from '~/stores/reception'
import { storeToRefs } from 'pinia'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -73,8 +73,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation réception' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'

View File

@@ -226,8 +226,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier réception' })
import { usePdfPrinter } from '#imports'
import { computed } from 'vue'
import UpdateBovin from '~/components/reception/update-bovin.vue'

View File

@@ -72,8 +72,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Réceptions en attente' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { deleteReception } from '~/services/reception'

View File

@@ -125,8 +125,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Scanner' })
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'

View File

@@ -51,8 +51,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expédition' })
import { storeToRefs } from 'pinia'
import { useShipmentStore } from '~/stores/shipment'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'

View File

@@ -71,8 +71,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Validation expédition' })
import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'

View File

@@ -197,8 +197,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Modifier expédition' })
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'

View File

@@ -84,8 +84,6 @@
</template>
<script setup lang="ts">
useHead({ title: 'Expéditions en attente' })
import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { deleteShipment } from '~/services/shipment'

View File

@@ -1,12 +1,6 @@
export interface BovineBuildingRef {
id: number
label: string
}
export interface BovineBuildingCaseRef {
id: number
caseNumber: number | null
building: BovineBuildingRef | null
building: { label: string } | null
}
export interface BovineData {
@@ -18,18 +12,13 @@ export interface BovineData {
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: { id: number; name: string } | string | null
supplier: string | null
workNumber: string | null
birthDate: string | null
bovineType: { id: number; label: string; code: string } | null
breedCode: string | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
reception?: string | null
entryCause?: 'A' | 'N' | 'P' | null
ednotifConfirmedAt?: string | null
}
export type BovinePayload = {
@@ -39,6 +28,4 @@ export type BovinePayload = {
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
reception?: string | null
entryCause?: 'A' | 'N' | 'P' | null
}

View File

@@ -18,11 +18,6 @@ export interface ReceptionData {
receptionDate: string
currentStep: number
isValid: boolean
entryCompleted?: boolean
validatedAt?: string | null
registeredBovineCount?: number
confirmedBovineCount?: number
declaredBovineCount?: number
receptionType?: ReceptionTypeData | null
merchandiseType?: MerchandiseTypeData | null
merchandiseDetail?: string | null
@@ -68,7 +63,6 @@ export type ReceptionPayload = {
receptionDate?: string
currentStep?: number
isValid?: boolean
entryCompleted?: boolean
receptionType?: string | null
merchandiseType?: string | null
merchandiseDetail?: string | null

View File

@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
import type {UserData} from '~/services/dto/user-data'
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
import type {UserPayload} from "~/services/dto/user-data";
import {userHasRole} from '~/utils/roles'
import {ROLE} from '~/utils/constants'
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -12,9 +12,7 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
isAuthenticated: (state) => Boolean(state.user),
hasRole: (state) => (role: string): boolean => userHasRole(state.user?.roles, role),
isAdmin: (state) => userHasRole(state.user?.roles, 'ROLE_ADMIN'),
isBureau: (state) => userHasRole(state.user?.roles, 'ROLE_BUREAU')
isAdmin: (state) => Boolean(state.user?.roles?.includes(ROLE[0].value))
},
actions: {
clearSession() {

View File

@@ -10,7 +10,6 @@ export const MERCHANDISE_TYPE_CODES = {
export const ROLE = [
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
{ label: 'Bureau', value: 'ROLE_BUREAU' },
{ label: 'Utilisateur', value: 'ROLE_USER' }
]
export const SUPPLIER_CODE = {

View File

@@ -1,38 +0,0 @@
/**
* Hiérarchie des rôles côté front. Doit rester synchronisée avec
* `role_hierarchy` dans config/packages/security.yaml côté back.
*
* Pour ajouter un nouveau rôle :
* 1. Ajouter une entrée ici (son rôle parent dans la chaîne)
* 2. Ajouter `ROLE_X: ROLE_Y` dans security.yaml côté back
* 3. Ajouter le rôle dans `ROLE` (utils/constants.ts) pour le form admin
*/
export const ROLE_HIERARCHY: Record<string, string[]> = {
ROLE_ADMIN: ['ROLE_BUREAU'],
ROLE_BUREAU: ['ROLE_USER'],
ROLE_USER: []
}
/**
* Retourne l'ensemble des rôles effectifs en expansant la hiérarchie.
* Ex : ['ROLE_ADMIN'] → Set { 'ROLE_ADMIN', 'ROLE_BUREAU', 'ROLE_USER' }.
*/
export const expandRoles = (roles: string[]): Set<string> => {
const expanded = new Set<string>(roles)
const visit = (role: string): void => {
const parents = ROLE_HIERARCHY[role] ?? []
for (const parent of parents) {
if (!expanded.has(parent)) {
expanded.add(parent)
visit(parent)
}
}
}
for (const r of roles) visit(r)
return expanded
}
export const userHasRole = (userRoles: string[] | null | undefined, role: string): boolean => {
if (!userRoles || userRoles.length === 0) return false
return expandRoles(userRoles).has(role)
}

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260428061801 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD building_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F4D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id)');
$this->addSql('CREATE INDEX IDX_2068337F4D2A7E12 ON bovine (building_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F4D2A7E12');
$this->addSql('DROP INDEX IDX_2068337F4D2A7E12');
$this->addSql('ALTER TABLE bovine DROP building_id');
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Bascule de Bovine.breed_code (string) vers une relation Bovine.bovine_type_id (FK).
* Ajoute au passage les BovineType manquants (Aubrac=14, Croisé=39, Blonde d'aquitaine=79).
*/
final class Version20260428065800 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migration breedCode -> relation BovineType + ajout des races manquantes.';
}
public function up(Schema $schema): void
{
// 1. Insertion des BovineType manquants (idempotent via NOT EXISTS).
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Aubrac', '14' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '14')");
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Croisé', '39' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '39')");
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Blonde d''aquitaine', '79' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '79')");
// 2. Ajout de la colonne FK + index.
$this->addSql('ALTER TABLE bovine ADD bovine_type_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_2068337F7899F32E ON bovine (bovine_type_id)');
// 3. Backfill : associe chaque bovin à son BovineType via le code.
$this->addSql('UPDATE bovine SET bovine_type_id = (SELECT id FROM bovine_type WHERE bovine_type.code = bovine.breed_code) WHERE breed_code IS NOT NULL');
// 4. Contrainte de clé étrangère (après backfill pour éviter une violation transitoire).
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F7899F32E FOREIGN KEY (bovine_type_id) REFERENCES bovine_type (id)');
// 5. Drop de l'ancienne colonne string.
$this->addSql('ALTER TABLE bovine DROP breed_code');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
$this->addSql('UPDATE bovine SET breed_code = (SELECT code FROM bovine_type WHERE bovine_type.id = bovine.bovine_type_id) WHERE bovine_type_id IS NOT NULL');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F7899F32E');
$this->addSql('DROP INDEX IDX_2068337F7899F32E');
$this->addSql('ALTER TABLE bovine DROP bovine_type_id');
}
}

View File

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

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429101011 extends AbstractMigration
{
public function getDescription(): string
{
return "Ajout de la cause d'entrée (enum CauseEntree EDNOTIF) sur bovine.";
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD entry_cause VARCHAR(1) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP entry_cause');
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260429143822 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de bovine.ednotif_confirmed_at (timestamp de confirmation EDNOTIF par le sync inventory).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD ednotif_confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
// Backfill : les bovins déjà en BDD ont été synchronisés depuis EDNOTIF
// historiquement (commande sync-inventory), on les considère confirmés.
// Les nouveaux bovins créés via le workflow entrée auront `NULL` par
// défaut et seront confirmés au prochain sync.
$this->addSql('UPDATE bovine SET ednotif_confirmed_at = NOW() WHERE ednotif_confirmed_at IS NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP ednotif_confirmed_at');
}
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260430090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de reception.validated_at (timestamp de validation complète : entrée terminée + tous bovins confirmés EDNOTIF).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception DROP validated_at');
}
}

View File

@@ -19,7 +19,7 @@ use App\State\Bovin\BovineInventoryExportProvider;
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
tags: ['Bovines'],
),
security: "is_granted('ROLE_BUREAU')",
security: "is_granted('ROLE_ADMIN')",
output: false,
provider: BovineInventoryExportProvider::class,
),

View File

@@ -19,7 +19,7 @@ use App\State\Bovin\BovineSyncInventoryProcessor;
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_BUREAU')",
security: "is_granted('ROLE_ADMIN')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use App\Entity\Building;
use App\Entity\Supplier;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
@@ -72,12 +71,6 @@ final class FeedBovinePricesCommand extends Command
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
}
// Pré-chargement des bâtiments par code (insensible casse).
$buildingByCode = [];
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
}
$bovineRepo = $this->em->getRepository(Bovine::class);
$stats = [
@@ -86,11 +79,9 @@ final class FeedBovinePricesCommand extends Command
'notFound' => 0,
'invalid' => 0,
'supplierMissing' => 0,
'buildingMissing' => 0,
];
$missingNationalNumbers = [];
$missingSuppliers = [];
$missingBuildings = [];
$io->progressStart($highestRow);
for ($row = 1; $row <= $highestRow; ++$row) {
@@ -100,7 +91,6 @@ final class FeedBovinePricesCommand extends Command
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
$rawWeight = $sheet->getCell([3, $row])->getValue();
$rawPrice = $sheet->getCell([4, $row])->getValue();
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
$rawNationalNumber = trim($rawNationalNumber);
if ('' === $rawNationalNumber) {
@@ -144,18 +134,6 @@ final class FeedBovinePricesCommand extends Command
}
$bovine->setSupplier($supplier);
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
$buildingCode = mb_strtoupper(trim($rawBuilding));
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
$building = $buildingByCode[$buildingCode] ?? null;
if (null !== $building) {
$bovine->setBuilding($building);
} else {
++$stats['buildingMissing'];
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
}
}
++$stats['updated'];
$io->progressAdvance();
}
@@ -174,7 +152,6 @@ final class FeedBovinePricesCommand extends Command
['Bovins introuvables', $stats['notFound']],
['Lignes invalides', $stats['invalid']],
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
]
);
@@ -196,14 +173,6 @@ final class FeedBovinePricesCommand extends Command
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
}
if ([] !== $missingBuildings) {
$list = [];
foreach ($missingBuildings as $code => $count) {
$list[] = sprintf('%s (%d)', $code, $count);
}
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
}
if ($dryRun) {
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
} else {

View File

@@ -476,12 +476,9 @@ class SeedCommand extends Command
private function seedBovineTypes(): void
{
$bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
];
foreach ($bovineTypes as $type) {
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {

View File

@@ -77,12 +77,9 @@ class ReferenceFixtures extends Fixture
}
$bovineTypes = [
['label' => 'Aubrac', 'code' => '14'],
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Croisé', 'code' => '39'],
['label' => 'Parthenaise', 'code' => '71'],
['label' => "Blonde d'aquitaine", 'code' => '79'],
];
foreach ($bovineTypes as $type) {
$bovineType = new BovineType()

View File

@@ -10,12 +10,10 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\CauseEntree;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
@@ -29,17 +27,15 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'bovineType.label' => 'ipartial',
'bovineType.code' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
'reception' => 'exact',
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiResource(
order: ['birthDate' => 'ASC'],
operations: [
@@ -53,20 +49,16 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
new Post(
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_USER')",
),
],
security: "is_granted('ROLE_USER')",
)]
@@ -88,7 +80,7 @@ class Bovine
#[ORM\Column(type: 'float', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
private ?float $pricePerKg = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
@@ -101,24 +93,8 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne(inversedBy: 'bovines')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: false)]
private ?Reception $reception = null;
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?CauseEntree $entryCause = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(readableLink: true)]
private ?Supplier $supplier = null;
#[ORM\Column(length: 50, nullable: true)]
@@ -130,10 +106,9 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\ManyToOne]
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $bovineType = null;
private ?string $breedCode = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
@@ -153,11 +128,6 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
private ?DateTimeImmutable $ednotifConfirmedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -200,7 +170,7 @@ class Bovine
}
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
public function getFinalPrice(): ?float
{
if (null === $this->receivedWeight || null === $this->pricePerKg) {
@@ -234,52 +204,6 @@ class Bovine
return $this;
}
public function getReception(): ?Reception
{
return $this->reception;
}
public function setReception(?Reception $reception): static
{
$this->reception = $reception;
return $this;
}
public function getEntryCause(): ?CauseEntree
{
return $this->entryCause;
}
public function setEntryCause(?CauseEntree $entryCause): static
{
$this->entryCause = $entryCause;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
}
public function setBuilding(?Building $building): static
{
$this->building = $building;
return $this;
}
/**
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
*/
#[Groups(['bovine:read', 'building_case:read'])]
public function getEffectiveBuilding(): ?Building
{
return $this->buildingCase?->getIdBuilding() ?? $this->building;
}
public function getSupplier(): ?Supplier
{
return $this->supplier;
@@ -316,14 +240,14 @@ class Bovine
return $this;
}
public function getBovineType(): ?BovineType
public function getBreedCode(): ?string
{
return $this->bovineType;
return $this->breedCode;
}
public function setBovineType(?BovineType $bovineType): static
public function setBreedCode(?string $breedCode): static
{
$this->bovineType = $bovineType;
$this->breedCode = $breedCode;
return $this;
}
@@ -364,18 +288,6 @@ class Bovine
return $this;
}
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
{
return $this->ednotifConfirmedAt;
}
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
{
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;

View File

@@ -51,11 +51,11 @@ class BovineType
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $label = null;
#[ORM\Column(length: 50)]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $code = null;
public function getId(): ?int

View File

@@ -33,7 +33,7 @@ class Building
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]

View File

@@ -39,7 +39,7 @@ class BuildingCase
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
#[Groups(['building:read', 'building_case:read'])]
private ?int $id = null;
#[ORM\Column]

View File

@@ -6,7 +6,6 @@ namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
@@ -32,15 +31,13 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.id' => 'exact',
'receptionType.code' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource(
@@ -64,7 +61,6 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/receptions/weigh',
@@ -113,14 +109,6 @@ class Reception
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $isValid = false;
#[ORM\Column(options: ['default' => false])]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $entryCompleted = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['reception:read', 'reception-bovine:read'])]
private ?DateTimeImmutable $validatedAt = null;
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context(
@@ -215,12 +203,6 @@ class Reception
#[Groups(['reception:read', 'reception:write'])]
private ?string $bovineDetail = null;
/**
* @var Collection<int, Bovine>
*/
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
private Collection $bovines;
public function __construct(
?DateTimeImmutable $receptionDate = null,
) {
@@ -229,7 +211,6 @@ class Reception
$this->buildings = new ArrayCollection();
$this->pelletBuildings = new ArrayCollection();
$this->bovines_types = new ArrayCollection();
$this->bovines = new ArrayCollection();
}
public function getId(): ?int
@@ -288,90 +269,6 @@ class Reception
return $this;
}
#[Groups(['reception:read'])]
public function isEntryCompleted(): bool
{
return $this->entryCompleted;
}
public function setEntryCompleted(bool $entryCompleted): self
{
$this->entryCompleted = $entryCompleted;
return $this;
}
public function getValidatedAt(): ?DateTimeImmutable
{
return $this->validatedAt;
}
public function setValidatedAt(?DateTimeImmutable $validatedAt): self
{
$this->validatedAt = $validatedAt;
return $this;
}
public function isFullyConfirmed(): bool
{
if ($this->bovines->isEmpty()) {
return false;
}
foreach ($this->bovines as $bovine) {
if (null === $bovine->getEdnotifConfirmedAt()) {
return false;
}
}
return true;
}
public function tryValidate(): void
{
if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) {
$this->validatedAt = new DateTimeImmutable();
}
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->tryValidate();
}
#[Groups(['reception:read'])]
public function getRegisteredBovineCount(): int
{
return $this->bovines->count();
}
#[Groups(['reception:read'])]
public function getConfirmedBovineCount(): int
{
$count = 0;
foreach ($this->bovines as $bovine) {
if (null !== $bovine->getEdnotifConfirmedAt()) {
++$count;
}
}
return $count;
}
#[Groups(['reception:read'])]
public function getDeclaredBovineCount(): int
{
$fromTypes = 0;
foreach ($this->bovines_types as $rb) {
$fromTypes += (int) ($rb->getQuantity() ?? 0);
}
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
return $fromTypes + $fromOther;
}
#[Groups(['reception:read'])]
public function getReceptionDate(): ?DateTimeImmutable
{

View File

@@ -61,7 +61,6 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/shipments/weigh',

View File

@@ -54,11 +54,11 @@ class Supplier
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['supplier:read', 'reception:read', 'bovine:read'])]
#[Groups(['supplier:read', 'reception:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])]
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private string $name = '';
#[ORM\Column(length: 180, nullable: true)]

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
*
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
* Le `.value` est le code IPG transmis dans le payload SOAP.
*
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
* encore présente dans la release installée v0.0.6). À remplacer par l'import
* bundle quand une version embarquant l'enum sera publiée.
*/
enum CauseEntree: string
{
/** Entrée par achat. */
case Achat = 'A';
/** Entrée par naissance. */
case Naissance = 'N';
/** Entrée par prêt ou pension. */
case PretOuPension = 'P';
}

View File

@@ -13,60 +13,11 @@ use Doctrine\Persistence\ManagerRegistry;
*/
final class BovineRepository extends ServiceEntityRepository
{
public const AGE_RANGE_OVER_24 = 'over24';
public const AGE_RANGE_BETWEEN_22_AND_24 = 'between22And24';
public const AGE_RANGE_BETWEEN_20_AND_22 = 'between20And22';
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bovine::class);
}
/**
* Liste des bovins actifs pour l'export inventaire.
*
* @param null|list<string> $ageRanges Si null/vide → tous. Sinon filtre OR sur les tranches d'âge demandées.
*
* @return list<Bovine>
*/
public function findActiveForInventoryExport(?array $ageRanges = null): array
{
$qb = $this->createQueryBuilder('b')
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
->orderBy('b.birthDate', 'ASC')
;
if (null !== $ageRanges && [] !== $ageRanges) {
$orX = $qb->expr()->orX();
foreach ($ageRanges as $idx => $range) {
switch ($range) {
case self::AGE_RANGE_OVER_24:
$orX->add('b.ageMonths >= 24');
break;
case self::AGE_RANGE_BETWEEN_22_AND_24:
$orX->add($qb->expr()->andX('b.ageMonths >= 22', 'b.ageMonths < 24'));
break;
case self::AGE_RANGE_BETWEEN_20_AND_22:
$orX->add($qb->expr()->andX('b.ageMonths >= 20', 'b.ageMonths < 22'));
break;
}
}
if ($orX->count() > 0) {
$qb->andWhere($orX);
}
}
return $qb->getQuery()->getResult();
}
/**
* Compteurs des bovins actifs par tranche d'âge.
*
@@ -82,7 +33,6 @@ final class BovineRepository extends ServiceEntityRepository
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
)
->where('b.exitedAt IS NULL')
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
;
if (null !== $buildingCaseId) {

View File

@@ -7,18 +7,15 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Bovine;
use App\Repository\BovineRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\RichText\RichText;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\RequestStack;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -26,62 +23,44 @@ use Symfony\Component\HttpFoundation\Response;
*/
final class BovineInventoryExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const HEADER_FILL = 'FFF1F5F9';
private const HEADER_FILL = 'FFCCECFF';
private const SUBTITLE_TEXT_COLOR = 'FFFF0000';
// Couleurs pastel pour les lignes de données selon l'âge.
private const COLOR_RED = 'FFFCA5A5';
private const COLOR_ORANGE = 'FFFDBA74';
private const COLOR_YELLOW = 'FFFEF08A';
private const COLOR_YELLOW = 'FFFDE047';
private const BREED_CODE_LIMOUSINE = '34';
private const BREED_CODE_CHAROLAISE = '38';
/**
* Largeurs de colonnes (A à R).
*/
private const COLUMN_WIDTHS = [
'A' => 3.7,
'B' => 6.3,
'C' => 3.7,
'D' => 15.3,
'E' => 3.0,
'F' => 3.0,
'G' => 3.0,
'H' => 6.6,
'I' => 13.9,
'J' => 11.4,
'K' => 11.4,
'L' => 7.5,
'M' => 6.1,
'N' => 6.0,
'O' => 11.4,
'P' => 5.7,
'Q' => 5.0,
'R' => 14.4,
private const HEADERS = [
'N° National',
'N° Travail',
'Sexe',
'Né le',
'Age (mois)',
'Race',
'Bâtiment',
'Case',
'Entrée le',
];
private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
public function __construct(
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
private EntityManagerInterface $em,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
$raw = (string) ($request?->query->get('ageRanges') ?? '');
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw))));
$bovines = $this->em->createQueryBuilder()
->select('b')
->from(Bovine::class, 'b')
->where('b.exitedAt IS NULL')
->orderBy('b.birthDate', 'ASC')
->getQuery()
->getResult()
;
$bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges);
$bovines = $this->sortBovines($bovines);
$spreadsheet = $this->buildSpreadsheet($bovines, $ageRanges);
$spreadsheet = $this->buildSpreadsheet($bovines);
$body = $this->renderXlsx($spreadsheet);
$filename = sprintf('inventaire_bovins_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
@@ -94,311 +73,107 @@ final class BovineInventoryExportProvider implements ProviderInterface
}
/**
* Tri par âge décroissant puis race (Limousine d'abord, puis Charolaise, puis autres).
*
* @param list<Bovine> $bovines
*
* @return list<Bovine>
*/
private function sortBovines(array $bovines): array
{
usort($bovines, function (Bovine $a, Bovine $b): int {
$ageDiff = ($b->getAgeMonths() ?? 0) <=> ($a->getAgeMonths() ?? 0);
if (0 !== $ageDiff) {
return $ageDiff;
}
return $this->breedRank($a) <=> $this->breedRank($b);
});
return array_values($bovines);
}
private function breedRank(Bovine $bovine): int
{
$code = $bovine->getBovineType()?->getCode();
return match ($code) {
self::BREED_CODE_LIMOUSINE => 0,
self::BREED_CODE_CHAROLAISE => 1,
default => 2,
};
}
/**
* @param list<Bovine> $bovines
* @param list<string> $ageRanges
*/
private function buildSpreadsheet(array $bovines, array $ageRanges): Spreadsheet
private function buildSpreadsheet(array $bovines): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Inventaire');
// Police par défaut sur tout le classeur (en-têtes + data)
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
// Header row
foreach (self::HEADERS as $index => $label) {
$sheet->setCellValue([$index + 1, 1], $label);
}
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Alerte_Taurillons');
// Configuration impression : A4 paysage, ajusté à 1 page de large,
// lignes 3 et 4 (sous-titre + en-têtes) répétées en haut de chaque page.
$pageSetup = $sheet->getPageSetup();
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
$pageSetup->setFitToWidth(1);
$pageSetup->setFitToHeight(0); // illimité en hauteur, on tient juste sur 1 page de large
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(3, 4);
$pageSetup->setHorizontalCentered(true);
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
$year = (int) new DateTimeImmutable()->format('Y');
// Ligne 1 : titre rich text (Arial Black 18 noir + Arial Black 20 rouge pour l'année)
$richTitle = new RichText();
$first = $richTitle->createTextRun(sprintf('%s - ', self::FARM_NAME));
$first->getFont()->setName('Arial Black')->setSize(18)->setBold(true);
$second = $richTitle->createTextRun(sprintf('TAURILLONS %d', $year));
$second->getFont()->setName('Arial Black')->setSize(20)->setBold(true)
->getColor()->setARGB('FFFF0000')
;
$sheet->getCell('A1')->setValue($richTitle);
$sheet->getRowDimension(1)->setRowHeight(32.25);
// Date du jour à droite
$sheet->setCellValue('R1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
$sheet->getStyle('R1')->getNumberFormat()->setFormatCode('m/d/yyyy');
$sheet->getStyle('R1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('R1')->getFont()->setSize(14)->setBold(true);
// Bordure épaisse en bas du bloc titre (toute la largeur du tableau)
$sheet->getStyle('A1:R1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
// Ligne 3 : sous-titre dynamique fusionné sur toute la largeur du tableau
$sheet->setCellValue('A3', $this->computeSubtitle($ageRanges));
$sheet->mergeCells('A3:R3');
$sheet->getStyle('A3:R3')->applyFromArray([
'font' => [
'size' => 18,
'bold' => true,
'color' => ['argb' => self::SUBTITLE_TEXT_COLOR],
],
'fill' => [
$lastColumn = $sheet->getHighestColumn();
$headerRange = sprintf('A1:%s1', $lastColumn);
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => self::HEADER_FILL],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
'right' => ['borderStyle' => Border::BORDER_MEDIUM],
'left' => ['borderStyle' => Border::BORDER_MEDIUM],
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
],
]);
$sheet->getRowDimension(3)->setRowHeight(24.75);
// Ligne 4 : en-têtes
$headers = [
'A' => 'Limousin',
'B' => 'N° de travail',
'C' => 'Charolais',
'D' => "\nNational",
'E' => "Paturelle\n1 2 3",
'F' => '',
'G' => '',
'H' => 'Case',
'I' => 'Vendeur',
'J' => 'Date de naissance',
'K' => "Date\nentrée",
'L' => "Age\nentrée",
'M' => "Poids\n(kg)",
'N' => "Prix\ndu kg",
'O' => 'Total €',
'P' => "Age\ndu jour",
'Q' => 'Trpt',
'R' => 'Prix final',
];
foreach ($headers as $col => $value) {
$sheet->setCellValue($col.'4', $value);
}
$sheet->getRowDimension(4)->setRowHeight(43.5);
$sheet->getStyle('A4:R4')->applyFromArray([
'font' => ['bold' => true],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
'wrapText' => true,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => self::HEADER_FILL],
],
]);
// Pseudo-merge "Paturelle 1 2 3" via centerContinuous sur E:G
$sheet->getStyle('E4:G4')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER_CONTINUOUS);
// Texte des en-têtes A/B/C en diagonale (60°) comme dans le template,
// sans retour à la ligne (le texte peut être tronqué visuellement par la
// largeur de colonne, c'est l'effet recherché).
$sheet->getStyle('A4:C4')->getAlignment()
->setTextRotation(60)
->setWrapText(false)
;
// Largeurs de colonnes
foreach (self::COLUMN_WIDTHS as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
// Column widths
foreach (self::COLUMN_WIDTHS as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
// Lignes de données
$rowNumber = 5;
// N° National et N° Travail : valeurs numériques mais conservées en string
// (leading zeros, format métier) — on force le format texte pour éviter
// l'avertissement Excel "nombre stocké sous forme de texte".
$sheet->getStyle('A')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$sheet->getStyle('B')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
// Data rows
$rowNumber = 2;
foreach ($bovines as $bovine) {
$this->writeBovineRow($sheet, $rowNumber, $bovine);
$values = $this->formatRow($bovine);
foreach ($values as $colIndex => $value) {
$sheet->setCellValue([$colIndex + 1, $rowNumber], $value);
}
$color = $this->ageColor($bovine->getAgeMonths());
if (null !== $color) {
$rowRange = sprintf('A%d:%s%d', $rowNumber, $lastColumn, $rowNumber);
$sheet->getStyle($rowRange)->applyFromArray([
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => $color],
],
]);
}
++$rowNumber;
}
// Bordures sur l'ensemble du tableau (header + data)
$lastDataRow = $rowNumber - 1;
if ($lastDataRow >= 4) {
$range = 'A4:R'.$lastDataRow;
$sheet->getStyle($range)->getBorders()->applyFromArray([
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
// Freeze header row + auto-filter
$sheet->freezePane('A2');
if ($rowNumber > 2) {
$sheet->setAutoFilter(sprintf('%s:%s%d', 'A1', $lastColumn, $rowNumber - 1));
} else {
$sheet->setAutoFilter($headerRange);
}
return $spreadsheet;
}
private function writeBovineRow(Worksheet $sheet, int $row, Bovine $bovine): void
/**
* @return list<null|int|string>
*/
private function formatRow(Bovine $bovine): array
{
$type = $bovine->getBovineType();
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
$code = $building?->getCode();
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
$sheet->setCellValue('B'.$row, null !== $bovine->getWorkNumber() && ctype_digit($bovine->getWorkNumber())
? (int) $bovine->getWorkNumber()
: ($bovine->getWorkNumber() ?? ''));
$sheet->setCellValue('C'.$row, $isCharo ? 'X' : '');
$sheet->setCellValue('D'.$row, 'FR '.$bovine->getNationalNumber());
$sheet->setCellValue('E'.$row, 'B1' === $code ? 'X' : '');
$sheet->setCellValue('F'.$row, 'B2' === $code ? 'X' : '');
$sheet->setCellValue('G'.$row, 'B3' === $code ? 'X' : '');
$sheet->setCellValue('H'.$row, $bovine->getBuildingCase()?->getCaseNumber() ?? '');
$sheet->setCellValue('I'.$row, $bovine->getSupplier()?->getName() ?? '');
$birth = $bovine->getBirthDate();
$arrival = $bovine->getArrivalDate();
if (null !== $birth) {
$sheet->setCellValue('J'.$row, ExcelDate::PHPToExcel($birth));
}
if (null !== $arrival) {
$sheet->setCellValue('K'.$row, ExcelDate::PHPToExcel($arrival));
}
if (null !== $birth && null !== $arrival) {
$diff = $birth->diff($arrival);
$sheet->setCellValue('L'.$row, ($diff->y * 12) + $diff->m);
}
if (null !== $bovine->getReceivedWeight()) {
$sheet->setCellValue('M'.$row, $bovine->getReceivedWeight());
}
if (null !== $bovine->getPricePerKg()) {
$sheet->setCellValue('N'.$row, $bovine->getPricePerKg());
}
if (null !== $bovine->getFinalPrice()) {
$sheet->setCellValue('O'.$row, $bovine->getFinalPrice());
}
$sheet->setCellValue('P'.$row, $bovine->getAgeMonths() ?? '');
// Q (Tport) intentionnellement vide pour l'instant
// R = O - Q ; Q vide → R = O
if (null !== $bovine->getFinalPrice()) {
$sheet->setCellValue('R'.$row, $bovine->getFinalPrice());
}
// Formats par colonne
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('0000');
$sheet->getStyle('J'.$row.':K'.$row)->getNumberFormat()->setFormatCode('m/d/yyyy');
$sheet->getStyle('M'.$row)->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle('N'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
$sheet->getStyle('O'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
$sheet->getStyle('R'.$row)->getNumberFormat()->setFormatCode('#,##0.00\ "€";\-#,##0.00\ "€"');
// Centrage : A, C, E, F, G, H, P (cellules avec X ou nombres courts)
foreach (['A', 'C', 'E', 'F', 'G', 'H', 'P'] as $col) {
$sheet->getStyle($col.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
// Coloration uniquement de la cellule "Age mois Aujourd'hui" (P) selon l'âge
$color = $this->ageColor($bovine->getAgeMonths());
if (null !== $color) {
$sheet->getStyle('P'.$row)->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB($color)
;
}
return [
$bovine->getNationalNumber(),
$bovine->getWorkNumber(),
$this->formatSex($bovine->getSex()),
$this->formatDate($bovine->getBirthDate()),
$bovine->getAgeMonths(),
$bovine->getBreedCode(),
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
$bovine->getBuildingCase()?->getCaseNumber(),
$this->formatDate($bovine->getArrivalDate()),
];
}
/**
* Sous-titre dynamique selon les tranches d'âge cochées.
*
* @param list<string> $ageRanges
*/
private function computeSubtitle(array $ageRanges): string
private function formatSex(?string $sex): ?string
{
$selected = array_values(array_intersect(
$ageRanges,
[
BovineRepository::AGE_RANGE_BETWEEN_20_AND_22,
BovineRepository::AGE_RANGE_BETWEEN_22_AND_24,
BovineRepository::AGE_RANGE_OVER_24,
]
));
return match ($sex) {
'M' => 'Mâle',
'F' => 'Femelle',
default => $sex,
};
}
$hasLow = in_array(BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, $selected, true);
$hasMid = in_array(BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, $selected, true);
$hasHigh = in_array(BovineRepository::AGE_RANGE_OVER_24, $selected, true);
if ([] === $selected) {
return 'Inventaire complet';
}
if ($hasLow && $hasMid && $hasHigh) {
return 'Âge SUPÉRIEUR ou ÉGAL à 20 MOIS';
}
if ($hasMid && $hasHigh && !$hasLow) {
return 'Âge SUPÉRIEUR ou ÉGAL à 22 MOIS';
}
if ($hasHigh && !$hasMid && !$hasLow) {
return 'Âge SUPÉRIEUR ou ÉGAL à 24 MOIS';
}
if ($hasLow && $hasMid && !$hasHigh) {
return 'Âge entre 20 et 24 MOIS';
}
if ($hasMid && !$hasLow && !$hasHigh) {
return 'Âge entre 22 et 24 MOIS';
}
if ($hasLow && !$hasMid && !$hasHigh) {
return 'Âge entre 20 et 22 MOIS';
}
// Sélection non contiguë (ex: low + high sans mid) → liste explicite
$parts = [];
if ($hasLow) {
$parts[] = '20 à 22 mois';
}
if ($hasMid) {
$parts[] = '22 à 24 mois';
}
if ($hasHigh) {
$parts[] = '≥ 24 mois';
}
return 'Tranches d\'âge : '.implode(' / ', $parts);
private function formatDate(?DateTimeImmutable $date): ?string
{
return $date?->format('d/m/Y');
}
private function ageColor(?int $ageMonths): ?string

View File

@@ -7,8 +7,6 @@ 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;
@@ -17,7 +15,6 @@ 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,
) {}
@@ -44,35 +41,28 @@ final class BovineProcessor implements ProcessorInterface
return;
}
$bovine->setSex($identification->sex);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setBreedCode($this->normalizeBreedCode($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
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $code || '' === $code) {
if (null === $breedType) {
return null;
}
$existing = $this->em->getRepository(BovineType::class)->findOneBy(['code' => $code]);
if (null !== $existing) {
return $existing;
if (is_numeric($breedType)) {
return (string) $breedType;
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return $bovineType;
return null;
}
}

View File

@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
@@ -19,11 +18,6 @@ use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
@@ -40,20 +34,12 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$this->bovineTypeCache = [];
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
if (null !== $bovineType->getCode()) {
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
}
}
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
$impactedReceptions = [];
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
@@ -73,20 +59,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
// voit ce bovin remonter dans l'inventaire.
if (null === $bovine->getEdnotifConfirmedAt()) {
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
$reception = $bovine->getReception();
if (null !== $reception) {
$impactedReceptions[$reception->getId()] = $reception;
}
}
}
foreach ($impactedReceptions as $reception) {
$reception->tryValidate();
}
$now = new DateTimeImmutable();
@@ -111,7 +83,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
@@ -130,28 +102,4 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
/**
* Trouve un BovineType existant par code, sinon en crée un placeholder
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
*/
private function resolveBovineType(?string $code): ?BovineType
{
if (null === $code || '' === $code) {
return null;
}
if (isset($this->bovineTypeCache[$code])) {
return $this->bovineTypeCache[$code];
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
$this->bovineTypeCache[$code] = $bovineType;
return $bovineType;
}
}

View File

@@ -65,7 +65,7 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue;
}
$breedCode = $bovine->getBovineType()?->getCode();
$breedCode = $bovine->getBreedCode();
if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode;
}