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)); } }