Files
Starseed/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php
T
Matthieu 691ed04b71 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.
2026-06-18 11:31:02 +02:00

224 lines
8.6 KiB
PHP

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