feat: ajout du prix au kilo et de l'age moyen bovin + feed bovin via xlsx (!50)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #50.
This commit is contained in:
2026-04-28 07:25:31 +00:00
committed by Autin
parent 7b722bdd17
commit 08a17f91b3
25 changed files with 857 additions and 126 deletions

View File

@@ -82,7 +82,7 @@ frontend/
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
- Les opérations API Platform sont définies directement sur les entités Doctrine.
- Pas de classes Repository custom : utiliser `EntityManagerInterface` avec les repos par défaut.
- Repository custom autorisé dès qu'on a une requête métier non-triviale (agrégations, jointures spécifiques, filtres multiples). Toujours via DQL/QueryBuilder, **jamais de SQL brut** (pas de `Connection::executeQuery`, `fetchAssociative`, etc.). Les CRUD basiques restent sur le repo Doctrine par défaut via `EntityManagerInterface`.
- `config/reference.php` est auto-généré — ne pas modifier à la main.
- Endpoints toujours au pluriel (convention API Platform).
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.

View File

@@ -178,3 +178,65 @@ Pour suivre les logs en temps réel :
- tail -f var/log/dev.log
- tail -f var/log/prod.log
## Feed des prix bovins
Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD.
### Format du fichier XLSX attendu
Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
| Colonne | Champ | Format | Exemple |
|---------|-------|--------|---------|
| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` |
| 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.
### Lancement en dev
Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis :
```bash
# Simulation sans écriture en BDD
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run
# Persistance effective
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx
```
### Lancement en prod
```bash
# 1. Envoyer le fichier sur le serveur
scp feed_bovin.xlsx ferme-prod:/tmp/
# 2. SSH sur le serveur et lancer la commande dans le dossier de l'app
ssh ferme-prod
cd /var/www/ferme
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
```
### Sortie attendue
À la fin, un tableau récapitule :
- Lignes totales lues
- Bovins mis à jour
- 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

@@ -0,0 +1,83 @@
import { computed } from 'vue'
import { useAuthStore } from '~/stores/auth'
export interface BovineColumn {
key: string
label: string
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).
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de
* pouvoir ajuster les largeurs indépendamment.
*/
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
const auth = useAuthStore()
const adminColumnsInventory: 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: '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' }
]
const userColumnsInventory: 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: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const adminColumnsCase: 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 userColumnsCase: 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[]>(() => {
if (options.variant === 'case') {
return auth.isAdmin ? adminColumnsCase : userColumnsCase
}
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
})
return { columns }
}

View File

@@ -33,7 +33,22 @@
</NuxtLink>
</div>
<div class="mt-8 mb-16">
<div class="flex flex-wrap gap-3 mt-4">
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
<span class="text-sm uppercase tracking-wide text-white"> 24 mois</span>
</div>
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
<span class="text-sm uppercase tracking-wide text-white">22 24 mois</span>
</div>
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
<span class="text-sm uppercase tracking-wide text-white">20 22 mois</span>
</div>
</div>
<div class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
@@ -41,7 +56,6 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
:row-clickable="auth.isAdmin"
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
@@ -74,19 +88,19 @@
<template #header-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</template>
<template #header-breedCode>
<template #header-pricePerKg>
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
</template>
<template #header-finalPrice>
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
</template>
<template #header-bovineType.label>
<UiTextInput
v-model="filters.breedCode"
v-model="filters['bovineType.label']"
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>
@@ -94,16 +108,21 @@
{{ formatDate(item.birthDate) }}
</template>
<template #cell-age="{ item }">
{{ formatAgeLabel(item.ageMonths) }}
<span
class="inline-block rounded px-2 py-0.5 font-semibold"
:class="ageBadgeClass(item.ageMonths)"
>
{{ formatAgeLabel(item.ageMonths) }}
</span>
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
</UiDataTable>
</div>
@@ -115,7 +134,8 @@ import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { formatAgeLabel } from '~/utils/bovine-age'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
const route = useRoute()
const router = useRouter()
@@ -128,6 +148,34 @@ const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value >
const buildingCase = ref<BuildingCaseData | null>(null)
interface InventoryStats {
total: number
over24: number
between22And24: number
between20And22: number
}
const stats = ref<InventoryStats>({
total: 0,
over24: 0,
between22And24: 0,
between20And22: 0
})
const loadStats = async () => {
if (!hasCaseId.value) {
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
return
}
try {
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {
buildingCaseId: caseId.value
}, { toast: false })
} catch {
// silencieux
}
}
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
@@ -136,7 +184,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
buildingCase: '',
nationalNumber: '',
workNumber: '',
breedCode: '',
'bovineType.label': '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
@@ -174,17 +222,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 = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const { columns } = useBovineColumns({ variant: 'case' })
const title = computed(() => {
if (!buildingCase.value) return ''
@@ -209,14 +247,12 @@ const formatDate = (date: string | null) => {
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
return ''
const formatPrice = (price: number | null) => {
if (price === null || price === undefined) return ''
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
const loadCase = async () => {
if (!hasCaseId.value) {
buildingCase.value = null
@@ -247,6 +283,7 @@ watch(caseId, (id) => {
}
filters.value.buildingCase = `/api/building_cases/${id}`
loadCase()
loadStats()
reload()
}, { immediate: true })
</script>

View File

@@ -13,6 +13,7 @@
<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.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"
@@ -34,17 +35,17 @@
</div>
<div class="flex flex-wrap gap-3 mt-4">
<div class="flex items-center gap-3 rounded-md border-2 border-violet-300 px-4 py-2">
<span class="text-2xl font-bold text-violet-700">{{ stats.over24 }}</span>
<span class="text-sm uppercase tracking-wide text-violet-700"> 24 mois</span>
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
<span class="text-sm uppercase tracking-wide text-white"> 24 mois</span>
</div>
<div class="flex items-center gap-3 rounded-md border-2 border-red-300 px-4 py-2">
<span class="text-2xl font-bold text-red-700">{{ stats.between22And24 }}</span>
<span class="text-sm uppercase tracking-wide text-red-700">22 24 mois</span>
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
<span class="text-sm uppercase tracking-wide text-white">22 24 mois</span>
</div>
<div class="flex items-center gap-3 rounded-md border-2 border-orange-300 px-4 py-2">
<span class="text-2xl font-bold text-orange-700">{{ stats.between20And22 }}</span>
<span class="text-sm uppercase tracking-wide text-orange-700">20 22 mois</span>
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
<span class="text-sm uppercase tracking-wide text-white">20 22 mois</span>
</div>
</div>
@@ -56,7 +57,6 @@
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
>
<template #header-nationalNumber>
<UiTextInput
@@ -83,9 +83,9 @@
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
</template>
<template #header-breedCode>
<template #header-bovineType.label>
<UiTextInput
v-model="filters.breedCode"
v-model="filters['bovineType.label']"
placeholder="Race"
size="compact"
/>
@@ -102,21 +102,38 @@
<template #header-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</template>
<template #header-pricePerKg>
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
</template>
<template #header-finalPrice>
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
</template>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-age="{ item }">
{{ formatAgeLabel(item.ageMonths) }}
<span
class="inline-block rounded px-2 py-0.5 font-semibold"
:class="ageBadgeClass(item.ageMonths)"
>
{{ formatAgeLabel(item.ageMonths) }}
</span>
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
{{ item.effectiveBuilding?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
</UiDataTable>
</div>
</div>
@@ -127,7 +144,8 @@
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { formatAgeLabel } from '~/utils/bovine-age'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
const router = useRouter()
const auth = useAuthStore()
@@ -218,7 +236,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'exists[exitedAt]': 'false',
nationalNumber: '',
workNumber: '',
breedCode: '',
'bovineType.label': '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
@@ -255,17 +273,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 = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const { columns } = useBovineColumns()
const formatDate = (date: string | null) => {
if (!date) return '—'
@@ -278,14 +286,12 @@ const formatDate = (date: string | null) => {
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-violet-300 hover:bg-violet-400'
if (item.ageMonths >= 22) return 'bg-red-300 hover:bg-red-400'
if (item.ageMonths >= 20) return 'bg-orange-300 hover:bg-orange-400'
return ''
const formatPrice = (price: number | null) => {
if (price === null || price === undefined) return ''
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
onMounted(() => {
reload()
loadStats()

View File

@@ -1,19 +1,27 @@
export interface BovineBuildingRef {
label: string
}
export interface BovineBuildingCaseRef {
caseNumber: number | null
building: { label: string } | null
building: BovineBuildingRef | null
}
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
pricePerKg: number | null
finalPrice: number | null
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
breedCode: string | null
bovineType: { id: number; label: string; code: string } | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
@@ -22,6 +30,7 @@ export interface BovineData {
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
pricePerKg?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null

View File

@@ -8,3 +8,11 @@ export const formatAgeLabel = (months: number | null | undefined): string => {
if (!label) label = '< 1 mois'
return label
}
export const ageBadgeClass = (months: number | null | undefined): string => {
if (months === null || months === undefined) return ''
if (months >= 24) return 'bg-red-500 text-white'
if (months >= 22) return 'bg-orange-500 text-white'
if (months >= 20) return 'bg-yellow-500 text-white'
return ''
}

View File

@@ -0,0 +1,31 @@
<?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 Version20260424132554 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 price_per_kg DOUBLE PRECISION DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP price_per_kg');
}
}

View File

@@ -0,0 +1,35 @@
<?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 Version20260427154952 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de la colonne display_order sur building et seed des valeurs par code.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE building ADD display_order INT DEFAULT NULL');
$this->addSql("UPDATE building SET display_order = 1 WHERE code = 'B3'");
$this->addSql("UPDATE building SET display_order = 2 WHERE code = 'B2'");
$this->addSql("UPDATE building SET display_order = 3 WHERE code = 'B1'");
$this->addSql("UPDATE building SET display_order = 4 WHERE code = 'ZT'");
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE building DROP display_order');
}
}

View File

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

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

@@ -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_USER')",
security: "is_granted('ROLE_ADMIN')",
output: false,
provider: BovineInventoryExportProvider::class,
),

View File

@@ -0,0 +1,215 @@
<?php
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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:feed-bovine-prices',
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
)]
final class FeedBovinePricesCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$file = (string) $input->getArgument('file');
$dryRun = (bool) $input->getOption('dry-run');
if (!file_exists($file)) {
$io->error(sprintf('Fichier introuvable : %s', $file));
return Command::FAILURE;
}
$io->title('Feed bovins depuis '.basename($file));
if ($dryRun) {
$io->warning('Dry-run activé : aucune écriture en BDD.');
}
try {
$spreadsheet = IOFactory::load($file);
} catch (Throwable $e) {
$io->error('Impossible de lire le fichier : '.$e->getMessage());
return Command::FAILURE;
}
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
$supplierByName = [];
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
$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 = [
'total' => 0,
'updated' => 0,
'notFound' => 0,
'invalid' => 0,
'supplierMissing' => 0,
'buildingMissing' => 0,
];
$missingNationalNumbers = [];
$missingSuppliers = [];
$missingBuildings = [];
$io->progressStart($highestRow);
for ($row = 1; $row <= $highestRow; ++$row) {
++$stats['total'];
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
$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) {
++$stats['invalid'];
$io->progressAdvance();
continue;
}
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
if (null === $bovine) {
++$stats['notFound'];
$missingNationalNumbers[] = $nationalNumber;
$io->progressAdvance();
continue;
}
// Lookup supplier (peut être null si introuvable ou colonne vide).
$supplier = null;
$supplierName = mb_strtoupper(trim($rawSupplier));
if ('' !== $supplierName) {
$supplier = $supplierByName[$supplierName] ?? null;
if (null === $supplier) {
++$stats['supplierMissing'];
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
}
}
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
if (null !== $weight) {
$bovine->setReceivedWeight($weight);
}
if (null !== $price) {
$bovine->setPricePerKg($price);
}
$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();
}
$io->progressFinish();
if (!$dryRun) {
$this->em->flush();
}
$io->section('Résultats');
$io->table(
['Métrique', 'Valeur'],
[
['Lignes totales', $stats['total']],
['Bovins mis à jour', $stats['updated']],
['Bovins introuvables', $stats['notFound']],
['Lignes invalides', $stats['invalid']],
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
]
);
if ([] !== $missingNationalNumbers) {
$preview = array_slice($missingNationalNumbers, 0, 10);
$io->warning(sprintf(
'%d bovin(s) introuvable(s). Aperçu : %s%s',
count($missingNationalNumbers),
implode(', ', $preview),
count($missingNationalNumbers) > 10 ? '…' : '',
));
}
if ([] !== $missingSuppliers) {
$list = [];
foreach ($missingSuppliers as $name => $count) {
$list[] = sprintf('%s (%d)', $name, $count);
}
$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 {
$io->success('Feed terminé avec succès.');
}
return Command::SUCCESS;
}
}

View File

@@ -476,9 +476,12 @@ 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,9 +77,12 @@ 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

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\BovineRepository;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -21,17 +22,18 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: BovineRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'bovineType.label' => 'ipartial',
'bovineType.code' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
@@ -77,6 +79,11 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null;
#[ORM\Column(type: 'float', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
private ?float $pricePerKg = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
@@ -87,6 +94,11 @@ class Bovine
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
@@ -100,9 +112,10 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\Column(length: 20, nullable: true)]
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
#[ApiProperty(readableLink: true)]
private ?BovineType $bovineType = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
@@ -151,6 +164,29 @@ class Bovine
return $this;
}
public function getPricePerKg(): ?float
{
return $this->pricePerKg;
}
public function setPricePerKg(?float $pricePerKg): static
{
$this->pricePerKg = $pricePerKg;
return $this;
}
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
public function getFinalPrice(): ?float
{
if (null === $this->receivedWeight || null === $this->pricePerKg) {
return null;
}
return $this->receivedWeight * $this->pricePerKg;
}
public function getArrivalDate(): ?DateTimeImmutable
{
return $this->arrivalDate;
@@ -175,6 +211,28 @@ class Bovine
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;
@@ -211,14 +269,14 @@ class Bovine
return $this;
}
public function getBreedCode(): ?string
public function getBovineType(): ?BovineType
{
return $this->breedCode;
return $this->bovineType;
}
public function setBreedCode(?string $breedCode): static
public function setBovineType(?BovineType $bovineType): static
{
$this->breedCode = $breedCode;
$this->bovineType = $bovineType;
return $this;
}

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'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
private ?string $label = null;
#[ORM\Column(length: 50)]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
private ?string $code = null;
public function getId(): ?int

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ORM\Table(name: 'building')]
#[ApiResource(
order: ['displayOrder' => 'ASC', 'id' => 'ASC'],
operations: [
new Get(
requirements: ['id' => '\d+'],
@@ -43,6 +44,10 @@ class Building
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $code = '';
#[ORM\Column(name: 'display_order', type: 'integer', nullable: true)]
#[Groups(['building:read', 'building:summary'])]
private ?int $displayOrder = null;
/**
* @var Collection<int, Reception>
*/
@@ -101,6 +106,18 @@ class Building
return $this;
}
public function getDisplayOrder(): ?int
{
return $this->displayOrder;
}
public function setDisplayOrder(?int $displayOrder): self
{
$this->displayOrder = $displayOrder;
return $this;
}
/**
* @return Collection<int, Reception>
*/

View File

@@ -511,14 +511,10 @@ class Reception
$this->identificationNumber = $number;
$args->getObjectManager()
->getConnection()
->executeStatement(
'UPDATE reception SET identification_number = :number WHERE id = :id',
[
'number' => $number,
'id' => $this->id,
]
)
->createQuery(sprintf('UPDATE %s r SET r.identificationNumber = :number WHERE r.id = :id', self::class))
->setParameter('number', $number)
->setParameter('id', $this->id)
->execute()
;
}

View File

@@ -358,14 +358,10 @@ class Shipment
$this->identificationNumber = $number;
$args->getObjectManager()
->getConnection()
->executeStatement(
'UPDATE shipment SET identification_number = :number WHERE id = :id',
[
'number' => $number,
'id' => $this->id,
]
)
->createQuery(sprintf('UPDATE %s s SET s.identificationNumber = :number WHERE s.id = :id', self::class))
->setParameter('number', $number)
->setParameter('id', $this->id)
->execute()
;
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bovine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Bovine>
*/
final class BovineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bovine::class);
}
/**
* Compteurs des bovins actifs par tranche d'âge.
*
* @return array{total: int, over24: int, between22And24: int, between20And22: int}
*/
public function getInventoryStats(?int $buildingCaseId = null): array
{
$qb = $this->createQueryBuilder('b')
->select(
'COUNT(b.id) AS total',
'SUM(CASE WHEN b.ageMonths >= 24 THEN 1 ELSE 0 END) AS over24',
'SUM(CASE WHEN b.ageMonths >= 22 AND b.ageMonths < 24 THEN 1 ELSE 0 END) AS between22And24',
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
)
->where('b.exitedAt IS NULL')
;
if (null !== $buildingCaseId) {
$qb->andWhere('b.buildingCase = :caseId')
->setParameter('caseId', $buildingCaseId)
;
}
$row = $qb->getQuery()->getSingleResult();
return [
'total' => (int) ($row['total'] ?? 0),
'over24' => (int) ($row['over24'] ?? 0),
'between22And24' => (int) ($row['between22And24'] ?? 0),
'between20And22' => (int) ($row['between20And22'] ?? 0),
];
}
}

View File

@@ -25,12 +25,12 @@ final class BovineInventoryExportProvider implements ProviderInterface
{
private const HEADER_FILL = 'FFF1F5F9';
private const COLOR_VIOLET = 'FFC4B5FD';
private const COLOR_RED = 'FFFCA5A5';
private const COLOR_ORANGE = 'FFFDBA74';
private const COLOR_YELLOW = 'FFFDE047';
private const HEADERS = [
'N° National',
'N° Travail',
@@ -155,7 +155,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
$this->formatSex($bovine->getSex()),
$this->formatDate($bovine->getBirthDate()),
$bovine->getAgeMonths(),
$bovine->getBreedCode(),
$bovine->getBovineType()?->getLabel(),
$bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
$bovine->getBuildingCase()?->getCaseNumber(),
$this->formatDate($bovine->getArrivalDate()),
@@ -182,14 +182,14 @@ final class BovineInventoryExportProvider implements ProviderInterface
return null;
}
if ($ageMonths >= 24) {
return self::COLOR_VIOLET;
}
if ($ageMonths >= 22) {
return self::COLOR_RED;
}
if ($ageMonths >= 20) {
if ($ageMonths >= 22) {
return self::COLOR_ORANGE;
}
if ($ageMonths >= 20) {
return self::COLOR_YELLOW;
}
return null;
}

View File

@@ -7,7 +7,8 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BovineInventoryStats;
use Doctrine\DBAL\Connection;
use App\Repository\BovineRepository;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @implements ProviderInterface<BovineInventoryStats>
@@ -15,26 +16,22 @@ use Doctrine\DBAL\Connection;
final class BovineInventoryStatsProvider implements ProviderInterface
{
public function __construct(
private Connection $connection,
private BovineRepository $bovineRepository,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BovineInventoryStats
{
$row = $this->connection->fetchAssociative(<<<'SQL'
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE age_months >= 24) AS over_24,
COUNT(*) FILTER (WHERE age_months >= 22 AND age_months < 24) AS between_22_and_24,
COUNT(*) FILTER (WHERE age_months >= 20 AND age_months < 22) AS between_20_and_22
FROM bovine
WHERE exited_at IS NULL
SQL);
$rawCaseId = $this->requestStack->getCurrentRequest()?->query->get('buildingCaseId');
$caseId = null !== $rawCaseId && ctype_digit((string) $rawCaseId) ? (int) $rawCaseId : null;
$row = $this->bovineRepository->getInventoryStats($caseId);
$stats = new BovineInventoryStats();
$stats->total = (int) ($row['total'] ?? 0);
$stats->over24 = (int) ($row['over_24'] ?? 0);
$stats->between22And24 = (int) ($row['between_22_and_24'] ?? 0);
$stats->between20And22 = (int) ($row['between_20_and_22'] ?? 0);
$stats->total = $row['total'];
$stats->over24 = $row['over24'];
$stats->between22And24 = $row['between22And24'];
$stats->between20And22 = $row['between20And22'];
return $stats;
}

View File

@@ -8,6 +8,7 @@ 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;
@@ -18,6 +19,11 @@ 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,
@@ -34,6 +40,13 @@ 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;
@@ -83,7 +96,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBreedCode($identification->breedType);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
@@ -102,4 +115,28 @@ 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->getBreedCode();
$breedCode = $bovine->getBovineType()?->getCode();
if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode;
}