*/ final class BovineInventoryExportProvider implements ProviderInterface { 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 = 'FFFEF08A'; 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, ]; public function __construct( private BovineRepository $bovineRepository, private RequestStack $requestStack, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response { $request = $this->requestStack->getCurrentRequest(); $raw = (string) ($request?->query->get('ageRanges') ?? ''); $ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); $bovines = $this->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')); $response = new Response($body); $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); $response->headers->set('Content-Length', (string) strlen($body)); return $response; } /** * Tri par âge décroissant puis race (Limousine d'abord, puis Charolaise, puis autres). * * @param list $bovines * * @return list */ 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 $bovines * @param list $ageRanges */ private function buildSpreadsheet(array $bovines, array $ageRanges): Spreadsheet { $spreadsheet = new Spreadsheet(); // Police par défaut sur tout le classeur (en-têtes + data) $spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11); $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' => [ 'top' => ['borderStyle' => Border::BORDER_MEDIUM], 'right' => ['borderStyle' => Border::BORDER_MEDIUM], 'left' => ['borderStyle' => Border::BORDER_MEDIUM], ], ]); $sheet->getRowDimension(3)->setRowHeight(24.75); // 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); } // Lignes de données $rowNumber = 5; foreach ($bovines as $bovine) { $this->writeBovineRow($sheet, $rowNumber, $bovine); ++$rowNumber; } // Bordures sur l'ensemble du tableau (header + data) $lastDataRow = $rowNumber - 1; if ($lastDataRow >= 4) { $range = 'A4:R'.$lastDataRow; $sheet->getStyle($range)->getBorders()->applyFromArray([ 'allBorders' => ['borderStyle' => Border::BORDER_THIN], 'top' => ['borderStyle' => Border::BORDER_MEDIUM], 'outline' => ['borderStyle' => Border::BORDER_MEDIUM], ]); } 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) ; } } /** * Sous-titre dynamique selon les tranches d'âge cochées. * * @param list $ageRanges */ private function computeSubtitle(array $ageRanges): string { $selected = array_values(array_intersect( $ageRanges, [ BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, BovineRepository::AGE_RANGE_OVER_24, ] )); $hasLow = in_array(BovineRepository::AGE_RANGE_BETWEEN_20_AND_22, $selected, true); $hasMid = in_array(BovineRepository::AGE_RANGE_BETWEEN_22_AND_24, $selected, true); $hasHigh = in_array(BovineRepository::AGE_RANGE_OVER_24, $selected, true); if ([] === $selected) { return 'Inventaire complet'; } if ($hasLow && $hasMid && $hasHigh) { return 'Âge SUPÉRIEUR ou ÉGAL à 20 MOIS'; } if ($hasMid && $hasHigh && !$hasLow) { return 'Âge SUPÉRIEUR ou ÉGAL à 22 MOIS'; } if ($hasHigh && !$hasMid && !$hasLow) { return 'Âge SUPÉRIEUR ou ÉGAL à 24 MOIS'; } if ($hasLow && $hasMid && !$hasHigh) { return 'Âge entre 20 et 24 MOIS'; } if ($hasMid && !$hasLow && !$hasHigh) { return 'Âge entre 22 et 24 MOIS'; } if ($hasLow && !$hasMid && !$hasHigh) { return 'Âge entre 20 et 22 MOIS'; } // Sélection non contiguë (ex: low + high sans mid) → liste explicite $parts = []; if ($hasLow) { $parts[] = '20 à 22 mois'; } if ($hasMid) { $parts[] = '22 à 24 mois'; } if ($hasHigh) { $parts[] = '≥ 24 mois'; } return 'Tranches d\'âge : '.implode(' / ', $parts); } private function ageColor(?int $ageMonths): ?string { if (null === $ageMonths) { return null; } if ($ageMonths >= 24) { return self::COLOR_RED; } if ($ageMonths >= 22) { return self::COLOR_ORANGE; } if ($ageMonths >= 20) { return self::COLOR_YELLOW; } return null; } private function renderXlsx(Spreadsheet $spreadsheet): string { $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); ob_start(); $writer->save('php://output'); $body = ob_get_clean(); return false !== $body ? $body : ''; } }