691ed04b71
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.
224 lines
8.6 KiB
PHP
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;
|
|
}
|
|
}
|