diff --git a/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php new file mode 100644 index 0000000..4085901 --- /dev/null +++ b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php @@ -0,0 +1,328 @@ +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'] ?? []); + + $qb = $this->repository + ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly) + ; + + // Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint + // au currentSite pour un user non-bypass (s'intersecte avec un eventuel + // ?siteId du client). No-op pour bypass_scope ou currentSite null. + $scopeSite = $this->siteScopeOrNull(); + if (null !== $scopeSite) { + $this->repository->applySiteScope($qb, (int) $scopeSite->getId()); + } + + /** @var list $providers */ + $providers = $qb->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($providers); + $this->repository->hydrateContacts($providers); + + $withSiren = $this->security->isGranted('technique.providers.accounting.view'); + + $binary = $this->exporter->export( + 'Répertoire prestataires', + $this->buildHeaders($withSiren), + $this->buildRows($providers, $withSiren), + ); + + return $this->buildResponse($binary); + } + + /** + * Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement + * (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off + * / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull(). + */ + private function siteScopeOrNull(): ?SiteInterface + { + if ($this->security->isGranted('sites.bypass_scope')) { + return null; + } + + return $this->currentSiteProvider->get(); + } + + /** + * Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation, + * uniquement si l'utilisateur a accounting.view. + * + * @return list + */ + private function buildHeaders(bool $withSiren): array + { + $headers = [ + 'Nom prestataire', + '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 $providers + * + * @return iterable> + */ + private function buildRows(array $providers, bool $withSiren): iterable + { + foreach ($providers as $provider) { + $contact = $this->principalContact($provider); + + $row = [ + $provider->getCompanyName(), + null !== $contact ? $this->formatContactName($contact) : '', + $contact?->getPhonePrimary() ?? '', + $contact?->getPhoneSecondary() ?? '', + $contact?->getEmail() ?? '', + $this->formatCategories($provider), + $this->formatSites($provider), + ]; + + if ($withSiren) { + $row[] = $provider->getSiren(); + } + + $row[] = $provider->getCreatedAt()?->format('d/m/Y'); + + yield $row; + } + } + + /** + * Contact principal du prestataire : le ProviderContact de plus petit + * `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun + * contact (les colonnes contact restent vides). + */ + private function principalContact(Provider $provider): ?ProviderContact + { + $contacts = $provider->getContacts()->toArray(); + if ([] === $contacts) { + return null; + } + + usort( + $contacts, + static fn (ProviderContact $a, ProviderContact $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-3.04 : au moins l'une des deux), d'ou le trim final. + */ + private function formatContactName(ProviderContact $contact): string + { + return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? '')); + } + + /** + * Libelles des categories du prestataire, dedupliques, tries, joints par + * virgule. + */ + private function formatCategories(Provider $provider): string + { + $names = []; + foreach ($provider->getCategories() as $category) { + // @var CategoryInterface $category + $name = $category->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement + * au fournisseur M2 dont les sites sont portes par les adresses). La colonne + * « Sites » agrege l'union distincte des sites rattaches. + */ + private function formatSites(Provider $provider): string + { + $names = []; + foreach ($provider->getSites() as $site) { + // @var SiteInterface $site + $name = $site->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * @param array $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-prestataires-%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 ProviderProvider 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 ProviderProvider pour un comportement identique a la liste. + * + * @return list + */ + 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 ProviderProvider. + * + * @return list + */ + 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; + } +} diff --git a/tests/Module/Technique/Api/ProviderExportControllerTest.php b/tests/Module/Technique/Api/ProviderExportControllerTest.php new file mode 100644 index 0000000..9c9519e --- /dev/null +++ b/tests/Module/Technique/Api/ProviderExportControllerTest.php @@ -0,0 +1,319 @@ +createAdminClient(); + $this->seedProvider('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-prestataires-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-prestataires-\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 prestataire', $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->seedProvider('Active One'); + $this->seedProvider('Archived One', [self::SITE_86], 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->seedProvider('Searchable Alpha'); + $this->seedProvider('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(); + $provider = $this->seedProvider('Contact Co'); + + // position 1 (secondaire) insere en premier... + $this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); + // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. + $principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); + // Le telephone secondaire n'est pas porte par le helper de base : on le pose + // directement sur le contact principal pour alimenter la colonne dediee. + $principal->setPhoneSecondary('0698765432'); + $this->getEm()->flush(); + + $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 EN DIRECT par + * le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse). + */ + public function testExportPopulatesCategoryAndSiteColumns(): void + { + $client = $this->createAdminClient(); + $this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE'); + + $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); + + // Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()). + // Derive du helper de base (idempotent) plutot que de hardcoder le prefixe. + self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat); + // Colonne « Sites » : site rattache en direct au prestataire (RG-3.03). + self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat); + } + + public function testSirenColumnPresentWithAccountingView(): void + { + // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. + $client = $this->createAdminClient(); + $this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789'); + + $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 providers.view. + $this->createAdminClient(); + $this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321'); + + $creds = $this->createUserWithPermission('technique.providers.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)); + } + + /** + * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) : + * un user minimal portant uniquement technique.providers.view + + * technique.providers.accounting.view voit bien la colonne SIREN et sa valeur. + * Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve + * pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant + * negatif est couvert par testSirenColumnAbsentWithoutAccountingView. + */ + public function testSirenColumnPresentForMinimalUserWithAccountingView(): void + { + // Seed via admin, puis relecture par un user non-admin a 2 permissions. + $this->createAdminClient(); + $this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123'); + + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + ]); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('456789123', $this->flatten($grid)); + } + + /** + * Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par + * la jointure (selection/hydratation des collections) ; l'export doit le rendre + * sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il + * n'apparait qu'une fois dans la colonne « Nom prestataire ». + */ + public function testExportDeduplicatesProviderWithMultipleCategories(): void + { + $client = $this->createAdminClient(); + $provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE'); + // 2e categorie PRESTATAIRE sur le meme prestataire. + $provider->addCategory($this->providerCategory('SECURITE')); + $this->getEm()->flush(); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name)); + self::assertSame( + 1, + $occurrences, + 'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).', + ); + } + + /** + * Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur + * le site 86 n'exporte QUE les prestataires rattaches au site 86 — les + * prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant + * export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser. + */ + public function testExportIsScopedToCurrentSiteForNonBypassUser(): void + { + // Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement + // no-op et ce test perd son sens). + $this->skipIfSitesModuleDisabled(); + + $this->createAdminClient(); + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('PRESTA SITE 86', $names); + self::assertNotContains('PRESTA SITE 17', $names); + self::assertNotContains('PRESTA SITE 82', $names); + } + + public function testForbiddenWithoutProvidersViewPermission(): 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); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + 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 prestataire » (1re colonne) des lignes de donnees. + * + * @return list + */ + 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 null|array + */ + 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> $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, + )); + } +}