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 : '';
+ }
+}