From 33e0f124cbaa5864209d581d58e06e52bda4f913 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 28 Apr 2026 11:38:59 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20refonte=20du=20style=20de=20l'export?= =?UTF-8?q?=20taurillons=20pour=20matcher=20le=20template=20m=C3=A9tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Police Aptos Narrow par défaut sur tout le classeur - Titre A1 en RichText (Arial Black 18 noir + 20 rouge pour l'année) - Date R1 en Aptos Narrow 14 gras - Sous-titre A3 fusionné A3:R3, dynamique selon les filtres, bordures top + right - Bordure thick en bas du bloc titre (A1:R1) - En-têtes A/B/C avec rotation 60° et wrap désactivé - Couleur d'âge appliquée uniquement sur la colonne P (Age mois Aujourd'hui) - Couleurs pastel red-300 / orange-300 / yellow-200 - Tri âge desc puis race (Limousine → Charolaise → autres) - Configuration impression : A4 paysage, fit width 1 page, lignes 3-4 répétées, centré horizontalement Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Bovin/BovineInventoryExportProvider.php | 403 ++++++++++++++---- 1 file changed, 314 insertions(+), 89 deletions(-) diff --git a/src/State/Bovin/BovineInventoryExportProvider.php b/src/State/Bovin/BovineInventoryExportProvider.php index 9d9b079..65521be 100644 --- a/src/State/Bovin/BovineInventoryExportProvider.php +++ b/src/State/Bovin/BovineInventoryExportProvider.php @@ -9,13 +9,15 @@ use ApiPlatform\State\ProviderInterface; use App\Entity\Bovine; use App\Repository\BovineRepository; use DateTimeImmutable; -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; @@ -24,28 +26,47 @@ 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' => 5.4, + 'M' => 6.1, + 'N' => 8.4, + 'O' => 11.4, + 'P' => 5.7, + 'Q' => 4.0, + 'R' => 14.4, ]; - private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12]; - public function __construct( private BovineRepository $bovineRepository, private RequestStack $requestStack, @@ -58,8 +79,9 @@ final class BovineInventoryExportProvider implements ProviderInterface $ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); $bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges); + $bovines = $this->sortBovines($bovines); - $spreadsheet = $this->buildSpreadsheet($bovines); + $spreadsheet = $this->buildSpreadsheet($bovines, $ageRanges); $body = $this->renderXlsx($spreadsheet); $filename = sprintf('inventaire_bovins_%s.xlsx', new DateTimeImmutable()->format('Y-m-d')); @@ -72,107 +94,310 @@ final class BovineInventoryExportProvider implements ProviderInterface } /** + * Tri par âge décroissant puis race (Limousine d'abord, puis Charolaise, puis autres). + * * @param list $bovines + * + * @return list */ - 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 $bovines + * @param list $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], ], ]); + $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 mois\nD'Entrée", + 'M' => "Poids \nkg", + 'N' => 'Prix du kg', + 'O' => 'Total €', + 'P' => "Age mois\nAujourd'hui", + 'Q' => 'Tport', + '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 + * Sous-titre dynamique selon les tranches d'âge cochées. + * + * @param list $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