Compare commits

...

1 Commits

Author SHA1 Message Date
Matthieu e917c413dc 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).
2026-06-05 14:56:50 +02:00
5 changed files with 729 additions and 0 deletions
@@ -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).
@@ -256,6 +256,95 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
// === M2 Commercial (ERP-85) — miroir des COMMENT de la migration
// Version20260605130000 pour le chemin schema:update (dev/test). ===
'supplier' => [
'_table' => 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).',
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
'volume_forecast' => 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du fournisseur.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).',
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
'deleted_at' => 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.',
] + self::timestampableBlamableComments(),
'supplier_category' => [
'_table' => 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).',
],
'supplier_contact' => [
'_table' => 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).',
'position' => 'Ordre d affichage du contact dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
'supplier_address' => [
'_table' => 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.',
'address_type' => 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
'supplier_address_site' => [
'_table' => 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
],
'supplier_address_contact' => [
'_table' => 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'supplier_contact_id' => 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
],
'supplier_address_category' => [
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).',
],
'supplier_rib' => [
'_table' => 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.',
'label' => 'Libelle du RIB (ex: compte principal).',
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
];
}
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire fournisseurs (M2, § 4.6).
* Jumeau du {@see ClientExportControllerTest} (M1).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* 401 anonyme.
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Export Alpha');
$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::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-fournisseurs-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom fournisseur', $headers[0]);
self::assertContains('Contact principal', $headers);
self::assertContains('Téléphone principal', $headers);
self::assertContains('Téléphone secondaire', $headers);
self::assertContains('Email', $headers);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Active One');
$this->seedSupplier('Archived One', true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Searchable Alpha');
$this->seedSupplier('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
* ordre de position inverse pour garantir que c'est bien le principal (et non
* le premier insere) qui alimente la ligne.
*/
public function testExportUsesPrincipalContactColumns(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
self::assertSame('Principal Alice', $row[1]);
self::assertSame('0612345678', $row[2]);
self::assertSame('0698765432', $row[3]);
self::assertSame('alice@contact.co', $row[4]);
}
/**
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
* vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse
* (RG-2.06).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT');
$em = $this->getEm();
$site = $em->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.');
$address = new SupplierAddress();
$address->setSupplier($supplier);
$address->setAddressType('DEPART');
$address->setPostalCode('86100');
$address->setCity('Châtellerault');
$address->setStreet('1 rue du Test');
$address->addSite($site);
$em->persist($address);
$em->flush();
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
self::assertStringContainsString('test_cli_cat_negociant', $flat);
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
self::assertStringContainsString((string) $site->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Siren Co');
$em = $this->getEm();
$supplier->setSiren('123456789');
$em->flush();
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE suppliers.view.
$admin = $this->createAdminClient();
$supplier = $this->seedSupplier('No Siren Co');
$em = $this->getEm();
$supplier->setSiren('987654321');
$em->flush();
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
public function testForbiddenWithoutSuppliersViewPermission(): 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);
}
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/**
* 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_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return array<int, mixed>|null
*/
private function rowFor(string $binary, string $companyName): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[0] ?? '') === $companyName) {
return $row;
}
}
return null;
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}