src/Repository/ReservationRepository.php line 55

  1. <?php
  2. namespace App\Repository;
  3. use App\Entity\Habitat;
  4. use App\Entity\Property;
  5. use App\Entity\Reservation;
  6. use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
  7. use Doctrine\ORM\NoResultException;
  8. use Doctrine\Persistence\ManagerRegistry;
  9. /**
  10.  * @extends ServiceEntityRepository<Reservation>
  11.  *
  12.  * @method Reservation|null find($id, $lockMode = null, $lockVersion = null)
  13.  * @method Reservation|null findOneBy(array $criteria, array $orderBy = null)
  14.  * @method Reservation[]    findAll()
  15.  * @method Reservation[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  16.  */
  17. class ReservationRepository extends ServiceEntityRepository
  18. {
  19.     public function __construct(ManagerRegistry $registry)
  20.     {
  21.         parent::__construct($registryReservation::class);
  22.     }
  23.     public function add(Reservation $entitybool $flush false): void
  24.     {
  25.         $this->getEntityManager()->persist($entity);
  26.         if ($flush) {
  27.             $this->getEntityManager()->flush();
  28.         }
  29.     }
  30.     public function remove(Reservation $entitybool $flush false): void
  31.     {
  32.         $this->getEntityManager()->remove($entity);
  33.         if ($flush) {
  34.             $this->getEntityManager()->flush();
  35.         }
  36.     }
  37.     /**
  38.      * Return booked intervals for a habitat in a window (optional).
  39.      *
  40.      * @param int                  $habitatId
  41.      * @param \DateTimeInterface|null $from
  42.      * @param \DateTimeInterface|null $to
  43.      * @param int                  $marchandId
  44.      * @param string[]             $statuses
  45.      * @return array<array{checkin:\DateTimeInterface, checkout:\DateTimeInterface}>
  46.      */
  47.     public function findBookedRanges(int $habitatId, ?\DateTimeInterface $from null, ?\DateTimeInterface $to nullint $marchandId, array $statuses = []): array
  48.     {
  49.         $qb $this->createQueryBuilder('r')
  50.             ->join('r.marchand''m')
  51.             ->join('r.habitat''h')
  52.             ->andWhere('m.id = :mid')->setParameter('mid'$marchandId)
  53.             ->andWhere('h.id = :hid')->setParameter('hid'$habitatId);
  54.         // Optional status filter (only certain ones block dates)
  55.         if (!empty($statuses)) {
  56.             $qb->andWhere('r.status IN (:statuses)')
  57.                 ->setParameter('statuses'$statuses);
  58.         }
  59.         // Optional window optimization
  60.         if ($from) {
  61.             $qb->andWhere('r.checkOutDate > :from')->setParameter('from'$from);
  62.         }
  63.         if ($to) {
  64.             $qb->andWhere('r.checkInDate < :to')->setParameter('to'$to);
  65.         }
  66.         $qb->orderBy('r.checkInDate''ASC');
  67.         $rows $qb->getQuery()->getResult();
  68.         return array_map(function(Reservation $r) {
  69.             return [
  70.                 'checkin'  => $r->getCheckInDate(),
  71.                 'checkout' => $r->getCheckOutDate(),
  72.             ];
  73.         }, $rows);
  74.     }
  75.     /**
  76.      * True if any reservation overlaps with [checkin, checkout) for the given habitat.
  77.      * Overlap rule (half-open interval):
  78.      *   [checkIn, checkOut) overlaps [checkin, checkout)  <=>  checkIn < checkout AND checkOut > checkin
  79.      *
  80.      * @param int                   $habitatId
  81.      * @param \DateTimeInterface    $checkin    inclusive
  82.      * @param \DateTimeInterface    $checkout   exclusive
  83.      * @param string[]|null         $statuses   null => default blocking statuses (tentative, confirmed, arrived);
  84.      *                                          []   => no status filter; custom array to override
  85.      */
  86.     public function hasOverlap(
  87.         int $habitatId,
  88.         \DateTimeInterface $checkin,
  89.         \DateTimeInterface $checkout,
  90.         ?array $statuses null
  91.     ): bool {
  92.         if ($checkout <= $checkin) {
  93.             throw new \InvalidArgumentException('checkout must be strictly after checkin.');
  94.         }
  95.         // Normalize (use DATE at midnight if your columns are DATE)
  96.         $ci $checkin instanceof \DateTimeImmutable \DateTime::createFromImmutable($checkin) : (clone $checkin);
  97.         $co $checkout instanceof \DateTimeImmutable \DateTime::createFromImmutable($checkout) : (clone $checkout);
  98.         $ci->setTime(000);
  99.         $co->setTime(000);
  100.         // Default statuses that block dates if none provided
  101.         if ($statuses === null) {
  102.             $statuses = [
  103.                 Reservation::STATUS_TENTATIVE,
  104.                 Reservation::STATUS_CONFIRMED,
  105.                 Reservation::STATUS_ARRIVED,
  106.             ];
  107.         }
  108.         $qb $this->createQueryBuilder('r')
  109.             ->select('COUNT(r.id)')
  110.             ->join('r.habitat''h')
  111.             ->andWhere('h.id = :hid')
  112.             // half-open overlap condition:
  113.             ->andWhere('r.checkInDate < :reqCheckout')
  114.             ->andWhere('r.checkOutDate > :reqCheckin')
  115.             ->setParameter('hid'$habitatId)
  116.             ->setParameter('reqCheckin'$ci)
  117.             ->setParameter('reqCheckout'$co);
  118.         // Optional status filter
  119.         if ($statuses !== []) {
  120.             $qb->andWhere('r.status IN (:statuses)')
  121.                 ->setParameter('statuses'$statuses);
  122.         }
  123.         $count = (int) $qb->getQuery()->getSingleScalarResult();
  124.         return $count 0;
  125.     }
  126.     public function listData(array $filtersint $marchandId): array
  127.     {
  128.         $from       $filters['from']       ?? null;   // \DateTimeInterface|null
  129.         $to         $filters['to']         ?? null;   // \DateTimeInterface|null
  130.         $status     $filters['status']     ?? 'all';  // string
  131.         $termFilter $filters['term']       ?? 'all';  // 'all'|'night'|'week'|'month'
  132.         $propertyId $filters['propertyId'] ?? null;   // int|null
  133.         // Base QB
  134.         $qb $this->createQueryBuilder('r')
  135.             ->join('r.marchand''m')
  136.             ->leftJoin('r.habitat''h')
  137.             ->leftJoin('r.locataire''t')
  138.             ->addSelect('h.libelle AS property')
  139.             ->addSelect("CONCAT(COALESCE(t.name,''),' ',COALESCE(t.lastname,'')) AS locataire")
  140.             // include term and totalAmount explicitly
  141.             ->addSelect('r.id, r.code, r.status, r.term, r.checkInDate, r.checkOutDate, r.totalAmount')
  142.             ->andWhere('m.id = :mid')->setParameter('mid'$marchandId);
  143.         // Filters
  144.         if ($from instanceof \DateTimeInterface) {
  145.             $from0 $from instanceof \DateTimeImmutable $from \DateTimeImmutable::createFromMutable($from);
  146.             $qb->andWhere('r.checkInDate >= :from')->setParameter('from'$from0->setTime(0,0));
  147.         }
  148.         if ($to instanceof \DateTimeInterface) {
  149.             $to2359 $to instanceof \DateTimeImmutable $to \DateTimeImmutable::createFromMutable($to);
  150.             $qb->andWhere('r.checkOutDate <= :to')->setParameter('to'$to2359->setTime(23,59,59));
  151.         }
  152.         if ($status !== 'all') {
  153.             $qb->andWhere('r.status = :st')->setParameter('st'$status);
  154.         }
  155.         if ($termFilter !== 'all') {
  156.             $qb->andWhere('r.term = :tm')->setParameter('tm'$termFilter);
  157.         }
  158.         if ($propertyId) {
  159.             $qb->andWhere('h.id = :pid')->setParameter('pid'$propertyId);
  160.         }
  161.         $rows $qb->orderBy('r.checkInDate''DESC')->getQuery()->getArrayResult();
  162.         // Helpers
  163.         $toImmutable = static function ($v): \DateTimeImmutable {
  164.             if ($v instanceof \DateTimeImmutable) return $v;
  165.             if ($v instanceof \DateTime) return \DateTimeImmutable::createFromMutable($v);
  166.             return new \DateTimeImmutable((string)$v);
  167.         };
  168.         // Compute deriveds
  169.         $active 0;
  170.         $upcoming 0;
  171.         $revenue 0.0;
  172.         $count count($rows);
  173.         $today = new \DateTimeImmutable('today');
  174.         $revenueStatuses = ['confirmed''confirmée''completed'];
  175.         foreach ($rows as &$r) {
  176.             $ci $toImmutable($r['checkInDate'] ?? null);
  177.             $co $toImmutable($r['checkOutDate'] ?? null);
  178.             $r['nights'] = max(0, (int)$co->diff($ci)->format('%a'));
  179.             if ($ci <= $today && $today $co) {
  180.                 $active++;
  181.             }
  182.             if ($ci $today && $ci <= $today->modify('+7 days')) {
  183.                 $upcoming++;
  184.             }
  185.             if (in_array(($r['status'] ?? ''), $revenueStatusestrue)) {
  186.                 $revenue += (float)($r['totalAmount'] ?? 0);
  187.             }
  188.         }
  189.         unset($r);
  190.         // Occupancy
  191.         $occupancyRate $count min(100.0, ($active max(1$count)) * 100.0) : 0.0;
  192.         // Series (last 12 months)
  193.         $seriesReservations = [];
  194.         $seriesRevenue = [];
  195.         $now = new \DateTimeImmutable('first day of this month');
  196.         for ($i 11$i >= 0$i--) {
  197.             $m $now->modify("-{$i} months");
  198.             $label $m->format('Y-m');
  199.             $seriesReservations[$label] = 0;
  200.             $seriesRevenue[$label] = 0.0;
  201.         }
  202.         foreach ($rows as $r) {
  203.             $ci $toImmutable($r['checkInDate'] ?? null);
  204.             $label $ci->format('Y-m');
  205.             if (isset($seriesReservations[$label])) {
  206.                 $seriesReservations[$label]++;
  207.                 if (in_array(($r['status'] ?? ''), $revenueStatusestrue)) {
  208.                     $seriesRevenue[$label] += (float)($r['totalAmount'] ?? 0);
  209.                 }
  210.             }
  211.         }
  212.         return [
  213.             'totals' => [
  214.                 'count'         => $count,
  215.                 'active'        => $active,
  216.                 'upcoming'      => $upcoming,
  217.                 'revenue'       => round($revenue2),
  218.                 'occupancyRate' => $occupancyRate,
  219.             ],
  220.             'table' => array_map(static function(array $r) {
  221.                 return [
  222.                     'id'          => $r['id'],
  223.                     'code'        => $r['code'],
  224.                     'status'      => $r['status'],
  225.                     'term'        => $r['term'],
  226.                     'checkInDate' => $r['checkInDate'],
  227.                     'checkOutDate'=> $r['checkOutDate'],
  228.                     'nights'      => $r['nights'] ?? 0,
  229.                     'total'       => $r['totalAmount'] ?? 0,
  230.                     'property'    => $r['property'] ?? '',
  231.                     'locataire'   => $r['locataire'] ?? '',
  232.                 ];
  233.             }, $rows),
  234.             'series' => [
  235.                 'reservations' => array_map(
  236.                     fn($k,$v)=>['label'=>$k,'value'=>$v],
  237.                     array_keys($seriesReservations),
  238.                     array_values($seriesReservations)
  239.                 ),
  240.                 'revenue'      => array_map(
  241.                     fn($k,$v)=>['label'=>$k,'value'=>round($v,2)],
  242.                     array_keys($seriesRevenue),
  243.                     array_values($seriesRevenue)
  244.                 ),
  245.             ],
  246.         ];
  247.     }
  248.     public function dashboardData(array $filtersint $totalActiveUnits 0int $marchandId): array
  249.     {
  250.         $from       $filters['from'] ?? null;                 // \DateTimeInterface|null
  251.         $to         $filters['to'] ?? null;                   // \DateTimeInterface|null
  252.         $status     $filters['status'] ?? 'all';              // 'all' or a status
  253.         $termFilter $filters['term'] ?? 'all';               // 'all'|'night'|'week'|'month'
  254.         $propertyId $filters['propertyId'] ?? null;          // int|null
  255.         $today  = new \DateTimeImmutable('today');
  256.         $tomorrow $today->modify('+1 day');
  257.         $in7    $today->modify('+7 days');
  258.         // ---------- Base rows for the main table (respecting filters) ----------
  259.         $qb $this->createQueryBuilder('r')
  260.             ->join('r.marchand''m')
  261.             ->leftJoin('r.habitat''h')
  262.             ->leftJoin('r.locataire''t')
  263.             ->addSelect('h.libelle AS property')
  264.             ->andWhere('m.id = :mid')->setParameter('mid'$marchandId)
  265.             ->addSelect("CONCAT(COALESCE(t.name,''),' ',COALESCE(t.lastname,'')) AS tenant")
  266.             ->addSelect('r.id, r.code, r.status, r.term, r.checkInDate, r.checkOutDate, r.totalAmount');
  267.         if ($from)       { $qb->andWhere('r.checkInDate >= :from')->setParameter('from', (clone $from)->setTime(0,0)); }
  268.         if ($to)         { $qb->andWhere('r.checkOutDate <= :to')->setParameter('to', (clone $to)->setTime(23,59,59)); }
  269.         if ($status !== 'all') { $qb->andWhere('r.status = :st')->setParameter('st'$status); }
  270.         if ($termFilter !== 'all') { $qb->andWhere('r.term = :tm')->setParameter('tm'$termFilter); }
  271.         if ($propertyId) { $qb->andWhere('h.id = :pid')->setParameter('pid', (int) $propertyId); }
  272.         if ($marchandId) { $qb->andWhere('h.marchand = :mid')->setParameter('mid', (int) $marchandId); }
  273.         $rows $qb->orderBy('r.checkInDate''DESC')->getQuery()->getArrayResult();
  274.         // ---------- KPIs + series ----------
  275.         $totalReservations \count($rows);
  276.         $activeNow 0;
  277.         $upcomingCheckins 0;
  278.         $revenue 0.0;
  279.         // Lists
  280.         $listCurrent = [];     // stays currently in-house
  281.         $listTodayCI = [];     // check-ins today
  282.         $toImmutable = static fn($v): \DateTimeImmutable =>
  283.         $v instanceof \DateTimeImmutable $v :
  284.             ($v instanceof \DateTime \DateTimeImmutable::createFromMutable($v) : new \DateTimeImmutable((string)$v));
  285.         foreach ($rows as &$r) {
  286.             $ci $toImmutable($r['checkInDate']);
  287.             $co $toImmutable($r['checkOutDate']);
  288.             // Active now (checkIn <= today < checkOut) and not canceled
  289.             if ($ci <= $today && $today $co && !in_array(($r['status'] ?? ''), ['canceled','annulée'], true)) {
  290.                 $activeNow++;
  291.                 $listCurrent[] = [
  292.                     'code'     => $r['code'],
  293.                     'property' => $r['property'],
  294.                     'tenant'   => $r['tenant'],
  295.                     'from'     => $ci,
  296.                     'to'       => $co,
  297.                     'status'   => $r['status'],
  298.                 ];
  299.             }
  300.             // Upcoming arrivals in next 7 days (excluding today)
  301.             if ($ci >= $tomorrow && $ci <= $in7 && !in_array(($r['status'] ?? ''), ['canceled','annulée'], true)) {
  302.                 $upcomingCheckins++;
  303.             }
  304.             // Today check-ins list
  305.             if ($ci->format('Y-m-d') === $today->format('Y-m-d') && !in_array(($r['status'] ?? ''), ['canceled','annulée'], true)) {
  306.                 $listTodayCI[] = [
  307.                     'code'     => $r['code'],
  308.                     'property' => $r['property'],
  309.                     'tenant'   => $r['tenant'],
  310.                     'from'     => $ci,
  311.                     'to'       => $co,
  312.                     'status'   => $r['status'],
  313.                 ];
  314.             }
  315.             // Revenue (confirmed/arrived/departed i.e., realized)
  316.             if (in_array(($r['status'] ?? ''), ['confirmed','confirmée','arrived','arrivée','departed','parti'], true)) {
  317.                 $revenue += (float)($r['totalAmount'] ?? 0);
  318.             }
  319.             // Derived nights for table
  320.             $r['nights'] = max(0, (int)$co->diff($ci)->format('%a'));
  321.             $r['checkin']  = $ci;
  322.             $r['checkout'] = $co;
  323.         }
  324.         unset($r);
  325.         // Current occupancy rate
  326.         $occupancyRate 0.0;
  327.         if ($totalActiveUnits 0) {
  328.             $occupancyRate min(100.0, ($activeNow $totalActiveUnits) * 100.0);
  329.         }
  330.         // Series for last 12 months (reservations count + revenue)
  331.         $seriesReservations = [];
  332.         $seriesRevenue = [];
  333.         $nowFirst = new \DateTimeImmutable('first day of this month');
  334.         for ($i 11$i >= 0$i--) {
  335.             $m $nowFirst->modify("-{$i} months")->format('Y-m');
  336.             $seriesReservations[$m] = 0;
  337.             $seriesRevenue[$m]      = 0.0;
  338.         }
  339.         foreach ($rows as $r) {
  340.             $label $toImmutable($r['checkInDate'])->format('Y-m');
  341.             if (isset($seriesReservations[$label])) {
  342.                 $seriesReservations[$label]++;
  343.                 if (in_array(($r['status'] ?? ''), ['confirmed','confirmée','arrived','arrivée','departed','parti'], true)) {
  344.                     $seriesRevenue[$label] += (float)($r['totalAmount'] ?? 0);
  345.                 }
  346.             }
  347.         }
  348.         return [
  349.             'stats' => [
  350.                 'total'             => $totalReservations,
  351.                 'active'            => $activeNow,
  352.                 'upcomingCheckins'  => $upcomingCheckins,
  353.                 'occupancyRate'     => round($occupancyRate1),
  354.                 'revenue'           => round($revenue2),
  355.                 'currencySymbol'    => $filters['currencySymbol'] ?? '$',
  356.             ],
  357.             'series' => [
  358.                 'reservations' => array_map(fn($k,$v)=>['label'=>$k,'value'=>$v], array_keys($seriesReservations), array_values($seriesReservations)),
  359.                 'revenue'      => array_map(fn($k,$v)=>['label'=>$k,'value'=>round($v,2)], array_keys($seriesRevenue), array_values($seriesRevenue)),
  360.             ],
  361.             // Keep your previous table shape but normalize field names a bit
  362.             'table' => [
  363.                 'rows' => array_map(static function(array $r) {
  364.                     return [
  365.                         'code'     => $r['code'],
  366.                         'property' => $r['property'],
  367.                         'tenant'   => $r['tenant'],
  368.                         'checkin'  => $r['checkin'],
  369.                         'checkout' => $r['checkout'],
  370.                         'term'     => $r['term'],
  371.                         'nights'   => $r['nights'],
  372.                         'total'    => (float)($r['totalAmount'] ?? 0),
  373.                         'status'   => $r['status'],
  374.                     ];
  375.                 }, $rows),
  376.                 'currencySymbol' => $filters['currencySymbol'] ?? '$',
  377.             ],
  378.             'lists' => [
  379.                 'current'       => $listCurrent,
  380.                 'todayCheckins' => $listTodayCI,
  381.             ],
  382.         ];
  383.     }
  384.     /**
  385.      * Returns true if $habitat has NO non-canceled reservations overlapping [from, to).
  386.      *
  387.      * Overlap rule (half-open interval):
  388.      *   [checkIn, checkOut) overlaps [from, to)  <=>  checkIn < to AND checkOut > from
  389.      *
  390.      * @param Habitat              $habitat
  391.      * @param \DateTimeInterface   $from   inclusive (start of stay)
  392.      * @param \DateTimeInterface   $to     exclusive (end of stay)
  393.      * @param int|null             $excludeReservationId  reservation ID to ignore (e.g. when modifying/moving)
  394.      */
  395.     public function isHabitatAvailable(
  396.         Habitat $habitat,
  397.         \DateTimeInterface $from,
  398.         \DateTimeInterface $to,
  399.         ?int $excludeReservationId nullint $marchandId
  400.     ): bool {
  401.         // Basic guard
  402.         if ($to <= $from) {
  403.             throw new \InvalidArgumentException('The "to" date must be strictly after the "from" date.');
  404.         }
  405.         // Normalize to mutable DateTime for DBAL (columns are DATE, but DateTime works fine)
  406.         $fromDT $from instanceof \DateTimeImmutable \DateTime::createFromImmutable($from) : clone $from;
  407.         $toDT   $to   instanceof \DateTimeImmutable \DateTime::createFromImmutable($to)   : clone $to;
  408.         // If you store DATE (no time), setting time to midnight is harmless/explicit
  409.         $fromDT->setTime(000);
  410.         $toDT->setTime(000);
  411.         $qb $this->createQueryBuilder('r')
  412.             ->select('COUNT(r.id)')
  413.             ->join('r.marchand''m')
  414.             ->where('r.habitat = :habitat')
  415.             ->andWhere('m.id = :mid')->setParameter('mid'$marchandId)
  416.             // ignore canceled reservations
  417.             ->andWhere('r.status <> :canceled')
  418.             // half-open overlap condition:
  419.             ->andWhere('r.checkInDate < :to')
  420.             ->andWhere('r.checkOutDate > :from')
  421.             ->setParameter('habitat'$habitat)
  422.             ->setParameter('canceled'Reservation::STATUS_CANCELED)
  423.             ->setParameter('from'$fromDT)
  424.             ->setParameter('to'$toDT);
  425.         if ($excludeReservationId !== null) {
  426.             $qb->andWhere('r.id <> :excludeId')
  427.                 ->setParameter('excludeId'$excludeReservationId);
  428.         }
  429.         $conflicts = (int) $qb->getQuery()->getSingleScalarResult();
  430.         return $conflicts === 0;
  431.     }
  432.     public function loadReservationsForPeriodForSingleAppartment($startDate$periodHabitat $property)
  433.     {
  434.         $start date('Y-m-d'$startDate);
  435.         $end date('Y-m-d'$startDate + ($period 3600 24));
  436.         $q $this
  437.             ->createQueryBuilder('u')
  438.             ->select('u')
  439.             ->where('u.habitat = :app ')
  440.             ->andWhere('((u.checkInDate >= :start AND u.checkOutDate <= :end) OR'
  441.                 .'(u.checkInDate < :start AND u.checkOutDate >= :start) OR'
  442.                 .'(u.checkInDate <= :end AND u.checkOutDate > :end) OR'
  443.                 .'(u.checkInDate < :start AND u.checkOutDate > :end))')
  444. //            ->andWhere('u.confirmed = :confirmed')
  445.             ->setParameter('start'$start)
  446.             ->setParameter('end'$end)
  447.             ->setParameter('app'$property)
  448. //            ->setParameter('confirmed', true)
  449.             ->addOrderBy('u.checkOutDate''ASC')
  450.             ->getQuery();
  451.         $reservations null;
  452.         try {
  453.             $reservations $q->getResult();
  454.         } catch (NoResultException $e) {
  455.         }
  456.         return $reservations;
  457.     }
  458. }