src/Controller/CandidatureController.php line 94
<?phpnamespace App\Controller;use App\Entity\Candidature;use App\Entity\Metier;use App\Entity\Etablissement;use App\Entity\User;use App\Entity\EtablissementMetier;use App\Entity\Prospection;use App\Entity\ProspectionMetier;use App\Form\CandidatureType;use App\Service\FileUploader;use App\Service\PdfGenerator;use App\Service\SpreadsheetGenerator;use App\Service\Constant;use Doctrine\ORM\EntityManagerInterface;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Routing\Annotation\Route;use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;#[Route('/candidature')]class CandidatureController extends AbstractController{private const STATUT_LABELS = [2 => 'Accepté',1 => 'Rejeté','default' => 'En attente'];public function __construct(private readonly EntityManagerInterface $entityManager,private readonly UserPasswordHasherInterface $passwordHasher,private readonly FileUploader $fileUploader,private readonly PdfGenerator $pdfGenerator,private readonly SpreadsheetGenerator $spreadsheetGenerator,private readonly ParameterBagInterface $params,private readonly Constant $constant) {}#[Route('/', name: 'app_candidature_index', methods: ['GET'])]#[IsGranted('ROLE_USER')]public function index(): Response{$user = $this->getUser();$qb = $this->entityManager->getRepository(Candidature::class)->createQueryBuilder('c')->leftJoin('c.etablissement', 'e')->leftJoin('c.metier', 'm')->addSelect('e', 'm');// ADMIN : toutes les candidaturesif ($this->isGranted('ROLE_ADMIN')) {$qb->leftJoin('c.user', 'u')->addSelect('u');$is_admin = true;// ENT : mêmes colonnes/actions qu'admin mais filtre par établissement} elseif ($this->isGranted('ROLE_ENT')) {$qb->leftJoin('c.user', 'u')->addSelect('u')->where('e.user = :user') // Filtre sur l'établissement de l'utilisateur ENT->setParameter('user', $user);$is_admin = true;// JURY : mêmes droits que ENT pour l'affichage (voit les candidatures de son établissement)} elseif ($this->isGranted('ROLE_JURY')) {$qb->leftJoin('c.user', 'u')->addSelect('u')->where('e.user = :user')->setParameter('user', $user);$is_admin = true;// CANDIDAT : ses propres candidatures} else {$qb->where('c.user = :user')->setParameter('user', $user);$is_admin = false;}$candidatures = $qb->orderBy('c.id', 'DESC')->getQuery()->getResult();return $this->render('candidature/index.html.twig', ['candidatures' => $candidatures,'is_admin' => $is_admin,]);}#[Route('/new', name: 'app_candidature_new', methods: ['GET', 'POST'])]public function new(Request $request): Response{// Bloquer les utilisateurs ENTif ($this->isGranted('ROLE_ENT')) {$this->addFlash('error','Vous êtes connecté comme établissement. <a href="' . $this->generateUrl('app_logout') . '" class="underline font-semibold">Se déconnecter</a> pour créer une candidature.');return $this->redirectToRoute('app_candidature_index');}if ($this->getUser() && !$this->canSubmitCandidature($this->getUser())) {$this->addFlash('error', 'Vous avez déjà soumis une candidature.');return $this->redirectToRoute('app_candidature_index');}$candidature = $this->createCandidature($this->getUser());$this->prefillFromRequest($candidature, $request);$form = $this->createForm(CandidatureType::class, $candidature);$form->handleRequest($request);if ($form->isSubmitted() && $form->isValid()) {if (!$this->canSubmitCandidature($this->getUser())) {$this->addFlash('error', 'Vous avez déjà soumis une candidature.');return $this->redirectToRoute('app_candidature_index');}// Validation : vérifier que le métier est autorisé pour l'établissement$etab = $candidature->getEtablissement();$met = $candidature->getMetier();if ($etab && $met) {$emExists = $this->entityManager->getRepository(EtablissementMetier::class)->findOneBy(['etablissement' => $etab, 'metier' => $met]);if (!$emExists) {$this->addFlash('error', 'Le métier sélectionné n\'est pas ouvert dans cet établissement.');return $this->render('candidature/form.html.twig', ['candidature' => $candidature,'form' => $form,'document_labels' => $this->constant->document_labels,'candidaturesCount' => $this->getUser() ? $this->countUserCandidatures($this->getUser()) : 0]);}}$candidature->setNumero($this->generateNumero());if ($this->processCandidatureFiles($candidature, $form)) {$this->entityManager->persist($candidature);$this->entityManager->flush();return $this->redirectToRoute('app_candidature_success', ['id' => $candidature->getId()]);}}$this->displayFormErrors($form);return $this->render('candidature/form.html.twig', ['candidature' => $candidature,'form' => $form,'document_labels' => $this->constant->document_labels,'candidaturesCount' => $this->getUser() ? $this->countUserCandidatures($this->getUser()) : 0]);}#[Route('/success/{id}', name: 'app_candidature_success', requirements: ['id' => '\\d+'])]#[Security("is_granted('ROLE_CANDIDAT') and user === candidature.getUser() or is_granted('ROLE_ADMIN')")]public function success(Candidature $candidature): Response{return $this->render('candidature/success.html.twig', ['candidature' => $candidature]);}#[Route('/{id}/edit', name: 'app_candidature_edit', methods: ['GET', 'POST'], requirements: ['id' => '\\d+'])]#[Security("(is_granted('ROLE_ADMIN') or (is_granted('ROLE_ENT') and candidature.getEtablissement() and candidature.getEtablissement().getUser() == user))")]public function edit(Request $request, Candidature $candidature): Response{$form = $this->createForm(CandidatureType::class, $candidature, ['evaluation_only' => true]);$form->handleRequest($request);if ($form->isSubmitted() && $form->isValid()) {$candidature->setModification(new \DateTime());$this->entityManager->flush();$this->addFlash('success', 'Évaluation mise à jour avec succès.');return $this->redirectToRoute('app_candidature_index');}return $this->render('candidature/form.html.twig', ['candidature' => $candidature,'form' => $form,'document_labels' => $this->constant->document_labels]);}#[Route('/{id}/update-info', name: 'app_candidature_update_info', methods: ['POST'], requirements: ['id' => '\\d+'])]#[Security("(is_granted('ROLE_ADMIN') or (is_granted('ROLE_ENT') and candidature.getEtablissement() and candidature.getEtablissement().getUser() == user))")]public function updateInfo(Request $request, Candidature $candidature): Response{if (!$this->isCsrfTokenValid('candidature_update_info_' . $candidature->getId(), $request->request->get('_token'))) {$this->addFlash('error', 'Token de sécurité invalide.');return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);}// Mettre à jour nom/prénoms/contact du candidat associé$candidatUser = $candidature->getUser();if ($candidatUser) {$nom = trim($request->request->get('nom', ''));$prenoms = trim($request->request->get('prenoms', ''));$contact = trim($request->request->get('contact', ''));if ($nom !== '') {$candidatUser->setNom($nom);}if ($prenoms !== '') {$candidatUser->setPrenoms($prenoms);}$candidatUser->setContact($contact !== '' ? $contact : null);}// Mettre à jour le métier$metierId = $request->request->get('metier_id');if ($metierId) {$metier = $this->entityManager->getRepository(Metier::class)->find($metierId);if ($metier) {$emExists = $this->entityManager->getRepository(EtablissementMetier::class)->findOneBy(['etablissement' => $candidature->getEtablissement(), 'metier' => $metier]);if ($emExists) {$candidature->setMetier($metier);} else {$this->addFlash('error', "Le métier sélectionné n'est pas ouvert dans cet établissement.");return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);}}}// Traiter les fichiers uploadés$dir = $this->params->get('dir_media') . $candidature->getNumero() . '/';$this->fileUploader->mkdir($dir);foreach (['fphoto', 'fpiece', 'fextrait', 'fniveau', 'fautre'] as $field) {$file = $request->files->get($field);if ($file) {$getter = 'get' . ucfirst($field);$setter = 'set' . ucfirst($field);$oldFile = $candidature->$getter();$fileName = $this->fileUploader->upload($file, $dir, $oldFile);$candidature->$setter($fileName);}}$candidature->setModification(new \DateTime());$this->entityManager->flush();$this->addFlash('success', 'Informations mises à jour avec succès.');return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);}#[Route('/{id}', name: 'app_candidature_delete', methods: ['POST'], requirements: ['id' => '\\d+'])]#[Security("(is_granted('ROLE_CANDIDAT') and user === candidature.getUser()) or is_granted('ROLE_ADMIN')")]public function delete(Request $request, Candidature $candidature): Response{if ($this->isCsrfTokenValid('delete' . $candidature->getId(), $request->request->get('_token'))) {$this->deleteCandidature($candidature);$this->addFlash('success', 'Candidature supprimée avec succès.');}return $this->redirectToRoute('app_candidature_index');}// ==================== ROUTES D'IMPRESSION ET STATISTIQUES ====================#[Route('/print', name: 'app_candidature_print', methods: ['GET'])]public function print(Request $request): Response{$type = $request->query->get('type');$numero = $request->query->get('numero');if ($type === 'stat') {return $this->printStatistics($request);}if (!$numero) {$this->addFlash('error', 'Numéro de candidature manquant');return $this->redirectToRoute('app_candidature_impressions');}$candidature = $this->entityManager->getRepository(Candidature::class)->findOneBy(['numero' => trim($numero)]);if (!$candidature) {$this->addFlash('error', 'Candidature non trouvée');return $this->redirectToRoute('app_candidature_impressions');}return match ($type) {'fiche' => $this->generateFichePdf($candidature),'convocation' => $candidature->getEntstatut() === 2? $this->generateConvocationPdf($candidature): $this->handleInvalidConvocation(),default => $this->redirectToRoute('app_candidature_impressions')};}#[Route('/stat', name: 'app_candidature_stat')]#[Security("is_granted('ROLE_ADMIN') or is_granted('ROLE_ENT') or is_granted('ROLE_JURY') or is_granted('ROLE_COM')")]public function stat(Request $request): Response{$user = $this->getUser();$etablissementId = $request->query->get('etablissement');$metierId = $request->query->get('metier');// --- SÉCURITÉ ET FILTRAGE AUTOMATIQUE ---// Si c'est un ENT ou JURY, on force son établissementif ($this->isGranted('ROLE_ENT') || $this->isGranted('ROLE_JURY')) {$etabLie = $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);if ($etabLie) {$etablissementId = $etabLie->getId();}}$etablissement = $etablissementId ? $this->entityManager->getRepository(Etablissement::class)->find($etablissementId) : null;$metier = $metierId ? $this->entityManager->getRepository(Metier::class)->find($metierId) : null;// Pour le filtre : Admin voit tout, l'ENT ne voit que lui-même dans la listeif ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_COM')) {$etablissements = $this->entityManager->getRepository(Etablissement::class)->findBy([], ['nom' => 'ASC']);} else {$etablissements = $etablissement ? [$etablissement] : [];}// Récupérer les métiers de l'établissement sélectionné (pour le filtre)$metiers = [];if ($etablissement) {$metiers = $this->getMetiersForEtablissement($etablissement);}// Obtenir les statistiques (sans la contrainte EtablissementMetier)$stats = $this->getDetailedStatistics($etablissement, $metier);// Calculer les totaux pour les graphs$totals = $this->calculateTotals($stats);if ($request->query->get('export') === 'true') {return $this->exportDetailedStatistics($stats, $etablissement, $metier);}// --- Statistiques de prospection ---$prospectionTotals = $this->getProspectionTotals($etablissement, $metier);return $this->render('candidature/stat.html.twig', ['etablissements' => $etablissements,'etablissement_selectionne' => $etablissement,'metiers' => $metiers,'metier_selectionne' => $metier,'stats' => $stats,'totals' => $totals,'prospection_totals' => $prospectionTotals]);}#[Route('/impressions', name: 'app_candidature_impressions')]public function impressions(Request $request): Response{$user = $this->getUser();// Récupération des métiers et établissements selon le rôleif ($this->isGranted('ROLE_ADMIN')) {// ADMIN : tous les métiers et tous les établissements$metiers = $this->entityManager->getRepository(Metier::class)->findAll();$etablissements = $this->entityManager->getRepository(Etablissement::class)->findAll();} elseif ($this->isGranted('ROLE_ENT') || $this->isGranted('ROLE_JURY')) {// ENT/JURY : uniquement les métiers de leur établissement$etablissement = $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);if ($etablissement) {$metiers = $this->getMetiersForEtablissement($etablissement);$etablissements = [$etablissement];} else {$metiers = [];$etablissements = [];$this->addFlash('warning', 'Votre compte n\'est pas associé à un établissement.');}} else {// AUTRES (CANDIDAT, etc.)$metiers = [];$etablissements = [];}// Traitement des exports POSTif ($request->isMethod('POST')) {$response = $this->handleExportActions($request);if ($response) return $response;}return $this->render('candidature/impressions.html.twig', ['metiers' => $metiers,'etablissements' => $etablissements,'codes' => $request->get('codes')]);}#[Route('/etablissement/{id}/metiers', name: 'app_etablissement_metiers', methods: ['GET'])]public function getMetiersByEtablissement(int $id): JsonResponse{$etablissement = $this->entityManager->getRepository(Etablissement::class)->find($id);return $this->json($etablissement ? $this->getMetiersWithDetails($etablissement) : []);}private function getMetiersForEtablissement(Etablissement $etablissement): array{return $this->entityManager->getRepository(Metier::class)->createQueryBuilder('m')->innerJoin('m.etablissementMetiers', 'em')->where('em.etablissement = :etablissement')->setParameter('etablissement', $etablissement)->orderBy('m.nom', 'ASC')->getQuery()->getResult();}private function getDetailedStatistics(?Etablissement $etablissement = null, ?Metier $metier = null): array{$qb = $this->entityManager->getRepository(Candidature::class)->createQueryBuilder('c')->innerJoin('c.etablissement', 'e')->innerJoin('c.metier', 'm')->leftJoin('c.user', 'u')->select('m.id as metier_id','m.nom as metier_nom','e.id as etablissement_id','e.nom as etablissement_nom','COUNT(c.id) as total','SUM(CASE WHEN c.etustatut = 2 THEN 1 ELSE 0 END) as eligible','SUM(CASE WHEN c.entstatut IS NOT NULL OR c.resultat IS NOT NULL THEN 1 ELSE 0 END) as evalue','SUM(CASE WHEN c.entstatut = 2 THEN 1 ELSE 0 END) as admissible','SUM(CASE WHEN c.resultat = 2 THEN 1 ELSE 0 END) as admis','SUM(CASE WHEN u.sexe = :homme THEN 1 ELSE 0 END) as hommes','SUM(CASE WHEN u.sexe = :femme THEN 1 ELSE 0 END) as femmes')->setParameter('homme', 'MASCULIN')->setParameter('femme', 'FEMININ')->groupBy('e.id, m.id')->orderBy('e.nom, m.nom');if ($etablissement) {$qb->andWhere('c.etablissement = :etablissement')->setParameter('etablissement', $etablissement);}if ($metier) {$qb->andWhere('c.metier = :metier')->setParameter('metier', $metier);}return $qb->getQuery()->getResult();}private function calculateTotals(array $stats): array{$totals = ['total' => 0,'eligible' => 0,'evalue' => 0,'admissible' => 0,'admis' => 0,'hommes' => 0,'femmes' => 0];foreach ($stats as $stat) {$totals['total'] += $stat['total'];$totals['eligible'] += $stat['eligible'];$totals['evalue'] += $stat['evalue'];$totals['admissible'] += $stat['admissible'];$totals['admis'] += $stat['admis'];$totals['hommes'] += $stat['hommes'];$totals['femmes'] += $stat['femmes'];}// Calculer les non-évalués$totals['non_evalue'] = $totals['total'] - $totals['evalue'];$totals['non_eligible'] = $totals['total'] - $totals['eligible'];return $totals;}private function getProspectionTotals(?Etablissement $etablissement = null, ?Metier $metier = null): array{$totals = ['nb_entreprises' => 0, 'total_postes' => 0];$entreprisesQb = $this->entityManager->getRepository(Prospection::class)->createQueryBuilder('p')->select('COUNT(DISTINCT p.id)');if ($etablissement) {$entreprisesQb->andWhere('p.etablissement = :etab')->setParameter('etab', $etablissement);}$totals['nb_entreprises'] = (int) $entreprisesQb->getQuery()->getSingleScalarResult();$postesQb = $this->entityManager->getRepository(ProspectionMetier::class)->createQueryBuilder('pm')->innerJoin('pm.prospection', 'p')->select('SUM(pm.nombrePostes)');if ($etablissement) {$postesQb->andWhere('p.etablissement = :etab')->setParameter('etab', $etablissement);}if ($metier) {$postesQb->andWhere('pm.metier = :metier')->setParameter('metier', $metier);}$totals['total_postes'] = (int) $postesQb->getQuery()->getSingleScalarResult();return $totals;}private function exportDetailedStatistics(array $stats, ?Etablissement $etablissement, ?Metier $metier): Response{$headers = ['Établissement','Métier','Total','Hommes','Femmes','Éligibles','Évalués','Admissibles','Admis'];$datas = [];foreach ($stats as $stat) {$datas[] = [$stat['etablissement_nom'],$stat['metier_nom'],$stat['total'],$stat['hommes'],$stat['femmes'],$stat['eligible'],$stat['evalue'],$stat['admissible'],$stat['admis']];}$filename = 'STATISTIQUES';if ($etablissement) $filename .= '_' . $etablissement->getNom();if ($metier) $filename .= '_' . $metier->getNom();$filename .= '_' . date('Ymd') . '.xlsx';$this->spreadsheetGenerator->generate($filename, $headers, $datas);return new Response();}#[Route('/stat/etablissement/{id}/metiers', name: 'app_stat_etablissement_metiers', methods: ['GET'])]public function getMetiersForStat(int $id): JsonResponse{$etablissement = $this->entityManager->getRepository(Etablissement::class)->find($id);if (!$etablissement) {return $this->json([]);}$metiers = $this->getMetiersForEtablissement($etablissement);return $this->json(array_map(fn($m) => ['id' => $m->getId(),'nom' => $m->getNom()], $metiers));}// ==================== MÉTHODES PRIVÉES ====================private function canSubmitCandidature(?User $user): bool{return $user && $this->countUserCandidatures($user) < 1;}private function countUserCandidatures(User $user): int{return $this->entityManager->getRepository(Candidature::class)->count(['user' => $user]);}private function createCandidature(?User $user): Candidature{$candidature = new Candidature();if ($user) $candidature->setUser($user);$candidature->setCreation(new \DateTime());return $candidature;}private function prefillFromRequest(Candidature $candidature, Request $request): void{if ($metierId = $request->query->get('metier')) {$metier = $this->entityManager->getRepository(Metier::class)->find($metierId);if ($metier) $candidature->setMetier($metier);}if ($etablissementId = $request->query->get('etablissement')) {$etablissement = $this->entityManager->getRepository(Etablissement::class)->find($etablissementId);if ($etablissement) $candidature->setEtablissement($etablissement);}}private function processCandidatureFiles(Candidature $candidature, $form): bool{$missingFiles = [];$fichiers = [];foreach (array_keys($this->constant->document_labels) as $field) {$file = $form->get($field)->getData();$fichiers[$field] = $file;$getter = 'get' . ucfirst($field);$existingFile = method_exists($candidature, $getter) ? $candidature->$getter() : null;if (($this->constant->document_labels[$field]['required'] ?? false) && !$file && !$existingFile) {$missingFiles[] = $this->constant->document_labels[$field]['text'] ?? $field;}}if ($missingFiles) {$this->addFlash('error', 'Documents requis : ' . implode(', ', $missingFiles));return false;}$this->handleUploadedFiles($candidature, $fichiers);return true;}private function validateRequiredDocuments(Candidature $candidature, $form): bool{$missingFiles = [];foreach (array_keys($this->constant->document_labels) as $field) {$file = $form->get($field)->getData();$getter = 'get' . ucfirst($field);$existingFile = method_exists($candidature, $getter) ? $candidature->$getter() : null;if (($this->constant->document_labels[$field]['required'] ?? false) && !$file && !$existingFile) {$missingFiles[] = $this->constant->document_labels[$field]['text'] ?? $field;}}if ($missingFiles) {$this->addFlash('error', 'Documents requis : ' . implode(', ', $missingFiles));return false;}return true;}private function displayFormErrors($form): void{if ($form->isSubmitted() && !$form->isValid()) {foreach ($form->getErrors(true) as $error) {$this->addFlash('error', $error->getMessage());}}}private function handleUploadedFiles(Candidature $candidature, array $fichiers): void{$dir = $this->params->get('dir_media') . $candidature->getNumero() . '/';$this->fileUploader->mkdir($dir);foreach (['fphoto', 'fpiece', 'fextrait', 'fniveau', 'fautre'] as $field) {$file = $fichiers[$field] ?? null;if ($file) {$getter = 'get' . ucfirst($field);$setter = 'set' . ucfirst($field);$oldFile = $candidature->$getter();$fileName = $this->fileUploader->upload($file, $dir, $oldFile);$candidature->$setter($fileName);}}}private function generateNumero(): string{$last = $this->entityManager->getRepository(Candidature::class)->findOneBy([], ['id' => 'DESC']);$nextId = $last ? $last->getId() + 1 : 1;// Format: 2600001A (année sur 2 chiffres + numéro sur 5 chiffres + lettre aléatoire)$year = substr(date('Y'), -2); // 26 au lieu de 2026$letters = 'ABCDEFGHIJKLMNPQRSTUVWXYZ'; // Sans O pour éviter confusion avec 0$letter = $letters[random_int(0, strlen($letters) - 1)];return $year . str_pad($nextId, 5, '0', STR_PAD_LEFT) . $letter;}private function deleteCandidature(Candidature $candidature): void{$this->fileUploader->remove($this->params->get('dir_media') . $candidature->getNumero());$this->entityManager->remove($candidature);$this->entityManager->flush();}private function getStatisticsByMetier(?Metier $metier = null): array{$metiers = $metier ? [$metier] : $this->entityManager->getRepository(Metier::class)->findAll();$results = [];foreach ($metiers as $m) {$stats = $this->calculateMetierStatistics($m);$results[] = ['name' => $m->getNom(),'sum_h' => $stats['hommes'],'sum_f' => $stats['femmes'],'sum_pl' => $stats['places'],'sum' => $stats['total'],'sum_evalue' => $stats['evalue'],'sum_admissible' => $stats['admissible'],'sum_admis' => $stats['admis']];}return $results;}private function calculateMetierStatistics(Metier $metier): array{$repo = $this->entityManager->getRepository(Candidature::class);$candidatures = $repo->createQueryBuilder('c')->innerJoin('c.etablissement', 'e')->leftJoin('c.user', 'u')->addSelect('u')->where('c.metier = :metier')->setParameter('metier', $metier)->getQuery()->getResult();$countH = $countF = 0;foreach ($candidatures as $c) {$user = $c->getUser();if ($user) {$user->getSexe() === 'MASCULIN' ? $countH++ : $countF++;}}// Évalués (candidatures ayant au moins une note)$qb = $repo->createQueryBuilder('c')->select('COUNT(c.id)')->where('c.metier = :metier');$orConditions = [];for ($i = 1; $i <= 13; $i++) {$orConditions[] = $qb->expr()->isNotNull('c.note' . $i);}$evalue = (int) $qb->andWhere($qb->expr()->orX(...$orConditions))->setParameter('metier', $metier)->getQuery()->getSingleScalarResult();// Places totales (somme des places dans tous les établissements pour ce métier)$places = array_sum(array_map(fn($em) => $em->getNbrplace() ?? 0,$this->entityManager->getRepository(EtablissementMetier::class)->findBy(['metier' => $metier])));$admissible = (int) $repo->createQueryBuilder('c')->select('COUNT(c.id)')->where('c.metier = :metier')->andWhere('c.entstatut = 2')->setParameter('metier', $metier)->getQuery()->getSingleScalarResult();$admis = (int) $repo->createQueryBuilder('c')->select('COUNT(c.id)')->where('c.metier = :metier')->andWhere('c.resultat = 2')->setParameter('metier', $metier)->getQuery()->getSingleScalarResult();return ['total' => $countH + $countF,'hommes' => $countH,'femmes' => $countF,'places' => $places,'evalue' => $evalue,'admissible' => $admissible,'admis' => $admis];}private function exportStatistics(array $results): Response{$headers = ['N°', 'Métier', 'Postes', 'Hommes', 'Femmes', 'Total', 'Évalués', 'Non évalués', 'Admissibles', 'Admis'];$datas = [];foreach ($results as $i => $r) {$datas[] = [$i + 1,$r['name'],$r['sum_pl'],$r['sum_h'],$r['sum_f'],$r['sum'],$r['sum_evalue'],$r['sum'] - $r['sum_evalue'],$r['sum_admissible'],$r['sum_admis']];}$this->spreadsheetGenerator->generate('STATISTIQUES_CANDIDATURES.xlsx', $headers, $datas);return new Response();}private function getMetiersWithDetails(Etablissement $etablissement): array{$metiers = $this->entityManager->getRepository(Metier::class)->createQueryBuilder('m')->innerJoin('m.etablissementMetiers', 'em')->innerJoin('m.secteur', 's')->where('em.etablissement = :etablissement')->setParameter('etablissement', $etablissement)->orderBy('s.nom', 'ASC')->addOrderBy('m.nom', 'ASC')->getQuery()->getResult();return array_map(fn($metier) => ['id' => $metier->getId(),'nom' => $metier->getNom(),'secteur_nom' => $metier->getSecteur()->getNom(),'nbrplace' => ($em = $this->entityManager->getRepository(EtablissementMetier::class)->findOneBy(['etablissement' => $etablissement, 'metier' => $metier])) ? $em->getNbrplace() : null,'niveau' => $em?->getNiveauRequis(),'duree' => $em?->getDureeFormation()], $metiers);}private function handleExportActions(Request $request): ?Response{$action = $request->request->get('action');if ($action === 'export_codes') {return $this->exportByCodes($request->request->get('codes'));}if ($action === 'export_filters') {return $this->exportByFilters($request->request->get('etablissement'),$request->request->get('statut', 'TOUS'),$request->request->get('metier'));}if ($action === 'export_liste_candidats_inscrits') {return $this->exportListeCandidatsInscrits($request->request->get('etablissement_liste'),$request->request->get('metier_liste'));}return null;}private function exportListeCandidatsInscrits(?string $etablissementId, ?string $metierId): ?Response{if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_ENT') && !$this->isGranted('ROLE_JURY')) {throw $this->createAccessDeniedException('Accès non autorisé à cet export.');}$user = $this->getUser();$etablissement = null;if ($this->isGranted('ROLE_ADMIN')) {if (!$etablissementId || $etablissementId === 'TOUS') {$this->addFlash('error', 'Veuillez sélectionner un établissement.');return $this->redirectToRoute('app_candidature_impressions');}$etablissement = $this->entityManager->getRepository(Etablissement::class)->find((int) $etablissementId);if (!$etablissement) {$this->addFlash('error', 'Établissement invalide.');return $this->redirectToRoute('app_candidature_impressions');}} else {$etablissement = $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);if (!$etablissement) {$this->addFlash('warning', 'Votre compte n\'est pas associé à un établissement.');return $this->redirectToRoute('app_candidature_impressions');}if ($etablissementId && (int) $etablissementId !== $etablissement->getId()) {$this->addFlash('error', 'Sélection d\'établissement non autorisée.');return $this->redirectToRoute('app_candidature_impressions');}}$metiersAutorises = $this->getMetiersForEtablissement($etablissement);$metierIdsAutorises = array_map(static fn(Metier $metier): int => $metier->getId(), $metiersAutorises);$qb = $this->entityManager->getRepository(Candidature::class)->createQueryBuilder('c')->leftJoin('c.user', 'u')->leftJoin('c.etablissement', 'e')->leftJoin('c.metier', 'm')->addSelect('u', 'e', 'm')->andWhere('c.etablissement = :etablissement')->setParameter('etablissement', $etablissement)->orderBy('u.nom', 'ASC')->addOrderBy('u.prenoms', 'ASC')->addOrderBy('c.numero', 'ASC');$metierSelectionne = null;if ($metierId && $metierId !== 'TOUS') {$metierSelectionneId = (int) $metierId;if (!in_array($metierSelectionneId, $metierIdsAutorises, true)) {$this->addFlash('error', 'Sélection de métier non autorisée.');return $this->redirectToRoute('app_candidature_impressions');}$metierSelectionne = $this->entityManager->getRepository(Metier::class)->find($metierSelectionneId);if (!$metierSelectionne) {$this->addFlash('error', 'Métier invalide.');return $this->redirectToRoute('app_candidature_impressions');}$qb->andWhere('c.metier = :metier')->setParameter('metier', $metierSelectionne);}$candidatures = $qb->getQuery()->getResult();if (empty($candidatures)) {$this->addFlash('warning', 'Aucune candidature trouvée pour les critères sélectionnés.');return $this->redirectToRoute('app_candidature_impressions');}$headers = ['N°', 'Numéro', 'Nom', 'Prénoms', 'Contact', 'Établissement', 'Métier'];$datas = [];foreach ($candidatures as $index => $candidature) {$candidat = $candidature->getUser();$datas[] = [$index + 1,$candidature->getNumero(),$candidat?->getNom() ?? '',$candidat?->getPrenoms() ?? '',$candidat?->getContact() ?? '',$candidature->getEtablissement()?->getNom() ?? '',$candidature->getMetier()?->getNom() ?? '',];}$filename = 'LISTE_CANDIDATS_INSCRITS_' . date('Ymd_His') . '.xlsx';$this->spreadsheetGenerator->generate($filename, $headers, $datas);return new Response();}private function exportByCodes(string $codes): ?Response{$numeros = array_filter(array_map('trim', explode(';', str_replace(["\r", "\n"], ';', $codes))));if (empty($numeros)) return null;$results = $this->entityManager->getRepository(Candidature::class)->findBy(['numero' => $numeros]);if (empty($results)) return null;$headers = ['N°', 'Numéro', 'Nom', 'Prénoms', 'Date naissance', 'Lieu naissance', 'Sexe', 'Contact', 'Métier', 'Note'];$datas = [];foreach ($results as $i => $r) {$user = $r->getUser();$datas[] = [$i + 1,$r->getNumero(),$user ? strtoupper($user->getNom()) : '',$user ? strtoupper($user->getPrenoms()) : '',$user?->getDatenaissance()?->format('d/m/Y') ?? '',$user ? strtoupper($user->getLieunaissance() ?? '') : '',$user && $user->getSexe() === 'MASCULIN' ? 'M' : 'F',$user?->getContact() ?? '',$r->getMetier()?->getNom() ?? '',$this->calculateTotalNote($r)];}$this->spreadsheetGenerator->generate('EXPORT_PAR_CODE.xlsx', $headers, $datas);return new Response();}private function exportByFilters($etablissement, $statut, $metierId): ?Response{$qb = $this->entityManager->getRepository(Candidature::class)->createQueryBuilder('c')->innerJoin('c.metier', 'm')->innerJoin('c.etablissement', 'e')->leftJoin('c.user', 'u');if ($etablissement && $etablissement !== 'TOUS') {$qb->andWhere('c.etablissement = :etablissement')->setParameter('etablissement', $etablissement);}if ($metierId) {$qb->andWhere('c.metier = :metier')->setParameter('metier', $metierId);}if ($statut === '2') $qb->andWhere('c.entstatut = 2');elseif ($statut === '2|2') $qb->andWhere('c.resultat = 2');elseif ($statut === '1') $qb->andWhere('c.etustatut = 1');$candidatures = $qb->getQuery()->getResult();if (empty($candidatures)) return null;$headers = ['N°', 'Numéro', 'Nom', 'Prénoms', 'Date naissance', 'Sexe', 'Contact', 'Métier', 'Établissement', 'Statut'];$datas = [];foreach ($candidatures as $i => $c) {$user = $c->getUser();$datas[] = [$i + 1,$c->getNumero(),$user?->getNom() ?? '',$user?->getPrenoms() ?? '',$user?->getDatenaissance()?->format('d/m/Y') ?? '',$user?->getSexe() ?? '',$user?->getContact() ?? '',$c->getMetier()?->getNom() ?? '',$c->getEtablissement()?->getNom() ?? '',self::STATUT_LABELS[$c->getEtustatut()] ?? self::STATUT_LABELS['default']];}$this->spreadsheetGenerator->generate('EXPORT_CANDIDATURES.xlsx', $headers, $datas);return new Response();}private function calculateTotalNote(Candidature $candidature): float{$total = 0;for ($i = 1; $i <= 13; $i++) {$method = 'getNote' . $i;$total += $candidature->$method() ?? 0;}return $total;}private function generateFichePdf(Candidature $candidature): Response{return $this->pdfGenerator->stream('candidature/print/fiche.html.twig', ['candidature' => $candidature,'image' => $this->prepareImagesForPdf($candidature)]);}private function generateConvocationPdf(Candidature $candidature): Response{return $this->pdfGenerator->stream('candidature/print/convocation.html.twig', ['candidature' => $candidature,'image' => $this->prepareImagesForPdf($candidature)]);}private function printStatistics(Request $request): Response{$metierId = $request->query->get('metier');$metier = $metierId ? $this->entityManager->getRepository(Metier::class)->find($metierId) : null;$results = $this->getStatisticsByMetier($metier);$dirImage = $this->params->get('dir_image');$images = ['entete' => $this->pdfGenerator->imageToBase64($dirImage . 'entete_generique_e2c.png')];return $this->pdfGenerator->stream('candidature/print/stat.html.twig', ['results' => $results,'image' => $images,'date' => new \DateTime()]);}private function prepareImagesForPdf(Candidature $candidature): array{$dirMedia = $this->params->get('dir_media');$dirImage = $this->params->get('dir_image');$photoPath = $candidature->getFphoto()? $dirMedia . $candidature->getNumero() . '/' . $candidature->getFphoto(): $dirImage . 'user.svg';return ['entete' => $this->pdfGenerator->imageToBase64($dirImage . 'entete_generique_e2c.png'),'photo' => $this->pdfGenerator->imageToBase64($photoPath)];}private function handleInvalidConvocation(): Response{$this->addFlash('error', 'Ce candidat n\'est pas admissible, aucune convocation disponible');return $this->redirectToRoute('app_candidature_impressions');}}