From f0c09d6961829827fedfeb8b506f0e5677555cd3 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 11:07:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport)=20:=20export=20XLSX=20r=C3=A9pe?= =?UTF-8?q?rtoire=20+=20prix=20transporteur=20(ERP-162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/carriers/export.xlsx (mêmes filtres que la liste : includeArchived, search, certificationType) et GET /api/carriers/{id}/prices/export.xlsx (tableau Prix regroupé Benne / Fond Mouvant). Controllers Symfony custom avec #[Route(priority: 1)] pour éviter le conflit API Platform {id}, génération déléguée au service Shared SpreadsheetExporterInterface. --- .../Controller/CarrierExportController.php | 158 ++++++++++++++++ .../CarrierPriceExportController.php | 170 ++++++++++++++++++ .../Api/CarrierExportControllerTest.php | 157 ++++++++++++++++ .../Api/CarrierPriceExportControllerTest.php | 161 +++++++++++++++++ 4 files changed, 646 insertions(+) create mode 100644 src/Module/Transport/Infrastructure/Controller/CarrierExportController.php create mode 100644 src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php create mode 100644 tests/Module/Transport/Api/CarrierExportControllerTest.php create mode 100644 tests/Module/Transport/Api/CarrierPriceExportControllerTest.php diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php new file mode 100644 index 0000000..a5c7d03 --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php @@ -0,0 +1,158 @@ +readBool($request->query->get('includeArchived')); + $search = $request->query->getString('search') ?: null; + $certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []); + + /** @var list $carriers */ + $carriers = $this->repository + ->createListQueryBuilder($includeArchived, $search, $certificationTypes) + ->getQuery() + ->getResult() + ; + + $binary = $this->exporter->export( + 'Répertoire transporteurs', + $this->buildHeaders(), + $this->buildRows($carriers), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.6). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Nom', + 'Certification', + 'Statut QUALIMAT', + 'Date de validité', + 'Affrété', + 'Volume m³', + 'Date de création', + ]; + } + + /** + * @param list $carriers + * + * @return iterable> + */ + private function buildRows(array $carriers): iterable + { + foreach ($carriers as $carrier) { + // Statut / date de validite proviennent du referentiel QUALIMAT lie + // (RG-4.04), deja fetch-joine par le repository (anti N+1, § 2.11). + $qualimat = $carrier->getQualimatCarrier(); + + yield [ + $carrier->getName(), + $carrier->getCertificationType() ?? '', + $qualimat?->getStatus() ?? '', + $qualimat?->getValidityDate()?->format('d/m/Y') ?? '', + $carrier->isChartered() ? 'Oui' : 'Non', + $carrier->getVolumeM3() ?? '', + $carrier->getCreatedAt()?->format('d/m/Y'), + ]; + } + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('repertoire-transporteurs-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + * Aligne sur CarrierProvider pour un comportement identique a la liste. + */ + private function readBool(mixed $raw): bool + { + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur CarrierProvider pour un comportement identique a la liste. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } +} diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php new file mode 100644 index 0000000..2abb99e --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php @@ -0,0 +1,170 @@ + 'Benne', 'FOND_MOUVANT' => 'Fond Mouvant']; + + private const array PRICE_STATE_LABELS = [ + 'EN_COURS' => 'En cours', + 'VALIDE' => 'Validé', + 'NON_VALIDE' => 'Non validé', + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')] + private readonly CarrierRepositoryInterface $repository, + private readonly SpreadsheetExporterInterface $exporter, + ) {} + + #[Route('/api/carriers/{id}/prices/export.xlsx', name: 'transport_carrier_prices_export_xlsx', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)] + #[IsGranted('transport.carriers.view')] + public function __invoke(int $id): Response + { + $carrier = $this->repository->findById($id); + // Soft-delete jamais expose (comme CarrierProvider::provideItem) : 404. + if (null === $carrier || null !== $carrier->getDeletedAt()) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $binary = $this->exporter->export( + 'Prix transporteur', + $this->buildHeaders(), + $this->buildRows($carrier), + ); + + return $this->buildResponse($carrier, $binary); + } + + /** + * Colonnes du tableau Prix regroupe (spec-front « Onglet Prix » / docx p.10). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Type de contenant', + 'Transporteurs', + 'Adresse APRO ou Adresse Sites', + 'Adresse livraisons', + 'Forfait €', + 'Tonne €', + 'Indexation', + 'État du prix', + ]; + } + + /** + * Lignes regroupees par type de contenant (Fond Mouvant / Benne). On trie les + * prix par contenant puis position pour materialiser le regroupement. + * + * @return iterable> + */ + private function buildRows(Carrier $carrier): iterable + { + $prices = $carrier->getPrices()->toArray(); + usort( + $prices, + static fn (CarrierPrice $a, CarrierPrice $b): int => [$a->getContainerType(), $a->getPosition()] + <=> [$b->getContainerType(), $b->getPosition()], + ); + + // Indexation : portee par le transporteur (RG-4.03), identique pour toutes + // ses lignes de prix. Vide si non renseigne (spec-front). + $indexation = $carrier->getIndexationRate() ?? ''; + + foreach ($prices as $price) { + $isForfait = 'FORFAIT' === $price->getPricingUnit(); + + yield [ + self::CONTAINER_LABELS[$price->getContainerType()] ?? $price->getContainerType(), + $carrier->getName(), + $this->formatDeparture($price), + $this->formatDelivery($price), + $isForfait ? $price->getPrice() : '', + $isForfait ? '' : $price->getPrice(), + $indexation, + self::PRICE_STATE_LABELS[$price->getPriceState()] ?? $price->getPriceState(), + ]; + } + } + + /** + * Point de depart du prix (colonne « Adresse APRO ou Adresse Sites ») : + * - branche CLIENT : le site de depart (un des 3 sites 86/17/82) ; + * - branche FOURNISSEUR : l'adresse d'approvisionnement, identifiee par la + * raison sociale du fournisseur (cf. note de classe sur les contrats Shared). + */ + private function formatDeparture(CarrierPrice $price): string + { + if ('CLIENT' === $price->getDirection()) { + return $price->getDepartureSite()?->getName() ?? ''; + } + + return $price->getSupplierSupplyAddress()?->getSupplier()?->getCompanyName() ?? ''; + } + + /** + * Point de livraison du prix (colonne « Adresse livraisons ») : + * - branche CLIENT : l'adresse de livraison, identifiee par la raison sociale + * du client ; + * - branche FOURNISSEUR : le site de livraison (un des 3 sites 86/17/82). + */ + private function formatDelivery(CarrierPrice $price): string + { + if ('CLIENT' === $price->getDirection()) { + return $price->getClientDeliveryAddress()?->getClient()?->getCompanyName() ?? ''; + } + + return $price->getDeliverySite()?->getName() ?? ''; + } + + private function buildResponse(Carrier $carrier, string $binary): Response + { + $filename = sprintf('prix-transporteur-%d-%s.xlsx', (int) $carrier->getId(), new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } +} diff --git a/tests/Module/Transport/Api/CarrierExportControllerTest.php b/tests/Module/Transport/Api/CarrierExportControllerTest.php new file mode 100644 index 0000000..5e18abe --- /dev/null +++ b/tests/Module/Transport/Api/CarrierExportControllerTest.php @@ -0,0 +1,157 @@ +createAdminClient(); + $this->seedCarrier('Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="repertoire-transporteurs-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-transporteurs-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $headers = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Nom', $headers[0]); + self::assertContains('Certification', $headers); + self::assertContains('Statut QUALIMAT', $headers); + self::assertContains('Date de validité', $headers); + self::assertContains('Affrété', $headers); + self::assertContains('Volume m³', $headers); + self::assertContains('Date de création', $headers); + } + + public function testExportExcludesArchivedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedCarrier('Active One'); + $this->seedCarrier('Archived One', true); + + $names = $this->carrierNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('ACTIVE ONE', $names); + self::assertNotContains('ARCHIVED ONE', $names); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedCarrier('Searchable Alpha'); + $this->seedCarrier('Other Beta'); + + $names = $this->carrierNames( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('SEARCHABLE ALPHA', $names); + self::assertNotContains('OTHER BETA', $names); + } + + /** + * Colonnes « Statut QUALIMAT » et « Date de validite » : alimentees par le + * referentiel QUALIMAT lie (RG-4.04). Un transporteur complet seede un lien + * QUALIMAT (statut « Valide », validite 31/12/2027). + */ + public function testExportPopulatesQualimatColumns(): void + { + $client = $this->createAdminClient(); + $this->seedCompleteCarrier('Grelillier'); + + $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); + + self::assertStringContainsString('QUALIMAT', $flat); + self::assertStringContainsString('Valide', $flat); + self::assertStringContainsString('31/12/2027', $flat); + } + + public function testForbiddenWithoutCarriersViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Nom » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function carrierNames(string $binary): array + { + $rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Aplatit toute la grille en une chaine, pour les assertions de presence. + * + * @param array> $grid + */ + private function flatten(array $grid): string + { + return implode('|', array_map( + static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), + $grid, + )); + } +} diff --git a/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php new file mode 100644 index 0000000..92e7c48 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php @@ -0,0 +1,161 @@ +createAdminClient(); + $carrier = $this->seedCompleteCarrier('Price Alpha'); + + $response = $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="prix-transporteur-', $disposition); + self::assertMatchesRegularExpression( + '/filename="prix-transporteur-\d+-\d{8}\.xlsx"/', + $disposition, + ); + + $headerRow = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Type de contenant', $headerRow[0]); + self::assertContains('Transporteurs', $headerRow); + self::assertContains('Adresse APRO ou Adresse Sites', $headerRow); + self::assertContains('Adresse livraisons', $headerRow); + self::assertContains('Forfait €', $headerRow); + self::assertContains('Tonne €', $headerRow); + self::assertContains('Indexation', $headerRow); + self::assertContains('État du prix', $headerRow); + } + + /** + * Le transporteur complet seede 2 prix : une branche CLIENT (Benne / Tonne / + * 42.50 / Valide) et une branche FOURNISSEUR (Fond Mouvant / Forfait / 320.00 / + * En cours). On verifie le regroupement par contenant, la ventilation + * Forfait/Tonne, les libelles d'etat FR et les points de depart/livraison + * cross-module (le prix CLIENT livre chez le client, le prix FOURNISSEUR part + * de l'adresse du fournisseur). + */ + public function testExportRendersGroupedPriceRows(): void + { + $client = $this->createAdminClient(); + $carrier = $this->seedCompleteCarrier('Price Grouping'); + + $grid = $this->gridFromResponse($client->request('GET', $this->exportUrl($carrier))->getContent()); + + $benne = $this->rowForContainer($grid, 'Benne'); + self::assertNotNull($benne, 'Ligne « Benne » introuvable dans l\'export prix.'); + self::assertSame($carrier->getName(), $benne[1]); + // Branche CLIENT : prix en Tonne (42.50 -> 42.5 apres typage numerique du + // classeur), colonne Forfait vide, etat « Valide », livraison chez le client. + self::assertEmpty($benne[4]); + self::assertEqualsWithDelta(42.5, (float) $benne[5], 0.001); + self::assertSame('Validé', $benne[7]); + self::assertStringContainsString('TESTCARRIERREF CLI', (string) $benne[3]); + + $fondMouvant = $this->rowForContainer($grid, 'Fond Mouvant'); + self::assertNotNull($fondMouvant, 'Ligne « Fond Mouvant » introuvable dans l\'export prix.'); + // Branche FOURNISSEUR : prix au Forfait (320.00 -> 320), colonne Tonne vide, + // etat « En cours », depart depuis l'adresse du fournisseur (APRO). + self::assertEqualsWithDelta(320.0, (float) $fondMouvant[4], 0.001); + self::assertEmpty($fondMouvant[5]); + self::assertSame('En cours', $fondMouvant[7]); + self::assertStringContainsString('TESTCARRIERREF FRN', (string) $fondMouvant[2]); + } + + public function testNotFoundForUnknownCarrier(): void + { + $client = $this->createAdminClient(); + + $client->request('GET', '/api/carriers/99999999/prices/export.xlsx'); + + self::assertResponseStatusCodeSame(404); + } + + public function testForbiddenWithoutCarriersViewPermission(): void + { + $carrier = $this->seedCompleteCarrier('Price Forbidden'); + + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $carrier = $this->seedCompleteCarrier('Price Anonymous'); + + $client = self::createClient(); + $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseStatusCodeSame(401); + } + + private function exportUrl(Carrier $carrier): string + { + return sprintf('/api/carriers/%d/prices/export.xlsx', (int) $carrier->getId()); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_price_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Renvoie la 1re ligne de donnees dont la colonne « Type de contenant » + * (1re colonne) vaut $container, ou null. + * + * @param array> $grid + * + * @return null|array + */ + private function rowForContainer(array $grid, string $container): ?array + { + foreach (array_slice($grid, 1) as $row) { + if ((string) ($row[0] ?? '') === $container) { + return $row; + } + } + + return null; + } +}