- L : Age entrée sur 2 lignes (largeur 5.4 -> 7.5) - M : Poids (kg) au lieu de Poids kg - N : Prix du kg sur 2 lignes (largeur 8.4 -> 6.0) - P : Age du jour au lieu de Age mois Aujourd'hui - Q : Trpt au lieu de Tport (largeur 4.0 -> 5.0 pour tenir sur 1 ligne) - Sous-titre : ajout bordure left Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
432 lines
16 KiB
PHP
432 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State\Bovin;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\Entity\Bovine;
|
|
use App\Repository\BovineRepository;
|
|
use DateTimeImmutable;
|
|
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\Worksheet\PageSetup;
|
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* @implements ProviderInterface<Response>
|
|
*/
|
|
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<Bovine> $bovines
|
|
*
|
|
* @return list<Bovine>
|
|
*/
|
|
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();
|
|
|
|
// 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<string> $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 : '';
|
|
}
|
|
}
|