PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
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 1.7.3 All 36 releases
vikbooking / admin / helpers / src / model / pricing.php

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

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