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
19 changed files with 158 additions and 869 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` | | B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
| C | Poids à l'arrivée (kg) | Entier | `368` | | C | Poids à l'arrivée (kg) | Entier | `368` |
| D | Prix au kilo | Décimal | `5.7` | | D | Prix au kilo | Décimal | `5.7` |
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
### Comportement ### 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. - **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. - **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. - **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é. - **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
- La commande est **idempotente** : peut être relancée sans effet de bord. - 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 ### 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 ```bash
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture) # 1. Envoyer le fichier sur le serveur
scp feed_bovin.xlsx <user>@<host>:/tmp/ scp feed_bovin.xlsx ferme-prod:/tmp/
# 2. SSH sur le serveur # 2. SSH sur le serveur et lancer la commande dans le dossier de l'app
ssh <user>@<host> ssh ferme-prod
# 3. Se placer dans le dossier de l'app (pour bin/console)
cd /var/www/ferme cd /var/www/ferme
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run # vérification
# 4. Dry-run pour vérifier sans rien écrire php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx # exécution
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run rm /tmp/feed_bovin.xlsx # nettoyage
# 5. Persistance effective
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
# 6. Cleanup
rm /tmp/feed_bovin.xlsx
``` ```
> 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 ### Sortie attendue
À la fin, un tableau récapitule : À 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) - Bovins introuvables (avec aperçu des 10 premiers numéros)
- Lignes invalides (numéro national vide) - Lignes invalides (numéro national vide)
- Fournisseurs introuvables (avec liste et compte par nom) - Fournisseurs introuvables (avec liste et compte par nom)
- Bâtiments introuvables (avec liste des codes inconnus)

View File

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

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

@@ -7,77 +7,41 @@ export interface BovineColumn {
width?: 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). * Définition partagée des colonnes des tableaux bovins (inventory + case).
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de * Deux définitions distinctes admin/user pour pouvoir ajuster les largeurs
* pouvoir ajuster les largeurs indépendamment. * indépendamment selon le contexte.
*/ */
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => { export const useBovineColumns = () => {
const auth = useAuthStore() const auth = useAuthStore()
const adminColumnsInventory: BovineColumn[] = [ const adminColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' }, { key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' }, { key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' }, { key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' }, { 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.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }, { key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' }, { key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
{ key: 'finalPrice', label: 'Prix total', width: '80px' } { key: 'finalPrice', label: 'Prix total', width: '100px' }
] ]
const userColumnsInventory: BovineColumn[] = [ const userColumns: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' }, { key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' }, { key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' }, { key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' }, { key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' }, { key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' }, { key: 'breedCode', label: 'Race', width: '70px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' }, { key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' }, { key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' } { key: 'arrivalDate', label: 'Entrée le', width: '90px' }
] ]
const adminColumnsCase: BovineColumn[] = [ const columns = computed<BovineColumn[]>(() => auth.isAdmin ? adminColumns : userColumns)
{ 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 } return { columns }
} }

View File

@@ -94,13 +94,19 @@
<template #header-finalPrice> <template #header-finalPrice>
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled /> <UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
</template> </template>
<template #header-bovineType.label> <template #header-breedCode>
<UiTextInput <UiTextInput
v-model="filters['bovineType.label']" v-model="filters.breedCode"
placeholder="Race" placeholder="Race"
size="compact" size="compact"
/> />
</template> </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> <template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" /> <UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template> </template>
@@ -118,6 +124,12 @@
<template #cell-arrivalDate="{ item }"> <template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }} {{ formatDate(item.arrivalDate) }}
</template> </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 }"> <template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }} {{ formatPrice(item.pricePerKg) }}
</template> </template>
@@ -184,7 +196,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
buildingCase: '', buildingCase: '',
nationalNumber: '', nationalNumber: '',
workNumber: '', workNumber: '',
'bovineType.label': '', breedCode: '',
sex: '', sex: '',
'arrivalDate[after]': '', 'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '', 'arrivalDate[strictly_before]': '',
@@ -222,7 +234,7 @@ const singleDateFilter = (afterKey: string, beforeKey: string) =>
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]') const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]') const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const { columns } = useBovineColumns({ variant: 'case' }) const { columns } = useBovineColumns()
const title = computed(() => { const title = computed(() => {
if (!buildingCase.value) return '' if (!buildingCase.value) return ''

View File

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

View File

@@ -1,10 +1,6 @@
export interface BovineBuildingRef {
label: string
}
export interface BovineBuildingCaseRef { export interface BovineBuildingCaseRef {
caseNumber: number | null caseNumber: number | null
building: BovineBuildingRef | null building: { label: string } | null
} }
export interface BovineData { export interface BovineData {
@@ -16,12 +12,10 @@ export interface BovineData {
arrivalDate: string | null arrivalDate: string | null
exitDate: string | null exitDate: string | null
buildingCase: BovineBuildingCaseRef | null buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null supplier: string | null
workNumber: string | null workNumber: string | null
birthDate: string | null birthDate: string | null
bovineType: { id: number; label: string; code: string } | null breedCode: string | null
sex: string | null sex: string | null
ageMonths: number | null ageMonths: number | null
exitedAt: string | null exitedAt: string | null

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

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Entity\Bovine; use App\Entity\Bovine;
use App\Entity\Building;
use App\Entity\Supplier; use App\Entity\Supplier;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
@@ -72,12 +71,6 @@ final class FeedBovinePricesCommand extends Command
$supplierByName[mb_strtoupper($supplier->getName())] = $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); $bovineRepo = $this->em->getRepository(Bovine::class);
$stats = [ $stats = [
@@ -86,11 +79,9 @@ final class FeedBovinePricesCommand extends Command
'notFound' => 0, 'notFound' => 0,
'invalid' => 0, 'invalid' => 0,
'supplierMissing' => 0, 'supplierMissing' => 0,
'buildingMissing' => 0,
]; ];
$missingNationalNumbers = []; $missingNationalNumbers = [];
$missingSuppliers = []; $missingSuppliers = [];
$missingBuildings = [];
$io->progressStart($highestRow); $io->progressStart($highestRow);
for ($row = 1; $row <= $highestRow; ++$row) { for ($row = 1; $row <= $highestRow; ++$row) {
@@ -100,7 +91,6 @@ final class FeedBovinePricesCommand extends Command
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? ''); $rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
$rawWeight = $sheet->getCell([3, $row])->getValue(); $rawWeight = $sheet->getCell([3, $row])->getValue();
$rawPrice = $sheet->getCell([4, $row])->getValue(); $rawPrice = $sheet->getCell([4, $row])->getValue();
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
$rawNationalNumber = trim($rawNationalNumber); $rawNationalNumber = trim($rawNationalNumber);
if ('' === $rawNationalNumber) { if ('' === $rawNationalNumber) {
@@ -144,18 +134,6 @@ final class FeedBovinePricesCommand extends Command
} }
$bovine->setSupplier($supplier); $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']; ++$stats['updated'];
$io->progressAdvance(); $io->progressAdvance();
} }
@@ -174,7 +152,6 @@ final class FeedBovinePricesCommand extends Command
['Bovins introuvables', $stats['notFound']], ['Bovins introuvables', $stats['notFound']],
['Lignes invalides', $stats['invalid']], ['Lignes invalides', $stats['invalid']],
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']], ['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)); $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) { if ($dryRun) {
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.'); $io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
} else { } else {

View File

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

View File

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

View File

@@ -27,13 +27,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'bovine')] #[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])] #[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [ #[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial', 'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial', 'workNumber' => 'ipartial',
'bovineType.label' => 'ipartial', 'breedCode' => 'ipartial',
'bovineType.code' => 'ipartial', 'sex' => 'exact',
'sex' => 'exact', 'buildingCase' => 'exact',
'buildingCase' => 'exact', 'receivedWeight' => 'exact',
'receivedWeight' => 'exact',
])] ])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])] #[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])] #[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
@@ -94,11 +93,6 @@ class Bovine
#[ApiProperty(readableLink: true)] #[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null; private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?Building $building = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null; private ?Supplier $supplier = null;
@@ -112,10 +106,9 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null; private ?DateTimeImmutable $birthDate = null;
#[ORM\ManyToOne] #[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])] #[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(readableLink: true)] private ?string $breedCode = null;
private ?BovineType $bovineType = null;
#[ORM\Column(length: 1, nullable: true)] #[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])] #[Groups(['bovine:read', 'building_case:read'])]
@@ -211,28 +204,6 @@ class Bovine
return $this; 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 public function getSupplier(): ?Supplier
{ {
return $this->supplier; return $this->supplier;
@@ -269,14 +240,14 @@ class Bovine
return $this; 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; return $this;
} }

View File

@@ -51,11 +51,11 @@ class BovineType
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[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; private ?string $label = null;
#[ORM\Column(length: 50)] #[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; private ?string $code = null;
public function getId(): ?int public function getId(): ?int

View File

@@ -13,59 +13,11 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
final class BovineRepository extends ServiceEntityRepository 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) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Bovine::class); 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')
->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. * Compteurs des bovins actifs par tranche d'âge.
* *

View File

@@ -7,18 +7,15 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Entity\Bovine; use App\Entity\Bovine;
use App\Repository\BovineRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\RichText\RichText;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup; use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
@@ -26,62 +23,44 @@ use Symfony\Component\HttpFoundation\Response;
*/ */
final class BovineInventoryExportProvider implements ProviderInterface 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_RED = 'FFFCA5A5';
private const COLOR_ORANGE = 'FFFDBA74'; private const COLOR_ORANGE = 'FFFDBA74';
private const COLOR_YELLOW = 'FFFEF08A'; private const COLOR_YELLOW = 'FFFDE047';
private const BREED_CODE_LIMOUSINE = '34'; private const HEADERS = [
'N° National',
private const BREED_CODE_CHAROLAISE = '38'; 'N° Travail',
'Sexe',
/** 'Né le',
* Largeurs de colonnes (A à R). 'Age (mois)',
*/ 'Race',
private const COLUMN_WIDTHS = [ 'Bâtiment',
'A' => 3.7, 'Case',
'B' => 6.3, 'Entrée le',
'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 COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
public function __construct( public function __construct(
private BovineRepository $bovineRepository, private EntityManagerInterface $em,
private RequestStack $requestStack,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{ {
$request = $this->requestStack->getCurrentRequest(); $bovines = $this->em->createQueryBuilder()
$raw = (string) ($request?->query->get('ageRanges') ?? ''); ->select('b')
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); ->from(Bovine::class, 'b')
->where('b.exitedAt IS NULL')
->orderBy('b.birthDate', 'ASC')
->getQuery()
->getResult()
;
$bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges); $spreadsheet = $this->buildSpreadsheet($bovines);
$bovines = $this->sortBovines($bovines);
$spreadsheet = $this->buildSpreadsheet($bovines, $ageRanges);
$body = $this->renderXlsx($spreadsheet); $body = $this->renderXlsx($spreadsheet);
$filename = sprintf('inventaire_bovins_%s.xlsx', new DateTimeImmutable()->format('Y-m-d')); $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 * @param list<Bovine> $bovines
*
* @return list<Bovine>
*/ */
private function sortBovines(array $bovines): array private function buildSpreadsheet(array $bovines): Spreadsheet
{
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
{ {
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Inventaire');
// Police par défaut sur tout le classeur (en-têtes + data) // Header row
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11); foreach (self::HEADERS as $index => $label) {
$sheet->setCellValue([$index + 1, 1], $label);
}
$sheet = $spreadsheet->getActiveSheet(); $lastColumn = $sheet->getHighestColumn();
$sheet->setTitle('Alerte_Taurillons'); $headerRange = sprintf('A1:%s1', $lastColumn);
$sheet->getStyle($headerRange)->applyFromArray([
// Configuration impression : A4 paysage, ajusté à 1 page de large, 'font' => ['bold' => true],
// lignes 3 et 4 (sous-titre + en-têtes) répétées en haut de chaque page. 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
$pageSetup = $sheet->getPageSetup(); 'fill' => [
$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' => [
'fillType' => Fill::FILL_SOLID, 'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => self::HEADER_FILL], 'startColor' => ['argb' => self::HEADER_FILL],
], ],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [ 'borders' => [
'top' => ['borderStyle' => Border::BORDER_MEDIUM], 'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'right' => ['borderStyle' => Border::BORDER_MEDIUM],
'left' => ['borderStyle' => Border::BORDER_MEDIUM],
], ],
]); ]);
$sheet->getRowDimension(3)->setRowHeight(24.75);
// Ligne 4 : en-têtes // Column widths
$headers = [ foreach (self::COLUMN_WIDTHS as $index => $width) {
'A' => 'Limousin', $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
'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);
} }
// Lignes de données // N° National et N° Travail : valeurs numériques mais conservées en string
$rowNumber = 5; // (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) { 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; ++$rowNumber;
} }
// Bordures sur l'ensemble du tableau (header + data) // Freeze header row + auto-filter
$lastDataRow = $rowNumber - 1; $sheet->freezePane('A2');
if ($lastDataRow >= 4) { if ($rowNumber > 2) {
$range = 'A4:R'.$lastDataRow; $sheet->setAutoFilter(sprintf('%s:%s%d', 'A1', $lastColumn, $rowNumber - 1));
$sheet->getStyle($range)->getBorders()->applyFromArray([ } else {
'allBorders' => ['borderStyle' => Border::BORDER_THIN], $sheet->setAutoFilter($headerRange);
'top' => ['borderStyle' => Border::BORDER_MEDIUM],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
} }
return $spreadsheet; 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(); return [
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode(); $bovine->getNationalNumber(),
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode(); $bovine->getWorkNumber(),
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding(); $this->formatSex($bovine->getSex()),
$code = $building?->getCode(); $this->formatDate($bovine->getBirthDate()),
$bovine->getAgeMonths(),
$sheet->setCellValue('A'.$row, $isLim ? 'X' : ''); $bovine->getBreedCode(),
$sheet->setCellValue('B'.$row, null !== $bovine->getWorkNumber() && ctype_digit($bovine->getWorkNumber()) $bovine->getBuildingCase()?->getIdBuilding()?->getLabel(),
? (int) $bovine->getWorkNumber() $bovine->getBuildingCase()?->getCaseNumber(),
: ($bovine->getWorkNumber() ?? '')); $this->formatDate($bovine->getArrivalDate()),
$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)
;
}
} }
/** private function formatSex(?string $sex): ?string
* Sous-titre dynamique selon les tranches d'âge cochées.
*
* @param list<string> $ageRanges
*/
private function computeSubtitle(array $ageRanges): string
{ {
$selected = array_values(array_intersect( return match ($sex) {
$ageRanges, 'M' => 'Mâle',
[ 'F' => 'Femelle',
BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, default => $sex,
BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, };
BovineRepository::AGE_RANGE_OVER_24, }
]
));
$hasLow = in_array(BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, $selected, true); private function formatDate(?DateTimeImmutable $date): ?string
$hasMid = in_array(BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, $selected, true); {
$hasHigh = in_array(BovineRepository::AGE_RANGE_OVER_24, $selected, true); return $date?->format('d/m/Y');
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 ageColor(?int $ageMonths): ?string private function ageColor(?int $ageMonths): ?string

View File

@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult; use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine; use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface; use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
@@ -19,11 +18,6 @@ use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
*/ */
final class BovineSyncInventoryProcessor implements ProcessorInterface final class BovineSyncInventoryProcessor implements ProcessorInterface
{ {
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct( public function __construct(
private BovinApiInterface $bovinApi, private BovinApiInterface $bovinApi,
private EntityManagerInterface $em, private EntityManagerInterface $em,
@@ -40,13 +34,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$result = new BovineSyncInventoryResult(); $result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals); $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 = []; $existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) { foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine; $existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
@@ -96,7 +83,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$identification = $animal->identification; $identification = $animal->identification;
if (null !== $identification) { if (null !== $identification) {
$bovine->setSex($identification->sex); $bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType)); $bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber); $bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date); $bovine->setBirthDate($identification->birthDate?->date);
} }
@@ -115,28 +102,4 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setExitDate($latestExit); $bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths(); $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; continue;
} }
$breedCode = $bovine->getBovineType()?->getCode(); $breedCode = $bovine->getBreedCode();
if (null === $headerBreedCode && null !== $breedCode) { if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode; $headerBreedCode = $breedCode;
} }