src/Repository/Cvs/JobsRepository.php line 440

Open in your IDE?
  1. <?php
  2. namespace App\Repository\Cvs;
  3. use App\Entity\Cvs\Jobs;
  4. use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
  5. use Doctrine\Persistence\ManagerRegistry;
  6. /**
  7.  * @extends ServiceEntityRepository<Jobs>
  8.  *
  9.  * @method Jobs|null find($id, $lockMode = null, $lockVersion = null)
  10.  * @method Jobs|null findOneBy(array $criteria, array $orderBy = null)
  11.  * @method Jobs[]    findAll()
  12.  * @method Jobs[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  13.  */
  14. class JobsRepository extends ServiceEntityRepository
  15. {
  16.     /**
  17.      * Durée de vie du cache de résultats pour les requêtes "homepage"
  18.      * (offres populaires, compteur, pool aléatoire). Ces données n'ont pas
  19.      * besoin d'être temps réel : un cache court suffit et mutualise le coût
  20.      * entre tous les visiteurs.
  21.      */
  22.     private const HOMEPAGE_CACHE_TTL 600// 10 min
  23.     /**
  24.      * Taille maximale du pool d'ids dans lequel on tire les offres aléatoires.
  25.      * Inutile de charger 60k ids en mémoire PHP pour afficher 6 offres :
  26.      * un pool de 1000 offres récentes donne un aléatoire largement suffisant
  27.      * tout en restant borné en mémoire et en coût DB.
  28.      */
  29.     private const RANDOM_POOL_SIZE 1000;
  30.     public function __construct(ManagerRegistry $registry)
  31.     {
  32.         parent::__construct($registryJobs::class);
  33.     }
  34.     /**
  35.      * Recherche des offres par mots-clés, filtrées par locale, avec pagination.
  36.      * Si $cityKeyword est fourni, contraint les résultats à matcher la ville (en plus du query).
  37.      *
  38.      * NB : la recherche utilisateur reste non cachée (résultats à la demande,
  39.      * potentiellement uniques par requête).
  40.      *
  41.      * @param string $query Mots-clés de recherche
  42.      * @param string $locale Langue (fr, en)
  43.      * @param int $offset Décalage pour la pagination
  44.      * @param int $limit Nombre de résultats
  45.      * @param string|null $cityKeyword Si fourni, le résultat doit aussi matcher j.city LIKE %cityKeyword%
  46.      * @return Jobs[]
  47.      */
  48.     public function searchJobs(string $query ''string $locale 'fr'int $offset 0int $limit 10, ?string $cityKeyword null): array
  49.     {
  50.         $qb $this->createQueryBuilder('j')
  51.             ->where('j.online = :online')
  52.             ->andWhere('j.locale = :locale')
  53.             ->setParameter('online'true)
  54.             ->setParameter('locale'$locale)
  55.             ->orderBy('j.createdAt''DESC')
  56.             ->setFirstResult($offset)
  57.             ->setMaxResults($limit);
  58.         if (!empty(trim($query))) {
  59.             $terms array_filter(explode(' 'trim($query)), function($t) { return strlen($t) >= 2; });
  60.             foreach ($terms as $i => $term) {
  61.                 $param 'term' $i;
  62.                 $qb->andWhere(
  63.                     '(j.jobTitle LIKE :' $param .
  64.                     ' OR j.companyName LIKE :' $param .
  65.                     ' OR j.category LIKE :' $param .
  66.                     ' OR j.city LIKE :' $param .
  67.                     ' OR j.requiredSkills LIKE :' $param ')'
  68.                 )
  69.                     ->setParameter($param'%' $term '%');
  70.             }
  71.         }
  72.         // Contrainte ville : doit matcher la ville indépendamment du query
  73.         if ($cityKeyword !== null && trim($cityKeyword) !== '') {
  74.             $qb->andWhere('j.city LIKE :cityKw')
  75.                 ->setParameter('cityKw''%' trim($cityKeyword) . '%');
  76.         }
  77.         return $qb->getQuery()->getResult();
  78.     }
  79.     /**
  80.      * Compte le total d'offres correspondant à la recherche (pour pagination).
  81.      * Si $cityKeyword est fourni, contraint le compte à la ville.
  82.      *
  83.      * @param string $query Mots-clés de recherche
  84.      * @param string $locale Langue (fr, en)
  85.      * @param string|null $cityKeyword
  86.      * @return int
  87.      */
  88.     public function countSearchJobs(string $query ''string $locale 'fr', ?string $cityKeyword null): int
  89.     {
  90.         $qb $this->createQueryBuilder('j')
  91.             ->select('COUNT(j.id)')
  92.             ->where('j.online = :online')
  93.             ->andWhere('j.locale = :locale')
  94.             ->setParameter('online'true)
  95.             ->setParameter('locale'$locale);
  96.         if (!empty(trim($query))) {
  97.             $terms array_filter(explode(' 'trim($query)), function($t) { return strlen($t) >= 2; });
  98.             foreach ($terms as $i => $term) {
  99.                 $param 'term' $i;
  100.                 $qb->andWhere(
  101.                     '(j.jobTitle LIKE :' $param .
  102.                     ' OR j.companyName LIKE :' $param .
  103.                     ' OR j.category LIKE :' $param .
  104.                     ' OR j.city LIKE :' $param .
  105.                     ' OR j.requiredSkills LIKE :' $param ')'
  106.                 )
  107.                     ->setParameter($param'%' $term '%');
  108.             }
  109.         }
  110.         if ($cityKeyword !== null && trim($cityKeyword) !== '') {
  111.             $qb->andWhere('j.city LIKE :cityKw')
  112.                 ->setParameter('cityKw''%' trim($cityKeyword) . '%');
  113.         }
  114.         return (int) $qb->getQuery()->getSingleScalarResult();
  115.     }
  116.     /**
  117.      * Retourne des offres aléatoires en ligne, filtrées par locale.
  118.      * Utilisé pour l'affichage par défaut sur la homepage (avant toute recherche).
  119.      *
  120.      * Optimisations :
  121.      *  - on ne charge PLUS tous les ids (60k+) : on borne à RANDOM_POOL_SIZE
  122.      *    via un ORDER BY createdAt DESC + LIMIT (requête couverte par l'index
  123.      *    idx_jobs_listing) ;
  124.      *  - la liste d'ids est mise en cache de résultats par locale, donc la DB
  125.      *    n'est touchée qu'une fois toutes les HOMEPAGE_CACHE_TTL secondes,
  126.      *    quel que soit le nombre de visiteurs ;
  127.      *  - le shuffle (Fisher-Yates seedé) reste fait en PHP sur ce pool borné,
  128.      *    ce qui conserve la pagination reproductible via $seed.
  129.      *
  130.      * @param string|null $locale Langue de filtrage (fr, en). Si null, toutes locales confondues.
  131.      * @param int $offset Décalage pour la pagination
  132.      * @param int $limit Nombre max de résultats
  133.      * @param int|null $seed Seed pour rendre l'ordre aléatoire reproductible
  134.      * @return Jobs[]
  135.      */
  136.     public function findRandomJobs(?string $locale nullint $offset 0int $limit 10, ?int $seed null): array
  137.     {
  138.         // Pool d'ids borné + caché par locale. On prend les plus récentes :
  139.         // l'aléatoire porte sur un sous-ensemble pertinent plutôt que sur des
  140.         // offres potentiellement très anciennes.
  141.         $qb $this->createQueryBuilder('j')
  142.             ->select('j.id')
  143.             ->where('j.online = :online')
  144.             ->setParameter('online'true)
  145.             ->orderBy('j.createdAt''DESC')
  146.             ->setMaxResults(self::RANDOM_POOL_SIZE);
  147.         if ($locale !== null) {
  148.             $qb->andWhere('j.locale = :locale')
  149.                 ->setParameter('locale'$locale);
  150.         }
  151.         $cacheKey 'jobs_random_pool_' . ($locale ?? 'all');
  152.         $rows $qb->getQuery()
  153.             ->enableResultCache(self::HOMEPAGE_CACHE_TTL$cacheKey)
  154.             ->getScalarResult();
  155.         if (empty($rows)) {
  156.             return [];
  157.         }
  158.         $ids array_map(static function($row) { return (int) $row['id']; }, $rows);
  159.         // Mélange Fisher-Yates (correct, contrairement à usort + rand)
  160.         if ($seed !== null) {
  161.             mt_srand($seed);
  162.         }
  163.         $count count($ids);
  164.         for ($i $count 1$i 0$i--) {
  165.             $j mt_rand(0$i);
  166.             $tmp $ids[$i];
  167.             $ids[$i] = $ids[$j];
  168.             $ids[$j] = $tmp;
  169.         }
  170.         if ($seed !== null) {
  171.             mt_srand(); // reset à un seed aléatoire pour la suite
  172.         }
  173.         // Slice selon offset / limit
  174.         $pageIds array_slice($ids$offset$limit);
  175.         if (empty($pageIds)) {
  176.             return [];
  177.         }
  178.         // Récupère les entités
  179.         $jobs $this->createQueryBuilder('j')
  180.             ->where('j.id IN (:ids)')
  181.             ->setParameter('ids'$pageIds)
  182.             ->getQuery()
  183.             ->getResult();
  184.         // Réordonne les résultats dans l'ordre des IDs aléatoires
  185.         $indexed = [];
  186.         foreach ($jobs as $job) {
  187.             $indexed[$job->getId()] = $job;
  188.         }
  189.         $ordered = [];
  190.         foreach ($pageIds as $id) {
  191.             if (isset($indexed[$id])) {
  192.                 $ordered[] = $indexed[$id];
  193.             }
  194.         }
  195.         return $ordered;
  196.     }
  197.     /**
  198.      * Recherche des offres par keyword exact dans le champ keywords uniquement.
  199.      *
  200.      * Mise en cache de résultats : cette méthode est appelée en boucle sur la
  201.      * homepage (une fois par mot-clé populaire). Le LIKE '%kw%' force un scan ;
  202.      * le cache évite de rejouer ces N scans à chaque visiteur.
  203.      *
  204.      * @param string $keyword Le mot-clé exact à chercher
  205.      * @param string $locale Langue (fr, en)
  206.      * @param int $limit Nombre max de résultats
  207.      * @return Jobs[]
  208.      */
  209.     public function findByKeyword(string $keywordstring $locale 'fr'int $limit 4): array
  210.     {
  211.         $keyword trim($keyword);
  212.         if (empty($keyword)) {
  213.             return [];
  214.         }
  215.         $cacheKey 'jobs_by_kw_' md5($locale '|' $keyword '|' $limit);
  216.         return $this->createQueryBuilder('j')
  217.             ->where('j.online = :online')
  218.             ->andWhere('j.locale = :locale')
  219.             ->andWhere('j.keywords LIKE :kw')
  220.             ->setParameter('online'true)
  221.             ->setParameter('locale'$locale)
  222.             ->setParameter('kw''%' $keyword '%')
  223.             ->orderBy('j.createdAt''DESC')
  224.             ->setMaxResults($limit)
  225.             ->getQuery()
  226.             ->enableResultCache(self::HOMEPAGE_CACHE_TTL$cacheKey)
  227.             ->getResult();
  228.     }
  229.     /**
  230.      * Recommande des offres similaires aux offres likées par le candidat
  231.      * (même catégorie ou mêmes compétences requises)
  232.      *
  233.      * @param array $likedJobIds IDs des jobs déjà likés
  234.      * @param int $limit Nombre max de résultats
  235.      * @return Jobs[]
  236.      */
  237.     public function findRecommendedByLikedJobs(array $likedJobIdsint $limit 10): array
  238.     {
  239.         if (empty($likedJobIds)) {
  240.             return [];
  241.         }
  242.         // Récupérer les catégories et compétences des jobs likés
  243.         $likedJobs $this->createQueryBuilder('lj')
  244.             ->select('lj.category, lj.requiredSkills')
  245.             ->where('lj.id IN (:ids)')
  246.             ->setParameter('ids'$likedJobIds)
  247.             ->getQuery()
  248.             ->getResult();
  249.         $categories = [];
  250.         $skillTerms = [];
  251.         foreach ($likedJobs as $lj) {
  252.             if (!empty($lj['category'])) {
  253.                 $categories[] = $lj['category'];
  254.             }
  255.             if (!empty($lj['requiredSkills'])) {
  256.                 // Extraire quelques mots-clés des compétences
  257.                 $words array_filter(explode(','$lj['requiredSkills']), function($w) {
  258.                     return strlen(trim($w)) >= 3;
  259.                 });
  260.                 foreach (array_slice($words03) as $word) {
  261.                     $skillTerms[] = trim($word);
  262.                 }
  263.             }
  264.         }
  265.         $categories array_unique($categories);
  266.         $skillTerms array_unique(array_slice($skillTerms05));
  267.         $qb $this->createQueryBuilder('j')
  268.             ->where('j.online = :online')
  269.             ->andWhere('j.id NOT IN (:excludeIds)')
  270.             ->setParameter('online'true)
  271.             ->setParameter('excludeIds'$likedJobIds);
  272.         // Construire les conditions OR pour catégories et skills
  273.         $orConditions = [];
  274.         $i 0;
  275.         foreach ($categories as $cat) {
  276.             $param 'cat' $i;
  277.             $orConditions[] = 'j.category = :' $param;
  278.             $qb->setParameter($param$cat);
  279.             $i++;
  280.         }
  281.         foreach ($skillTerms as $skill) {
  282.             $param 'skill' $i;
  283.             $orConditions[] = 'j.requiredSkills LIKE :' $param;
  284.             $qb->setParameter($param'%' $skill '%');
  285.             $i++;
  286.         }
  287.         if (empty($orConditions)) {
  288.             return [];
  289.         }
  290.         $qb->andWhere('(' implode(' OR '$orConditions) . ')')
  291.             ->orderBy('j.createdAt''DESC')
  292.             ->setMaxResults($limit);
  293.         return $qb->getQuery()->getResult();
  294.     }
  295.     /**
  296.      * Recommande des offres provenant des entreprises likées par le candidat
  297.      *
  298.      * @param array $likedEnterpriseIds IDs des entreprises likées
  299.      * @param array $excludeJobIds IDs des jobs à exclure (déjà likés)
  300.      * @param int $limit Nombre max de résultats
  301.      * @return Jobs[]
  302.      */
  303.     public function findRecommendedByLikedEnterprises(array $likedEnterpriseIds, array $excludeJobIds = [], int $limit 10): array
  304.     {
  305.         if (empty($likedEnterpriseIds)) {
  306.             return [];
  307.         }
  308.         $qb $this->createQueryBuilder('j')
  309.             ->where('j.online = :online')
  310.             ->andWhere('j.enterprise IN (:enterpriseIds)')
  311.             ->setParameter('online'true)
  312.             ->setParameter('enterpriseIds'$likedEnterpriseIds);
  313.         if (!empty($excludeJobIds)) {
  314.             $qb->andWhere('j.id NOT IN (:excludeIds)')
  315.                 ->setParameter('excludeIds'$excludeJobIds);
  316.         }
  317.         $qb->orderBy('j.createdAt''DESC')
  318.             ->setMaxResults($limit);
  319.         return $qb->getQuery()->getResult();
  320.     }
  321.     /**
  322.      * Trouve des offres similaires à une offre donnée
  323.      * (même catégorie, mêmes compétences, même ville, ou même entreprise)
  324.      *
  325.      * @param Jobs $job L'offre de référence
  326.      * @param int $limit Nombre max de résultats
  327.      * @return Jobs[]
  328.      */
  329.     public function findSimilarJobs(Jobs $jobint $limit 5): array
  330.     {
  331.         $qb $this->createQueryBuilder('j')
  332.             ->where('j.online = :online')
  333.             ->andWhere('j.locale = :locale')
  334.             ->andWhere('j.id != :currentId')
  335.             ->setParameter('online'true)
  336.             ->setParameter('locale'$job->getLocale())
  337.             ->setParameter('currentId'$job->getId());
  338.         $orConditions = [];
  339.         $i 0;
  340.         // Même catégorie
  341.         if (!empty($job->getCategory())) {
  342.             $param 'cat' $i;
  343.             $orConditions[] = 'j.category = :' $param;
  344.             $qb->setParameter($param$job->getCategory());
  345.             $i++;
  346.         }
  347.         // Même ville
  348.         if (!empty($job->getCity())) {
  349.             $param 'city' $i;
  350.             $orConditions[] = 'j.city = :' $param;
  351.             $qb->setParameter($param$job->getCity());
  352.             $i++;
  353.         }
  354.         // Compétences similaires (mots-clés extraits de requiredSkills)
  355.         if (!empty($job->getRequiredSkills())) {
  356.             $skills array_filter(explode(','$job->getRequiredSkills()), function($w) {
  357.                 return strlen(trim($w)) >= 3;
  358.             });
  359.             foreach (array_slice($skills03) as $skill) {
  360.                 $param 'skill' $i;
  361.                 $orConditions[] = 'j.requiredSkills LIKE :' $param;
  362.                 $qb->setParameter($param'%' trim($skill) . '%');
  363.                 $i++;
  364.             }
  365.         }
  366.         // Mots-clés du titre similaires
  367.         if (!empty($job->getJobTitle())) {
  368.             $titleWords array_filter(explode(' '$job->getJobTitle()), function($w) {
  369.                 return strlen(trim($w)) >= 4;
  370.             });
  371.             foreach (array_slice($titleWords02) as $word) {
  372.                 $param 'title' $i;
  373.                 $orConditions[] = 'j.jobTitle LIKE :' $param;
  374.                 $qb->setParameter($param'%' trim($word) . '%');
  375.                 $i++;
  376.             }
  377.         }
  378.         if (empty($orConditions)) {
  379.             // Fallback : retourner les offres les plus récentes
  380.             $qb->orderBy('j.createdAt''DESC')
  381.                 ->setMaxResults($limit);
  382.             return $qb->getQuery()->getResult();
  383.         }
  384.         $qb->andWhere('(' implode(' OR '$orConditions) . ')')
  385.             ->orderBy('j.createdAt''DESC')
  386.             ->setMaxResults($limit);
  387.         $results $qb->getQuery()->getResult();
  388.         // Si pas assez de résultats, compléter avec des offres récentes
  389.         if (count($results) < $limit) {
  390.             $excludeIds array_map(function($j) { return $j->getId(); }, $results);
  391.             $excludeIds[] = $job->getId();
  392.             $complement $this->createQueryBuilder('j')
  393.                 ->where('j.online = :online')
  394.                 ->andWhere('j.locale = :locale')
  395.                 ->andWhere('j.id NOT IN (:excludeIds)')
  396.                 ->setParameter('online'true)
  397.                 ->setParameter('locale'$job->getLocale())
  398.                 ->setParameter('excludeIds'$excludeIds)
  399.                 ->orderBy('j.createdAt''DESC')
  400.                 ->setMaxResults($limit count($results))
  401.                 ->getQuery()
  402.                 ->getResult();
  403.             $results array_merge($results$complement);
  404.         }
  405.         return $results;
  406.     }
  407.     /**
  408.      * Compte le nombre total d'offres en ligne sur tout le site (toutes locales confondues).
  409.      *
  410.      * Mise en cache de résultats : ce chiffre alimente une stat "hero", il n'a
  411.      * pas besoin d'être exact à la seconde. Évite un scan à chaque chargement.
  412.      *
  413.      * @return int
  414.      */
  415.     public function countAllOnlineJobs(): int
  416.     {
  417.         return (int) $this->createQueryBuilder('j')
  418.             ->select('COUNT(j.id)')
  419.             ->where('j.online = :online')
  420.             ->setParameter('online'true)
  421.             ->getQuery()
  422.             ->enableResultCache(self::HOMEPAGE_CACHE_TTL'jobs_count_all_online')
  423.             ->getSingleScalarResult();
  424.     }
  425. }