Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
wp-content
/
plugins
/
vikbooking
/
admin
/
helpers
:
availability.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?php /** * @package VikBooking * @subpackage com_vikbooking * @author Alessio Gaggii - e4j - Extensionsforjoomla.com * @copyright Copyright (C) 2022 e4j - Extensionsforjoomla.com. All rights reserved. * @license GNU General Public License version 2 or later; see LICENSE * @link https://vikwp.com */ defined('ABSPATH') or die('No script kiddies please!'); /** * Availability handler class for Vik Booking. * Also used to handle website inquiry reservations. * * @since 1.15.0 (J) - 1.5.0 (WP) */ class VikBookingAvailability { /** * The singleton instance of the class. * * @var VikBookingAvailability */ protected static $instance = null; /** * An array containing the stay dates. * * @var array */ protected $stay_dates = []; /** * An array containing the stay date timestamps. * * @var array */ protected $stay_ts = []; /** * An array containing the room parties. * * @var array */ protected $room_parties = []; /** * The total number of days to go "back and forth". * * @var int */ protected $back_and_forth = 14; /** * A list of the room ids to be checked. * * @var array */ protected $room_ids = []; /** * Whether to ignore restrictions. * * @var bool */ protected $ignore_restrictions = false; /** * Whether to ignore rooms availability. * * @var bool */ protected $ignore_availability = false; /** * Whether check-ins on check-outs are allowed. * * @var bool */ protected $inonout_allowed = true; /** * The percent ratio for nights/transfers in split stays. * * @var int */ protected $nights_transfers_ratio = 100; /** * Whether we need to behave for the front-end booking process. * * @var bool */ protected $is_front_booking = false; /** * The warning string occurred. * * @var string */ protected $warning = ''; /** * The error string occurred. * * @var string */ protected $error = ''; /** * The last error code occurred in TACVBO. * * @var int */ protected $errorCode = 0; /** * A list of fully booked room ids. * * @var array */ protected $fully_booked = []; /** * Associative list of all rooms. * * @var array */ protected $all_rooms = []; /** * Associative list of all rate plans. * * @var array */ protected $all_rplans = []; /** * Map of min/max LOS tariffs defined per room. * * @var array */ protected $min_max_los_tariffs_map = []; /** * Class constructor is protected. * * @see getInstance() */ protected function __construct() { // load dependencies if (!class_exists('TACVBO')) { require_once VBO_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'tac.vikbooking.php'; } } /** * Returns the global object, either a new instance or the existing instance * if the class was already instantiated, unless a new instance is requested. * * @param bool $anew True for forcing a new instance. * * @return VikBookingAvailability */ public static function getInstance($anew = false) { if (is_null(static::$instance) || $anew) { static::$instance = new static(); } return static::$instance; } /** * Counts the total number of nights of stay according to the stay dates. * * @param int $from_ts optional check-in timestamp. * @param int $to_ts optional check-out timestamp. * * @return int the total number of nights of stay. * * @since 1.16.0 (J) - 1.6.0 (WP) added args to make this an helper method. */ public function countNightsOfStay($from_ts = null, $to_ts = null) { if (!count($this->stay_ts) && (empty($from_ts) || empty($to_ts))) { return 1; } if (!empty($from_ts) && !empty($to_ts)) { $use_from = $from_ts; $use_to = $to_ts; } else { $use_from = $this->stay_ts[0]; $use_to = $this->stay_ts[1]; } $secdiff = $use_to - $use_from; $daysdiff = $secdiff / 86400; if (is_int($daysdiff)) { $daysdiff = $daysdiff < 1 ? 1 : $daysdiff; } else { if ($daysdiff < 1) { $daysdiff = 1; } else { $sum = floor($daysdiff) * 86400; $newdiff = $secdiff - $sum; $maxhmore = VikBooking::getHoursMoreRb() * 3600; if ($maxhmore >= $newdiff) { $daysdiff = floor($daysdiff); } else { $daysdiff = ceil($daysdiff); } } } return $daysdiff; } /** * Explains the error code occurred or passed by using translation strings. * * @param int $force_code optional error code to explain. * * @return string the explanation of the error, or an empty string. */ public function explainErrorCode($force_code = 0) { // the error code to parse $use_ecode = $force_code ? $force_code : $this->errorCode; if (empty($use_ecode)) { return ''; } /** * Error code identifier: * * 1 = missing/invalid request options. * 2 = invalid authentication. * 3 = no rooms found for the given party. * 4 = not compliant with booking restrictions. * 5 = not compliant with global closing dates. * 6 = no rates defined for the given length of stay. * 7 = no availability for the dates requested. * 8 = no rooms available due to restrictions at room or rate plan level. */ switch ($use_ecode) { case 1: return 'Missing or invalid request options.'; case 2: return 'Invalid request authentication.'; case 3: $expl = JText::translate('VBO_AV_ECODE_3'); return $expl != 'VBO_AV_ECODE_3' ? $expl : 'No rooms found for the given party.'; case 4: return 'Not compliant with the booking restrictions.'; case 5: return 'Not compliant with the global closing dates.'; case 6: return 'No rates defined for the given length of stay.'; case 7: $expl = JText::translate('VBO_AV_ECODE_7'); return $expl != 'VBO_AV_ECODE_7' ? $expl : 'No availability for the dates requested.'; case 8: return 'No rooms available due to room or rate plan restrictions.'; default: return 'Unknown error code.'; } } /** * Returns a list of room IDs from the given category IDs. * * @param array $category_ids List of category IDs to filter. * * @return array List of involved and unique room IDs. * * @since 1.17.6 (J) - 1.7.6 (WP) */ public function filterRoomCategories(array $category_ids) { $category_ids = array_filter(array_map('abs', array_map('intval', $category_ids))); if (!$category_ids) { return []; } $dbo = JFactory::getDbo(); $q = $dbo->getQuery(true) ->select($dbo->qn('id')) ->from($dbo->qn('#__vikbooking_rooms')); foreach ($category_ids as $cat_id) { $q->where([ $dbo->qn('idcat') . ' = ' . $dbo->q($cat_id . ';'), $dbo->qn('idcat') . ' LIKE ' . $dbo->q($cat_id . ';%'), $dbo->qn('idcat') . ' LIKE ' . $dbo->q('%;' . $cat_id . ';%'), $dbo->qn('idcat') . ' LIKE ' . $dbo->q('%;' . $cat_id . ';'), ], 'OR'); } $dbo->setQuery($q); return array_values(array_unique($dbo->loadColumn())); } /** * Loads a list of room categories. * * @return array The associative list of categories. * * @since 1.17.6 (J) - 1.7.6 (WP) */ public function loadRoomCategories() { $dbo = JFactory::getDbo(); $dbo->setQuery( $dbo->getQuery(true) ->select([ $dbo->qn('id'), $dbo->qn('name'), ]) ->from($dbo->qn('#__vikbooking_categories')) ->order($dbo->qn('name') . ' ASC') ); return $dbo->loadAssocList(); } /** * Loads all rooms in VBO and maps them into an associative array. * * @param array $ids optional list of IDs to load. * @param int $max optional rooms limit to fetch. * @param bool $anew true to avoid object caching. * * @return array the associative list of rooms. * * @since 1.16.10 (J) - 1.6.10 (WP) added arguments $ids, $max. * @since 1.17.5 (J) - 1.7.5 (WP) added argument $anew. */ public function loadRooms(array $ids = [], $max = 0, $anew = false) { if ($this->all_rooms && !$anew) { // return previously cached array if available return $this->all_rooms; } $dbo = JFactory::getDbo(); $q = $dbo->getQuery(true) ->select([ $dbo->qn('id'), $dbo->qn('name'), $dbo->qn('img'), $dbo->qn('idcat'), $dbo->qn('avail'), $dbo->qn('units'), $dbo->qn('fromadult'), $dbo->qn('toadult'), $dbo->qn('fromchild'), $dbo->qn('tochild'), $dbo->qn('totpeople'), $dbo->qn('mintotpeople'), ]) ->from($dbo->qn('#__vikbooking_rooms')); if ($ids) { $ids = array_map('intval', $ids); $q->where($dbo->qn('id') . ' IN (' . implode(', ', $ids) . ')'); } $q->order($dbo->qn('avail') . ' DESC'); $q->order($dbo->qn('name') . ' ASC'); $dbo->setQuery($q, 0, $max); $room_rows = $dbo->loadAssocList(); if (!$room_rows) { return $anew ? [] : $this->all_rooms; } if ($this->isFrontBooking()) { // apply translations on rooms $vbo_tn = VikBooking::getTranslator(); $vbo_tn->translateContents($room_rows, '#__vikbooking_rooms'); } $assoc_rooms = []; foreach ($room_rows as $room) { $assoc_rooms[$room['id']] = $room; } if (!$anew) { // cache room records $this->all_rooms = $assoc_rooms; } return $anew ? $assoc_rooms : $this->all_rooms; } /** * Sets the current rooms as an associative array of information. The * array keys represent the room IDs as an associative array of details. * * @param array $rooms the associatve list of rooms. * * @return self */ public function setRooms(array $rooms = []) { $this->all_rooms = $rooms; return $this; } /** * Filters all rooms by keeping just the ones published/available. * * @return array associative array of published (available) rooms. */ public function filterPublishedRooms() { $rooms = $this->loadRooms(); foreach ($rooms as $k => $room) { if (!($room['avail'] ?? 1)) { unset($rooms[$k]); } } return $rooms; } /** * Finds a room by name. * * @param string $name The room name to look for. * * @return array * * @since 1.16.10 (J) - 1.6.10 (WP) */ public function getRoomByName(string $name) { $dbo = JFactory::getDbo(); $q = $dbo->getQuery(true) ->select('*') ->from($dbo->qn('#__vikbooking_rooms')) ->order($dbo->qn('avail') . ' DESC') ->order($dbo->qn('name') . ' ASC'); foreach (array_filter(preg_split("/[\s\-_.,]+/", $name)) as $nm_part) { $q->where($dbo->qn('name') . ' LIKE ' . $dbo->q("%{$nm_part}%")); } $dbo->setQuery($q, 0, 1); $record = $dbo->loadAssoc(); return $record ?? []; } /** * Loads all rate plans in VBO and maps them into an associative array. * * @param bool $no_cache True to avoid internal caching. * * @return array the associative list of rate plans. * * @since 1.17.6 (J) - 1.7.6 (WP) added $no_cache argument. */ public function loadRatePlans($no_cache = false) { if (!$no_cache && $this->all_rplans) { // return previously cached array if available return $this->all_rplans; } $dbo = JFactory::getDbo(); $rate_plans = []; $derived_rplans = []; $q = "SELECT * FROM `#__vikbooking_prices` ORDER BY `name` ASC;"; $dbo->setQuery($q); $rplan_rows = $dbo->loadAssocList(); foreach ($rplan_rows as $rplan) { if (!empty($rplan['derived_id']) && !empty($rplan['derived_data'])) { // decode the derived data information $rplan['derived_data'] = json_decode($rplan['derived_data'], true); // add rate plan ID $derived_rplans[] = $rplan['id']; } $rate_plans[$rplan['id']] = $rplan; } // add the information about the parent rate plans, if any foreach ($rate_plans as $rplan_id => $rplan_data) { if (in_array($rplan_id, $derived_rplans) && isset($rate_plans[$rplan_data['derived_id']])) { // set parent rate details $rate_plans[$rplan_id]['parent_rate_id'] = $rate_plans[$rplan_data['derived_id']]['id']; $rate_plans[$rplan_id]['parent_rate_name'] = $rate_plans[$rplan_data['derived_id']]['name']; } } // sort rate plans $rate_plans = VikBooking::sortRatePlans($rate_plans, true); if (!$no_cache) { $this->all_rplans = $rate_plans; } return $rate_plans; } /** * Returns a list of rate plans derived from the given parent rate plan ID. * * @param int $parent_rate_id The parent rate plan ID. * * @return array * * @since 1.16.10 (J) - 1.6.10 (WP) */ public function getDerivedRatePlans(int $parent_rate_id) { $derived_rplans = []; foreach ($this->loadRatePlans() as $rp_id => $rplan) { if ($rplan['derived_id'] && $rplan['derived_id'] == $parent_rate_id && $rplan['derived_data']) { // this rate plan is derived from the given parent rate plan $derived_rplans[] = $rplan; } } return $derived_rplans; } /** * Returns the orphan dates for the given or set rooms and dates. * * @param array $options List of fetching options. * * @return array Associative list of rooms orphan dates. * * @since 1.17.7 (J) - 1.7.7 (WP) */ public function getOrphanDates(array $options = []) { // flag to indicate whether room-level restrictions were loaded $use_room_level_restr = false; // load and set rooms and restrictions if ($options['room_ids'] ?? []) { // force the requested rooms to be loaded $this->loadRooms((array) $options['room_ids']); // load restrictions with room filters $all_restrictions = VikBooking::loadRestrictions(true, (array) $options['room_ids']); // turn flag on for room-level restrictions being used $use_room_level_restr = true; } else { // load restrictions with no filters $all_restrictions = VikBooking::loadRestrictions(false); } // default minimum stay $def_min_stay = VikBooking::getDefaultNightsCalendar(); // figure out dates range if (($current_stay_dates = $this->getStayDates()) && !($options['from_date'] ?? null)) { // populate current stay dates $options['from_date'] = $current_stay_dates[0]; $options['to_date'] = $current_stay_dates[1]; } else { // check given fetching options if (!($options['from_date'] ?? null)) { // default to today's date $options['from_date'] = date('Y-m-d'); } if (!($options['to_date'] ?? null)) { // default to next week's date $options['to_date'] = date('Y-m-d', strtotime('+1 week')); } } // calculate the highest minimum stay for a better accuracy $all_min_los = [($def_min_stay > 1 ? $def_min_stay : 0)]; foreach ($all_restrictions as $index => $restrs) { $all_min_los = array_merge($all_min_los, array_column($restrs, 'minlos')); } $max_min_los = max(array_map('intval', $all_min_los)); if (!$max_min_los) { // no minimum stay restrictions to apply, hence no orphan dates return []; } // calculate past limit timestamp for today at midnight $lim_past_ts = strtotime(date('Y-m-d')); // always fetch a wider availability window for better accuracy $fetch_from_date = date('Y-m-d', strtotime("-{$max_min_los} days", strtotime($options['from_date']))); $fetch_to_date = date('Y-m-d', strtotime("+{$max_min_los} days", strtotime($options['to_date']))); if (strtotime($fetch_from_date) < $lim_past_ts) { // start fetching the inventory from today's date (past dates not accepted) $fetch_from_date = date('Y-m-d'); } // always set stay dates $this->setStayDates($fetch_from_date, $fetch_to_date); // obtain the availability inventory by ignoring restrictions $ari = $this->getInventory(false); if (!$ari) { return []; } // build orphan dates pool $orphans_pool = []; // UTC timezone $utc_tz = new DateTimezone('UTC'); // get date bounds $from_bound = new DateTime($options['from_date'], $utc_tz); $to_bound = new DateTime($options['to_date'], $utc_tz); // build iterable dates interval (period) $date_range = new DatePeriod( // start date included by default in the result set $from_bound, // interval between recurrences within the period new DateInterval('P1D'), // end date excluded by default from the result set $to_bound->modify('+1 day') ); // scan rooms availability inventory foreach ($ari as $idroom => $room_ari) { // load proper room-level restrictions if not done already $all_restrictions = $use_room_level_restr ? $all_restrictions : VikBooking::loadRestrictions(true, [$idroom]); // iterate the dates interval foreach ($date_range as $dt) { // calculate date values $day_key = $dt->format('Y-m-d'); $day_now_ts = strtotime($day_key); $day_after_ts = strtotime('+1 day', $day_now_ts); // parse room restrictions for the current inventory day and room to get the minimum stay $restr = VikBooking::parseSeasonRestrictions($day_now_ts, $day_after_ts, 1, $all_restrictions); $minimum_stay = (int) ($restr['minlos'] ?? $def_min_stay); if ($minimum_stay < 2) { // no real minimum stay restriction detected for this day continue; } // scan rooms availability foreach ($room_ari['inventory'] as $keypoint => $inventory) { if ($inventory['day'] != $day_key) { // not the date-key point we are looking for continue; } // tell whether the listing is available on the current date-key point $is_available = (bool) ($inventory['units_to_sell'] ?? $inventory['available'] ?? 0); if (!$is_available) { // this day is fully booked, hence no orphan dates continue 2; } // scan the availability for the next minimum stay days from the current day-key point for ($d = 0; $d < $minimum_stay; $d++) { // build next date-key point value (start from current day-key point as it counts as one available night of stay) $next_keypoint = $keypoint + $d; if (!isset($room_ari['inventory'][$next_keypoint])) { // no more data available break; } // tell whether the listing will be available on this future date-key point (0th day will always be available) $will_be_available = (bool) ($room_ari['inventory'][$next_keypoint]['units_to_sell'] ?? $room_ari['inventory'][$next_keypoint]['available'] ?? 0); if (!$will_be_available) { /** * Probable orphan date found for bookings on days ahead. Ensure this * is truly an orphan date by checking the days before. If staying * on this (available) night is allowed, then this should not be * considered as an orphan date, even though arriving is not allowed. */ if (!($options['orphans_checkin'] ?? false)) { // check previous dates before saying it's an orphan date for ($backd = ($keypoint - 1), $distance = 1; $backd >= 0; $backd--, $distance++) { if (!isset($room_ari['inventory'][$backd])) { // nothing to do, let it be an orphan date break; } // tell whether the listing was available on this previous date-key point $was_available = (bool) ($room_ari['inventory'][$backd]['units_to_sell'] ?? $room_ari['inventory'][$backd]['available'] ?? 0); if (!$was_available) { // nothing to do, let it be an orphan date break; } // calculate past date values $past_day_now_ts = strtotime($room_ari['inventory'][$backd]['day']); $past_day_after_ts = strtotime('+1 day', $past_day_now_ts); // parse room restrictions for the current past day and room to get the minimum stay $restr = VikBooking::parseSeasonRestrictions($past_day_now_ts, $past_day_after_ts, 1, $all_restrictions); $minimum_stay = (int) ($restr['minlos'] ?? $def_min_stay); if ($minimum_stay <= $distance) { // free day with a minimum stay that allows to stay on the presumed orphan date // break the cycles for the presumed orphan date-key point, because it's not truly an orphan break 3; } } } // orphan date found if (!isset($orphans_pool[$idroom])) { // start container $orphans_pool[$idroom] = [ 'room_name' => $room_ari['room_name'], 'orphans' => [], ]; } // push orphan date information $orphans_pool[$idroom]['orphans'][] = [ 'day' => $day_key, 'current_min_stay_nights' => $minimum_stay, 'max_min_stay_nights_allowed' => $d, ]; // break the cycles for this date-key point break 2; } } } } } // return the associative list of room orphan dates found, if any return $orphans_pool; } /** * Returns the inventory for the rooms and dates set. * * @param bool $restrictions Whether to include booking restrictions data. * * @return array Associative list of rooms availability inventory. * * @since 1.16.10 (J) - 1.6.10 (WP) */ public function getInventory($restrictions = false) { $stay_ts = $this->getStayDates(true); if (!$stay_ts) { $this->setError('No dates provided'); return []; } if ($stay_ts[0] > $stay_ts[1]) { $this->setError('Invalid dates provided'); return []; } $info_from = getdate($stay_ts[0]); $room_ids = array_column($this->loadRooms(), 'id'); $room_names = array_column($this->loadRooms(), 'name'); $room_units = array_column($this->loadRooms(), 'units'); if (!$room_ids) { $this->setError('No rooms provided'); return []; } // load busy records $busy_records = VikBooking::loadBusyRecords($room_ids, $stay_ts[0], $stay_ts[1]); // room restrictions associative list $room_restrictions = []; // global minimum stay restriction $glob_minlos = VikBooking::getDefaultNightsCalendar(); $glob_minlos = $glob_minlos < 1 ? 1 : (int) $glob_minlos; // inventory pool $ari = []; foreach ($room_ids as $room_index => $room_id) { $ari[$room_id] = [ 'room_name' => $room_names[$room_index], 'inventory' => [], ]; } // loop through all dates in the range while ($info_from[0] <= $stay_ts[1]) { // build day after timestamp $day_after_ts = mktime(0, 0, 0, $info_from['mon'], $info_from['mday'] + 1, $info_from['year']); // scan the units booked for each requested room foreach ($room_ids as $room_index => $room_id) { $units_booked = 0; // scan all the occupied records of the current room foreach (($busy_records[$room_id] ?? []) as $busy) { $info_in = getdate($busy['checkin']); $info_out = getdate($busy['checkout']); $in_ts = mktime(0, 0, 0, $info_in['mon'], $info_in['mday'], $info_in['year']); $out_ts = mktime(0, 0, 0, $info_out['mon'], $info_out['mday'], $info_out['year']); if ($info_from[0] >= $in_ts && $info_from[0] < $out_ts) { // increase room units booked $units_booked++; } } // count units left $units_left = $units_booked >= $room_units[$room_index] ? 0 : ($room_units[$room_index] - $units_booked); // set room inventory date $inventory_day = [ 'day' => date('Y-m-d', $info_from[0]), ]; if ($room_units[$room_index] == 1) { // single-unit listing inventory structure $inventory_day['available'] = (bool) $units_left; } else { // multi-unit room-type inventory structure $inventory_day['units_booked'] = $units_booked; $inventory_day['units_to_sell'] = $units_left; } // check for room-level restrictions if ($restrictions) { if (!isset($room_restrictions[$room_id])) { // load room restrictions only once $room_restrictions[$room_id] = VikBooking::loadRestrictions(true, [$room_id]); } // parse room restrictions for the current inventory day $restr = VikBooking::parseSeasonRestrictions($info_from[0], $day_after_ts, 1, $room_restrictions[$room_id]); if ($restr) { $inventory_day['restrictions'] = [ 'min_los' => (int) $restr['minlos'], ]; if (($restr['maxlos'] ?? 0)) { $inventory_day['restrictions']['max_los'] = (int) $restr['maxlos']; } if (!empty($restr['cta'])) { $inventory_day['restrictions']['closed_to_arrival'] = array_map(function($wday) { switch ($wday) { case '1': return 'Monday'; case '2': return 'Tuesday'; case '3': return 'Wednesday'; case '4': return 'Thursday'; case '5': return 'Friday'; case '6': return 'Saturday'; default: return 'Sunday'; } }, $restr['cta']); } if (!empty($restr['ctd'])) { $inventory_day['restrictions']['closed_to_departure'] = array_map(function($wday) { switch ($wday) { case '1': return 'Monday'; case '2': return 'Tuesday'; case '3': return 'Wednesday'; case '4': return 'Thursday'; case '5': return 'Friday'; case '6': return 'Saturday'; default: return 'Sunday'; } }, $restr['ctd']); } } else { // set the global minimum stay restriction $inventory_day['restrictions'] = [ 'min_los' => $glob_minlos, ]; } } // push room-day inventory $ari[$room_id]['inventory'][] = $inventory_day; } // go to next date $info_from = getdate($day_after_ts); } return $ari; } /** * Gets the available room rates for the specified dates, party and rooms. * * @param array $params optional list of options to be forced for TACVBO. * * @return mixed boolean false in case of errors or array result of TACVBO class. */ public function getRates($params = []) { // reset errors to their initial values $this->error = ''; $this->errorCode = 0; if (!$this->stay_dates) { $this->setError('No dates provided'); return false; } if (!$this->room_parties) { $this->setError('No room party provided'); return false; } // count injected rooms, if any $tot_rooms = count($this->room_ids); // build options array for TACVBO $options = [ 'hash' => md5('vbo.e4j.vbo'), 'req_type' => 'hotel_availability', 'start_date' => $this->stay_dates[0], 'end_date' => $this->stay_dates[1], 'nights' => $this->countNightsOfStay(), 'num_rooms' => ($tot_rooms > 0 ? $tot_rooms : 1), 'adults' => [$this->getPartyGuests('adults', 0)], 'children' => [$this->getPartyGuests('children', 0)], 'only_rates' => 1, 'wtax' => $params['wtax'] ?? null, ]; if ($tot_rooms > 1 && count($this->room_parties) == $tot_rooms) { // re-build list of adults and children $options['adults'] = []; $options['children'] = []; for ($i = 0; $i < $tot_rooms; $i++) { $options['adults'][] = $this->getPartyGuests('adults', $i); $options['children'][] = $this->getPartyGuests('children', $i); } } // check for implicit settings if (!empty($params['max_rooms_limit'])) { // set rooms maximum limit TACVBO::$maxRoomsLimit = (int) $params['max_rooms_limit']; // unset implicit setting unset($params['max_rooms_limit']); } else { // reset to initial value for subsequent calls TACVBO::$maxRoomsLimit = 0; } if (is_array(($params['forced_room_ids'] ?? null))) { // set allowed room IDs TACVBO::$forcedRoomIds = $params['forced_room_ids']; // unset implicit setting unset($params['forced_room_ids']); } else { // reset to initial value for subsequent calls TACVBO::$forcedRoomIds = []; } // merge default options with params, if any $options = array_merge($options, $params); // invoke TACVBO class by injecting the options TACVBO::$getArray = true; TACVBO::$ignoreRestrictions = $this->ignore_restrictions; TACVBO::$ignoreAvailability = $this->ignore_availability; $website_rates = TACVBO::tac_av_l($options); // store the error code occurred (if any) $this->errorCode = TACVBO::getErrorCode(); if (!is_array($website_rates)) { // critical error $this->setError(str_replace('e4j.error.', '', $website_rates)); return false; } if (isset($website_rates['e4j.error'])) { // calculation/availability error $this->setError($website_rates['e4j.error']); // check if reserved keys like "fullybooked" are present if (isset($website_rates['fullybooked']) && is_array($website_rates['fullybooked'])) { // store fully booked rooms array $this->fully_booked = $website_rates['fullybooked']; } // always return false return false; } // optional filter by room IDs will be applied on this flow $found_rids = array_keys($website_rates); $unwanted_rids = $tot_rooms ? array_diff($found_rids, $this->room_ids) : []; foreach ($unwanted_rids as $rid) { unset($website_rates[$rid]); } return $website_rates; } /** * Finds the available suggestions in case of no availability previously occurred * while getting the rates. This method should be called after getRates() so that * a valid errorCode to be analized will be available, unless code is forced. * Suggestions can include closest booking dates and/or different room-guest parties. * * @param int $force_code the error code to force (no availability or party unsatisfied). * @param array $force_rooms an optional list of room IDs to consider for the suggestions. * * @return array array of alternative dates, alternative room-parties and split stays. * * @since 1.16.0 (J) - 1.6.0 (WP) list of split stays available is also returned. */ public function findSuggestions($force_code = 0, $force_rooms = []) { // reset error and warning strings to start a new calculation $this->error = ''; $this->warning = ''; // build containers for the two types of suggestions $alternative_dates = []; $alternative_parties = []; $split_stay_sols = []; // the error code to parse $use_ecode = $force_code ? $force_code : $this->errorCode; if (empty($use_ecode)) { // do not continue if no valid errors previously occurred or forced $this->setError('Empty error code'); return [$alternative_dates, $alternative_parties, $split_stay_sols]; } if ($use_ecode == 7 && (count($this->fully_booked) || count($force_rooms))) { // get the closest booking dates for the compatible, yet unavailable, rooms $use_rooms = count($force_rooms) ? $force_rooms : $this->fully_booked; $alternative_dates = $this->findClosestRoomDateSolutions($use_rooms); // calculate the split stay solutions available for the compatible rooms $split_stay_sols = $this->findSplitStays($use_rooms); } if ($use_ecode == 3) { // no rooms found for the given party, suggest alternative parties $active_rooms = count($force_rooms) ? $force_rooms : array_keys($this->filterPublishedRooms()); // match all the available rooms in the requested or near dates $all_solutions = $this->findClosestRoomDateSolutions($active_rooms); // sort solutions by bigger rooms $all_solutions = $this->sortBiggerRoomSolutions($all_solutions); // find matching solutions for the requested party $alternative_parties = $this->matchSolutionsParty($all_solutions); } return [$alternative_dates, $alternative_parties, $split_stay_sols]; } /** * Given a list of unavailable room IDs, yet compatible with the party and LOS requested, * we build a list of available solutions for booking split stays on the same dates. The * visibility should be public so that other Views could use just this method. * * @param array $room_ids list of unavailable, yet compatible, room IDs. * @param ?array $busy_list optional list of busy records for the involved dates. * * @return array associative list of available split stays, or empty array. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function findSplitStays(array $room_ids = [], ?array $busy_list = null) { if (!$room_ids) { return []; } // get all website rooms $all_rooms = $this->loadRooms(); // validate max occupancy for the given rooms if (count($this->room_parties) === 1) { // we only use the first party for the occupancy validation $party_adults = $this->getPartyGuests('adults', $party = 0); $party_children = $this->getPartyGuests('children', $party = 0); foreach ($room_ids as $rindex => $rid) { if (!isset($all_rooms[$rid])) { unset($room_ids[$rindex]); continue; } if ($party_adults > $all_rooms[$rid]['toadult'] || $party_children > $all_rooms[$rid]['tochild'] || ($party_adults + $party_children) > $all_rooms[$rid]['totpeople']) { // max occupancy not met unset($room_ids[$rindex]); continue; } } if (!$room_ids) { return []; } // reset array keys $room_ids = array_values($room_ids); } // get original check-in and check-out timestamps list($orig_checkin_ts, $orig_checkout_ts) = $this->getStayDates(true); $info_from = getdate($orig_checkin_ts); $info_to = getdate($orig_checkout_ts); // the final check-out date $final_checkout_ymd = date('Y-m-d', $orig_checkout_ts); // count original length of stay and nights involved $tot_nights = $this->countNightsOfStay(); $groupdays = VikBooking::getGroupDays($orig_checkin_ts, $orig_checkout_ts, $tot_nights); if ($tot_nights < 2) { // useless to waste time on finding a split stay if not at least 2 nights return []; } // load the occupied records for these dates and rooms $busy_records = !is_null($busy_list) ? $busy_list : VikBooking::loadBusyRecords($room_ids, $orig_checkin_ts, strtotime('+1 day', $orig_checkout_ts)); // calculate available rooms for each night $avroom_nights = []; foreach ($room_ids as $rid) { if (!isset($all_rooms[$rid])) { continue; } $room = $all_rooms[$rid]; foreach ($groupdays as $gday) { $day_key = date('Y-m-d', $gday); $bfound = 0; if (!isset($busy_records[$rid])) { $busy_records[$rid] = []; } foreach ($busy_records[$rid] as $bu) { $busy_info_in = getdate($bu['checkin']); $busy_info_out = getdate($bu['checkout']); $busy_in_ts = mktime(0, 0, 0, $busy_info_in['mon'], $busy_info_in['mday'], $busy_info_in['year']); $busy_out_ts = mktime(0, 0, 0, $busy_info_out['mon'], $busy_info_out['mday'], $busy_info_out['year']); if ($gday >= $busy_in_ts && $gday == $busy_out_ts && !$this->inonout_allowed && $room['units'] < 2) { // check-ins on check-outs not allowed $bfound++; if ($bfound >= $room['units']) { break; } } if ($gday >= $busy_in_ts && $gday < $busy_out_ts) { $bfound++; if ($bfound >= $room['units']) { break; } } } if ($bfound < $room['units']) { // push this night as available for this room if (!isset($avroom_nights[$rid])) { $avroom_nights[$rid] = []; } $avroom_nights[$rid][] = $day_key; } else { // room not available on this night, make sure to unset any previous value if (isset($avroom_nights[$rid]) && in_array($day_key, $avroom_nights[$rid])) { $unav_key = array_search($day_key, $avroom_nights[$rid]); unset($avroom_nights[$rid][$unav_key]); $avroom_nights[$rid] = array_values($avroom_nights[$rid]); } } } } if (!count($avroom_nights)) { // no rooms available at all, there's no way to do anything return []; } // make sure all nights requested can be satisfied by at least one room foreach ($groupdays as $gday) { $day_key = date('Y-m-d', $gday); $day_av = false; foreach ($avroom_nights as $rid => $av_nights) { if (in_array($day_key, $av_nights)) { // night was found $day_av = true; break; } } if (!$day_av) { // this night is not available in any room, unable to proceed return []; } } // count the number of consecutive nights per room $cons_room_nights = []; $tot_gdays = count($groupdays); foreach ($groupdays as $k => $gday) { $day_key = date('Y-m-d', $gday); if (!isset($cons_room_nights[$day_key])) { $cons_room_nights[$day_key] = []; } foreach ($avroom_nights as $rid => $av_nights) { if (in_array($day_key, $av_nights)) { if (!isset($cons_room_nights[$day_key][$rid])) { $cons_room_nights[$day_key][$rid] = []; } // count the next consecutive nights of stay $cons_room_nights[$day_key][$rid][] = $day_key; for ($j = ($k + 1); $j < $tot_gdays; $j++) { $next_day_key = date('Y-m-d', $groupdays[$j]); if (in_array($next_day_key, $av_nights)) { $cons_room_nights[$day_key][$rid][] = $next_day_key; } else { break; } } } } } // sort the solutions with the highest number of consecutive nights to reduce the splits $cons_room_nights_sorted = []; foreach ($cons_room_nights as $day_key => $cons_nights) { $cons_room_nights_cnt = []; foreach ($cons_nights as $rid => $cons_dates) { $cons_room_nights_cnt[$rid] = count($cons_dates); } // sort the array in a descending order arsort($cons_room_nights_cnt); // restore sorted values in cloned array $cons_room_nights_sorted[$day_key] = []; foreach ($cons_room_nights_cnt as $rid => $tot_cons_nights) { $cons_room_nights_sorted[$day_key][$rid] = $cons_room_nights[$day_key][$rid]; } } $cons_room_nights = $cons_room_nights_sorted; // validate the data just built $first_day_key = date('Y-m-d', $groupdays[0]); if (!isset($cons_room_nights[$first_day_key]) || !count($cons_room_nights[$first_day_key]) || count($cons_room_nights) != count($groupdays)) { // unable to proceed return []; } // remove the consecutive nights from the check-out date as this won't be a night of stay unset($cons_room_nights[$final_checkout_ymd]); // build the split stay solutions $split_stay_sols = []; // the number of rooms available on the first night should determine the number of split stay solutions foreach ($cons_room_nights[$first_day_key] as $start_rid => $cons_nights) { // start container of the various splits for this stay $split_stay_sol = []; // calculate last consecutive night available $leave_date = end($cons_nights); $leave_date_info = getdate(strtotime($leave_date)); // set the check-out date to the day after the last night $checkout_ymd = date('Y-m-d', mktime(0, 0, 0, $leave_date_info['mon'], ($leave_date_info['mday'] + 1), $leave_date_info['year'])); // define the first stay $split_stay = [ 'idroom' => $start_rid, 'room_name' => $all_rooms[$start_rid]['name'], 'checkin' => $cons_nights[0], 'checkout' => $checkout_ymd, 'nights' => $this->countNightsOfStay(strtotime($cons_nights[0]), strtotime($checkout_ymd)), ]; // make sure this room has got a tariff defined for this number of nights of stay if (!$this->roomNightsAllowed($start_rid, $split_stay['nights'])) { // no tariffs found for this los continue; } // push first stay $split_stay_sol[] = $split_stay; // loop through the next stays while (isset($cons_room_nights[$checkout_ymd])) { /** * For the next splits, we use just the first available rooms, which is * the one with the highest number of consecutive nights available. */ foreach ($cons_room_nights[$checkout_ymd] as $rid => $split_cons_nights) { // calculate last consecutive night available $leave_date = end($split_cons_nights); $leave_date_info = getdate(strtotime($leave_date)); // set the check-out date to the day after the last night $checkout_ymd = date('Y-m-d', mktime(0, 0, 0, $leave_date_info['mon'], ($leave_date_info['mday'] + 1), $leave_date_info['year'])); if ($leave_date == $final_checkout_ymd) { // check-out date reached $checkout_ymd = $final_checkout_ymd; } // count nights of stay $split_nights = $this->countNightsOfStay(strtotime($split_cons_nights[0]), strtotime($checkout_ymd)); // make sure this room has got a tariff defined for this number of nights of stay if (!$this->roomNightsAllowed($rid, $split_nights)) { // no tariffs found for this los, abort solution $split_stay_sol = []; break 2; } // push split stay $split_stay_sol[] = [ 'idroom' => $rid, 'room_name' => $all_rooms[$rid]['name'], 'checkin' => $split_cons_nights[0], 'checkout' => $checkout_ymd, 'nights' => $split_nights, ]; // we try to reduce the number of splits by considering just the first room break; } } if (count($split_stay_sol) < 2) { // not a split stay, but rather a fully available room continue; } // push split stay solution $split_stay_sols[] = $split_stay_sol; } /** * Load rooms involved in all split stays in order to validate * global/room-level restrictions and closing dates on the stay. */ $rooms_involved = []; foreach ($split_stay_sols as $split_stay_sol) { foreach ($split_stay_sol as $split_stay) { if (!in_array($split_stay['idroom'], $rooms_involved)) { $rooms_involved[] = $split_stay['idroom']; } } } // load restrictions for all rooms involved $all_restrictions = VikBooking::loadRestrictions(true, $rooms_involved); $glob_restrictions = VikBooking::globalRestrictions($all_restrictions); $invalid_room_restr = []; // validate global restrictions if (VikBooking::validateRoomRestriction($glob_restrictions, $info_from, $info_to, $tot_nights)) { // global restrictions apply over this stay return []; } // validate closing dates if (VikBooking::validateClosingDates($orig_checkin_ts, $orig_checkout_ts)) { // global closing dates apply over this stay return []; } // validate restrictions at room level foreach ($rooms_involved as $rid) { // load restrictions at room level $room_level_restr = VikBooking::roomRestrictions($rid, $all_restrictions); if (VikBooking::validateRoomRestriction($room_level_restr, $info_from, $info_to, $tot_nights)) { // room-level restrictions apply over this stay $invalid_room_restr[] = $rid; } } // unset the split stays with the restricted rooms (if any) $altered_sols = false; foreach ($invalid_room_restr as $rid) { foreach ($split_stay_sols as $k => $split_stay_sol) { foreach ($split_stay_sol as $split_stay) { if ($rid == $split_stay['idroom']) { // this booking split stay cannot be suggested because of this room unset($split_stay_sols[$k]); $altered_sols = true; continue 2; } } } } // apply nights/transfers ratio limit (unless disabled) $nights_transfers_ratio = $this->getNightsTransfersRatio(); if ($nights_transfers_ratio > 0 && $nights_transfers_ratio < 100) { // count and apply limits foreach ($split_stay_sols as $k => $split_stay_sol) { // count nights and transfers $split_stay_transfers = count($split_stay_sol) - 1; $split_stay_nights = 0; foreach ($split_stay_sol as $split_stay_room) { $split_stay_nights += $split_stay_room['nights']; } // max allowed transfers $max_transfers = round($split_stay_nights * $nights_transfers_ratio / 100, 0); if (!$split_stay_transfers || $split_stay_transfers > $max_transfers) { // unset solution unset($split_stay_sols[$k]); $altered_sols = true; } } } if ($altered_sols && count($split_stay_sols)) { // restore the array keys $split_stay_sols = array_values($split_stay_sols); } // return the available booking split stay solutions (if any) return $split_stay_sols; } /** * Returns the number of guests requested from the given room-party index. * * @param string $guest either "adults", "children" or "guests". * @param int $party the party index number, 0 by default. * * @return int the total number of guests requested in the party. */ protected function getPartyGuests($guest = 'adults', $party = 0) { if (!isset($this->room_parties[$party])) { return 0; } if (!strcasecmp($guest, 'adults')) { // adults return $this->room_parties[$party]['adults']; } if (!strcasecmp($guest, 'children')) { // children return $this->room_parties[$party]['children']; } // total guests $tot_guests = 0; foreach ($this->room_parties as $rparty) { $tot_guests += $rparty['adults']; $tot_guests += $rparty['children']; } return $tot_guests; } /** * Given a list of unavailable room IDs, yet compatible with the party and LOS requested, * we build a list of available dates when such rooms could be booked for the same LOS. * * @param array $room_ids list of unavailable, yet compatible, room IDs. * * @return array associative list of available room-dates, or empty array. */ protected function findClosestRoomDateSolutions($room_ids = []) { if (!$room_ids) { return []; } // get all website rooms $all_rooms = $this->loadRooms(); // get original check-in and check-out timestamps list($orig_checkin_ts, $orig_checkout_ts) = $this->getStayDates(true); $info_from = getdate($orig_checkin_ts); $info_to = getdate($orig_checkout_ts); // count original length of stay $tot_nights = $this->countNightsOfStay(); // earliest checkin timestamp allowed $lim_past_ts = mktime(0, 0, 0, date('n'), ((int)date('j') + VikBooking::getMinDaysAdvance()), date('Y')); // suggested range of dates (+/- "back and forth" days from original dates) $sug_from_ts = mktime($info_from['hours'], $info_from['minutes'], $info_from['seconds'], $info_from['mon'], ($info_from['mday'] - $this->back_and_forth), $info_from['year']); if ($sug_from_ts < $lim_past_ts) { $sug_from_ts = $lim_past_ts; // since we are close to the requested check-in, double up the "back and forth" for the max date $this->setBackForthDays($this->getBackForthDays() * 2); } $sug_to_ts = mktime($info_to['hours'], $info_to['minutes'], $info_to['seconds'], $info_to['mon'], ($info_to['mday'] + $this->back_and_forth), $info_to['year']); $sug_to_ts = $sug_to_ts < $sug_from_ts ? $sug_from_ts : $sug_to_ts; // get days timestamps for suggestions $groupdays = []; $sug_start_info = getdate($sug_from_ts); $sug_from_midnight = mktime(0, 0, 0, $sug_start_info['mon'], $sug_start_info['mday'], $sug_start_info['year']); $sug_start_info = getdate($sug_from_midnight); while ($sug_start_info[0] <= $sug_to_ts) { array_push($groupdays, $sug_start_info[0]); $sug_start_info = getdate(mktime(0, 0, 0, $sug_start_info['mon'], ($sug_start_info['mday'] + 1), $sug_start_info['year'])); } // build suggestions array of dates with some availability for the given rooms $suggestions = []; $busy_records = VikBooking::loadBusyRecords($room_ids, $sug_from_ts, strtotime('+1 day', $sug_to_ts)); foreach ($room_ids as $rid) { if (!isset($all_rooms[$rid])) { continue; } $room = $all_rooms[$rid]; foreach ($groupdays as $gday) { $day_key = date('Y-m-d', $gday); $bfound = 0; if (!isset($busy_records[$rid])) { $busy_records[$rid] = []; } foreach ($busy_records[$rid] as $bu) { $busy_info_in = getdate($bu['checkin']); $busy_info_out = getdate($bu['checkout']); $busy_in_ts = mktime(0, 0, 0, $busy_info_in['mon'], $busy_info_in['mday'], $busy_info_in['year']); $busy_out_ts = mktime(0, 0, 0, $busy_info_out['mon'], $busy_info_out['mday'], $busy_info_out['year']); if ($gday >= $busy_in_ts && $gday == $busy_out_ts && !$this->inonout_allowed && $room['units'] < 2) { // check-ins on check-outs not allowed $bfound++; if ($bfound >= $room['units']) { break; } } if ($gday >= $busy_in_ts && $gday < $busy_out_ts) { $bfound++; if ($bfound >= $room['units']) { break; } } } if ($bfound < $room['units']) { if (!isset($suggestions[$day_key])) { $suggestions[$day_key] = []; } $room_day = $room; $room_day['units_left'] = $room['units'] - $bfound; $suggestions[$day_key] = $suggestions[$day_key] + [$rid => $room_day]; } } } if (!$suggestions) { // no available nights found for the prior and next "back and forth" days for the given rooms return []; } // build the solutions array with keys=checkin, values=all rooms suited for the requested number of nights $solutions = []; // get all rooms available for the number of nights requested in the suggestions array of dates foreach ($suggestions as $kday => $rooms) { $day_ts_info = getdate(strtotime($kday)); foreach ($rooms as $rid => $room) { $suitable = true; $room_days_av_left = [$kday => $room['units_left']]; for ($i = 1; $i < $tot_nights; $i++) { $next_night = mktime(0, 0, 0, $day_ts_info['mon'], ($day_ts_info['mday'] + $i), $day_ts_info['year']); $next_night_dt = date('Y-m-d', $next_night); if (!isset($suggestions[$next_night_dt]) || !isset($suggestions[$next_night_dt][$rid])) { $suitable = false; break; } $room_days_av_left[$next_night_dt] = $suggestions[$next_night_dt][$rid]['units_left']; } if ($suitable === true) { if (!isset($solutions[$kday])) { $solutions[$kday] = []; } unset($room['units_left']); $room['days_av_left'] = $room_days_av_left; $solutions[$kday] = $solutions[$kday] + [$rid => $room]; } } } if (!count($solutions)) { // the requested length of stay could not be satisfied for any available night return []; } // sort the solutions by the closest checkin date to the one requested $sortmap = []; $orig_checkin_ymd = date('Y-m-d', $orig_checkin_ts); foreach ($solutions as $kday => $solution) { $kdayts = strtotime($kday); $sortmap[$kdayts] = $kdayts > $orig_checkin_ts ? ($kdayts - $orig_checkin_ts) : ($orig_checkin_ts - $kdayts); if ($orig_checkin_ymd == $kday) { // the original check-in day is available, so we want it to be first, regardless of the check-in time $sortmap[$kdayts] = 1; } } asort($sortmap); $sorted = []; foreach ($sortmap as $kdayts => $v) { $kday = date('Y-m-d', $kdayts); $sorted[$kday] = $solutions[$kday]; } $solutions = $sorted; unset($sorted); /** * Load rooms involved in the final alternative solutions in order * to validate global/room-level restrictions and closing dates. * * @since 1.15.4 (J) - 1.5.10 (WP) */ $rooms_involved = []; foreach ($solutions as $arrive_ymd => $roomsol) { foreach (array_keys($roomsol) as $rid) { if (!in_array($rid, $rooms_involved)) { $rooms_involved[] = $rid; } } } // load restrictions for all rooms involved $all_restrictions = VikBooking::loadRestrictions(true, $rooms_involved); $glob_restrictions = VikBooking::globalRestrictions($all_restrictions); $room_level_restr = []; foreach ($solutions as $arrive_ymd => $roomsol) { // build suggested stay dates $sug_in = getdate(strtotime($arrive_ymd)); $sug_out = getdate(mktime(0, 0, 0, $sug_in['mon'], ($sug_in['mday'] + $tot_nights), $sug_in['year'])); // validate global restrictions if (VikBooking::validateRoomRestriction($glob_restrictions, $sug_in, $sug_out, $tot_nights)) { // global restrictions apply over this stay unset($solutions[$arrive_ymd]); continue; } // validate closing dates if (VikBooking::validateClosingDates($sug_in[0], $sug_out[0])) { // global closing dates apply over this stay unset($solutions[$arrive_ymd]); continue; } // validate restrictions at room level foreach ($roomsol as $rid => $rdata) { if (!isset($room_level_restr[$rid])) { // load restrictions at room level $room_level_restr[$rid] = VikBooking::roomRestrictions($rid, $all_restrictions); } if (VikBooking::validateRoomRestriction($room_level_restr[$rid], $sug_in, $sug_out, $tot_nights)) { // room-level restrictions apply over this stay unset($solutions[$arrive_ymd][$rid]); if (!count($solutions[$arrive_ymd])) { // unset the entire suggested date unset($solutions[$arrive_ymd]); break; } continue; } } } if (!count($solutions)) { // the calculated suggestions do not meet the restrictions or the closing dates return []; } // return the solution alternative dates for all rooms available return $solutions; } /** * Sorts an associative array of room-solutions by the bigger rooms. * * @param array $solutions the date solutions obtained for some rooms. * * @return array the same array sorted by bigger rooms on top. */ protected function sortBiggerRoomSolutions($solutions) { if (!is_array($solutions) || !$solutions) { return $solutions; } // sort rooms-solutions by max-adults, 'max-guests', 'max-children', in a descending order foreach ($solutions as $kday => $solution) { // with this sorting, we will have the bigger rooms on top to quickly fit the party requested uasort($solutions[$kday], function($a, $b) { if ($a['toadult'] == $b['toadult']) { if ($a['totpeople'] == $b['totpeople']) { return $a['tochild'] > $b['tochild'] ? -1 : 1; } return $a['totpeople'] > $b['totpeople'] ? -1 : 1; } return $a['toadult'] > $b['toadult'] ? -1 : 1; }); } return $solutions; } /** * Given a list of available dates and rooms (solutions), attempts * to match a party of rooms that fits the party requested. * * @param array $solutions the list of available dates and related rooms. * * @return array list of alternative party solutions, if any. */ protected function matchSolutionsParty($solutions) { if (!is_array($solutions) || !$solutions) { return []; } // build the list of alternative parties $alternative_parties = []; // build list of party guests $party_guests = [ 'adults' => 0, 'children' => 0, ]; foreach ($this->room_parties as $rparty) { $party_guests['adults'] += $rparty['adults']; $party_guests['children'] += $rparty['children']; } // check if the rooms of each solution can fit the number of guests requested, unset the solution otherwise foreach ($solutions as $kday => $solution) { $solution_guests = [ 'adults' => 0, 'children' => 0, ]; foreach ($solution as $rid => $roomsol) { // count minimum units left for this room $room_min_uleft = min($roomsol['days_av_left']); // check if this solution of rooms can allocate all the guests requested if ($roomsol['totpeople'] < ($roomsol['toadult'] + $roomsol['tochild']) && !$party_guests['children']) { // in case of no children requested, we ignore them to avoid adjusting the room capacity $roomsol['tochild'] = 0; } if ($roomsol['totpeople'] < ($roomsol['toadult'] + $roomsol['tochild'])) { /** * The sum of the max_adults and max_children exceeds the max_guests: lower the adults * we can take first (if party children > 0), then the children, until sum=max_guests */ while (($roomsol['toadult'] > 0 || $roomsol['tochild'] > 0)) { if (!$party_guests['children'] && $roomsol['totpeople'] == $roomsol['toadult']) { /** * When no children requested in the party, we cannot under-utilize rooms. * Break the loop without lowering the 'toadult'. */ $roomsol['tochild'] = 0; break; } if ($party_guests['children'] && $solution_guests['children'] >= $party_guests['children']) { /** * If all the children requested were allocated in other solutions, * we should not under-utilize rooms by reducing the number of adults. */ break; } if ($roomsol['toadult'] > 0 && $party_guests['children'] > 0 && !($roomsol['tochild'] > $party_guests['children'])) { /** * We lower first the adults that we put in this room, only if there are children in the party * and if the children in the party are more than the 'max_children' of this room. */ $roomsol['toadult']--; if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) { break; } } if ($roomsol['tochild'] > 0) { // if the max_guests is still greater than the sum of adults+children we take, take out one child $roomsol['tochild']--; if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) { break; } } if ($roomsol['toadult'] > 0) { // if even at this point we still have a high sum of guests to take compared to the max_guests, take out again one adult $roomsol['toadult']--; if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) { break; } } } } $solution_guests['adults'] += $roomsol['toadult'] * $room_min_uleft; $solution_guests['children'] += $roomsol['tochild'] * $room_min_uleft; // update 'max_adults' and 'max_children' for this solution (for later guests allocation) $solution[$rid]['toadult'] = $roomsol['toadult']; $solution[$rid]['tochild'] = $roomsol['tochild']; } $solutions[$kday] = $solution; if ($solution_guests['adults'] < $party_guests['adults'] || $solution_guests['children'] < $party_guests['children']) { // the guests we can allocate with the solution of this day are not enough: unset the solution unset($solutions[$kday]); continue; } // if we get to this point we can suggest a booking solution for the party requested, but in different rooms if (!isset($alternative_parties[$kday])) { $alternative_parties[$kday] = []; } // re-loop over the rooms in this solution to build the booking solution for this day $guests_allocated = [ 'adults' => 0, 'children' => 0 ]; /** * The rooms available for an alternative booking solutions have been sorted by capacity * in a descending order to quickly fit the guest party requested. However, if a smaller * and cheaper room was capable of fitting all guests, we should opt for this solution. * * @since 1.15.4 (J) - 1.5.9 (WP) */ $smaller_fit_found = false; $smaller_solutions = array_reverse($solution, true); foreach ($smaller_solutions as $rid => $roomsol) { if ($party_guests['adults'] > 0 && $party_guests['adults'] > $roomsol['toadult']) { // too many adults requested for this small room continue; } if ($party_guests['children'] > 0 && $party_guests['children'] > $roomsol['tochild']) { // too many children requested for this small room continue; } if (($party_guests['adults'] + $party_guests['children']) > $roomsol['totpeople']) { // too many guests requested for this small room continue; } // we've got a fitting room which could be smaller $roomsol['guests_allocation'] = [ 'adults' => $party_guests['adults'], 'children' => $party_guests['children'], ]; array_push($alternative_parties[$kday], $roomsol); // turn flag on and break the loop $smaller_fit_found = true; break; } if ($smaller_fit_found) { // no need to parse the rooms from the largest to the smallest continue; } foreach ($solution as $rid => $roomsol) { // count minimum units left for this room $room_min_uleft = min($roomsol['days_av_left']); // fullfil all the units of this room for ($units_taken = 0; $units_taken < $room_min_uleft; $units_taken++) { $current_allocation = [ 'adults' => 0, 'children' => 0 ]; if ($guests_allocated['adults'] < $party_guests['adults']) { $humans_taken = $roomsol['toadult']; $missing_humans = $party_guests['adults'] - $guests_allocated['adults']; $humans_taken = $humans_taken > $missing_humans ? $missing_humans : $humans_taken; $current_allocation['adults'] = $humans_taken; $guests_allocated['adults'] += $humans_taken; } if ($guests_allocated['children'] < $party_guests['children']) { $humans_taken = $roomsol['tochild']; $missing_humans = $party_guests['children'] - $guests_allocated['children']; $humans_taken = $humans_taken > $missing_humans ? $missing_humans : $humans_taken; $current_allocation['children'] = $humans_taken; $guests_allocated['children'] += $humans_taken; } $roomsol['guests_allocation'] = $current_allocation; array_push($alternative_parties[$kday], $roomsol); if ($guests_allocated['adults'] >= $party_guests['adults'] && $guests_allocated['children'] >= $party_guests['children']) { // we have allocated all guests, exit the for-loop break; } } if ($guests_allocated['adults'] >= $party_guests['adults'] && $guests_allocated['children'] >= $party_guests['children']) { //we have allocated all guests with this solution, no need to loop over other rooms available in this day. break; } } } // return the alternative parties found, if any return $alternative_parties; } /** * Given a list of alternative dates obtained from an inquiry/request information, * composes a valid room-rate array to store the inquiry reservation. By calling this * method, the original stay dates will be overwritten. * * @param array $alt_dates the list of alternative dates found for the stay. * @param object $customer a stdClass object with the basic customer details. * * @return int the ID of the newly created inquiry reservation. */ public function allocateAltDatesInquiry($alt_dates, $customer) { if (!is_array($alt_dates) || !$alt_dates) { return 0; } foreach ($alt_dates as $ymd => $rooms) { // we expect just one room-type for the party, and we use the first suggestion foreach ($rooms as $rid => $alt_stay) { if (empty($alt_stay['days_av_left']) || !is_array($alt_stay['days_av_left'])) { // invalid structure continue; } // compose the new stay dates $sugg_checkin_dt = null; $sugg_checkout_dt = null; foreach ($alt_stay['days_av_left'] as $dayk => $uleft) { if (empty($sugg_checkin_dt)) { // grab the first date $sugg_checkin_dt = $dayk; } // always overwrite until last date $sugg_checkout_dt = $dayk; } // increase check-out date by one day (day after last night of stay) $sugg_out_info = getdate(strtotime($sugg_checkout_dt)); $sugg_checkout_dt = date('Y-m-d', mktime(0, 0, 0, $sugg_out_info['mon'], ($sugg_out_info['mday'] + 1), $sugg_out_info['year'])); // set the new stay dates $this->setStayDates($sugg_checkin_dt, $sugg_checkout_dt); // build the room rate plan array without any rate plan information $room_rplan = [ 'idroom' => $alt_stay['id'], ]; // create the inquiry reservation for the closest alternative dates return $this->createInquiryReservation($room_rplan, $customer); } } return 0; } /** * Given a list of alternative parties obtained from an inquiry/request information, * composes valid room-rate arrays to store the inquiry reservation. By calling this * method, the original stay dates and room party will be overwritten. * * @param array $alt_parties the list of alternative parties found for the stay. * @param object $customer a stdClass object with the basic customer details. * * @return int the ID of the newly created inquiry reservation. */ public function allocateAltPartyInquiry($alt_parties, $customer) { if (!is_array($alt_parties) || !$alt_parties) { return 0; } // build list of rooms to assign to the inquiry reservation $room_rates = []; // start party counter $party_counter = 0; foreach ($alt_parties as $ymd => $alt_rooms) { // we expect to have more than one room-type for the large party suggestion foreach ($alt_rooms as $alt_room) { if (empty($alt_room['guests_allocation']) || !is_array($alt_room['guests_allocation'])) { // invalid structure continue; } if (empty($alt_room['days_av_left']) || !is_array($alt_room['days_av_left'])) { // invalid structure continue; } // compose the new stay dates $sugg_checkin_dt = null; $sugg_checkout_dt = null; foreach ($alt_room['days_av_left'] as $dayk => $uleft) { if (empty($sugg_checkin_dt)) { // grab the first date $sugg_checkin_dt = $dayk; } // always overwrite until last date $sugg_checkout_dt = $dayk; } // increase check-out date by one day (day after last night of stay) $sugg_out_info = getdate(strtotime($sugg_checkout_dt)); $sugg_checkout_dt = date('Y-m-d', mktime(0, 0, 0, $sugg_out_info['mon'], ($sugg_out_info['mday'] + 1), $sugg_out_info['year'])); // set the new stay dates $this->setStayDates($sugg_checkin_dt, $sugg_checkout_dt); // set the current guests party (the first will replace the previous party, others will be pushed) $this->setRoomParty($alt_room['guests_allocation']['adults'], $alt_room['guests_allocation']['children'], ($party_counter === 0)); // increase party counter $party_counter++; // push current room with no rate plan information array_push($room_rates, [ 'idroom' => $alt_room['id'], ]); } if (count($room_rates)) { // we use the closest dates in the first suggestion party array break; } } // count total rooms in the party $tot_room_party = count($room_rates); if (!$tot_room_party) { // something went wrong return 0; } // grab the main/first room reservation $room_rplan = $room_rates[0]; // build extra rooms $extra_rooms = []; if ($tot_room_party > 1) { // grab the remaining rooms unset($room_rates[0]); $extra_rooms = array_values($room_rates); } // create the inquiry reservation for the closest alternative dates and rooms party return $this->createInquiryReservation($room_rplan, $customer, $extra_rooms); } /** * Creates a new pending reservation from the inquiry/request information. * Requires a valid room-rate array to be available, or in case suggestions should * be applied, the room-rate array should be adjusted to comply with this method. * * @param array $room_rplan a room-rate array to allocate the booking. * @param object $customer a stdClass object with the basic customer details. * @param array $extra_rooms optional list of additional room-rate arrays to store * in case of alternative parties suggested. * * @return int the ID of the newly created inquiry reservation. */ public function createInquiryReservation($room_rplan, $customer, $extra_rooms = array()) { if (!is_array($room_rplan) || empty($room_rplan['idroom'])) { return 0; } if (empty($this->stay_ts) || empty($this->room_parties)) { // no stay dates or room party set return 0; } $dbo = JFactory::getDbo(); // build reservation object $res_obj = new stdClass; $res_obj->custdata = $customer->custdata; $res_obj->ts = time(); $res_obj->status = 'standby'; $res_obj->days = $this->countNightsOfStay(); $res_obj->checkin = $this->stay_ts[0]; $res_obj->checkout = $this->stay_ts[1]; $res_obj->custmail = $customer->email; $res_obj->sid = VikBooking::getSecretLink(); $res_obj->idpayment = $this->getDefaultPaymentId(); $res_obj->roomsnum = count($this->room_parties); if (!empty($room_rplan['cost'])) { $res_obj->total = (float)$room_rplan['cost']; } $res_obj->adminnotes = $customer->adminnotes; $res_obj->lang = $customer->lang; $res_obj->country = $customer->country; if (!empty($room_rplan['taxes'])) { $res_obj->tot_taxes = (float)$room_rplan['taxes']; } if (!empty($room_rplan['city_taxes'])) { $res_obj->tot_city_taxes = (float)$room_rplan['city_taxes']; } $res_obj->phone = $customer->phone; $res_obj->type = 'inquiry'; // store record if (!$dbo->insertObject('#__vikbooking_orders', $res_obj, 'id')) { // could not store the booking record return 0; } // get the ID of the newly created reservation $res_id = $res_obj->id; // check if mandatory options should be assigned (not in case of suggestions) $room_options = null; if (!empty($room_rplan['idprice']) && isset($res_obj->tot_city_taxes)) { $mand_taxes = VikBooking::getMandatoryTaxesFees([$room_rplan['idroom']], $this->getPartyGuests('adults', 0), $res_obj->days); if (is_array($mand_taxes) && !empty($mand_taxes['options'])) { $room_options = implode(';', $mand_taxes['options']); } } // build room-reservation object $room_res_obj = new stdClass; $room_res_obj->idorder = $res_id; $room_res_obj->idroom = $room_rplan['idroom']; $room_res_obj->adults = $this->getPartyGuests('adults', 0); $room_res_obj->children = $this->getPartyGuests('children', 0); if (!empty($room_rplan['idprice'])) { $room_res_obj->idtar = $this->getTariffId($room_rplan['idroom'], $room_rplan['idprice'], $res_obj->days); } $room_res_obj->optionals = $room_options; $room_res_obj->t_first_name = $customer->name; $room_res_obj->t_last_name = $customer->lname; // store record if (!$dbo->insertObject('#__vikbooking_ordersrooms', $room_res_obj, 'id')) { // could not store the room-reservation record return $res_id; } // in case of suggestions for alternative room parties, parse the extra rooms $party_index = 1; foreach ($extra_rooms as $extra_room) { if (!is_array($extra_room) || empty($extra_room['idroom'])) { continue; } // build additional room-reservation object $room_res_obj = new stdClass; $room_res_obj->idorder = $res_id; $room_res_obj->idroom = $extra_room['idroom']; $room_res_obj->adults = $this->getPartyGuests('adults', $party_index); $room_res_obj->children = $this->getPartyGuests('children', $party_index); // store record $dbo->insertObject('#__vikbooking_ordersrooms', $room_res_obj, 'id'); // increase room party index $party_index++; } // return the newly created reservation ID return $res_id; } /** * Attempts to get the tariff ID for the given room, rate plan and nights. * * @param int $room_id the ID of the VBO room. * @param int $rplan_id the rate plan ID in VBO. * @param int $nights the number of nights of stay. * * @return int|null the tariff ID or null. */ public function getTariffId($room_id, $rplan_id, $nights) { if (empty($room_id) || empty($rplan_id) || $nights < 1) { return null; } $dbo = JFactory::getDbo(); $q = "SELECT `id` FROM `#__vikbooking_dispcost` WHERE `idroom`={$room_id} AND `days`={$nights} AND `idprice`={$rplan_id}"; $dbo->setQuery($q, 0, 1); $res = $dbo->loadResult(); if ($res) { return (int)$res; } return null; } /** * Grabs the details of a given booking id. * * @param int $bid the booking ID to look for. * * @return array empty array or booking record details. */ public function getBookingDetails($bid) { $bid = (int)$bid; $dbo = JFactory::getDbo(); $q = "SELECT `o`.*, `co`.`idcustomer`, CONCAT_WS(' ', `c`.`first_name`, `c`.`last_name`) AS `customer_fullname`, `c`.`country` AS `customer_country`, `c`.`pic` FROM `#__vikbooking_orders` AS `o` LEFT JOIN `#__vikbooking_customers_orders` AS `co` ON `co`.`idorder`=`o`.`id` LEFT JOIN `#__vikbooking_customers` AS `c` ON `c`.`id`=`co`.`idcustomer` WHERE `o`.`id`={$bid}"; $dbo->setQuery($q, 0, 1); $row = $dbo->loadAssoc(); if ($row) { return $row; } return []; } /** * Validates if the room allows the given number of nights of stay by checking if a * tariff is defined for the given length of stay. Useful for particular rate tables. * * @param int $room_id the ID of the VBO room. * @param int $nights the number of nights of stay. * * @return bool true if a tariff is found between min and max nights. * * @since 1.16.3 (J) - 1.6.3 (WP) */ public function roomNightsAllowed($room_id, $nights) { if (!isset($this->min_max_los_tariffs_map[$room_id])) { $dbo = JFactory::getDbo(); $q = $dbo->getQuery(true) ->select('MIN(' . $dbo->qn('t.days') . ') AS ' . $dbo->qn('min_nights')) ->select('MAX(' . $dbo->qn('t.days') . ') AS ' . $dbo->qn('max_nights')) ->from($dbo->qn('#__vikbooking_dispcost', 't')) ->where($dbo->qn('t.idroom') . ' = ' . (int)$room_id); $dbo->setQuery($q, 0, 1); $tariffs = $dbo->loadObject(); if (!$tariffs || !$tariffs->min_nights || !$tariffs->max_nights) { return false; } // set values $this->min_max_los_tariffs_map[$room_id] = [$tariffs->min_nights, $tariffs->max_nights]; } // check if the number of nights of stay is within the range of tariffs los map return ($nights >= min($this->min_max_los_tariffs_map[$room_id]) && $nights <= max($this->min_max_los_tariffs_map[$room_id])); } /** * Sets the stay dates, check-in and check-out date timestamps. * * @param string $from check-in date string in Y-m-d or VBO format. * @param string $to check-out date string in Y-m-d or VBO format. * * @return self */ public function setStayDates($from, $to) { if (empty($from) || empty($to)) { return $this; } $checkinh = 0; $checkinm = 0; $checkouth = 0; $checkoutm = 0; $timeopst = VikBooking::getTimeOpenStore(); if (is_array($timeopst)) { if ($timeopst[0] < $timeopst[1]) { // check-in not allowed on a day where there is already a check out (no arrivals/depatures on the same day) $this->inonout_allowed = false; } $opent = VikBooking::getHoursMinutes($timeopst[0]); $closet = VikBooking::getHoursMinutes($timeopst[1]); $checkinh = $opent[0]; $checkinm = $opent[1]; $checkouth = $closet[0]; $checkoutm = $closet[1]; } $from_ts = VikBooking::getDateTimestamp($from, $checkinh, $checkinm); $to_ts = VikBooking::getDateTimestamp($to, $checkouth, $checkoutm); // set stay dates and timestamps $this->stay_dates = [date('Y-m-d', $from_ts), date('Y-m-d', $to_ts)]; $this->stay_ts = [$from_ts, $to_ts]; return $this; } /** * Returns the current stay dates or timestamps. * * @param bool $ts whether to get the date timestamps. * * @return array the current stay dates or timestamps. */ public function getStayDates($ts = false) { return $ts ? $this->stay_ts : $this->stay_dates; } /** * Sets a room party with adults and children, by optionally replacing the others. * * @param int $adults the number of adults for this room party. * @param int $children the number of children for this room party. * @param bool $replace if true, any previously set room party will be replaced. * * @return self */ public function setRoomParty($adults, $children = 0, $replace = false) { $room_party = [ 'adults' => $adults, 'children' => $children, ]; if ($replace) { $this->room_parties = [$room_party]; } else { array_push($this->room_parties, $room_party); } return $this; } /** * Returns the current room parties array. * * @return array the current room parties. */ public function getRoomParties() { return $this->room_parties; } /** * Sets and returns the flag to ignore the restrictions. * * @param bool $set the boolean status to set. * * @return bool the current ignore status. */ public function ignoreRestrictions($set = null) { if (is_bool($set)) { $this->ignore_restrictions = $set; } return $this->ignore_restrictions; } /** * Sets and returns the flag to ignore the rooms availability. * * @param bool $set the boolean status to set. * * @return bool the current ignore status. * * @since 1.16.10 (J) - 1.6.10 (WP) */ public function ignoreAvailability($set = null) { if (is_bool($set)) { $this->ignore_availability = $set; } return $this->ignore_availability; } /** * Takes the first payment ID "string", if available. * * @return string|null */ protected function getDefaultPaymentId() { $dbo = JFactory::getDbo(); $q = "SELECT `id`, `name` FROM `#__vikbooking_gpayments` WHERE `published`=1 ORDER BY `ordering` ASC"; $dbo->setQuery($q, 0, 1); $data = $dbo->loadAssoc(); if ($data) { return $data['id'] . '=' . $data['name']; } return null; } /** * Returns the current nights/transfers ratio for split stays. * * @return int the nights transfers ratio for the percent calculation. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function getNightsTransfersRatio() { return $this->nights_transfers_ratio; } /** * Returns the default nights/transfers ratio for split stays. * * @return int the nights transfers ratio defined in the configuration. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function getDefaultNightsTransfersRatio() { $config = VBOFactory::getConfig(); return (int)$config->get('split_stay_ratio', 50); } /** * Sets the nights/transfers ratio for split stays. Use it to start applying limits. * * @param mixed $ratio integer value, or the default config setting will be applied. * * @return self * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function setNightsTransfersRatio($ratio = null) { if (is_int($ratio)) { $this->nights_transfers_ratio = $ratio; } else { $this->nights_transfers_ratio = $this->getDefaultNightsTransfersRatio(); } return $this; } /** * Tells whether we need to behave for the front-end booking process. * * @return bool true if we are in the front-end booking process or false. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function isFrontBooking() { return (bool)$this->is_front_booking; } /** * Toggles the flag to behave for the front-end booking process. * * @return self * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function setIsFrontBooking($is_front = true) { $this->is_front_booking = (bool)$is_front; return $this; } /** * This helper method aims to collect the stay dates of each room in * a booking with split stay. Records will be loaded by ID ascending, * so in the same exact way as the rooms get stored. For this reason, * it is then possible to match the stay dates of a room by array-key, * even if bookings with split stay should always have different room IDs. * * @param int $bid the website reservation ID. * * @return array the list of busy records with stay dates information. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function loadSplitStayBusyRecords($bid) { $dbo = JFactory::getDbo(); $bid = (int)$bid; /** * It is fundamental to keep the ID column as the busy record ID * to allow the room switching in case of booking modification. */ $q = "SELECT `ob`.`idorder`, `b`.`id`, `b`.`idroom`, `b`.`checkin`, `b`.`checkout`, `b`.`sharedcal` FROM `#__vikbooking_ordersbusy` AS `ob` LEFT JOIN `#__vikbooking_busy` AS `b` ON `ob`.`idbusy`=`b`.`id` WHERE `ob`.`idorder`={$bid} ORDER BY `b`.`sharedcal` ASC, `b`.`id` ASC;"; $dbo->setQuery($q); $records = $dbo->loadAssocList(); if (!$records) { // the booking may no longer exist, or maybe it was cancelled return []; } return $records; } /** * Returns a list of the tax rate records. * * @return array the list of tax rates. * * @since 1.16.0 (J) - 1.6.0 (WP) */ public function getTaxRates() { $dbo = JFactory::getDbo(); $dbo->setQuery("SELECT * FROM `#__vikbooking_iva`;"); return $dbo->loadAssocList(); } /** * Sets the IDs of the rooms to filter or use. * * @param mixed $room_ids the list of room IDs to filter or use, or int room ID. * * @return self */ public function setRoomIds($room_ids = []) { if (is_scalar($room_ids)) { // single room ID integer $room_ids = [$room_ids]; } $this->room_ids = $room_ids; return $this; } /** * Returns the current room ids to filter or use. * * @return array the current room ids. */ public function getRoomIds() { return $this->room_ids; } /** * In case of no availability, overrides the default number of days to check * prior and after the originally requested check-in and check-out dates. * * @param int $days the total number of days to use back and forth. * * @return self */ public function setBackForthDays($days) { if (is_int($days) && $days >= 0) { $this->back_and_forth = $days; } return $this; } /** * Returns the current number of back and forth days. * * @return int the current number of days. */ public function getBackForthDays() { return $this->back_and_forth; } /** * Sets warning messages by concatenating the existing ones. * * @param string $str * * @return self */ protected function setWarning($str) { $this->warning .= $str . "\n"; return $this; } /** * Gets the current warning string. * * @return string */ public function getWarning() { return rtrim($this->warning, "\n"); } /** * Sets errors by concatenating the existing ones. * * @param string $str * * @return self */ protected function setError($str) { $this->error .= $str . "\n"; return $this; } /** * Gets the current error string. * * @return string */ public function getError() { return rtrim($this->error, "\n"); } /** * Gets the current error code. * * @return int */ public function getErrorCode() { return $this->errorCode; } }