src/Controller/Front/Product/HotelController.php line 524

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Front\Product;
  3. use App\Config\FaqRouteEnum;
  4. use App\Config\ModuleEnum;
  5. use App\Entity\CustomerMoral;
  6. use App\Repository\CityRepository;
  7. use App\Repository\FrontThemeRepository;
  8. use App\Repository\HotelXmlPriceRepository;
  9. use App\Repository\HotelXmlRepository;
  10. use App\Repository\OrderLineRepository;
  11. use App\Repository\OrderRepository;
  12. use App\Service\Api3TAuthenticationService;
  13. use App\Service\CartService;
  14. use App\Service\CurrencyService;
  15. use App\Service\FrontRecentSearchService;
  16. use App\Service\FrontService;
  17. use App\Service\Helpers;
  18. use App\Service\HotelApiService;
  19. use App\Service\ParameterService;
  20. use DateTime;
  21. use Exception;
  22. use Gedmo\Translatable\TranslatableListener;
  23. use Psr\Log\LoggerInterface;
  24. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  25. use Symfony\Component\HttpClient\HttpClient;
  26. use Symfony\Component\HttpFoundation\JsonResponse;
  27. use Symfony\Component\HttpFoundation\RedirectResponse;
  28. use Symfony\Component\HttpFoundation\Request;
  29. use Symfony\Component\HttpFoundation\Response;
  30. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  31. use Symfony\Component\HttpFoundation\StreamedResponse;
  32. use Symfony\Component\Routing\Annotation\Route;
  33. use Psr\Cache\CacheItemPoolInterface;
  34. #[Route('/hotel')]
  35. class HotelController extends AbstractController
  36. {
  37.     private const UNNORMALIZED_CACHE_PREFIX = 'hub_unnormalized_rooms_';
  38.     private const UNNORMALIZED_CACHE_TTL = 1800; // 30 min
  39.     public function __construct(
  40.         private readonly Api3TAuthenticationService $apiAuthenticationService,
  41.         private readonly HotelApiService            $hotelApiService,
  42.         private readonly CurrencyService            $currencyService,
  43.         private readonly FrontService               $frontService,
  44.         private readonly ParameterService           $parameterService,
  45.         private readonly Helpers                    $helpers,
  46.         private readonly LoggerInterface            $logger,
  47.         private CacheItemPoolInterface              $searchCachePool,
  48.     )
  49.     {
  50.     }
  51.     #[Route('/hotelcity/{name}', name: 'app_front_hotel_city')]
  52.     public function hotelByCity(
  53.         string                  $name,
  54.         CityRepository          $cityRepository,
  55.         HotelXmlPriceRepository $hotelXmlPriceRepository,
  56.         FrontService            $frontService
  57.     ): Response
  58.     {
  59.         $hotels = [];
  60.         $city = $cityRepository->findOneBy(['name' => $name]);
  61.         $description = "";
  62.         $country = "";
  63.         if ($city) {
  64.             $description = $city->getDescription();
  65.             $country = $city->getCountry()->getName();
  66.         }
  67.         $hotelXmlPrices = $hotelXmlPriceRepository->getHotelXmlPriceByCriterias(['cities' => [$name]]);
  68.         foreach ($hotelXmlPrices as $HotelXmlPrice) {
  69.             $hotels[] = $frontService->getItemHotel($HotelXmlPrice);
  70.         }
  71.         return $this->render('front/hotel/hotel_by_city.html.twig', [
  72.             'hotels' => $hotels,
  73.             'city' => $city,
  74.             'name' => $name,
  75.             'country' => $country,
  76.             'society' => $this->parameterService->getSocietyParameters(),
  77.             'social_networks' => $this->frontService->getSocialNetworks(),
  78.             'agencies' => $this->frontService->getAgencies(),
  79.             'currencies' => $this->frontService->getCurrencies(),
  80.             'currencySwitcher' => true,
  81.             'tracking_tools' => $this->frontService->getTrackingTools(),
  82.             'faqs' => $this->frontService->getFaqs(FaqRouteEnum::APP_FRONT_HOTEL_CITY, strtolower($name)),
  83.         ]);
  84.     }
  85.     #[Route('/hoteltheme/{name}', name: 'app_front_hotel_theme')]
  86.     public function hotelByTheme(
  87.         string                  $name,
  88.         FrontThemeRepository    $frontThemeRepository,
  89.         HotelXmlPriceRepository $hotelXmlPriceRepository,
  90.         FrontService            $frontService
  91.     ): Response
  92.     {
  93.         $hotels = [];
  94.         $theme = $frontThemeRepository->findOneBy(['name' => $name]);
  95.         $description = "";
  96.         if ($theme) {
  97.             $description = $theme->getDescription();
  98.         }
  99.         $hotelXmlPrices = $hotelXmlPriceRepository->getHotelXmlPriceByCriterias(['themes' => $name]);
  100.         foreach ($hotelXmlPrices as $HotelXmlPrice) {
  101.             $hotels[] = $frontService->getItemHotel($HotelXmlPrice);
  102.         }
  103.         return $this->render('front/hotel/hotel_by_theme.html.twig', [
  104.             'hotels' => $hotels,
  105.             'description' => $description,
  106.             'name' => $name,
  107.             'society' => $this->parameterService->getSocietyParameters(),
  108.             'social_networks' => $this->frontService->getSocialNetworks(),
  109.             'agencies' => $this->frontService->getAgencies(),
  110.             'currencies' => $this->frontService->getCurrencies(),
  111.             'tracking_tools' => $this->frontService->getTrackingTools()
  112.         ]);
  113.     }
  114.     #[Route('/search', name: 'hotel_search', methods: ['GET', 'POST'])]
  115.     public function hotelSearch(Request $request, FrontRecentSearchService $frontRecentSearchService): Response
  116.     {
  117.         $data = $request->get("data"); // data : JSON request
  118.         $requestData = json_decode($data, true);
  119.         $date = new DateTime();
  120.         // Output the date in default format
  121.         $currentDate = $date->format('Y-m-d'); // Example: 2025-01-02 15:30:45
  122.         if ($requestData['checkIn'] < $currentDate || $requestData['checkOut'] < $currentDate) {
  123.             return $this->redirectToRoute('app_main');
  124.         }
  125.         $customer = $this->apiAuthenticationService->getCustomer($this->getUser());
  126.         if (is_null($customer)) {
  127.             $this->addFlash('error', 'UNKNOWN_CUSTOMER');
  128.             return $this->redirectToRoute('front_info_message');
  129.         }
  130.         $hotel_search_history = $frontRecentSearchService->updateHotelSearchHistory($requestData, $data);
  131.         $isSearchByMap = $this->parameterService->isSearchMap();
  132.         return $this->render('front/hotel/search.html.twig', [
  133.             'requestData' => $requestData,
  134.             'customer' => $customer,
  135.             'society' => $this->parameterService->getSocietyParameters(),
  136.             'social_networks' => $this->frontService->getSocialNetworks(),
  137.             'agencies' => $this->frontService->getAgencies(),
  138.             'currencies' => $this->frontService->getCurrencies(),
  139.             'hotel_search_history' => $hotel_search_history,
  140.             'sections' => $this->frontService->getSections(),
  141.             'currencySwitcher' => true,
  142.             'tracking_tools' => $this->frontService->getTrackingTools(),
  143.             'isSearchByMap'=> $isSearchByMap,
  144.         ]);
  145.     }
  146.     #[Route('/formsearch/{city_label}/{hotel_label}', name: 'hotel_form_search', methods: ['GET', 'POST'])]
  147.     public function hotelFormSearch(Request $request, string $city_label, string $hotel_label): Response
  148.     {
  149.         $requestData = json_decode($request->get("data"), true);
  150.         return $this->render('front/hotel/form_search.html.twig', [
  151.             'requestData' => $requestData,
  152.             'hotel_label' => $hotel_label,
  153.             'city_label' => $city_label
  154.             /* 'society' => $this->parameterService->getSocietyParameters(),
  155.              'social_networks' => $this->frontService->getSocialNetworks(),
  156.              'agencies' => $this->frontService->getAgencies(),
  157.              'currencies' => $this->frontService->getCurrencies(),*/
  158.         ]);
  159.     }
  160.     #[Route('/search_json', name: 'hotel_search_json', methods: ['GET', 'POST'])]
  161.     public function hotelSearchJson(Request $request, HotelApiService $hotelApiService): JsonResponse
  162.     {
  163.         // ----- PREPARE the HttpRequestCall ----- //
  164.         // Get auth_data : user, timestamp, signature
  165.         $user = $this->getUser();
  166.         $auth_data = $this->apiAuthenticationService->getAuthenticationArray($user);
  167.         $customer = $this->apiAuthenticationService->getCustomer($user);
  168.         $showResellingRates = false;
  169.         if ($customer instanceof CustomerMoral) {
  170.             if ($customer->getResellerMargin() > 0 && (in_array('ROLE_B2B_ADMIN', $user->getRoles()) || in_array('ROLE_B2B_AGENT', $user->getRoles()))) {
  171.                 $showResellingRates = true;
  172.             }
  173.         }
  174.         $requestJson = json_decode($request->getContent(), true);
  175.         $xmlSourceId = $request->headers->get('xmlSourceId') ?? 0;
  176.         $base_uri = $request->getSchemeAndHttpHost();
  177.         // ----- PERFORM the HttpRequestCall ----- //
  178.         $data = $hotelApiService->query_ApiHotel_Availability($auth_data, $requestJson, $xmlSourceId, $base_uri, true);
  179.         $today = DateTime::createFromFormat("Y-m-d", date("Y-m-d"));
  180.         if (count($data['content']) > 0) { // Re-format data to fit the FRONT-B2C :::: VIEW_BY_BOARD ::::
  181.             $nbRooms = count($requestJson['occupancies']);
  182.             $content_front = []; // prepare data to fit the front design
  183.             foreach ($data['content'] as $hotel) {
  184.                 $hotel_key = strtoupper($hotel['hotel']['name']); // HOTEL_ITEM_IDENTIFIER TO GROUP MULTIPLE HOTELS WITH SAME NAME
  185.                 $hotel_offer_count = 0;
  186.                 if (isset($content_front[$hotel_key]['offersCount'])) {
  187.                     $hotel_offer_count = $content_front[$hotel_key]['offersCount'];
  188.                 }
  189.                 $content_front[$hotel_key]['hotel'] = $hotel['hotel'];
  190.                 $content_front[$hotel_key]['hotel']['customRating'] = $this->hotelApiService->getHotelCustomRating($hotel['hotel']['name']);
  191.                 // TODO Add function getReviewRating(hotelName, reviewsSource);
  192.                 $content_front[$hotel_key]['hotel']['reviewRating'] = null;//rand(4, 10); // TODO GET RATING FROM External src like Tripadvisor
  193.                 if (count($hotel['sources']) > 0) {
  194.                     $content_front[$hotel_key]['hotel']['facilities'] = $hotel['sources'][0]['facilities'];
  195.                 }
  196.                 $content_front[$hotel_key]['hotel']['promos'] = [];
  197.                 $content_front[$hotel_key]['hotel']['discounts'] = [];
  198.                 $content_front[$hotel_key]['hotel']['infos'] = [];
  199.                 foreach ($hotel['sources'] as $hotel_source) {
  200.                     $sourceKey = $hotel['hotel']['id']; // considering the full-hotel-id as the sourceKey (string containing the end-source-id)
  201.                     $content_front[$hotel_key]['sources'][$sourceKey]['sourceKey'] = $sourceKey;
  202.                     $hotelSourcePrice = 0;
  203.                     $hotelSourceAvailable = false;
  204.                     $hotelSourceFreeCancellation = false;
  205.                     $tab_boards = [];
  206.                     foreach ($hotel_source['rooms'] as $index => $index_hrts) { // room_index
  207.                         // SORT hrts for current hotel-source by salePromoRate
  208.                         usort($index_hrts, function ($a, $b) {
  209.                             return $a['salePromoRate'] <=> $b['salePromoRate']; // Ascending
  210.                         });
  211.                         foreach ($index_hrts as $hrt) {
  212.                             // freeCancellation FLAG to use in React Filter (i.e. is there a possibility to cancel without fees )
  213.                             if ($hrt['NRF']) {
  214.                                 $hrt['freeCancellation'] = false;
  215.                             } else {
  216.                                 if (is_null($hrt['deadline'])) {
  217.                                     $hrt['freeCancellation'] = true;
  218.                                 } else {
  219.                                     $deadline = new DateTime($hrt['deadline']);
  220.                                     $difference = date_diff($today, $deadline); // Interval before deadline.
  221.                                     if ($difference->invert) { // deadline is exceeded
  222.                                         $hrt['freeCancellation'] = false;
  223.                                     } else {
  224.                                         $hrt['freeCancellation'] = true;
  225.                                     }
  226.                                 }
  227.                             }
  228.                             $hotelSourceFreeCancellation = $hotelSourceFreeCancellation || $hrt['freeCancellation'];
  229.                             // END freeCancellation FLAG
  230.                             // SHOW SaleResellingRates rather thant saleRate applied to the customer
  231.                             if ($showResellingRates) {
  232.                                 $hrt['salePromoRate'] = $hrt['salePromoRate'] * (100.0 + $customer->getResellerMargin()) / 100.0;
  233.                                 $hrt['saleRate'] = $hrt['saleRate'] * (100.0 + $customer->getResellerMargin()) / 100.0;
  234.                             }
  235.                             $content_front[$hotel_key]['sources'][$sourceKey]['hrts'][$hrt['boardName']][$index][] = $hrt;
  236.                             $tab_boards[$hrt['boardName']] = $hrt['boardXmlName'];
  237.                             // Increment the number of available-offers
  238.                             if ($hrt['available']) {
  239.                                 $hotel_offer_count++;
  240.                                 $hotelSourceAvailable = true;
  241.                             }
  242.                         }
  243.                     } // END Room Indexes
  244.                     // HOTEL BADGES (Promos, Discounts, Infos)
  245.                     foreach ($hotel_source['promos'] as $item) {
  246.                         $content_front[$hotel_key]['hotel']['promos'][] = $item;
  247.                     }
  248.                     foreach ($hotel_source['discounts'] as $item) {
  249.                         $content_front[$hotel_key]['hotel']['discounts'][] = $item;
  250.                     }
  251.                     foreach ($hotel_source['infos'] as $item) {
  252.                         $content_front[$hotel_key]['hotel']['infos'][] = $item;
  253.                     }
  254.                     $bestOffers = $this->hotelApiService->getBestPriceBoard($content_front[$hotel_key], $sourceKey);
  255.                     $content_front[$hotel_key]['sources'][$sourceKey]['bestPrice'] = $bestOffers['bestPrice_currentSourceKey'];
  256.                     $content_front[$hotel_key]['bestPrice'] = $bestOffers['bestPrice'];
  257.                     $content_front[$hotel_key]['bestBoard'] = $bestOffers['bestBoard'];
  258.                     $content_front[$hotel_key]['bestRooms'] = !empty($sourceBest['bestRooms'])
  259.                         ? $this->helpers->formatArrayCounts($sourceBest['bestRooms'])
  260.                         : '';
  261.                     $content_front[$hotel_key]['offersCount'] = $hotel_offer_count;
  262.                     foreach ($content_front[$hotel_key]['sources'][$sourceKey]['hrts'] as $board_key => $board_hrts) {
  263.                         if (count($board_hrts) < $nbRooms) {
  264.                             unset($content_front[$hotel_key]['sources'][$sourceKey]['hrts'][$board_key]);
  265.                         }
  266.                     }
  267.                     foreach ($tab_boards as $board_key => $board_value) {
  268.                         $content_front[$hotel_key]['sources'][$sourceKey]['boards'][] =
  269.                             [
  270.                                 'key' => $board_value,
  271.                                 'value' => $board_value,
  272.                             ];
  273.                     }
  274.                     // set general stock
  275.                     $content_front[$hotel_key]['sources'][$sourceKey]['generalStock'] = $hotel_source['generalStock'];
  276.                     // set search code
  277.                     $content_front[$hotel_key]['sources'][$sourceKey]['searchCode'] = $hotel_source['searchCode'];
  278.                     // set source id
  279.                     $content_front[$hotel_key]['sources'][$sourceKey]['xmlSourceId'] = $hotel_source['xmlSourceId'];
  280.                     // set xml source name
  281.                     $content_front[$hotel_key]['sources'][$sourceKey]['xmlSourceName'] = $hotel_source['xmlSourceName'];
  282.                     // set hotel Source available
  283.                     $content_front[$hotel_key]['sources'][$sourceKey]['available'] = $hotelSourceAvailable;
  284.                     // set hotel Source available
  285.                     $content_front[$hotel_key]['sources'][$sourceKey]['freeCancellation'] = $hotelSourceFreeCancellation;
  286.                     // set hotel Source available
  287.                     $content_front[$hotel_key]['sources'][$sourceKey]['associationRequired'] = $hotel_source['associationRequired'];
  288.                 }
  289.             }
  290.             // array values hotels
  291.             $data['content'] = array_values($content_front);
  292.             // array values sources
  293.             foreach ($data['content'] as &$hotel) {
  294.                 usort($hotel['sources'], function ($a, $b) {
  295.                     return $a['bestPrice'] <=> $b['bestPrice']; // Ascending
  296.                 });
  297.                 $hotel['sources'] = array_values($hotel['sources']);
  298.                 foreach ($hotel['sources'] as &$source) {
  299.                     $source['hrts'] = array_values($source['hrts']);
  300.                 }
  301.             }
  302.         }
  303.         return $this->json($data);
  304.     }
  305.     #[Route('/autocomplete', name: 'hotel_autocomplete', methods: ['GET'])]
  306.     public function autoComplete(Request $request): JsonResponse
  307.     {
  308.         $query = $request->query->get('query');
  309.         if ($query == null) {
  310.             return $this->json([]);
  311.         }
  312.         $query_trim = trim($query);
  313.         $headers = [
  314.             'Accept' => 'application/json',
  315.             'Content-Type' => 'application/json',
  316.         ];
  317.         try {
  318.             $httpClient = HttpClient::create();
  319.             $response = $httpClient->request('GET', $request->getSchemeAndHttpHost() . '/api/hotel/autocomplete', [
  320.                 'headers' => $headers,
  321.                 'query' => ['query' => $query_trim]
  322.             ]);
  323.             $data = json_decode($response->getContent(), true);
  324.             return $this->json($data['response']);
  325.         } catch (Exception $e) {
  326.             return $this->json(['error' => $e->getMessage()]);
  327.         }
  328.     }
  329.     /**
  330.      * The detail of selected offer from the front : hotel search results page. --- Check-Rate ---
  331.      * Supposed to be called either from "hotel/search or "orderline_add_room"
  332.      */
  333.     #[Route('/offer-detail', name: 'hotel_offer_detail', methods: ['GET', 'POST'])]
  334.     public function hotelOfferDetail(
  335.         Request             $request,
  336.         OrderLineRepository $orderLineRepository,
  337.         OrderRepository     $orderRepository,
  338.     ): Response
  339.     {
  340.         $guestData = [];
  341.         if ($request->isMethod('POST')) {
  342.             $guestData = [
  343.                 'first_name' => $request->request->get('first_name'),
  344.                 'last_name' => $request->request->get('last_name'),
  345.                 'phone' => $request->request->get('phone'),
  346.                 'email' => $request->request->get('email'),
  347.             ];
  348.             $request->getSession()->set('guestData', $guestData);
  349.         }
  350.         $customer = $this->apiAuthenticationService->getCustomer($this->getUser());
  351.         $auth_data = $this->apiAuthenticationService->getAuthenticationArray($this->getUser());
  352.         $rooms_rate_keys = [];
  353.         for ($i = 0; $i < $request->get('nbRooms'); $i++) {
  354.             $rooms_rate_keys[] = ["rateKey" => $request->get("room-" . $i)];
  355.         }
  356.         $checkRateRequest = [
  357.             'rooms' => $rooms_rate_keys,
  358.             'searchCode' => $request->get('searchCode')
  359.         ];
  360.         $saleCurrency = $this->currencyService->getSessionCurrency();
  361.         $product_fee = $this->frontService->getProductFee(ModuleEnum::hotel->getValue(), $customer);
  362.         /** call api/hotel/checkrate */
  363.         $httpClient = HttpClient::create();
  364.         $response = $httpClient->request(
  365.             'POST', $request->getUriForPath('/') . 'api/hotel/checkrate',
  366.             [
  367.                 'headers' => [
  368.                     'Accept' => 'application/json',
  369.                     'Content-Type' => 'application/json',
  370.                     'currency' => $saleCurrency,
  371.                     'user' => $auth_data['user'],
  372.                     'timestamp' => $auth_data['timestamp'],
  373.                     'signature' => hash('sha256', $auth_data['signature'])
  374.                 ],
  375.                 'json' => $checkRateRequest
  376.             ]
  377.         );
  378.         $responseData = json_decode($response->getContent(), true);
  379.         $searchCriteriaArray = json_decode($request->get("searchCriteria"), true);
  380.         $twig_parameters = [
  381.             'searchCriteriaArray' => $searchCriteriaArray,
  382.             'searchCode' => null,
  383.             'checkRateData' => null,
  384.             "social_networks" => $this->frontService->getSocialNetworks(),
  385.             'menu_front' => $this->frontService->getFrontMenu(),
  386.             "currencies" => $this->frontService->getCurrencies(),
  387.             "agencies" => $this->frontService->getAgencies(),
  388.             "society" => $this->parameterService->getSocietyParameters(),
  389.             "referenceCurrency" => $this->parameterService->getReferenceCurrency(),
  390.             'guestData' => $guestData,
  391.             'action' => '',
  392.             'customer' => $customer
  393.         ];
  394.         $template = "";
  395.         if (is_null($responseData) or $responseData['error']) {
  396.             // CASE 0 : Template for Error
  397.             return $this->render("front/hotel/offer_error.html.twig", $twig_parameters);
  398.         } else {
  399.             $checkRateData = reset($responseData['content']);
  400.             $checkRateData['available'] = $this->hotelApiService->isAvailable($checkRateData) ? "true" : "false";
  401.             $rooms = [];
  402.             foreach ($checkRateData['sources'][0]['rooms'] as $room_index) {
  403.                 $rooms[] = $room_index[0]; // the selected room for the index.
  404.             }
  405.             //$checkRateData['cancellation'] = $this->hotelApiService->getDeadlineFromRooms($rooms);
  406.             $twig_parameters['checkRateData'] = $checkRateData;
  407.             $twig_parameters['searchCode'] = $responseData['searchCode'];
  408.             $twig_parameters['product_fee'] = $product_fee;
  409.             $twig_parameters['META_PIXEL_ID'] = $this->parameterService->getTrackerMetaId();
  410.             // CASE 1 : Template to add something to an existing orderline
  411.             if ($request->get('orderlineId')) {
  412.                 $orderlineID = $request->get('orderlineId');
  413.                 $twig_parameters['orderLine'] = $orderLineRepository->find($orderlineID);
  414.                 $twig_parameters['action'] = $this->generateUrl("orderline_add_room", ['id' => $orderlineID]);
  415.             }
  416.             // CASE 2 : new Order and new OrderLine
  417.             if (empty($request->get('orderlineId')) && empty($request->get('orderId'))) { //
  418.                 $twig_parameters['action'] = $this->generateUrl("app_admin_add_cart_line_hotel");
  419.             }
  420.             // CASE 3 : Add new OrderLine into an existing Order
  421.             if (empty($request->get('orderlineId')) && $request->get('orderId')) { //
  422.                 $orderID = $request->get('orderId');
  423.                 $twig_parameters['order'] = $orderRepository->find($orderID);
  424.                 $twig_parameters['action'] = $this->generateUrl("order_add_orderline_hotel_book", ['id' => $orderID]);
  425.             }
  426.             return $this->render("front/hotel/offer_detail.html.twig", $twig_parameters);
  427.         }
  428.     }
  429.     /**
  430.      *  The detail of a given hotelId
  431.      */
  432.     #[Route('/{country_label}/{city_label}/{hotel_label}/{id}', name: 'hotel_detail', methods: ['GET'])]
  433.     public function hotelDetail(
  434.         Request             $request,
  435.                             $id,
  436.         string              $city_label,
  437.         string              $hotel_label,
  438.         HotelXmlRepository  $hotelXmlRepository
  439.     ): Response
  440.     {
  441.         $uid = $id; // source@uid
  442.         $hotel_source = strstr($uid, '@', true); // left subString before @
  443.         $hotel_uid = substr(strstr($uid, '@'), 1); // right subString after -
  444.         if ($hotel_source == 0) {
  445.             $hotelDetails = $this->hotelApiService->hotelDetails($hotel_uid, $request->getLocale());
  446.         } else {
  447.             $hotelDetails = $this->hotelApiService->hotelDetailsHUB($hotel_uid);
  448.         }
  449.         //$recommendedHotels = $hotelXmlRepository->getRecommendedHotels($city_label, $hotel_uid);
  450.         $recommendedHotels = [];
  451.         //dd(count($recommendedHotels));
  452.         $currentDateTime = new DateTime();
  453.         $arrivalDate = $currentDateTime->format('Y-m-d');//
  454.         $departureDate = new DateTime("tomorrow");
  455.         $departureDate = $departureDate->format('Y-m-d');
  456.         return $this->render('front/hotel/hotel_detail.html.twig', [
  457.             'hotel_data' => $hotelDetails,
  458.             'society' => $this->parameterService->getSocietyParameters(),
  459.             'social_networks' => $this->frontService->getSocialNetworks(),
  460.             'currencies' => $this->frontService->getCurrencies(),
  461.             'agencies' => $this->frontService->getAgencies(),
  462.             'menu_front' => $this->frontService->getFrontMenu(),
  463.             'recommendedHotels' => $recommendedHotels,
  464.             "arrivalDate" => $arrivalDate,
  465.             "departureDate" => $departureDate,
  466.             "city_label" => $city_label,
  467.             "hotel_label" => $hotel_label,
  468.             "META_PIXEL_ID" => $this->parameterService->getTrackerMetaId(),
  469.             'faqs' => $this->frontService->getFaqs(FaqRouteEnum::HOTEL_DETAIL, $uid),
  470.         ]);
  471.     }
  472.     #[Route('/{id}/gallery', name: 'hotel_gallery_json', methods: ['GET'])]
  473.     public function hotelGallery(
  474.         Request $request,
  475.                 $id
  476.     ): JsonResponse
  477.     {
  478.         $auth_data = $this->apiAuthenticationService->getAuthenticationArray($this->getUser());
  479.         // call hotel detail and get images
  480.         $hotelDetailsRequest = [
  481.             'hotelId' => $id
  482.         ];
  483.         $httpClient = HttpClient::create();
  484.         $response = $httpClient->request(
  485.             'POST', $request->getUriForPath('/') . 'api/hotel/details',
  486.             [
  487.                 'headers' => [
  488.                     'Accept' => 'application/json',
  489.                     'Content-Type' => 'application/json',
  490.                     'user' => $auth_data['user'],
  491.                     'timestamp' => $auth_data['timestamp'],
  492.                     'signature' => hash('sha256', $auth_data['signature'])
  493.                 ],
  494.                 'json' => $hotelDetailsRequest
  495.             ]
  496.         );
  497.         $responseData = json_decode($response->getContent(), true);
  498.         if (is_null($responseData) || !key_exists('content', $responseData)) {
  499.             return $this->json([]);
  500.         }
  501.         $hotel_data_array = $responseData['content']['images'];
  502.         return $this->json($hotel_data_array);
  503.     }
  504.     /**
  505.      * @throws Exception
  506.      */
  507.     #[Route('/promo', name: 'app_front_hotel_promo', methods: ['GET'])]
  508.     public function hotelPromo(HotelXmlPriceRepository $hotelXmlPriceRepository,
  509.                                FrontService            $frontService
  510.     ): Response
  511.     {
  512.         $hotels = [];
  513.         $hotelXmlPricesWithPromos = $hotelXmlPriceRepository->getHotelXmlPriceWithPromos();
  514.         foreach ($hotelXmlPricesWithPromos as $hotelXmlPricesWithPromo) {
  515.             $hotels[] = $frontService->getItemHotel($hotelXmlPricesWithPromo);
  516.         }
  517.         return $this->render('front/hotel/hotel_promo.html.twig', [
  518.             'hotelsWithPromos' => $hotels,
  519.             'social_networks' => $this->frontService->getSocialNetworks(),
  520.             'currencies' => $this->frontService->getCurrencies(),
  521.             'agencies' => $this->frontService->getAgencies(),
  522.             'society' => $this->parameterService->getSocietyParameters(),
  523.             'currencySwitcher' => true,
  524.             'META_PIXEL_ID' => $this->parameterService->getTrackerMetaId()
  525.         ]);
  526.     }
  527.     #[Route('/cart/addProductHotel', name: 'app_admin_add_cart_line_hotel', methods: ['POST'])]
  528.     public function addProductHotel(
  529.         Request          $request,
  530.         SessionInterface $session,
  531.         CartService      $cartService,
  532.     ): RedirectResponse
  533.     {
  534.         $cart_array = $cartService->initCart($this->getUser(), $request);
  535.         /* 2- add a line into cart_array['products'] */
  536.         $nbRooms = $request->get("nbRooms"); // quantity : nbRooms
  537.         $cart_line_label = $request->get('product_label');
  538.         $cart_line_date = $request->get('product_date');
  539.         $beneficiaryName = $request->get('beneficiary_name');
  540.         if (empty($beneficiaryName)) {
  541.             $beneficiaryFirstName = trim((string) $request->get('beneficiary_first_name', ''));
  542.             $beneficiaryLastName = trim((string) $request->get('beneficiary_last_name', ''));
  543.             $beneficiaryName = trim($beneficiaryFirstName . ' ' . $beneficiaryLastName);
  544.         }
  545.         if (empty($beneficiaryName)) {
  546.             $beneficiaryName = trim(
  547.                 (string) $request->get('room_0_pax_0_firstName', '') . ' ' .
  548.                 (string) $request->get('room_0_pax_0_lastName', '')
  549.             );
  550.         }
  551.         $beneficiaryEmail = $request->get('beneficiary_email');
  552.         $product_price = $request->get("totalAmount");
  553.         $productFee = $request->get('product_fee') ?? 0;
  554.         $dates = explode('-', $cart_line_date);
  555.         $date1 = DateTime::createFromFormat('d/m/Y', $dates[0]);
  556.         $date2 = DateTime::createFromFormat('d/m/Y', $dates[1]);
  557.         $interval = $date1->diff($date2);
  558.         $nbNights = $interval->days;
  559.         $hotel_with_party = false;
  560.         $product_elements = null;
  561.         for ($room_idx = 0; $room_idx < $nbRooms; $room_idx++) {
  562.             /* guests */
  563.             $room_guests = [];
  564.             $room_adults_count = $request->get("room_" . $room_idx . "_adults_count");
  565.             $room_children_count = $request->get("room_" . $room_idx . "_children_count");
  566.             $room_guest_count = $room_adults_count + $room_children_count;
  567.             for ($guest_index = 0; $guest_index < $room_guest_count; $guest_index++) {
  568.                 $paxCivility = $request->get("room_" . $room_idx . "_pax_" . $guest_index . "_civility");
  569.                 $paxFirstName = $request->get("room_" . $room_idx . "_pax_" . $guest_index . "_firstName");
  570.                 $paxLastName = $request->get("room_" . $room_idx . "_pax_" . $guest_index . "_lastName");
  571.                 $paxAge = $request->get("room_" . $room_idx . "_pax_" . $guest_index . "_age");
  572.                 if (!empty($paxFirstName) || !empty($paxLastName)) {
  573.                     // Use the detailed per-pax data
  574.                     $guest = [
  575.                         'civility' => $paxCivility ?? '',
  576.                         'firstName' => $paxFirstName ?? '',
  577.                         'lastName' => $paxLastName ?? '',
  578.                         'age' => $paxAge,
  579.                     ];
  580.                 } else {
  581.                     // Fallback: use the primary beneficiary name (backward compatibility)
  582.                     $guest = [
  583.                         'civility' => '',
  584.                         'firstName' => $beneficiaryName,
  585.                         'lastName' => '-',
  586.                     ];
  587.                 }
  588.                 $room_guests[] = $guest;
  589.             }
  590.             $room = [
  591.                 'rateKey' => $request->get("room_" . $room_idx . "_key"),
  592.                 'label' => $request->get("room_" . $room_idx . "_label"),
  593.                 'isAvailable' => $request->get("room_" . $room_idx . "_available"),
  594.                 'price' => $request->get("room_" . $room_idx . "_price"),
  595.                 'adultsCount' => $room_adults_count,
  596.                 'childrenCount' => $room_children_count,
  597.                 'guests' => $room_guests
  598.             ];
  599.             $room_nb_parties = $request->get("room_" . $room_idx . "_nbParties");
  600.             $room_parties_zones = [];
  601.             if (!is_null($room_nb_parties)) { // if there is parties associated to the current room
  602.                 $hotel_with_party = true;
  603.                 for ($party_index = 0; $party_index < $room_nb_parties; $party_index++) {
  604.                     $room_parties_zones[] = $request->get("room_" . $room_idx . "_party_" . $party_index);
  605.                 }
  606.                 $room['parties'] = $room_parties_zones;
  607.                 //$product_price+= $request->get("partyPrice_".$party_index);
  608.             }
  609.             $product_elements[] = $room;
  610.         }
  611.         if ($request->isMethod('POST')) {
  612.             $guestData = [
  613.                 'first_name' => $request->request->get('first_name'),
  614.                 'last_name' => $request->request->get('last_name'),
  615.                 'phone' => $request->request->get('phone'),
  616.                 'email' => $request->request->get('email'),
  617.             ];
  618.             $request->getSession()->set('guestData', $guestData);
  619.         }
  620.         $cart_line_hotel = [
  621.             'module' => ModuleEnum::hotel->getValue(),
  622.             'elements' => $product_elements,
  623.             'label' => $cart_line_label,
  624.             'date' => $cart_line_date,
  625.             'checkIn' => $dates[0],
  626.             'checkOut' => $dates[1],
  627.             'nbNights' => $nbNights,
  628.             'quantity' => $nbRooms,
  629.             'options' => $request->get("options"),
  630.             'comment' => $request->get("comment"),
  631.             'price' => $product_price,
  632.             'fee' => $productFee,
  633.             'available' => $request->get('available'),
  634.             'searchCode' => $request->get('searchCode'),
  635.             'tokenForBook' => $request->get('tokenForBook'),
  636.             'beneficiary' => [
  637.                 'name' => $beneficiaryName,
  638.                 'email' => $beneficiaryEmail
  639.             ],
  640.             'image' => $request->get('hotel_image'),
  641.             'city' => $request->get('city'),
  642.             'country' => $request->get('country'),
  643.             'rating' => $request->get('rating'),
  644.             'guestData' => $guestData,
  645.         ];
  646.         $cart_array['products'][] = $cart_line_hotel; // add a cart line ( a product )
  647.         $session->set("cart", $cart_array);
  648.         //return $this->redirectToRoute('app_shared_cart_index', [], Response::HTTP_SEE_OTHER);
  649.         return $this->redirectToRoute('app_shared_cart_hotel_product_index', [], Response::HTTP_SEE_OTHER);
  650.     }
  651.     private function getCountPages($cityId, $request,$auth_data): array
  652.     {
  653.         $GetPagesRequest = [
  654.             'cityId' => $cityId
  655.         ];
  656.         $httpClient = HttpClient::create();
  657.         $response= $httpClient->request(
  658.             'POST', $request->getUriForPath('/') . 'api/hotel/getPages',
  659.             [
  660.                 'headers' => [
  661.                     'Accept' => 'application/json',
  662.                     'Content-Type' => 'application/json',
  663.                     'user' => $auth_data['user'],
  664.                     'timestamp' => $auth_data['timestamp'],
  665.                     'signature' => hash('sha256', $auth_data['signature'])
  666.                 ],
  667.                 'json' => $GetPagesRequest
  668.             ]
  669.         );
  670.         $responseData = json_decode($response->getContent(), true);
  671.         if (
  672.             !is_array($responseData) ||
  673.             !array_key_exists('content', $responseData)
  674.         ) {
  675.             return [];
  676.         }
  677.         return $responseData['content'];
  678.     }
  679.     #[Route('/search_stream', name: 'hotel_search_stream', methods: ['POST'])]
  680.     public function hotelSearchStream(
  681.         Request         $request,
  682.         HotelApiService $hotelApiService
  683.     ): StreamedResponse
  684.     {
  685.         $requestJson = json_decode($request->getContent(), true);
  686.         $xmlSourceId = $request->headers->get('xmlSourceId') ?? 0;
  687.         $isAllowedPagination = $request->headers->get('isAllowedPagination') ?? 0;
  688.         $user = $this->getUser();
  689.         $auth_data = $this->apiAuthenticationService->getAuthenticationArray($user);
  690.         $customer = $this->apiAuthenticationService->getCustomer($user);
  691.         $showResellingRates = $this->shouldShowResellingRates($customer, $user);
  692.         $request->getSession()?->save();
  693.         $nbRooms = count($requestJson['occupancies'] ?? []);
  694.         $baseUri = $request->getSchemeAndHttpHost();
  695.         return new StreamedResponse(function () use (
  696.             $requestJson,
  697.             $xmlSourceId,
  698.             $auth_data,
  699.             $customer,
  700.             $showResellingRates,
  701.             $nbRooms,
  702.             $baseUri,
  703.             $hotelApiService,
  704.             $request,$isAllowedPagination
  705.         ) {
  706.             // =========================================================
  707.             // SSE INIT
  708.             // =========================================================
  709.             set_time_limit(0);
  710.             while (ob_get_level() > 0) {
  711.                 ob_end_clean();
  712.             }
  713.             @ini_set('output_buffering', '0');
  714.             @ini_set('zlib.output_compression', '0');
  715.             @ini_set('implicit_flush', '1');
  716.             ob_implicit_flush(true);
  717.             $startTime = microtime(true);
  718.             $idSearch = $request->query->get('idSearch') ?? bin2hex(random_bytes(8));
  719.             $cacheKey = self::UNNORMALIZED_CACHE_PREFIX . $idSearch;
  720.             // =========================================================
  721.             // HELPER : ANNULER TOUTES LES REQUETES ENCORE EN VOL
  722.             // =========================================================
  723.             $cancelAllResponses = function (array $responses): void {
  724.                 foreach ($responses as $r) {
  725.                     try {
  726.                         $r->cancel();
  727.                     } catch (\Throwable $e) {
  728.                         // on ignore : la requête peut déjà être terminée/annulée
  729.                     }
  730.                 }
  731.             };
  732.             // =========================================================
  733.             // HELPER : PERSISTER LES DONNEES UNNORMALIZED (une seule fois)
  734.             // =========================================================
  735.             $persistUnnormalizedChunk = function (array $hotelsUnnormalizedData) use ($cacheKey): void {
  736.                 if (empty($hotelsUnnormalizedData)) {
  737.                     return;
  738.                 }
  739.                 try {
  740.                     $item = $this->searchCachePool->getItem($cacheKey);
  741.                     $existing = $item->isHit() ? $item->get() : [];
  742.                     foreach ($hotelsUnnormalizedData as $entry) {
  743.                         if ($entry['unnormalizedData'] == "") {
  744.                             continue;
  745.                         }
  746.                         $hotelKey = $entry['hotelKey'];
  747.                         $existing[$hotelKey] = array_merge(
  748.                             $existing[$hotelKey] ?? [],
  749.                             [$entry['unnormalizedData']]
  750.                         );
  751.                     }
  752.                     $item->set($existing);
  753.                     $item->expiresAfter(self::UNNORMALIZED_CACHE_TTL);
  754.                     $this->searchCachePool->save($item);
  755.                     $this->logger->info('SSE: unnormalized rooms chunk cached', [
  756.                         'cacheKey' => $cacheKey,
  757.                         'hotelCount' => count($existing),
  758.                     ]);
  759.                 } catch (\Throwable $e) {
  760.                     $this->logger->error('SSE: failed to cache unnormalized rooms chunk', [
  761.                         'cacheKey' => $cacheKey,
  762.                         'message' => $e->getMessage(),
  763.                     ]);
  764.                 }
  765.             };
  766.             $this->logger->info('SSE: callback START');
  767.             echo ": heartbeat\n\n";
  768.             flush();
  769.             $this->logger->info('SSE: heartbeat SENT');
  770.             // =========================================================
  771.             // CONFIG
  772.             // =========================================================
  773.             $totalLoaded = 0;
  774.             $httpClient = HttpClient::create(
  775.                 [
  776.                     'timeout' => 120,
  777.                     'max_duration' => 180,
  778.                 ],
  779.                 30
  780.             );
  781.             // =========================================================
  782.             // HELPER : ENVOYER LES HOTELS EN SSE
  783.             // Retourne false si la connexion a été perdue en cours de route,
  784.             // pour que l'appelant sache qu'il doit annuler et sortir.
  785.             // =========================================================
  786.             $flushContent = function (array $content, int $pageNumber) use (
  787.                 &$totalLoaded,
  788.                 $persistUnnormalizedChunk,
  789.                 $nbRooms,
  790.                 $showResellingRates,
  791.                 $customer,
  792.                 $request
  793.             ): bool {
  794.                 $metaBatchSize = 50;
  795.                 if (empty($content)) {
  796.                     return true;
  797.                 }
  798.                 $chunkData = $this->buildContentFront(
  799.                     $content,
  800.                     $nbRooms,
  801.                     $showResellingRates,
  802.                     $customer
  803.                 );
  804.                 $metaHotels = $chunkData['meta'];
  805.                 $hotelsUnnormalizedData = $chunkData['hotelsUnnormalizedData'];
  806.                 // --- persistance immédiate, page consultable dès maintenant ---
  807.                 $persistUnnormalizedChunk($hotelsUnnormalizedData);
  808.                 $metaBuffer = [];
  809.                 foreach ($metaHotels as $hotel) {
  810.                     $metaBuffer[] = $hotel;
  811.                     $totalLoaded++;
  812.                     if (count($metaBuffer) >= $metaBatchSize) {
  813.                         $this->emitSSE('hotels_meta', [
  814.                             'page'   => $pageNumber,
  815.                             'hotels' => $metaBuffer,
  816.                         ]);
  817.                         $metaBuffer = [];
  818.                     }
  819.                     if (connection_aborted()) {
  820.                         return false;
  821.                     }
  822.                 }
  823.                 if (!empty($metaBuffer)) {
  824.                     $this->emitSSE('hotels_meta', [
  825.                         'page'   => $pageNumber,
  826.                         'hotels' => $metaBuffer,
  827.                     ]);
  828.                 }
  829.                 unset($metaBuffer);
  830.                 if (connection_aborted()) {
  831.                     return false;
  832.                 }
  833.                 unset($metaHotels, $chunkData, $hotelsUnnormalizedData);
  834.                 return true;
  835.             };
  836.             // =========================================================
  837.             // STATUS : RECHERCHE EN COURS
  838.             // =========================================================
  839.             $this->emitSSE('status', [
  840.                 'status' => 'searching',
  841.                 'message' => 'Recherche des disponibilités...',
  842.                 'idSearch' => $idSearch,
  843.             ]);
  844.             $responses = [];
  845.             $pagesCount = 1;
  846.             if ($xmlSourceId != 0 && $isAllowedPagination!=0) {
  847.                 $t = microtime(true);
  848.                 try {
  849.                     $pages = $this->getCountPages($requestJson['cityId'] ?? '', $request, $auth_data);
  850.                     $pagesCount = (int)($pages['pages_count'] ?? 1);
  851.                 } catch (\Throwable $e) {
  852.                     $this->logger->error('SSE: getCountPages failed', ['message' => $e->getMessage()]);
  853.                 }
  854.                 $this->logger->info('SSE: getCountPages duration', [
  855.                     'ms' => round((microtime(true) - $t) * 1000),
  856.                 ]);
  857.             }
  858.             // =========================================================
  859.             // LANCEMENT DES PAGES 1..N
  860.             // =========================================================
  861.             try {
  862.                 if (!connection_aborted()) {
  863.                     for ($p = 1; $p <= $pagesCount; $p++) {
  864.                         if (connection_aborted()) {
  865.                             $cancelAllResponses($responses);
  866.                             return; // pas de persistUnnormalized() ici
  867.                         }
  868.                         $req = $requestJson;
  869.                         $req['page'] = $p;
  870.                         $responses[$p] = $hotelApiService->query_ApiHotel_Availability_Async(
  871.                             $auth_data,
  872.                             $req,
  873.                             $xmlSourceId,
  874.                             $baseUri,
  875.                             $httpClient,
  876.                             true
  877.                         );
  878.                     }
  879.                 }
  880.             } catch (\Throwable $e) {
  881.                 $this->logger->error('SSE: error launching pages', [
  882.                     'message' => $e->getMessage(),
  883.                     'trace' => $e->getTraceAsString(),
  884.                 ]);
  885.                 // annuler ce qui a déjà été lancé avant l'exception ---
  886.                 $cancelAllResponses($responses);
  887.                 return;
  888.             }
  889.             // =========================================================
  890.             // PAGES 1..N : réception au fil de l'eau
  891.             // On boucle avec un while : si une page échoue en erreur HTTP
  892.             // (4xx/5xx, ex: 504), Symfony HttpClient lève l'exception au
  893.             // niveau du foreach lui-même (donc hors de portée du try/catch
  894.             // interne). On identifie la page fautive via $e->getResponse(),
  895.             // on la retire, et on relance stream() sur les pages restantes
  896.             // au lieu d'abandonner tout le batch.
  897.             // =========================================================
  898.             if (!empty($responses) && !connection_aborted()) {
  899.                 $remainingResponses = $responses;
  900.                 while (!empty($remainingResponses)) {
  901.                     try {
  902.                         foreach ($httpClient->stream($remainingResponses, 120) as $response => $chunk) {
  903.                             if (connection_aborted()) {
  904.                                 $this->logger->info('SSE: connection aborted', [
  905.                                     'totalLoaded' => $totalLoaded,
  906.                                 ]);
  907.                                 $cancelAllResponses($remainingResponses);
  908.                                 return; // pas de persistUnnormalized()
  909.                             }
  910.                             $pageNumber = null;
  911.                             foreach ($responses as $page => $pageResponse) {
  912.                                 if ($pageResponse === $response) {
  913.                                     $pageNumber = $page;
  914.                                     break;
  915.                                 }
  916.                             }
  917.                             try {
  918.                                 if ($chunk->isTimeout()) {
  919.                                     $this->logger->warning('SSE: page request timeout', [
  920.                                         'page' => $pageNumber,
  921.                                     ]);
  922.                                     continue;
  923.                                 }
  924.                                 if (!$chunk->isLast()) {
  925.                                     continue;
  926.                                 }
  927.                                 $pageStart = microtime(true);
  928.                                 $raw = $response->getContent(false);
  929.                                 // la page a répondu (avec ou sans contenu) -> on la retire des "en attente"
  930.                                 unset($remainingResponses[$pageNumber]);
  931.                                 if (empty($raw)) {
  932.                                     $this->logger->warning('SSE: empty response', [
  933.                                         'page' => $pageNumber,
  934.                                     ]);
  935.                                     continue;
  936.                                 }
  937.                                 $data = json_decode($raw, true);
  938.                                 if (!is_array($data)) {
  939.                                     $this->logger->warning('SSE: invalid JSON', [
  940.                                         'page' => $pageNumber,
  941.                                     ]);
  942.                                     continue;
  943.                                 }
  944.                                 $content = $data['content'] ?? [];
  945.                                 if (empty($content)) {
  946.                                     $this->logger->info('SSE: empty content', [
  947.                                         'page' => $pageNumber,
  948.                                     ]);
  949.                                     continue;
  950.                                 }
  951.                                 // -------------------------------------------------
  952.                                 // TRAITER ET ENVOYER LA PAGE
  953.                                 // -------------------------------------------------
  954.                                 $stillConnected = $flushContent($content,$pageNumber);
  955.                                 if (!$stillConnected) {
  956.                                     $this->logger->info('SSE: connection aborted during flushContent', [
  957.                                         'page' => $pageNumber,
  958.                                         'totalLoaded' => $totalLoaded,
  959.                                     ]);
  960.                                     $cancelAllResponses($remainingResponses);
  961.                                     return;
  962.                                 }
  963.                                 $this->emitSSE('progress', [
  964.                                     'page' => $pageNumber,
  965.                                     'pagesCount' => $pagesCount,
  966.                                     'loaded' => $totalLoaded,
  967.                                 ]);
  968.                                 unset($data, $content, $raw);
  969.                             } catch (\Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface $e) {
  970.                                 // filet de sécurité si ça arrive ici pour une page identifiée
  971.                                 $this->logger->error('SSE: page request failed (inner)', [
  972.                                     'page' => $pageNumber,
  973.                                     'message' => $e->getMessage(),
  974.                                 ]);
  975.                                 unset($remainingResponses[$pageNumber]);
  976.                                 continue;
  977.                             } catch (\Throwable $e) {
  978.                                 $this->logger->error('SSE: error processing page', [
  979.                                     'page' => $pageNumber,
  980.                                     'message' => $e->getMessage(),
  981.                                 ]);
  982.                                 unset($remainingResponses[$pageNumber]);
  983.                                 continue;
  984.                             }
  985.                         }
  986.                         // foreach terminé sans exception fatale -> toutes les pages restantes traitées
  987.                         break;
  988.                     } catch (\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface $e) {
  989.                         // Cas du 504 : l'exception a échappé au try/catch interne car levée
  990.                         // directement par le générateur stream(), avant l'entrée dans le foreach.
  991.                         // getResponse() permet d'identifier précisément quelle page a échoué.
  992.                         $failedResponse = $e->getResponse();
  993.                         $failedPage = null;
  994.                         foreach ($remainingResponses as $page => $r) {
  995.                             if ($r === $failedResponse) {
  996.                                 $failedPage = $page;
  997.                                 break;
  998.                             }
  999.                         }
  1000.                         if ($failedPage !== null) {
  1001.                             $this->logger->error('SSE: page failed with HTTP error, removing and continuing', [
  1002.                                 'page' => $failedPage,
  1003.                                 'status' => $e->getResponse()->getStatusCode(),
  1004.                                 'message' => $e->getMessage(),
  1005.                             ]);
  1006.                             unset($remainingResponses[$failedPage]);
  1007.                             // on relance stream() avec les pages restantes
  1008.                             continue;
  1009.                         }
  1010.                         $this->logger->error('SSE: HTTP error, page not identified, stopping to avoid infinite loop', [
  1011.                             'message' => $e->getMessage(),
  1012.                         ]);
  1013.                         $cancelAllResponses($remainingResponses);
  1014.                         break;
  1015.                     } catch (\Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface $e) {
  1016.                         // erreur de transport pure (DNS, connexion refusée, etc.) sans réponse HTTP
  1017.                         // associée -> impossible d'identifier la page fautive de façon fiable
  1018.                         $this->logger->error('SSE: transport error streaming pages, stopping to avoid infinite loop', [
  1019.                             'message' => $e->getMessage(),
  1020.                         ]);
  1021.                         $cancelAllResponses($remainingResponses);
  1022.                         break;
  1023.                     } catch (\Throwable $e) {
  1024.                         $this->logger->error('SSE: fatal error streaming pages', [
  1025.                             'message' => $e->getMessage(),
  1026.                         ]);
  1027.                         $cancelAllResponses($remainingResponses);
  1028.                         break;
  1029.                     }
  1030.                 }
  1031.                 unset($remainingResponses);
  1032.             }
  1033.             // =========================================================
  1034.             // FIN : stream terminé normalement
  1035.             // =========================================================
  1036.             $duration = round(microtime(true) - $startTime, 2);
  1037.             $this->emitSSE('progress', [
  1038.                 'page' => $pagesCount,
  1039.                 'pagesCount' => $pagesCount,
  1040.                 'loaded' => $totalLoaded,
  1041.             ]);
  1042.             $this->emitSSE('done', [
  1043.                 'total' => $totalLoaded,
  1044.                 'idSearch' => $idSearch,
  1045.                 'message' => 'stream completed',
  1046.                 'time_seconds' => $duration,
  1047.             ]);
  1048.             unset($responses);
  1049.         }, 200, [
  1050.             'Content-Type' => 'text/event-stream',
  1051.             'Cache-Control' => 'no-cache, no-transform',
  1052.             'X-Accel-Buffering' => 'no',
  1053.             'Connection' => 'keep-alive',
  1054.         ]);
  1055.     }
  1056.     private function emitSSE(string $event, mixed $data): void
  1057.     {
  1058.         // 1. JSON encode
  1059.         $payload = json_encode(
  1060.             $data,
  1061.             JSON_UNESCAPED_UNICODE |
  1062.             JSON_UNESCAPED_SLASHES |
  1063.             JSON_PARTIAL_OUTPUT_ON_ERROR
  1064.         );
  1065.         if ($payload === false) {
  1066.             $payload = json_encode([
  1067.                 'error' => 'json encode failed'
  1068.             ]);
  1069.         }
  1070.         // 2. Construction SSE
  1071.         $output =
  1072.             "event: {$event}\n" .
  1073.             "data: {$payload}\n\n";
  1074.         // 3. Echo
  1075.         echo $output;
  1076.         // 4. Output buffer
  1077.         $bufferLength = ob_get_length();
  1078.         if ($bufferLength !== false && $bufferLength > 0) {
  1079.             @ob_flush();
  1080.         }
  1081.         // 5. PHP flush
  1082.         @flush();
  1083.     }
  1084.     private function buildContentFront(
  1085.         array $hotels,
  1086.         int   $nbRooms,
  1087.         bool  $showResellingRates,
  1088.         mixed $customer, bool $isGetOffer = false
  1089.     ): array
  1090.     {
  1091.         $today = new DateTime();
  1092.         $content_front = [];
  1093.         $hotelNames = array_unique(array_map(
  1094.             fn($hotel) => $hotel['hotel']['name'],
  1095.             $hotels
  1096.         ));
  1097.         $customRatingsByName = $this->hotelApiService->getHotelCustomRatings($hotelNames);
  1098.         foreach ($hotels as $hotel) {
  1099.             $hotel_key = strtoupper($hotel['hotel']['name']);
  1100.             $sourceKey = $hotel['hotel']['id'];
  1101.             if (!$isGetOffer) {
  1102.                 $unnormalized = $hotel['hotel']['unnormalizedHotelData'] ?? null;
  1103.                 unset($hotel['hotel']['unnormalizedHotelData']);
  1104.             }
  1105.             $hotel_offer_count = 0;
  1106.             if (isset($content_front[$hotel_key]['offersCount'])) {
  1107.                 $hotel_offer_count = $content_front[$hotel_key]['offersCount'];
  1108.             }
  1109.             if (!isset($content_front[$hotel_key]['hotel'])) {
  1110.                 $content_front[$hotel_key]['hotel'] = $hotel['hotel'];
  1111.                 $content_front[$hotel_key]['hotel']['promos'] = [];
  1112.                 $content_front[$hotel_key]['hotel']['discounts'] = [];
  1113.                 $content_front[$hotel_key]['hotel']['infos'] = [];
  1114.                 $content_front[$hotel_key]['hotel']['customRating'] = $customRatingsByName[$hotel['hotel']['name']] ?? null;
  1115.                 $content_front[$hotel_key]['hotel']['reviewRating'] = rand(4, 10);
  1116.                 $content_front[$hotel_key]['hotel']['HotelDataUnnormalizedTmp'] = [];
  1117.             }
  1118.             if (!$isGetOffer) {
  1119.                 $content_front[$hotel_key]['hotel']['HotelDataUnnormalizedTmp'][] = $unnormalized;
  1120.             }
  1121.             if (count($hotel['sources']) > 0) {
  1122.                 $content_front[$hotel_key]['hotel']['facilities'] = $hotel['sources'][0]['facilities'];
  1123.             }
  1124.             foreach ($hotel['sources'] as $hotel_source) {
  1125.                 $content_front[$hotel_key]['sources'][$sourceKey]['sourceKey'] = $sourceKey;
  1126.                 $hotelSourcePrice = 0;
  1127.                 $hotelSourceAvailable = false;
  1128.                 $hotelSourceFreeCancellation = false;
  1129.                 $tab_boards = [];
  1130.                 foreach ($hotel_source['rooms'] as $index => $index_hrts) {
  1131.                     usort($index_hrts, function ($a, $b) {
  1132.                         return $a['salePromoRate'] <=> $b['salePromoRate'];
  1133.                     });
  1134.                     foreach ($index_hrts as $hrt) {
  1135.                         if ($hrt['NRF']) {
  1136.                             $hrt['freeCancellation'] = false;
  1137.                         } else {
  1138.                             if (is_null($hrt['deadline'])) {
  1139.                                 $hrt['freeCancellation'] = true;
  1140.                             } else {
  1141.                                 $deadline = new DateTime($hrt['deadline']);
  1142.                                 $difference = date_diff($today, $deadline);
  1143.                                 if ($difference->invert) {
  1144.                                     $hrt['freeCancellation'] = false;
  1145.                                 } else {
  1146.                                     $hrt['freeCancellation'] = true;
  1147.                                 }
  1148.                             }
  1149.                         }
  1150.                         $hotelSourceFreeCancellation = $hotelSourceFreeCancellation || $hrt['freeCancellation'];
  1151.                         if ($showResellingRates) {
  1152.                             $hrt['salePromoRate'] = $hrt['salePromoRate'] * (100.0 + $customer->getResellerMargin()) / 100.0;
  1153.                             $hrt['saleRate'] = $hrt['saleRate'] * (100.0 + $customer->getResellerMargin()) / 100.0;
  1154.                         }
  1155.                         $content_front[$hotel_key]['sources'][$sourceKey]['hrts'][$hrt['boardName']][$index][] = $hrt;
  1156.                         $tab_boards[$hrt['boardName']] = $hrt['boardXmlName'];
  1157.                         if ($hrt['available']) {
  1158.                             $hotel_offer_count++;
  1159.                             $hotelSourceAvailable = true;
  1160.                         }
  1161.                     }
  1162.                 } // END Room Indexes
  1163.                 foreach ($hotel_source['promos'] as $item) {
  1164.                     $content_front[$hotel_key]['hotel']['promos'][] = $item;
  1165.                 }
  1166.                 foreach ($hotel_source['discounts'] as $item) {
  1167.                     $content_front[$hotel_key]['hotel']['discounts'][] = $item;
  1168.                 }
  1169.                 foreach ($hotel_source['infos'] as $item) {
  1170.                     $content_front[$hotel_key]['hotel']['infos'][] = $item;
  1171.                 }
  1172.                 $currentSourceHrts = $content_front[$hotel_key]['sources'][$sourceKey]['hrts'] ?? [];
  1173.                 $sourceBest = $this->hotelApiService->getBestPriceBoardForSource([
  1174.                     'hrts' => $currentSourceHrts,
  1175.                 ]);
  1176.                 $content_front[$hotel_key]['sources'][$sourceKey]['bestPrice'] = $sourceBest['bestPrice'];
  1177.                 $currentHotelBest = $content_front[$hotel_key]['bestPrice'] ?? null;
  1178.                 if ($currentHotelBest === null || ($sourceBest['bestPrice'] !== null && $sourceBest['bestPrice'] < $currentHotelBest)) {
  1179.                     $content_front[$hotel_key]['bestPrice'] = $sourceBest['bestPrice'];
  1180.                     $content_front[$hotel_key]['bestBoard'] = $sourceBest['bestBoard'];
  1181.                     $content_front[$hotel_key]['bestRooms'] = !empty($sourceBest['bestRooms'])
  1182.                         ? $this->helpers->formatArrayCounts($sourceBest['bestRooms'])
  1183.                         : '';
  1184.                 }
  1185.                 $content_front[$hotel_key]['offersCount'] = $hotel_offer_count;
  1186.                 foreach ($content_front[$hotel_key]['sources'][$sourceKey]['hrts'] as $board_key => $board_hrts) {
  1187.                     if (count($board_hrts) < $nbRooms) {
  1188.                         unset($content_front[$hotel_key]['sources'][$sourceKey]['hrts'][$board_key]);
  1189.                     }
  1190.                 }
  1191.                 foreach ($tab_boards as $board_key => $board_value) {
  1192.                     $content_front[$hotel_key]['sources'][$sourceKey]['boards'][] =
  1193.                         [
  1194.                             'key' => $board_value,
  1195.                             'value' => $board_value,
  1196.                         ];
  1197.                 }
  1198.                 $content_front[$hotel_key]['sources'][$sourceKey]['generalStock'] = $hotel_source['generalStock'];
  1199.                 $content_front[$hotel_key]['sources'][$sourceKey]['searchCode'] = $hotel_source['searchCode'];
  1200.                 $content_front[$hotel_key]['sources'][$sourceKey]['xmlSourceId'] = $hotel_source['xmlSourceId'];
  1201.                 $content_front[$hotel_key]['sources'][$sourceKey]['xmlSourceName'] = $hotel_source['xmlSourceName'];
  1202.                 $content_front[$hotel_key]['sources'][$sourceKey]['available'] = $hotelSourceAvailable;
  1203.                 $content_front[$hotel_key]['sources'][$sourceKey]['freeCancellation'] = $hotelSourceFreeCancellation;
  1204.                 $content_front[$hotel_key]['sources'][$sourceKey]['associationRequired'] = $hotel_source['associationRequired'];
  1205.             }
  1206.         }
  1207.         $hotels = array_values($content_front);
  1208.         foreach ($hotels as &$hotel) {
  1209.             usort($hotel['sources'], function ($a, $b) {
  1210.                 return $a['bestPrice'] <=> $b['bestPrice'];
  1211.             });
  1212.             $hotel['sources'] = array_values($hotel['sources']);
  1213.             foreach ($hotel['sources'] as &$source) {
  1214.                 $source['hrts'] = array_values($source['hrts']);
  1215.             }
  1216.         }
  1217.         if (!$isGetOffer) {
  1218.             [$hotelsMeta, $hotelsUnnormalizedData] = $this->splitMetaAndRates($hotels);
  1219.             return [
  1220.                 'meta' => $hotelsMeta,
  1221.                 'hotelsUnnormalizedData' => $hotelsUnnormalizedData
  1222.             ];
  1223.         } else {
  1224.             return $hotels;
  1225.         }
  1226.     }
  1227.     private function splitMetaAndRates(array $hotels): array
  1228.     {
  1229.         $hotelsMeta = [];
  1230.         $hotelsUnnormalizedData = [];
  1231.         foreach ($hotels as $hotel) {
  1232.             $hotelKey = strtoupper($hotel['hotel']['name']);
  1233.             foreach (($hotel['hotel']['HotelDataUnnormalizedTmp'] ?? []) as $key => $data) {
  1234.                 $hotelsUnnormalizedData[] = [
  1235.                     'hotelKey' => $hotelKey,
  1236.                     'unnormalizedData' => $data,
  1237.                 ];
  1238.             };
  1239.             unset($hotel['hotel']['HotelDataUnnormalizedTmp']);
  1240.             $hotelsMeta[] = [
  1241.                 'hotelKey' => $hotelKey,
  1242.                 'hotel' => $hotel['hotel'],
  1243.                 'bestPrice' => $hotel['bestPrice'] ?? null,
  1244.                 'bestBoard' => $hotel['bestBoard'] ?? null,
  1245.                 'bestRooms' => $hotel['bestRooms'] ?? '',
  1246.                 'offersCount' => $hotel['offersCount'] ?? 0
  1247.             ];
  1248.         }
  1249.         return [$hotelsMeta, $hotelsUnnormalizedData];
  1250.     }
  1251.     private function getUnnormalizedRoomsData(
  1252.         string $idSearch,
  1253.         array  $hotelsKey
  1254.     ): array {
  1255.         $item = $this->searchCachePool->getItem(self::UNNORMALIZED_CACHE_PREFIX . $idSearch);
  1256.         if (!$item->isHit()) {
  1257.             return [];
  1258.         }
  1259.         $data = $item->get();
  1260.         $dataToNormalize = [];
  1261.         foreach ($hotelsKey as $hotelName) {
  1262.             $hotelKey = strtoupper($hotelName);
  1263.             $encoded = $data[$hotelKey] ?? null;
  1264.             if ($encoded === null) {
  1265.                 continue;
  1266.             }
  1267.             foreach ($encoded as $value) {
  1268.                 $dataToNormalize[] = unserialize(gzuncompress(base64_decode($value)));
  1269.             }
  1270.         }
  1271.         return $dataToNormalize;
  1272.     }
  1273.     #[Route('/getOfferDetails', name: 'hotel_offer_details', methods: ['POST'])]
  1274.     public function getRateDetail(Request $request): JsonResponse
  1275.     {
  1276.         // 3- User & Customer Authentication & Authorization
  1277.         $user = $this->getUser();
  1278.         $auth_data = $this->apiAuthenticationService->getAuthenticationArray($user);
  1279.         $customer = $this->apiAuthenticationService->getCustomer($user);
  1280.         $currency = $customer->getCurrency();
  1281.         $body = json_decode($request->getContent(), true);
  1282.         $idSearch = $body['idSearch'] ?? null;
  1283.         $hotelsKey = $body['hotelsKey'] ?? null;
  1284.         $nbRooms = $body['nbRooms'] ?? 1;
  1285.         $localSearchCode = uniqid(more_entropy: true) ?? null;
  1286.         $showResellingRates = $this->shouldShowResellingRates($customer, $user);
  1287.         if (!$idSearch || !$hotelsKey) {
  1288.             return $this->json(['error' => 'Missing parameters'], 400);
  1289.         }
  1290.         // 1 & 2. Charger le cache une seule fois et récupérer les données de tous les hôtels demandés
  1291.         $dataToNormalize = $this->getUnnormalizedRoomsData($idSearch, $hotelsKey);
  1292.         if (empty($dataToNormalize)) {
  1293.             return $this->json([
  1294.                 'error' => 'rate_expired'
  1295.             ], 404);
  1296.         }
  1297.         // 3. Normalisation en un seul appel
  1298.         $hotelsNormalizedHub = $this->hotelApiService->normalizeSearchResultsFromHubFormat(
  1299.             $dataToNormalize,
  1300.             $currency,
  1301.             $localSearchCode
  1302.         );
  1303.         // 4. Formatage en un seul appel
  1304.         $this->hotelApiService->formatHotelAvailabilityArray($hotelsNormalizedHub);
  1305.         // 5. Marges & exclusions en un seul appel
  1306.         $this->hotelApiService->applyMarginsAndHideExcludedHotelXml($hotelsNormalizedHub, $customer);
  1307.         // 6. Construction du contenu front en un seul appel
  1308.         $hotelsToNormalize = $this->buildContentFront(
  1309.             $hotelsNormalizedHub,
  1310.             $nbRooms,
  1311.             $showResellingRates,
  1312.             $customer,
  1313.             true
  1314.         );
  1315.         if (empty($hotelsToNormalize)) {
  1316.             return $this->json([
  1317.                 'error' => 'rate_expired'
  1318.             ], 404);
  1319.         }
  1320.         return $this->json($hotelsToNormalize);
  1321.     }
  1322.     /**
  1323.      * Déterminer si les tarifs revendeur doivent être affichés.
  1324.      */
  1325.     private function shouldShowResellingRates(mixed $customer, mixed $user): bool
  1326.     {
  1327.         if (!$customer instanceof CustomerMoral) {
  1328.             return false;
  1329.         }
  1330.         if ($customer->getResellerMargin() <= 0) {
  1331.             return false;
  1332.         }
  1333.         $roles = $user->getRoles();
  1334.         return in_array('ROLE_B2B_ADMIN', $roles, true)
  1335.             || in_array('ROLE_B2B_AGENT', $roles, true);
  1336.     }
  1337. }