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; } }