feat(logistique) : export XLSX des tickets de pesée (ERP-186)

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.
This commit is contained in:
Matthieu
2026-06-18 11:31:02 +02:00
parent 349d2cf202
commit 691ed04b71
2 changed files with 481 additions and 0 deletions
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX des tickets de pesee (M5, § 4.5).
* Jumeau du {@see \App\Tests\Module\Transport\Api\CarrierExportControllerTest}.
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), mapping
* des colonnes (numero, contrepartie, poids vide/plein/net, DSD vide/plein) avec
* net = plein - vide, cloisonnement par site courant (un non-admin n'exporte que
* les tickets de son site), 403 sans `logistique.weighing_tickets.view`, 401
* anonyme.
*
* Nettoyage manuel (pas de DAMA) : tickets/clients de test (prefixes dedies) +
* users/roles `test*`.
*
* @internal
*/
final class WeighingTicketExportControllerTest extends AbstractApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/weighing_tickets/export.xlsx';
private const string NUMBER_PREFIX = 'ZTEST-';
private const string CLIENT_PREFIX = 'ZTESTWT';
protected function tearDown(): void
{
$em = $this->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<int, array<int, mixed>>
*/
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<int, array<int, mixed>> $grid
*
* @return null|array<int, mixed>
*/
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<string>
*/
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));
}
}