Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions
e208bcd893 chore: bump version to v0.0.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-04-28 11:52:26 +00:00
3fe0bbf71e feat: modification de la gestion des rôles + ajout rôle d'un bureau (!52)
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
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 11:52:18 +00:00
gitea-actions
d566e5d9f7 chore: bump version to v0.0.92
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-04-28 10:03:56 +00:00
5bb0aad620 feat: amélioration de l'export inventaire bovin (!51)
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
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 10:03:50 +00:00
gitea-actions
19a29f854e chore: bump version to v0.0.91
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
2026-04-28 07:37:20 +00:00
c21dcd1869 docs : étapes détaillées pour le feed des prix bovins en prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Passe par /tmp (le user SSH n'a pas les droits sur /var/www/ferme)
- 6 étapes numérotées (scp, ssh, cd, dry-run, run, rm)
- Note sur chmod 644 si www-data ne peut pas lire

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:36:57 +02:00
18 changed files with 700 additions and 128 deletions

View File

@@ -218,18 +218,30 @@ docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bo
### Lancement en prod
```bash
# 1. Envoyer le fichier sur le serveur
scp feed_bovin.xlsx ferme-prod:/tmp/
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).
# 2. SSH sur le serveur et lancer la commande dans le dossier de l'app
ssh ferme-prod
```bash
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
scp feed_bovin.xlsx <user>@<host>:/tmp/
# 2. SSH sur le serveur
ssh <user>@<host>
# 3. Se placer dans le dossier de l'app (pour bin/console)
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
# 4. Dry-run pour vérifier sans rien écrire
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
# 5. Persistance effective
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
# 6. Cleanup
rm /tmp/feed_bovin.xlsx
```
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
### Sortie attendue
À la fin, un tableau récapitule :

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.90'
app.version: '0.0.93'

View File

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

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

@@ -18,13 +18,15 @@ export interface UseBovineColumnsOptions {
/**
* 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.
* 4 variants : avec/sans colonnes prix × inventory/case.
*
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
*/
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
const auth = useAuthStore()
const adminColumnsInventory: BovineColumn[] = [
const withPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -38,7 +40,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
]
const userColumnsInventory: BovineColumn[] = [
const withoutPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -50,7 +52,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const adminColumnsCase: BovineColumn[] = [
const withPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
@@ -62,7 +64,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const userColumnsCase: BovineColumn[] = [
const withoutPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
@@ -73,10 +75,13 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
]
const columns = computed<BovineColumn[]>(() => {
if (options.variant === 'case') {
return auth.isAdmin ? adminColumnsCase : userColumnsCase
const isCase = options.variant === 'case'
const seePrice = auth.isBureau
if (isCase) {
return seePrice ? withPricesCase : withoutPricesCase
}
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
return seePrice ? withPricesInventory : withoutPricesInventory
})
return { columns }

View File

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

View File

@@ -13,17 +13,17 @@
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
<div
v-if="auth.isAdmin"
v-if="auth.isBureau"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
title="Exporter en Excel"
@click="exportInventory"
@click="showExportModal = true"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<button
v-if="auth.isAdmin"
v-if="auth.isBureau"
type="button"
:disabled="syncing"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
@@ -136,12 +136,19 @@
</template>
</UiDataTable>
</div>
<InventoryExportModal
v-model="showExportModal"
:loading="exporting"
@submit="exportInventory"
/>
</div>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useBovineColumns } from '~/composables/useBovineColumns'
@@ -183,12 +190,17 @@ const loadStats = async () => {
const syncing = ref(false)
const exporting = ref(false)
const showExportModal = ref(false)
const exportInventory = async () => {
const exportInventory = async (filters: InventoryExportFilters) => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('bovines/inventory-export')
const query: Record<string, unknown> = {}
if (filters.ageRanges.length > 0) {
query.ageRanges = filters.ageRanges.join(',')
}
const blob = await api.getBlob('bovines/inventory-export', query)
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -199,6 +211,7 @@ const exportInventory = async () => {
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
showExportModal.value = false
} catch {
// toast déjà géré par useApi onResponseError
} finally {

View File

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

View File

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

38
frontend/utils/roles.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -81,7 +81,7 @@ class Bovine
#[ORM\Column(type: 'float', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
private ?float $pricePerKg = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
@@ -177,7 +177,7 @@ class Bovine
}
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
public function getFinalPrice(): ?float
{
if (null === $this->receivedWeight || null === $this->pricePerKg) {

View File

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

View File

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

View File

@@ -13,11 +13,59 @@ use Doctrine\Persistence\ManagerRegistry;
*/
final class BovineRepository extends ServiceEntityRepository
{
public const AGE_RANGE_OVER_24 = 'over24';
public const AGE_RANGE_BETWEEN_22_AND_24 = 'between22And24';
public const AGE_RANGE_BETWEEN_20_AND_22 = 'between20And22';
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bovine::class);
}
/**
* Liste des bovins actifs pour l'export inventaire.
*
* @param null|list<string> $ageRanges Si null/vide → tous. Sinon filtre OR sur les tranches d'âge demandées.
*
* @return list<Bovine>
*/
public function findActiveForInventoryExport(?array $ageRanges = null): array
{
$qb = $this->createQueryBuilder('b')
->where('b.exitedAt IS NULL')
->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.
*

View File

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