PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.7.2
VikBooking Hotel Booking Engine & PMS v1.7.2
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.2, at admin/helpers/src/model/pricing.php

1,600 lines 54.7 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 // dates involved
170 $start_ts = strtotime($from_date);
171 $end_ts = strtotime($to_date);
172
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]);
179
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']);
188
189 // calculate tariffs for this day
190 $tars = VikBooking::applySeasonsRoom($roomrates, $today_tsin, $today_tsout, [], $cached_seasons);
191
192 foreach ($tars as $index => $tar) {
193 // apply rounding to 2 decimals at most
194 $tars[$index]['cost'] = round($tar['cost'], 2);
195
196 // set formatted cost
197 $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
198
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 }
210
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 }
218
219 // go to next day
220 $infostart = getdate($tomorrow_ts);
221 }
222
223 // free memory up
224 unset($cached_seasons);
225
226 // return the calculated rates
227 return $current_rates;
228 }
229
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();
243
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', []);
259
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 }
264
265 if (!$id_room) {
266 throw new InvalidArgumentException('Room record ID is mandatory.', 400);
267 }
268
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);
274
275 // load check-in and check-out times
276 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance([], true)->loadCheckinOutTimes();
277
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');
281
282 // access the availability helper
283 $av_helper = VikBooking::getAvailabilityInstance(true);
284
285 if (!$id_price) {
286 // load all rate plans
287 $all_rate_plans = $av_helper->loadRatePlans();
288
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 }
294
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 }
308
309 if (!$id_price) {
310 throw new Exception('No rate plans configured.', 500);
311 }
312
313 // load the eventually involved derived rate plans from the given rate ID
314 $derived_rate_plans = $av_helper->getDerivedRatePlans($id_price);
315
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 ];
326
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 }
332
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 }
354
355 if ($rplan_derived_rate < 0) {
356 // negative rates are not allowed
357 continue;
358 }
359
360 // make sure to apply rounding
361 $rplan_derived_rate = round($rplan_derived_rate, 2);
362
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 }
371
372 // the newly applied rates
373 $newly_rates = [];
374
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'];
379
380 // set rate to apply to the current rate plan
381 $rate = $rplan_info['rate'];
382
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;";
396
397 $dbo->setQuery($q);
398 $roomrates = $dbo->loadAssocList();
399
400 foreach ($roomrates as $rrk => $rrv) {
401 $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
402 $roomrates[$rrk]['days'] = 1;
403 }
404
405 if (!$roomrates) {
406 if ($rplan_info['is_derived']) {
407 // this is not the main rate plan we are updating
408 continue;
409 }
410
411 // terminate the process by throwing an error
412 throw new UnexpectedValueException('No rates found for the given room ID.', 400);
413 }
414
415 // turn the rates list into a single-level array
416 $roomrates = $roomrates[0];
417
418 // set rate plan name
419 $rate_plan_name = $roomrates['name'];
420
421 // dates involved
422 $start_ts = strtotime($from_date);
423 $end_ts = strtotime($to_date);
424
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]);
431
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
440 // apply seasonal rates by injecting the cached seasonal records
441 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons);
442
443 // apply rounding to 2 decimals at most
444 $tars[0]['cost'] = round($tars[0]['cost'], 2);
445
446 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
447
448 $infostart = getdate($tomorrow_ts);
449 }
450
451 if (!$current_rates) {
452 if ($rplan_info['is_derived']) {
453 // this is not the main rate plan we are updating
454 continue;
455 }
456
457 // terminate the process by throwing an error
458 throw new UnexpectedValueException('No seasonal rates found for the given room ID.', 400);
459 }
460
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 }
500
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 }
508
509 // terminate the process by throwing an error
510 throw new RuntimeException('No rates modification needed with the given parameters.', 500);
511 }
512 }
513
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']);
519
520 if ($second > 0 && $second == $first) {
521 $second += 86399;
522 }
523
524 if (!($second > $first)) {
525 unset($season_intervals[$sik]);
526 continue;
527 }
528
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;
535
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 }
553
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 . "-,";
560
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;
575
576 $dbo->insertObject('#__vikbooking_seasons', $season_record, 'id');
577
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 }
586
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);
592
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;
604
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 }
614
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']);
619
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);
632
633 if ($calc_maxlos) {
634 // set calculated max los
635 $max_los = $calc_maxlos;
636 }
637
638 if ($calc_cta) {
639 // set calculated cta week days
640 $cta_wdays = $calc_cta;
641 }
642
643 if ($calc_ctd) {
644 // set calculated ctd week days
645 $ctd_wdays = $calc_ctd;
646 }
647 }
648
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 }
660
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]);
663
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 }
681
682 // check if all dates involved share the same price
683 $common_rate = -1;
684
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);
690
691 // apply seasonal rates by injecting the cached seasonal records
692 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout, [], $cached_seasons);
693
694 // apply rounding to 2 decimals at most
695 $tars[0]['cost'] = round($tars[0]['cost'], 2);
696
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 }
707
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 }
713
714 $infostart = getdate($tomorrow_ts);
715 }
716
717 // free memory up
718 unset($cached_seasons);
719
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);
730
731 if ($rate > 0) {
732 // a new rate was set
733 $rflow_record->setNightlyFee($rate);
734 }
735
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 }
745
746 if (method_exists($rflow_record, 'setBaseFee')) {
747 $rflow_record->setBaseFee($roomrates['cost']);
748 }
749
750 // push rates flow record
751 $rflow_handler->pushRecord($rflow_record);
752
753 // store rates flow record
754 $rflow_handler->storeRecords();
755 }
756
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 }
762
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 = [];
776
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 }
790
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();
795
796 // check for custom ota pricing overrides
797 if ($ota_pricing) {
798 // build ota pricing overrides for each channel
799 $ota_pricing_overrides = [];
800
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 }
806
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);
811
812 if ($rmodamount <= 0) {
813 // invalid rate amount factor
814 continue;
815 }
816
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 }
829
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 }
841
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 ];
849
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 = '';
858
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 }
877
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 }
886
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 }
892
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 }
918
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'] = [];
923
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']) . ']' : '';
928
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 }
947
948 // set rate data
949 $node['pushdata'] = [
950 'pricetype' => $rd['rate_id'],
951 'defrate' => $roomrates['cost'],
952 'rplans' => [],
953 'cur_rplans' => [],
954 'rplanarimode' => [],
955 ];
956
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 }
964
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 }
978
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);
984
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 }
995
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 }
1002
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 }
1011
1012 // push update node
1013 $update_rows[] = $node;
1014 }
1015
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 }
1023
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 }
1038
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 }
1048
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 ];
1065
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 }
1072
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 }
1148
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 }
1159
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 ];
1167
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 }
1185
1186 // push channels response data to the pool
1187 $newly_rates['vcm'][] = $channels_response_data;
1188
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 }
1193
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 }
1207
1208 return $newly_rates;
1209 }
1210
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 = [];
1220
1221 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
1222
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() : '';
1226
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 }
1236
1237 return $channel_details;
1238 }
1239
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 }
1249
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 }
1259
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;
1277
1278 if (!isset($this->cached_restrictions[$signature])) {
1279 // load and cache restrictions
1280 $this->cached_restrictions[$signature] = VikBooking::loadRestrictions(true, [$room_id]);
1281 }
1282
1283 // build default return values
1284 $calc_maxlos = 0;
1285 $calc_cta = [];
1286 $calc_ctd = [];
1287 $split_ct_nodes = [];
1288 $conflicts = false;
1289
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 }
1300
1301 // list of week days involved
1302 $wdays_involved = [];
1303
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']);
1311
1312 // set week-day involved
1313 $wdays_involved[] = $infostart['wday'];
1314
1315 // calculate restrictions
1316 $restrictions = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $this->cached_restrictions[$signature] ?? []);
1317
1318 // check for max LOS
1319 if ($restrictions['maxlos'] ?? null) {
1320 if (!$calc_maxlos) {
1321 $calc_maxlos = (int) $restrictions['maxlos'];
1322 }
1323 }
1324
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 }
1336
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 }
1348
1349 // go to next day
1350 $infostart = getdate($tomorrow_ts);
1351 }
1352
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);
1358
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 }
1366
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);
1372
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 }
1380
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 }
1394
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 }
1436
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 }
1442
1443 // unset last reference
1444 unset($split_ct_node);
1445 }
1446
1447 // double check for possible conflicts
1448 $conflicts = count($wdays_involved) < 7 ? false : $conflicts;
1449
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 }
1463
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';
1479
1480 if (!$room_cache) {
1481 // unable to perform a detection
1482 return false;
1483 }
1484
1485 // check how many rate plans are linked to the given channel identifier
1486 $cached_ota_rate_plans = [];
1487
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 }
1493
1494 if (!$cached_ota_rate_plans) {
1495 // unable to perform a detection due to missing bulk rates cache data
1496 return false;
1497 }
1498
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 }
1503
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 }
1508
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 }
1513
1514 if (stripos($rate_plan_name, 'Standard') !== false) {
1515 // we assume this a main rate plan
1516 return false;
1517 }
1518
1519 // this is probably a secondary rate plan for this OTA
1520 return true;
1521 }
1522
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;
1539
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 }
1544
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 }
1549
1550 if (!$room_cache) {
1551 // unable to perform a detection
1552 return false;
1553 }
1554
1555 // check how many rate plans are linked to the given channel identifier
1556 $cached_ota_rate_plans = [];
1557
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 }
1563
1564 if (!$cached_ota_rate_plans) {
1565 // unable to perform a detection due to missing bulk rates cache data
1566 return false;
1567 }
1568
1569 // return whether this rate plan was updated through a bulk action
1570 return in_array($rate_plan_id, $cached_ota_rate_plans);
1571 }
1572
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 ];
1593
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 }
1599 }
1600