feat(commercial) : export XLSX fournisseurs (SupplierExportController, SIREN gaté accounting.view) (ERP-91)
Export XLSX du répertoire fournisseurs (spec § 4.6), jumeau de l'export client M1.
- SupplierExportController avec #[Route(priority: 1)] (anti-conflit API Platform {id}),
is_granted('commercial.suppliers.view'). Mêmes filtres que la liste
(includeArchived/archivedOnly/search/categoryCode/siteId) via createListQueryBuilder()
partagé avec le SupplierProvider ; non archivés par défaut.
- Colonnes : Nom, Contact principal (SupplierContact de plus petit position — ERP-106),
Tél principal/secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis sans
accounting.view), Date de création.
- hydrateContacts() ajouté au repository (chargement batché des contacts en une requête
IN, anti-N+1) — méthode dédiée à l'export, la liste paginée n'embarque pas les contacts.
- Tables supplier* ajoutées à ColumnCommentsCatalog : leurs COMMENT (posés par la
migration ERP-85) étaient dropés par le schema:update --force du test-db-setup et non
restaurés, cassant ColumnsHaveSqlCommentTest dès un re-setup de la base de test.
- Test fonctionnel SupplierExportControllerTest (9 cas).
This commit is contained in:
@@ -65,4 +65,16 @@ interface SupplierRepositoryInterface
|
||||
* @param list<Supplier> $suppliers
|
||||
*/
|
||||
public function hydrateListCollections(array $suppliers): void;
|
||||
|
||||
/**
|
||||
* Hydrate en lot la collection `contacts` sur un jeu de fournisseurs DEJA
|
||||
* charges (memes instances via l'identity map). Reservee a l'export XLSX
|
||||
* (§ 4.6) qui a besoin du contact principal : la LISTE paginee n'embarque
|
||||
* pas les contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe
|
||||
* supplementaire dans {@see self::hydrateListCollections()} — on n'impose pas
|
||||
* le cout du chargement des contacts au chemin liste.
|
||||
*
|
||||
* @param list<Supplier> $suppliers
|
||||
*/
|
||||
public function hydrateContacts(array $suppliers): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||
use App\Module\Commercial\Domain\Repository\SupplierRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
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 du repertoire fournisseurs (M2, spec-back § 4.6). Jumeau du
|
||||
* {@see ClientExportController} (M1).
|
||||
*
|
||||
* 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/suppliers/export.xlsx` comme l'item `GET /api/suppliers/{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 fournisseurs (memes filtres que
|
||||
* `GET /api/suppliers`, via {@see SupplierRepositoryInterface::createListQueryBuilder()})
|
||||
* et mapping metier des colonnes.
|
||||
*
|
||||
* Colonnes de contact : depuis la suppression du contact inline (ERP-106), elles
|
||||
* sont alimentees par le CONTACT PRINCIPAL du fournisseur — le SupplierContact de
|
||||
* plus petit `position` (decision D2, spec § 4.6).
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `commercial.suppliers.accounting.view` (gating identique a la lecture).
|
||||
*/
|
||||
#[AsController]
|
||||
final class SupplierExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository')]
|
||||
private readonly SupplierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/suppliers/export.xlsx', name: 'commercial_suppliers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('commercial.suppliers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
/** @var list<Supplier> $suppliers */
|
||||
$suppliers = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
|
||||
// selection ne fetch-join pas les to-many. On remplit categories + sites en
|
||||
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
|
||||
// contact principal) — chacune en requetes IN bornees, anti N+1.
|
||||
$this->repository->hydrateListCollections($suppliers);
|
||||
$this->repository->hydrateContacts($suppliers);
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.suppliers.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire fournisseurs',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($suppliers, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
|
||||
* uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom fournisseur',
|
||||
'Contact principal',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Supplier> $suppliers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $suppliers, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($suppliers as $supplier) {
|
||||
$contact = $this->principalContact($supplier);
|
||||
|
||||
$row = [
|
||||
$supplier->getCompanyName(),
|
||||
null !== $contact ? $this->formatContactName($contact) : '',
|
||||
$contact?->getPhonePrimary() ?? '',
|
||||
$contact?->getPhoneSecondary() ?? '',
|
||||
$contact?->getEmail() ?? '',
|
||||
$this->formatCategories($supplier),
|
||||
$this->formatSites($supplier),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $supplier->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $supplier->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact principal du fournisseur : le SupplierContact de plus petit
|
||||
* `position` (decision D2, spec § 4.6). Null si le fournisseur n'a aucun
|
||||
* contact (les colonnes contact restent vides).
|
||||
*/
|
||||
private function principalContact(Supplier $supplier): ?SupplierContact
|
||||
{
|
||||
$contacts = $supplier->getContacts()->toArray();
|
||||
if ([] === $contacts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort(
|
||||
$contacts,
|
||||
static fn (SupplierContact $a, SupplierContact $b): int => $a->getPosition() <=> $b->getPosition(),
|
||||
);
|
||||
|
||||
return $contacts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
|
||||
* sont optionnelles (RG-2.04 : au moins l'une des deux), d'ou le trim final.
|
||||
*/
|
||||
private function formatContactName(SupplierContact $contact): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du fournisseur, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatCategories(Supplier $supplier): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($supplier->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le fournisseur ne porte pas de sites en propre : ils sont rattaches aux
|
||||
* adresses (RG-2.06). La colonne « Sites » agrege donc l'union distincte des
|
||||
* sites de toutes les adresses du fournisseur.
|
||||
*/
|
||||
private function formatSites(Supplier $supplier): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($supplier->getAddresses() as $address) {
|
||||
foreach ($address->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-fournisseurs-%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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur SupplierProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur SupplierProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur SupplierProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,31 @@ class DoctrineSupplierRepository extends ServiceEntityRepository implements Supp
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $suppliers): void
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
$id = $supplier->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
|
||||
// instances Supplier (identity map). Tri par position pour que le « contact
|
||||
// principal » (plus petit position) soit deterministe a l'export (§ 4.6).
|
||||
$this->createQueryBuilder('s')
|
||||
->leftJoin('s.contacts', 'sc')->addSelect('sc')
|
||||
->where('s.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->orderBy('sc.position', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
|
||||
* lies (firstName / lastName / email) — decision D1, refonte-contact (§ 4.1).
|
||||
|
||||
Reference in New Issue
Block a user