src/Controller/CandidatureController.php line 50

  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Admis;
  4. use App\Entity\Candidature;
  5. use App\Entity\Metier;
  6. use App\Entity\Etablissement;
  7. use App\Entity\User;
  8. use App\Entity\Secteur;
  9. use App\Entity\DirectionRegionale;
  10. use App\Entity\EtablissementMetier;
  11. use App\Entity\Prospection;
  12. use App\Entity\ProspectionMetier;
  13. use App\Form\CandidatureType;
  14. use App\Service\FileUploader;
  15. use App\Service\PdfGenerator;
  16. use App\Service\SpreadsheetGenerator;
  17. use App\Service\Constant;
  18. use Doctrine\ORM\EntityManagerInterface;
  19. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  20. use Symfony\Component\HttpFoundation\Request;
  21. use Symfony\Component\HttpFoundation\Response;
  22. use Symfony\Component\HttpFoundation\JsonResponse;
  23. use Symfony\Component\Routing\Annotation\Route;
  24. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  25. use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
  26. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
  27. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  28. #[Route('/candidature')]
  29. class CandidatureController extends AbstractController
  30. {
  31.     private const STATUT_LABELS = [
  32.         => 'Accepté',
  33.         => 'Rejeté',
  34.         'default' => 'En attente'
  35.     ];
  36.     public function __construct(
  37.         private readonly EntityManagerInterface $entityManager,
  38.         private readonly UserPasswordHasherInterface $passwordHasher,
  39.         private readonly FileUploader $fileUploader,
  40.         private readonly PdfGenerator $pdfGenerator,
  41.         private readonly SpreadsheetGenerator $spreadsheetGenerator,
  42.         private readonly ParameterBagInterface $params,
  43.         private readonly Constant $constant
  44.     ) {}
  45.     #[Route('/'name'app_candidature_index'methods: ['GET'])]
  46.     #[IsGranted('ROLE_USER')]
  47.     public function index(): Response
  48.     {
  49.         $user $this->getUser();
  50.         $qb $this->entityManager->getRepository(Candidature::class)
  51.             ->createQueryBuilder('c')
  52.             ->leftJoin('c.etablissement''e')
  53.             ->leftJoin('c.metier''m')
  54.             ->addSelect('e''m');
  55.         // ADMIN : toutes les candidatures
  56.         if ($this->isGranted('ROLE_ADMIN')) {
  57.             $qb->leftJoin('c.user''u')->addSelect('u');
  58.             $is_admin true;
  59.             // ENT : mêmes colonnes/actions qu'admin mais filtre par établissement
  60.         } elseif ($this->isGranted('ROLE_ENT')) {
  61.             $qb->leftJoin('c.user''u')->addSelect('u')
  62.                 ->where('e.user = :user')  // Filtre sur l'établissement de l'utilisateur ENT
  63.                 ->setParameter('user'$user);
  64.             $is_admin true;
  65.             // JURY : mêmes droits que ENT pour l'affichage (voit les candidatures de son établissement)
  66.         } elseif ($this->isGranted('ROLE_JURY')) {
  67.             $qb->leftJoin('c.user''u')->addSelect('u')
  68.                 ->where('e.user = :user')
  69.                 ->setParameter('user'$user);
  70.             $is_admin true;
  71.             // CANDIDAT : ses propres candidatures
  72.         } else {
  73.             $qb->where('c.user = :user')
  74.                 ->setParameter('user'$user);
  75.             $is_admin false;
  76.         }
  77.         $candidatures $qb->orderBy('c.id''DESC')->getQuery()->getResult();
  78.         return $this->render('candidature/index.html.twig', [
  79.             'candidatures' => $candidatures,
  80.             'is_admin' => $is_admin,
  81.         ]);
  82.     }
  83.     #[Route('/new'name'app_candidature_new'methods: ['GET''POST'])]
  84.     public function new(Request $request): Response
  85.     {
  86.         // Bloquer les utilisateurs ENT
  87.         if ($this->isGranted('ROLE_ENT')) {
  88.             $this->addFlash(
  89.                 'error',
  90.                 'Vous êtes connecté comme établissement. <a href="' $this->generateUrl('app_logout') . '" class="underline font-semibold">Se déconnecter</a> pour créer une candidature.'
  91.             );
  92.             return $this->redirectToRoute('app_candidature_index');
  93.         }
  94.         if ($this->getUser() && !$this->canSubmitCandidature($this->getUser())) {
  95.             $this->addFlash('error''Vous avez déjà soumis une candidature.');
  96.             return $this->redirectToRoute('app_candidature_index');
  97.         }
  98.         $candidature $this->createCandidature($this->getUser());
  99.         $this->prefillFromRequest($candidature$request);
  100.         $form $this->createForm(CandidatureType::class, $candidature);
  101.         $form->handleRequest($request);
  102.         if ($form->isSubmitted() && $form->isValid()) {
  103.             if (!$this->canSubmitCandidature($this->getUser())) {
  104.                 $this->addFlash('error''Vous avez déjà soumis une candidature.');
  105.                 return $this->redirectToRoute('app_candidature_index');
  106.             }
  107.             // Validation : vérifier que le métier est autorisé pour l'établissement
  108.             $etab $candidature->getEtablissement();
  109.             $met $candidature->getMetier();
  110.             if ($etab && $met) {
  111.                 $emExists $this->entityManager->getRepository(EtablissementMetier::class)
  112.                     ->findOneBy(['etablissement' => $etab'metier' => $met]);
  113.                 if (!$emExists) {
  114.                     $this->addFlash('error''Le métier sélectionné n\'est pas ouvert dans cet établissement.');
  115.                     return $this->render('candidature/form.html.twig', [
  116.                         'candidature' => $candidature,
  117.                         'form' => $form,
  118.                         'document_labels' => $this->constant->document_labels,
  119.                         'candidaturesCount' => $this->getUser() ? $this->countUserCandidatures($this->getUser()) : 0
  120.                     ]);
  121.                 }
  122.             }
  123.             $candidature->setNumero($this->generateNumero());
  124.             if ($this->processCandidatureFiles($candidature$form)) {
  125.                 $this->entityManager->persist($candidature);
  126.                 $this->entityManager->flush();
  127.                 return $this->redirectToRoute('app_candidature_success', ['id' => $candidature->getId()]);
  128.             }
  129.         }
  130.         $this->displayFormErrors($form);
  131.         return $this->render('candidature/form.html.twig', [
  132.             'candidature' => $candidature,
  133.             'form' => $form,
  134.             'document_labels' => $this->constant->document_labels,
  135.             'candidaturesCount' => $this->getUser() ? $this->countUserCandidatures($this->getUser()) : 0
  136.         ]);
  137.     }
  138.     #[Route('/success/{id}'name'app_candidature_success'requirements: ['id' => '\\d+'])]
  139.     #[Security("is_granted('ROLE_CANDIDAT') and user === candidature.getUser() or is_granted('ROLE_ADMIN')")]
  140.     public function success(Candidature $candidature): Response
  141.     {
  142.         return $this->render('candidature/success.html.twig', ['candidature' => $candidature]);
  143.     }
  144.     #[Route('/{id}/edit'name'app_candidature_edit'methods: ['GET''POST'], requirements: ['id' => '\\d+'])]
  145.     #[Security("(is_granted('ROLE_ADMIN') or (is_granted('ROLE_ENT') and candidature.getEtablissement() and candidature.getEtablissement().getUser() == user))")]
  146.     public function edit(Request $requestCandidature $candidature): Response
  147.     {
  148.         $form $this->createForm(CandidatureType::class, $candidature, ['evaluation_only' => true]);
  149.         $form->handleRequest($request);
  150.         if ($form->isSubmitted() && $form->isValid()) {
  151.             $candidature->setModification(new \DateTime());
  152.             $this->entityManager->flush();
  153.             $this->addFlash('success''Évaluation mise à jour avec succès.');
  154.             return $this->redirectToRoute('app_candidature_index');
  155.         }
  156.         return $this->render('candidature/form.html.twig', [
  157.             'candidature' => $candidature,
  158.             'form' => $form,
  159.             'document_labels' => $this->constant->document_labels
  160.         ]);
  161.     }
  162.     #[Route('/{id}/update-info'name'app_candidature_update_info'methods: ['POST'], requirements: ['id' => '\\d+'])]
  163.     #[Security("(is_granted('ROLE_ADMIN') or (is_granted('ROLE_ENT') and candidature.getEtablissement() and candidature.getEtablissement().getUser() == user))")]
  164.     public function updateInfo(Request $requestCandidature $candidature): Response
  165.     {
  166.         if (!$this->isCsrfTokenValid('candidature_update_info_' $candidature->getId(), $request->request->get('_token'))) {
  167.             $this->addFlash('error''Token de sécurité invalide.');
  168.             return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);
  169.         }
  170.         // Mettre à jour nom/prénoms/contact du candidat associé
  171.         $candidatUser $candidature->getUser();
  172.         if ($candidatUser) {
  173.             $nom trim($request->request->get('nom'''));
  174.             $prenoms trim($request->request->get('prenoms'''));
  175.             $contact trim($request->request->get('contact'''));
  176.             if ($nom !== '') {
  177.                 $candidatUser->setNom($nom);
  178.             }
  179.             if ($prenoms !== '') {
  180.                 $candidatUser->setPrenoms($prenoms);
  181.             }
  182.             $candidatUser->setContact($contact !== '' $contact null);
  183.         }
  184.         // Mettre à jour le métier
  185.         $metierId $request->request->get('metier_id');
  186.         if ($metierId) {
  187.             $metier $this->entityManager->getRepository(Metier::class)->find($metierId);
  188.             if ($metier) {
  189.                 $emExists $this->entityManager->getRepository(EtablissementMetier::class)
  190.                     ->findOneBy(['etablissement' => $candidature->getEtablissement(), 'metier' => $metier]);
  191.                 if ($emExists) {
  192.                     $candidature->setMetier($metier);
  193.                 } else {
  194.                     $this->addFlash('error'"Le métier sélectionné n'est pas ouvert dans cet établissement.");
  195.                     return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);
  196.                 }
  197.             }
  198.         }
  199.         // Traiter les fichiers uploadés
  200.         $dir $this->params->get('dir_media') . $candidature->getNumero() . '/';
  201.         $this->fileUploader->mkdir($dir);
  202.         foreach (['fphoto''fpiece''fextrait''fniveau''fautre'] as $field) {
  203.             $file $request->files->get($field);
  204.             if ($file) {
  205.                 $getter 'get' ucfirst($field);
  206.                 $setter 'set' ucfirst($field);
  207.                 $oldFile $candidature->$getter();
  208.                 $fileName $this->fileUploader->upload($file$dir$oldFile);
  209.                 $candidature->$setter($fileName);
  210.             }
  211.         }
  212.         $candidature->setModification(new \DateTime());
  213.         $this->entityManager->flush();
  214.         $this->addFlash('success''Informations mises à jour avec succès.');
  215.         return $this->redirectToRoute('app_candidature_edit', ['id' => $candidature->getId()]);
  216.     }
  217.     #[Route('/{id}'name'app_candidature_delete'methods: ['POST'], requirements: ['id' => '\\d+'])]
  218.     #[Security("(is_granted('ROLE_CANDIDAT') and user === candidature.getUser()) or is_granted('ROLE_ADMIN')")]
  219.     public function delete(Request $requestCandidature $candidature): Response
  220.     {
  221.         if ($this->isCsrfTokenValid('delete' $candidature->getId(), $request->request->get('_token'))) {
  222.             $this->deleteCandidature($candidature);
  223.             $this->addFlash('success''Candidature supprimée avec succès.');
  224.         }
  225.         return $this->redirectToRoute('app_candidature_index');
  226.     }
  227.     // ==================== ROUTES D'IMPRESSION ET STATISTIQUES ====================
  228.     #[Route('/print'name'app_candidature_print'methods: ['GET'])]
  229.     public function print(Request $request): Response
  230.     {
  231.         $type $request->query->get('type');
  232.         $numero $request->query->get('numero');
  233.         if ($type === 'stat') {
  234.             return $this->printStatistics($request);
  235.         }
  236.         if (!$numero) {
  237.             $this->addFlash('error''Numéro de candidature manquant');
  238.             return $this->redirectToRoute('app_candidature_impressions');
  239.         }
  240.         $candidature $this->entityManager->getRepository(Candidature::class)
  241.             ->findOneBy(['numero' => trim($numero)]);
  242.         if (!$candidature) {
  243.             $this->addFlash('error''Candidature non trouvée');
  244.             return $this->redirectToRoute('app_candidature_impressions');
  245.         }
  246.         return match ($type) {
  247.             'fiche' => $this->generateFichePdf($candidature),
  248.             'convocation' => $candidature->getEntstatut() === 2
  249.                 $this->generateConvocationPdf($candidature)
  250.                 : $this->handleInvalidConvocation(),
  251.             default => $this->redirectToRoute('app_candidature_impressions')
  252.         };
  253.     }
  254.     #[Route('/stat'name'app_candidature_stat')]
  255.     #[Security("is_granted('ROLE_ADMIN') or is_granted('ROLE_ENT') or is_granted('ROLE_JURY') or is_granted('ROLE_COM')")]
  256.     public function stat(Request $request): Response
  257.     {
  258.         $user $this->getUser();
  259.         $etablissementId $request->query->get('etablissement');
  260.         $metierId $request->query->get('metier');
  261.         // --- SÉCURITÉ ET FILTRAGE AUTOMATIQUE ---
  262.         // Si c'est un ENT ou JURY, on force son établissement
  263.         if ($this->isGranted('ROLE_ENT') || $this->isGranted('ROLE_JURY')) {
  264.             $etabLie $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);
  265.             if ($etabLie) {
  266.                 $etablissementId $etabLie->getId();
  267.             }
  268.         }
  269.         $etablissement $etablissementId $this->entityManager->getRepository(Etablissement::class)->find($etablissementId) : null;
  270.         $metier $metierId $this->entityManager->getRepository(Metier::class)->find($metierId) : null;
  271.         // Pour le filtre : Admin voit tout, l'ENT ne voit que lui-même dans la liste
  272.         if ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_COM')) {
  273.             $etablissements $this->entityManager->getRepository(Etablissement::class)->findBy([], ['nom' => 'ASC']);
  274.         } else {
  275.             $etablissements $etablissement ? [$etablissement] : [];
  276.         }
  277.         // Récupérer les métiers de l'établissement sélectionné (pour le filtre)
  278.         $metiers = [];
  279.         if ($etablissement) {
  280.             $metiers $this->getMetiersForEtablissement($etablissement);
  281.         }
  282.         // Obtenir les statistiques (sans la contrainte EtablissementMetier)
  283.         $stats $this->getDetailedStatistics($etablissement$metier);
  284.         // Calculer les totaux pour les graphs
  285.         $totals $this->calculateTotals($stats);
  286.         if ($request->query->get('export') === 'true') {
  287.             return $this->exportDetailedStatistics($stats$etablissement$metier);
  288.         }
  289.         // --- Statistiques de prospection ---
  290.         $prospectionTotals $this->getProspectionTotals($etablissement$metier);
  291.         $statsSecteur $this->isGranted('ROLE_ADMIN') ? $this->getStatsSecteurData() : [];
  292.         $statsRegion $this->isGranted('ROLE_ADMIN') ? $this->getStatsRegionData() : [];
  293.         return $this->render('candidature/stat.html.twig', [
  294.             'etablissements' => $etablissements,
  295.             'etablissement_selectionne' => $etablissement,
  296.             'metiers' => $metiers,
  297.             'metier_selectionne' => $metier,
  298.             'stats' => $stats,
  299.             'stats_secteur' => $statsSecteur,
  300.             'stats_region' => $statsRegion,
  301.             'totals' => $totals,
  302.             'prospection_totals' => $prospectionTotals
  303.         ]);
  304.     }
  305.     #[Route('/impressions'name'app_candidature_impressions')]
  306.     public function impressions(Request $request): Response
  307.     {
  308.         $user $this->getUser();
  309.         // Récupération des métiers et établissements selon le rôle
  310.         if ($this->isGranted('ROLE_ADMIN')) {
  311.             // ADMIN : tous les métiers et tous les établissements
  312.             $metiers $this->entityManager->getRepository(Metier::class)->findAll();
  313.             $etablissements $this->entityManager->getRepository(Etablissement::class)->findAll();
  314.         } elseif ($this->isGranted('ROLE_ENT') || $this->isGranted('ROLE_JURY')) {
  315.             // ENT/JURY : uniquement les métiers de leur établissement
  316.             $etablissement $this->entityManager->getRepository(Etablissement::class)
  317.                 ->findOneBy(['user' => $user]);
  318.             if ($etablissement) {
  319.                 $metiers $this->getMetiersForEtablissement($etablissement);
  320.                 $etablissements = [$etablissement];
  321.             } else {
  322.                 $metiers = [];
  323.                 $etablissements = [];
  324.                 $this->addFlash('warning''Votre compte n\'est pas associé à un établissement.');
  325.             }
  326.         } else {
  327.             // AUTRES (CANDIDAT, etc.)
  328.             $metiers = [];
  329.             $etablissements = [];
  330.         }
  331.         // Traitement des exports POST
  332.         if ($request->isMethod('POST')) {
  333.             $response $this->handleExportActions($request);
  334.             if ($response) return $response;
  335.         }
  336.         return $this->render('candidature/impressions.html.twig', [
  337.             'metiers' => $metiers,
  338.             'etablissements' => $etablissements,
  339.             'codes' => $request->get('codes')
  340.         ]);
  341.     }
  342.     #[Route('/etablissement/{id}/metiers'name'app_etablissement_metiers'methods: ['GET'])]
  343.     public function getMetiersByEtablissement(int $id): JsonResponse
  344.     {
  345.         $etablissement $this->entityManager->getRepository(Etablissement::class)->find($id);
  346.         return $this->json($etablissement $this->getMetiersWithDetails($etablissement) : []);
  347.     }
  348.     private function getMetiersForEtablissement(Etablissement $etablissement): array
  349.     {
  350.         return $this->entityManager->getRepository(Metier::class)
  351.             ->createQueryBuilder('m')
  352.             ->innerJoin('m.etablissementMetiers''em')
  353.             ->where('em.etablissement = :etablissement')
  354.             ->setParameter('etablissement'$etablissement)
  355.             ->orderBy('m.nom''ASC')
  356.             ->getQuery()
  357.             ->getResult();
  358.     }
  359.     private function getDetailedStatistics(?Etablissement $etablissement null, ?Metier $metier null): array
  360.     {
  361.         $qb $this->entityManager->getRepository(Candidature::class)
  362.             ->createQueryBuilder('c')
  363.             ->innerJoin('c.etablissement''e')
  364.             ->innerJoin('c.metier''m')
  365.             ->leftJoin('c.user''u')
  366.             ->select(
  367.                 'm.id as metier_id',
  368.                 'm.nom as metier_nom',
  369.                 'e.id as etablissement_id',
  370.                 'e.nom as etablissement_nom',
  371.                 'COUNT(c.id) as total',
  372.                 'SUM(CASE WHEN c.etustatut = 2 THEN 1 ELSE 0 END) as eligible',
  373.                 'SUM(CASE WHEN c.entstatut IS NOT NULL OR c.resultat IS NOT NULL THEN 1 ELSE 0 END) as evalue',
  374.                 'SUM(CASE WHEN c.entstatut = 2 THEN 1 ELSE 0 END) as admissible',
  375.                 'SUM(CASE WHEN c.resultat = 2 THEN 1 ELSE 0 END) as admis',
  376.                 'SUM(CASE WHEN u.sexe = :homme THEN 1 ELSE 0 END) as hommes',
  377.                 'SUM(CASE WHEN u.sexe = :femme THEN 1 ELSE 0 END) as femmes'
  378.             )
  379.             ->setParameter('homme''MASCULIN')
  380.             ->setParameter('femme''FEMININ')
  381.             ->groupBy('e.id, m.id')
  382.             ->orderBy('e.nom, m.nom');
  383.         if ($etablissement) {
  384.             $qb->andWhere('c.etablissement = :etablissement')
  385.                 ->setParameter('etablissement'$etablissement);
  386.         }
  387.         if ($metier) {
  388.             $qb->andWhere('c.metier = :metier')
  389.                 ->setParameter('metier'$metier);
  390.         }
  391.         return $qb->getQuery()->getResult();
  392.     }
  393.     private function calculateTotals(array $stats): array
  394.     {
  395.         $totals = [
  396.             'total' => 0,
  397.             'eligible' => 0,
  398.             'evalue' => 0,
  399.             'admissible' => 0,
  400.             'admis' => 0,
  401.             'hommes' => 0,
  402.             'femmes' => 0
  403.         ];
  404.         foreach ($stats as $stat) {
  405.             $totals['total'] += $stat['total'];
  406.             $totals['eligible'] += $stat['eligible'];
  407.             $totals['evalue'] += $stat['evalue'];
  408.             $totals['admissible'] += $stat['admissible'];
  409.             $totals['admis'] += $stat['admis'];
  410.             $totals['hommes'] += $stat['hommes'];
  411.             $totals['femmes'] += $stat['femmes'];
  412.         }
  413.         // Calculer les non-évalués
  414.         $totals['non_evalue'] = $totals['total'] - $totals['evalue'];
  415.         $totals['non_eligible'] = $totals['total'] - $totals['eligible'];
  416.         return $totals;
  417.     }
  418.     private function getProspectionTotals(?Etablissement $etablissement null, ?Metier $metier null): array
  419.     {
  420.         $totals = ['nb_entreprises' => 0'total_postes' => 0];
  421.         $entreprisesQb $this->entityManager->getRepository(Prospection::class)
  422.             ->createQueryBuilder('p')
  423.             ->select('COUNT(DISTINCT p.id)');
  424.         if ($etablissement) {
  425.             $entreprisesQb->andWhere('p.etablissement = :etab')->setParameter('etab'$etablissement);
  426.         }
  427.         $totals['nb_entreprises'] = (int) $entreprisesQb->getQuery()->getSingleScalarResult();
  428.         $postesQb $this->entityManager->getRepository(ProspectionMetier::class)
  429.             ->createQueryBuilder('pm')
  430.             ->innerJoin('pm.prospection''p')
  431.             ->select('SUM(pm.nombrePostes)');
  432.         if ($etablissement) {
  433.             $postesQb->andWhere('p.etablissement = :etab')->setParameter('etab'$etablissement);
  434.         }
  435.         if ($metier) {
  436.             $postesQb->andWhere('pm.metier = :metier')->setParameter('metier'$metier);
  437.         }
  438.         $totals['total_postes'] = (int) $postesQb->getQuery()->getSingleScalarResult();
  439.         return $totals;
  440.     }
  441.     private function exportDetailedStatistics(array $stats, ?Etablissement $etablissement, ?Metier $metier): Response
  442.     {
  443.         $headers = [
  444.             'Établissement',
  445.             'Métier',
  446.             'Total',
  447.             'Hommes',
  448.             'Femmes',
  449.             'Éligibles',
  450.             'Évalués',
  451.             'Admissibles',
  452.             'Admis'
  453.         ];
  454.         $datas = [];
  455.         foreach ($stats as $stat) {
  456.             $datas[] = [
  457.                 $stat['etablissement_nom'],
  458.                 $stat['metier_nom'],
  459.                 $stat['total'],
  460.                 $stat['hommes'],
  461.                 $stat['femmes'],
  462.                 $stat['eligible'],
  463.                 $stat['evalue'],
  464.                 $stat['admissible'],
  465.                 $stat['admis']
  466.             ];
  467.         }
  468.         $sheets = [
  469.             ['Détail Etablissements'$headers$datas]
  470.         ];
  471.         if ($this->isGranted('ROLE_ADMIN')) {
  472.             // Feuille Secteurs
  473.             $statsSecteur $this->getStatsSecteurData();
  474.             $datasSecteur = [];
  475.             foreach ($statsSecteur as $stat) {
  476.                 $datasSecteur[] = [
  477.                     $stat['secteur_nom'], $stat['total'], $stat['hommes'], $stat['femmes'], 
  478.                     $stat['eligible'], $stat['evalue'], $stat['admissible'], $stat['admis']
  479.                 ];
  480.             }
  481.             $sheets[] = ['Secteurs', ['Secteur d\'activité''Total''Hommes''Femmes''Éligibles''Évalués''Admissibles''Admis'], $datasSecteur];
  482.             // Feuille Régions
  483.             $statsRegion $this->getStatsRegionData();
  484.             $datasRegion = [];
  485.             foreach ($statsRegion as $stat) {
  486.                 $datasRegion[] = [
  487.                     $stat['region_nom'], $stat['total'], $stat['hommes'], $stat['femmes'], 
  488.                     $stat['eligible'], $stat['evalue'], $stat['admissible'], $stat['admis']
  489.                 ];
  490.             }
  491.             $sheets[] = ['Régions', ['Direction Régionale''Total''Hommes''Femmes''Éligibles''Évalués''Admissibles''Admis'], $datasRegion];
  492.         }
  493.         $filename 'STATISTIQUES';
  494.         if ($etablissement$filename .= '_' $etablissement->getNom();
  495.         if ($metier$filename .= '_' $metier->getNom();
  496.         $filename .= '_' date('Ymd_His') . '.xlsx';
  497.         return $this->spreadsheetGenerator->generateDynamicSheets($filename$sheets);
  498.     }
  499.     #[Route('/stat/etablissement/{id}/metiers'name'app_stat_etablissement_metiers'methods: ['GET'])]
  500.     public function getMetiersForStat(int $id): JsonResponse
  501.     {
  502.         $etablissement $this->entityManager->getRepository(Etablissement::class)->find($id);
  503.         if (!$etablissement) {
  504.             return $this->json([]);
  505.         }
  506.         $metiers $this->getMetiersForEtablissement($etablissement);
  507.         return $this->json(array_map(fn($m) => [
  508.             'id' => $m->getId(),
  509.             'nom' => $m->getNom()
  510.         ], $metiers));
  511.     }
  512.     // ==================== MÉTHODES PRIVÉES ====================
  513.     private function canSubmitCandidature(?User $user): bool
  514.     {
  515.         return $user && $this->countUserCandidatures($user) < 1;
  516.     }
  517.     private function countUserCandidatures(User $user): int
  518.     {
  519.         return $this->entityManager->getRepository(Candidature::class)->count(['user' => $user]);
  520.     }
  521.     private function createCandidature(?User $user): Candidature
  522.     {
  523.         $candidature = new Candidature();
  524.         if ($user$candidature->setUser($user);
  525.         $candidature->setCreation(new \DateTime());
  526.         return $candidature;
  527.     }
  528.     private function prefillFromRequest(Candidature $candidatureRequest $request): void
  529.     {
  530.         if ($metierId $request->query->get('metier')) {
  531.             $metier $this->entityManager->getRepository(Metier::class)->find($metierId);
  532.             if ($metier$candidature->setMetier($metier);
  533.         }
  534.         if ($etablissementId $request->query->get('etablissement')) {
  535.             $etablissement $this->entityManager->getRepository(Etablissement::class)->find($etablissementId);
  536.             if ($etablissement$candidature->setEtablissement($etablissement);
  537.         }
  538.     }
  539.     private function processCandidatureFiles(Candidature $candidature$form): bool
  540.     {
  541.         $missingFiles = [];
  542.         $fichiers = [];
  543.         foreach (array_keys($this->constant->document_labels) as $field) {
  544.             $file $form->get($field)->getData();
  545.             $fichiers[$field] = $file;
  546.             $getter 'get' ucfirst($field);
  547.             $existingFile method_exists($candidature$getter) ? $candidature->$getter() : null;
  548.             if (($this->constant->document_labels[$field]['required'] ?? false) && !$file && !$existingFile) {
  549.                 $missingFiles[] = $this->constant->document_labels[$field]['text'] ?? $field;
  550.             }
  551.         }
  552.         if ($missingFiles) {
  553.             $this->addFlash('error''Documents requis : ' implode(', '$missingFiles));
  554.             return false;
  555.         }
  556.         $this->handleUploadedFiles($candidature$fichiers);
  557.         return true;
  558.     }
  559.     private function validateRequiredDocuments(Candidature $candidature$form): bool
  560.     {
  561.         $missingFiles = [];
  562.         foreach (array_keys($this->constant->document_labels) as $field) {
  563.             $file $form->get($field)->getData();
  564.             $getter 'get' ucfirst($field);
  565.             $existingFile method_exists($candidature$getter) ? $candidature->$getter() : null;
  566.             if (($this->constant->document_labels[$field]['required'] ?? false) && !$file && !$existingFile) {
  567.                 $missingFiles[] = $this->constant->document_labels[$field]['text'] ?? $field;
  568.             }
  569.         }
  570.         if ($missingFiles) {
  571.             $this->addFlash('error''Documents requis : ' implode(', '$missingFiles));
  572.             return false;
  573.         }
  574.         return true;
  575.     }
  576.     private function displayFormErrors($form): void
  577.     {
  578.         if ($form->isSubmitted() && !$form->isValid()) {
  579.             foreach ($form->getErrors(true) as $error) {
  580.                 $this->addFlash('error'$error->getMessage());
  581.             }
  582.         }
  583.     }
  584.     private function handleUploadedFiles(Candidature $candidature, array $fichiers): void
  585.     {
  586.         $dir $this->params->get('dir_media') . $candidature->getNumero() . '/';
  587.         $this->fileUploader->mkdir($dir);
  588.         foreach (['fphoto''fpiece''fextrait''fniveau''fautre'] as $field) {
  589.             $file $fichiers[$field] ?? null;
  590.             if ($file) {
  591.                 $getter 'get' ucfirst($field);
  592.                 $setter 'set' ucfirst($field);
  593.                 $oldFile $candidature->$getter();
  594.                 $fileName $this->fileUploader->upload($file$dir$oldFile);
  595.                 $candidature->$setter($fileName);
  596.             }
  597.         }
  598.     }
  599.     private function generateNumero(): string
  600.     {
  601.         $last $this->entityManager->getRepository(Candidature::class)->findOneBy([], ['id' => 'DESC']);
  602.         $nextId $last $last->getId() + 1;
  603.         // Format: 2600001A (année sur 2 chiffres + numéro sur 5 chiffres + lettre aléatoire)
  604.         $year substr(date('Y'), -2); // 26 au lieu de 2026
  605.         $letters 'ABCDEFGHIJKLMNPQRSTUVWXYZ'// Sans O pour éviter confusion avec 0
  606.         $letter $letters[random_int(0strlen($letters) - 1)];
  607.         return $year str_pad($nextId5'0'STR_PAD_LEFT) . $letter;
  608.     }
  609.     private function deleteCandidature(Candidature $candidature): void
  610.     {
  611.         $this->fileUploader->remove($this->params->get('dir_media') . $candidature->getNumero());
  612.         $this->entityManager->remove($candidature);
  613.         $this->entityManager->flush();
  614.     }
  615.     private function getStatisticsByMetier(?Metier $metier null): array
  616.     {
  617.         $metiers $metier ? [$metier] : $this->entityManager->getRepository(Metier::class)->findAll();
  618.         $results = [];
  619.         foreach ($metiers as $m) {
  620.             $stats $this->calculateMetierStatistics($m);
  621.             $results[] = [
  622.                 'name' => $m->getNom(),
  623.                 'sum_h' => $stats['hommes'],
  624.                 'sum_f' => $stats['femmes'],
  625.                 'sum_pl' => $stats['places'],
  626.                 'sum' => $stats['total'],
  627.                 'sum_evalue' => $stats['evalue'],
  628.                 'sum_admissible' => $stats['admissible'],
  629.                 'sum_admis' => $stats['admis']
  630.             ];
  631.         }
  632.         return $results;
  633.     }
  634.     private function calculateMetierStatistics(Metier $metier): array
  635.     {
  636.         $repo $this->entityManager->getRepository(Candidature::class);
  637.         $candidatures $repo->createQueryBuilder('c')
  638.             ->innerJoin('c.etablissement''e')
  639.             ->leftJoin('c.user''u')
  640.             ->addSelect('u')
  641.             ->where('c.metier = :metier')
  642.             ->setParameter('metier'$metier)
  643.             ->getQuery()
  644.             ->getResult();
  645.         $countH $countF 0;
  646.         foreach ($candidatures as $c) {
  647.             $user $c->getUser();
  648.             if ($user) {
  649.                 $user->getSexe() === 'MASCULIN' $countH++ : $countF++;
  650.             }
  651.         }
  652.         // Évalués (candidatures ayant au moins une note)
  653.         $qb $repo->createQueryBuilder('c')
  654.             ->select('COUNT(c.id)')
  655.             ->where('c.metier = :metier');
  656.         $orConditions = [];
  657.         for ($i 1$i <= 13$i++) {
  658.             $orConditions[] = $qb->expr()->isNotNull('c.note' $i);
  659.         }
  660.         $evalue = (int) $qb->andWhere($qb->expr()->orX(...$orConditions))
  661.             ->setParameter('metier'$metier)
  662.             ->getQuery()
  663.             ->getSingleScalarResult();
  664.         // Places totales (somme des places dans tous les établissements pour ce métier)
  665.         $places array_sum(array_map(
  666.             fn($em) => $em->getNbrplace() ?? 0,
  667.             $this->entityManager->getRepository(EtablissementMetier::class)->findBy(['metier' => $metier])
  668.         ));
  669.         $admissible = (int) $repo->createQueryBuilder('c')
  670.             ->select('COUNT(c.id)')
  671.             ->where('c.metier = :metier')
  672.             ->andWhere('c.entstatut = 2')
  673.             ->setParameter('metier'$metier)
  674.             ->getQuery()
  675.             ->getSingleScalarResult();
  676.         $admis = (int) $repo->createQueryBuilder('c')
  677.             ->select('COUNT(c.id)')
  678.             ->where('c.metier = :metier')
  679.             ->andWhere('c.resultat = 2')
  680.             ->setParameter('metier'$metier)
  681.             ->getQuery()
  682.             ->getSingleScalarResult();
  683.         return [
  684.             'total' => $countH $countF,
  685.             'hommes' => $countH,
  686.             'femmes' => $countF,
  687.             'places' => $places,
  688.             'evalue' => $evalue,
  689.             'admissible' => $admissible,
  690.             'admis' => $admis
  691.         ];
  692.     }
  693.     private function exportStatistics(array $results): Response
  694.     {
  695.         $headers = ['N°''Métier''Postes''Hommes''Femmes''Total''Évalués''Non évalués''Admissibles''Admis'];
  696.         $datas = [];
  697.         foreach ($results as $i => $r) {
  698.             $datas[] = [
  699.                 $i 1,
  700.                 $r['name'],
  701.                 $r['sum_pl'],
  702.                 $r['sum_h'],
  703.                 $r['sum_f'],
  704.                 $r['sum'],
  705.                 $r['sum_evalue'],
  706.                 $r['sum'] - $r['sum_evalue'],
  707.                 $r['sum_admissible'],
  708.                 $r['sum_admis']
  709.             ];
  710.         }
  711.         return $this->spreadsheetGenerator->generate('STATISTIQUES_CANDIDATURES.xlsx'$headers$datas);
  712.     }
  713.     private function getMetiersWithDetails(Etablissement $etablissement): array
  714.     {
  715.         $metiers $this->entityManager->getRepository(Metier::class)
  716.             ->createQueryBuilder('m')
  717.             ->innerJoin('m.etablissementMetiers''em')
  718.             ->innerJoin('m.secteur''s')
  719.             ->where('em.etablissement = :etablissement')
  720.             ->setParameter('etablissement'$etablissement)
  721.             ->orderBy('s.nom''ASC')->addOrderBy('m.nom''ASC')
  722.             ->getQuery()->getResult();
  723.         return array_map(fn($metier) => [
  724.             'id' => $metier->getId(),
  725.             'nom' => $metier->getNom(),
  726.             'secteur_nom' => $metier->getSecteur()->getNom(),
  727.             'nbrplace' => ($em $this->entityManager->getRepository(EtablissementMetier::class)
  728.                 ->findOneBy(['etablissement' => $etablissement'metier' => $metier])) ? $em->getNbrplace() : null,
  729.             'niveau' => $em?->getNiveauRequis(),
  730.             'duree' => $em?->getDureeFormation()
  731.         ], $metiers);
  732.     }
  733.     private function handleExportActions(Request $request): ?Response
  734.     {
  735.         $action $request->request->get('action');
  736.         if ($action === 'export_codes') {
  737.             return $this->exportByCodes($request->request->get('codes'));
  738.         }
  739.         if ($action === 'export_filters') {
  740.             return $this->exportByFilters(
  741.                 $request->request->get('etablissement'),
  742.                 $request->request->get('statut''TOUS'),
  743.                 $request->request->get('metier')
  744.             );
  745.         }
  746.         if ($action === 'export_liste_candidats_inscrits') {
  747.             return $this->exportListeCandidatsInscrits(
  748.                 $request->request->get('etablissement_liste'),
  749.                 $request->request->get('metier_liste')
  750.             );
  751.         }
  752.         if ($action === 'export_liste_admis') {
  753.             return $this->exportListeAdmis(
  754.                 $request->request->get('etablissement_admis'),
  755.                 $request->request->get('metier_admis')
  756.             );
  757.         }
  758.         if ($action === 'export_liste_admis_definitifs') {
  759.             return $this->exportListeAdmisDefinitifs(
  760.                 $request->request->get('etablissement_admis_def'),
  761.                 $request->request->get('metier_admis_def')
  762.             );
  763.         }
  764.         if ($action === 'export_stats_secteur') {
  765.             return $this->exportStatsSecteur();
  766.         }
  767.         if ($action === 'export_stats_region') {
  768.             return $this->exportStatsRegion();
  769.         }
  770.         return null;
  771.     }
  772.     private function exportListeCandidatsInscrits(?string $etablissementId, ?string $metierId): ?Response
  773.     {
  774.         if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_ENT') && !$this->isGranted('ROLE_JURY')) {
  775.             throw $this->createAccessDeniedException('Accès non autorisé à cet export.');
  776.         }
  777.         $user $this->getUser();
  778.         $etablissement null;
  779.         if ($this->isGranted('ROLE_ADMIN')) {
  780.             if (!$etablissementId || $etablissementId === 'TOUS') {
  781.                 $this->addFlash('error''Veuillez sélectionner un établissement.');
  782.                 return $this->redirectToRoute('app_candidature_impressions');
  783.             }
  784.             $etablissement $this->entityManager->getRepository(Etablissement::class)->find((int) $etablissementId);
  785.             if (!$etablissement) {
  786.                 $this->addFlash('error''Établissement invalide.');
  787.                 return $this->redirectToRoute('app_candidature_impressions');
  788.             }
  789.         } else {
  790.             $etablissement $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);
  791.             if (!$etablissement) {
  792.                 $this->addFlash('warning''Votre compte n\'est pas associé à un établissement.');
  793.                 return $this->redirectToRoute('app_candidature_impressions');
  794.             }
  795.             if ($etablissementId && (int) $etablissementId !== $etablissement->getId()) {
  796.                 $this->addFlash('error''Sélection d\'établissement non autorisée.');
  797.                 return $this->redirectToRoute('app_candidature_impressions');
  798.             }
  799.         }
  800.         $metiersAutorises $this->getMetiersForEtablissement($etablissement);
  801.         $metierIdsAutorises array_map(static fn(Metier $metier): int => $metier->getId(), $metiersAutorises);
  802.         $qb $this->entityManager->getRepository(Candidature::class)
  803.             ->createQueryBuilder('c')
  804.             ->leftJoin('c.user''u')
  805.             ->leftJoin('c.etablissement''e')
  806.             ->leftJoin('c.metier''m')
  807.             ->addSelect('u''e''m')
  808.             ->andWhere('c.etablissement = :etablissement')
  809.             ->setParameter('etablissement'$etablissement)
  810.             ->orderBy('u.nom''ASC')
  811.             ->addOrderBy('u.prenoms''ASC')
  812.             ->addOrderBy('c.numero''ASC');
  813.         $metierSelectionne null;
  814.         if ($metierId && $metierId !== 'TOUS') {
  815.             $metierSelectionneId = (int) $metierId;
  816.             if (!in_array($metierSelectionneId$metierIdsAutorisestrue)) {
  817.                 $this->addFlash('error''Sélection de métier non autorisée.');
  818.                 return $this->redirectToRoute('app_candidature_impressions');
  819.             }
  820.             $metierSelectionne $this->entityManager->getRepository(Metier::class)->find($metierSelectionneId);
  821.             if (!$metierSelectionne) {
  822.                 $this->addFlash('error''Métier invalide.');
  823.                 return $this->redirectToRoute('app_candidature_impressions');
  824.             }
  825.             $qb->andWhere('c.metier = :metier')->setParameter('metier'$metierSelectionne);
  826.         }
  827.         $candidatures $qb->getQuery()->getResult();
  828.         if (empty($candidatures)) {
  829.             $this->addFlash('warning''Aucune candidature trouvée pour les critères sélectionnés.');
  830.             return $this->redirectToRoute('app_candidature_impressions');
  831.         }
  832.         $headers = ['N°''Numéro''Nom''Prénoms''Contact''Établissement''Métier'];
  833.         $datas = [];
  834.         foreach ($candidatures as $index => $candidature) {
  835.             $candidat $candidature->getUser();
  836.             $datas[] = [
  837.                 $index 1,
  838.                 $candidature->getNumero(),
  839.                 $candidat?->getNom() ?? '',
  840.                 $candidat?->getPrenoms() ?? '',
  841.                 $candidat?->getContact() ?? '',
  842.                 $candidature->getEtablissement()?->getNom() ?? '',
  843.                 $candidature->getMetier()?->getNom() ?? '',
  844.             ];
  845.         }
  846.         $filename 'LISTE_CANDIDATS_INSCRITS_' date('Ymd_His') . '.xlsx';
  847.         return $this->spreadsheetGenerator->generate($filename$headers$datas);
  848.     }
  849.     private function exportListeAdmis(?string $etablissementId, ?string $metierId): ?Response
  850.     {
  851.         if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_ENT') && !$this->isGranted('ROLE_JURY')) {
  852.             throw $this->createAccessDeniedException('Accès non autorisé à cet export.');
  853.         }
  854.         $user $this->getUser();
  855.         $etablissement null;
  856.         if ($this->isGranted('ROLE_ADMIN')) {
  857.             if (!$etablissementId || $etablissementId === 'TOUS') {
  858.                 $this->addFlash('error''Veuillez sélectionner un établissement.');
  859.                 return $this->redirectToRoute('app_candidature_impressions');
  860.             }
  861.             $etablissement $this->entityManager->getRepository(Etablissement::class)->find((int) $etablissementId);
  862.             if (!$etablissement) {
  863.                 $this->addFlash('error''Établissement invalide.');
  864.                 return $this->redirectToRoute('app_candidature_impressions');
  865.             }
  866.         } else {
  867.             $etablissement $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);
  868.             if (!$etablissement) {
  869.                 $this->addFlash('warning''Votre compte n\'est pas associé à un établissement.');
  870.                 return $this->redirectToRoute('app_candidature_impressions');
  871.             }
  872.         }
  873.         $metiersAutorises $this->getMetiersForEtablissement($etablissement);
  874.         $metierIdsAutorises array_map(static fn(Metier $m): int => $m->getId(), $metiersAutorises);
  875.         $qb $this->entityManager->getRepository(Candidature::class)
  876.             ->createQueryBuilder('c')
  877.             ->leftJoin('c.user''u')
  878.             ->leftJoin('c.etablissement''e')
  879.             ->leftJoin('c.metier''m')
  880.             ->addSelect('u''e''m')
  881.             ->andWhere('c.etablissement = :etablissement')
  882.             ->andWhere('c.resultat = 2')
  883.             ->setParameter('etablissement'$etablissement)
  884.             ->orderBy('u.nom''ASC')
  885.             ->addOrderBy('u.prenoms''ASC');
  886.         if ($metierId && $metierId !== 'TOUS') {
  887.             $metierSelectionneId = (int) $metierId;
  888.             if (!in_array($metierSelectionneId$metierIdsAutorisestrue)) {
  889.                 $this->addFlash('error''Sélection de métier non autorisée.');
  890.                 return $this->redirectToRoute('app_candidature_impressions');
  891.             }
  892.             $metierSelectionne $this->entityManager->getRepository(Metier::class)->find($metierSelectionneId);
  893.             if (!$metierSelectionne) {
  894.                 $this->addFlash('error''Métier invalide.');
  895.                 return $this->redirectToRoute('app_candidature_impressions');
  896.             }
  897.             $qb->andWhere('c.metier = :metier')->setParameter('metier'$metierSelectionne);
  898.         }
  899.         $candidatures $qb->getQuery()->getResult();
  900.         if (empty($candidatures)) {
  901.             $this->addFlash('warning''Aucun candidat admis trouvé pour les critères sélectionnés.');
  902.             return $this->redirectToRoute('app_candidature_impressions');
  903.         }
  904.         $headers = ['N°''Numéro''Nom''Prénoms''Contact''Date Naissance''Lieu Naissance''Établissement''Métier'];
  905.         $datas = [];
  906.         foreach ($candidatures as $index => $candidature) {
  907.             $candidat $candidature->getUser();
  908.             $datas[] = [
  909.                 $index 1,
  910.                 $candidature->getNumero(),
  911.                 $candidat?->getNom() ?? '',
  912.                 $candidat?->getPrenoms() ?? '',
  913.                 $candidat?->getContact() ?? '',
  914.                 $candidat?->getDatenaissance()?->format('d/m/Y') ?? '',
  915.                 $candidat?->getLieunaissance() ?? '',
  916.                 $candidature->getEtablissement()?->getNom() ?? '',
  917.                 $candidature->getMetier()?->getNom() ?? '',
  918.             ];
  919.         }
  920.         $filename 'LISTE_CANDIDATS_ADMIS_' date('Ymd_His') . '.xlsx';
  921.         return $this->spreadsheetGenerator->generate($filename$headers$datas);
  922.     }
  923.     private function exportListeAdmisDefinitifs(?string $etablissementId, ?string $metierId): ?Response
  924.     {
  925.         if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_ENT') && !$this->isGranted('ROLE_JURY')) {
  926.             throw $this->createAccessDeniedException('Accès non autorisé à cet export.');
  927.         }
  928.         $user $this->getUser();
  929.         $etablissement null;
  930.         if ($this->isGranted('ROLE_ADMIN')) {
  931.             if (!$etablissementId || $etablissementId === 'TOUS') {
  932.                 $this->addFlash('error''Veuillez sélectionner un établissement.');
  933.                 return $this->redirectToRoute('app_candidature_impressions');
  934.             }
  935.             $etablissement $this->entityManager->getRepository(Etablissement::class)->find((int) $etablissementId);
  936.             if (!$etablissement) {
  937.                 $this->addFlash('error''Établissement invalide.');
  938.                 return $this->redirectToRoute('app_candidature_impressions');
  939.             }
  940.         } else {
  941.             $etablissement $this->entityManager->getRepository(Etablissement::class)->findOneBy(['user' => $user]);
  942.             if (!$etablissement) {
  943.                 $this->addFlash('warning''Votre compte n\'est pas associé à un établissement.');
  944.                 return $this->redirectToRoute('app_candidature_impressions');
  945.             }
  946.         }
  947.         $metiersAutorises $this->getMetiersForEtablissement($etablissement);
  948.         $metierIdsAutorises array_map(static fn(Metier $m): int => $m->getId(), $metiersAutorises);
  949.         $qb $this->entityManager->getRepository(Admis::class)
  950.             ->createQueryBuilder('a')
  951.             ->leftJoin('a.user''u')
  952.             ->leftJoin('a.etablissement''e')
  953.             ->leftJoin('a.metier''m')
  954.             ->leftJoin('a.candidature''c')
  955.             ->addSelect('u''e''m''c')
  956.             ->andWhere('a.etablissement = :etablissement')
  957.             ->setParameter('etablissement'$etablissement)
  958.             ->orderBy('u.nom''ASC')
  959.             ->addOrderBy('u.prenoms''ASC');
  960.         if ($metierId && $metierId !== 'TOUS') {
  961.             $metierSelectionneId = (int) $metierId;
  962.             if (!in_array($metierSelectionneId$metierIdsAutorisestrue)) {
  963.                 $this->addFlash('error''Sélection de métier non autorisée.');
  964.                 return $this->redirectToRoute('app_candidature_impressions');
  965.             }
  966.             $metierSelectionne $this->entityManager->getRepository(Metier::class)->find($metierSelectionneId);
  967.             if (!$metierSelectionne) {
  968.                 $this->addFlash('error''Métier invalide.');
  969.                 return $this->redirectToRoute('app_candidature_impressions');
  970.             }
  971.             $qb->andWhere('a.metier = :metier')->setParameter('metier'$metierSelectionne);
  972.         }
  973.         $admisDefinitifs $qb->getQuery()->getResult();
  974.         if (empty($admisDefinitifs)) {
  975.             $this->addFlash('warning''Aucun candidat admis définitif trouvé pour les critères sélectionnés.');
  976.             return $this->redirectToRoute('app_candidature_impressions');
  977.         }
  978.         $headers = ['N°''N° Inscription''N° Candidature''Nom''Prénoms''Contact''Date Naissance''Lieu Naissance''Établissement''Métier''Date Inscription''Confirmé'];
  979.         $datas = [];
  980.         foreach ($admisDefinitifs as $index => $admis) {
  981.             $candidat $admis->getUser();
  982.             $datas[] = [
  983.                 $index 1,
  984.                 $admis->getNumeroInscription(),
  985.                 $admis->getCandidature()?->getNumero() ?? '',
  986.                 $candidat?->getNom() ?? '',
  987.                 $candidat?->getPrenoms() ?? '',
  988.                 $candidat?->getContact() ?? '',
  989.                 $candidat?->getDatenaissance()?->format('d/m/Y') ?? '',
  990.                 $candidat?->getLieunaissance() ?? '',
  991.                 $admis->getEtablissement()?->getNom() ?? '',
  992.                 $admis->getMetier()?->getNom() ?? '',
  993.                 $admis->getDateInscription()?->format('d/m/Y H:i') ?? '',
  994.                 $admis->getConfirme() ? 'Oui' 'Non',
  995.             ];
  996.         }
  997.         $filename 'LISTE_CANDIDATS_ADMIS_DEFINITIFS_' date('Ymd_His') . '.xlsx';
  998.         return $this->spreadsheetGenerator->generate($filename$headers$datas);
  999.     }
  1000.     private function getStatsSecteurData(): array
  1001.     {
  1002.         return $this->entityManager->getRepository(Secteur::class)
  1003.             ->createQueryBuilder('s')
  1004.             ->leftJoin('s.metiers''m')
  1005.             ->leftJoin(Candidature::class, 'c'\Doctrine\ORM\Query\Expr\Join::WITH'c.metier = m.id')
  1006.             ->leftJoin('c.user''u')
  1007.             ->select(
  1008.                 's.nom as secteur_nom',
  1009.                 'COUNT(c.id) as total',
  1010.                 'SUM(CASE WHEN c.etustatut = 2 THEN 1 ELSE 0 END) as eligible',
  1011.                 'SUM(CASE WHEN c.entstatut IS NOT NULL OR c.resultat IS NOT NULL THEN 1 ELSE 0 END) as evalue',
  1012.                 'SUM(CASE WHEN c.entstatut = 2 THEN 1 ELSE 0 END) as admissible',
  1013.                 'SUM(CASE WHEN c.resultat = 2 THEN 1 ELSE 0 END) as admis',
  1014.                 'SUM(CASE WHEN u.sexe = :homme THEN 1 ELSE 0 END) as hommes',
  1015.                 'SUM(CASE WHEN u.sexe = :femme THEN 1 ELSE 0 END) as femmes'
  1016.             )
  1017.             ->setParameter('homme''MASCULIN')
  1018.             ->setParameter('femme''FEMININ')
  1019.             ->groupBy('s.id')
  1020.             ->orderBy('s.nom''ASC')
  1021.             ->getQuery()->getResult();
  1022.     }
  1023.     private function exportStatsSecteur(): ?Response
  1024.     {
  1025.         if (!$this->isGranted('ROLE_ADMIN')) {
  1026.             throw $this->createAccessDeniedException('Accès non autorisé à cet export.');
  1027.         }
  1028.         $stats $this->getStatsSecteurData();
  1029.         $headers = [
  1030.             'Secteur d\'activité',
  1031.             'Total',
  1032.             'Hommes',
  1033.             'Femmes',
  1034.             'Éligibles',
  1035.             'Évalués',
  1036.             'Admissibles',
  1037.             'Admis'
  1038.         ];
  1039.         $datas = [];
  1040.         foreach ($stats as $stat) {
  1041.             $datas[] = [
  1042.                 $stat['secteur_nom'],
  1043.                 $stat['total'],
  1044.                 $stat['hommes'],
  1045.                 $stat['femmes'],
  1046.                 $stat['eligible'],
  1047.                 $stat['evalue'],
  1048.                 $stat['admissible'],
  1049.                 $stat['admis']
  1050.             ];
  1051.         }
  1052.         $filename 'STATISTIQUES_PAR_SECTEUR_' date('Ymd_His') . '.xlsx';
  1053.         return $this->spreadsheetGenerator->generate($filename$headers$datas);
  1054.     }
  1055.     private function getStatsRegionData(): array
  1056.     {
  1057.         return $this->entityManager->getRepository(DirectionRegionale::class)
  1058.             ->createQueryBuilder('dr')
  1059.             ->leftJoin('dr.etablissements''e')
  1060.             ->leftJoin(Candidature::class, 'c'\Doctrine\ORM\Query\Expr\Join::WITH'c.etablissement = e.id')
  1061.             ->leftJoin('c.user''u')
  1062.             ->select(
  1063.                 'dr.nom as region_nom',
  1064.                 'COUNT(c.id) as total',
  1065.                 'SUM(CASE WHEN c.etustatut = 2 THEN 1 ELSE 0 END) as eligible',
  1066.                 'SUM(CASE WHEN c.entstatut IS NOT NULL OR c.resultat IS NOT NULL THEN 1 ELSE 0 END) as evalue',
  1067.                 'SUM(CASE WHEN c.entstatut = 2 THEN 1 ELSE 0 END) as admissible',
  1068.                 'SUM(CASE WHEN c.resultat = 2 THEN 1 ELSE 0 END) as admis',
  1069.                 'SUM(CASE WHEN u.sexe = :homme THEN 1 ELSE 0 END) as hommes',
  1070.                 'SUM(CASE WHEN u.sexe = :femme THEN 1 ELSE 0 END) as femmes'
  1071.             )
  1072.             ->setParameter('homme''MASCULIN')
  1073.             ->setParameter('femme''FEMININ')
  1074.             ->groupBy('dr.id')
  1075.             ->orderBy('dr.nom''ASC')
  1076.             ->getQuery()->getResult();
  1077.     }
  1078.     private function exportStatsRegion(): ?Response
  1079.     {
  1080.         if (!$this->isGranted('ROLE_ADMIN')) {
  1081.             throw $this->createAccessDeniedException('Accès non autorisé à cet export.');
  1082.         }
  1083.         $stats $this->getStatsRegionData();
  1084.         $headers = [
  1085.             'Direction Régionale',
  1086.             'Total',
  1087.             'Hommes',
  1088.             'Femmes',
  1089.             'Éligibles',
  1090.             'Évalués',
  1091.             'Admissibles',
  1092.             'Admis'
  1093.         ];
  1094.         $datas = [];
  1095.         foreach ($stats as $stat) {
  1096.             $datas[] = [
  1097.                 $stat['region_nom'],
  1098.                 $stat['total'],
  1099.                 $stat['hommes'],
  1100.                 $stat['femmes'],
  1101.                 $stat['eligible'],
  1102.                 $stat['evalue'],
  1103.                 $stat['admissible'],
  1104.                 $stat['admis']
  1105.             ];
  1106.         }
  1107.         $filename 'STATISTIQUES_PAR_REGION_' date('Ymd_His') . '.xlsx';
  1108.         return $this->spreadsheetGenerator->generate($filename$headers$datas);
  1109.     }
  1110.     private function exportByCodes(string $codes): ?Response
  1111.     {
  1112.         $numeros array_filter(array_map('trim'explode(';'str_replace(["\r""\n"], ';'$codes))));
  1113.         if (empty($numeros)) return null;
  1114.         $results $this->entityManager->getRepository(Candidature::class)->findBy(['numero' => $numeros]);
  1115.         if (empty($results)) return null;
  1116.         $headers = ['N°''Numéro''Nom''Prénoms''Date naissance''Lieu naissance''Sexe''Contact''Métier''Note'];
  1117.         $datas = [];
  1118.         foreach ($results as $i => $r) {
  1119.             $user $r->getUser();
  1120.             $datas[] = [
  1121.                 $i 1,
  1122.                 $r->getNumero(),
  1123.                 $user strtoupper($user->getNom()) : '',
  1124.                 $user strtoupper($user->getPrenoms()) : '',
  1125.                 $user?->getDatenaissance()?->format('d/m/Y') ?? '',
  1126.                 $user strtoupper($user->getLieunaissance() ?? '') : '',
  1127.                 $user && $user->getSexe() === 'MASCULIN' 'M' 'F',
  1128.                 $user?->getContact() ?? '',
  1129.                 $r->getMetier()?->getNom() ?? '',
  1130.                 $this->calculateTotalNote($r)
  1131.             ];
  1132.         }
  1133.         return $this->spreadsheetGenerator->generate('EXPORT_PAR_CODE.xlsx'$headers$datas);
  1134.     }
  1135.     private function exportByFilters($etablissement$statut$metierId): ?Response
  1136.     {
  1137.         $qb $this->entityManager->getRepository(Candidature::class)
  1138.             ->createQueryBuilder('c')
  1139.             ->innerJoin('c.metier''m')
  1140.             ->innerJoin('c.etablissement''e')
  1141.             ->leftJoin('c.user''u');
  1142.         if ($etablissement && $etablissement !== 'TOUS') {
  1143.             $qb->andWhere('c.etablissement = :etablissement')->setParameter('etablissement'$etablissement);
  1144.         }
  1145.         if ($metierId) {
  1146.             $qb->andWhere('c.metier = :metier')->setParameter('metier'$metierId);
  1147.         }
  1148.         if ($statut === '2'$qb->andWhere('c.entstatut = 2');
  1149.         elseif ($statut === '2|2'$qb->andWhere('c.resultat = 2');
  1150.         elseif ($statut === '1'$qb->andWhere('c.etustatut = 1');
  1151.         $candidatures $qb->getQuery()->getResult();
  1152.         if (empty($candidatures)) return null;
  1153.         $headers = ['N°''Numéro''Nom''Prénoms''Date naissance''Sexe''Contact''Métier''Établissement''Statut'];
  1154.         $datas = [];
  1155.         foreach ($candidatures as $i => $c) {
  1156.             $user $c->getUser();
  1157.             $datas[] = [
  1158.                 $i 1,
  1159.                 $c->getNumero(),
  1160.                 $user?->getNom() ?? '',
  1161.                 $user?->getPrenoms() ?? '',
  1162.                 $user?->getDatenaissance()?->format('d/m/Y') ?? '',
  1163.                 $user?->getSexe() ?? '',
  1164.                 $user?->getContact() ?? '',
  1165.                 $c->getMetier()?->getNom() ?? '',
  1166.                 $c->getEtablissement()?->getNom() ?? '',
  1167.                 self::STATUT_LABELS[$c->getEtustatut()] ?? self::STATUT_LABELS['default']
  1168.             ];
  1169.         }
  1170.         return $this->spreadsheetGenerator->generate('EXPORT_CANDIDATURES.xlsx'$headers$datas);
  1171.     }
  1172.     private function calculateTotalNote(Candidature $candidature): float
  1173.     {
  1174.         $total 0;
  1175.         for ($i 1$i <= 13$i++) {
  1176.             $method 'getNote' $i;
  1177.             $total += $candidature->$method() ?? 0;
  1178.         }
  1179.         return $total;
  1180.     }
  1181.     private function generateFichePdf(Candidature $candidature): Response
  1182.     {
  1183.         return $this->pdfGenerator->stream('candidature/print/fiche.html.twig', [
  1184.             'candidature' => $candidature,
  1185.             'image' => $this->prepareImagesForPdf($candidature)
  1186.         ]);
  1187.     }
  1188.     private function generateConvocationPdf(Candidature $candidature): Response
  1189.     {
  1190.         return $this->pdfGenerator->stream('candidature/print/convocation.html.twig', [
  1191.             'candidature' => $candidature,
  1192.             'image' => $this->prepareImagesForPdf($candidature)
  1193.         ]);
  1194.     }
  1195.     private function printStatistics(Request $request): Response
  1196.     {
  1197.         $metierId $request->query->get('metier');
  1198.         $metier $metierId $this->entityManager->getRepository(Metier::class)->find($metierId) : null;
  1199.         $results $this->getStatisticsByMetier($metier);
  1200.         $dirImage $this->params->get('dir_image');
  1201.         $images = ['entete' => $this->pdfGenerator->imageToBase64($dirImage 'entete_generique_e2c.png')];
  1202.         return $this->pdfGenerator->stream('candidature/print/stat.html.twig', [
  1203.             'results' => $results,
  1204.             'image' => $images,
  1205.             'date' => new \DateTime()
  1206.         ]);
  1207.     }
  1208.     private function prepareImagesForPdf(Candidature $candidature): array
  1209.     {
  1210.         $dirMedia $this->params->get('dir_media');
  1211.         $dirImage $this->params->get('dir_image');
  1212.         $photoPath $candidature->getFphoto()
  1213.             ? $dirMedia $candidature->getNumero() . '/' $candidature->getFphoto()
  1214.             : $dirImage 'user.svg';
  1215.         return [
  1216.             'entete' => $this->pdfGenerator->imageToBase64($dirImage 'entete_generique_e2c.png'),
  1217.             'photo' => $this->pdfGenerator->imageToBase64($photoPath)
  1218.         ];
  1219.     }
  1220.     private function handleInvalidConvocation(): Response
  1221.     {
  1222.         $this->addFlash('error''Ce candidat n\'est pas admissible, aucune convocation disponible');
  1223.         return $this->redirectToRoute('app_candidature_impressions');
  1224.     }
  1225. }