PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
1.8.16 1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 All 37 releases
← All changes | admin/helpers/src/model/pricing.php +1760 -1381 1.7.2 → 1.8.16 View file →
@@ -13,1587 +13,1966 @@
13 13
14 14 /**
15 15 * VikBooking model pricing.
16 16 *
17 - * @since 1.16.10 (J) - 1.6.10 (WP)
17 + * @since 1.16.10 (J) - 1.6.10 (WP)
18 18 */
19 19 class VBOModelPricing extends JObject
20 20 {
21 - /** @var array */
22 - protected $room_rate_plans = [];
21 + /** @var array */
22 + protected $room_rate_plans = [];
23 23
24 - /** @var array */
25 - protected $channels_updated_list = [];
24 + /** @var array */
25 + protected $channels_updated_list = [];
26 26
27 - /** @var array */
28 - protected $channel_warnings = [];
27 + /** @var array */
28 + protected $channel_warnings = [];
29 29
30 - /** @var array */
31 - protected $channel_errors = [];
30 + /** @var array */
31 + protected $channel_errors = [];
32 32
33 - /** @var array */
34 - protected $cached_restrictions = [];
33 + /** @var array */
34 + protected $cached_restrictions = [];
35 35
36 - /**
37 - * Proxy for immediately accessing the object and bind data.
38 - *
39 - * @param array|object $data optional data to bind.
40 - * @param boolean $anew true for forcing a new instance.
41 - *
42 - * @return self
43 - */
44 - public static function getInstance($data = [])
45 - {
46 - return new static($data);
47 - }
36 + /** @var array */
37 + protected static $cached_all_rate_plans = [];
48 38
49 - /**
50 - * Returns the information about rates and restrictions for
51 - * a given room in a range of dates.
52 - *
53 - * @param array $options Options for getting room rates.
54 - *
55 - * @return array
56 - *
57 - * @throws Exception
58 - */
59 - public function getRoomRates(array $options)
60 - {
61 - $dbo = JFactory::getDbo();
39 + /** @var array */
40 + protected static $cached_base_rates = [];
62 41
63 - // gather options
64 - $from_date = (string) ($options['from_date'] ?? '');
65 - $to_date = (string) ($options['to_date'] ?? '');
66 - $id_room = (int) ($options['id_room'] ?? 0);
67 - $id_price = (int) ($options['id_price'] ?? 0);
68 - $all_rplans = (bool) ($options['all_rplans'] ?? false);
69 - $restrictions = (bool) ($options['restrictions'] ?? true);
42 + /**
43 + * Proxy for immediately accessing the object and bind data.
44 + *
45 + * @param array|object $data optional data to bind.
46 + * @param boolean $anew true for forcing a new instance.
47 + *
48 + * @return self
49 + */
50 + public static function getInstance($data = [])
51 + {
52 + return new static($data);
53 + }
70 54
71 - if (!$from_date || !$to_date) {
72 - // must be in Y-m-d format
73 - throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
74 - }
55 + /**
56 + * Returns the information about rates and restrictions for
57 + * a given room in a range of dates.
58 + *
59 + * @param array $options Options for getting room rates.
60 + *
61 + * @return array
62 + *
63 + * @throws Exception
64 + *
65 + * @since 1.18.0 (J) - 1.8.0 (WP) added support for multiple room IDs and faster processing.
66 + */
67 + public function getRoomRates(array $options)
68 + {
69 + // gather options
70 + $from_date = (string) ($options['from_date'] ?? '');
71 + $to_date = (string) ($options['to_date'] ?? '');
72 + $id_room = (int) ($options['id_room'] ?? 0);
73 + $id_price = (int) ($options['id_price'] ?? 0);
74 + $all_rplans = (bool) ($options['all_rplans'] ?? false);
75 + $restrictions = (bool) ($options['restrictions'] ?? true);
76 + $id_rooms = array_values(array_filter(array_map('intval', (array) ($options['id_rooms'] ?? []))));
75 77
76 - if (JFactory::getDate($to_date) < JFactory::getDate($from_date)) {
77 - // invalid dates
78 - throw new InvalidArgumentException('Invalid dates received.', 400);
79 - }
78 + if (!$from_date || !$to_date) {
79 + // must be in Y-m-d format
80 + throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
81 + }
80 82
81 - if (!$id_room) {
82 - throw new InvalidArgumentException('Room record ID is mandatory.', 400);
83 - }
83 + if (JFactory::getDate($to_date) < JFactory::getDate($from_date)) {
84 + // invalid dates
85 + throw new InvalidArgumentException('Invalid dates received.', 400);
86 + }
84 87
85 - /**
86 - * Disable season records caching because new rates will have to be re-calculated
87 - * for the response by checking the same exact dates.
88 - */
89 - VikBooking::setSeasonsCache(false);
88 + if (!$id_room && !$id_rooms) {
89 + throw new InvalidArgumentException('Room record ID is mandatory.', 400);
90 + }
90 91
91 - // load check-in and check-out times
92 - list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance()->loadCheckinOutTimes();
92 + // collect the list of room IDs involved
93 + $listing_ids = array_values(array_filter(array_merge([$id_room], $id_rooms)));
93 94
94 - // date format
95 - $vbo_df = VikBooking::getDateFormat();
96 - $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
95 + // load check-in and check-out times
96 + list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance()->loadCheckinOutTimes();
97 97
98 - if (!$all_rplans) {
99 - // get room rates either from the provided rate plan ID or by fetching the main one
100 - if (!$id_price) {
101 - // load all rate plans
102 - $all_rate_plans = VikBooking::getAvailabilityInstance(true)->loadRatePlans();
98 + // date format
99 + $vbo_df = VikBooking::getDateFormat();
100 + $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
103 101
104 - // use the first (main) rate plan ID after the automatic sorting
105 - foreach ($all_rate_plans as $all_rate_plan) {
106 - $id_price = $all_rate_plan['id'];
107 - break;
108 - }
109 - }
102 + // get room rates either from the provided rate plan ID or by fetching the main one
103 + if (!$all_rplans && !$id_price) {
104 + // load all rate plans
105 + $all_rate_plans = self::$cached_all_rate_plans ?: VikBooking::getAvailabilityInstance(true)->loadRatePlans();
106 + self::$cached_all_rate_plans = $all_rate_plans;
110 107
111 - if (!$id_price) {
112 - throw new Exception('No rate plans configured.', 500);
113 - }
108 + // use the first (main) rate plan ID after the automatic sorting
109 + foreach ($all_rate_plans as $all_rate_plan) {
110 + $id_price = $all_rate_plan['id'];
111 + break;
112 + }
114 113
115 - // read the rates for the lowest number of nights for a specific rate plan ID
116 - $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
117 - FROM `#__vikbooking_dispcost` AS `r`
118 - INNER JOIN (
119 - SELECT MIN(`days`) AS `min_days`
120 - FROM `#__vikbooking_dispcost`
121 - WHERE `idroom`=" . $id_room . " AND `idprice`=" . $id_price . "
122 - GROUP BY `idroom`
123 - ) AS `r2` ON `r`.`days`=`r2`.`min_days`
124 - LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice` AND `p`.`id`=" . $id_price . "
125 - WHERE `r`.`idroom`=" . $id_room . " AND `r`.`idprice`=" . $id_price . "
126 - GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
127 - ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
128 - } else {
129 - // get room rates from all rate plans configured for the given room
130 - // read the rates for the lowest number of nights for all rate plans
131 - $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
132 - FROM `#__vikbooking_dispcost` AS `r`
133 - INNER JOIN (
134 - SELECT MIN(`days`) AS `min_days`
135 - FROM `#__vikbooking_dispcost`
136 - WHERE `idroom`=" . $id_room . "
137 - GROUP BY `idroom`
138 - ) AS `r2` ON `r`.`days`=`r2`.`min_days`
139 - LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice`
140 - WHERE `r`.`idroom`=" . $id_room . "
141 - GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
142 - ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
143 - }
114 + if (!$id_price) {
115 + throw new Exception('No rate plans configured.', 500);
116 + }
117 + }
144 118
145 - // load room rates from db
146 - $dbo->setQuery($q);
147 - $roomrates = $dbo->loadAssocList();
119 + // pool of room rates
120 + $pool_roomrates = [];
148 121
149 - foreach ($roomrates as $rrk => $rrv) {
150 - $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
151 - $roomrates[$rrk]['days'] = 1;
152 - }
122 + // iterate over all listing IDs to obtain the respective room rates
123 + foreach ($listing_ids as $listing_id) {
124 + if (!$all_rplans) {
125 + // read the rates for the lowest number of nights for a specific rate plan ID
126 + $roomrates = $this->getBaseRoomRates($listing_id, $id_price);
127 + } else {
128 + // get room rates from all rate plans configured for the given room
129 + // read the rates for the lowest number of nights for all rate plans
130 + $roomrates = $this->getBaseRoomRates($listing_id);
131 + }
153 132
154 - if (!$roomrates) {
155 - // terminate the process by throwing an error
156 - throw new UnexpectedValueException('No rates found for the given room ID.', 400);
157 - }
133 + if ($roomrates) {
134 + // set room rates to the pool
135 + $pool_roomrates[$listing_id] = $roomrates;
136 + }
137 + }
158 138
159 - // fetch all restrictions, if requested
160 - $all_restrictions = $restrictions ? VikBooking::loadRestrictions(true, [$id_room]) : [];
139 + if (!$pool_roomrates) {
140 + // terminate the process by throwing an error
141 + throw new UnexpectedValueException('No rates found for the given listing ID(s).', 400);
142 + }
161 143
162 - // calculate global minimum stay
163 - $glob_minlos = VikBooking::getDefaultNightsCalendar();
164 - $glob_minlos = $glob_minlos < 1 ? 1 : $glob_minlos;
144 + // fetch all restrictions, if requested
145 + $all_restrictions = $restrictions ? VikBooking::loadRestrictions(true, $listing_ids) : [];
165 146
166 - // read current room rates
167 - $current_rates = [];
147 + // calculate global minimum stay
148 + $glob_minlos = VikBooking::getDefaultNightsCalendar();
149 + $glob_minlos = $glob_minlos < 1 ? 1 : $glob_minlos;
168 150
169 - // dates involved
170 - $start_ts = strtotime($from_date);
171 - $end_ts = strtotime($to_date);
151 + // dates involved
152 + $start_ts = strtotime($from_date);
153 + $end_ts = strtotime($to_date);
172 154
173 - /**
174 - * Preload seasonal records in favour of CPU usage, but against RAM usage.
175 - *
176 - * @since 1.17.2 (J) - 1.7.2 (WP)
177 - */
178 - $cached_seasons = VikBooking::getDateSeasonRecords($start_ts, ($end_ts + ($checkout_h * 3600)), [$id_room]);
155 + // read current room rates
156 + $current_rates_pool = [];
179 157
180 - // loop through all the requested range of dates
181 - $infostart = getdate($start_ts);
182 - while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
183 - // calculate timestamps
184 - $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
185 - $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
186 - $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
187 - $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
158 + /**
159 + * Preload seasonal records in favour of CPU usage, but against RAM usage.
160 + *
161 + * @since 1.17.2 (J) - 1.7.2 (WP)
162 + * @since 1.18.5 (J) - 1.8.5 (WP) added support for week-day seasons cache.
163 + */
164 + $cached_seasons = VikBooking::getDateSeasonRecords($start_ts, ($end_ts + ($checkout_h * 3600)), $listing_ids);
165 + $cached_wdayseasons = VikBooking::getWdaySeasonRecords();
188 166
189 - // calculate tariffs for this day
190 - $tars = VikBooking::applySeasonsRoom($roomrates, $today_tsin, $today_tsout, [], $cached_seasons);
167 + // loop through all the requested range of dates
168 + $infostart = getdate($start_ts);
169 + while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
170 + // calculate timestamps
171 + $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
172 + $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
173 + $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
174 + $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
191 175
192 - foreach ($tars as $index => $tar) {
193 - // apply rounding to 2 decimals at most
194 - $tars[$index]['cost'] = round($tar['cost'], 2);
176 + // current day key
177 + $day_key = date('Y-m-d', $infostart[0]);
195 178
196 - // set formatted cost
197 - $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
179 + if (count($pool_roomrates) > 1 && !$all_rplans) {
180 + // process all listings at once through the cached season records in case of
181 + // multiple listings and single rate plan to reduce the work load
182 + $listing_tars = VikBooking::applySeasonalPrices($pool_roomrates, $today_tsin, $today_tsout, $cached_seasons, $cached_wdayseasons);
198 183
199 - // calculate restrictions
200 - $tars[$index]['restrictions'] = [];
201 - if ($restrictions) {
202 - $restr = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $all_restrictions);
203 - if (!$restr) {
204 - $restr = ['minlos' => $glob_minlos];
205 - }
206 - // set day restrictions
207 - $tars[$index]['restrictions'] = $restr;
208 - }
209 - }
184 + // scan the tariff results
185 + foreach ($listing_tars as $listing_id => $tars) {
186 + // initialize listing rates, if needed
187 + if (!isset($current_rates_pool[$listing_id])) {
188 + $current_rates_pool[$listing_id] = [];
189 + }
210 190
211 - if (!$all_rplans) {
212 - // set rate for this day (single rate plan)
213 - $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
214 - } else {
215 - // set rates for this day (all rate plans)
216 - $current_rates[(date('Y-m-d', $infostart[0]))] = $tars;
217 - }
191 + // filter restrictions by the current listing
192 + $listing_restrictions = VikBooking::listingRestrictions($all_restrictions, [$listing_id]);
218 193
219 - // go to next day
220 - $infostart = getdate($tomorrow_ts);
221 - }
194 + foreach ($tars as $index => $tar) {
195 + // apply rounding to 2 decimals at most
196 + $tars[$index]['cost'] = round($tar['cost'], 2);
222 197
223 - // free memory up
224 - unset($cached_seasons);
198 + // set formatted cost
199 + $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
225 200
226 - // return the calculated rates
227 - return $current_rates;
228 - }
201 + // calculate restrictions
202 + $tars[$index]['restrictions'] = [];
203 + if ($restrictions) {
204 + $restr = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $listing_restrictions);
205 + if (!$restr) {
206 + $restr = ['minlos' => $glob_minlos];
207 + }
208 + // set day restrictions
209 + $tars[$index]['restrictions'] = $restr;
210 + }
211 + }
229 212
230 - /**
231 - * Applies new rates and/or restrictions to the given room and rate plan(s).
232 - * Changes are always applied to the website rates, and eventually also on the OTAs.
233 - *
234 - * @return array
235 - *
236 - * @throws Exception
237 - *
238 - * @since 1.17.1 (J) - 1.7.1 (WP) added support to CTA, CTD and Max LOS restrictions.
239 - */
240 - public function modifyRateRestrictions()
241 - {
242 - $dbo = JFactory::getDbo();
213 + // set rate for this day (single rate plan)
214 + $current_rates_pool[$listing_id][$day_key] = $tars[0];
215 + }
216 + } else {
217 + // iterate over all listing IDs involved
218 + foreach ($listing_ids as $listing_id) {
219 + if (!isset($pool_roomrates[$listing_id])) {
220 + continue;
221 + }
243 222
244 - // expected and supported properties binded
245 - $from_date = (string) $this->get('from_date', '');
246 - $to_date = (string) $this->get('to_date', '');
247 - $id_room = (int) $this->get('id_room', 0);
248 - $id_price = (int) $this->get('id_price', 0);
249 - $rplan_name = $this->get('rplan_name', '');
250 - $rate = (float) $this->get('rate', 0);
251 - $min_los = (int) $this->get('min_los', 0);
252 - $max_los = (int) $this->get('max_los', 0);
253 - $cta_wdays = (array) $this->get('cta_wdays', []);
254 - $ctd_wdays = (array) $this->get('ctd_wdays', []);
255 - $upd_otas = (bool) $this->get('update_otas', true);
256 - $close_rplan = (bool) $this->get('close_rate_plan', false);
257 - $merge_restr = (bool) $this->get('merge_restrictions', true);
258 - $ota_pricing = (array) $this->get('ota_pricing', []);
223 + // initialize listing rates, if needed
224 + if (!isset($current_rates_pool[$listing_id])) {
225 + $current_rates_pool[$listing_id] = [];
226 + }
259 227
260 - if (!$from_date || !$to_date) {
261 - // must be in Y-m-d format
262 - throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
263 - }
228 + // filter restrictions by the current listing
229 + $listing_restrictions = VikBooking::listingRestrictions($all_restrictions, [$listing_id]);
264 230
265 - if (!$id_room) {
266 - throw new InvalidArgumentException('Room record ID is mandatory.', 400);
267 - }
231 + // calculate listing tariffs for this day
232 + $tars = VikBooking::applySeasonsRoom($pool_roomrates[$listing_id], $today_tsin, $today_tsout, [], $cached_seasons, $cached_wdayseasons);
268 233
269 - /**
270 - * Disable season records caching because new rates will have to be re-calculated
271 - * for the response by checking the same exact dates.
272 - */
273 - VikBooking::setSeasonsCache(false);
234 + foreach ($tars as $index => $tar) {
235 + // apply rounding to 2 decimals at most
236 + $tars[$index]['cost'] = round($tar['cost'], 2);
274 237
275 - // load check-in and check-out times
276 - list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance([], true)->loadCheckinOutTimes();
238 + // set formatted cost
239 + $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
277 240
278 - // date format
279 - $vbo_df = VikBooking::getDateFormat();
280 - $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
241 + // calculate restrictions
242 + $tars[$index]['restrictions'] = [];
243 + if ($restrictions) {
244 + $restr = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $listing_restrictions);
245 + if (!$restr) {
246 + $restr = ['minlos' => $glob_minlos];
247 + }
248 + // set day restrictions
249 + $tars[$index]['restrictions'] = $restr;
250 + }
251 + }
281 252
282 - // access the availability helper
283 - $av_helper = VikBooking::getAvailabilityInstance(true);
253 + if (!$all_rplans) {
254 + // set rate for this day (single rate plan)
255 + $current_rates_pool[$listing_id][$day_key] = $tars[0];
256 + } else {
257 + // set rates for this day (all rate plans)
258 + $current_rates_pool[$listing_id][$day_key] = $tars;
259 + }
260 + }
261 + }
284 262
285 - if (!$id_price) {
286 - // load all rate plans
287 - $all_rate_plans = $av_helper->loadRatePlans();
263 + // go to next day
264 + $infostart = getdate($tomorrow_ts);
265 + }
288 266
289 - // use the first rate plan ID after the automatic sorting
290 - foreach ($all_rate_plans as $all_rate_plan) {
291 - $id_price = $all_rate_plan['id'];
292 - break;
293 - }
267 + // free memory up
268 + unset($cached_seasons, $cached_wdayseasons);
294 269
295 - if ($rplan_name) {
296 - // check if the given rate plan name is found
297 - $rplan_name = trim(preg_replace("/[^a-z]/i", ' ', $rplan_name));
298 - foreach ($all_rate_plans as $all_rate_plan) {
299 - $match_against = trim(preg_replace("/[^a-z]/i", ' ', $all_rate_plan['name']));
300 - if (stripos($match_against, $rplan_name) !== false || stripos($rplan_name, $match_against) !== false) {
301 - // use the matched rate plan instead
302 - $id_price = $all_rate_plan['id'];
303 - break;
304 - }
305 - }
306 - }
307 - }
270 + if ($id_room && isset($current_rates_pool[$id_room])) {
271 + // single listing room rates requested
272 + return $current_rates_pool[$id_room];
273 + }
308 274
309 - if (!$id_price) {
310 - throw new Exception('No rate plans configured.', 500);
311 - }
275 + // return the calculated rates for all listings
276 + return $current_rates_pool;
277 + }
312 278
313 - // load the eventually involved derived rate plans from the given rate ID
314 - $derived_rate_plans = $av_helper->getDerivedRatePlans($id_price);
279 + /**
280 + * Calculates and applies new rates (and restrictions) to the given room and rate plan(s).
281 + * Rather than immediately setting a fixed rate (exact rate), calculates the current room
282 + * rates for the given range of dates for either increasing or decreasing them.
283 + *
284 + * @return array
285 + *
286 + * @throws Exception
287 + *
288 + * @since 1.18.6 (J) - 1.8.6 (WP)
289 + */
290 + public function increaseDecreaseRoomRates()
291 + {
292 + // expected and supported properties binded
293 + $from_date = (string) $this->get('from_date', '');
294 + $to_date = (string) $this->get('to_date', '');
295 + $id_room = (int) $this->get('id_room', 0);
296 + $id_price = (int) $this->get('id_price', 0);
297 + $addsub_op = (int) $this->get('addsub_op', 0);
298 + $addsub_amount = (float) $this->get('addsub_amount', 0);
299 + $addsub_value = (int) $this->get('addsub_value', 0);
300 + $upd_otas = (bool) $this->get('update_otas', true);
301 + $async_rar = (bool) $this->get('async_rar', false);
315 302
316 - // build the list of rate plans involved by adding the requested one
317 - $rate_plans_pool = [
318 - $id_price => [
319 - 'id' => $id_price,
320 - // the main rate plan selected for the update is NEVER considered as derived, even if it actually was.
321 - 'is_derived' => 0,
322 - 'derived_data' => null,
323 - 'rate' => $rate,
324 - ],
325 - ];
303 + if (!$from_date || !$to_date) {
304 + // must be in Y-m-d format
305 + throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
306 + }
326 307
327 - foreach ($derived_rate_plans as $derived_rate_plan) {
328 - if (isset($rate_plans_pool[$derived_rate_plan['id']])) {
329 - // skip duplicate rate plan
330 - continue;
331 - }
308 + if (!$id_room) {
309 + throw new InvalidArgumentException('Room record ID is mandatory.', 400);
310 + }
332 311
333 - // calculate new rate for this derived rate plan
334 - $rplan_derived_rate = $rate;
335 - if (($derived_rate_plan['derived_data']['mode'] ?? 'discount') == 'discount') {
336 - // discount rate
337 - if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
338 - // percent value
339 - $rplan_derived_rate = $rplan_derived_rate * (100 - (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
340 - } else {
341 - // absolute value
342 - $rplan_derived_rate -= (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
343 - }
344 - } else {
345 - // increase rate
346 - if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
347 - // percent value
348 - $rplan_derived_rate = $rplan_derived_rate * (100 + (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
349 - } else {
350 - // absolute value
351 - $rplan_derived_rate += (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
352 - }
353 - }
312 + // get full command properties eligible for setting new rates
313 + $rate_command = $this->getProperties();
354 314
355 - if ($rplan_derived_rate < 0) {
356 - // negative rates are not allowed
357 - continue;
358 - }
315 + // unset command properties related to increase/decrease rates and get rid of initial dates
316 + unset(
317 + $rate_command['addsub_op'],
318 + $rate_command['addsub_amount'],
319 + $rate_command['addsub_value'],
320 + $rate_command['from_date'],
321 + $rate_command['to_date'],
322 + $rate_command['async_rar']
323 + );
359 324
360 - // make sure to apply rounding
361 - $rplan_derived_rate = round($rplan_derived_rate, 2);
325 + // fetch current room rates for the given dates by ignoring restrictions
326 + $room_rates = $this->getRoomRates([
327 + 'from_date' => $from_date,
328 + 'to_date' => $to_date,
329 + 'id_room' => $id_room,
330 + 'id_price' => $id_price,
331 + 'all_rplans' => false,
332 + 'restrictions' => false,
333 + ]);
362 334
363 - // push derived rate plan to the update pool
364 - $rate_plans_pool[$derived_rate_plan['id']] = [
365 - 'id' => $derived_rate_plan['id'],
366 - 'is_derived' => 1,
367 - 'derived_data' => $derived_rate_plan['derived_data'],
368 - 'rate' => $rplan_derived_rate,
369 - ];
370 - }
335 + // build raw list of rates data instructions per day
336 + $rates_daily_data = [];
371 337
372 - // the newly applied rates
373 - $newly_rates = [];
338 + // calculate the new rates to set for every inventory date
339 + foreach ($room_rates as $day_key => $tariff) {
340 + // get day room rate
341 + $currentRate = (float) ($tariff['cost'] ?? 0);
374 342
375 - // apply the pricing modification to all the involved rate plans
376 - foreach ($rate_plans_pool as $rplan_id => $rplan_info) {
377 - // set rate plan ID
378 - $now_id_price = $rplan_info['id'];
343 + // ensure the rate plan ID is set
344 + if (!$id_price && intval($tariff['idprice']) > 0) {
345 + $id_price = (int) $tariff['idprice'];
346 + }
379 347
380 - // set rate to apply to the current rate plan
381 - $rate = $rplan_info['rate'];
348 + // calculate the fixed (exact) new rate to set
349 + if ($addsub_op === 1) {
350 + // increase rate
351 + if ($addsub_value === 1) {
352 + // percent
353 + $currentRate += $currentRate * $addsub_amount / 100;
354 + } else {
355 + // fixed
356 + $currentRate += $addsub_amount;
357 + }
358 + } else {
359 + // decrease rate
360 + if ($addsub_value === 1) {
361 + // percent
362 + $currentRate -= $currentRate * $addsub_amount / 100;
363 + } else {
364 + // fixed
365 + $currentRate -= $addsub_amount;
366 + }
367 + }
382 368
383 - // read the rates for the lowest number of nights
384 - $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
385 - FROM `#__vikbooking_dispcost` AS `r`
386 - INNER JOIN (
387 - SELECT MIN(`days`) AS `min_days`
388 - FROM `#__vikbooking_dispcost`
389 - WHERE `idroom`=" . $id_room . " AND `idprice`=" . $now_id_price . "
390 - GROUP BY `idroom`
391 - ) AS `r2` ON `r`.`days`=`r2`.`min_days`
392 - LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice` AND `p`.`id`=" . $now_id_price . "
393 - WHERE `r`.`idroom`=" . $id_room . " AND `r`.`idprice`=" . $now_id_price . "
394 - GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
395 - ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
369 + if ($currentRate < 0) {
370 + throw new Exception(sprintf('Invalid (negative) rate calculated for %s.', $day_key), 400);
371 + }
396 372
397 - $dbo->setQuery($q);
398 - $roomrates = $dbo->loadAssocList();
373 + // push rate data instructions with date and calculated rate
374 + $rates_daily_data[] = array_merge($rate_command, [
375 + 'rate' => $currentRate,
376 + 'date' => $day_key,
377 + 'idprice' => $id_price,
378 + ]);
379 + }
399 380
400 - foreach ($roomrates as $rrk => $rrv) {
401 - $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
402 - $roomrates[$rrk]['days'] = 1;
403 - }
381 + // normalize single and contiguous dates into a range
382 + $rates_data_range = [];
383 + $rates_data_sdate = null;
384 + $rates_data_edate = null;
385 + $rates_data_sign = null;
386 + foreach ($rates_daily_data as $rate_data) {
387 + // clone rate data command by deleting the date key
388 + $rate_data_command = $rate_data;
389 + unset($rate_data_command['date']);
404 390
405 - if (!$roomrates) {
406 - if ($rplan_info['is_derived']) {
407 - // this is not the main rate plan we are updating
408 - continue;
409 - }
391 + // calculate rate data signature
392 + $current_data_sign = serialize($rate_data_command);
410 393
411 - // terminate the process by throwing an error
412 - throw new UnexpectedValueException('No rates found for the given room ID.', 400);
413 - }
394 + if (!$rates_data_sdate) {
395 + // set range start date
396 + $rates_data_sdate = $rate_data['date'];
397 + // set range end date
398 + $rates_data_edate = $rate_data['date'];
399 + // set rate data signature
400 + $rates_data_sign = $current_data_sign;
401 + // go to next rate data object
402 + continue;
403 + }
414 404
415 - // turn the rates list into a single-level array
416 - $roomrates = $roomrates[0];
405 + if ($current_data_sign != $rates_data_sign || $rate_data['date'] != date('Y-m-d', strtotime('+1 day', strtotime($rates_data_edate)))) {
406 + // push previous rate-data object
407 + $rates_data_range[] = array_merge([
408 + 'start' => $rates_data_sdate,
409 + 'end' => $rates_data_edate,
410 + ], unserialize($rates_data_sign));
411 + // set range start date
412 + $rates_data_sdate = $rate_data['date'];
413 + // set range end date
414 + $rates_data_edate = $rate_data['date'];
415 + // set rate data signature
416 + $rates_data_sign = $current_data_sign;
417 + } else {
418 + // contiguous date with equal rate-data information found
419 + $rates_data_edate = $rate_data['date'];
420 + }
421 + }
417 422
418 - // set rate plan name
419 - $rate_plan_name = $roomrates['name'];
423 + if ($rates_data_sdate && $rates_data_edate) {
424 + // push last rate-data object
425 + $rates_data_range[] = array_merge([
426 + 'start' => $rates_data_sdate,
427 + 'end' => $rates_data_edate,
428 + ], unserialize($rates_data_sign));
429 + }
420 430
421 - // dates involved
422 - $start_ts = strtotime($from_date);
423 - $end_ts = strtotime($to_date);
431 + // set the sorted and grouped rate-data objects
432 + $rates_data = $rates_data_range;
433 + unset($rates_data_range);
424 434
425 - /**
426 - * Preload seasonal records in favour of CPU usage, but against RAM usage.
427 - *
428 - * @since 1.17.2 (J) - 1.7.2 (WP)
429 - */
430 - $cached_seasons = VikBooking::getDateSeasonRecords($start_ts, ($end_ts + ($checkout_h * 3600)), [$id_room]);
435 + // count the number of requests to perform to determine if
436 + // OTAs should be updated in background, asynchronously
437 + $async_upd_otas = $upd_otas && (count($rates_data) > 10 || $async_rar);
431 438
432 - // read current room rates
433 - $current_rates = [];
434 - $infostart = getdate($start_ts);
435 - while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
436 - $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
437 - $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
438 - $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
439 + // gather the results list
440 + $new_rates_list = [];
439 441
440 - // apply seasonal rates by injecting the cached seasonal records
441 - $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons);
442 + // iterate all rate-data request objects
443 + foreach ($rates_data as $rate_data) {
444 + // normalize properties from current rate data
445 + $rate_data['from_date'] = $rate_data['start'];
446 + $rate_data['to_date'] = $rate_data['end'];
447 + unset($rate_data['start'], $rate_data['end']);
442 448
443 - // apply rounding to 2 decimals at most
444 - $tars[0]['cost'] = round($tars[0]['cost'], 2);
449 + // check if OTAs should be updated
450 + $rate_data['update_otas'] = !$async_upd_otas && $upd_otas;
445 451
446 - $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
452 + // bind rate data properties
453 + $this->setProperties($rate_data);
447 454
448 - $infostart = getdate($tomorrow_ts);
449 - }
455 + // apply the new rate/restrictions
456 + $new_rates = $this->modifyRateRestrictions();
450 457
451 - if (!$current_rates) {
452 - if ($rplan_info['is_derived']) {
453 - // this is not the main rate plan we are updating
454 - continue;
455 - }
458 + if ($new_rates['vcm'] ?? null) {
459 + // merge with previous channel manager results, if any
460 + $new_rates_list['vcm'] = array_merge(($new_rates_list['vcm'] ?? []), $new_rates['vcm']);
456 461
457 - // terminate the process by throwing an error
458 - throw new UnexpectedValueException('No seasonal rates found for the given room ID.', 400);
459 - }
462 + // unset channel manager results property
463 + unset($new_rates['vcm']);
464 + }
460 465
461 - $all_days = array_keys($current_rates);
462 - $season_intervals = [];
463 - $firstind = 0;
464 - $firstdaycost = $current_rates[$all_days[0]]['cost'];
465 - $nextdaycost = false;
466 - for ($i = 1; $i < count($all_days); $i++) {
467 - $ind = $all_days[$i];
468 - $nextdaycost = $current_rates[$ind]['cost'];
469 - if ($firstdaycost != $nextdaycost) {
470 - $interval = [
471 - 'from' => $all_days[$firstind],
472 - 'to' => $all_days[($i - 1)],
473 - 'cost' => $firstdaycost
474 - ];
475 - $season_intervals[] = $interval;
476 - $firstdaycost = $nextdaycost;
477 - $firstind = $i;
478 - }
479 - }
480 - if ($nextdaycost === false) {
481 - $interval = [
482 - 'from' => $all_days[$firstind],
483 - 'to' => $all_days[$firstind],
484 - 'cost' => $firstdaycost
485 - ];
486 - $season_intervals[] = $interval;
487 - } elseif ($firstdaycost == $nextdaycost) {
488 - $interval = [
489 - 'from' => $all_days[$firstind],
490 - 'to' => $all_days[($i - 1)],
491 - 'cost' => $firstdaycost
492 - ];
493 - $season_intervals[] = $interval;
494 - }
495 - foreach ($season_intervals as $sik => $siv) {
496 - if ((float)$siv['cost'] == $rate) {
497 - unset($season_intervals[$sik]);
498 - }
499 - }
466 + // merge daily rates applied
467 + $new_rates_list = array_merge($new_rates_list, $new_rates);
468 + }
500 469
501 - if (!$season_intervals) {
502 - // do not raise this error if it was requested to set the restriction or to close a rate plan
503 - if ((!($min_los > 0) && !$close_rplan) || !$upd_otas) {
504 - if ($rplan_info['is_derived']) {
505 - // this is not the main rate plan we are updating
506 - continue;
507 - }
470 + // check whether OTAs should be updated asynchronously
471 + if ($async_upd_otas) {
472 + // trigger an automatic bulk action for uploading the rates just applied to the PMS
473 + VikChannelManager::autoBulkActions([
474 + 'from_date' => $from_date,
475 + 'to_date' => $to_date,
476 + 'forced_rooms' => [$id_room],
477 + 'update' => 'rates',
478 + ]);
479 + }
508 480
509 - // terminate the process by throwing an error
510 - throw new RuntimeException('No rates modification needed with the given parameters.', 500);
511 - }
512 - }
481 + // returned the merged list of rate results
482 + return $new_rates_list;
483 + }
513 484
514 - if ($rate > 0) {
515 - // make sure to set a cost greater than zero to avoid errors
516 - foreach ($season_intervals as $sik => $siv) {
517 - $first = strtotime($siv['from']);
518 - $second = strtotime($siv['to']);
485 + /**
486 + * Applies new rates and/or restrictions to the given room and rate plan(s).
487 + * Changes are always applied to the website rates, and eventually also on the OTAs.
488 + *
489 + * @return array
490 + *
491 + * @throws Exception
492 + *
493 + * @since 1.17.1 (J) - 1.7.1 (WP) added support to CTA, CTD and Max LOS restrictions.
494 + */
495 + public function modifyRateRestrictions()
496 + {
497 + $dbo = JFactory::getDbo();
519 498
520 - if ($second > 0 && $second == $first) {
521 - $second += 86399;
522 - }
499 + // expected and supported properties binded
500 + $from_date = (string) $this->get('from_date', '');
501 + $to_date = (string) $this->get('to_date', '');
502 + $id_room = (int) $this->get('id_room', 0);
503 + $id_price = (int) $this->get('id_price', 0);
504 + $rplan_name = $this->get('rplan_name', '');
505 + $rate = (float) $this->get('rate', 0);
506 + $min_los = (int) $this->get('min_los', 0);
507 + $max_los = (int) $this->get('max_los', 0);
508 + $cta_wdays = (array) $this->get('cta_wdays', []);
509 + $ctd_wdays = (array) $this->get('ctd_wdays', []);
510 + $upd_otas = (bool) $this->get('update_otas', true);
511 + $close_rplan = (bool) $this->get('close_rate_plan', false);
512 + $merge_restr = (bool) $this->get('merge_restrictions', true);
513 + $ota_pricing = (array) $this->get('ota_pricing', []);
514 + $skip_derived = (bool) $this->get('skip_derived', false);
515 + $use_cache = (bool) $this->get('use_cache', false);
523 516
524 - if (!($second > $first)) {
525 - unset($season_intervals[$sik]);
526 - continue;
527 - }
517 + if (!$from_date || !$to_date) {
518 + // must be in Y-m-d format
519 + throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
520 + }
528 521
529 - $baseone = getdate($first);
530 - $basets = mktime(0, 0, 0, 1, 1, $baseone['year']);
531 - $sfrom = $baseone[0] - $basets;
532 - $basetwo = getdate($second);
533 - $basets = mktime(0, 0, 0, 1, 1, $basetwo['year']);
534 - $sto = $basetwo[0] - $basets;
522 + if (!$id_room) {
523 + throw new InvalidArgumentException('Room record ID is mandatory.', 400);
524 + }
535 525
536 - // check leap year
537 - if ($baseone['year'] % 4 == 0 && ($baseone['year'] % 100 != 0 || $baseone['year'] % 400 == 0)) {
538 - $leapts = mktime(0, 0, 0, 2, 29, $baseone['year']);
539 - if ($baseone[0] > $leapts) {
540 - $sfrom -= 86400;
541 - /**
542 - * To avoid issue with leap years and dates near Feb 29th, we only reduce the seconds if these were reduced
543 - * for the from-date of the seasons. Doing it just for the to-date in 2019 for 2020 (leap) produced invalid results.
544 - */
545 - if ($basetwo['year'] % 4 == 0 && ($basetwo['year'] % 100 != 0 || $basetwo['year'] % 400 == 0)) {
546 - $leapts = mktime(0, 0, 0, 2, 29, $basetwo['year']);
547 - if ($basetwo[0] > $leapts) {
548 - $sto -= date('d-m', $baseone[0]) != '31-12' && date('d-m', $basetwo[0]) == '31-12' ? 1 : 86400;
549 - }
550 - }
551 - }
552 - }
526 + /**
527 + * Disable season records caching because new rates will have to be re-calculated
528 + * for the response by checking the same exact dates.
529 + */
530 + VikBooking::setSeasonsCache(false);
553 531
554 - $tieyear = $baseone['year'];
555 - $season_type = (float)$siv['cost'] > $rate ? "2" : "1";
556 - $season_diffcost = $season_type == "1" ? ($rate - (float)$siv['cost']) : ((float)$siv['cost'] - $rate);
557 - $roomstr = "-" . $id_room . "-,";
558 - $season_name = date('Y-m-d H:i').' - '.substr($baseone['month'], 0, 3).' '.$baseone['mday'].($siv['from'] != $siv['to'] ? '/'.($baseone['month'] != $basetwo['month'] ? substr($basetwo['month'], 0, 3).' ' : '').$basetwo['mday'] : '');
559 - $pricestr = "-" . $now_id_price . "-,";
532 + // load check-in and check-out times
533 + list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance([], true)->loadCheckinOutTimes();
560 534
561 - // build and store season record
562 - $season_record = new stdClass;
563 - $season_record->type = $season_type == "1" ? 1 : 2;
564 - $season_record->from = $sfrom;
565 - $season_record->to = $sto;
566 - $season_record->diffcost = $season_diffcost;
567 - $season_record->idrooms = $roomstr;
568 - $season_record->spname = $season_name;
569 - $season_record->wdays = '';
570 - $season_record->checkinincl = 0;
571 - $season_record->val_pcent = 1;
572 - $season_record->losoverride = '';
573 - $season_record->year = $tieyear;
574 - $season_record->idprices = $pricestr;
535 + // date format
536 + $vbo_df = VikBooking::getDateFormat();
537 + $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
575 538
576 - $dbo->insertObject('#__vikbooking_seasons', $season_record, 'id');
539 + // access the availability helper
540 + $av_helper = VikBooking::getAvailabilityInstance(true);
577 541
578 - /**
579 - * Push the newly created season record to the list of preloaded records.
580 - *
581 - * @since 1.17.2 (J) - 1.7.2 (WP)
582 - */
583 - $cached_seasons[] = (array) $season_record;
584 - }
585 - }
542 + // load all rate plans
543 + $all_rate_plans = self::$cached_all_rate_plans ?: $av_helper->loadRatePlans(true);
544 + self::$cached_all_rate_plans = $all_rate_plans;
586 545
587 - // calculate the involved dates
588 - $start_ts = strtotime($from_date);
589 - $end_ts = strtotime($to_date);
590 - $infostart = getdate($start_ts);
591 - $infoend = getdate($end_ts);
546 + if (!$id_price) {
547 + // use the first rate plan ID after the automatic sorting
548 + foreach ($all_rate_plans as $all_rate_plan) {
549 + $id_price = $all_rate_plan['id'];
550 + break;
551 + }
592 552
593 - /**
594 - * Restrictions can be set only if VCM is enabled because we use the Connector Class.
595 - * It is allowed to set just a restriction for the website without any rate modification.
596 - * OTAs instead would need a rate to be passed in order to eventually transmit the restrictions.
597 - */
598 - $current_minlos = 0;
599 - $current_maxlos = 0;
600 - $current_cta = [];
601 - $current_ctd = [];
602 - $split_ct_nodes = [];
603 - $vboConnector = null;
553 + if ($rplan_name) {
554 + // check if the given rate plan name is found
555 + $rplan_name = trim(preg_replace("/[^a-z]/i", ' ', $rplan_name));
556 + foreach ($all_rate_plans as $all_rate_plan) {
557 + $match_against = trim(preg_replace("/[^a-z]/i", ' ', $all_rate_plan['name']));
558 + if (stripos($match_against, $rplan_name) !== false || stripos($rplan_name, $match_against) !== false) {
559 + // use the matched rate plan instead
560 + $id_price = $all_rate_plan['id'];
561 + break;
562 + }
563 + }
564 + }
565 + }
604 566
605 - if (method_exists('VikChannelManager', 'getVikBookingConnectorInstance')) {
606 - // invoke the Connector for any update request
607 - $vboConnector = VikChannelManager::getVikBookingConnectorInstance();
608 - // set the caller to 'VBO' to reduce the sleep time between the requests
609 - $vboConnector->caller = 'VBO';
610 - } else {
611 - // make sure the OTA update flag is off
612 - $upd_otas = false;
613 - }
567 + if (!$id_price) {
568 + throw new Exception('No rate plans configured.', 500);
569 + }
614 570
615 - // minimum length of stay is always necessary for creating a restriction even with other modifiers
616 - if ($min_los > 0 && $vboConnector) {
617 - // set the end date to the last second
618 - $end_ts = mktime(23, 59, 59, $infoend['mon'], $infoend['mday'], $infoend['year']);
571 + // load the eventually involved derived rate plans from the given rate ID
572 + $derived_rate_plans = $skip_derived ? [] : $av_helper->getDerivedRatePlans($id_price, self::$cached_all_rate_plans);
619 573
620 - /**
621 - * Setting just a min los restriction may lift a previously defined CTA/CTD rule for the same dates.
622 - * If merging restrictions is not disabled, and if no other modifiers are set (max los, cta, ctd), the
623 - * system will automatically calculate the current modifiers in order to keep them on the OTAs. Such
624 - * calculated restriction modifiers will not need to be created on VikBooking, but just passed to the OTAs.
625 - * If conflicting restriction modifiers are detected, it is recommended to schedule an auto bulk action.
626 - *
627 - * @since 1.17.1 (J) - 1.7.1 (WP)
628 - */
629 - if ($merge_restr && !$max_los && !$cta_wdays && !$ctd_wdays) {
630 - // calculate the restriction modifiers beside the min los and get additional details
631 - list($calc_maxlos, $calc_cta, $calc_ctd, $split_ct_nodes, $conflicts) = $this->calculateRestrictionModifiers($start_ts, $end_ts, $id_room);
574 + // build the list of rate plans involved by adding the requested one
575 + $rate_plans_pool = [
576 + $id_price => [
577 + 'id' => $id_price,
578 + // the main rate plan selected for the update is NEVER considered as derived, even if it actually was.
579 + 'is_derived' => 0,
580 + 'derived_data' => null,
581 + 'rate' => $rate,
582 + ],
583 + ];
632 584
633 - if ($calc_maxlos) {
634 - // set calculated max los
635 - $max_los = $calc_maxlos;
636 - }
585 + foreach ($derived_rate_plans as $derived_rate_plan) {
586 + if (isset($rate_plans_pool[$derived_rate_plan['id']])) {
587 + // skip duplicate rate plan
588 + continue;
589 + }
637 590
638 - if ($calc_cta) {
639 - // set calculated cta week days
640 - $cta_wdays = $calc_cta;
641 - }
591 + // calculate new rate for this derived rate plan
592 + $rplan_derived_rate = $rate;
593 + if (($derived_rate_plan['derived_data']['mode'] ?? 'discount') == 'discount') {
594 + // discount rate
595 + if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
596 + // percent value
597 + $rplan_derived_rate = $rplan_derived_rate * (100 - (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
598 + } else {
599 + // absolute value
600 + $rplan_derived_rate -= (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
601 + }
602 + } else {
603 + // increase rate
604 + if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
605 + // percent value
606 + $rplan_derived_rate = $rplan_derived_rate * (100 + (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
607 + } else {
608 + // absolute value
609 + $rplan_derived_rate += (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
610 + }
611 + }
642 612
643 - if ($calc_ctd) {
644 - // set calculated ctd week days
645 - $ctd_wdays = $calc_ctd;
646 - }
647 - }
613 + if ($rplan_derived_rate < 0) {
614 + // negative rates are not allowed
615 + continue;
616 + }
648 617
649 - if (!$rplan_info['is_derived']) {
650 - // build the minimum stay restriction string, eventually inclusive of CTA/CTD rules
651 - $vbo_min_los_str = $min_los;
652 - if ($cta_wdays) {
653 - // append cta instructions
654 - $vbo_min_los_str .= 'CTA[' . implode(',', $cta_wdays) . ']';
655 - }
656 - if ($ctd_wdays) {
657 - // append ctd instructions
658 - $vbo_min_los_str .= 'CTD[' . implode(',', $ctd_wdays) . ']';
659 - }
618 + // make sure to apply rounding
619 + $rplan_derived_rate = round($rplan_derived_rate, 2);
660 620
661 - // create the restriction in VBO (only for the parent rate since it will be a room-level restriction)
662 - $restr_res = $vboConnector->createRestriction(date('Y-m-d H:i:s', $start_ts), date('Y-m-d H:i:s', $end_ts), [$id_room], [$vbo_min_los_str, $max_los]);
621 + // push derived rate plan to the update pool
622 + $rate_plans_pool[$derived_rate_plan['id']] = [
623 + 'id' => $derived_rate_plan['id'],
624 + 'is_derived' => 1,
625 + 'derived_data' => $derived_rate_plan['derived_data'],
626 + 'rate' => $rplan_derived_rate,
627 + ];
628 + }
663 629
664 - // update values for the response and the rest of the operations
665 - if ($restr_res) {
666 - $current_minlos = $min_los;
667 - $current_maxlos = $max_los;
668 - $current_cta = $cta_wdays;
669 - $current_ctd = $ctd_wdays;
670 - } else {
671 - $current_minlos = 'e4j.error.' . $vboConnector->getError();
672 - }
673 - } else {
674 - // always update the min/max stay information
675 - $current_minlos = $min_los;
676 - $current_maxlos = $max_los;
677 - $current_cta = $cta_wdays;
678 - $current_ctd = $ctd_wdays;
679 - }
680 - }
630 + // the newly applied rates
631 + $newly_rates = [];
681 632
682 - // check if all dates involved share the same price
683 - $common_rate = -1;
633 + // apply the pricing modification to all the involved rate plans
634 + foreach ($rate_plans_pool as $rplan_id => $rplan_info) {
635 + // set rate plan ID
636 + $now_id_price = $rplan_info['id'];
684 637
685 - // prepare output by re-calculating the new rates in real-time
686 - while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
687 - $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
688 - $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
689 - $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
638 + // set rate to apply to the current rate plan
639 + $rate = $rplan_info['rate'];
690 640
691 - // apply seasonal rates by injecting the cached seasonal records
692 - $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons);
641 + // read the room rates for the lowest number of nights
642 + $roomrates = $this->getBaseRoomRates($id_room, $now_id_price);
693 643
694 - // apply rounding to 2 decimals at most
695 - $tars[0]['cost'] = round($tars[0]['cost'], 2);
644 + if (!$roomrates) {
645 + if ($rplan_info['is_derived']) {
646 + // this is not the main rate plan we are updating
647 + continue;
648 + }
696 649
697 - if ($common_rate < 0) {
698 - // save first-day common rate
699 - $common_rate = $tars[0]['cost'];
700 - } else {
701 - // check if we've got a different rate for this day
702 - if ($common_rate != $tars[0]['cost']) {
703 - // freeze all controls, because this day has got a different cost
704 - $common_rate = 0;
705 - }
706 - }
650 + // terminate the process by throwing an error
651 + throw new UnexpectedValueException('No rates found for the given room ID.', 400);
652 + }
707 653
708 - $indkey = $infostart['mday'] . '-' . $infostart['mon'] . '-' . $infostart['year'] . '-' . $now_id_price;
709 - $newly_rates[$indkey] = $tars[0];
710 - if (is_int($current_minlos) && $current_minlos > 0) {
711 - $newly_rates[$indkey]['newminlos'] = $current_minlos;
712 - }
654 + // turn the rates list into a single-level array
655 + $roomrates = $roomrates[0];
713 656
714 - $infostart = getdate($tomorrow_ts);
715 - }
657 + // set rate plan name
658 + $rate_plan_name = $roomrates['name'];
716 659
717 - // free memory up
718 - unset($cached_seasons);
660 + // dates involved
661 + $start_ts = strtotime($from_date);
662 + $end_ts = strtotime($to_date);
719 663
720 - /**
721 - * Store a record in the rates flow for this rate modification on VBO.
722 - */
723 - $rflow_handler = VikBooking::getRatesFlowInstance($anew = true);
724 - if ($rflow_handler !== null) {
725 - $rflow_record = $rflow_handler->getRecord()
726 - ->setCreatedBy($this->get('_created_by', 'VBO'))
727 - ->setDates($from_date, $to_date)
728 - ->setVBORoomID($id_room)
729 - ->setVBORatePlanID($now_id_price);
664 + /**
665 + * Preload seasonal records in favour of CPU usage, but against RAM usage.
666 + *
667 + * @since 1.17.2 (J) - 1.7.2 (WP)
668 + * @since 1.18.5 (J) - 1.8.5 (WP) added support for week-day seasons cache.
669 + */
670 + $cached_seasons = VikBooking::getDateSeasonRecords($start_ts, ($end_ts + ($checkout_h * 3600)), [$id_room]);
671 + $cached_wdayseasons = VikBooking::getWdaySeasonRecords();
730 672
731 - if ($rate > 0) {
732 - // a new rate was set
733 - $rflow_record->setNightlyFee($rate);
734 - }
673 + // read current room rates
674 + $current_rates = [];
675 + $infostart = getdate($start_ts);
676 + while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
677 + $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
678 + $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
679 + $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
735 680
736 - if (is_int($current_minlos) && $current_minlos > 0) {
737 - // push restriction extra data
738 - $rflow_record->setRestrictions([
739 - 'minLOS' => $current_minlos,
740 - 'maxLOS' => $current_maxlos,
741 - 'cta' => (bool) $current_cta,
742 - 'ctd' => (bool) $current_ctd,
743 - ]);
744 - }
681 + // apply seasonal rates by injecting the cached seasonal records
682 + $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons, $cached_wdayseasons);
745 683
746 - if (method_exists($rflow_record, 'setBaseFee')) {
747 - $rflow_record->setBaseFee($roomrates['cost']);
748 - }
684 + // apply rounding to 2 decimals at most
685 + $tars[0]['cost'] = round($tars[0]['cost'], 2);
749 686
750 - // push rates flow record
751 - $rflow_handler->pushRecord($rflow_record);
687 + $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
752 688
753 - // store rates flow record
754 - $rflow_handler->storeRecords();
755 - }
689 + $infostart = getdate($tomorrow_ts);
690 + }
756 691
757 - // check if restrictions can be transmitted to OTAs in case of no rate given
758 - if ($upd_otas && $rate <= 0 && is_int($current_minlos) && $current_minlos > 0 && $common_rate > 0) {
759 - // ensure OTAs will get the minimum stay by using the rate shared by all dates involved
760 - $rate = $common_rate;
761 - }
692 + if (!$current_rates) {
693 + if ($rplan_info['is_derived']) {
694 + // this is not the main rate plan we are updating
695 + continue;
696 + }
762 697
763 - /**
764 - * Channels will only be updated if a rate greater than zero was passed,
765 - * and of course if the flag to the update the OTAs is enabled. Restrictions
766 - * alone could not be pushed to the OTAs, or rate threshold errors would occur.
767 - */
768 - if ($upd_otas && $rate > 0) {
769 - // launch channel manager (from VBO, unlikely through the App, we update one rate plan per request)
770 - $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
771 - $channels_updated = [];
772 - $channels_bkdown = [];
773 - $channels_success = [];
774 - $channels_warnings = [];
775 - $channels_errors = [];
698 + // terminate the process by throwing an error
699 + throw new UnexpectedValueException('No seasonal rates found for the given room ID.', 400);
700 + }
776 701
777 - // load room details
778 - $q = "SELECT `id`,`name`,`units` FROM `#__vikbooking_rooms` WHERE `id`=" . $id_room . ";";
779 - $dbo->setQuery($q);
780 - $row = $dbo->loadAssoc();
781 - if ($row) {
782 - $row['channels'] = [];
783 - // get the mapped channels for this room
784 - $q = "SELECT * FROM `#__vikchannelmanager_roomsxref` WHERE `idroomvb`=" . $id_room . ";";
785 - $dbo->setQuery($q);
786 - foreach ($dbo->loadAssocList() as $ch_data) {
787 - $row['channels'][$ch_data['idchannel']] = $ch_data;
788 - }
789 - }
702 + $all_days = array_keys($current_rates);
703 + $season_intervals = [];
704 + $firstind = 0;
705 + $firstdaycost = $current_rates[$all_days[0]]['cost'];
706 + $nextdaycost = false;
707 + for ($i = 1; $i < count($all_days); $i++) {
708 + $ind = $all_days[$i];
709 + $nextdaycost = $current_rates[$ind]['cost'];
710 + if ($firstdaycost != $nextdaycost) {
711 + $interval = [
712 + 'from' => $all_days[$firstind],
713 + 'to' => $all_days[($i - 1)],
714 + 'cost' => $firstdaycost
715 + ];
716 + $season_intervals[] = $interval;
717 + $firstdaycost = $nextdaycost;
718 + $firstind = $i;
719 + }
720 + }
721 + if ($nextdaycost === false) {
722 + $interval = [
723 + 'from' => $all_days[$firstind],
724 + 'to' => $all_days[$firstind],
725 + 'cost' => $firstdaycost
726 + ];
727 + $season_intervals[] = $interval;
728 + } elseif ($firstdaycost == $nextdaycost) {
729 + $interval = [
730 + 'from' => $all_days[$firstind],
731 + 'to' => $all_days[($i - 1)],
732 + 'cost' => $firstdaycost
733 + ];
734 + $season_intervals[] = $interval;
735 + }
736 + foreach ($season_intervals as $sik => $siv) {
737 + if ((float)$siv['cost'] == $rate) {
738 + unset($season_intervals[$sik]);
739 + }
740 + }
790 741
791 - if ($row && ($row['channels'] ?? [])) {
792 - // this room is actually mapped to some channels supporting AV requests
793 - // load the 'Bulk Action - Rates Upload' cache
794 - $bulk_rates_cache = VikChannelManager::getBulkRatesCache();
742 + if (!$season_intervals) {
743 + // do not raise this error if it was requested to set the restriction or to close a rate plan
744 + if (!($min_los > 0) && !$close_rplan && !$upd_otas) {
745 + if ($rplan_info['is_derived']) {
746 + // this is not the main rate plan we are updating
747 + continue;
748 + }
795 749
796 - // check for custom ota pricing overrides
797 - if ($ota_pricing) {
798 - // build ota pricing overrides for each channel
799 - $ota_pricing_overrides = [];
750 + // terminate the process by throwing an error with code 409 (Conflict) for an easy identification
751 + throw new RuntimeException('No rates modification needed with the given parameters.', 409);
752 + }
753 + }
800 754
801 - foreach ($ota_pricing as $ota_id => $pricing_command) {
802 - if (!preg_match("/^(\+|\-)[0-9]+(\.|\,)?([0-9]+)?(\%|\*)$/", $pricing_command)) {
803 - // invalid pricing instructions
804 - continue;
805 - }
755 + if ($rate > 0) {
756 + // make sure to set a cost greater than zero to avoid errors
757 + foreach ($season_intervals as $sik => $siv) {
758 + $first = strtotime($siv['from']);
759 + $second = strtotime($siv['to']);
806 760
807 - // get pricing command
808 - $rmodop = substr($pricing_command, 0, 1);
809 - $rmodval = substr($pricing_command, -1, 1);
810 - $rmodamount = (float) str_replace([$rmodop, $rmodval], '', $pricing_command);
761 + if ($second > 0 && $second == $first) {
762 + $second += 86399;
763 + }
811 764
812 - if ($rmodamount <= 0) {
813 - // invalid rate amount factor
814 - continue;
815 - }
765 + if (!($second > $first)) {
766 + unset($season_intervals[$sik]);
767 + continue;
768 + }
816 769
817 - // build ota pricing instructions
818 - $ota_pricing_overrides[$ota_id] = [
819 - // modify rates
820 - 1,
821 - // increase or decrease
822 - ($rmodop == '+' ? 1 : 0),
823 - // amount
824 - $rmodamount,
825 - // percent or absolute
826 - ($rmodval == '%' ? 1 : 0),
827 - ];
828 - }
770 + $baseone = getdate($first);
771 + $basets = mktime(0, 0, 0, 1, 1, $baseone['year']);
772 + $sfrom = $baseone[0] - $basets;
773 + $basetwo = getdate($second);
774 + $basets = mktime(0, 0, 0, 1, 1, $basetwo['year']);
775 + $sto = $basetwo[0] - $basets;
829 776
830 - if ($ota_pricing_overrides && method_exists($vboConnector, 'setOTAPricingOverrides')) {
831 - /**
832 - * Set OTA pricing instruction overrides.
833 - *
834 - * @since 1.17.2 (J) - 1.7.2 (WP)
835 - *
836 - * @requires VCM >= 1.9.4
837 - */
838 - $vboConnector->setOTAPricingOverrides($ota_pricing_overrides);
839 - }
840 - }
777 + // check leap year
778 + if ($baseone['year'] % 4 == 0 && ($baseone['year'] % 100 != 0 || $baseone['year'] % 400 == 0)) {
779 + $leapts = mktime(0, 0, 0, 2, 29, $baseone['year']);
780 + if ($baseone[0] > $leapts) {
781 + $sfrom -= 86400;
782 + /**
783 + * To avoid issue with leap years and dates near Feb 29th, we only reduce the seconds if these were reduced
784 + * for the from-date of the seasons. Doing it just for the to-date in 2019 for 2020 (leap) produced invalid results.
785 + */
786 + if ($basetwo['year'] % 4 == 0 && ($basetwo['year'] % 100 != 0 || $basetwo['year'] % 400 == 0)) {
787 + $leapts = mktime(0, 0, 0, 2, 29, $basetwo['year']);
788 + if ($basetwo[0] > $leapts) {
789 + $sto -= date('d-m', $baseone[0]) != '31-12' && date('d-m', $basetwo[0]) == '31-12' ? 1 : 86400;
790 + }
791 + }
792 + }
793 + }
841 794
842 - // we update one rate plan per time, even though we could update all of them with a similar request
843 - $rates_data = [
844 - [
845 - 'rate_id' => $now_id_price,
846 - 'cost' => $rate,
847 - ]
848 - ];
795 + $tieyear = $baseone['year'];
796 + $season_type = (float)$siv['cost'] > $rate ? "2" : "1";
797 + $season_diffcost = $season_type == "1" ? ($rate - (float)$siv['cost']) : ((float)$siv['cost'] - $rate);
798 + $roomstr = "-" . $id_room . "-,";
799 + $season_name = date('Y-m-d H:i').' - '.substr($baseone['month'], 0, 3).' '.$baseone['mday'].($siv['from'] != $siv['to'] ? '/'.($baseone['month'] != $basetwo['month'] ? substr($basetwo['month'], 0, 3).' ' : '').$basetwo['mday'] : '');
800 + $pricestr = "-" . $now_id_price . "-,";
849 801
850 - // build the array with the update details
851 - $update_rows = [];
852 - foreach ($rates_data as $rk => $rd) {
853 - $node = $row;
854 - $setminlos = '';
855 - $setmaxlos = '';
856 - $setctawdays = '';
857 - $setctdwdays = '';
802 + // build and store season record
803 + $season_record = new stdClass;
804 + $season_record->type = $season_type == "1" ? 1 : 2;
805 + $season_record->from = $sfrom;
806 + $season_record->to = $sto;
807 + $season_record->diffcost = $season_diffcost;
808 + $season_record->idrooms = $roomstr;
809 + $season_record->spname = $season_name;
810 + $season_record->wdays = '';
811 + $season_record->checkinincl = 0;
812 + $season_record->val_pcent = 1;
813 + $season_record->losoverride = '';
814 + $season_record->year = $tieyear;
815 + $season_record->idprices = $pricestr;
858 816
859 - // pass the restrictions to the channels if specified
860 - if (is_int($current_maxlos) && $current_maxlos > 0) {
861 - // set max los first
862 - $setmaxlos = $current_maxlos;
863 - }
864 - if (is_int($current_minlos) && $current_minlos > 0) {
865 - // set min los after
866 - $setminlos = $current_minlos;
867 - // max los must have a length > 0 or min los won't be set
868 - $setmaxlos = $setmaxlos ?: '0';
869 - // eavaluate whether cta/ctd week-days should be added
870 - if ($current_cta) {
871 - $setctawdays = 'CTA[' . implode(',', $current_cta) . ']';
872 - }
873 - if ($current_ctd) {
874 - $setctdwdays = 'CTD[' . implode(',', $current_ctd) . ']';
875 - }
876 - }
817 + $dbo->insertObject('#__vikbooking_seasons', $season_record, 'id');
877 818
878 - // check for follow restriction flag in a derived rate plan
879 - if ($rplan_info['is_derived'] && !((bool) ($rplan_info['derived_data']['follow_restr'] ?? 1))) {
880 - // unset restriction values for only updating the rates
881 - $setminlos = '';
882 - $setmaxlos = '';
883 - $setctawdays = '';
884 - $setctdwdays = '';
885 - }
819 + /**
820 + * Push the newly created season record to the list of preloaded records.
821 + *
822 + * @since 1.17.2 (J) - 1.7.2 (WP)
823 + */
824 + $cached_seasons[] = (array) $season_record;
825 + }
826 + }
886 827
887 - // close rate plan (min or max los do not need to be set)
888 - if ($close_rplan) {
889 - // VikBookingConnector class in VCM requires the closure to be concatenated to maxlos
890 - $setmaxlos .= 'closed';
891 - }
828 + // calculate the involved dates
829 + $start_ts = strtotime($from_date);
830 + $end_ts = strtotime($to_date);
831 + $infostart = getdate($start_ts);
832 + $infoend = getdate($end_ts);
892 833
893 - // check bulk rates cache to see if the exact rate should be increased for the channels (the exact rate has already been set in VBO at this point of the code)
894 - if (!$ota_pricing && ($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
895 - if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmod'] > 0 && (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] > 0) {
896 - if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodop'] > 0) {
897 - // Increase rates
898 - if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
899 - // Percentage charge
900 - $rd['cost'] = $rd['cost'] * (100 + (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount']) / 100;
901 - } else {
902 - // Fixed charge
903 - $rd['cost'] += (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
904 - }
905 - } else {
906 - // Lower rates
907 - if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
908 - // Percentage discount
909 - $disc_op = $rd['cost'] * (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] / 100;
910 - $rd['cost'] -= $disc_op;
911 - } else {
912 - // Fixed discount
913 - $rd['cost'] -= (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
914 - }
915 - }
916 - }
917 - }
834 + /**
835 + * Restrictions can be set only if VCM is enabled because we use the Connector Class.
836 + * It is allowed to set just a restriction for the website without any rate modification.
837 + * OTAs instead would need a rate to be passed in order to eventually transmit the restrictions.
838 + */
839 + $current_minlos = 0;
840 + $current_maxlos = 0;
841 + $current_cta = [];
842 + $current_ctd = [];
843 + $split_ct_nodes = [];
844 + $vboConnector = null;
918 845
919 - // set rate inventory node(s)
920 - if (count($split_ct_nodes) > 1 && $setminlos && ($current_cta || $current_ctd)) {
921 - // there will be multiple OTA rate inventory nodes for better accuracy and avoid conflicts with cta/ctd rules
922 - $node['ratesinventory'] = [];
846 + if (method_exists('VikChannelManager', 'getVikBookingConnectorInstance')) {
847 + // invoke the Connector for any update request
848 + $vboConnector = VikChannelManager::getVikBookingConnectorInstance();
849 + // set the caller to 'VBO' to reduce the sleep time between the requests
850 + $vboConnector->caller = 'VBO';
851 + } else {
852 + // make sure the OTA update flag is off
853 + $upd_otas = false;
854 + }
923 855
924 - foreach ($split_ct_nodes as $split_ct_node) {
925 - // calculate proper cta/ctd strings for this date interval
926 - $setctawdays_range = $split_ct_node['cta'] ? 'CTA[' . implode(',', $split_ct_node['cta']) . ']' : '';
927 - $setctdwdays_range = $split_ct_node['ctd'] ? 'CTD[' . implode(',', $split_ct_node['ctd']) . ']' : '';
856 + // minimum length of stay is always necessary for creating a restriction even with other modifiers
857 + if ($min_los > 0 && $vboConnector) {
858 + // set the end date to the last second
859 + $end_ts = mktime(23, 59, 59, $infoend['mon'], $infoend['mday'], $infoend['year']);
928 860
929 - // set rate inventory node for the properly calculated range of dates, cta and ctd rules
930 - $node['ratesinventory'][] = implode('_', [
931 - $split_ct_node['from_dt'],
932 - $split_ct_node['to_dt'],
933 - $setminlos . $setctawdays_range . $setctdwdays_range,
934 - $setmaxlos,
935 - 1,
936 - 2,
937 - $rd['cost'],
938 - 0,
939 - ]);
940 - }
941 - } else {
942 - // regular rate inventory node
943 - $node['ratesinventory'] = [
944 - $from_date . '_' . $to_date . '_' . $setminlos . $setctawdays . $setctdwdays . '_' . $setmaxlos . '_1_2_' . $rd['cost'] . '_0',
945 - ];
946 - }
861 + /**
862 + * Setting just a min los restriction may lift a previously defined CTA/CTD rule for the same dates.
863 + * If merging restrictions is not disabled, and if no other modifiers are set (max los, cta, ctd), the
864 + * system will automatically calculate the current modifiers in order to keep them on the OTAs. Such
865 + * calculated restriction modifiers will not need to be created on VikBooking, but just passed to the OTAs.
866 + * If conflicting restriction modifiers are detected, it is recommended to schedule an auto bulk action.
867 + *
868 + * @since 1.17.1 (J) - 1.7.1 (WP)
869 + */
870 + if ($merge_restr && !$max_los && !$cta_wdays && !$ctd_wdays) {
871 + // calculate the restriction modifiers beside the min los and get additional details
872 + list($calc_maxlos, $calc_cta, $calc_ctd, $split_ct_nodes, $conflicts) = $this->calculateRestrictionModifiers($start_ts, $end_ts, $id_room);
947 873
948 - // set rate data
949 - $node['pushdata'] = [
950 - 'pricetype' => $rd['rate_id'],
951 - 'defrate' => $roomrates['cost'],
952 - 'rplans' => [],
953 - 'cur_rplans' => [],
954 - 'rplanarimode' => [],
955 - ];
874 + if ($calc_maxlos) {
875 + // set calculated max los
876 + $max_los = $calc_maxlos;
877 + }
956 878
957 - // build push data for each channel rate plan according to the Bulk Rates Cache or to the OTA Pricing
958 - if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
959 - // Bulk Rates Cache available for this room_id and rate_id
960 - $node['pushdata']['rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplans'];
961 - $node['pushdata']['cur_rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['cur_rplans'];
962 - $node['pushdata']['rplanarimode'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplanarimode'];
963 - }
879 + if ($calc_cta) {
880 + // set calculated cta week days
881 + $cta_wdays = $calc_cta;
882 + }
964 883
965 - // check the channels mapped for this room and add what was not found in the Bulk Rates Cache, if anything
966 - foreach ($node['channels'] as $idchannel => $ch_data) {
967 - if (!isset($node['pushdata']['rplans'][$idchannel])) {
968 - // this channel was not found in the Bulk Rates Cache. Read data from ota mapping pricing
969 - $ota_mapping_pricing = json_decode($ch_data['otapricing'], true);
970 - $ch_rplan_id = '';
971 - if (is_array($ota_mapping_pricing) && isset($ota_mapping_pricing['RatePlan'])) {
972 - foreach ($ota_mapping_pricing['RatePlan'] as $rpkey => $rpv) {
973 - // get the first key (rate plan ID) of the RatePlan array from OTA Pricing
974 - $ch_rplan_id = $rpkey;
975 - break;
976 - }
977 - }
884 + if ($calc_ctd) {
885 + // set calculated ctd week days
886 + $ctd_wdays = $calc_ctd;
887 + }
888 + }
978 889
979 - // prevent channel from being updated if not directly involved
980 - $is_secondary_rplan = $this->guessOTASecondaryRatePlan($idchannel, $roomrates, ($bulk_rates_cache[$id_room] ?? []));
981 - $is_mapped_rplan = $this->isOTARatePlanMapped($idchannel, $roomrates, ($bulk_rates_cache[$id_room] ?? []));
982 - $is_google_platform = defined('VikChannelManagerConfig::GOOGLEHOTEL') && $idchannel == VikChannelManagerConfig::GOOGLEHOTEL;
983 - $is_google_platform = $is_google_platform || (defined('VikChannelManagerConfig::GOOGLEVR') && $idchannel == VikChannelManagerConfig::GOOGLEVR);
890 + if (!$rplan_info['is_derived']) {
891 + // build the minimum stay restriction string, eventually inclusive of CTA/CTD rules
892 + $vbo_min_los_str = $min_los;
893 + if ($cta_wdays) {
894 + // append cta instructions
895 + $vbo_min_los_str .= 'CTA[' . implode(',', $cta_wdays) . ']';
896 + }
897 + if ($ctd_wdays) {
898 + // append ctd instructions
899 + $vbo_min_los_str .= 'CTD[' . implode(',', $ctd_wdays) . ']';
900 + }
984 901
985 - // prevent Airbnb from being updated if not for the main rate plan only
986 - if (defined('VikChannelManagerConfig::AIRBNBAPI') && $idchannel == VikChannelManagerConfig::AIRBNBAPI) {
987 - if ($rplan_info['is_derived'] || $is_secondary_rplan) {
988 - // skip this channel from updating a derived/secondary rate plan that would not exist
989 - $ch_rplan_id = '';
990 - }
991 - } elseif (!$is_google_platform && !$is_mapped_rplan) {
992 - // no bulk rates cache found for this channel, room and rate plan
993 - $ch_rplan_id = '';
994 - }
902 + // create the restriction in VBO (only for the parent rate since it will be a room-level restriction)
903 + $restr_res = $vboConnector->createRestriction(date('Y-m-d H:i:s', $start_ts), date('Y-m-d H:i:s', $end_ts), [$id_room], [$vbo_min_los_str, $max_los]);
995 904
996 - // make sure an OTA rate plan ID was found
997 - if (empty($ch_rplan_id)) {
998 - // exclude this channel from being updated
999 - unset($node['channels'][$idchannel]);
1000 - continue;
1001 - }
905 + // update values for the response and the rest of the operations
906 + if ($restr_res) {
907 + $current_minlos = $min_los;
908 + $current_maxlos = $max_los;
909 + $current_cta = $cta_wdays;
910 + $current_ctd = $ctd_wdays;
911 + } else {
912 + $current_minlos = 'e4j.error.' . $vboConnector->getError();
913 + }
914 + } else {
915 + // always update the min/max stay information
916 + $current_minlos = $min_los;
917 + $current_maxlos = $max_los;
918 + $current_cta = $cta_wdays;
919 + $current_ctd = $ctd_wdays;
920 + }
921 + }
1002 922
1003 - // set channel rate plan data
1004 - $node['pushdata']['rplans'][$idchannel] = $ch_rplan_id;
1005 - if ($idchannel == VikChannelManagerConfig::BOOKING) {
1006 - // Default Pricing is used by default, when no data available
1007 - $node['pushdata']['rplanarimode'][$idchannel] = 'person';
1008 - }
1009 - }
1010 - }
923 + /**
924 + * Ensure the room and rate plan effective min/max LOS is applied (room-rate level restrictions).
925 + *
926 + * @since 1.18.0 (J) - 1.8.0 (WP)
927 + */
928 + if (is_int($current_minlos) && $current_minlos > 0) {
929 + $effective_min_los = VBORoomHelper::calcEffectiveMinLOS($id_room, $now_id_price);
930 + $effective_max_los = VBORoomHelper::calcEffectiveMaxLOS($id_room, $now_id_price);
1011 931
1012 - // push update node
1013 - $update_rows[] = $node;
1014 - }
932 + // if we have a weekly rate plan, the minimum stay should always be forced regardless of room-level restrictions
933 + if ($effective_min_los > 1 && $current_minlos < $effective_min_los) {
934 + $current_minlos = $effective_min_los;
935 + }
1015 936
1016 - // update rates on the various channels
1017 - $channels_map = [];
1018 - foreach ($update_rows as $update_row) {
1019 - if (!$update_row['channels']) {
1020 - // skip update for this room as no channels are involved
1021 - continue;
1022 - }
937 + // check if we are dealing with a one-night rate plan
938 + if ($effective_max_los === 1) {
939 + // the maximum stay should always be 1 regardless of room-level restrictions, unless it's a parent rate
940 + if (!$rplan_info['is_derived'] && $current_minlos > $effective_max_los) {
941 + // we are requesting a higher minimum stay for this rate plan, which should rather be closed, but we assume
942 + // the one-night rate plan is higher than any other rate plan, so it is safe to appy the requested min stay
943 + $current_maxlos = $current_minlos;
944 + } else {
945 + // keep one night as minimum and maximum stay
946 + $current_minlos = $effective_max_los;
947 + $current_maxlos = $effective_max_los;
948 + }
949 + }
1023 950
1024 - // set channels updated
1025 - foreach ($update_row['channels'] as $ch) {
1026 - if (($channels_updated[$ch['idchannel']] ?? [])) {
1027 - continue;
1028 - }
1029 - $channels_map[$ch['idchannel']] = ucfirst($ch['channel']);
1030 - $ota_logo_url = is_object($vcm_logos) ? $vcm_logos->setProvenience($ch['channel'])->getLogoURL() : false;
1031 - $channel_logo = $ota_logo_url !== false ? $ota_logo_url : '';
1032 - $channels_updated[$ch['idchannel']] = [
1033 - 'id' => $ch['idchannel'],
1034 - 'name' => ucfirst($ch['channel']),
1035 - 'logo' => $channel_logo
1036 - ];
1037 - }
951 + // ensure the minimum stay is less than or equal to the maximum stay
952 + if (is_int($current_maxlos) && $current_maxlos > 0 && $current_minlos > $current_maxlos) {
953 + $current_minlos = $current_maxlos;
954 + }
955 + }
1038 956
1039 - // prepare request data
1040 - $channels_ids = array_keys($update_row['channels']);
1041 - $channels_rplans = [];
1042 - foreach ($channels_ids as $ch_id) {
1043 - $ch_rplan = isset($update_row['pushdata']['rplans'][$ch_id]) ? $update_row['pushdata']['rplans'][$ch_id] : '';
1044 - $ch_rplan .= isset($update_row['pushdata']['rplanarimode'][$ch_id]) ? '='.$update_row['pushdata']['rplanarimode'][$ch_id] : '';
1045 - $ch_rplan .= isset($update_row['pushdata']['cur_rplans'][$ch_id]) && !empty($update_row['pushdata']['cur_rplans'][$ch_id]) ? ':'.$update_row['pushdata']['cur_rplans'][$ch_id] : '';
1046 - $channels_rplans[] = $ch_rplan;
1047 - }
957 + // check if all dates involved share the same price
958 + $common_rate = -1;
1048 959
1049 - $channels = [
1050 - implode(',', $channels_ids)
1051 - ];
1052 - $chrplans = [
1053 - implode(',', $channels_rplans)
1054 - ];
1055 - $nodes = [
1056 - implode(';', $update_row['ratesinventory'])
1057 - ];
1058 - $rooms = [$id_room];
1059 - $pushvars = [
1060 - implode(';', [
1061 - $update_row['pushdata']['pricetype'],
1062 - $update_row['pushdata']['defrate'],
1063 - ])
1064 - ];
960 + // prepare output by re-calculating the new rates in real-time
961 + while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
962 + $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
963 + $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
964 + $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
1065 965
1066 - // send the request
1067 - $result = $vboConnector->channelsRatesPush($channels, $chrplans, $nodes, $rooms, $pushvars);
1068 - if ($vc_error = $vboConnector->getError(true)) {
1069 - $channels_errors[] = $vc_error;
1070 - continue;
1071 - }
966 + // apply seasonal rates by injecting the cached seasonal records
967 + $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons, $cached_wdayseasons);
1072 968
1073 - // parse the channels update result and compose success, warnings, errors
1074 - $result_pool = json_decode($result, true);
1075 - foreach (($result_pool ?: []) as $rid => $ch_responses) {
1076 - foreach ($ch_responses as $ch_id => $ch_res) {
1077 - if ($ch_id == 'breakdown' || !is_numeric($ch_id)) {
1078 - // get the rates/dates breakdown of the update request
1079 - $bkdown = $ch_res;
1080 - if (is_array($ch_res)) {
1081 - $bkdown = '';
1082 - foreach ($ch_res as $bk => $bv) {
1083 - $bkparts = explode('-', $bk);
1084 - if (count($bkparts) == 6) {
1085 - // breakdown key is usually composed of two dates in Y-m-d concatenated with another "-".
1086 - $bkdown .= 'From ' . implode('-', array_slice($bkparts, 0, 3)) . ' - To ' . implode('-', array_slice($bkparts, 3, 3)) . ': ' . $bv . "\n";
1087 - } else {
1088 - $bkdown .= $bk . ': ' . $bv . "\n";
1089 - }
1090 - }
1091 - // since the Connector does not return breakdown info about the restrictions, we concatenate the response here
1092 - if ((int) $setminlos > 0) {
1093 - $bkdown = rtrim($bkdown, "\n");
1094 - $bkdown .= ' - Min LOS: ' . $setminlos;
1095 - if ((int) $setmaxlos > 0) {
1096 - $bkdown .= ' - Max LOS: ' . $setmaxlos;
1097 - }
1098 - $bkdown_ctad_rules = [];
1099 - if ($setctawdays && $current_cta) {
1100 - $bkdown_ctad_rules[] = 'CTA: ' . implode(',', $this->weekDaysToShort($current_cta));
1101 - }
1102 - if ($setctdwdays && $current_ctd) {
1103 - $bkdown_ctad_rules[] = 'CTD: ' . implode(',', $this->weekDaysToShort($current_ctd));
1104 - }
1105 - if ($bkdown_ctad_rules) {
1106 - $bkdown .= ' [' . implode(' ', $bkdown_ctad_rules) . ']';
1107 - }
1108 - $bkdown .= "\n";
1109 - }
1110 - $bkdown = rtrim($bkdown, "\n");
1111 - }
1112 - if (!isset($channels_bkdown[$ch_id])) {
1113 - $channels_bkdown[$ch_id] = $bkdown;
1114 - } else {
1115 - $channels_bkdown[$ch_id] .= "\n".$bkdown;
1116 - }
1117 - continue;
1118 - }
1119 - $ch_id = (int)$ch_id;
1120 - if (substr($ch_res, 0, 6) == 'e4j.OK') {
1121 - // success
1122 - if (!isset($channels_success[$ch_id])) {
1123 - $channels_success[$ch_id] = $channels_map[$ch_id];
1124 - }
1125 - } elseif (substr($ch_res, 0, 11) == 'e4j.warning') {
1126 - // warning
1127 - if (!isset($channels_warnings[$ch_id])) {
1128 - $channels_warnings[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.warning.', '', $ch_res);
1129 - } else {
1130 - $channels_warnings[$ch_id] .= "\n".str_replace('e4j.warning.', '', $ch_res);
1131 - }
1132 - // add the channel also to the successful list in case of Warning
1133 - if (!isset($channels_success[$ch_id])) {
1134 - $channels_success[$ch_id] = $channels_map[$ch_id];
1135 - }
1136 - } elseif (substr($ch_res, 0, 9) == 'e4j.error') {
1137 - // error
1138 - if (!isset($channels_errors[$ch_id])) {
1139 - $channels_errors[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.error.', '', $ch_res);
1140 - } else {
1141 - $channels_errors[$ch_id] .= "\n".str_replace('e4j.error.', '', $ch_res);
1142 - }
1143 - }
1144 - }
1145 - }
1146 - }
1147 - }
969 + // apply rounding to 2 decimals at most
970 + $tars[0]['cost'] = round($tars[0]['cost'], 2);
1148 971
1149 - if ($channels_updated) {
1150 - /**
1151 - * We now support chained updates due to derived rate plans.
1152 - * The "vcm" response property is now an array of objects with equal structure.
1153 - *
1154 - * @since 1.16.10 (J) - 1.6.10 (WP)
1155 - */
1156 - if (!isset($newly_rates['vcm'])) {
1157 - $newly_rates['vcm'] = [];
1158 - }
972 + if ($common_rate < 0) {
973 + // save first-day common rate
974 + $common_rate = $tars[0]['cost'];
975 + } else {
976 + // check if we've got a different rate for this day
977 + if ($common_rate != $tars[0]['cost']) {
978 + // freeze all controls, because this day has got a different cost
979 + $common_rate = 0;
980 + }
981 + }
1159 982
1160 - // build channels response data for the current rate plan
1161 - $channels_response_data = [
1162 - 'rplan_id' => $now_id_price,
1163 - 'rplan_name' => $rate_plan_name,
1164 - 'is_derived' => $rplan_info['is_derived'],
1165 - 'channels_updated' => $channels_updated,
1166 - ];
983 + $indkey = $infostart['mday'] . '-' . $infostart['mon'] . '-' . $infostart['year'] . '-' . $now_id_price;
984 + $newly_rates[$indkey] = $tars[0];
985 + if (is_int($current_minlos) && $current_minlos > 0) {
986 + $newly_rates[$indkey]['newminlos'] = $current_minlos;
987 + }
1167 988
1168 - // set these property only if not empty
1169 - if ($channels_bkdown) {
1170 - $channels_response_data['channels_bkdown'] = $channels_bkdown['breakdown'];
1171 - }
1172 - if ($channels_success) {
1173 - $channels_response_data['channels_success'] = $channels_success;
1174 - }
1175 - if ($channels_warnings) {
1176 - $channels_response_data['channels_warnings'] = $channels_warnings;
1177 - // cache channel warnings
1178 - $this->channel_warnings = $channels_warnings;
1179 - }
1180 - if ($channels_errors) {
1181 - $channels_response_data['channels_errors'] = $channels_errors;
1182 - // cache channel errors
1183 - $this->channel_errors = $channels_errors;
1184 - }
989 + $infostart = getdate($tomorrow_ts);
990 + }
1185 991
1186 - // push channels response data to the pool
1187 - $newly_rates['vcm'][] = $channels_response_data;
992 + // free memory up
993 + unset($cached_seasons, $cached_wdayseasons);
1188 994
1189 - // cache the channels updated list by eventually merging what was already set
1190 - foreach ($channels_updated as $idch => $chinfo) {
1191 - $this->channels_updated_list[$idch] = $chinfo;
1192 - }
995 + /**
996 + * Store a record in the rates flow for this rate modification on VBO.
997 + */
998 + $rflow_handler = VikBooking::getRatesFlowInstance($anew = true);
999 + if ($rflow_handler !== null) {
1000 + $rflow_record = $rflow_handler->getRecord()
1001 + ->setCreatedBy($this->get('_created_by', 'VBO'))
1002 + ->setDates($from_date, $to_date)
1003 + ->setVBORoomID($id_room)
1004 + ->setVBORatePlanID($now_id_price);
1193 1005
1194 - // check for possible conflicts
1195 - if (!$rplan_info['is_derived'] && !$split_ct_nodes && ($conflicts ?? false) === true) {
1196 - // trigger an automatic bulk action for uploading the rates to ensure a full refresh
1197 - VikChannelManager::autoBulkActions([
1198 - 'from_date' => $from_date,
1199 - 'to_date' => $to_date,
1200 - 'forced_rooms' => [$id_room],
1201 - 'update' => 'rates',
1202 - ]);
1203 - }
1204 - }
1205 - }
1206 - }
1006 + if ($rate > 0) {
1007 + // a new rate was set
1008 + $rflow_record->setNightlyFee($rate);
1009 + }
1207 1010
1208 - return $newly_rates;
1209 - }
1011 + if (is_int($current_minlos) && $current_minlos > 0) {
1012 + // push restriction extra data
1013 + $rflow_record->setRestrictions([
1014 + 'minLOS' => $current_minlos,
1015 + 'maxLOS' => $current_maxlos,
1016 + 'cta' => (bool) $current_cta,
1017 + 'ctd' => (bool) $current_ctd,
1018 + ]);
1019 + }
1210 1020
1211 - /**
1212 - * Returns the information about the channels updated with
1213 - * the last rates/restrictions modification request, if any.
1214 - *
1215 - * @return array
1216 - */
1217 - public function getChannelsUpdated()
1218 - {
1219 - $channel_details = [];
1021 + if (method_exists($rflow_record, 'setBaseFee')) {
1022 + $rflow_record->setBaseFee($roomrates['cost']);
1023 + }
1220 1024
1221 - $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
1025 + // push rates flow record
1026 + $rflow_handler->pushRecord($rflow_record);
1222 1027
1223 - foreach ($this->channels_updated_list as $idchannel => $channel_data) {
1224 - $small_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getSmallLogoURL() : '';
1225 - $tiny_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getTinyLogoURL() : '';
1028 + // store rates flow record
1029 + $rflow_handler->storeRecords();
1030 + }
1226 1031
1227 - // set channel details
1228 - $channel_details[] = [
1229 - 'id' => $channel_data['id'],
1230 - 'name' => $channel_data['name'],
1231 - 'logo' => $channel_data['logo'],
1232 - 'small_logo' => $small_logo_url,
1233 - 'tiny_logo' => $tiny_logo_url,
1234 - ];
1235 - }
1032 + // check if restrictions can be transmitted to OTAs in case of no rate given
1033 + if ($upd_otas && $rate <= 0 && is_int($current_minlos) && $current_minlos > 0 && $common_rate > 0) {
1034 + // ensure OTAs will get the minimum stay by using the rate shared by all dates involved
1035 + $rate = $common_rate;
1036 + }
1236 1037
1237 - return $channel_details;
1238 - }
1038 + /**
1039 + * Channels will only be updated if a rate greater than zero was passed,
1040 + * and of course if the flag to the update the OTAs is enabled. Restrictions
1041 + * alone could not be pushed to the OTAs, or rate threshold errors would occur.
1042 + */
1043 + if ($upd_otas && $rate > 0) {
1044 + // launch channel manager (from VBO, unlikely through the App, we update one rate plan per request)
1045 + $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
1046 + $channels_updated = [];
1047 + $channels_bkdown = [];
1048 + $channels_success = [];
1049 + $channels_warnings = [];
1050 + $channels_errors = [];
1239 1051
1240 - /**
1241 - * Returns the associative list of channel warning messages.
1242 - *
1243 - * @return array
1244 - */
1245 - public function getChannelWarnings()
1246 - {
1247 - return $this->channel_warnings;
1248 - }
1052 + // load room details
1053 + $q = "SELECT `id`,`name`,`units` FROM `#__vikbooking_rooms` WHERE `id`=" . $id_room . ";";
1054 + $dbo->setQuery($q);
1055 + $row = $dbo->loadAssoc();
1056 + if ($row) {
1057 + $row['channels'] = [];
1058 + // get the mapped channels for this room
1059 + $q = "SELECT * FROM `#__vikchannelmanager_roomsxref` WHERE `idroomvb`=" . $id_room . ";";
1060 + $dbo->setQuery($q);
1061 + foreach ($dbo->loadAssocList() as $ch_data) {
1062 + $row['channels'][$ch_data['idchannel']] = $ch_data;
1063 + }
1064 + }
1249 1065
1250 - /**
1251 - * Returns the associative list of channel error messages.
1252 - *
1253 - * @return array
1254 - */
1255 - public function getChannelErrors()
1256 - {
1257 - return $this->channel_errors;
1258 - }
1066 + if ($row && ($row['channels'] ?? [])) {
1067 + // this room is actually mapped to some channels supporting AV requests
1068 + // load the 'Bulk Action - Rates Upload' cache
1069 + $bulk_rates_cache = VikChannelManager::getBulkRatesCache();
1259 1070
1260 - /**
1261 - * Calculates the restriction modifiers for a specific range of dates and room.
1262 - *
1263 - * @param int $start_ts The date range start date timestamp.
1264 - * @param int $end_ts The date range end date timestamp.
1265 - * @param int $room_id The VikBooking room ID.
1266 - *
1267 - * @return array Numeric list of modifiers (Max LOS, CTA, CTD, split nodes, conflicts).
1268 - *
1269 - * @since 1.17.1 (J) - 1.7.1 (WP)
1270 - */
1271 - public function calculateRestrictionModifiers($start_ts, $end_ts, $room_id)
1272 - {
1273 - // build cache signature
1274 - $from_dt = date('Y-m-d', $start_ts);
1275 - $to_dt = date('Y-m-d', $end_ts);
1276 - $signature = $from_dt . '_' . $to_dt . '_' . $room_id;
1071 + // check for custom ota pricing overrides
1072 + if ($ota_pricing) {
1073 + // build ota pricing overrides for each channel
1074 + $ota_pricing_overrides = [];
1277 1075
1278 - if (!isset($this->cached_restrictions[$signature])) {
1279 - // load and cache restrictions
1280 - $this->cached_restrictions[$signature] = VikBooking::loadRestrictions(true, [$room_id]);
1281 - }
1076 + foreach ($ota_pricing as $ota_id => $pricing_command) {
1077 + if (!preg_match("/^(\+|\-)[0-9]+(\.|\,)?([0-9]+)?(\%|\*)$/", $pricing_command)) {
1078 + // invalid pricing instructions
1079 + continue;
1080 + }
1282 1081
1283 - // build default return values
1284 - $calc_maxlos = 0;
1285 - $calc_cta = [];
1286 - $calc_ctd = [];
1287 - $split_ct_nodes = [];
1288 - $conflicts = false;
1082 + // get pricing command
1083 + $rmodop = substr($pricing_command, 0, 1);
1084 + $rmodval = substr($pricing_command, -1, 1);
1085 + $rmodamount = (float) str_replace([$rmodop, $rmodval], '', $pricing_command);
1289 1086
1290 - if (!($this->cached_restrictions[$signature] ?? [])) {
1291 - // do not proceed as nothing would be found
1292 - return [
1293 - $calc_maxlos,
1294 - $calc_cta,
1295 - $calc_ctd,
1296 - $split_ct_nodes,
1297 - $conflicts,
1298 - ];
1299 - }
1087 + if ($rmodamount <= 0) {
1088 + // invalid rate amount factor
1089 + continue;
1090 + }
1300 1091
1301 - // list of week days involved
1302 - $wdays_involved = [];
1092 + // build ota pricing instructions
1093 + $ota_pricing_overrides[$ota_id] = [
1094 + // modify rates
1095 + 1,
1096 + // increase or decrease
1097 + ($rmodop == '+' ? 1 : 0),
1098 + // amount
1099 + $rmodamount,
1100 + // percent or absolute
1101 + ($rmodval == '%' ? 1 : 0),
1102 + ];
1103 + }
1303 1104
1304 - // loop through the requested range of dates
1305 - $infostart = getdate($start_ts);
1306 - $infofirst = $infostart;
1307 - while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
1308 - // calculate timestamps
1309 - $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
1310 - $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
1105 + if ($ota_pricing_overrides && method_exists($vboConnector, 'setOTAPricingOverrides')) {
1106 + /**
1107 + * Set OTA pricing instruction overrides.
1108 + *
1109 + * @since 1.17.2 (J) - 1.7.2 (WP)
1110 + *
1111 + * @requires VCM >= 1.9.4
1112 + */
1113 + $vboConnector->setOTAPricingOverrides($ota_pricing_overrides);
1114 + }
1115 + }
1311 1116
1312 - // set week-day involved
1313 - $wdays_involved[] = $infostart['wday'];
1117 + // we update one rate plan per time, even though we could update all of them with a similar request
1118 + $rates_data = [
1119 + [
1120 + 'rate_id' => $now_id_price,
1121 + 'cost' => $rate,
1122 + ]
1123 + ];
1314 1124
1315 - // calculate restrictions
1316 - $restrictions = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $this->cached_restrictions[$signature] ?? []);
1125 + // build the array with the update details
1126 + $update_rows = [];
1127 + foreach ($rates_data as $rk => $rd) {
1128 + $node = $row;
1129 + $setminlos = '';
1130 + $setmaxlos = '';
1131 + $setctawdays = '';
1132 + $setctdwdays = '';
1317 1133
1318 - // check for max LOS
1319 - if ($restrictions['maxlos'] ?? null) {
1320 - if (!$calc_maxlos) {
1321 - $calc_maxlos = (int) $restrictions['maxlos'];
1322 - }
1323 - }
1134 + // pass the restrictions to the channels if specified
1135 + if (is_int($current_maxlos) && $current_maxlos > 0) {
1136 + // set max los first
1137 + $setmaxlos = $current_maxlos;
1138 + }
1139 + if (is_int($current_minlos) && $current_minlos > 0) {
1140 + // set min los after
1141 + $setminlos = $current_minlos;
1142 + // max los must have a length > 0 or min los won't be set
1143 + $setmaxlos = $setmaxlos ?: '0';
1144 + // eavaluate whether cta/ctd week-days should be added
1145 + if ($current_cta) {
1146 + $setctawdays = 'CTA[' . implode(',', $current_cta) . ']';
1147 + }
1148 + if ($current_ctd) {
1149 + $setctdwdays = 'CTD[' . implode(',', $current_ctd) . ']';
1150 + }
1151 + }
1324 1152
1325 - // check for CTA week-days
1326 - if ($restrictions['cta'] ?? []) {
1327 - if (!$calc_cta) {
1328 - $calc_cta = (array) $restrictions['cta'];
1329 - $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['cta']);
1330 - } else {
1331 - $conflicts = $conflicts || $calc_cta != (array) $restrictions['cta'];
1332 - }
1333 - } elseif ($calc_cta) {
1334 - $conflicts = true;
1335 - }
1153 + // check for follow restriction flag in a derived rate plan
1154 + if ($rplan_info['is_derived'] && !((bool) ($rplan_info['derived_data']['follow_restr'] ?? 1))) {
1155 + // unset restriction values for only updating the rates
1156 + $setminlos = '';
1157 + $setmaxlos = '';
1158 + $setctawdays = '';
1159 + $setctdwdays = '';
1160 + }
1336 1161
1337 - // check for CTD week-days
1338 - if ($restrictions['ctd'] ?? []) {
1339 - if (!$calc_ctd) {
1340 - $calc_ctd = (array) $restrictions['ctd'];
1341 - $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['ctd']);
1342 - } else {
1343 - $conflicts = $conflicts || $calc_ctd != (array) $restrictions['ctd'];
1344 - }
1345 - } elseif ($calc_ctd) {
1346 - $conflicts = true;
1347 - }
1162 + // close rate plan (min or max los do not need to be set)
1163 + if ($close_rplan) {
1164 + // VikBookingConnector class in VCM requires the closure to be concatenated to maxlos
1165 + $setmaxlos .= 'closed';
1166 + }
1348 1167
1349 - // go to next day
1350 - $infostart = getdate($tomorrow_ts);
1351 - }
1168 + // check bulk rates cache to see if the exact rate should be increased for the channels (the exact rate has already been set in VBO at this point of the code)
1169 + if (!$ota_pricing && ($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
1170 + if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmod'] > 0 && (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] > 0) {
1171 + if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodop'] > 0) {
1172 + // Increase rates
1173 + if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
1174 + // Percentage charge
1175 + $rd['cost'] = $rd['cost'] * (100 + (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount']) / 100;
1176 + } else {
1177 + // Fixed charge
1178 + $rd['cost'] += (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
1179 + }
1180 + } else {
1181 + // Lower rates
1182 + if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
1183 + // Percentage discount
1184 + $disc_op = $rd['cost'] * (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] / 100;
1185 + $rd['cost'] -= $disc_op;
1186 + } else {
1187 + // Fixed discount
1188 + $rd['cost'] -= (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
1189 + }
1190 + }
1191 + }
1192 + }
1352 1193
1353 - if ($calc_cta) {
1354 - // ensure it's a list of integers representing week-days
1355 - $calc_cta = array_map(function($w) {
1356 - return (int) str_replace('-', '', (string) $w);
1357 - }, $calc_cta);
1194 + // set rate inventory node(s)
1195 + if (count($split_ct_nodes) > 1 && $setminlos && ($current_cta || $current_ctd)) {
1196 + // there will be multiple OTA rate inventory nodes for better accuracy and avoid conflicts with cta/ctd rules
1197 + $node['ratesinventory'] = [];
1358 1198
1359 - if (count($wdays_involved) < 7) {
1360 - // filter the CTA week days according to the nights involved
1361 - $calc_cta = array_filter($calc_cta, function($w) use ($wdays_involved) {
1362 - return in_array($w, $wdays_involved);
1363 - });
1364 - }
1365 - }
1199 + foreach ($split_ct_nodes as $split_ct_node) {
1200 + // calculate proper cta/ctd strings for this date interval
1201 + $setctawdays_range = $split_ct_node['cta'] ? 'CTA[' . implode(',', $split_ct_node['cta']) . ']' : '';
1202 + $setctdwdays_range = $split_ct_node['ctd'] ? 'CTD[' . implode(',', $split_ct_node['ctd']) . ']' : '';
1366 1203
1367 - if ($calc_ctd) {
1368 - // ensure it's a list of integers representing week-days
1369 - $calc_ctd = array_map(function($w) {
1370 - return (int) str_replace('-', '', (string) $w);
1371 - }, $calc_ctd);
1204 + // set rate inventory node for the properly calculated range of dates, cta and ctd rules
1205 + $node['ratesinventory'][] = implode('_', [
1206 + $split_ct_node['from_dt'],
1207 + $split_ct_node['to_dt'],
1208 + $setminlos . $setctawdays_range . $setctdwdays_range,
1209 + $setmaxlos,
1210 + 1,
1211 + 2,
1212 + $rd['cost'],
1213 + 0,
1214 + ]);
1215 + }
1216 + } else {
1217 + // regular rate inventory node
1218 + $node['ratesinventory'] = [
1219 + $from_date . '_' . $to_date . '_' . $setminlos . $setctawdays . $setctdwdays . '_' . $setmaxlos . '_1_2_' . $rd['cost'] . '_0',
1220 + ];
1221 + }
1372 1222
1373 - if (count($wdays_involved) < 7) {
1374 - // filter the CTD week days according to the nights involved
1375 - $calc_ctd = array_filter($calc_ctd, function($w) use ($wdays_involved) {
1376 - return in_array($w, $wdays_involved);
1377 - });
1378 - }
1379 - }
1223 + // set rate data
1224 + $node['pushdata'] = [
1225 + 'pricetype' => $rd['rate_id'],
1226 + 'defrate' => $roomrates['cost'],
1227 + 'rplans' => [],
1228 + 'cur_rplans' => [],
1229 + 'rplanarimode' => [],
1230 + ];
1380 1231
1381 - if ($conflicts && count($wdays_involved) < 7 && ($calc_cta || $calc_ctd)) {
1382 - // conflicting cta/ctd restrictions for a range of dates less than a week may be split into multiple OTA nodes
1383 - $day_ctad_list = [];
1384 - for ($w = 0; $w < count($wdays_involved); $w++) {
1385 - // build day individual cta/ctd restrictions
1386 - $split_ts = mktime(0, 0, 0, $infofirst['mon'], $infofirst['mday'] + $w, $infofirst['year']);
1387 - $day_ctad_list[] = [
1388 - 'ts' => $split_ts,
1389 - 'day' => date('Y-m-d', $split_ts),
1390 - 'cta' => in_array($wdays_involved[$w], $calc_cta) ? $calc_cta : [],
1391 - 'ctd' => in_array($wdays_involved[$w], $calc_ctd) ? $calc_ctd : [],
1392 - ];
1393 - }
1232 + // build push data for each channel rate plan according to the Bulk Rates Cache or to the OTA Pricing
1233 + if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
1234 + // Bulk Rates Cache available for this room_id and rate_id
1235 + $node['pushdata']['rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplans'];
1236 + $node['pushdata']['cur_rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['cur_rplans'];
1237 + $node['pushdata']['rplanarimode'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplanarimode'];
1238 + }
1394 1239
1395 - // attempt to merge the split cta/ctd nodes for consecutive dates with equal rules
1396 - $split_from_ts = 0;
1397 - $split_to_ts = 0;
1398 - $split_cta = [];
1399 - $split_ctd = [];
1400 - foreach ($day_ctad_list as $ct_node_restr) {
1401 - if (!$split_from_ts) {
1402 - // initialize values
1403 - $split_from_ts = $ct_node_restr['ts'];
1404 - $split_to_ts = $ct_node_restr['ts'];
1405 - $split_cta = $ct_node_restr['cta'];
1406 - $split_ctd = $ct_node_restr['ctd'];
1407 - continue;
1408 - }
1409 - if ($split_cta != $ct_node_restr['cta'] || $split_ctd != $ct_node_restr['ctd']) {
1410 - // close previous interval
1411 - $split_ct_nodes[] = [
1412 - 'from_ts' => $split_from_ts,
1413 - 'to_ts' => $split_to_ts,
1414 - 'cta' => $split_cta,
1415 - 'ctd' => $split_ctd,
1416 - ];
1417 - // start new values
1418 - $split_from_ts = $ct_node_restr['ts'];
1419 - $split_to_ts = $ct_node_restr['ts'];
1420 - $split_cta = $ct_node_restr['cta'];
1421 - $split_ctd = $ct_node_restr['ctd'];
1422 - } else {
1423 - // increase till day timestamp
1424 - $split_to_ts = $ct_node_restr['ts'];
1425 - }
1426 - }
1427 - if (!$split_ct_nodes || $split_ct_nodes[count($split_ct_nodes) - 1]['to_ts'] != $split_from_ts) {
1428 - // close last or only interval
1429 - $split_ct_nodes[] = [
1430 - 'from_ts' => $split_from_ts,
1431 - 'to_ts' => $split_to_ts,
1432 - 'cta' => $split_cta,
1433 - 'ctd' => $split_ctd,
1434 - ];
1435 - }
1240 + // check the channels mapped for this room and add what was not found in the Bulk Rates Cache, if anything
1241 + foreach ($node['channels'] as $idchannel => $ch_data) {
1242 + if (!isset($node['pushdata']['rplans'][$idchannel])) {
1243 + // this channel was not found in the Bulk Rates Cache
1244 + $ota_mapping_pricing = (array) json_decode($ch_data['otapricing'], true);
1436 1245
1437 - // normalize timestamps
1438 - foreach ($split_ct_nodes as &$split_ct_node) {
1439 - $split_ct_node['from_dt'] = date('Y-m-d', $split_ct_node['from_ts']);
1440 - $split_ct_node['to_dt'] = date('Y-m-d', $split_ct_node['to_ts']);
1441 - }
1246 + if (defined('VikChannelManagerConfig::EXPEDIA') && $idchannel == VikChannelManagerConfig::EXPEDIA) {
1247 + // make sure to sort the Expedia rate plans accordingly
1248 + $ota_mapping_pricing = VikChannelManager::sortExpediaChannelPricing($ota_mapping_pricing);
1249 + }
1442 1250
1443 - // unset last reference
1444 - unset($split_ct_node);
1445 - }
1251 + // read data from ota mapping pricing
1252 + $ch_rplan_id = '';
1253 + if (isset($ota_mapping_pricing['RatePlan'])) {
1254 + foreach ($ota_mapping_pricing['RatePlan'] as $rpkey => $rpv) {
1255 + // get the first key (rate plan ID) of the RatePlan array from OTA Pricing
1256 + $ch_rplan_id = $rpkey;
1257 + break;
1258 + }
1259 + }
1446 1260
1447 - // double check for possible conflicts
1448 - $conflicts = count($wdays_involved) < 7 ? false : $conflicts;
1261 + // build a list of OTAs NOT supporting rate plans
1262 + $ota_single_rplan = [];
1263 + if (defined('VikChannelManagerConfig::AIRBNBAPI')) {
1264 + $ota_single_rplan[] = VikChannelManagerConfig::AIRBNBAPI;
1265 + }
1266 + if (defined('VikChannelManagerConfig::VRBOAPI')) {
1267 + $ota_single_rplan[] = VikChannelManagerConfig::VRBOAPI;
1268 + }
1449 1269
1450 - return [
1451 - // max LOS
1452 - $calc_maxlos,
1453 - // cta week-days
1454 - $calc_cta,
1455 - // ctd week-days
1456 - $calc_ctd,
1457 - // split cta/ctd nodes (range of dates)
1458 - $split_ct_nodes,
1459 - // whether some dates have conflicting modifiers
1460 - $conflicts,
1461 - ];
1462 - }
1270 + // prevent channel from being updated if not directly involved
1271 + $vbo_single_rplan = count($all_rate_plans) === 1;
1272 + $is_secondary_rplan = $this->guessOTASecondaryRatePlan($idchannel, $roomrates, ($bulk_rates_cache[$id_room] ?? []));
1273 + $is_google_platform = defined('VikChannelManagerConfig::GOOGLEHOTEL') && $idchannel == VikChannelManagerConfig::GOOGLEHOTEL;
1274 + $is_google_platform = $is_google_platform || (defined('VikChannelManagerConfig::GOOGLEVR') && $idchannel == VikChannelManagerConfig::GOOGLEVR);
1463 1275
1464 - /**
1465 - * Detects if we are updating a secondary rate plan, probably not supported by the OTA.
1466 - * Useful to prevent non-refundable rate plans to be transmitted to channels like Airbnb.
1467 - *
1468 - * @param int $idchannel The channel unique key.
1469 - * @param array $room_rates The rate plan record details.
1470 - * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1471 - *
1472 - * @return bool True if a secondary rate plan was detected.
1473 - */
1474 - protected function guessOTASecondaryRatePlan($idchannel, array $room_rates, array $room_cache)
1475 - {
1476 - $room_type_id = $room_rates['idroom'] ?? 0;
1477 - $rate_plan_id = $room_rates['idprice'] ?? 0;
1478 - $rate_plan_name = $room_rates['name'] ?? 'Standard';
1276 + /**
1277 + * No bulk rates cache found for this channel, room and rate plan.
1278 + * We prevent channels like Airbnb from being updated if not for the
1279 + * main rate plan only, otherwise we attempt to process the request.
1280 + *
1281 + * @since 1.17.6 (J) - 1.7.6 (WP)
1282 + */
1283 + if (in_array($idchannel, $ota_single_rplan)) {
1284 + if (($rplan_info['is_derived'] || $is_secondary_rplan) && !$vbo_single_rplan) {
1285 + // skip this channel from updating a derived/secondary rate plan that would not exist
1286 + $ch_rplan_id = '';
1287 + }
1288 + } elseif (!$is_google_platform && $rplan_info['is_derived'] && !$vbo_single_rplan) {
1289 + // derived rate plans will always require bulk rates cache information, unless it's Google
1290 + $ch_rplan_id = '';
1291 + }
1479 1292
1480 - if (!$room_cache) {
1481 - // unable to perform a detection
1482 - return false;
1483 - }
1293 + // make sure an OTA rate plan ID was found
1294 + if (empty($ch_rplan_id)) {
1295 + // exclude this channel from being updated
1296 + unset($node['channels'][$idchannel]);
1297 + continue;
1298 + }
1484 1299
1485 - // check how many rate plans are linked to the given channel identifier
1486 - $cached_ota_rate_plans = [];
1300 + // set channel rate plan data
1301 + $node['pushdata']['rplans'][$idchannel] = $ch_rplan_id;
1302 + if ($idchannel == VikChannelManagerConfig::BOOKING) {
1303 + // Default Pricing is used by default, when no data available
1304 + $node['pushdata']['rplanarimode'][$idchannel] = 'person';
1305 + }
1306 + }
1307 + }
1487 1308
1488 - foreach ($room_cache as $price_id => $plan_cache) {
1489 - if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1490 - $cached_ota_rate_plans[] = $price_id;
1491 - }
1492 - }
1309 + // push update node
1310 + $update_rows[] = $node;
1311 + }
1493 1312
1494 - if (!$cached_ota_rate_plans) {
1495 - // unable to perform a detection due to missing bulk rates cache data
1496 - return false;
1497 - }
1313 + // update rates on the various channels
1314 + $channels_map = [];
1315 + foreach ($update_rows as $update_row) {
1316 + if (!$update_row['channels']) {
1317 + // skip update for this room as no channels are involved
1318 + continue;
1319 + }
1498 1320
1499 - if (in_array($rate_plan_id, $cached_ota_rate_plans)) {
1500 - // this rate plan was updated through a bulk action, so it's reliable
1501 - return false;
1502 - }
1321 + // set channels updated
1322 + foreach ($update_row['channels'] as $ch) {
1323 + if (($channels_updated[$ch['idchannel']] ?? [])) {
1324 + continue;
1325 + }
1326 + $channels_map[$ch['idchannel']] = ucfirst($ch['channel']);
1327 + $ota_logo_url = is_object($vcm_logos) ? $vcm_logos->setProvenience($ch['channel'])->getLogoURL() : false;
1328 + $channel_logo = $ota_logo_url !== false ? $ota_logo_url : '';
1329 + $channels_updated[$ch['idchannel']] = [
1330 + 'id' => $ch['idchannel'],
1331 + 'name' => ucfirst($ch['channel']),
1332 + 'logo' => $channel_logo
1333 + ];
1334 + }
1503 1335
1504 - // access the room rate plan relations
1505 - if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1506 - $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1507 - }
1336 + // prepare request data
1337 + $channels_ids = array_keys($update_row['channels']);
1338 + $channels_rplans = [];
1339 + foreach ($channels_ids as $ch_id) {
1340 + $ch_rplan = isset($update_row['pushdata']['rplans'][$ch_id]) ? $update_row['pushdata']['rplans'][$ch_id] : '';
1341 + $ch_rplan .= isset($update_row['pushdata']['rplanarimode'][$ch_id]) ? '='.$update_row['pushdata']['rplanarimode'][$ch_id] : '';
1342 + $ch_rplan .= isset($update_row['pushdata']['cur_rplans'][$ch_id]) && !empty($update_row['pushdata']['cur_rplans'][$ch_id]) ? ':'.$update_row['pushdata']['cur_rplans'][$ch_id] : '';
1343 + $channels_rplans[] = $ch_rplan;
1344 + }
1508 1345
1509 - if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1510 - // this room-type has got just one rate plan assigned, so it must be a parent rate
1511 - return false;
1512 - }
1346 + $channels = [
1347 + implode(',', $channels_ids)
1348 + ];
1349 + $chrplans = [
1350 + implode(',', $channels_rplans)
1351 + ];
1352 + $nodes = [
1353 + implode(';', $update_row['ratesinventory'])
1354 + ];
1355 + $rooms = [$id_room];
1356 + $pushvars = [
1357 + implode(';', [
1358 + $update_row['pushdata']['pricetype'],
1359 + $update_row['pushdata']['defrate'],
1360 + ])
1361 + ];
1513 1362
1514 - if (stripos($rate_plan_name, 'Standard') !== false) {
1515 - // we assume this a main rate plan
1516 - return false;
1517 - }
1363 + // send the request
1364 + $result = $vboConnector->channelsRatesPush($channels, $chrplans, $nodes, $rooms, $pushvars);
1365 + if ($vc_error = $vboConnector->getError(true)) {
1366 + $channels_errors[] = $vc_error;
1367 + continue;
1368 + }
1518 1369
1519 - // this is probably a secondary rate plan for this OTA
1520 - return true;
1521 - }
1370 + // parse the channels update result and compose success, warnings, errors
1371 + $result_pool = json_decode($result, true);
1372 + foreach (($result_pool ?: []) as $rid => $ch_responses) {
1373 + foreach ($ch_responses as $ch_id => $ch_res) {
1374 + if ($ch_id == 'breakdown' || !is_numeric($ch_id)) {
1375 + // get the rates/dates breakdown of the update request
1376 + $bkdown = $ch_res;
1377 + if (is_array($ch_res)) {
1378 + $bkdown = '';
1379 + foreach ($ch_res as $bk => $bv) {
1380 + $bkparts = explode('-', $bk);
1381 + if (count($bkparts) == 6) {
1382 + // breakdown key is usually composed of two dates in Y-m-d concatenated with another "-".
1383 + $bkdown .= 'From ' . implode('-', array_slice($bkparts, 0, 3)) . ' - To ' . implode('-', array_slice($bkparts, 3, 3)) . ': ' . $bv . "\n";
1384 + } else {
1385 + $bkdown .= $bk . ': ' . $bv . "\n";
1386 + }
1387 + }
1388 + // since the Connector does not return breakdown info about the restrictions, we concatenate the response here
1389 + if ((int) $setminlos > 0) {
1390 + $bkdown = rtrim($bkdown, "\n");
1391 + $bkdown .= ' - Min LOS: ' . $setminlos;
1392 + if ((int) $setmaxlos > 0) {
1393 + $bkdown .= ' - Max LOS: ' . $setmaxlos;
1394 + }
1395 + $bkdown_ctad_rules = [];
1396 + if ($setctawdays && $current_cta) {
1397 + $bkdown_ctad_rules[] = 'CTA: ' . implode(',', $this->weekDaysToShort($current_cta));
1398 + }
1399 + if ($setctdwdays && $current_ctd) {
1400 + $bkdown_ctad_rules[] = 'CTD: ' . implode(',', $this->weekDaysToShort($current_ctd));
1401 + }
1402 + if ($bkdown_ctad_rules) {
1403 + $bkdown .= ' [' . implode(' ', $bkdown_ctad_rules) . ']';
1404 + }
1405 + $bkdown .= "\n";
1406 + }
1407 + $bkdown = rtrim($bkdown, "\n");
1408 + }
1409 + if (!isset($channels_bkdown[$ch_id])) {
1410 + $channels_bkdown[$ch_id] = $bkdown;
1411 + } else {
1412 + $channels_bkdown[$ch_id] .= "\n".$bkdown;
1413 + }
1414 + continue;
1415 + }
1416 + $ch_id = (int)$ch_id;
1417 + if (substr($ch_res, 0, 6) == 'e4j.OK') {
1418 + // success
1419 + if (!isset($channels_success[$ch_id])) {
1420 + $channels_success[$ch_id] = $channels_map[$ch_id];
1421 + }
1422 + } elseif (substr($ch_res, 0, 11) == 'e4j.warning') {
1423 + // warning
1424 + if (!isset($channels_warnings[$ch_id])) {
1425 + $channels_warnings[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.warning.', '', $ch_res);
1426 + } else {
1427 + $channels_warnings[$ch_id] .= "\n".str_replace('e4j.warning.', '', $ch_res);
1428 + }
1429 + // add the channel also to the successful list in case of Warning
1430 + if (!isset($channels_success[$ch_id])) {
1431 + $channels_success[$ch_id] = $channels_map[$ch_id];
1432 + }
1433 + } elseif (substr($ch_res, 0, 9) == 'e4j.error') {
1434 + // error
1435 + if (!isset($channels_errors[$ch_id])) {
1436 + $channels_errors[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.error.', '', $ch_res);
1437 + } else {
1438 + $channels_errors[$ch_id] .= "\n".str_replace('e4j.error.', '', $ch_res);
1439 + }
1440 + }
1441 + }
1442 + }
1443 + }
1444 + }
1522 1445
1523 - /**
1524 - * Detects if we are updating a non-mapped rate plan, probably not supported by the OTA. Useful to
1525 - * prevent non-refundable rate plans to be transmitted to OTAs that would not directly support it.
1526 - *
1527 - * @param int $idchannel The channel unique key.
1528 - * @param array $room_rates The rate plan record details.
1529 - * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1530 - *
1531 - * @return bool True if the rate plan was mapped in the Bulk Rates Cache.
1532 - *
1533 - * @since 1.17.2 (J) - 1.7.2 (WP)
1534 - */
1535 - protected function isOTARatePlanMapped($idchannel, array $room_rates, array $room_cache)
1536 - {
1537 - $room_type_id = $room_rates['idroom'] ?? 0;
1538 - $rate_plan_id = $room_rates['idprice'] ?? 0;
1446 + if ($channels_updated) {
1447 + /**
1448 + * We now support chained updates due to derived rate plans.
1449 + * The "vcm" response property is now an array of objects with equal structure.
1450 + *
1451 + * @since 1.16.10 (J) - 1.6.10 (WP)
1452 + */
1453 + if (!isset($newly_rates['vcm'])) {
1454 + $newly_rates['vcm'] = [];
1455 + }
1539 1456
1540 - // access the room rate plan relations
1541 - if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1542 - $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1543 - }
1457 + // build channels response data for the current rate plan
1458 + $channels_response_data = [
1459 + 'rplan_id' => $now_id_price,
1460 + 'rplan_name' => $rate_plan_name,
1461 + 'is_derived' => $rplan_info['is_derived'],
1462 + 'channels_updated' => $channels_updated,
1463 + ];
1544 1464
1545 - if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1546 - // this room-type has got just one rate plan assigned, so we consider it as mapped
1547 - return true;
1548 - }
1465 + // set these property only if not empty
1466 + if ($channels_bkdown) {
1467 + $channels_response_data['channels_bkdown'] = $channels_bkdown['breakdown'];
1468 + }
1469 + if ($channels_success) {
1470 + $channels_response_data['channels_success'] = $channels_success;
1471 + }
1472 + if ($channels_warnings) {
1473 + $channels_response_data['channels_warnings'] = $channels_warnings;
1474 + // cache channel warnings
1475 + $this->channel_warnings = $channels_warnings;
1476 + }
1477 + if ($channels_errors) {
1478 + $channels_response_data['channels_errors'] = $channels_errors;
1479 + // cache channel errors
1480 + $this->channel_errors = $channels_errors;
1481 + }
1549 1482
1550 - if (!$room_cache) {
1551 - // unable to perform a detection
1552 - return false;
1553 - }
1483 + // push channels response data to the pool
1484 + $newly_rates['vcm'][] = $channels_response_data;
1554 1485
1555 - // check how many rate plans are linked to the given channel identifier
1556 - $cached_ota_rate_plans = [];
1486 + // cache the channels updated list by eventually merging what was already set
1487 + foreach ($channels_updated as $idch => $chinfo) {
1488 + $this->channels_updated_list[$idch] = $chinfo;
1489 + }
1557 1490
1558 - foreach ($room_cache as $price_id => $plan_cache) {
1559 - if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1560 - $cached_ota_rate_plans[] = $price_id;
1561 - }
1562 - }
1491 + // check for possible conflicts
1492 + if (!$rplan_info['is_derived'] && !$split_ct_nodes && ($conflicts ?? false) === true) {
1493 + // trigger an automatic bulk action for uploading the rates to ensure a full refresh
1494 + VikChannelManager::autoBulkActions([
1495 + 'from_date' => $from_date,
1496 + 'to_date' => $to_date,
1497 + 'forced_rooms' => [$id_room],
1498 + 'update' => 'rates',
1499 + ]);
1500 + }
1501 + }
1502 + }
1503 + }
1563 1504
1564 - if (!$cached_ota_rate_plans) {
1565 - // unable to perform a detection due to missing bulk rates cache data
1566 - return false;
1567 - }
1505 + return $newly_rates;
1506 + }
1568 1507
1569 - // return whether this rate plan was updated through a bulk action
1570 - return in_array($rate_plan_id, $cached_ota_rate_plans);
1571 - }
1508 + /**
1509 + * Gets the rates for the lowest number of nights for the given listing and one, or all, rate plan(s).
1510 + *
1511 + * @param int $id_room The listing ID.
1512 + * @param ?int $id_price Optional rate plan, or listing rates for any rate plan will be fetched.
1513 + * @param bool $use_cache Whether to allow caching or not, enabled by default.
1514 + *
1515 + * @return array
1516 + *
1517 + * @since 1.18.5 (J) - 1.8.5 (WP)
1518 + */
1519 + public function getBaseRoomRates(int $id_room, ?int $id_price = null, bool $use_cache = true)
1520 + {
1521 + // calculate signature cache
1522 + $cache_signature = sprintf('%d_%d', $id_room, ($id_price ?: 0));
1572 1523
1573 - /**
1574 - * Used for breakdown purposes, converts a list of week-day indexes.
1575 - *
1576 - * @param array $wdays List of zero-based week day indexes.
1577 - *
1578 - * @return array List of readable week days.
1579 - *
1580 - * @since 1.17.1 (J) - 1.7.1 (WP)
1581 - */
1582 - protected function weekDaysToShort(array $wdays)
1583 - {
1584 - $map = [
1585 - 'Sun',
1586 - 'Mon',
1587 - 'Tue',
1588 - 'Wed',
1589 - 'Thu',
1590 - 'Fri',
1591 - 'Sat',
1592 - ];
1524 + if ($use_cache && isset(self::$cached_base_rates[$cache_signature])) {
1525 + // return the previously cached value to avoid duplicate queries
1526 + return self::$cached_base_rates[$cache_signature];
1527 + }
1593 1528
1594 - return array_map(function($wday_index) use ($map) {
1595 - $wday_index = (int) $wday_index;
1596 - return $map[$wday_index] ?? $wday_index;
1597 - }, $wdays);
1598 - }
1529 + $dbo = JFactory::getDbo();
1530 +
1531 + // inner-join query
1532 + $qinner = $dbo->getQuery(true)
1533 + ->select('MIN(' . $dbo->qn('days') . ') AS ' . $dbo->qn('min_days'))
1534 + ->from($dbo->qn('#__vikbooking_dispcost'))
1535 + ->where($dbo->qn('idroom') . ' = ' . $id_room)
1536 + ->group($dbo->qn('idroom'));
1537 + if ($id_price) {
1538 + $qinner->where($dbo->qn('idprice') . ' = ' . $id_price);
1539 + }
1540 +
1541 + // main query
1542 + $q = $dbo->getQuery(true)
1543 + ->select([
1544 + $dbo->qn('r.id'),
1545 + $dbo->qn('r.idroom'),
1546 + $dbo->qn('r.days'),
1547 + $dbo->qn('r.idprice'),
1548 + $dbo->qn('r.cost'),
1549 + $dbo->qn('p.name'),
1550 + ])
1551 + ->from($dbo->qn('#__vikbooking_dispcost', 'r'))
1552 + ->innerJoin('(' . $qinner . ') AS ' . $dbo->qn('r2') . ' ON ' . $dbo->qn('r.days') . ' = ' . $dbo->qn('r2.min_days'))
1553 + ->leftJoin(
1554 + $dbo->qn('#__vikbooking_prices', 'p') . ' ON ' . $dbo->qn('p.id') . ' = ' . $dbo->qn('r.idprice') .
1555 + ($id_price ? ' AND ' . $dbo->qn('p.id') . ' = ' . $id_price : '')
1556 + )
1557 + ->where($dbo->qn('r.idroom') . ' = ' . $id_room)
1558 + ->group([
1559 + $dbo->qn('r.id'),
1560 + $dbo->qn('r.idroom'),
1561 + $dbo->qn('r.days'),
1562 + $dbo->qn('r.idprice'),
1563 + $dbo->qn('r.cost'),
1564 + $dbo->qn('p.name'),
1565 + ])
1566 + ->order($dbo->qn('r.days') . ' ASC')
1567 + ->order($dbo->qn('r.cost') . ' ASC');
1568 + if ($id_price) {
1569 + $q->where($dbo->qn('r.idprice') . ' = ' . $id_price);
1570 + }
1571 +
1572 + // read the rates for the lowest number of nights, either for
1573 + // a specific rate plan ID, or for all configured rate plans
1574 + $dbo->setQuery($q);
1575 + $roomrates = $dbo->loadAssocList();
1576 +
1577 + foreach ($roomrates as $rrk => $rrv) {
1578 + $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
1579 + $roomrates[$rrk]['days'] = 1;
1580 + }
1581 +
1582 + if ($use_cache) {
1583 + // cache value
1584 + self::$cached_base_rates[$cache_signature] = $roomrates;
1585 + }
1586 +
1587 + return $roomrates;
1588 + }
1589 +
1590 + /**
1591 + * Returns the information about the channels updated with
1592 + * the last rates/restrictions modification request, if any.
1593 + *
1594 + * @return array
1595 + */
1596 + public function getChannelsUpdated()
1597 + {
1598 + $channel_details = [];
1599 +
1600 + $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
1601 +
1602 + foreach ($this->channels_updated_list as $idchannel => $channel_data) {
1603 + $small_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getSmallLogoURL() : '';
1604 + $tiny_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getTinyLogoURL() : '';
1605 +
1606 + // set channel details
1607 + $channel_details[] = [
1608 + 'id' => $channel_data['id'],
1609 + 'name' => $channel_data['name'],
1610 + 'logo' => $channel_data['logo'],
1611 + 'small_logo' => $small_logo_url,
1612 + 'tiny_logo' => $tiny_logo_url,
1613 + ];
1614 + }
1615 +
1616 + return $channel_details;
1617 + }
1618 +
1619 + /**
1620 + * Returns the associative list of channel warning messages.
1621 + *
1622 + * @return array
1623 + */
1624 + public function getChannelWarnings()
1625 + {
1626 + return $this->channel_warnings;
1627 + }
1628 +
1629 + /**
1630 + * Returns the associative list of channel error messages.
1631 + *
1632 + * @return array
1633 + */
1634 + public function getChannelErrors()
1635 + {
1636 + return $this->channel_errors;
1637 + }
1638 +
1639 + /**
1640 + * Calculates the restriction modifiers for a specific range of dates and room.
1641 + *
1642 + * @param int $start_ts The date range start date timestamp.
1643 + * @param int $end_ts The date range end date timestamp.
1644 + * @param int $room_id The VikBooking room ID.
1645 + *
1646 + * @return array Numeric list of modifiers (Max LOS, CTA, CTD, split nodes, conflicts).
1647 + *
1648 + * @since 1.17.1 (J) - 1.7.1 (WP)
1649 + */
1650 + public function calculateRestrictionModifiers($start_ts, $end_ts, $room_id)
1651 + {
1652 + // build cache signature
1653 + $from_dt = date('Y-m-d', $start_ts);
1654 + $to_dt = date('Y-m-d', $end_ts);
1655 + $signature = $from_dt . '_' . $to_dt . '_' . $room_id;
1656 +
1657 + if (!isset($this->cached_restrictions[$signature])) {
1658 + // load and cache restrictions
1659 + $this->cached_restrictions[$signature] = VikBooking::loadRestrictions(true, [$room_id]);
1660 + }
1661 +
1662 + // build default return values
1663 + $calc_maxlos = 0;
1664 + $calc_cta = [];
1665 + $calc_ctd = [];
1666 + $split_ct_nodes = [];
1667 + $conflicts = false;
1668 +
1669 + if (!($this->cached_restrictions[$signature] ?? [])) {
1670 + // do not proceed as nothing would be found
1671 + return [
1672 + $calc_maxlos,
1673 + $calc_cta,
1674 + $calc_ctd,
1675 + $split_ct_nodes,
1676 + $conflicts,
1677 + ];
1678 + }
1679 +
1680 + // list of week days involved
1681 + $wdays_involved = [];
1682 +
1683 + // loop through the requested range of dates
1684 + $infostart = getdate($start_ts);
1685 + $infofirst = $infostart;
1686 + while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
1687 + // calculate timestamps
1688 + $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
1689 + $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
1690 +
1691 + // set week-day involved
1692 + $wdays_involved[] = $infostart['wday'];
1693 +
1694 + // calculate restrictions
1695 + $restrictions = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $this->cached_restrictions[$signature] ?? []);
1696 +
1697 + // check for max LOS
1698 + if ($restrictions['maxlos'] ?? null) {
1699 + if (!$calc_maxlos) {
1700 + $calc_maxlos = (int) $restrictions['maxlos'];
1701 + }
1702 + }
1703 +
1704 + // check for CTA week-days
1705 + if ($restrictions['cta'] ?? []) {
1706 + if (!$calc_cta) {
1707 + $calc_cta = (array) $restrictions['cta'];
1708 + $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['cta']);
1709 + } else {
1710 + $conflicts = $conflicts || $calc_cta != (array) $restrictions['cta'];
1711 + }
1712 + } elseif ($calc_cta) {
1713 + $conflicts = true;
1714 + }
1715 +
1716 + // check for CTD week-days
1717 + if ($restrictions['ctd'] ?? []) {
1718 + if (!$calc_ctd) {
1719 + $calc_ctd = (array) $restrictions['ctd'];
1720 + $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['ctd']);
1721 + } else {
1722 + $conflicts = $conflicts || $calc_ctd != (array) $restrictions['ctd'];
1723 + }
1724 + } elseif ($calc_ctd) {
1725 + $conflicts = true;
1726 + }
1727 +
1728 + // go to next day
1729 + $infostart = getdate($tomorrow_ts);
1730 + }
1731 +
1732 + if ($calc_cta) {
1733 + // ensure it's a list of integers representing week-days
1734 + $calc_cta = array_map(function($w) {
1735 + return (int) str_replace('-', '', (string) $w);
1736 + }, $calc_cta);
1737 +
1738 + if (count($wdays_involved) < 7) {
1739 + // filter the CTA week days according to the nights involved
1740 + $calc_cta = array_filter($calc_cta, function($w) use ($wdays_involved) {
1741 + return in_array($w, $wdays_involved);
1742 + });
1743 + }
1744 + }
1745 +
1746 + if ($calc_ctd) {
1747 + // ensure it's a list of integers representing week-days
1748 + $calc_ctd = array_map(function($w) {
1749 + return (int) str_replace('-', '', (string) $w);
1750 + }, $calc_ctd);
1751 +
1752 + if (count($wdays_involved) < 7) {
1753 + // filter the CTD week days according to the nights involved
1754 + $calc_ctd = array_filter($calc_ctd, function($w) use ($wdays_involved) {
1755 + return in_array($w, $wdays_involved);
1756 + });
1757 + }
1758 + }
1759 +
1760 + if ($conflicts && count($wdays_involved) < 7 && ($calc_cta || $calc_ctd)) {
1761 + // conflicting cta/ctd restrictions for a range of dates less than a week may be split into multiple OTA nodes
1762 + $day_ctad_list = [];
1763 + for ($w = 0; $w < count($wdays_involved); $w++) {
1764 + // build day individual cta/ctd restrictions
1765 + $split_ts = mktime(0, 0, 0, $infofirst['mon'], $infofirst['mday'] + $w, $infofirst['year']);
1766 + $day_ctad_list[] = [
1767 + 'ts' => $split_ts,
1768 + 'day' => date('Y-m-d', $split_ts),
1769 + 'cta' => in_array($wdays_involved[$w], $calc_cta) ? $calc_cta : [],
1770 + 'ctd' => in_array($wdays_involved[$w], $calc_ctd) ? $calc_ctd : [],
1771 + ];
1772 + }
1773 +
1774 + // attempt to merge the split cta/ctd nodes for consecutive dates with equal rules
1775 + $split_from_ts = 0;
1776 + $split_to_ts = 0;
1777 + $split_cta = [];
1778 + $split_ctd = [];
1779 + foreach ($day_ctad_list as $ct_node_restr) {
1780 + if (!$split_from_ts) {
1781 + // initialize values
1782 + $split_from_ts = $ct_node_restr['ts'];
1783 + $split_to_ts = $ct_node_restr['ts'];
1784 + $split_cta = $ct_node_restr['cta'];
1785 + $split_ctd = $ct_node_restr['ctd'];
1786 + continue;
1787 + }
1788 + if ($split_cta != $ct_node_restr['cta'] || $split_ctd != $ct_node_restr['ctd']) {
1789 + // close previous interval
1790 + $split_ct_nodes[] = [
1791 + 'from_ts' => $split_from_ts,
1792 + 'to_ts' => $split_to_ts,
1793 + 'cta' => $split_cta,
1794 + 'ctd' => $split_ctd,
1795 + ];
1796 + // start new values
1797 + $split_from_ts = $ct_node_restr['ts'];
1798 + $split_to_ts = $ct_node_restr['ts'];
1799 + $split_cta = $ct_node_restr['cta'];
1800 + $split_ctd = $ct_node_restr['ctd'];
1801 + } else {
1802 + // increase till day timestamp
1803 + $split_to_ts = $ct_node_restr['ts'];
1804 + }
1805 + }
1806 + if (!$split_ct_nodes || $split_ct_nodes[count($split_ct_nodes) - 1]['to_ts'] != $split_from_ts) {
1807 + // close last or only interval
1808 + $split_ct_nodes[] = [
1809 + 'from_ts' => $split_from_ts,
1810 + 'to_ts' => $split_to_ts,
1811 + 'cta' => $split_cta,
1812 + 'ctd' => $split_ctd,
1813 + ];
1814 + }
1815 +
1816 + // normalize timestamps
1817 + foreach ($split_ct_nodes as &$split_ct_node) {
1818 + $split_ct_node['from_dt'] = date('Y-m-d', $split_ct_node['from_ts']);
1819 + $split_ct_node['to_dt'] = date('Y-m-d', $split_ct_node['to_ts']);
1820 + }
1821 +
1822 + // unset last reference
1823 + unset($split_ct_node);
1824 + }
1825 +
1826 + // double check for possible conflicts
1827 + $conflicts = count($wdays_involved) < 7 ? false : $conflicts;
1828 +
1829 + return [
1830 + // max LOS
1831 + $calc_maxlos,
1832 + // cta week-days
1833 + $calc_cta,
1834 + // ctd week-days
1835 + $calc_ctd,
1836 + // split cta/ctd nodes (range of dates)
1837 + $split_ct_nodes,
1838 + // whether some dates have conflicting modifiers
1839 + $conflicts,
1840 + ];
1841 + }
1842 +
1843 + /**
1844 + * Detects if we are updating a secondary rate plan, probably not supported by the OTA.
1845 + * Useful to prevent non-refundable rate plans to be transmitted to channels like Airbnb.
1846 + *
1847 + * @param int $idchannel The channel unique key.
1848 + * @param array $room_rates The rate plan record details.
1849 + * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1850 + *
1851 + * @return bool True if a secondary rate plan was detected.
1852 + */
1853 + protected function guessOTASecondaryRatePlan($idchannel, array $room_rates, array $room_cache)
1854 + {
1855 + $room_type_id = $room_rates['idroom'] ?? 0;
1856 + $rate_plan_id = $room_rates['idprice'] ?? 0;
1857 + $rate_plan_name = $room_rates['name'] ?? 'Standard';
1858 +
1859 + if (!$room_cache) {
1860 + // unable to perform a detection
1861 + return false;
1862 + }
1863 +
1864 + // check how many rate plans are linked to the given channel identifier
1865 + $cached_ota_rate_plans = [];
1866 +
1867 + foreach ($room_cache as $price_id => $plan_cache) {
1868 + if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1869 + $cached_ota_rate_plans[] = $price_id;
1870 + }
1871 + }
1872 +
1873 + if (!$cached_ota_rate_plans) {
1874 + // unable to perform a detection due to missing bulk rates cache data
1875 + return false;
1876 + }
1877 +
1878 + if (in_array($rate_plan_id, $cached_ota_rate_plans)) {
1879 + // this rate plan was updated through a bulk action, so it's reliable
1880 + return false;
1881 + }
1882 +
1883 + // access the room rate plan relations
1884 + if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1885 + $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1886 + }
1887 +
1888 + if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1889 + // this room-type has got just one rate plan assigned, so it must be a parent rate
1890 + return false;
1891 + }
1892 +
1893 + if (stripos($rate_plan_name, 'Standard') !== false) {
1894 + // we assume this a main rate plan
1895 + return false;
1896 + }
1897 +
1898 + // this is probably a secondary rate plan for this OTA
1899 + return true;
1900 + }
1901 +
1902 + /**
1903 + * Detects if we are updating a non-mapped rate plan, probably not supported by the OTA. Useful to
1904 + * prevent non-refundable rate plans to be transmitted to OTAs that would not directly support it.
1905 + *
1906 + * @param int $idchannel The channel unique key.
1907 + * @param array $room_rates The rate plan record details.
1908 + * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1909 + *
1910 + * @return bool True if the rate plan was mapped in the Bulk Rates Cache.
1911 + *
1912 + * @since 1.17.2 (J) - 1.7.2 (WP)
1913 + */
1914 + protected function isOTARatePlanMapped($idchannel, array $room_rates, array $room_cache)
1915 + {
1916 + $room_type_id = $room_rates['idroom'] ?? 0;
1917 + $rate_plan_id = $room_rates['idprice'] ?? 0;
1918 +
1919 + // access the room rate plan relations
1920 + if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1921 + $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1922 + }
1923 +
1924 + if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1925 + // this room-type has got just one rate plan assigned, so we consider it as mapped
1926 + return true;
1927 + }
1928 +
1929 + if (!$room_cache) {
1930 + // unable to perform a detection
1931 + return false;
1932 + }
1933 +
1934 + // check how many rate plans are linked to the given channel identifier
1935 + $cached_ota_rate_plans = [];
1936 +
1937 + foreach ($room_cache as $price_id => $plan_cache) {
1938 + if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1939 + $cached_ota_rate_plans[] = $price_id;
1940 + }
1941 + }
1942 +
1943 + if (!$cached_ota_rate_plans) {
1944 + // unable to perform a detection due to missing bulk rates cache data
1945 + return false;
1946 + }
1947 +
1948 + // return whether this rate plan was updated through a bulk action
1949 + return in_array($rate_plan_id, $cached_ota_rate_plans);
1950 + }
1951 +
1952 + /**
1953 + * Used for breakdown purposes, converts a list of week-day indexes.
1954 + *
1955 + * @param array $wdays List of zero-based week day indexes.
1956 + *
1957 + * @return array List of readable week days.
1958 + *
1959 + * @since 1.17.1 (J) - 1.7.1 (WP)
1960 + */
1961 + protected function weekDaysToShort(array $wdays)
1962 + {
1963 + $map = [
1964 + 'Sun',
1965 + 'Mon',
1966 + 'Tue',
1967 + 'Wed',
1968 + 'Thu',
1969 + 'Fri',
1970 + 'Sat',
1971 + ];
1972 +
1973 + return array_map(function($wday_index) use ($map) {
1974 + $wday_index = (int) $wday_index;
1975 + return $map[$wday_index] ?? $wday_index;
1976 + }, $wdays);
1977 + }
1599 1978 }