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,223 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Controller;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX de la liste des tickets de pesee (M5, spec-back § 4.5 — bouton
* « Exporter » : « Exporte toute la liste des tickets de pesée »). Jumeau des
* controllers d'export SupplierExportController (M2) / ProviderExportController
* (M3) — references en prose volontairement (un {@see} inter-module violerait la
* regle ABSOLUE n°1).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/weighing_tickets/export.xlsx`
* comme l'item `GET /api/weighing_tickets/{id}.{_format}` (id="export",
* _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des tickets (MEMES criteres que la liste
* `GET /api/weighing_tickets`, mais SANS pagination — export complet § 4.5) et
* mapping metier des colonnes.
*
* Filtrage : on rejoue EXACTEMENT la selection du {@see WeighingTicketProvider}
* pour que l'export reflete ce que l'utilisateur voit a l'ecran :
* - recherche fuzzy ?search (number, nom client/fournisseur, other_label, immat) ;
* - tri ?order[displayDate]=asc|desc (defaut number DESC) ;
* - cloisonnement par site courant (§ 2.3 / RG-5.09) : un user sans
* `sites.bypass_scope` possedant un site courant n'exporte que les tickets de
* ce site. La decision est prise ICI (l'user), le filtre DQL sur wt.site est
* pose sur le QueryBuilder. No-op pour bypass_scope ou site courant null.
*/
#[AsController]
final class WeighingTicketExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
private readonly WeighingTicketRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/weighing_tickets/export.xlsx', name: 'logistique_weighing_tickets_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('logistique.weighing_tickets.view')]
public function __invoke(Request $request): Response
{
$search = $request->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<WeighingTicket> $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<string, mixed> $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<string>
*/
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<WeighingTicket> $tickets
*
* @return iterable<list<null|scalar>>
*/
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;
}
}
@@ -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));
}
}