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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user