PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.7.1
VikBooking Hotel Booking Engine & PMS v1.7.1
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
vikbooking / admin / helpers / src / model / pricing.php

pricing.php in VikBooking Hotel Booking Engine & PMS 1.7.1, at admin/helpers/src/model/pricing.php

1,460 lines 49.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2024 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * VikBooking model pricing.
16 *
17 * @since 1.16.10 (J) - 1.6.10 (WP)
18 */
19 class VBOModelPricing extends JObject
20 {
21 /** @var array */
22 protected $room_rate_plans = [];
23
24 /** @var array */
25 protected $channels_updated_list = [];
26
27 /** @var array */
28 protected $channel_warnings = [];
29
30 /** @var array */
31 protected $channel_errors = [];
32
33 /** @var array */
34 protected $cached_restrictions = [];
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 }
48
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();
62
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);
70
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 }
75
76 if (JFactory::getDate($to_date) < JFactory::getDate($from_date)) {
77 // invalid dates
78 throw new InvalidArgumentException('Invalid dates received.', 400);
79 }
80
81 if (!$id_room) {
82 throw new InvalidArgumentException('Room record ID is mandatory.', 400);
83 }
84
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);
90
91 // load check-in and check-out times
92 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance()->loadCheckinOutTimes();
93
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');
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();
103
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 }
110
111 if (!$id_price) {
112 throw new Exception('No rate plans configured.', 500);
113 }
114
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 }
144
145 // load room rates from db
146 $dbo->setQuery($q);
147 $roomrates = $dbo->loadAssocList();
148
149 foreach ($roomrates as $rrk => $rrv) {
150 $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
151 $roomrates[$rrk]['days'] = 1;
152 }
153
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 }
158
159 // fetch all restrictions, if requested
160 $all_restrictions = $restrictions ? VikBooking::loadRestrictions(true, [$id_room]) : [];
161
162 // calculate global minimum stay
163 $glob_minlos = VikBooking::getDefaultNightsCalendar();
164 $glob_minlos = $glob_minlos < 1 ? 1 : $glob_minlos;
165
166 // read current room rates
167 $current_rates = [];
168
169 // loop through all the requested range of dates
170 $start_ts = strtotime($from_date);
171 $end_ts = strtotime($to_date);
172 $infostart = getdate($start_ts);
173 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
174 // calculate timestamps
175 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
176 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
177 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
178 $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
179
180 // calculate tariffs for this day
181 $tars = VikBooking::applySeasonsRoom($roomrates, $today_tsin, $today_tsout);
182
183 foreach ($tars as $index => $tar) {
184 // apply rounding to 2 decimals at most
185 $tars[$index]['cost'] = round($tar['cost'], 2);
186
187 // set formatted cost
188 $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
189
190 // calculate restrictions
191 $tars[$index]['restrictions'] = [];
192 if ($restrictions) {
193 $restr = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $all_restrictions);
194 if (!$restr) {
195 $restr = ['minlos' => $glob_minlos];
196 }
197 // set day restrictions
198 $tars[$index]['restrictions'] = $restr;
199 }
200 }
201
202 if (!$all_rplans) {
203 // set rate for this day (single rate plan)
204 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
205 } else {
206 // set rates for this day (all rate plans)
207 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars;
208 }
209
210 // go to next day
211 $infostart = getdate($tomorrow_ts);
212 }
213
214 return $current_rates;
215 }
216
217 /**
218 * Applies new rates and/or restrictions to the given room and rate plan(s).
219 * Changes are always applied to the website rates, and eventually also on the OTAs.
220 *
221 * @return array
222 *
223 * @throws Exception
224 *
225 * @since 1.17.1 (J) - 1.7.1 (WP) added support to CTA, CTD and Max LOS restrictions.
226 */
227 public function modifyRateRestrictions()
228 {
229 $dbo = JFactory::getDbo();
230
231 // expected and supported properties binded
232 $from_date = (string) $this->get('from_date', '');
233 $to_date = (string) $this->get('to_date', '');
234 $id_room = (int) $this->get('id_room', 0);
235 $id_price = (int) $this->get('id_price', 0);
236 $rplan_name = $this->get('rplan_name', '');
237 $rate = (float) $this->get('rate', 0);
238 $min_los = (int) $this->get('min_los', 0);
239 $max_los = (int) $this->get('max_los', 0);
240 $cta_wdays = (array) $this->get('cta_wdays', []);
241 $ctd_wdays = (array) $this->get('ctd_wdays', []);
242 $upd_otas = (bool) $this->get('update_otas', true);
243 $close_rplan = (bool) $this->get('close_rate_plan', false);
244 $merge_restr = (bool) $this->get('merge_restrictions', true);
245
246 if (!$from_date || !$to_date) {
247 // must be in Y-m-d format
248 throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
249 }
250
251 if (!$id_room) {
252 throw new InvalidArgumentException('Room record ID is mandatory.', 400);
253 }
254
255 /**
256 * Disable season records caching because new rates will have to be re-calculated
257 * for the response by checking the same exact dates.
258 */
259 VikBooking::setSeasonsCache(false);
260
261 // load check-in and check-out times
262 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance([], true)->loadCheckinOutTimes();
263
264 // date format
265 $vbo_df = VikBooking::getDateFormat();
266 $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
267
268 // access the availability helper
269 $av_helper = VikBooking::getAvailabilityInstance(true);
270
271 if (!$id_price) {
272 // load all rate plans
273 $all_rate_plans = $av_helper->loadRatePlans();
274
275 // use the first rate plan ID after the automatic sorting
276 foreach ($all_rate_plans as $all_rate_plan) {
277 $id_price = $all_rate_plan['id'];
278 break;
279 }
280
281 if ($rplan_name) {
282 // check if the given rate plan name is found
283 $rplan_name = trim(preg_replace("/[^a-z]/i", ' ', $rplan_name));
284 foreach ($all_rate_plans as $all_rate_plan) {
285 $match_against = trim(preg_replace("/[^a-z]/i", ' ', $all_rate_plan['name']));
286 if (stripos($match_against, $rplan_name) !== false || stripos($rplan_name, $match_against) !== false) {
287 // use the matched rate plan instead
288 $id_price = $all_rate_plan['id'];
289 break;
290 }
291 }
292 }
293 }
294
295 if (!$id_price) {
296 throw new Exception('No rate plans configured.', 500);
297 }
298
299 // load the eventually involved derived rate plans from the given rate ID
300 $derived_rate_plans = $av_helper->getDerivedRatePlans($id_price);
301
302 // build the list of rate plans involved by adding the requested one
303 $rate_plans_pool = [
304 $id_price => [
305 'id' => $id_price,
306 // the main rate plan selected for the update is NEVER considered as derived, even if it actually was.
307 'is_derived' => 0,
308 'derived_data' => null,
309 'rate' => $rate,
310 ],
311 ];
312
313 foreach ($derived_rate_plans as $derived_rate_plan) {
314 if (isset($rate_plans_pool[$derived_rate_plan['id']])) {
315 // skip duplicate rate plan
316 continue;
317 }
318
319 // calculate new rate for this derived rate plan
320 $rplan_derived_rate = $rate;
321 if (($derived_rate_plan['derived_data']['mode'] ?? 'discount') == 'discount') {
322 // discount rate
323 if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
324 // percent value
325 $rplan_derived_rate = $rplan_derived_rate * (100 - (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
326 } else {
327 // absolute value
328 $rplan_derived_rate -= (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
329 }
330 } else {
331 // increase rate
332 if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
333 // percent value
334 $rplan_derived_rate = $rplan_derived_rate * (100 + (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
335 } else {
336 // absolute value
337 $rplan_derived_rate += (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
338 }
339 }
340
341 if ($rplan_derived_rate < 0) {
342 // negative rates are not allowed
343 continue;
344 }
345
346 // make sure to apply rounding
347 $rplan_derived_rate = round($rplan_derived_rate, 2);
348
349 // push derived rate plan to the update pool
350 $rate_plans_pool[$derived_rate_plan['id']] = [
351 'id' => $derived_rate_plan['id'],
352 'is_derived' => 1,
353 'derived_data' => $derived_rate_plan['derived_data'],
354 'rate' => $rplan_derived_rate,
355 ];
356 }
357
358 // the newly applied rates
359 $newly_rates = [];
360
361 // apply the pricing modification to all the involved rate plans
362 foreach ($rate_plans_pool as $rplan_id => $rplan_info) {
363 // set rate plan ID
364 $now_id_price = $rplan_info['id'];
365
366 // set rate to apply to the current rate plan
367 $rate = $rplan_info['rate'];
368
369 // read the rates for the lowest number of nights
370 $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
371 FROM `#__vikbooking_dispcost` AS `r`
372 INNER JOIN (
373 SELECT MIN(`days`) AS `min_days`
374 FROM `#__vikbooking_dispcost`
375 WHERE `idroom`=" . $id_room . " AND `idprice`=" . $now_id_price . "
376 GROUP BY `idroom`
377 ) AS `r2` ON `r`.`days`=`r2`.`min_days`
378 LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice` AND `p`.`id`=" . $now_id_price . "
379 WHERE `r`.`idroom`=" . $id_room . " AND `r`.`idprice`=" . $now_id_price . "
380 GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
381 ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
382
383 $dbo->setQuery($q);
384 $roomrates = $dbo->loadAssocList();
385
386 foreach ($roomrates as $rrk => $rrv) {
387 $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
388 $roomrates[$rrk]['days'] = 1;
389 }
390
391 if (!$roomrates) {
392 if ($rplan_info['is_derived']) {
393 // this is not the main rate plan we are updating
394 continue;
395 }
396
397 // terminate the process by throwing an error
398 throw new UnexpectedValueException('No rates found for the given room ID.', 400);
399 }
400
401 // turn the rates list into a single-level array
402 $roomrates = $roomrates[0];
403
404 // set rate plan name
405 $rate_plan_name = $roomrates['name'];
406
407 // read current room rates
408 $current_rates = [];
409 $start_ts = strtotime($from_date);
410 $end_ts = strtotime($to_date);
411 $infostart = getdate($start_ts);
412 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
413 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
414 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
415 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
416
417 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout);
418
419 // apply rounding to 2 decimals at most
420 $tars[0]['cost'] = round($tars[0]['cost'], 2);
421
422 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
423
424 $infostart = getdate($tomorrow_ts);
425 }
426
427 if (!$current_rates) {
428 if ($rplan_info['is_derived']) {
429 // this is not the main rate plan we are updating
430 continue;
431 }
432
433 // terminate the process by throwing an error
434 throw new UnexpectedValueException('No seasonal rates found for the given room ID.', 400);
435 }
436
437 $all_days = array_keys($current_rates);
438 $season_intervals = [];
439 $firstind = 0;
440 $firstdaycost = $current_rates[$all_days[0]]['cost'];
441 $nextdaycost = false;
442 for ($i = 1; $i < count($all_days); $i++) {
443 $ind = $all_days[$i];
444 $nextdaycost = $current_rates[$ind]['cost'];
445 if ($firstdaycost != $nextdaycost) {
446 $interval = [
447 'from' => $all_days[$firstind],
448 'to' => $all_days[($i - 1)],
449 'cost' => $firstdaycost
450 ];
451 $season_intervals[] = $interval;
452 $firstdaycost = $nextdaycost;
453 $firstind = $i;
454 }
455 }
456 if ($nextdaycost === false) {
457 $interval = [
458 'from' => $all_days[$firstind],
459 'to' => $all_days[$firstind],
460 'cost' => $firstdaycost
461 ];
462 $season_intervals[] = $interval;
463 } elseif ($firstdaycost == $nextdaycost) {
464 $interval = [
465 'from' => $all_days[$firstind],
466 'to' => $all_days[($i - 1)],
467 'cost' => $firstdaycost
468 ];
469 $season_intervals[] = $interval;
470 }
471 foreach ($season_intervals as $sik => $siv) {
472 if ((float)$siv['cost'] == $rate) {
473 unset($season_intervals[$sik]);
474 }
475 }
476
477 if (!$season_intervals) {
478 // do not raise this error if it was requested to set the restriction or to close a rate plan
479 if ((!($min_los > 0) && !$close_rplan) || !$upd_otas) {
480 if ($rplan_info['is_derived']) {
481 // this is not the main rate plan we are updating
482 continue;
483 }
484
485 // terminate the process by throwing an error
486 throw new RuntimeException('No rates modification needed with the given parameters.', 500);
487 }
488 }
489
490 if ($rate > 0) {
491 // make sure to set a cost greater than zero to avoid errors
492 foreach ($season_intervals as $sik => $siv) {
493 $first = strtotime($siv['from']);
494 $second = strtotime($siv['to']);
495
496 if ($second > 0 && $second == $first) {
497 $second += 86399;
498 }
499
500 if (!($second > $first)) {
501 unset($season_intervals[$sik]);
502 continue;
503 }
504
505 $baseone = getdate($first);
506 $basets = mktime(0, 0, 0, 1, 1, $baseone['year']);
507 $sfrom = $baseone[0] - $basets;
508 $basetwo = getdate($second);
509 $basets = mktime(0, 0, 0, 1, 1, $basetwo['year']);
510 $sto = $basetwo[0] - $basets;
511
512 // check leap year
513 if ($baseone['year'] % 4 == 0 && ($baseone['year'] % 100 != 0 || $baseone['year'] % 400 == 0)) {
514 $leapts = mktime(0, 0, 0, 2, 29, $baseone['year']);
515 if ($baseone[0] > $leapts) {
516 $sfrom -= 86400;
517 /**
518 * To avoid issue with leap years and dates near Feb 29th, we only reduce the seconds if these were reduced
519 * for the from-date of the seasons. Doing it just for the to-date in 2019 for 2020 (leap) produced invalid results.
520 */
521 if ($basetwo['year'] % 4 == 0 && ($basetwo['year'] % 100 != 0 || $basetwo['year'] % 400 == 0)) {
522 $leapts = mktime(0, 0, 0, 2, 29, $basetwo['year']);
523 if ($basetwo[0] > $leapts) {
524 $sto -= date('d-m', $baseone[0]) != '31-12' && date('d-m', $basetwo[0]) == '31-12' ? 1 : 86400;
525 }
526 }
527 }
528 }
529
530 $tieyear = $baseone['year'];
531 $season_type = (float)$siv['cost'] > $rate ? "2" : "1";
532 $season_diffcost = $season_type == "1" ? ($rate - (float)$siv['cost']) : ((float)$siv['cost'] - $rate);
533 $roomstr = "-" . $id_room . "-,";
534 $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'] : '');
535 $pricestr = "-" . $now_id_price . "-,";
536
537 // build and store season record
538 $season_record = new stdClass;
539 $season_record->type = $season_type == "1" ? 1 : 2;
540 $season_record->from = $sfrom;
541 $season_record->to = $sto;
542 $season_record->diffcost = $season_diffcost;
543 $season_record->idrooms = $roomstr;
544 $season_record->spname = $season_name;
545 $season_record->wdays = '';
546 $season_record->checkinincl = 0;
547 $season_record->val_pcent = 1;
548 $season_record->losoverride = '';
549 $season_record->year = $tieyear;
550 $season_record->idprices = $pricestr;
551
552 $dbo->insertObject('#__vikbooking_seasons', $season_record, 'id');
553 }
554 }
555
556 // calculate the involved dates
557 $start_ts = strtotime($from_date);
558 $end_ts = strtotime($to_date);
559 $infostart = getdate($start_ts);
560 $infoend = getdate($end_ts);
561
562 /**
563 * Restrictions can be set only if VCM is enabled because we use the Connector Class.
564 * It is allowed to set just a restriction for the website without any rate modification.
565 * OTAs instead would need a rate to be passed in order to eventually transmit the restrictions.
566 */
567 $current_minlos = 0;
568 $current_maxlos = 0;
569 $current_cta = [];
570 $current_ctd = [];
571 $split_ct_nodes = [];
572 $vboConnector = null;
573
574 if (method_exists('VikChannelManager', 'getVikBookingConnectorInstance')) {
575 // invoke the Connector for any update request
576 $vboConnector = VikChannelManager::getVikBookingConnectorInstance();
577 // set the caller to 'VBO' to reduce the sleep time between the requests
578 $vboConnector->caller = 'VBO';
579 } else {
580 // make sure the OTA update flag is off
581 $upd_otas = false;
582 }
583
584 // minimum length of stay is always necessary for creating a restriction even with other modifiers
585 if ($min_los > 0 && $vboConnector) {
586 // set the end date to the last second
587 $end_ts = mktime(23, 59, 59, $infoend['mon'], $infoend['mday'], $infoend['year']);
588
589 /**
590 * Setting just a min los restriction may lift a previously defined CTA/CTD rule for the same dates.
591 * If merging restrictions is not disabled, and if no other modifiers are set (max los, cta, ctd), the
592 * system will automatically calculate the current modifiers in order to keep them on the OTAs. Such
593 * calculated restriction modifiers will not need to be created on VikBooking, but just passed to the OTAs.
594 * If conflicting restriction modifiers are detected, it is recommended to schedule an auto bulk action.
595 *
596 * @since 1.17.1 (J) - 1.7.1 (WP)
597 */
598 if ($merge_restr && !$max_los && !$cta_wdays && !$ctd_wdays) {
599 // calculate the restriction modifiers beside the min los and get additional details
600 list($calc_maxlos, $calc_cta, $calc_ctd, $split_ct_nodes, $conflicts) = $this->calculateRestrictionModifiers($start_ts, $end_ts, $id_room);
601
602 if ($calc_maxlos) {
603 // set calculated max los
604 $max_los = $calc_maxlos;
605 }
606
607 if ($calc_cta) {
608 // set calculated cta week days
609 $cta_wdays = $calc_cta;
610 }
611
612 if ($calc_ctd) {
613 // set calculated ctd week days
614 $ctd_wdays = $calc_ctd;
615 }
616 }
617
618 if (!$rplan_info['is_derived']) {
619 // build the minimum stay restriction string, eventually inclusive of CTA/CTD rules
620 $vbo_min_los_str = $min_los;
621 if ($cta_wdays) {
622 // append cta instructions
623 $vbo_min_los_str .= 'CTA[' . implode(',', $cta_wdays) . ']';
624 }
625 if ($ctd_wdays) {
626 // append ctd instructions
627 $vbo_min_los_str .= 'CTD[' . implode(',', $ctd_wdays) . ']';
628 }
629
630 // create the restriction in VBO (only for the parent rate since it will be a room-level restriction)
631 $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]);
632
633 // update values for the response and the rest of the operations
634 if ($restr_res) {
635 $current_minlos = $min_los;
636 $current_maxlos = $max_los;
637 $current_cta = $cta_wdays;
638 $current_ctd = $ctd_wdays;
639 } else {
640 $current_minlos = 'e4j.error.' . $vboConnector->getError();
641 }
642 } else {
643 // always update the min/max stay information
644 $current_minlos = $min_los;
645 $current_maxlos = $max_los;
646 $current_cta = $cta_wdays;
647 $current_ctd = $ctd_wdays;
648 }
649 }
650
651 // check if all dates involved share the same price
652 $common_rate = -1;
653
654 // prepare output by re-calculating the new rates in real-time
655 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
656 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
657 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
658 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
659
660 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout);
661
662 // apply rounding to 2 decimals at most
663 $tars[0]['cost'] = round($tars[0]['cost'], 2);
664
665 if ($common_rate < 0) {
666 // save first-day common rate
667 $common_rate = $tars[0]['cost'];
668 } else {
669 // check if we've got a different rate for this day
670 if ($common_rate != $tars[0]['cost']) {
671 // freeze all controls, because this day has got a different cost
672 $common_rate = 0;
673 }
674 }
675
676 $indkey = $infostart['mday'] . '-' . $infostart['mon'] . '-' . $infostart['year'] . '-' . $now_id_price;
677 $newly_rates[$indkey] = $tars[0];
678 if (is_int($current_minlos) && $current_minlos > 0) {
679 $newly_rates[$indkey]['newminlos'] = $current_minlos;
680 }
681
682 $infostart = getdate($tomorrow_ts);
683 }
684
685 /**
686 * Store a record in the rates flow for this rate modification on VBO.
687 */
688 $rflow_handler = VikBooking::getRatesFlowInstance($anew = true);
689 if ($rflow_handler !== null) {
690 $rflow_record = $rflow_handler->getRecord()
691 ->setCreatedBy($this->get('_created_by', 'VBO'))
692 ->setDates($from_date, $to_date)
693 ->setVBORoomID($id_room)
694 ->setVBORatePlanID($now_id_price);
695
696 if ($rate > 0) {
697 // a new rate was set
698 $rflow_record->setNightlyFee($rate);
699 }
700
701 if (is_int($current_minlos) && $current_minlos > 0) {
702 // push restriction extra data
703 $rflow_record->setRestrictions([
704 'minLOS' => $current_minlos,
705 'maxLOS' => $current_maxlos,
706 'cta' => (bool) $current_cta,
707 'ctd' => (bool) $current_ctd,
708 ]);
709 }
710
711 if (method_exists($rflow_record, 'setBaseFee')) {
712 $rflow_record->setBaseFee($roomrates['cost']);
713 }
714
715 // push rates flow record
716 $rflow_handler->pushRecord($rflow_record);
717
718 // store rates flow record
719 $rflow_handler->storeRecords();
720 }
721
722 // check if restrictions can be transmitted to OTAs in case of no rate given
723 if ($upd_otas && $rate <= 0 && is_int($current_minlos) && $current_minlos > 0 && $common_rate > 0) {
724 // ensure OTAs will get the minimum stay by using the rate shared by all dates involved
725 $rate = $common_rate;
726 }
727
728 /**
729 * Channels will only be updated if a rate greater than zero was passed,
730 * and of course if the flag to the update the OTAs is enabled. Restrictions
731 * alone could not be pushed to the OTAs, or rate threshold errors would occur.
732 */
733 if ($upd_otas && $rate > 0) {
734 // launch channel manager (from VBO, unlikely through the App, we update one rate plan per request)
735 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
736 $channels_updated = [];
737 $channels_bkdown = [];
738 $channels_success = [];
739 $channels_warnings = [];
740 $channels_errors = [];
741
742 // load room details
743 $q = "SELECT `id`,`name`,`units` FROM `#__vikbooking_rooms` WHERE `id`=" . $id_room . ";";
744 $dbo->setQuery($q);
745 $row = $dbo->loadAssoc();
746 if ($row) {
747 $row['channels'] = [];
748 // get the mapped channels for this room
749 $q = "SELECT * FROM `#__vikchannelmanager_roomsxref` WHERE `idroomvb`=" . $id_room . ";";
750 $dbo->setQuery($q);
751 foreach ($dbo->loadAssocList() as $ch_data) {
752 $row['channels'][$ch_data['idchannel']] = $ch_data;
753 }
754 }
755
756 if ($row && ($row['channels'] ?? [])) {
757 // this room is actually mapped to some channels supporting AV requests
758 // load the 'Bulk Action - Rates Upload' cache
759 $bulk_rates_cache = VikChannelManager::getBulkRatesCache();
760
761 // we update one rate plan per time, even though we could update all of them with a similar request
762 $rates_data = [
763 [
764 'rate_id' => $now_id_price,
765 'cost' => $rate,
766 ]
767 ];
768
769 // build the array with the update details
770 $update_rows = [];
771 foreach ($rates_data as $rk => $rd) {
772 $node = $row;
773 $setminlos = '';
774 $setmaxlos = '';
775 $setctawdays = '';
776 $setctdwdays = '';
777
778 // pass the restrictions to the channels if specified
779 if (is_int($current_maxlos) && $current_maxlos > 0) {
780 // set max los first
781 $setmaxlos = $current_maxlos;
782 }
783 if (is_int($current_minlos) && $current_minlos > 0) {
784 // set min los after
785 $setminlos = $current_minlos;
786 // max los must have a length > 0 or min los won't be set
787 $setmaxlos = $setmaxlos ?: '0';
788 // eavaluate whether cta/ctd week-days should be added
789 if ($current_cta) {
790 $setctawdays = 'CTA[' . implode(',', $current_cta) . ']';
791 }
792 if ($current_ctd) {
793 $setctdwdays = 'CTD[' . implode(',', $current_ctd) . ']';
794 }
795 }
796
797 // check for follow restriction flag in a derived rate plan
798 if ($rplan_info['is_derived'] && !((bool) ($rplan_info['derived_data']['follow_restr'] ?? 1))) {
799 // unset restriction values for only updating the rates
800 $setminlos = '';
801 $setmaxlos = '';
802 $setctawdays = '';
803 $setctdwdays = '';
804 }
805
806 // close rate plan (min or max los do not need to be set)
807 if ($close_rplan) {
808 // VikBookingConnector class in VCM requires the closure to be concatenated to maxlos
809 $setmaxlos .= 'closed';
810 }
811
812 // 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)
813 if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
814 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmod'] > 0 && (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] > 0) {
815 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodop'] > 0) {
816 // Increase rates
817 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
818 // Percentage charge
819 $rd['cost'] = $rd['cost'] * (100 + (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount']) / 100;
820 } else {
821 // Fixed charge
822 $rd['cost'] += (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
823 }
824 } else {
825 // Lower rates
826 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
827 // Percentage discount
828 $disc_op = $rd['cost'] * (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] / 100;
829 $rd['cost'] -= $disc_op;
830 } else {
831 // Fixed discount
832 $rd['cost'] -= (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
833 }
834 }
835 }
836 }
837
838 // set rate inventory node(s)
839 if (count($split_ct_nodes) > 1 && $setminlos && ($current_cta || $current_ctd)) {
840 // there will be multiple OTA rate inventory nodes for better accuracy and avoid conflicts with cta/ctd rules
841 $node['ratesinventory'] = [];
842
843 foreach ($split_ct_nodes as $split_ct_node) {
844 // calculate proper cta/ctd strings for this date interval
845 $setctawdays_range = $split_ct_node['cta'] ? 'CTA[' . implode(',', $split_ct_node['cta']) . ']' : '';
846 $setctdwdays_range = $split_ct_node['ctd'] ? 'CTD[' . implode(',', $split_ct_node['ctd']) . ']' : '';
847
848 // set rate inventory node for the properly calculated range of dates, cta and ctd rules
849 $node['ratesinventory'][] = implode('_', [
850 $split_ct_node['from_dt'],
851 $split_ct_node['to_dt'],
852 $setminlos . $setctawdays_range . $setctdwdays_range,
853 $setmaxlos,
854 1,
855 2,
856 $rd['cost'],
857 0,
858 ]);
859 }
860 } else {
861 // regular rate inventory node
862 $node['ratesinventory'] = [
863 $from_date . '_' . $to_date . '_' . $setminlos . $setctawdays . $setctdwdays . '_' . $setmaxlos . '_1_2_' . $rd['cost'] . '_0',
864 ];
865 }
866
867 // set rate data
868 $node['pushdata'] = [
869 'pricetype' => $rd['rate_id'],
870 'defrate' => $roomrates['cost'],
871 'rplans' => [],
872 'cur_rplans' => [],
873 'rplanarimode' => [],
874 ];
875
876 // build push data for each channel rate plan according to the Bulk Rates Cache or to the OTA Pricing
877 if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
878 // Bulk Rates Cache available for this room_id and rate_id
879 $node['pushdata']['rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplans'];
880 $node['pushdata']['cur_rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['cur_rplans'];
881 $node['pushdata']['rplanarimode'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplanarimode'];
882 }
883
884 // check the channels mapped for this room and add what was not found in the Bulk Rates Cache, if anything
885 foreach ($node['channels'] as $idchannel => $ch_data) {
886 if (!isset($node['pushdata']['rplans'][$idchannel])) {
887 // this channel was not found in the Bulk Rates Cache. Read data from OTA Pricing
888 $otapricing = json_decode($ch_data['otapricing'], true);
889 $ch_rplan_id = '';
890 if (is_array($otapricing) && isset($otapricing['RatePlan'])) {
891 foreach ($otapricing['RatePlan'] as $rpkey => $rpv) {
892 // get the first key (rate plan ID) of the RatePlan array from OTA Pricing
893 $ch_rplan_id = $rpkey;
894 break;
895 }
896 }
897
898 // prevent Airbnb from being updated if not for the main rate plan only
899 if ($idchannel == VikChannelManagerConfig::AIRBNBAPI) {
900 if ($rplan_info['is_derived'] || $this->guessOTASecondaryRatePlan($idchannel, $roomrates, ($bulk_rates_cache[$id_room] ?? []))) {
901 // skip this channel from updating a derived/secondary rate plan that would not exist
902 $ch_rplan_id = '';
903 }
904 }
905
906 // make sure an OTA rate plan ID was found
907 if (empty($ch_rplan_id)) {
908 // exclude this channel from being updated
909 unset($node['channels'][$idchannel]);
910 continue;
911 }
912
913 // set channel rate plan data
914 $node['pushdata']['rplans'][$idchannel] = $ch_rplan_id;
915 if ($idchannel == VikChannelManagerConfig::BOOKING) {
916 // Default Pricing is used by default, when no data available
917 $node['pushdata']['rplanarimode'][$idchannel] = 'person';
918 }
919 }
920 }
921
922 // push update node
923 $update_rows[] = $node;
924 }
925
926 // update rates on the various channels
927 $channels_map = [];
928 foreach ($update_rows as $update_row) {
929 if (!$update_row['channels']) {
930 // skip update for this room as no channels are involved
931 continue;
932 }
933
934 // set channels updated
935 foreach ($update_row['channels'] as $ch) {
936 if (($channels_updated[$ch['idchannel']] ?? [])) {
937 continue;
938 }
939 $channels_map[$ch['idchannel']] = ucfirst($ch['channel']);
940 $ota_logo_url = is_object($vcm_logos) ? $vcm_logos->setProvenience($ch['channel'])->getLogoURL() : false;
941 $channel_logo = $ota_logo_url !== false ? $ota_logo_url : '';
942 $channels_updated[$ch['idchannel']] = [
943 'id' => $ch['idchannel'],
944 'name' => ucfirst($ch['channel']),
945 'logo' => $channel_logo
946 ];
947 }
948
949 // prepare request data
950 $channels_ids = array_keys($update_row['channels']);
951 $channels_rplans = [];
952 foreach ($channels_ids as $ch_id) {
953 $ch_rplan = isset($update_row['pushdata']['rplans'][$ch_id]) ? $update_row['pushdata']['rplans'][$ch_id] : '';
954 $ch_rplan .= isset($update_row['pushdata']['rplanarimode'][$ch_id]) ? '='.$update_row['pushdata']['rplanarimode'][$ch_id] : '';
955 $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] : '';
956 $channels_rplans[] = $ch_rplan;
957 }
958
959 $channels = [
960 implode(',', $channels_ids)
961 ];
962 $chrplans = [
963 implode(',', $channels_rplans)
964 ];
965 $nodes = [
966 implode(';', $update_row['ratesinventory'])
967 ];
968 $rooms = [$id_room];
969 $pushvars = [
970 implode(';', [
971 $update_row['pushdata']['pricetype'],
972 $update_row['pushdata']['defrate'],
973 ])
974 ];
975
976 // send the request
977 $result = $vboConnector->channelsRatesPush($channels, $chrplans, $nodes, $rooms, $pushvars);
978 if ($vc_error = $vboConnector->getError(true)) {
979 $channels_errors[] = $vc_error;
980 continue;
981 }
982
983 // parse the channels update result and compose success, warnings, errors
984 $result_pool = json_decode($result, true);
985 foreach (($result_pool ?: []) as $rid => $ch_responses) {
986 foreach ($ch_responses as $ch_id => $ch_res) {
987 if ($ch_id == 'breakdown' || !is_numeric($ch_id)) {
988 // get the rates/dates breakdown of the update request
989 $bkdown = $ch_res;
990 if (is_array($ch_res)) {
991 $bkdown = '';
992 foreach ($ch_res as $bk => $bv) {
993 $bkparts = explode('-', $bk);
994 if (count($bkparts) == 6) {
995 // breakdown key is usually composed of two dates in Y-m-d concatenated with another "-".
996 $bkdown .= 'From ' . implode('-', array_slice($bkparts, 0, 3)) . ' - To ' . implode('-', array_slice($bkparts, 3, 3)) . ': ' . $bv . "\n";
997 } else {
998 $bkdown .= $bk . ': ' . $bv . "\n";
999 }
1000 }
1001 // since the Connector does not return breakdown info about the restrictions, we concatenate the response here
1002 if ((int) $setminlos > 0) {
1003 $bkdown = rtrim($bkdown, "\n");
1004 $bkdown .= ' - Min LOS: ' . $setminlos;
1005 if ((int) $setmaxlos > 0) {
1006 $bkdown .= ' - Max LOS: ' . $setmaxlos;
1007 }
1008 $bkdown_ctad_rules = [];
1009 if ($setctawdays && $current_cta) {
1010 $bkdown_ctad_rules[] = 'CTA: ' . implode(',', $this->weekDaysToShort($current_cta));
1011 }
1012 if ($setctdwdays && $current_ctd) {
1013 $bkdown_ctad_rules[] = 'CTD: ' . implode(',', $this->weekDaysToShort($current_ctd));
1014 }
1015 if ($bkdown_ctad_rules) {
1016 $bkdown .= ' [' . implode(' ', $bkdown_ctad_rules) . ']';
1017 }
1018 $bkdown .= "\n";
1019 }
1020 $bkdown = rtrim($bkdown, "\n");
1021 }
1022 if (!isset($channels_bkdown[$ch_id])) {
1023 $channels_bkdown[$ch_id] = $bkdown;
1024 } else {
1025 $channels_bkdown[$ch_id] .= "\n".$bkdown;
1026 }
1027 continue;
1028 }
1029 $ch_id = (int)$ch_id;
1030 if (substr($ch_res, 0, 6) == 'e4j.OK') {
1031 // success
1032 if (!isset($channels_success[$ch_id])) {
1033 $channels_success[$ch_id] = $channels_map[$ch_id];
1034 }
1035 } elseif (substr($ch_res, 0, 11) == 'e4j.warning') {
1036 // warning
1037 if (!isset($channels_warnings[$ch_id])) {
1038 $channels_warnings[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.warning.', '', $ch_res);
1039 } else {
1040 $channels_warnings[$ch_id] .= "\n".str_replace('e4j.warning.', '', $ch_res);
1041 }
1042 // add the channel also to the successful list in case of Warning
1043 if (!isset($channels_success[$ch_id])) {
1044 $channels_success[$ch_id] = $channels_map[$ch_id];
1045 }
1046 } elseif (substr($ch_res, 0, 9) == 'e4j.error') {
1047 // error
1048 if (!isset($channels_errors[$ch_id])) {
1049 $channels_errors[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.error.', '', $ch_res);
1050 } else {
1051 $channels_errors[$ch_id] .= "\n".str_replace('e4j.error.', '', $ch_res);
1052 }
1053 }
1054 }
1055 }
1056 }
1057 }
1058
1059 if ($channels_updated) {
1060 /**
1061 * We now support chained updates due to derived rate plans.
1062 * The "vcm" response property is now an array of objects with equal structure.
1063 *
1064 * @since 1.16.10 (J) - 1.6.10 (WP)
1065 */
1066 if (!isset($newly_rates['vcm'])) {
1067 $newly_rates['vcm'] = [];
1068 }
1069
1070 // build channels response data for the current rate plan
1071 $channels_response_data = [
1072 'rplan_id' => $now_id_price,
1073 'rplan_name' => $rate_plan_name,
1074 'is_derived' => $rplan_info['is_derived'],
1075 'channels_updated' => $channels_updated,
1076 ];
1077
1078 // set these property only if not empty
1079 if ($channels_bkdown) {
1080 $channels_response_data['channels_bkdown'] = $channels_bkdown['breakdown'];
1081 }
1082 if ($channels_success) {
1083 $channels_response_data['channels_success'] = $channels_success;
1084 }
1085 if ($channels_warnings) {
1086 $channels_response_data['channels_warnings'] = $channels_warnings;
1087 // cache channel warnings
1088 $this->channel_warnings = $channels_warnings;
1089 }
1090 if ($channels_errors) {
1091 $channels_response_data['channels_errors'] = $channels_errors;
1092 // cache channel errors
1093 $this->channel_errors = $channels_errors;
1094 }
1095
1096 // push channels response data to the pool
1097 $newly_rates['vcm'][] = $channels_response_data;
1098
1099 // cache the channels updated list by eventually merging what was already set
1100 foreach ($channels_updated as $idch => $chinfo) {
1101 $this->channels_updated_list[$idch] = $chinfo;
1102 }
1103
1104 // check for possible conflicts
1105 if (!$rplan_info['is_derived'] && !$split_ct_nodes && ($conflicts ?? false) === true) {
1106 // trigger an automatic bulk action for uploading the rates to ensure a full refresh
1107 VikChannelManager::autoBulkActions([
1108 'from_date' => $from_date,
1109 'to_date' => $to_date,
1110 'forced_rooms' => [$id_room],
1111 'update' => 'rates',
1112 ]);
1113 }
1114 }
1115 }
1116 }
1117
1118 return $newly_rates;
1119 }
1120
1121 /**
1122 * Returns the information about the channels updated with
1123 * the last rates/restrictions modification request, if any.
1124 *
1125 * @return array
1126 */
1127 public function getChannelsUpdated()
1128 {
1129 $channel_details = [];
1130
1131 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
1132
1133 foreach ($this->channels_updated_list as $idchannel => $channel_data) {
1134 $small_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getSmallLogoURL() : '';
1135 $tiny_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getTinyLogoURL() : '';
1136
1137 // set channel details
1138 $channel_details[] = [
1139 'id' => $channel_data['id'],
1140 'name' => $channel_data['name'],
1141 'logo' => $channel_data['logo'],
1142 'small_logo' => $small_logo_url,
1143 'tiny_logo' => $tiny_logo_url,
1144 ];
1145 }
1146
1147 return $channel_details;
1148 }
1149
1150 /**
1151 * Returns the associative list of channel warning messages.
1152 *
1153 * @return array
1154 */
1155 public function getChannelWarnings()
1156 {
1157 return $this->channel_warnings;
1158 }
1159
1160 /**
1161 * Returns the associative list of channel error messages.
1162 *
1163 * @return array
1164 */
1165 public function getChannelErrors()
1166 {
1167 return $this->channel_errors;
1168 }
1169
1170 /**
1171 * Calculates the restriction modifiers for a specific range of dates and room.
1172 *
1173 * @param int $start_ts The date range start date timestamp.
1174 * @param int $end_ts The date range end date timestamp.
1175 * @param int $room_id The VikBooking room ID.
1176 *
1177 * @return array Numeric list of modifiers (Max LOS, CTA, CTD, split nodes, conflicts).
1178 *
1179 * @since 1.17.1 (J) - 1.7.1 (WP)
1180 */
1181 public function calculateRestrictionModifiers($start_ts, $end_ts, $room_id)
1182 {
1183 // build cache signature
1184 $from_dt = date('Y-m-d', $start_ts);
1185 $to_dt = date('Y-m-d', $end_ts);
1186 $signature = $from_dt . '_' . $to_dt . '_' . $room_id;
1187
1188 if (!isset($this->cached_restrictions[$signature])) {
1189 // load and cache restrictions
1190 $this->cached_restrictions[$signature] = VikBooking::loadRestrictions(true, [$room_id]);
1191 }
1192
1193 // build default return values
1194 $calc_maxlos = 0;
1195 $calc_cta = [];
1196 $calc_ctd = [];
1197 $split_ct_nodes = [];
1198 $conflicts = false;
1199
1200 if (!($this->cached_restrictions[$signature] ?? [])) {
1201 // do not proceed as nothing would be found
1202 return [
1203 $calc_maxlos,
1204 $calc_cta,
1205 $calc_ctd,
1206 $split_ct_nodes,
1207 $conflicts,
1208 ];
1209 }
1210
1211 // list of week days involved
1212 $wdays_involved = [];
1213
1214 // loop through the requested range of dates
1215 $infostart = getdate($start_ts);
1216 $infofirst = $infostart;
1217 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
1218 // calculate timestamps
1219 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
1220 $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
1221
1222 // set week-day involved
1223 $wdays_involved[] = $infostart['wday'];
1224
1225 // calculate restrictions
1226 $restrictions = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $this->cached_restrictions[$signature] ?? []);
1227
1228 // check for max LOS
1229 if ($restrictions['maxlos'] ?? null) {
1230 if (!$calc_maxlos) {
1231 $calc_maxlos = (int) $restrictions['maxlos'];
1232 }
1233 }
1234
1235 // check for CTA week-days
1236 if ($restrictions['cta'] ?? []) {
1237 if (!$calc_cta) {
1238 $calc_cta = (array) $restrictions['cta'];
1239 $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['cta']);
1240 } else {
1241 $conflicts = $conflicts || $calc_cta != (array) $restrictions['cta'];
1242 }
1243 } elseif ($calc_cta) {
1244 $conflicts = true;
1245 }
1246
1247 // check for CTD week-days
1248 if ($restrictions['ctd'] ?? []) {
1249 if (!$calc_ctd) {
1250 $calc_ctd = (array) $restrictions['ctd'];
1251 $conflicts = $conflicts || !in_array($infostart['wday'], (array) $restrictions['ctd']);
1252 } else {
1253 $conflicts = $conflicts || $calc_ctd != (array) $restrictions['ctd'];
1254 }
1255 } elseif ($calc_ctd) {
1256 $conflicts = true;
1257 }
1258
1259 // go to next day
1260 $infostart = getdate($tomorrow_ts);
1261 }
1262
1263 if ($calc_cta) {
1264 // ensure it's a list of integers representing week-days
1265 $calc_cta = array_map(function($w) {
1266 return (int) str_replace('-', '', (string) $w);
1267 }, $calc_cta);
1268
1269 if (count($wdays_involved) < 7) {
1270 // filter the CTA week days according to the nights involved
1271 $calc_cta = array_filter($calc_cta, function($w) use ($wdays_involved) {
1272 return in_array($w, $wdays_involved);
1273 });
1274 }
1275 }
1276
1277 if ($calc_ctd) {
1278 // ensure it's a list of integers representing week-days
1279 $calc_ctd = array_map(function($w) {
1280 return (int) str_replace('-', '', (string) $w);
1281 }, $calc_ctd);
1282
1283 if (count($wdays_involved) < 7) {
1284 // filter the CTD week days according to the nights involved
1285 $calc_ctd = array_filter($calc_ctd, function($w) use ($wdays_involved) {
1286 return in_array($w, $wdays_involved);
1287 });
1288 }
1289 }
1290
1291 if ($conflicts && count($wdays_involved) < 7 && ($calc_cta || $calc_ctd)) {
1292 // conflicting cta/ctd restrictions for a range of dates less than a week may be split into multiple OTA nodes
1293 $day_ctad_list = [];
1294 for ($w = 0; $w < count($wdays_involved); $w++) {
1295 // build day individual cta/ctd restrictions
1296 $split_ts = mktime(0, 0, 0, $infofirst['mon'], $infofirst['mday'] + $w, $infofirst['year']);
1297 $day_ctad_list[] = [
1298 'ts' => $split_ts,
1299 'day' => date('Y-m-d', $split_ts),
1300 'cta' => in_array($wdays_involved[$w], $calc_cta) ? $calc_cta : [],
1301 'ctd' => in_array($wdays_involved[$w], $calc_ctd) ? $calc_ctd : [],
1302 ];
1303 }
1304
1305 // attempt to merge the split cta/ctd nodes for consecutive dates with equal rules
1306 $split_from_ts = 0;
1307 $split_to_ts = 0;
1308 $split_cta = [];
1309 $split_ctd = [];
1310 foreach ($day_ctad_list as $ct_node_restr) {
1311 if (!$split_from_ts) {
1312 // initialize values
1313 $split_from_ts = $ct_node_restr['ts'];
1314 $split_to_ts = $ct_node_restr['ts'];
1315 $split_cta = $ct_node_restr['cta'];
1316 $split_ctd = $ct_node_restr['ctd'];
1317 continue;
1318 }
1319 if ($split_cta != $ct_node_restr['cta'] || $split_ctd != $ct_node_restr['ctd']) {
1320 // close previous interval
1321 $split_ct_nodes[] = [
1322 'from_ts' => $split_from_ts,
1323 'to_ts' => $split_to_ts,
1324 'cta' => $split_cta,
1325 'ctd' => $split_ctd,
1326 ];
1327 // start new values
1328 $split_from_ts = $ct_node_restr['ts'];
1329 $split_to_ts = $ct_node_restr['ts'];
1330 $split_cta = $ct_node_restr['cta'];
1331 $split_ctd = $ct_node_restr['ctd'];
1332 } else {
1333 // increase till day timestamp
1334 $split_to_ts = $ct_node_restr['ts'];
1335 }
1336 }
1337 if (!$split_ct_nodes || $split_ct_nodes[count($split_ct_nodes) - 1]['to_ts'] != $split_from_ts) {
1338 // close last or only interval
1339 $split_ct_nodes[] = [
1340 'from_ts' => $split_from_ts,
1341 'to_ts' => $split_to_ts,
1342 'cta' => $split_cta,
1343 'ctd' => $split_ctd,
1344 ];
1345 }
1346
1347 // normalize timestamps
1348 foreach ($split_ct_nodes as &$split_ct_node) {
1349 $split_ct_node['from_dt'] = date('Y-m-d', $split_ct_node['from_ts']);
1350 $split_ct_node['to_dt'] = date('Y-m-d', $split_ct_node['to_ts']);
1351 }
1352
1353 // unset last reference
1354 unset($split_ct_node);
1355 }
1356
1357 // double check for possible conflicts
1358 $conflicts = count($wdays_involved) < 7 ? false : $conflicts;
1359
1360 return [
1361 // max LOS
1362 $calc_maxlos,
1363 // cta week-days
1364 $calc_cta,
1365 // ctd week-days
1366 $calc_ctd,
1367 // split cta/ctd nodes (range of dates)
1368 $split_ct_nodes,
1369 // whether some dates have conflicting modifiers
1370 $conflicts,
1371 ];
1372 }
1373
1374 /**
1375 * Detects if we are updating a secondary rate plan, probably not supported by the OTA.
1376 * Useful to prevent non-refundable rate plans to be transmitted to channels like Airbnb.
1377 *
1378 * @param int $idchannel The channel unique key.
1379 * @param array $room_rates The rate plan record details.
1380 * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1381 *
1382 * @return bool True if a secondary rate plan was detected.
1383 */
1384 protected function guessOTASecondaryRatePlan($idchannel, array $room_rates, array $room_cache)
1385 {
1386 $room_type_id = $room_rates['idroom'] ?? 0;
1387 $rate_plan_id = $room_rates['idprice'] ?? 0;
1388 $rate_plan_name = $room_rates['name'] ?? 'Standard';
1389
1390 if (!$room_cache) {
1391 // unable to perform a detection
1392 return false;
1393 }
1394
1395 // check how many rate plans are linked to the given channel identifier
1396 $cached_ota_rate_plans = [];
1397
1398 foreach ($room_cache as $price_id => $plan_cache) {
1399 if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1400 $cached_ota_rate_plans[] = $price_id;
1401 }
1402 }
1403
1404 if (!$cached_ota_rate_plans) {
1405 // unable to perform a detection due to missing bulk rates cache data
1406 return false;
1407 }
1408
1409 if (in_array($rate_plan_id, $cached_ota_rate_plans)) {
1410 // this rate plan was updated through a bulk action, so it's reliable
1411 return false;
1412 }
1413
1414 // access the room rate plan relations
1415 if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1416 $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1417 }
1418
1419 if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1420 // this room-type has got just one rate plan assigned, so it must be a parent rate
1421 return false;
1422 }
1423
1424 if (stripos($rate_plan_name, 'Standard') !== false) {
1425 // we assume this a main rate plan
1426 return false;
1427 }
1428
1429 // this is probably a secondary rate plan for this OTA
1430 return true;
1431 }
1432
1433 /**
1434 * Used for breakdown purposes, converts a list of week-day indexes.
1435 *
1436 * @param array $wdays List of zero-based week day indexes.
1437 *
1438 * @return array List of readable week days.
1439 *
1440 * @since 1.17.1 (J) - 1.7.1 (WP)
1441 */
1442 protected function weekDaysToShort(array $wdays)
1443 {
1444 $map = [
1445 'Sun',
1446 'Mon',
1447 'Tue',
1448 'Wed',
1449 'Thu',
1450 'Fri',
1451 'Sat',
1452 ];
1453
1454 return array_map(function($wday_index) use ($map) {
1455 $wday_index = (int) $wday_index;
1456 return $map[$wday_index] ?? $wday_index;
1457 }, $wdays);
1458 }
1459 }
1460