From 02a22597b3561ac3acffbd0b3e32f5cd0d27aceb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 18 Jun 2026 11:31:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(logistique)=20:=20export=20XLSX=20des=20ti?= =?UTF-8?q?ckets=20de=20pes=C3=A9e=20(ERP-186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint GET /api/weighing_tickets/export.xlsx — controller custom (priority: 1) calque sur les exports M2/M3/M4, delegue la generation au SpreadsheetExporter partage. Rejoue la selection du WeighingTicketProvider (recherche ?search, tri ?order[displayDate], cloisonnement par site courant) SANS pagination : export complet de la liste (§ 4.5). Colonnes : Numero, Type contrepartie, Contrepartie (nom Client/Fournisseur/ Autre), Date, Immatriculation, Poids vide, Poids plein, Poids net, DSD vide, DSD plein. Securite logistique.weighing_tickets.view. Tests fonctionnels : 200 + en-tetes/Content-Disposition, mapping des colonnes avec net = plein - vide (RG-5.05), cloisonnement par site (non-admin), 403, 401. --- .../WeighingTicketExportController.php | 223 +++++++++++++++ .../WeighingTicketExportControllerTest.php | 258 ++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php diff --git a/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php b/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php new file mode 100644 index 0000000..f15cd56 --- /dev/null +++ b/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php @@ -0,0 +1,223 @@ +query->getString('search') ?: null; + + $qb = $this->repository->createListQueryBuilder($search); + + $this->applyDisplayDateOrder($qb, $request->query->all()); + $this->applySiteScope($qb); + + // Export complet : pas de pagination (§ 4.5). On materialise toute la + // selection filtree (cloisonnee par site) AVANT le mapping des colonnes. + /** @var list $tickets */ + $tickets = $qb->getQuery()->getResult(); + + $binary = $this->exporter->export( + 'Tickets de pesée', + $this->buildHeaders(), + $this->buildRows($tickets), + ); + + return $this->buildResponse($binary); + } + + /** + * Tri par date du ticket (§ 4.1), miroir de WeighingTicketProvider : + * displayDate = COALESCE(full_date, empty_date) (getter calcule, pas une + * colonne). Absent du payload -> tri par defaut du repository (number DESC). + * + * @param array $query + */ + private function applyDisplayDateOrder(QueryBuilder $qb, array $query): void + { + $order = $query['order'] ?? null; + if (!is_array($order) || !isset($order['displayDate'])) { + return; + } + + $direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC'; + $rootAlias = $qb->getRootAliases()[0]; + + $qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction); + } + + /** + * Cloisonnement par site courant (§ 2.3 / RG-5.09), miroir de + * WeighingTicketProvider::applySiteScope() : restreint la selection au site + * courant si l'user n'a pas le bypass et qu'un site est resolu. No-op sinon. + */ + private function applySiteScope(QueryBuilder $qb): void + { + $scopeSite = $this->siteScopeOrNull(); + if (null === $scopeSite) { + return; + } + + $rootAlias = $qb->getRootAliases()[0]; + $qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias)) + ->setParameter('scopeSite', $scopeSite) + ; + } + + /** + * Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique + * (user `sites.bypass_scope`, ou pas de site courant — module Sites off / + * user sans currentSite). Miroir de WeighingTicketProvider::currentScopeSite(). + */ + private function siteScopeOrNull(): ?SiteInterface + { + if ($this->security->isGranted('sites.bypass_scope')) { + return null; + } + + return $this->currentSiteProvider->get(); + } + + /** + * Colonnes de l'export (spec § 4.5). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Numéro', + 'Type contrepartie', + 'Contrepartie', + 'Date', + 'Immatriculation', + 'Poids vide (kg)', + 'Poids plein (kg)', + 'Poids net (kg)', + 'DSD vide', + 'DSD plein', + ]; + } + + /** + * @param list $tickets + * + * @return iterable> + */ + private function buildRows(array $tickets): iterable + { + foreach ($tickets as $ticket) { + yield [ + $ticket->getNumber(), + $this->counterpartyTypeLabel($ticket->getCounterpartyType()), + $this->counterpartyName($ticket), + $ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '', + $ticket->getImmatriculation() ?? '', + $ticket->getEmptyWeight() ?? '', + $ticket->getFullWeight() ?? '', + $ticket->getNetWeight() ?? '', + $ticket->getEmptyDsd() ?? '', + $ticket->getFullDsd() ?? '', + ]; + } + } + + /** + * Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour + * une valeur inattendue (garde-fou : ne masque pas une donnee corrompue). + */ + private function counterpartyTypeLabel(?string $type): string + { + return match ($type) { + 'CLIENT' => 'Client', + 'FOURNISSEUR' => 'Fournisseur', + 'AUTRE' => 'Autre', + default => $type ?? '', + }; + } + + /** + * Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client, + * du fournisseur, ou libelle libre « Autre ». Client / Supplier sont + * fetch-joines par le repository (anti N+1, § 4.0). + */ + private function counterpartyName(WeighingTicket $ticket): string + { + return match ($ticket->getCounterpartyType()) { + 'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '', + 'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '', + 'AUTRE' => $ticket->getOtherLabel() ?? '', + default => '', + }; + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('tickets-pesee-%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; + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php b/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php new file mode 100644 index 0000000..4f967df --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php @@ -0,0 +1,258 @@ +getEm(); + $em->createQuery('DELETE FROM '.WeighingTicket::class.' wt WHERE wt.number LIKE :p') + ->setParameter('p', self::NUMBER_PREFIX.'%')->execute() + ; + $em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p') + ->setParameter('p', self::CLIENT_PREFIX.'%')->execute() + ; + $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p') + ->setParameter('p', 'testuser_%')->execute() + ; + $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p') + ->setParameter('p', 'test_%')->execute() + ; + + parent::tearDown(); + } + + public function testExportReturnsXlsxResponseWithHeaders(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $this->seedTicketWithClient($this->firstSite(), 'Acme'); + + $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::assertMatchesRegularExpression('/filename="tickets-pesee-\d{8}\.xlsx"/', $disposition); + + // 1re ligne = en-tetes attendus (ordre des colonnes § 4.5). + $header = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Numéro', $header[0]); + self::assertContains('Type contrepartie', $header); + self::assertContains('Contrepartie', $header); + self::assertContains('Date', $header); + self::assertContains('Immatriculation', $header); + self::assertContains('Poids vide (kg)', $header); + self::assertContains('Poids plein (kg)', $header); + self::assertContains('Poids net (kg)', $header); + self::assertContains('DSD vide', $header); + self::assertContains('DSD plein', $header); + } + + /** + * Mapping des colonnes : la ligne exportee porte les bonnes valeurs aux bons + * index, et le poids net = poids plein - poids vide (RG-5.05). + */ + public function testExportMapsColumnsAndComputesNetWeight(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $ticket = $this->seedTicketWithClient($this->firstSite(), 'Béton SA'); + + $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); + $header = $grid[0]; + $row = $this->rowByNumber($grid, (string) $ticket->getNumber()); + self::assertNotNull($row, 'La ligne du ticket seede doit etre presente dans l\'export.'); + + $cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null; + + self::assertSame('Client', $cell('Type contrepartie')); + self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie')); + self::assertSame('AB-123-CD', $cell('Immatriculation')); + self::assertSame(7150, (int) $cell('Poids vide (kg)')); + self::assertSame(14300, (int) $cell('Poids plein (kg)')); + self::assertSame(7150, (int) $cell('Poids net (kg)')); + self::assertSame(7150, (int) $cell('Poids plein (kg)') - (int) $cell('Poids vide (kg)')); + self::assertSame(41, (int) $cell('DSD vide')); + self::assertSame(42, (int) $cell('DSD plein')); + } + + /** + * Cloisonnement par site (§ 2.3 / RG-5.09) : un non-admin (sans bypass) + * possedant un site courant n'exporte QUE les tickets de ce site. + */ + public function testExportIsScopedToCurrentSiteForNonAdmin(): void + { + $sites = $this->getEm()->getRepository(Site::class)->findAll(); + self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites attendus (fixtures).'); + + $ticketHere = $this->seedTicketWithClient($sites[0], 'Ici'); + $ticketOther = $this->seedTicketWithClient($sites[1], 'Ailleurs'); + + $client = $this->viewClientWithCurrentSite($sites[0]); + + $numbers = $this->numbersFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains($ticketHere->getNumber(), $numbers); + self::assertNotContains($ticketOther->getNumber(), $numbers); + } + + public function testForbiddenWithoutViewPermission(): 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); + } + + private function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null; + self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis.'); + + return $site; + } + + /** + * Seede un ticket complet (contrepartie Client, pesee vide + plein) rattache au + * site donne. Numero unique prefixe pour la purge. Le net est pose + * explicitement (pas de Processor sur un persist direct) = plein - vide. + */ + private function seedTicketWithClient(Site $site, string $label): WeighingTicket + { + $em = $this->getEm(); + + $clientEntity = new ClientEntity(); + $clientEntity->setCompanyName(mb_strtoupper(self::CLIENT_PREFIX.' '.$label, 'UTF-8')); + $em->persist($clientEntity); + + $ticket = new WeighingTicket(); + $ticket->setSite($em->getReference(Site::class, $site->getId())); + $ticket->setNumber(self::NUMBER_PREFIX.substr(bin2hex(random_bytes(5)), 0, 10)); + $ticket->setCounterpartyType('CLIENT'); + $ticket->setClient($clientEntity); + $ticket->setImmatriculation('AB-123-CD'); + $ticket->setEmptyDate(new DateTimeImmutable('2026-06-17 09:00:00')); + $ticket->setEmptyWeight(7150); + $ticket->setEmptyDsd(41); + $ticket->setEmptyMode('AUTO'); + $ticket->setFullDate(new DateTimeImmutable('2026-06-17 09:12:00')); + $ticket->setFullWeight(14300); + $ticket->setFullDsd(42); + $ticket->setFullMode('AUTO'); + $ticket->setNetWeight(7150); + + $em->persist($ticket); + $em->flush(); + + return $ticket; + } + + /** + * Cree un non-admin portant `logistique.weighing_tickets.view`, lui positionne + * un site courant (cloisonnement § 2.3) et renvoie un client authentifie. + */ + private function viewClientWithCurrentSite(Site $site): Client + { + $creds = $this->createUserWithPermission('logistique.weighing_tickets.view'); + + $em = $this->getEm(); + $user = $em->getRepository(User::class)->findOneBy(['username' => $creds['username']]); + self::assertInstanceOf(User::class, $user); + $user->setCurrentSite($em->getReference(Site::class, $site->getId())); + $em->flush(); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } + + /** + * 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_wt_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Premiere ligne de donnees dont la colonne « Numéro » vaut $number, ou null. + * + * @param array> $grid + * + * @return null|array + */ + private function rowByNumber(array $grid, string $number): ?array + { + foreach (array_slice($grid, 1) as $row) { + if ((string) ($row[0] ?? '') === $number) { + return $row; + } + } + + return null; + } + + /** + * Colonne « Numéro » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function numbersFromResponse(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)); + } +}