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

1,096 lines 37.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2024 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * VikBooking model pricing.
16 *
17 * @since 1.16.10 (J) - 1.6.10 (WP)
18 */
19 class VBOModelPricing extends JObject
20 {
21 /** @var array */
22 protected $room_rate_plans = [];
23
24 /** @var array */
25 protected $channels_updated_list = [];
26
27 /** @var array */
28 protected $channel_warnings = [];
29
30 /** @var array */
31 protected $channel_errors = [];
32
33 /**
34 * Proxy for immediately accessing the object and bind data.
35 *
36 * @param array|object $data optional data to bind.
37 * @param boolean $anew true for forcing a new instance.
38 *
39 * @return self
40 */
41 public static function getInstance($data = [])
42 {
43 return new static($data);
44 }
45
46 /**
47 * Returns the information about rates and restrictions for
48 * a given room in a range of dates.
49 *
50 * @param array $options Options for getting room rates.
51 *
52 * @return array
53 *
54 * @throws Exception
55 */
56 public function getRoomRates(array $options)
57 {
58 $dbo = JFactory::getDbo();
59
60 // gather options
61 $from_date = (string) ($options['from_date'] ?? '');
62 $to_date = (string) ($options['to_date'] ?? '');
63 $id_room = (int) ($options['id_room'] ?? 0);
64 $id_price = (int) ($options['id_price'] ?? 0);
65 $all_rplans = (bool) ($options['all_rplans'] ?? false);
66 $restrictions = (bool) ($options['restrictions'] ?? true);
67
68 if (!$from_date || !$to_date) {
69 // must be in Y-m-d format
70 throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
71 }
72
73 if (JFactory::getDate($to_date) < JFactory::getDate($from_date)) {
74 // invalid dates
75 throw new InvalidArgumentException('Invalid dates received.', 400);
76 }
77
78 if (!$id_room) {
79 throw new InvalidArgumentException('Room record ID is mandatory.', 400);
80 }
81
82 /**
83 * Disable season records caching because new rates will have to be re-calculated
84 * for the response by checking the same exact dates.
85 */
86 VikBooking::setSeasonsCache(false);
87
88 // load check-in and check-out times
89 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance()->loadCheckinOutTimes();
90
91 // date format
92 $vbo_df = VikBooking::getDateFormat();
93 $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
94
95 if (!$all_rplans) {
96 // get room rates either from the provided rate plan ID or by fetching the main one
97 if (!$id_price) {
98 // load all rate plans
99 $all_rate_plans = VikBooking::getAvailabilityInstance(true)->loadRatePlans();
100
101 // use the first (main) rate plan ID after the automatic sorting
102 foreach ($all_rate_plans as $all_rate_plan) {
103 $id_price = $all_rate_plan['id'];
104 break;
105 }
106 }
107
108 if (!$id_price) {
109 throw new Exception('No rate plans configured.', 500);
110 }
111
112 // read the rates for the lowest number of nights for a specific rate plan ID
113 $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
114 FROM `#__vikbooking_dispcost` AS `r`
115 INNER JOIN (
116 SELECT MIN(`days`) AS `min_days`
117 FROM `#__vikbooking_dispcost`
118 WHERE `idroom`=" . $id_room . " AND `idprice`=" . $id_price . "
119 GROUP BY `idroom`
120 ) AS `r2` ON `r`.`days`=`r2`.`min_days`
121 LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice` AND `p`.`id`=" . $id_price . "
122 WHERE `r`.`idroom`=" . $id_room . " AND `r`.`idprice`=" . $id_price . "
123 GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
124 ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
125 } else {
126 // get room rates from all rate plans configured for the given room
127 // read the rates for the lowest number of nights for all rate plans
128 $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
129 FROM `#__vikbooking_dispcost` AS `r`
130 INNER JOIN (
131 SELECT MIN(`days`) AS `min_days`
132 FROM `#__vikbooking_dispcost`
133 WHERE `idroom`=" . $id_room . "
134 GROUP BY `idroom`
135 ) AS `r2` ON `r`.`days`=`r2`.`min_days`
136 LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice`
137 WHERE `r`.`idroom`=" . $id_room . "
138 GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
139 ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
140 }
141
142 // load room rates from db
143 $dbo->setQuery($q);
144 $roomrates = $dbo->loadAssocList();
145
146 foreach ($roomrates as $rrk => $rrv) {
147 $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
148 $roomrates[$rrk]['days'] = 1;
149 }
150
151 if (!$roomrates) {
152 // terminate the process by throwing an error
153 throw new UnexpectedValueException('No rates found for the given room ID.', 400);
154 }
155
156 // fetch all restrictions, if requested
157 $all_restrictions = $restrictions ? VikBooking::loadRestrictions(true, [$id_room]) : [];
158
159 // calculate global minimum stay
160 $glob_minlos = VikBooking::getDefaultNightsCalendar();
161 $glob_minlos = $glob_minlos < 1 ? 1 : $glob_minlos;
162
163 // read current room rates
164 $current_rates = [];
165
166 // loop through all the requested range of dates
167 $start_ts = strtotime($from_date);
168 $end_ts = strtotime($to_date);
169 $infostart = getdate($start_ts);
170 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
171 // calculate timestamps
172 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
173 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
174 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
175 $today_mid_ts = mktime(0, 0, 0, $infostart['mon'], $infostart['mday'], $infostart['year']);
176
177 // calculate tariffs for this day
178 $tars = VikBooking::applySeasonsRoom($roomrates, $today_tsin, $today_tsout);
179
180 foreach ($tars as $index => $tar) {
181 // apply rounding to 2 decimals at most
182 $tars[$index]['cost'] = round($tar['cost'], 2);
183
184 // set formatted cost
185 $tars[$index]['formatted_cost'] = VikBooking::numberFormat($tar['cost']);
186
187 // calculate restrictions
188 $tars[$index]['restrictions'] = [];
189 if ($restrictions) {
190 $restr = VikBooking::parseSeasonRestrictions($today_mid_ts, $tomorrow_ts, 1, $all_restrictions);
191 if (!$restr) {
192 $restr = ['minlos' => $glob_minlos];
193 }
194 // set day restrictions
195 $tars[$index]['restrictions'] = $restr;
196 }
197 }
198
199 if (!$all_rplans) {
200 // set rate for this day (single rate plan)
201 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
202 } else {
203 // set rates for this day (all rate plans)
204 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars;
205 }
206
207 // go to next day
208 $infostart = getdate($tomorrow_ts);
209 }
210
211 return $current_rates;
212 }
213
214 /**
215 * Applies new rates and/or restrictions (Min LOS) to the given room and rate plan(s).
216 * Changes are always applied to the website rates, and eventually also on the OTAs.
217 *
218 * @return array
219 *
220 * @throws Exception
221 */
222 public function modifyRateRestrictions()
223 {
224 $dbo = JFactory::getDbo();
225
226 // expected and supported properties binded
227 $from_date = (string) $this->get('from_date', '');
228 $to_date = (string) $this->get('to_date', '');
229 $id_room = (int) $this->get('id_room', 0);
230 $id_price = (int) $this->get('id_price', 0);
231 $rplan_name = $this->get('rplan_name', '');
232 $rate = (float) $this->get('rate', 0);
233 $min_los = (int) $this->get('min_los', 0);
234 $upd_otas = (bool) $this->get('update_otas', true);
235 $close_rplan = (bool) $this->get('close_rate_plan', false);
236
237 if (!$from_date || !$to_date) {
238 // must be in Y-m-d format
239 throw new InvalidArgumentException('Missing dates for applying the new rates or restriction.', 400);
240 }
241
242 if (!$id_room) {
243 throw new InvalidArgumentException('Room record ID is mandatory.', 400);
244 }
245
246 /**
247 * Disable season records caching because new rates will have to be re-calculated
248 * for the response by checking the same exact dates.
249 */
250 VikBooking::setSeasonsCache(false);
251
252 // load check-in and check-out times
253 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = VBOModelReservation::getInstance([], true)->loadCheckinOutTimes();
254
255 // date format
256 $vbo_df = VikBooking::getDateFormat();
257 $df = $vbo_df == "%d/%m/%Y" ? 'd/m/Y' : ($vbo_df == "%m/%d/%Y" ? 'm/d/Y' : 'Y/m/d');
258
259 // access the availability helper
260 $av_helper = VikBooking::getAvailabilityInstance(true);
261
262 if (!$id_price) {
263 // load all rate plans
264 $all_rate_plans = $av_helper->loadRatePlans();
265
266 // use the first rate plan ID after the automatic sorting
267 foreach ($all_rate_plans as $all_rate_plan) {
268 $id_price = $all_rate_plan['id'];
269 break;
270 }
271
272 if ($rplan_name) {
273 // check if the given rate plan name is found
274 $rplan_name = trim(preg_replace("/[^a-z]/i", ' ', $rplan_name));
275 foreach ($all_rate_plans as $all_rate_plan) {
276 $match_against = trim(preg_replace("/[^a-z]/i", ' ', $all_rate_plan['name']));
277 if (stripos($match_against, $rplan_name) !== false || stripos($rplan_name, $match_against) !== false) {
278 // use the matched rate plan instead
279 $id_price = $all_rate_plan['id'];
280 break;
281 }
282 }
283 }
284 }
285
286 if (!$id_price) {
287 throw new Exception('No rate plans configured.', 500);
288 }
289
290 // load the eventually involved derived rate plans from the given rate ID
291 $derived_rate_plans = $av_helper->getDerivedRatePlans($id_price);
292
293 // build the list of rate plans involved by adding the requested one
294 $rate_plans_pool = [
295 $id_price => [
296 'id' => $id_price,
297 // the main rate plan selected for the update is NEVER considered as derived, even if it actually was.
298 'is_derived' => 0,
299 'derived_data' => null,
300 'rate' => $rate,
301 ],
302 ];
303
304 foreach ($derived_rate_plans as $derived_rate_plan) {
305 if (isset($rate_plans_pool[$derived_rate_plan['id']])) {
306 // skip duplicate rate plan
307 continue;
308 }
309
310 // calculate new rate for this derived rate plan
311 $rplan_derived_rate = $rate;
312 if (($derived_rate_plan['derived_data']['mode'] ?? 'discount') == 'discount') {
313 // discount rate
314 if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
315 // percent value
316 $rplan_derived_rate = $rplan_derived_rate * (100 - (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
317 } else {
318 // absolute value
319 $rplan_derived_rate -= (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
320 }
321 } else {
322 // increase rate
323 if (($derived_rate_plan['derived_data']['type'] ?? 'percent') == 'percent') {
324 // percent value
325 $rplan_derived_rate = $rplan_derived_rate * (100 + (float) ($derived_rate_plan['derived_data']['value'] ?? 0)) / 100;
326 } else {
327 // absolute value
328 $rplan_derived_rate += (float) ($derived_rate_plan['derived_data']['value'] ?? 0);
329 }
330 }
331
332 if ($rplan_derived_rate < 0) {
333 // negative rates are not allowed
334 continue;
335 }
336
337 // make sure to apply rounding
338 $rplan_derived_rate = round($rplan_derived_rate, 2);
339
340 // push derived rate plan to the update pool
341 $rate_plans_pool[$derived_rate_plan['id']] = [
342 'id' => $derived_rate_plan['id'],
343 'is_derived' => 1,
344 'derived_data' => $derived_rate_plan['derived_data'],
345 'rate' => $rplan_derived_rate,
346 ];
347 }
348
349 // the newly applied rates
350 $newly_rates = [];
351
352 // apply the pricing modification to all the involved rate plans
353 foreach ($rate_plans_pool as $rplan_id => $rplan_info) {
354 // set rate plan ID
355 $now_id_price = $rplan_info['id'];
356
357 // set rate to apply to the current rate plan
358 $rate = $rplan_info['rate'];
359
360 // read the rates for the lowest number of nights
361 $q = "SELECT `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
362 FROM `#__vikbooking_dispcost` AS `r`
363 INNER JOIN (
364 SELECT MIN(`days`) AS `min_days`
365 FROM `#__vikbooking_dispcost`
366 WHERE `idroom`=" . $id_room . " AND `idprice`=" . $now_id_price . "
367 GROUP BY `idroom`
368 ) AS `r2` ON `r`.`days`=`r2`.`min_days`
369 LEFT JOIN `#__vikbooking_prices` `p` ON `p`.`id`=`r`.`idprice` AND `p`.`id`=" . $now_id_price . "
370 WHERE `r`.`idroom`=" . $id_room . " AND `r`.`idprice`=" . $now_id_price . "
371 GROUP BY `r`.`id`,`r`.`idroom`,`r`.`days`,`r`.`idprice`,`r`.`cost`,`p`.`name`
372 ORDER BY `r`.`days` ASC, `r`.`cost` ASC;";
373
374 $dbo->setQuery($q);
375 $roomrates = $dbo->loadAssocList();
376
377 foreach ($roomrates as $rrk => $rrv) {
378 $roomrates[$rrk]['cost'] = round(($rrv['cost'] / $rrv['days']), 2);
379 $roomrates[$rrk]['days'] = 1;
380 }
381
382 if (!$roomrates) {
383 if ($rplan_info['is_derived']) {
384 // this is not the main rate plan we are updating
385 continue;
386 }
387
388 // terminate the process by throwing an error
389 throw new UnexpectedValueException('No rates found for the given room ID.', 400);
390 }
391
392 // turn the rates list into a single-level array
393 $roomrates = $roomrates[0];
394
395 // set rate plan name
396 $rate_plan_name = $roomrates['name'];
397
398 // read current room rates
399 $current_rates = [];
400 $start_ts = strtotime($from_date);
401 $end_ts = strtotime($to_date);
402 $infostart = getdate($start_ts);
403 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
404 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
405 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
406 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
407
408 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout);
409
410 // apply rounding to 2 decimals at most
411 $tars[0]['cost'] = round($tars[0]['cost'], 2);
412
413 $current_rates[(date('Y-m-d', $infostart[0]))] = $tars[0];
414
415 $infostart = getdate($tomorrow_ts);
416 }
417
418 if (!$current_rates) {
419 if ($rplan_info['is_derived']) {
420 // this is not the main rate plan we are updating
421 continue;
422 }
423
424 // terminate the process by throwing an error
425 throw new UnexpectedValueException('No seasonal rates found for the given room ID.', 400);
426 }
427
428 $all_days = array_keys($current_rates);
429 $season_intervals = [];
430 $firstind = 0;
431 $firstdaycost = $current_rates[$all_days[0]]['cost'];
432 $nextdaycost = false;
433 for ($i = 1; $i < count($all_days); $i++) {
434 $ind = $all_days[$i];
435 $nextdaycost = $current_rates[$ind]['cost'];
436 if ($firstdaycost != $nextdaycost) {
437 $interval = [
438 'from' => $all_days[$firstind],
439 'to' => $all_days[($i - 1)],
440 'cost' => $firstdaycost
441 ];
442 $season_intervals[] = $interval;
443 $firstdaycost = $nextdaycost;
444 $firstind = $i;
445 }
446 }
447 if ($nextdaycost === false) {
448 $interval = [
449 'from' => $all_days[$firstind],
450 'to' => $all_days[$firstind],
451 'cost' => $firstdaycost
452 ];
453 $season_intervals[] = $interval;
454 } elseif ($firstdaycost == $nextdaycost) {
455 $interval = [
456 'from' => $all_days[$firstind],
457 'to' => $all_days[($i - 1)],
458 'cost' => $firstdaycost
459 ];
460 $season_intervals[] = $interval;
461 }
462 foreach ($season_intervals as $sik => $siv) {
463 if ((float)$siv['cost'] == $rate) {
464 unset($season_intervals[$sik]);
465 }
466 }
467
468 if (!$season_intervals) {
469 // do not raise this error if it was requested to set the restriction or to close a rate plan
470 if ((!($min_los > 0) && !$close_rplan) || !$upd_otas) {
471 if ($rplan_info['is_derived']) {
472 // this is not the main rate plan we are updating
473 continue;
474 }
475
476 // terminate the process by throwing an error
477 throw new RuntimeException('No rates modification needed with the given parameters.', 500);
478 }
479 }
480
481 if ($rate > 0) {
482 // make sure to set a cost greater than zero to avoid errors
483 foreach ($season_intervals as $sik => $siv) {
484 $first = strtotime($siv['from']);
485 $second = strtotime($siv['to']);
486
487 if ($second > 0 && $second == $first) {
488 $second += 86399;
489 }
490
491 if (!($second > $first)) {
492 unset($season_intervals[$sik]);
493 continue;
494 }
495
496 $baseone = getdate($first);
497 $basets = mktime(0, 0, 0, 1, 1, $baseone['year']);
498 $sfrom = $baseone[0] - $basets;
499 $basetwo = getdate($second);
500 $basets = mktime(0, 0, 0, 1, 1, $basetwo['year']);
501 $sto = $basetwo[0] - $basets;
502
503 // check leap year
504 if ($baseone['year'] % 4 == 0 && ($baseone['year'] % 100 != 0 || $baseone['year'] % 400 == 0)) {
505 $leapts = mktime(0, 0, 0, 2, 29, $baseone['year']);
506 if ($baseone[0] > $leapts) {
507 $sfrom -= 86400;
508 /**
509 * To avoid issue with leap years and dates near Feb 29th, we only reduce the seconds if these were reduced
510 * for the from-date of the seasons. Doing it just for the to-date in 2019 for 2020 (leap) produced invalid results.
511 */
512 if ($basetwo['year'] % 4 == 0 && ($basetwo['year'] % 100 != 0 || $basetwo['year'] % 400 == 0)) {
513 $leapts = mktime(0, 0, 0, 2, 29, $basetwo['year']);
514 if ($basetwo[0] > $leapts) {
515 $sto -= 86400;
516 }
517 }
518 }
519 }
520
521 $tieyear = $baseone['year'];
522 $season_type = (float)$siv['cost'] > $rate ? "2" : "1";
523 $season_diffcost = $season_type == "1" ? ($rate - (float)$siv['cost']) : ((float)$siv['cost'] - $rate);
524 $roomstr = "-" . $id_room . "-,";
525 $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'] : '');
526 $pricestr = "-" . $now_id_price . "-,";
527
528 // build and store season record
529 $season_record = new stdClass;
530 $season_record->type = $season_type == "1" ? 1 : 2;
531 $season_record->from = $sfrom;
532 $season_record->to = $sto;
533 $season_record->diffcost = $season_diffcost;
534 $season_record->idrooms = $roomstr;
535 $season_record->spname = $season_name;
536 $season_record->wdays = '';
537 $season_record->checkinincl = 0;
538 $season_record->val_pcent = 1;
539 $season_record->losoverride = '';
540 $season_record->year = $tieyear;
541 $season_record->idprices = $pricestr;
542
543 $dbo->insertObject('#__vikbooking_seasons', $season_record, 'id');
544 }
545 }
546
547 $start_ts = strtotime($from_date);
548 $end_ts = strtotime($to_date);
549 $infostart = getdate($start_ts);
550 $infoend = getdate($end_ts);
551
552 /**
553 * Restrictions can be set only if VCM is enabled because we use the Connector Class.
554 * It is allowed to set just a restriction for the website without any rate modification.
555 * OTAs instead would need a rate to be passed in order to eventually transmit the restrictions.
556 */
557 $current_minlos = 0;
558 $vboConnector = null;
559
560 if (method_exists('VikChannelManager', 'getVikBookingConnectorInstance')) {
561 // invoke the Connector for any update request
562 $vboConnector = VikChannelManager::getVikBookingConnectorInstance();
563 // set the caller to 'VBO' to reduce the sleep time between the requests
564 $vboConnector->caller = 'VBO';
565 } else {
566 // make sure the OTA update flag is off
567 $upd_otas = false;
568 }
569
570 if ($min_los > 0 && $vboConnector) {
571 // set the end date to the last second
572 $end_ts = mktime(23, 59, 59, $infoend['mon'], $infoend['mday'], $infoend['year']);
573
574 if (!$rplan_info['is_derived']) {
575 // create the restriction in VBO (only for the parent rate since it will be a room-level restriction)
576 $restr_res = $vboConnector->createRestriction(date('Y-m-d H:i:s', $start_ts), date('Y-m-d H:i:s', $end_ts), [$id_room], [$min_los, '']);
577
578 // update value for the ajax response
579 if ($restr_res) {
580 $current_minlos = $min_los;
581 } else {
582 $current_minlos = 'e4j.error.' . $vboConnector->getError();
583 }
584 } else {
585 // always update the minimum stay information
586 $current_minlos = $min_los;
587 }
588 }
589
590 // check if all dates involved share the same price
591 $common_rate = -1;
592
593 // prepare output by re-calculating the new rates in real-time
594 while ($infostart[0] > 0 && $infostart[0] <= $end_ts) {
595 $tomorrow_ts = mktime(0, 0, 0, $infostart['mon'], ($infostart['mday'] + 1), $infostart['year']);
596 $today_tsin = VikBooking::getDateTimestamp(date($df, $infostart[0]), $checkin_h, $checkin_m);
597 $today_tsout = VikBooking::getDateTimestamp(date($df, $tomorrow_ts), $checkout_h, $checkout_m);
598
599 $tars = VikBooking::applySeasonsRoom([$roomrates], $today_tsin, $today_tsout);
600
601 // apply rounding to 2 decimals at most
602 $tars[0]['cost'] = round($tars[0]['cost'], 2);
603
604 if ($common_rate < 0) {
605 // save first-day common rate
606 $common_rate = $tars[0]['cost'];
607 } else {
608 // check if we've got a different rate for this day
609 if ($common_rate != $tars[0]['cost']) {
610 // freeze all controls, because this day has got a different cost
611 $common_rate = 0;
612 }
613 }
614
615 $indkey = $infostart['mday'] . '-' . $infostart['mon'] . '-' . $infostart['year'] . '-' . $now_id_price;
616 $newly_rates[$indkey] = $tars[0];
617 if (is_int($current_minlos) && $current_minlos > 0) {
618 $newly_rates[$indkey]['newminlos'] = $current_minlos;
619 }
620
621 $infostart = getdate($tomorrow_ts);
622 }
623
624 /**
625 * Store a record in the rates flow for this rate modification on VBO.
626 */
627 $rflow_handler = VikBooking::getRatesFlowInstance($anew = true);
628 if ($rflow_handler !== null) {
629 $rflow_record = $rflow_handler->getRecord()
630 ->setCreatedBy($this->get('_created_by', 'VBO'))
631 ->setDates($from_date, $to_date)
632 ->setVBORoomID($id_room)
633 ->setVBORatePlanID($now_id_price);
634
635 if ($rate > 0) {
636 // a new rate was set
637 $rflow_record->setNightlyFee($rate);
638 }
639
640 if (is_int($current_minlos) && $current_minlos > 0) {
641 // push restriction extra data
642 $rflow_record->setRestrictions(['minLOS' => $current_minlos]);
643 }
644
645 if (method_exists($rflow_record, 'setBaseFee')) {
646 $rflow_record->setBaseFee($roomrates['cost']);
647 }
648
649 // push rates flow record
650 $rflow_handler->pushRecord($rflow_record);
651
652 // store rates flow record
653 $rflow_handler->storeRecords();
654 }
655
656 // check if restrictions can be transmitted to OTAs in case of no rate given
657 if ($upd_otas && $rate <= 0 && is_int($current_minlos) && $current_minlos > 0 && $common_rate > 0) {
658 // ensure OTAs will get the minimum stay by using the rate shared by all dates involved
659 $rate = $common_rate;
660 }
661
662 /**
663 * Channels will only be updated if a rate greater than zero was passed,
664 * and of course if the flag to the update the OTAs is enabled. Restrictions
665 * alone could not be pushed to the OTAs, or rate threshold errors would occur.
666 */
667 if ($upd_otas && $rate > 0) {
668 // launch channel manager (from VBO, unlikely through the App, we update one rate plan per request)
669 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
670 $channels_updated = [];
671 $channels_bkdown = [];
672 $channels_success = [];
673 $channels_warnings = [];
674 $channels_errors = [];
675
676 // load room details
677 $q = "SELECT `id`,`name`,`units` FROM `#__vikbooking_rooms` WHERE `id`=" . $id_room . ";";
678 $dbo->setQuery($q);
679 $row = $dbo->loadAssoc();
680 if ($row) {
681 $row['channels'] = [];
682 // get the mapped channels for this room
683 $q = "SELECT * FROM `#__vikchannelmanager_roomsxref` WHERE `idroomvb`=" . $id_room . ";";
684 $dbo->setQuery($q);
685 foreach ($dbo->loadAssocList() as $ch_data) {
686 $row['channels'][$ch_data['idchannel']] = $ch_data;
687 }
688 }
689
690 if ($row && ($row['channels'] ?? [])) {
691 // this room is actually mapped to some channels supporting AV requests
692 // load the 'Bulk Action - Rates Upload' cache
693 $bulk_rates_cache = VikChannelManager::getBulkRatesCache();
694
695 // we update one rate plan per time, even though we could update all of them with a similar request
696 $rates_data = [
697 [
698 'rate_id' => $now_id_price,
699 'cost' => $rate,
700 ]
701 ];
702
703 // build the array with the update details
704 $update_rows = [];
705 foreach ($rates_data as $rk => $rd) {
706 $node = $row;
707 $setminlos = '';
708 $setmaxlos = '';
709
710 // pass the restrictions to the channels if specified
711 if (is_int($current_minlos) && $current_minlos > 0) {
712 $setminlos = $current_minlos;
713 // max los must have a length > 0 or min los won't be set
714 $setmaxlos = '0';
715 }
716
717 // check for follow restriction flag in a derived rate plan
718 if ($rplan_info['is_derived'] && !((bool) ($rplan_info['derived_data']['follow_restr'] ?? 1))) {
719 // unset restriction values for only updating the rates
720 $setminlos = '';
721 $setmaxlos = '';
722 }
723
724 // close rate plan (min or max los do not need to be set)
725 if ($close_rplan) {
726 // VikBookingConnector class in VCM requires the closure to be concatenated to maxlos
727 $setmaxlos .= 'closed';
728 }
729
730 // 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)
731 if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
732 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmod'] > 0 && (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] > 0) {
733 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodop'] > 0) {
734 // Increase rates
735 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
736 // Percentage charge
737 $rd['cost'] = $rd['cost'] * (100 + (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount']) / 100;
738 } else {
739 // Fixed charge
740 $rd['cost'] += (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
741 }
742 } else {
743 // Lower rates
744 if ((int) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodval'] > 0) {
745 // Percentage discount
746 $disc_op = $rd['cost'] * (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'] / 100;
747 $rd['cost'] -= $disc_op;
748 } else {
749 // Fixed discount
750 $rd['cost'] -= (float) $bulk_rates_cache[$id_room][$rd['rate_id']]['rmodamount'];
751 }
752 }
753 }
754 }
755
756 $node['ratesinventory'] = [
757 $from_date.'_'.$to_date.'_'.$setminlos.'_'.$setmaxlos.'_1_2_'.$rd['cost'].'_0',
758 ];
759
760 $node['pushdata'] = [
761 'pricetype' => $rd['rate_id'],
762 'defrate' => $roomrates['cost'],
763 'rplans' => [],
764 'cur_rplans' => [],
765 'rplanarimode' => [],
766 ];
767
768 // build push data for each channel rate plan according to the Bulk Rates Cache or to the OTA Pricing
769 if (($bulk_rates_cache[$id_room][$rd['rate_id']] ?? null)) {
770 // Bulk Rates Cache available for this room_id and rate_id
771 $node['pushdata']['rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplans'];
772 $node['pushdata']['cur_rplans'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['cur_rplans'];
773 $node['pushdata']['rplanarimode'] = $bulk_rates_cache[$id_room][$rd['rate_id']]['rplanarimode'];
774 }
775
776 // check the channels mapped for this room and add what was not found in the Bulk Rates Cache, if anything
777 foreach ($node['channels'] as $idchannel => $ch_data) {
778 if (!isset($node['pushdata']['rplans'][$idchannel])) {
779 // this channel was not found in the Bulk Rates Cache. Read data from OTA Pricing
780 $otapricing = json_decode($ch_data['otapricing'], true);
781 $ch_rplan_id = '';
782 if (is_array($otapricing) && isset($otapricing['RatePlan'])) {
783 foreach ($otapricing['RatePlan'] as $rpkey => $rpv) {
784 // get the first key (rate plan ID) of the RatePlan array from OTA Pricing
785 $ch_rplan_id = $rpkey;
786 break;
787 }
788 }
789
790 // prevent Airbnb from being updated if not for the main rate plan only
791 if ($idchannel == VikChannelManagerConfig::AIRBNBAPI) {
792 if ($rplan_info['is_derived'] || $this->guessOTASecondaryRatePlan($idchannel, $roomrates, ($bulk_rates_cache[$id_room] ?? []))) {
793 // skip this channel from updating a derived/secondary rate plan that would not exist
794 $ch_rplan_id = '';
795 }
796 }
797
798 // make sure an OTA rate plan ID was found
799 if (empty($ch_rplan_id)) {
800 // exclude this channel from being updated
801 unset($node['channels'][$idchannel]);
802 continue;
803 }
804
805 // set channel rate plan data
806 $node['pushdata']['rplans'][$idchannel] = $ch_rplan_id;
807 if ($idchannel == VikChannelManagerConfig::BOOKING) {
808 // Default Pricing is used by default, when no data available
809 $node['pushdata']['rplanarimode'][$idchannel] = 'person';
810 }
811 }
812 }
813
814 // push update node
815 $update_rows[] = $node;
816 }
817
818 // update rates on the various channels
819 $channels_map = [];
820 foreach ($update_rows as $update_row) {
821 if (!$update_row['channels']) {
822 // skip update for this room as no channels are involved
823 continue;
824 }
825
826 // set channels updated
827 foreach ($update_row['channels'] as $ch) {
828 if (($channels_updated[$ch['idchannel']] ?? [])) {
829 continue;
830 }
831 $channels_map[$ch['idchannel']] = ucfirst($ch['channel']);
832 $ota_logo_url = is_object($vcm_logos) ? $vcm_logos->setProvenience($ch['channel'])->getLogoURL() : false;
833 $channel_logo = $ota_logo_url !== false ? $ota_logo_url : '';
834 $channels_updated[$ch['idchannel']] = [
835 'id' => $ch['idchannel'],
836 'name' => ucfirst($ch['channel']),
837 'logo' => $channel_logo
838 ];
839 }
840
841 // prepare request data
842 $channels_ids = array_keys($update_row['channels']);
843 $channels_rplans = [];
844 foreach ($channels_ids as $ch_id) {
845 $ch_rplan = isset($update_row['pushdata']['rplans'][$ch_id]) ? $update_row['pushdata']['rplans'][$ch_id] : '';
846 $ch_rplan .= isset($update_row['pushdata']['rplanarimode'][$ch_id]) ? '='.$update_row['pushdata']['rplanarimode'][$ch_id] : '';
847 $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] : '';
848 $channels_rplans[] = $ch_rplan;
849 }
850
851 $channels = [
852 implode(',', $channels_ids)
853 ];
854 $chrplans = [
855 implode(',', $channels_rplans)
856 ];
857 $nodes = [
858 implode(';', $update_row['ratesinventory'])
859 ];
860 $rooms = [$id_room];
861 $pushvars = [
862 implode(';', [
863 $update_row['pushdata']['pricetype'],
864 $update_row['pushdata']['defrate'],
865 ])
866 ];
867
868 // send the request
869 $result = $vboConnector->channelsRatesPush($channels, $chrplans, $nodes, $rooms, $pushvars);
870 if ($vc_error = $vboConnector->getError(true)) {
871 $channels_errors[] = $vc_error;
872 continue;
873 }
874
875 // parse the channels update result and compose success, warnings, errors
876 $result_pool = json_decode($result, true);
877 foreach (($result_pool ?: []) as $rid => $ch_responses) {
878 foreach ($ch_responses as $ch_id => $ch_res) {
879 if ($ch_id == 'breakdown' || !is_numeric($ch_id)) {
880 // get the rates/dates breakdown of the update request
881 $bkdown = $ch_res;
882 if (is_array($ch_res)) {
883 $bkdown = '';
884 foreach ($ch_res as $bk => $bv) {
885 $bkparts = explode('-', $bk);
886 if (count($bkparts) == 6) {
887 // breakdown key is usually composed of two dates in Y-m-d concatenated with another "-".
888 $bkdown .= 'From ' . implode('-', array_slice($bkparts, 0, 3)) . ' - To ' . implode('-', array_slice($bkparts, 3, 3)) . ': ' . $bv . "\n";
889 } else {
890 $bkdown .= $bk . ': ' . $bv . "\n";
891 }
892 }
893 // since the Connector does not return breakdown info about the restrictions, we concatenate the response here for the Ajax request
894 if ((int)$setminlos > 0) {
895 $bkdown = rtrim($bkdown, "\n");
896 $bkdown .= ' - Min LOS: ' . $setminlos."\n";
897 }
898 $bkdown = rtrim($bkdown, "\n");
899 }
900 if (!isset($channels_bkdown[$ch_id])) {
901 $channels_bkdown[$ch_id] = $bkdown;
902 } else {
903 $channels_bkdown[$ch_id] .= "\n".$bkdown;
904 }
905 continue;
906 }
907 $ch_id = (int)$ch_id;
908 if (substr($ch_res, 0, 6) == 'e4j.OK') {
909 // success
910 if (!isset($channels_success[$ch_id])) {
911 $channels_success[$ch_id] = $channels_map[$ch_id];
912 }
913 } elseif (substr($ch_res, 0, 11) == 'e4j.warning') {
914 // warning
915 if (!isset($channels_warnings[$ch_id])) {
916 $channels_warnings[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.warning.', '', $ch_res);
917 } else {
918 $channels_warnings[$ch_id] .= "\n".str_replace('e4j.warning.', '', $ch_res);
919 }
920 // add the channel also to the successful list in case of Warning
921 if (!isset($channels_success[$ch_id])) {
922 $channels_success[$ch_id] = $channels_map[$ch_id];
923 }
924 } elseif (substr($ch_res, 0, 9) == 'e4j.error') {
925 // error
926 if (!isset($channels_errors[$ch_id])) {
927 $channels_errors[$ch_id] = $channels_map[$ch_id].': '.str_replace('e4j.error.', '', $ch_res);
928 } else {
929 $channels_errors[$ch_id] .= "\n".str_replace('e4j.error.', '', $ch_res);
930 }
931 }
932 }
933 }
934 }
935 }
936
937 if ($channels_updated) {
938 /**
939 * We now support chained updates due to derived rate plans.
940 * The "vcm" response property is now an array of objects with equal structure.
941 *
942 * @since 1.16.10 (J) - 1.6.10 (WP)
943 */
944 if (!isset($newly_rates['vcm'])) {
945 $newly_rates['vcm'] = [];
946 }
947
948 // build channels response data for the current rate plan
949 $channels_response_data = [
950 'rplan_id' => $now_id_price,
951 'rplan_name' => $rate_plan_name,
952 'is_derived' => $rplan_info['is_derived'],
953 'channels_updated' => $channels_updated,
954 ];
955
956 // set these property only if not empty
957 if ($channels_bkdown) {
958 $channels_response_data['channels_bkdown'] = $channels_bkdown['breakdown'];
959 }
960 if ($channels_success) {
961 $channels_response_data['channels_success'] = $channels_success;
962 }
963 if ($channels_warnings) {
964 $channels_response_data['channels_warnings'] = $channels_warnings;
965 // cache channel warnings
966 $this->channel_warnings = $channels_warnings;
967 }
968 if ($channels_errors) {
969 $channels_response_data['channels_errors'] = $channels_errors;
970 // cache channel errors
971 $this->channel_errors = $channels_errors;
972 }
973
974 // push channels response data to the pool
975 $newly_rates['vcm'][] = $channels_response_data;
976
977 // cache the channels updated list by eventually merging what was already set
978 foreach ($channels_updated as $idch => $chinfo) {
979 $this->channels_updated_list[$idch] = $chinfo;
980 }
981 }
982 }
983 }
984
985 return $newly_rates;
986 }
987
988 /**
989 * Returns the information about the channels updated with
990 * the last rates/restrictions modification request, if any.
991 *
992 * @return array
993 */
994 public function getChannelsUpdated()
995 {
996 $channel_details = [];
997
998 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
999
1000 foreach ($this->channels_updated_list as $idchannel => $channel_data) {
1001 $small_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getSmallLogoURL() : '';
1002 $tiny_logo_url = $vcm_logos ? $vcm_logos->setProvenience(strtolower($channel_data['name']))->getTinyLogoURL() : '';
1003
1004 // set channel details
1005 $channel_details[] = [
1006 'id' => $channel_data['id'],
1007 'name' => $channel_data['name'],
1008 'logo' => $channel_data['logo'],
1009 'small_logo' => $small_logo_url,
1010 'tiny_logo' => $tiny_logo_url,
1011 ];
1012 }
1013
1014 return $channel_details;
1015 }
1016
1017 /**
1018 * Returns the associative list of channel warning messages.
1019 *
1020 * @return array
1021 */
1022 public function getChannelWarnings()
1023 {
1024 return $this->channel_warnings;
1025 }
1026
1027 /**
1028 * Returns the associative list of channel error messages.
1029 *
1030 * @return array
1031 */
1032 public function getChannelErrors()
1033 {
1034 return $this->channel_errors;
1035 }
1036
1037 /**
1038 * Detects if we are updating a secondary rate plan, probably not supported by the OTA.
1039 * Useful to prevent non-refundable rate plans to be transmitted to channels like Airbnb.
1040 *
1041 * @param int $idchannel The channel unique key.
1042 * @param array $room_rates The rate plan record details.
1043 * @param array $room_cahe The Bulk Rates Cache for the current room-type.
1044 *
1045 * @return bool True if a secondary rate plan was detected.
1046 */
1047 protected function guessOTASecondaryRatePlan($idchannel, array $room_rates, array $room_cache)
1048 {
1049 $room_type_id = $room_rates['idroom'] ?? 0;
1050 $rate_plan_id = $room_rates['idprice'] ?? 0;
1051 $rate_plan_name = $room_rates['name'] ?? 'Standard';
1052
1053 if (!$room_cache) {
1054 // unable to perform a detection
1055 return false;
1056 }
1057
1058 // check how many rate plans are linked to the given channel identifier
1059 $cached_ota_rate_plans = [];
1060
1061 foreach ($room_cache as $price_id => $plan_cache) {
1062 if (is_array($plan_cache) && ($plan_cache['rplans'][$idchannel] ?? null)) {
1063 $cached_ota_rate_plans[] = $price_id;
1064 }
1065 }
1066
1067 if (!$cached_ota_rate_plans) {
1068 // unable to perform a detection due to missing bulk rates cache data
1069 return false;
1070 }
1071
1072 if (in_array($rate_plan_id, $cached_ota_rate_plans)) {
1073 // this rate plan was updated through a bulk action, so it's reliable
1074 return false;
1075 }
1076
1077 // access the room rate plan relations
1078 if (!($this->room_rate_plans[$room_type_id] ?? [])) {
1079 $this->room_rate_plans[$room_type_id] = VBORoomHelper::getInstance()->getRatePlans($room_type_id);
1080 }
1081
1082 if (count(($this->room_rate_plans[$room_type_id] ?: [])) < 2) {
1083 // this room-type has got just one rate plan assigned, so it must be a parent rate
1084 return false;
1085 }
1086
1087 if (stripos($rate_plan_name, 'Standard') !== false) {
1088 // we assume this a main rate plan
1089 return false;
1090 }
1091
1092 // this is probably a secondary rate plan for this OTA
1093 return true;
1094 }
1095 }
1096