src/Repository/ProduitDeclinationValueRepository.php line 369

Open in your IDE?
  1. <?php
  2. namespace App\Repository;
  3. use App\Entity\ProduitDeclinationValue;
  4. use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
  5. use Doctrine\Persistence\ManagerRegistry;
  6. use App\Entity\ValueDeclination;
  7. /**
  8.  * @method ProduitDeclinationValue|null find($id, $lockMode = null, $lockVersion = null)
  9.  * @method ProduitDeclinationValue|null findOneBy(array $criteria, array $orderBy = null)
  10.  * @method ProduitDeclinationValue[]    findAll()
  11.  * @method ProduitDeclinationValue[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  12.  */
  13. class ProduitDeclinationValueRepository extends ServiceEntityRepository
  14. {
  15.     public function __construct(ManagerRegistry $registry)
  16.     {
  17.         parent::__construct($registryProduitDeclinationValue::class);
  18.     }
  19.     public function findProduitGroup(
  20.         $page,
  21.         $limit,
  22.         int $idProduit,
  23.         ?string $reference,
  24.         array $declinations = [],   // [declinationId => valueId|valueId[]]
  25.         bool $withDeleted false
  26.     ) {
  27.         $qb $this->createQueryBuilder('p')
  28.             ->leftJoin('p.produit''pp')
  29.             ->andWhere('pp.id = :produit')->setParameter('produit'$idProduit);
  30.         if ($reference) {
  31.             $qb->andWhere('UPPER(p.reference) LIKE :reference')
  32.             ->setParameter('reference''%'.mb_strtoupper($reference).'%');
  33.         }
  34.         // AND entre chaque declination
  35.         $i 0;
  36.         foreach ($declinations as $declId => $val) {
  37.             if ($val === null || $val === '' || $val === [] ) { continue; }
  38.             $i++;
  39.             $alias 'gdv'.$i;
  40.             $paramDecl 'd'.$i;
  41.             $paramVal  'v'.$i;
  42.             $vals is_array($val) ? array_values(array_unique($val)) : [ (int)$val ];
  43.             // innerJoin = critère obligatoire
  44.             $qb->innerJoin('p.groupDeclinationValues'$alias'WITH',
  45.                 $alias.'.declination = :'.$paramDecl.' AND '.$alias.'.value IN (:'.$paramVal.')'
  46.             )
  47.             ->setParameter($paramDecl, (int)$declId)
  48.             ->setParameter($paramVal$vals);
  49.         }
  50.         if (!$withDeleted) {
  51.             $qb->andWhere('pp.deletedAt IS NULL');
  52.         }
  53.         $qb->orderBy('p.reference''ASC');
  54.         if ($page !== false) {
  55.             $qb->setMaxResults($limit)
  56.             ->setFirstResult($page $limit);
  57.         }
  58.         return $qb->getQuery()->getResult();
  59.     }
  60.     public function countProduitGroup(
  61.         int $idProduit,
  62.         ?string $reference,
  63.         array $declinations = [],   // [declinationId => valueId|valueId[]]
  64.         bool $withDeleted false
  65.     ) {
  66.         $qb $this->createQueryBuilder('p')
  67.             ->select('COUNT(p)')
  68.             ->leftJoin('p.produit''pp')
  69.             ->andWhere('pp.id = :produit')->setParameter('produit'$idProduit);
  70.         if ($reference) {
  71.             $qb->andWhere('UPPER(p.reference) LIKE :reference')
  72.             ->setParameter('reference''%'.mb_strtoupper($reference).'%');
  73.         }
  74.         $i 0;
  75.         foreach ($declinations as $declId => $val) {
  76.             if ($val === null || $val === '' || $val === [] ) { continue; }
  77.             $i++;
  78.             $alias 'gdv'.$i;
  79.             $paramDecl 'd'.$i;
  80.             $paramVal  'v'.$i;
  81.             $vals is_array($val) ? array_values(array_unique($val)) : [ (int)$val ];
  82.             $qb->innerJoin('p.groupDeclinationValues'$alias'WITH',
  83.                 $alias.'.declination = :'.$paramDecl.' AND '.$alias.'.value IN (:'.$paramVal.')'
  84.             )
  85.             ->setParameter($paramDecl, (int)$declId)
  86.             ->setParameter($paramVal$vals);
  87.         }
  88.         if (!$withDeleted) {
  89.             $qb->andWhere('pp.deletedAt IS NULL');
  90.         }
  91.         return (int)$qb->getQuery()->getSingleScalarResult();
  92.     }
  93.     public function searchItem($query$isAvailable false$maxResult 5$withDeleted false, ?int $supplierId null)
  94.     {
  95.         $query trim((string) $query);
  96.         $isAvailable filter_var($isAvailableFILTER_VALIDATE_BOOLEANFILTER_NULL_ON_FAILURE) === true;
  97.         // Minimum 2 caractères
  98.         if (mb_strlen($query) < 2) {
  99.             return [];
  100.         }
  101.         $term mb_strtoupper($query);
  102.         $qb $this->createQueryBuilder('p')->distinct();
  103.         $qb->select('p.id, p.name, p.reference, p.description, p.price_ht, p.buyingPriceTtc')
  104.             ->innerJoin('p.produit''pp')
  105.             ->addSelect('pp.unit AS unit')
  106.             ->leftJoin('pp.tva''pptva')
  107.             ->addSelect('pptva.id AS tva_id')
  108.             ->addSelect('pptva.number AS tva')
  109.             ->leftJoin('p.stocks''s');
  110.         // 2 caractères => référence uniquement
  111.         // > 2 caractères => référence OU nom
  112.         if (mb_strlen($query) === 2) {
  113.             $qb->andWhere('UPPER(p.reference) LIKE :referenceStart')
  114.             ->setParameter('referenceStart'$term '%');
  115.         } else {
  116.             $qb->andWhere(
  117.                 $qb->expr()->orX(
  118.                     'UPPER(p.reference) LIKE :referenceStart',
  119.                     'UPPER(p.name) LIKE :nameContains'
  120.                 )
  121.             )
  122.             ->setParameter('referenceStart'$term '%')
  123.             ->setParameter('nameContains''%' $term '%');
  124.         }
  125.         if (!$withDeleted) {
  126.             $qb->andWhere('pp.deletedAt IS NULL');
  127.         }
  128.         if ($supplierId !== null && $supplierId 0) {
  129.             $qb->andWhere('IDENTITY(pp.supplier) = :supplierId')
  130.                 ->setParameter('supplierId'$supplierId);
  131.         }
  132.         if ($isAvailable) {
  133.             $qb->andWhere('(s.qtStock - s.qtReserved) >= 1');
  134.         }
  135.         $qb->orderBy('p.createdAt''DESC')
  136.         ->setMaxResults($maxResult);
  137.         return $qb->getQuery()->getResult();
  138.     }
  139.     public function findBestMatchForSocialOrder(string $reference, ?string $color null, ?string $size null): ?ProduitDeclinationValue
  140.     {
  141.         $reference mb_strtoupper(trim($reference));
  142.         if ($reference === '') {
  143.             return null;
  144.         }
  145.         $candidates $this->createQueryBuilder('p')
  146.             ->leftJoin('p.produit''produit')->addSelect('produit')
  147.             ->leftJoin('produit.tva''tva')->addSelect('tva')
  148.             ->leftJoin('p.groupDeclinationValues''gdv')->addSelect('gdv')
  149.             ->leftJoin('gdv.declination''decl')->addSelect('decl')
  150.             ->leftJoin('gdv.value''val')->addSelect('val')
  151.             ->andWhere('UPPER(p.reference) = :reference OR UPPER(produit.reference) = :reference')
  152.             ->setParameter('reference'$reference)
  153.             ->getQuery()
  154.             ->getResult();
  155.         if ($candidates === []) {
  156.             $candidates $this->createQueryBuilder('p')
  157.                 ->leftJoin('p.produit''produit')->addSelect('produit')
  158.                 ->leftJoin('produit.tva''tva')->addSelect('tva')
  159.                 ->leftJoin('p.groupDeclinationValues''gdv')->addSelect('gdv')
  160.                 ->leftJoin('gdv.declination''decl')->addSelect('decl')
  161.                 ->leftJoin('gdv.value''val')->addSelect('val')
  162.                 ->andWhere('UPPER(p.reference) LIKE :referenceLike OR UPPER(produit.reference) LIKE :referenceLike')
  163.                 ->setParameter('referenceLike'$reference '%')
  164.                 ->setMaxResults(20)
  165.                 ->getQuery()
  166.                 ->getResult();
  167.         }
  168.         if ($candidates === []) {
  169.             return null;
  170.         }
  171.         $normalizedColor $this->normalizeToken($color);
  172.         $normalizedSize $this->normalizeToken($size);
  173.         $bestCandidate null;
  174.         $bestScore = -1;
  175.         foreach ($candidates as $candidate) {
  176.             $score 0;
  177.             $candidateReference mb_strtoupper((string) $candidate->getReference());
  178.             $productReference mb_strtoupper((string) $candidate->getProduit()?->getReference());
  179.             if ($candidateReference === $reference) {
  180.                 $score += 100;
  181.             } elseif (str_starts_with($candidateReference$reference)) {
  182.                 $score += 70;
  183.             }
  184.             if ($productReference === $reference) {
  185.                 $score += 80;
  186.             } elseif ($productReference !== '' && str_starts_with($productReference$reference)) {
  187.                 $score += 50;
  188.             }
  189.             $matchedColor false;
  190.             $matchedSize false;
  191.             foreach ($candidate->getGroupDeclinationValues() as $groupValue) {
  192.                 $declinationName $this->normalizeToken($groupValue->getDeclination()?->getName());
  193.                 $valueName $this->normalizeToken($groupValue->getValue()?->getName());
  194.                 if ($normalizedColor !== '' && in_array($declinationName, ['couleur''color'], true) && $valueName === $normalizedColor) {
  195.                     $score += 20;
  196.                     $matchedColor true;
  197.                 }
  198.                 if ($normalizedSize !== '' && in_array($declinationName, ['taille''pointure''size'], true) && $valueName === $normalizedSize) {
  199.                     $score += 20;
  200.                     $matchedSize true;
  201.                 }
  202.             }
  203.             if ($normalizedColor !== '' && !$matchedColor) {
  204.                 $score -= 10;
  205.             }
  206.             if ($normalizedSize !== '' && !$matchedSize) {
  207.                 $score -= 10;
  208.             }
  209.             if ($score $bestScore) {
  210.                 $bestScore $score;
  211.                 $bestCandidate $candidate;
  212.             }
  213.         }
  214.         return $bestCandidate;
  215.     }
  216.     public function findClosestMatchesForSocialOrder(string $queryint $limit 3): array
  217.     {
  218.         $query trim($query);
  219.         if ($query === '') {
  220.             return [];
  221.         }
  222.         $normalizedQuery $this->normalizeToken($query);
  223.         if ($normalizedQuery === '') {
  224.             return [];
  225.         }
  226.         $term mb_strtoupper($query);
  227.         $candidates $this->createQueryBuilder('p')
  228.             ->leftJoin('p.produit''produit')->addSelect('produit')
  229.             ->leftJoin('produit.tva''tva')->addSelect('tva')
  230.             ->andWhere('UPPER(p.reference) LIKE :term OR UPPER(produit.reference) LIKE :term OR UPPER(p.name) LIKE :contains OR UPPER(produit.name) LIKE :contains')
  231.             ->setParameter('term'$term '%')
  232.             ->setParameter('contains''%' $term '%')
  233.             ->setMaxResults(25)
  234.             ->getQuery()
  235.             ->getResult();
  236.         $scored = [];
  237.         foreach ($candidates as $candidate) {
  238.             if (!$candidate instanceof ProduitDeclinationValue) {
  239.                 continue;
  240.             }
  241.             $reference $this->normalizeToken($candidate->getReference());
  242.             $name $this->normalizeToken($candidate->getName());
  243.             $productReference $this->normalizeToken($candidate->getProduit()?->getReference());
  244.             $productName $this->normalizeToken($candidate->getProduit()?->getName());
  245.             $score 0;
  246.             foreach ([$reference$name$productReference$productName] as $value) {
  247.                 if ($value === '') {
  248.                     continue;
  249.                 }
  250.                 if ($value === $normalizedQuery) {
  251.                     $score max($score1000);
  252.                     continue;
  253.                 }
  254.                 if (str_contains($value$normalizedQuery) || str_contains($normalizedQuery$value)) {
  255.                     $score max($score850 abs(strlen($value) - strlen($normalizedQuery)));
  256.                 }
  257.                 similar_text($normalizedQuery$value$percent);
  258.                 $score max($score, (int) round($percent 7));
  259.                 if (function_exists('levenshtein')) {
  260.                     $distance levenshtein($normalizedQuery$value);
  261.                     $score max($scoremax(0700 - ($distance 70)));
  262.                 }
  263.             }
  264.             if ($score <= 0) {
  265.                 continue;
  266.             }
  267.             $scored[] = [
  268.                 'score' => $score,
  269.                 'declination' => $candidate,
  270.             ];
  271.         }
  272.         usort($scored, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
  273.         $results = [];
  274.         $seen = [];
  275.         foreach ($scored as $item) {
  276.             /** @var ProduitDeclinationValue $declination */
  277.             $declination $item['declination'];
  278.             if (isset($seen[$declination->getId()])) {
  279.                 continue;
  280.             }
  281.             $seen[$declination->getId()] = true;
  282.             $results[] = $declination;
  283.             if (count($results) >= $limit) {
  284.                 break;
  285.             }
  286.         }
  287.         return $results;
  288.     }
  289.     private function normalizeToken(?string $value): string
  290.     {
  291.         $value trim((string) $value);
  292.         if ($value === '') {
  293.             return '';
  294.         }
  295.         $ascii = @iconv('UTF-8''ASCII//TRANSLIT//IGNORE'$value);
  296.         $ascii is_string($ascii) ? $ascii $value;
  297.         return strtolower(trim((string) preg_replace('/[^a-zA-Z0-9]+/'''$ascii)));
  298.     }
  299.     public function searchAndCountProduitDeclinations(
  300.         int $page,
  301.         int $limit,
  302.         $reference,
  303.         $name,
  304.         $categories,
  305.         $isAvailable,
  306.         $inPromo,
  307.         array $declinationFilters = [],
  308.         $qtMin null,
  309.         $qtMax null,
  310.         $buyingPriceMin null,
  311.         $buyingPriceMax null,
  312.         $priceMin null,
  313.         $priceMax null,
  314.         string $sortField 'createdAt',
  315.         string $sortType 'DESC',
  316.         bool $withDeleted false,
  317.         ?int $warehouseId null
  318.     ): array {
  319.         /* ===============================
  320.         1) QueryBuilder de base
  321.         =============================== */
  322.         $qb $this->createQueryBuilder('p');
  323.         $this->QueryBuilderSearch(
  324.             $qb,
  325.             $reference,
  326.             $name,
  327.             $categories,
  328.             $isAvailable,
  329.             $inPromo,
  330.             $declinationFilters,
  331.             $qtMin,
  332.             $qtMax,
  333.             $buyingPriceMin,
  334.             $buyingPriceMax,
  335.             $priceMin,
  336.             $priceMax,
  337.             $withDeleted,
  338.             $warehouseId
  339.         );
  340.         /* ===============================
  341.         2) COUNT (clone)
  342.         =============================== */
  343.         $qbCount = clone $qb;
  344.         $total count(
  345.             $qbCount
  346.                 ->resetDQLPart('select')
  347.                 ->resetDQLPart('orderBy')
  348.                 ->select('p.id')
  349.                 ->getQuery()
  350.                 ->getScalarResult()
  351.         );
  352.         /* ===============================
  353.         3) RESULTS (clone)
  354.         =============================== */
  355.         $qbResult = clone $qb;
  356.         if ($sortField === 'declinationPositions') {
  357.             $qbResult
  358.                 ->orderBy('declinationPosition1Sort''ASC')
  359.                 ->addOrderBy('declinationPosition2Sort''ASC')
  360.                 ->addOrderBy('p.reference''ASC');
  361.         } else {
  362.             $qbResult->orderBy($sortField === 'qtStock' 'qtStockSort' 'p.' $sortField$sortType);
  363.         }
  364.         $qbResult
  365.             ->setFirstResult($page $limit)
  366.             ->setMaxResults($limit);
  367.         $data $qbResult->getQuery()->getResult();
  368.         return [
  369.             'data'  => $data,
  370.             'total' => $total,
  371.         ];
  372.     }
  373.     public function searchProduitDeclinations(
  374.         $page,
  375.         $limit,
  376.         $reference,
  377.         $name,
  378.         $categories,
  379.         $isAvailable,
  380.         $inPromo,
  381.         array $declinationFilters = [],
  382.         $qtMin null,
  383.         $qtMax null,
  384.         $buyingPriceMin null,
  385.         $buyingPriceMax null,
  386.         $priceMin null,
  387.         $priceMax null,
  388.         $sortField 'createdAt',
  389.         $sortType 'DESC',
  390.         $withDeleted false,
  391.         ?int $warehouseId null
  392.     ) {
  393.         $qb $this->createQueryBuilder('p');
  394.         $qb $this->QueryBuilderSearch(
  395.             $qb,
  396.             $reference,
  397.             $name,
  398.             $categories,
  399.             $isAvailable,
  400.             $inPromo,
  401.             $declinationFilters,
  402.             $qtMin,
  403.             $qtMax,
  404.             $buyingPriceMin,
  405.             $buyingPriceMax,
  406.             $priceMin,
  407.             $priceMax,
  408.             $withDeleted,
  409.             $warehouseId
  410.         );
  411.         if ($sortField === 'declinationPositions') {
  412.             $qb->orderBy('declinationPosition1Sort''ASC')
  413.                ->addOrderBy('declinationPosition2Sort''ASC')
  414.                ->addOrderBy('p.reference''ASC');
  415.         } else {
  416.             $qb->orderBy(($sortField === 'qtStock' '' 'p.') . $sortField$sortType);
  417.         }
  418.         if ($page !== false$qb->setMaxResults($limit)->setFirstResult($page $limit);
  419.         return $qb->getQuery()->getResult();
  420.     }
  421.     public function countProduitDeclinations(
  422.             $reference,
  423.             $name,
  424.             $categories,
  425.             $isAvailable,
  426.             $inPromo,
  427.             array $declinationFilters = [],
  428.             $qtMin null,
  429.             $qtMax null,
  430.             $buyingPriceMin null,
  431.             $buyingPriceMax null,
  432.             $priceMin null,
  433.             $priceMax null,
  434.             $withDeleted false,
  435.             ?int $warehouseId null
  436.         ) {
  437.             $qb $this->createQueryBuilder('p');
  438.             $qb->select('COUNT(p)');
  439.             $qb $this->QueryBuilderSearch(
  440.                 $qb,
  441.                 $reference,
  442.                 $name,
  443.                 $categories,
  444.                 $isAvailable,
  445.                 $inPromo,
  446.                 $declinationFilters,
  447.                 $qtMin,
  448.                 $qtMax,
  449.                 $buyingPriceMin,
  450.                 $buyingPriceMax,
  451.                 $priceMin,
  452.                 $priceMax,
  453.                 $withDeleted,
  454.                 $warehouseId
  455.             );
  456.             $rows $qb->getQuery()->getScalarResult();
  457.             return \count($rows);
  458.         }
  459.     /*--Filtre liste declinaisons--*/
  460.     private function QueryBuilderSearch(
  461.         $qb,
  462.         $reference,
  463.         $name,
  464.         $categories,
  465.         $isAvailable,
  466.         $inPromo,
  467.         array $declinationFilters = [],
  468.         $qtMin null,
  469.         $qtMax null,
  470.         $buyingPriceMin null,
  471.         $buyingPriceMax null,
  472.         $priceMin null,
  473.         $priceMax null,
  474.         $withDeleted false,
  475.         ?int $warehouseId null
  476.     ) {
  477.         // Produit parent utilisé pour les filtres "liste des déclinaisons d'un produit"
  478.         $qb->leftJoin('p.produit''pp');
  479.         if ($name) {
  480.             $qb->andWhere('UPPER(p.name) LIKE :name')
  481.             ->setParameter('name''%' strtoupper($name) . '%');
  482.         }
  483.         if ($reference) {
  484.             $qb->andWhere('(UPPER(p.reference) LIKE :reference OR UPPER(pp.reference) LIKE :reference)')
  485.             ->setParameter('reference''%' strtoupper(trim($reference)) . '%');
  486.         }
  487.         // Filtres dynamiques declinaisons
  488.         foreach ($declinationFilters as $declinationId => $valueId) {
  489.             if (empty($valueId)) {
  490.                 continue;
  491.             }
  492.             $alias 'gdv_' . (int)$declinationId;
  493.             $qb
  494.                 ->innerJoin('p.groupDeclinationValues'$alias)
  495.                 ->andWhere($alias '.declination = :decl_' $declinationId)
  496.                 ->setParameter('decl_' $declinationId, (int)$declinationId);
  497.             if (is_array($valueId)) {
  498.                 $qb->andWhere($alias '.value IN (:val_' $declinationId ')')
  499.                    ->setParameter('val_' $declinationId$valueId);
  500.             } else {
  501.                 $qb->andWhere($alias '.value = :val_' $declinationId)
  502.                    ->setParameter('val_' $declinationId, (int)$valueId);
  503.             }
  504.         }
  505.         // Stock + agrégats
  506.         if (($warehouseId ?? 0) > 0) {
  507.             $qb->leftJoin('p.stocks''s''WITH''s.warehouse = :warehouseFilter')
  508.                ->setParameter('warehouseFilter'$warehouseId);
  509.         } else {
  510.             $qb->leftJoin('p.stocks''s');
  511.         }
  512.         $qb
  513.         ->addSelect('COALESCE(SUM(s.qtStock),0) AS HIDDEN qtStockSort')
  514.         ->addSelect('COALESCE(SUM(s.qtReserved),0) AS HIDDEN qtReservedSort')
  515.         ->addSelect('COALESCE(SUM(s.qtStock - s.qtReserved),0) AS HIDDEN qtAvailableSort')
  516.         ->addSelect("(SELECT MIN(sortValue1.name)
  517.             FROM App\Entity\GroupDeclinationValue sortGroup1
  518.             JOIN sortGroup1.declination sortDeclination1
  519.             JOIN sortGroup1.value sortValue1
  520.             WHERE sortGroup1.produitDeclination = p
  521.               AND sortDeclination1.position = 1) AS HIDDEN declinationPosition1Sort")
  522.         ->addSelect("(SELECT MIN(sortValue2.name)
  523.             FROM App\Entity\GroupDeclinationValue sortGroup2
  524.             JOIN sortGroup2.declination sortDeclination2
  525.             JOIN sortGroup2.value sortValue2
  526.             WHERE sortGroup2.produitDeclination = p
  527.               AND sortDeclination2.position = 2) AS HIDDEN declinationPosition2Sort")
  528.         ->groupBy('p.id');
  529.         // Stock
  530.         /*
  531.         $qb->leftJoin('p.stocks', 's')
  532.             ->addSelect('COALESCE(SUM(s.qtStock),0) AS HIDDEN qtStockSort')
  533.             ->groupBy('p.id');
  534.     */
  535.         if ($isAvailable !== "") {
  536.             if ($isAvailable) {
  537.                 $qb->andWhere('(s.qtStock - s.qtReserved) >= 1');
  538.             } else {
  539.                 $qb->andWhere('(s.qtStock - s.qtReserved) <= 0');
  540.             }
  541.         }
  542.         // Categories (produit parent)
  543.         if (!empty($categories)) {
  544.             $qb->leftJoin('pp.categories''ppc');
  545.             if (is_array($categories)) {
  546.                 $qb->andWhere('ppc.id IN (:categories)')
  547.                 ->setParameter('categories'$categories);
  548.             } else {
  549.                 $qb->andWhere('ppc.id = :categories')
  550.                 ->setParameter('categories', (int)$categories);
  551.             }
  552.         }
  553.         // Promo (produit parent)
  554.         if ($inPromo !== "") {
  555.             if ($inPromo == '1') {
  556.                 $qb->join('pp.promotion''promo')
  557.                 ->andWhere(':now >= promo.startAt')
  558.                 ->andWhere(':now <= promo.endAt')
  559.                 ->setParameter('now', new \DateTime('now'));
  560.             } elseif ($inPromo == '0') {
  561.                 $qb->andWhere('pp.promotion IS NULL');
  562.             }
  563.         }
  564.         // Prix (TTC)
  565.         if ($buyingPriceMin) {
  566.             $qb->andWhere('p.buyingPriceTtc >= :buyingPriceMin')
  567.             ->setParameter('buyingPriceMin'$buyingPriceMin);
  568.         }
  569.         if ($buyingPriceMax) {
  570.             $qb->andWhere('p.buyingPriceTtc <= :buyingPriceMax')
  571.             ->setParameter('buyingPriceMax'$buyingPriceMax);
  572.         }
  573.         if ($priceMin) {
  574.             $qb->andWhere('pp.price_ttc >= :priceMin')
  575.             ->setParameter('priceMin'$priceMin);
  576.         }
  577.         if ($priceMax) {
  578.             $qb->andWhere('pp.price_ttc <= :priceMax')
  579.             ->setParameter('priceMax'$priceMax);
  580.         }
  581.         // Quantité (disponible)
  582.         if ($qtMin !== null && $qtMin !== '') {
  583.             $qb->having('COALESCE(SUM(s.qtStock - s.qtReserved), 0) >= :qtMin')
  584.             ->setParameter('qtMin', (int) $qtMin);
  585.         }
  586.         if ($qtMax !== null && $qtMax !== '') {
  587.             $qb->andHaving('COALESCE(SUM(s.qtStock - s.qtReserved), 0) <= :qtMax')
  588.             ->setParameter('qtMax', (int) $qtMax);
  589.         }
  590.         // Quantite
  591.         //if ($qtMin) $qb->andWhere('s.qtStock >= :qtMin')->setParameter('qtMin', $qtMin);
  592.         //if ($qtMax) $qb->andWhere('s.qtStock <= :qtMax')->setParameter('qtMax', $qtMax);
  593.         // Suppression
  594.         $qb->andWhere('pp.deletedAt IS ' . ($withDeleted 'NOT' '') . ' NULL');
  595.         return $qb;
  596.     }
  597.     public function findDeclinationValueWithDeclination($idDeclination$idProduit, ?int $supplierId null) {
  598.         $qb $this->createQueryBuilder('p');
  599.         $qb->leftJoin('p.produit''pp')->andWhere('pp.deletedAt IS NULL')
  600.                 ->andWhere('pp.id = :produit')->setParameter('produit'$idProduit);
  601.         if ($supplierId !== null && $supplierId 0) {
  602.             $qb->andWhere('IDENTITY(pp.supplier) = :supplierId')
  603.                 ->setParameter('supplierId'$supplierId);
  604.         }
  605.         $qb->leftJoin('p.groupDeclinationValues''pgc')
  606.                ->leftJoin('pgc.declination''pgcd')
  607.                ->leftJoin('pgc.value''pgcv')
  608.                ->andWhere('pgcv.id = :idDeclination')->setParameter('idDeclination'$idDeclination );
  609.         $qb->orderBy('p.reference''ASC');
  610.         return $qb->getQuery()->getResult();
  611.     }
  612.     public function qtyAvailable($prdDec)
  613.     {
  614.         $qb $this->createQueryBuilder('p');
  615.         $qb->leftJoin('p.stocks','s')
  616.             ->Select('(s.qtStock - s.qtReserved) as qtAvailable')
  617.             ->where('p.id = :id')
  618.             ->setParameter('id'$prdDec->getId());
  619.     }
  620.     public function qtyReserved($prdDec)
  621.     {
  622.         $qb $this->createQueryBuilder('p');
  623.         $qb->leftJoin('p.stocks','s')
  624.             ->Select('s.qtReserved as qtReserved')
  625.             ->where('p.id = :id')
  626.             ->setParameter('id'$prdDec->getId());
  627.     }
  628.     //fonction et requête pour déclinaion 1 (Exemple : Couleur)
  629.    public function getStatsByDeclinationPosition($idProduit, ?string $dateBefore, ?string $dateAfter, ?int $resellerUserId null): array
  630.     {
  631.         $conn $this->getEntityManager()->getConnection();
  632.         $sql "
  633.             SELECT
  634.                 dcl.name AS declinaison_label,
  635.                 v.name AS valeur,
  636.                 (
  637.                     SELECT f.image_name
  638.                     FROM produit_declination_value_file pdf
  639.                     JOIN file f ON f.id = pdf.file_id
  640.                     WHERE pdf.produit_declination_value_id = pdv.id
  641.                     LIMIT 1
  642.                 ) AS decli_image,
  643.                 SUM(ddp.quantity) AS qtTotal,
  644.                 SUM(CASE WHEN d.status NOT IN ('annule', 'retourne', 'retour-en-cours') THEN ddp.quantity ELSE 0 END) AS qtVendu,
  645.                 SUM(CASE WHEN d.status = 'annule' THEN ddp.quantity ELSE 0 END) AS qtAnnulee,
  646.                 SUM(CASE WHEN d.status IN ('retourne', 'retour-en-cours') THEN ddp.quantity ELSE 0 END) AS qtRetour,
  647.                 SUM(ddp.total_amount_ttc) AS montantTotal
  648.             FROM document_declination_produit ddp
  649.             JOIN produit_declination_value pdv ON ddp.produit_declination_value_id = pdv.id
  650.             JOIN group_declination_value gdv ON gdv.produit_declination_id = pdv.id
  651.             JOIN value_declination v ON v.id = gdv.value_id
  652.             JOIN declination dcl ON dcl.id = v.declination_id AND dcl.position = 1
  653.             JOIN document d ON d.id = ddp.document_id
  654.             WHERE pdv.produit_id = :idProduit
  655.             AND d.type = 'commande'
  656.             AND d.category = 'client'
  657.         ";
  658.         $params = ['idProduit' => $idProduit];
  659.         if ($resellerUserId) {
  660.             $sql .= " AND d.reseller_user_id = :resellerUserId";
  661.             $params['resellerUserId'] = $resellerUserId;
  662.         }
  663.         if ($dateBefore && $dateAfter) {
  664.             $sql .= " AND d.created_at BETWEEN :dateBefore AND :dateAfter";
  665.             $params['dateBefore'] = $dateBefore;
  666.             $params['dateAfter']  = $dateAfter;
  667.         } elseif ($dateBefore) {
  668.             $sql .= " AND d.created_at >= :dateBefore";
  669.             $params['dateBefore'] = $dateBefore;
  670.         } elseif ($dateAfter) {
  671.             $sql .= " AND d.created_at <= :dateAfter";
  672.             $params['dateAfter'] = $dateAfter;
  673.         }
  674.         $sql .= "
  675.             GROUP BY dcl.name, v.name
  676.             ORDER BY qtTotal DESC
  677.         ";
  678.         $stmt $conn->prepare($sql);
  679.         $result $stmt->executeQuery($params);
  680.         return $result->fetchAllAssociative();
  681.     }
  682.     //fonction et requête pour toutes les déclinaisons
  683.     public function getAllDeclinationsStats($idProduit, ?string $dateBefore, ?string $dateAfter, ?int $resellerUserId null): array
  684. {
  685.     $conn $this->getEntityManager()->getConnection();
  686.     $sql "
  687.         SELECT
  688.             dcl1.name AS declinaison1_label,
  689.             v1.name   AS declinaison1_valeur,
  690.             pdv.id    AS decli_id,
  691.             -- Déclinaison 2 (ex : Taille)
  692.             dcl2.name AS declinaison2_label,
  693.             v2.name   AS declinaison2_valeur,
  694.             -- Totaux
  695.             SUM(ddp.quantity) AS qtTotal,
  696.             SUM(
  697.                 CASE
  698.                     WHEN d.status NOT IN ('annule', 'retourne', 'retour-en-cours')
  699.                     THEN ddp.quantity
  700.                     ELSE 0
  701.                 END
  702.             ) AS qtVendu,
  703.             SUM(
  704.                 CASE
  705.                     WHEN d.status = 'annule'
  706.                     THEN ddp.quantity
  707.                     ELSE 0
  708.                 END
  709.             ) AS qtAnnulee,
  710.             SUM(
  711.                 CASE
  712.                     WHEN d.status IN ('retourne', 'retour-en-cours')
  713.                     THEN ddp.quantity
  714.                     ELSE 0
  715.                 END
  716.             ) AS qtRetour,
  717.             SUM(ddp.total_amount_ttc) AS montantTotal
  718.         FROM document_declination_produit ddp
  719.         JOIN produit_declination_value pdv
  720.             ON ddp.produit_declination_value_id = pdv.id
  721.         -- Déclinaison 1 (ex : Couleur)
  722.         JOIN group_declination_value gdv1
  723.             ON gdv1.produit_declination_id = pdv.id
  724.         JOIN value_declination v1
  725.             ON v1.id = gdv1.value_id
  726.         JOIN declination dcl1
  727.             ON dcl1.id = v1.declination_id
  728.            AND dcl1.position = 1
  729.         -- Déclinaison 2 (ex : Taille)
  730.         JOIN group_declination_value gdv2
  731.             ON gdv2.produit_declination_id = pdv.id
  732.         JOIN value_declination v2
  733.             ON v2.id = gdv2.value_id
  734.         JOIN declination dcl2
  735.             ON dcl2.id = v2.declination_id
  736.            AND dcl2.position = 2
  737.         JOIN document d
  738.             ON d.id = ddp.document_id
  739.         WHERE pdv.produit_id = :idProduit
  740.           AND d.type = 'commande'
  741.           AND d.category = 'client'
  742.     ";
  743.     $params = ['idProduit' => $idProduit];
  744.     /* Gestion dynamique des dates */
  745.     if ($resellerUserId) {
  746.         $sql .= " AND d.reseller_user_id = :resellerUserId";
  747.         $params['resellerUserId'] = $resellerUserId;
  748.     }
  749.     if ($dateBefore !== null && $dateAfter !== null) {
  750.         $sql .= " AND d.created_at BETWEEN :dateBefore AND :dateAfter";
  751.         $params['dateBefore'] = $dateBefore;
  752.         $params['dateAfter']  = $dateAfter;
  753.     } elseif ($dateBefore !== null) {
  754.         $sql .= " AND d.created_at >= :dateBefore";
  755.         $params['dateBefore'] = $dateBefore;
  756.     } elseif ($dateAfter !== null) {
  757.         $sql .= " AND d.created_at <= :dateAfter";
  758.         $params['dateAfter'] = $dateAfter;
  759.     }
  760.     $sql .= "
  761.         GROUP BY dcl1.name, v1.name, v2.name, pdv.id
  762.         ORDER BY v1.name ASC, v2.name ASC
  763.     ";
  764.     $stmt   $conn->prepare($sql);
  765.     $result $stmt->executeQuery($params);
  766.     return $result->fetchAllAssociative();
  767. }
  768.     public function countByValue(ValueDeclination $value): int
  769.     {
  770.         return $this->createQueryBuilder('gdv')
  771.             ->select('COUNT(gdv.id)')
  772.             ->where('gdv.value = :val')
  773.             ->setParameter('val'$value)
  774.             ->getQuery()
  775.             ->getSingleScalarResult();
  776.     }
  777. }