feat: amélioration de l'export inventaire bovin (!51)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
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>
This commit was merged in pull request #51.
This commit is contained in:
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
96
frontend/components/inventory/inventory-export-modal.vue
Normal 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é : 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>
|
||||
96
frontend/components/ui/UiModal.vue
Normal file
96
frontend/components/ui/UiModal.vue
Normal 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>
|
||||
@@ -17,7 +17,7 @@
|
||||
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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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' => "N°\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
|
||||
|
||||
Reference in New Issue
Block a user