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