From eccb8e1fc64c53ba93e900ad6a168a25b02929ae Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 13:49:33 +0000 Subject: [PATCH] =?UTF-8?q?[#FER-22]=20Pouvoir=20exporter=20les=20r=C3=A9c?= =?UTF-8?q?eptions/exp=C3=A9ditions=20fines=20en=20Excel=20(!56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) | 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: https://gitea.malio.fr/MALIO-DEV/Ferme/pulls/56 Co-authored-by: tristan Co-committed-by: tristan --- CHANGELOG.md | 1 + frontend/pages/reception/finish-reception.vue | 35 ++ frontend/pages/shipment/finish-shipment.vue | 35 ++ src/ApiResource/ReceptionExport.php | 32 ++ src/ApiResource/ShipmentExport.php | 32 ++ src/Entity/Reception.php | 3 +- src/Entity/Shipment.php | 3 +- src/Repository/ReceptionRepository.php | 52 +++ src/Repository/ShipmentRepository.php | 46 +++ .../Reception/ReceptionExportProvider.php | 358 ++++++++++++++++++ src/State/Shipment/ShipmentExportProvider.php | 299 +++++++++++++++ 11 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 src/ApiResource/ReceptionExport.php create mode 100644 src/ApiResource/ShipmentExport.php create mode 100644 src/Repository/ReceptionRepository.php create mode 100644 src/Repository/ShipmentRepository.php create mode 100644 src/State/Reception/ReceptionExportProvider.php create mode 100644 src/State/Shipment/ShipmentExportProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c8444..98467a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Ajouter dans le fichier .env du frontend * [#FER-26] Passeport du bovin * [#FER-27] Fix export inventaire bovin * [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin +* [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel ### Changed diff --git a/frontend/pages/reception/finish-reception.vue b/frontend/pages/reception/finish-reception.vue index 4bc66c4..71f8a78 100644 --- a/frontend/pages/reception/finish-reception.vue +++ b/frontend/pages/reception/finish-reception.vue @@ -2,6 +2,15 @@

liste des réceptions finies

+
+ +
@@ -79,9 +88,35 @@ import type { ReceptionData } from '~/services/dto/reception-data' import type { ReceptionTypeData } from '~/services/dto/reception-type-data' import { getReceptionTypeList } from '~/services/reception-type' import { useDataTableServerState } from '~/composables/useDataTableServerState' +import { useAuthStore } from '~/stores/auth' const router = useRouter() +const auth = useAuthStore() +const api = useApi() const receptionTypes = ref([]) +const exporting = ref(false) + +const exportReceptions = async () => { + if (exporting.value) return + exporting.value = true + try { + const blob = await api.getBlob('receptions/export') + const filename = `receptions_${new Date().toISOString().slice(0, 10)}.xlsx` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.style.display = 'none' + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 60_000) + } catch { + // toast déjà géré par useApi onResponseError + } finally { + exporting.value = false + } +} const receptionTypeOptions = computed(() => receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label })) diff --git a/frontend/pages/shipment/finish-shipment.vue b/frontend/pages/shipment/finish-shipment.vue index 80ec0c3..81295c9 100644 --- a/frontend/pages/shipment/finish-shipment.vue +++ b/frontend/pages/shipment/finish-shipment.vue @@ -2,6 +2,15 @@

liste des expéditions finies

+
+ +
@@ -77,9 +86,35 @@ import type { ShipmentData } from '~/services/dto/shipment-data' import type { ShipmentTypeData } from '~/services/dto/shipment-type-data' import { getShipmentTypeList } from '~/services/shipment-type' import { useDataTableServerState } from '~/composables/useDataTableServerState' +import { useAuthStore } from '~/stores/auth' const router = useRouter() +const auth = useAuthStore() +const api = useApi() const shipmentTypes = ref([]) +const exporting = ref(false) + +const exportShipments = async () => { + if (exporting.value) return + exporting.value = true + try { + const blob = await api.getBlob('shipments/export') + const filename = `expeditions_${new Date().toISOString().slice(0, 10)}.xlsx` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.style.display = 'none' + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 60_000) + } catch { + // toast déjà géré par useApi onResponseError + } finally { + exporting.value = false + } +} const shipmentTypeOptions = computed(() => shipmentTypes.value.map(st => ({ value: st.id, label: st.label })) diff --git a/src/ApiResource/ReceptionExport.php b/src/ApiResource/ReceptionExport.php new file mode 100644 index 0000000..dc72667 --- /dev/null +++ b/src/ApiResource/ReceptionExport.php @@ -0,0 +1,32 @@ + + */ +final class ReceptionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Reception::class); + } + + /** + * Liste des réceptions validées pour l'export Excel (de la plus récente à la plus ancienne). + * + * @return list + */ + public function findValidatedForExport(): array + { + return $this->createQueryBuilder('r') + ->leftJoin('r.supplier', 'supplier')->addSelect('supplier') + ->leftJoin('supplier.addresses', 'supplierAddresses')->addSelect('supplierAddresses') + ->leftJoin('r.address', 'address')->addSelect('address') + ->leftJoin('r.carrier', 'carrier')->addSelect('carrier') + ->leftJoin('r.driver', 'driver')->addSelect('driver') + ->leftJoin('r.truck', 'truck')->addSelect('truck') + ->leftJoin('r.user', 'user')->addSelect('user') + ->leftJoin('r.receptionType', 'receptionType')->addSelect('receptionType') + ->leftJoin('r.merchandiseType', 'merchandiseType')->addSelect('merchandiseType') + ->leftJoin('r.weights', 'weights')->addSelect('weights') + ->leftJoin('r.bovines_types', 'bovinesTypes')->addSelect('bovinesTypes') + ->leftJoin('bovinesTypes.bovineType', 'bovineType')->addSelect('bovineType') + ->leftJoin('r.pelletBuildings', 'pelletBuildings')->addSelect('pelletBuildings') + ->leftJoin('pelletBuildings.pelletType', 'pelletType')->addSelect('pelletType') + ->leftJoin('pelletBuildings.building', 'pelletBuilding')->addSelect('pelletBuilding') + ->where('r.isValid = :valid') + ->setParameter('valid', true) + ->orderBy('r.receptionDate', 'DESC') + ->addOrderBy('r.id', 'DESC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Repository/ShipmentRepository.php b/src/Repository/ShipmentRepository.php new file mode 100644 index 0000000..5d2b9fb --- /dev/null +++ b/src/Repository/ShipmentRepository.php @@ -0,0 +1,46 @@ + + */ +final class ShipmentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Shipment::class); + } + + /** + * Liste des expéditions validées pour l'export Excel (de la plus récente à la plus ancienne). + * + * @return list + */ + public function findValidatedForExport(): array + { + return $this->createQueryBuilder('s') + ->leftJoin('s.customer', 'customer')->addSelect('customer') + ->leftJoin('customer.addresses', 'customerAddresses')->addSelect('customerAddresses') + ->leftJoin('s.address', 'address')->addSelect('address') + ->leftJoin('s.carrier', 'carrier')->addSelect('carrier') + ->leftJoin('s.driver', 'driver')->addSelect('driver') + ->leftJoin('s.truck', 'truck')->addSelect('truck') + ->leftJoin('s.user', 'user')->addSelect('user') + ->leftJoin('s.shipmentType', 'shipmentType')->addSelect('shipmentType') + ->leftJoin('s.weights', 'weights')->addSelect('weights') + ->where('s.isValid = :valid') + ->setParameter('valid', true) + ->orderBy('s.shipmentDate', 'DESC') + ->addOrderBy('s.id', 'DESC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/State/Reception/ReceptionExportProvider.php b/src/State/Reception/ReceptionExportProvider.php new file mode 100644 index 0000000..faee028 --- /dev/null +++ b/src/State/Reception/ReceptionExportProvider.php @@ -0,0 +1,358 @@ + + */ +final readonly class ReceptionExportProvider implements ProviderInterface +{ + private const FARM_NAME = 'FERME SCEA LES NAUDS'; + + private const HEADER_FILL = 'FFCCECFF'; + + /** + * Largeurs de colonnes (A à V). + */ + private const COLUMN_WIDTHS = [ + 'A' => 12.0, + 'B' => 11.0, + 'C' => 7.0, + 'D' => 14.0, + 'E' => 12.0, + 'F' => 22.0, + 'G' => 30.0, + 'H' => 30.0, + 'I' => 18.0, + 'J' => 8.0, + 'K' => 18.0, + 'L' => 14.0, + 'M' => 12.0, + 'N' => 16.0, + 'O' => 22.0, + 'P' => 11.0, + 'Q' => 11.0, + 'R' => 11.0, + 'S' => 22.0, + 'T' => 26.0, + 'U' => 9.0, + 'V' => 26.0, + ]; + + public function __construct( + private ReceptionRepository $receptionRepository, + private LoggerInterface $logger, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response + { + $receptions = $this->receptionRepository->findValidatedForExport(); + + $spreadsheet = $this->buildSpreadsheet($receptions); + $body = $this->renderXlsx($spreadsheet); + $filename = sprintf('receptions_%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; + } + + /** + * @param list $receptions + */ + private function buildSpreadsheet(array $receptions): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Receptions'); + + $pageSetup = $sheet->getPageSetup(); + $pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4); + $pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); + $pageSetup->setFitToWidth(1); + $pageSetup->setFitToHeight(0); + $pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2); + $sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3); + + // Ligne 1 : titre + date + $sheet->setCellValue('A1', sprintf('%s — RÉCEPTIONS TERMINÉES', self::FARM_NAME)); + $sheet->mergeCells('A1:U1'); + $sheet->getStyle('A1:U1')->applyFromArray([ + 'font' => [ + 'name' => 'Arial Black', + 'size' => 16, + 'bold' => true, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + $sheet->setCellValue('V1', ExcelDate::PHPToExcel(new DateTimeImmutable())); + $sheet->getStyle('V1')->getNumberFormat()->setFormatCode('dd/mm/yyyy'); + $sheet->getStyle('V1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER); + $sheet->getStyle('V1')->getFont()->setSize(12)->setBold(true); + $sheet->getRowDimension(1)->setRowHeight(26.0); + $sheet->getStyle('A1:V1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK); + + // Ligne 2 : en-têtes + $headers = [ + 'A' => 'N° identification', + 'B' => 'Date', + 'C' => 'Heure', + 'D' => 'Type réception', + 'E' => 'Utilisateur', + 'F' => 'Fournisseur', + 'G' => 'Adresse fournisseur', + 'H' => 'Adresse réception', + 'I' => 'Transporteur', + 'J' => 'Code trans.', + 'K' => 'Chauffeur', + 'L' => 'Camion', + 'M' => 'Plaque', + 'N' => 'Type marchandise', + 'O' => 'Détail marchandise', + 'P' => 'Brut (kg)', + 'Q' => 'Tare (kg)', + 'R' => 'Net (kg)', + 'S' => 'Détail bovins', + 'T' => 'Bovins par type', + 'U' => 'Total bovins', + 'V' => 'Granulés / bâtiments', + ]; + foreach ($headers as $col => $value) { + $sheet->setCellValue($col.'2', $value); + } + $sheet->getRowDimension(2)->setRowHeight(32.0); + $sheet->getStyle('A2:V2')->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], + ], + ]); + + foreach (self::COLUMN_WIDTHS as $col => $width) { + $sheet->getColumnDimension($col)->setWidth($width); + } + + // Données + $row = 3; + foreach ($receptions as $reception) { + try { + $this->writeReceptionRow($sheet, $row, $reception); + } catch (Throwable $e) { + $this->logger->warning('Export réceptions : ligne ignorée suite à une erreur.', [ + 'receptionId' => $reception->getId(), + 'identificationNumber' => $reception->getIdentificationNumber(), + 'row' => $row, + 'exception' => $e, + ]); + } + ++$row; + } + + $lastRow = $row - 1; + if ($lastRow >= 2) { + $sheet->getStyle('A2:V'.$lastRow)->getBorders()->applyFromArray([ + 'allBorders' => ['borderStyle' => Border::BORDER_THIN], + 'outline' => ['borderStyle' => Border::BORDER_MEDIUM], + ]); + } + + $sheet->freezePane('A3'); + + return $spreadsheet; + } + + private function writeReceptionRow(Worksheet $sheet, int $row, Reception $reception): void + { + $sheet->setCellValue('A'.$row, $reception->getIdentificationNumber() ?? ''); + + $date = $reception->getReceptionDate(); + if (null !== $date) { + $excelDate = $this->safePhpToExcel($date); + if (null !== $excelDate) { + $sheet->setCellValue('B'.$row, $excelDate); + $sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy'); + } + $sheet->setCellValue('C'.$row, $date->format('H:i')); + } + + $sheet->setCellValue('D'.$row, $reception->getReceptionType()?->getLabel() ?? ''); + $sheet->setCellValue('E'.$row, $reception->getUser()?->getUsername() ?? ''); + + $supplier = $reception->getSupplier(); + $sheet->setCellValue('F'.$row, $supplier?->getName() ?? ''); + $sheet->setCellValue('G'.$row, $this->formatAddresses($supplier?->getAddresses())); + $sheet->setCellValue('H'.$row, $reception->getAddress()?->getFullAddress() ?? ''); + + $carrier = $reception->getCarrier(); + $sheet->setCellValue('I'.$row, $carrier?->getName() ?? ''); + $sheet->setCellValue('J'.$row, $carrier?->getCode() ?? ''); + $sheet->setCellValue('K'.$row, $reception->getDriver()?->getName() ?? ''); + $sheet->setCellValue('L'.$row, $reception->getTruck()?->getName() ?? ''); + $sheet->setCellValue('M'.$row, $reception->getLicensePlate() ?? ''); + + $sheet->setCellValue('N'.$row, $reception->getMerchandiseType()?->getLabel() ?? ''); + $sheet->setCellValue('O'.$row, $reception->getMerchandiseDetail() ?? ''); + + $gross = $this->extractWeight($reception->getWeights(), 'gross'); + $tare = $this->extractWeight($reception->getWeights(), 'tare'); + if (null !== $gross) { + $sheet->setCellValue('P'.$row, $gross); + } + if (null !== $tare) { + $sheet->setCellValue('Q'.$row, $tare); + } + if (null !== $gross && null !== $tare) { + $sheet->setCellValue('R'.$row, $gross - $tare); + } + $sheet->getStyle('P'.$row.':R'.$row)->getNumberFormat()->setFormatCode('#,##0'); + + $sheet->setCellValue('S'.$row, $reception->getBovineDetail() ?? ''); + + [$bovinesText, $bovinesTotal] = $this->formatBovineTypes($reception); + $sheet->setCellValue('T'.$row, $bovinesText); + if (null !== $bovinesTotal) { + $sheet->setCellValue('U'.$row, $bovinesTotal); + } + $sheet->setCellValue('V'.$row, $this->formatPelletBuildings($reception)); + + // Alignements + $sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('P'.$row.':R'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); + $sheet->getStyle('U'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true); + $sheet->getStyle('O'.$row)->getAlignment()->setWrapText(true); + $sheet->getStyle('S'.$row.':V'.$row)->getAlignment()->setWrapText(true); + } + + /** + * @return array{0: string, 1: ?int} [texte concaténé, total] + */ + private function formatBovineTypes(Reception $reception): array + { + $parts = []; + $total = 0; + $found = false; + foreach ($reception->getBovinesTypes() as $rb) { + $label = $rb->getBovineType()?->getLabel(); + $qty = $rb->getQuantity(); + if (null === $label && null === $qty) { + continue; + } + $parts[] = sprintf('%s : %d', $label ?? '—', $qty ?? 0); + $total += $qty ?? 0; + $found = true; + } + + return [implode(', ', $parts), $found ? $total : null]; + } + + private function formatPelletBuildings(Reception $reception): string + { + $parts = []; + foreach ($reception->getPelletBuildings() as $pb) { + $pellet = $pb->getPelletType()?->getLabel(); + $building = $pb->getBuilding()?->getLabel() ?? $pb->getBuilding()?->getCode(); + if (null === $pellet && null === $building) { + continue; + } + $parts[] = sprintf('%s (%s)', $pellet ?? '—', $building ?? '—'); + } + + return implode(', ', $parts); + } + + /** + * @param null|iterable
$addresses + */ + private function formatAddresses(?iterable $addresses): string + { + if (null === $addresses) { + return ''; + } + + $parts = []; + foreach ($addresses as $address) { + $full = $address->getFullAddress(); + if ('' !== $full) { + $parts[] = $full; + } + } + + return implode(' ; ', $parts); + } + + /** + * @param iterable $weights + */ + private function extractWeight(iterable $weights, string $type): ?int + { + foreach ($weights as $weight) { + if ($weight->getType() === $type) { + return $weight->getWeight(); + } + } + + return null; + } + + private function safePhpToExcel(?DateTimeImmutable $date): ?float + { + if (null === $date) { + return null; + } + + try { + $value = ExcelDate::PHPToExcel($date); + } catch (Throwable) { + return null; + } + + return is_float($value) ? $value : 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 : ''; + } +} diff --git a/src/State/Shipment/ShipmentExportProvider.php b/src/State/Shipment/ShipmentExportProvider.php new file mode 100644 index 0000000..86f1f8b --- /dev/null +++ b/src/State/Shipment/ShipmentExportProvider.php @@ -0,0 +1,299 @@ + + */ +final readonly class ShipmentExportProvider implements ProviderInterface +{ + private const FARM_NAME = 'FERME SCEA LES NAUDS'; + + private const HEADER_FILL = 'FFCCECFF'; + + /** + * Largeurs de colonnes (A à Q). + */ + private const COLUMN_WIDTHS = [ + 'A' => 12.0, + 'B' => 11.0, + 'C' => 7.0, + 'D' => 16.0, + 'E' => 12.0, + 'F' => 22.0, + 'G' => 30.0, + 'H' => 30.0, + 'I' => 18.0, + 'J' => 8.0, + 'K' => 18.0, + 'L' => 14.0, + 'M' => 12.0, + 'N' => 11.0, + 'O' => 11.0, + 'P' => 11.0, + 'Q' => 13.0, + ]; + + public function __construct( + private ShipmentRepository $shipmentRepository, + private LoggerInterface $logger, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response + { + $shipments = $this->shipmentRepository->findValidatedForExport(); + + $spreadsheet = $this->buildSpreadsheet($shipments); + $body = $this->renderXlsx($spreadsheet); + $filename = sprintf('expeditions_%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; + } + + /** + * @param list $shipments + */ + private function buildSpreadsheet(array $shipments): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Expeditions'); + + $pageSetup = $sheet->getPageSetup(); + $pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4); + $pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); + $pageSetup->setFitToWidth(1); + $pageSetup->setFitToHeight(0); + $pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2); + $sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3); + + // Ligne 1 : titre + date + $sheet->setCellValue('A1', sprintf('%s — EXPÉDITIONS TERMINÉES', self::FARM_NAME)); + $sheet->mergeCells('A1:P1'); + $sheet->getStyle('A1:P1')->applyFromArray([ + 'font' => [ + 'name' => 'Arial Black', + 'size' => 16, + 'bold' => true, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + $sheet->setCellValue('Q1', ExcelDate::PHPToExcel(new DateTimeImmutable())); + $sheet->getStyle('Q1')->getNumberFormat()->setFormatCode('dd/mm/yyyy'); + $sheet->getStyle('Q1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER); + $sheet->getStyle('Q1')->getFont()->setSize(12)->setBold(true); + $sheet->getRowDimension(1)->setRowHeight(26.0); + $sheet->getStyle('A1:Q1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK); + + // Ligne 2 : en-têtes + $headers = [ + 'A' => 'N° identification', + 'B' => 'Date', + 'C' => 'Heure', + 'D' => "Type d'expédition", + 'E' => 'Utilisateur', + 'F' => 'Client', + 'G' => 'Adresse client', + 'H' => 'Adresse expédition', + 'I' => 'Transporteur', + 'J' => 'Code trans.', + 'K' => 'Chauffeur', + 'L' => 'Camion', + 'M' => 'Plaque', + 'N' => 'Brut (kg)', + 'O' => 'Tare (kg)', + 'P' => 'Net (kg)', + 'Q' => 'Nb bovins', + ]; + foreach ($headers as $col => $value) { + $sheet->setCellValue($col.'2', $value); + } + $sheet->getRowDimension(2)->setRowHeight(32.0); + $sheet->getStyle('A2:Q2')->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], + ], + ]); + + foreach (self::COLUMN_WIDTHS as $col => $width) { + $sheet->getColumnDimension($col)->setWidth($width); + } + + // Données + $row = 3; + foreach ($shipments as $shipment) { + try { + $this->writeShipmentRow($sheet, $row, $shipment); + } catch (Throwable $e) { + $this->logger->warning('Export expéditions : ligne ignorée suite à une erreur.', [ + 'shipmentId' => $shipment->getId(), + 'identificationNumber' => $shipment->getIdentificationNumber(), + 'row' => $row, + 'exception' => $e, + ]); + } + ++$row; + } + + $lastRow = $row - 1; + if ($lastRow >= 2) { + $sheet->getStyle('A2:Q'.$lastRow)->getBorders()->applyFromArray([ + 'allBorders' => ['borderStyle' => Border::BORDER_THIN], + 'outline' => ['borderStyle' => Border::BORDER_MEDIUM], + ]); + } + + $sheet->freezePane('A3'); + + return $spreadsheet; + } + + private function writeShipmentRow(Worksheet $sheet, int $row, Shipment $shipment): void + { + $sheet->setCellValue('A'.$row, $shipment->getIdentificationNumber() ?? ''); + + $date = $shipment->getShipmentDate(); + if (null !== $date) { + $excelDate = $this->safePhpToExcel($date); + if (null !== $excelDate) { + $sheet->setCellValue('B'.$row, $excelDate); + $sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy'); + } + $sheet->setCellValue('C'.$row, $date->format('H:i')); + } + + $sheet->setCellValue('D'.$row, $shipment->getShipmentType()?->getLabel() ?? ''); + $sheet->setCellValue('E'.$row, $shipment->getUser()?->getUsername() ?? ''); + + $customer = $shipment->getCustomer(); + $sheet->setCellValue('F'.$row, $customer?->getName() ?? ''); + $sheet->setCellValue('G'.$row, $this->formatAddresses($customer?->getAddresses())); + $sheet->setCellValue('H'.$row, $shipment->getAddress()?->getFullAddress() ?? ''); + + $carrier = $shipment->getCarrier(); + $sheet->setCellValue('I'.$row, $carrier?->getName() ?? ''); + $sheet->setCellValue('J'.$row, $carrier?->getCode() ?? ''); + $sheet->setCellValue('K'.$row, $shipment->getDriver()?->getName() ?? ''); + $sheet->setCellValue('L'.$row, $shipment->getTruck()?->getName() ?? ''); + $sheet->setCellValue('M'.$row, $shipment->getLicensePlate() ?? ''); + + $gross = $this->extractWeight($shipment->getWeights(), 'gross'); + $tare = $this->extractWeight($shipment->getWeights(), 'tare'); + if (null !== $gross) { + $sheet->setCellValue('N'.$row, $gross); + } + if (null !== $tare) { + $sheet->setCellValue('O'.$row, $tare); + } + if (null !== $gross && null !== $tare) { + $sheet->setCellValue('P'.$row, $gross - $tare); + } + $sheet->getStyle('N'.$row.':P'.$row)->getNumberFormat()->setFormatCode('#,##0'); + + $sheet->setCellValue('Q'.$row, $shipment->getNbBovinSend()); + + // Alignements + $sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('N'.$row.':P'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); + $sheet->getStyle('Q'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true); + } + + /** + * @param null|iterable
$addresses + */ + private function formatAddresses(?iterable $addresses): string + { + if (null === $addresses) { + return ''; + } + + $parts = []; + foreach ($addresses as $address) { + $full = $address->getFullAddress(); + if ('' !== $full) { + $parts[] = $full; + } + } + + return implode(' ; ', $parts); + } + + /** + * @param iterable $weights + */ + private function extractWeight(iterable $weights, string $type): ?int + { + foreach ($weights as $weight) { + if ($weight->getType() === $type) { + return $weight->getWeight(); + } + } + + return null; + } + + private function safePhpToExcel(?DateTimeImmutable $date): ?float + { + if (null === $date) { + return null; + } + + try { + $value = ExcelDate::PHPToExcel($date); + } catch (Throwable) { + return null; + } + + return is_float($value) ? $value : 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 : ''; + } +}