src/Controller/CandidatureController.php line 94

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