PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
1.8.16 1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 All 37 releases
vikbooking / admin / helpers / src / taxonomy / finance.php

finance.php in VikBooking Hotel Booking Engine & PMS 1.8.16, at admin/helpers/src/taxonomy/finance.php

906 lines 35.0 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) 2022 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 * Taxonomy finance helper class.
16 *
17 * @since 1.16.0 (J) - 1.6.0 (WP)
18 */
19 class VBOTaxonomyFinance
20 {
21 /**
22 * The singleton instance of the class.
23 *
24 * @var VBOTaxonomyFinance
25 */
26 protected static $instance = null;
27
28 /**
29 * Finance calculation options.
30 *
31 * @since 1.18.13 (J) - 1.8.13 (WP)
32 */
33 protected array $options = [];
34
35 /**
36 * Class constructor is protected.
37 *
38 * @param array $options Options to bind.
39 *
40 * @see getInstance()
41 */
42 protected function __construct(array $options)
43 {
44 // bind options
45 $this->options = $options;
46 }
47
48 /**
49 * Returns the global object, either a new instance,
50 * or the previously instantiated instance.
51 *
52 * @param ?array $options Optional options to bind.
53 *
54 * @return VBOTaxonomyFinance
55 */
56 public static function getInstance(?array $options = null)
57 {
58 if (is_null(static::$instance)) {
59 static::$instance = new static((array) $options);
60 }
61
62 return static::$instance;
63 }
64
65 /**
66 * Returns the current calculation options.
67 *
68 * @return array
69 *
70 * @since 1.18.13 (J) - 1.8.13 (WP)
71 */
72 public function getOptions()
73 {
74 return $this->options;
75 }
76
77 /**
78 * Binds calculation options.
79 *
80 * @param array $options Options to bind.
81 *
82 * @return VBOTaxonomyFinance
83 *
84 * @since 1.18.13 (J) - 1.8.13 (WP)
85 */
86 public function setOptions(array $options)
87 {
88 // bind options
89 $this->options = $options;
90
91 return $this;
92 }
93
94 /**
95 * Returns the number of total units for all rooms, or for a specific room.
96 * By default, the rooms unpublished are skipped, and all rooms are used.
97 *
98 * @param mixed $idroom int or array.
99 * @param bool $published true or false.
100 *
101 * @return int
102 */
103 public function countRooms($idroom = 0, $published = true)
104 {
105 $dbo = JFactory::getDbo();
106
107 $totrooms = 0;
108 $clauses = [];
109
110 if (is_int($idroom) && $idroom > 0) {
111 $clauses[] = "`id`=" . (int)$idroom;
112 } elseif (is_array($idroom) && count($idroom)) {
113 $idroom = array_map('intval', $idroom);
114 $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")";
115 }
116
117 if ($published) {
118 $clauses[] = "`avail`=1";
119 }
120
121 $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`" . (count($clauses) ? " WHERE " . implode(' AND ', $clauses) : "");
122 $dbo->setQuery($q);
123 $totrooms = (int)$dbo->loadResult();
124
125 return $totrooms;
126 }
127
128 /**
129 * Counts the number of nights of difference between two timestamps.
130 *
131 * @param int $to_ts the target end date timestamp.
132 * @param int $from_ts the starting date timestamp.
133 *
134 * @return int the nights of difference between from and to timestamps.
135 */
136 public function countNightsTo($to_ts, $from_ts = 0)
137 {
138 if (empty($from_ts)) {
139 $from_ts = time();
140 }
141
142 $from_ymd = date('Y-m-d', $from_ts);
143 $to_ymd = date('Y-m-d', $to_ts);
144
145 if ($from_ymd == $to_ymd) {
146 return 1;
147 }
148
149 $from_date = new DateTime($from_ymd);
150 $to_date = new DateTime($to_ymd);
151 $daysdiff = (int)$from_date->diff($to_date)->format('%a');
152
153 if ($to_ts < $from_ts) {
154 // we need a negative integer number in this case
155 $daysdiff = $daysdiff - ($daysdiff * 2);
156 }
157
158 if ($from_ymd != $to_ymd && $daysdiff > 0) {
159 // the to date is actually another night of stay
160 $daysdiff += 1;
161 }
162
163 return $daysdiff;
164 }
165
166 /**
167 * Counts the number of days of difference between two timestamps.
168 *
169 * @param int $from_ts the starting date timestamp.
170 * @param int $to_ts the target end date timestamp.
171 *
172 * @return int the days of difference from the given dates.
173 *
174 * @since 1.16.10 (J) - 1.6.10 (WP)
175 */
176 public function countDaysDiff($from_ts, $to_ts)
177 {
178 if (empty($from_ts) || empty($to_ts)) {
179 return 0;
180 }
181
182 $from_ymd = date('Y-m-d', $from_ts);
183 $to_ymd = date('Y-m-d', $to_ts);
184
185 if ($from_ymd == $to_ymd) {
186 return 0;
187 }
188
189 $from_date = new DateTime($from_ymd);
190 $to_date = new DateTime($to_ymd);
191 $daysdiff = (int)$from_date->diff($to_date)->format('%a');
192
193 if ($to_ts < $from_ts) {
194 // we need a negative integer number in this case
195 $daysdiff = $daysdiff - ($daysdiff * 2);
196 }
197
198 return $daysdiff;
199 }
200
201
202 /**
203 * Helper method to format long currency values into short numbers.
204 * I.e. 2.600.000 = 2.6M (empty decimals are always removed to keep the string short).
205 *
206 * @param int|float $num the amount to format.
207 * @param int $decimals the precision to use.
208 *
209 * @return string the formatted amount string.
210 */
211 public function numberFormatShort($num, $decimals = 1)
212 {
213 // get global formatting values
214 $formatvals = VikBooking::getNumberFormatData();
215 $formatparts = explode(':', $formatvals);
216
217 if ($num < 951) {
218 // 0 - 950
219 $short_amount = number_format($num, $decimals, $formatparts[1], $formatparts[2]);
220 $type_amount = '';
221 } elseif ($num < 900000) {
222 // 1k - 950k
223 $short_amount = number_format($num / 1000, $decimals, $formatparts[1], $formatparts[2]);
224 $type_amount = 'k';
225 } elseif ($num < 900000000) {
226 // 0.9m - 950m
227 $short_amount = number_format($num / 1000000, $decimals, $formatparts[1], $formatparts[2]);
228 $type_amount = 'm';
229 } elseif ($num < 900000000000) {
230 // 0.9b - 950b
231 $short_amount = number_format($num / 1000000000, $decimals, $formatparts[1], $formatparts[2]);
232 $type_amount = 'b';
233 } else {
234 // >= 0.9t
235 $short_amount = number_format($num / 1000000000000, $decimals, $formatparts[1], $formatparts[2]);
236 $type_amount = 't';
237 }
238
239 // unpad zeroes from the right
240 if ($decimals > 0) {
241 while (substr($short_amount, -1, 1) == '0') {
242 $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
243 }
244
245 if (substr($short_amount, -1, 1) == $formatparts[1]) {
246 // remove also the decimals separator if no more decimals
247 $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
248 }
249 }
250
251 // return the formatted amount string
252 return $short_amount . $type_amount;
253 }
254
255 /**
256 * Calculate the absolute percent amount between the current and previous financial stat.
257 * This method will use the following calculation: ((A1 - A2) / A2) * 100.
258 *
259 * @param float $stat the current (previously calculated) financial amount.
260 * @param float $compare the previous period financial amount.
261 * @param int $precision the precision for apply rounding.
262 *
263 * @return int|float the absolute percent amount calculated.
264 */
265 public function calcAbsPercent($current, $compare, $precision = 1)
266 {
267 if ($current == $compare) {
268 return 0;
269 }
270
271 if ($current > $compare && $compare < 1) {
272 return 100;
273 }
274
275 if ($current < $compare && $current < 1) {
276 return 100;
277 }
278
279 return round(abs(($current - $compare) / $compare * 100), $precision);
280 }
281
282 /**
283 * Obtain booking statistics from a range of dates and an
284 * optional list of room types. The data obtained will be
285 * based on the effective nights of stay within the range,
286 * unless type "booking_dates" to obtain different data.
287 *
288 * @param string $from the Y-m-d (or website) date from.
289 * @param string $to the Y-m-d (or website) date to.
290 * @param array $rooms list of room IDs to filter.
291 * @param string $type one among "stay_dates", "booking_dates" or "checkin".
292 *
293 * @return array associative list of information.
294 *
295 * @throws Exception
296 *
297 * @since 1.18.13 (J) - 1.8.13 (WP) added support to $type = "checkin".
298 */
299 public function getStats($from, $to, array $rooms = [], $type = 'stay_dates')
300 {
301 $dbo = JFactory::getDbo();
302
303 // access the availability helper
304 $av_helper = VikBooking::getAvailabilityInstance();
305
306 $from_ts = VikBooking::getDateTimestamp($from, 0, 0);
307 $to_ts = VikBooking::getDateTimestamp($to, 23, 59, 59);
308 if (empty($from) || empty($from_ts) || empty($to_ts) || $to_ts < $from_ts) {
309 throw new Exception('Invalid dates provided', 500);
310 }
311
312 // total number of days (nights) in the range
313 $total_range_nights = $this->countNightsTo($to_ts, $from_ts);
314 if ($total_range_nights < 1) {
315 throw new Exception('Invalid number of days provided', 500);
316 }
317
318 // total number of room units
319 $total_room_units = $this->countRooms($rooms);
320
321 if ($total_room_units < 1) {
322 throw new Exception('Having no rooms published may lead to divisions by zero, hence errors.', 500);
323 }
324
325 // the associative array of statistics to collect and return
326 $stats = [
327 // booking ids involved
328 'bids' => [],
329 // total number of room units counted for stats
330 'room_units' => $total_room_units,
331 // total number of room units times the number of nights in the range
332 'tot_inventory' => ($total_room_units * $total_range_nights),
333 // number of rooms booked
334 'rooms_booked' => 0,
335 // number of bookings found
336 'tot_bookings' => 0,
337 // total number of nights booked (proportionally adjusted according to affected dates)
338 'nights_booked' => 0,
339 // percent value
340 'occupancy' => 0,
341 // average length of stay
342 'avg_los' => 0,
343 // points of sale revenue (revenue divided by each individual ota and ibe)
344 'pos_revenue' => [],
345 // list of countries with ranking
346 'country_ranks' => [],
347 // ibe revenue net
348 'ibe_revenue' => 0,
349 // otas revenue net (no matter if commissions were applied)
350 'ota_revenue' => 0,
351 // total refunded amounts (never deducted)
352 'tot_refunds' => 0,
353 // average daily rate
354 'adr' => 0,
355 // revenue per available room
356 'revpar' => 0,
357 // average booking window
358 'abw' => 0,
359 // amount of all taxes
360 'taxes' => 0,
361 // amount of VAT
362 'tot_vat' => 0,
363 // amount of city taxes
364 'city_taxes' => 0,
365 // amount of damage deposits
366 'damage_deposits' => 0,
367 // commissions (amount)
368 'cmms' => 0,
369 // sum of room-level costs or custom costs
370 'room_level_revenue' => 0,
371 // net revenue before tax (otas + ibe)
372 'revenue' => 0,
373 // gross revenue after tax
374 'gross_revenue' => 0,
375 // otas total before tax (same as ota_revenue, but only if ota commissions were applied)
376 'ota_tot_net' => 0,
377 // otas commissions
378 'ota_cmms' => 0,
379 // percent value for average ota commissions amount
380 'ota_avg_cmms' => 0,
381 // commission savings amount
382 'cmm_savings' => 0,
383 // total cancelled reservations
384 'tot_cancellations' => 0,
385 // cancelled reservation IDs
386 'cancellation_ids' => [],
387 // total amount of cancelled bookings
388 'cancellations_amt' => 0,
389 ];
390
391 if ($this->options['booking_level'] ?? null) {
392 // include booking-level stats
393 $stats['_bid_stats'] = [];
394 }
395
396 // get all (real/completed) bookings in the given range of dates
397 $q = $dbo->getQuery(true);
398 $q->select($dbo->qn([
399 'o.id',
400 'o.ts',
401 'o.status',
402 'o.days',
403 'o.checkin',
404 'o.checkout',
405 'o.totpaid',
406 'o.coupon',
407 'o.roomsnum',
408 'o.total',
409 'o.idorderota',
410 'o.channel',
411 'o.country',
412 'o.tot_taxes',
413 'o.tot_city_taxes',
414 'o.tot_fees',
415 'o.tot_damage_dep',
416 'o.cmms',
417 'o.refund',
418 'or.idorder',
419 'or.idroom',
420 'or.optionals',
421 'or.cust_cost',
422 'or.cust_idiva',
423 'or.extracosts',
424 'or.room_cost',
425 'c.country_name',
426 ]));
427 $q->from($dbo->qn('#__vikbooking_orders', 'o'));
428 $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
429 $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country'));
430 $q->where($dbo->qn('o.total') . ' > 0');
431 $q->where($dbo->qn('o.closure') . ' = 0');
432 // use the "andWhere" method after having set some "where" clauses
433 $q->andWhere([
434 $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'),
435 $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled'),
436 ], 'OR');
437 if ($type == 'stay_dates') {
438 // regular calculation based on stay dates
439 $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts);
440 $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
441 } elseif ($type == 'checkin') {
442 // calculation based on check-in date
443 $q->where($dbo->qn('o.checkin') . ' >= ' . $from_ts);
444 $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
445 } else {
446 // calculation based on booked dates
447 $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts);
448 $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts);
449 }
450
451 /**
452 * Do not filter the room booking records by room ID, but always join them, to improve accuracy with calculations.
453 *
454 * @since 1.18.0 (J) - 1.8.0 (WP)
455 */
456 if ($rooms) {
457 // $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', array_map('intval', $rooms)) . ')');
458 }
459
460 $q->order($dbo->qn('o.checkin') . ' ASC');
461 $q->order($dbo->qn('o.id') . ' ASC');
462 $q->order($dbo->qn('or.id') . ' ASC');
463
464 $dbo->setQuery($q);
465 $records = $dbo->loadAssocList();
466
467 if (!$records) {
468 // no bookings found, do not proceed
469 return $stats;
470 }
471
472 /**
473 * Join the busy records afterwards to support accurate calculations for split stays or early departures/late arrivals.
474 * This is also needed to support multi-room reservations without joining the busy records together with the room records.
475 *
476 * @since 1.18.0 (J) - 1.8.0 (WP)
477 */
478 $busy_joined = [];
479 foreach ($records as &$booking) {
480 if (strcasecmp($booking['status'], 'confirmed')) {
481 // we only want to target confirmed bookings
482 continue;
483 }
484
485 // build cache signature for busy records joined
486 $booking_room_signature = $booking['id'] . '-' . $booking['idroom'];
487
488 // build the db query
489 $dbo->setQuery(
490 $dbo->getQuery(true)
491 ->select($dbo->qn('ob.idbusy'))
492 ->select($dbo->qn('b.checkin', 'room_checkin'))
493 ->select($dbo->qn('b.checkout', 'room_checkout'))
494 ->from($dbo->qn('#__vikbooking_ordersbusy', 'ob'))
495 ->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'))
496 ->where($dbo->qn('ob.idorder') . ' = ' . (int) $booking['id'])
497 ->where($dbo->qn('b.idroom') . ' = ' . (int) $booking['idroom'])
498 ->order($dbo->qn('b.id') . ' ASC')
499 );
500
501 // either fetch the records or use the previously cached ones to support multi-room bookings with equal room IDs
502 $busy_joined[$booking_room_signature] = $busy_joined[$booking_room_signature] ?? $dbo->loadAssocList();
503
504 // get the busy record to process for the current booking-room by shifting the list
505 $process_busy = array_shift($busy_joined[$booking_room_signature]);
506
507 if (!$process_busy) {
508 continue;
509 }
510
511 // inject busy details
512 $booking['idbusy'] = $process_busy['idbusy'];
513 $booking['room_checkin'] = $process_busy['room_checkin'];
514 $booking['room_checkout'] = $process_busy['room_checkout'];
515 }
516
517 // unset last reference and cached values
518 unset($booking, $busy_joined);
519
520 /**
521 * Immediately count cancellations and unset the records found.
522 *
523 * @since 1.16.10 (J) - 1.6.10 (WP)
524 */
525 foreach ($records as $k => $b) {
526 if (!strcasecmp($b['status'], 'cancelled')) {
527 if (!in_array($b['id'], $stats['cancellation_ids'])) {
528 // add statistics for the cancelled booking only once
529 $stats['tot_cancellations']++;
530 $stats['cancellations_amt'] += (float) $b['total'];
531 $stats['cancellation_ids'][] = $b['id'];
532 }
533
534 // get rid of this record to not alter the regular statistics
535 unset($records[$k]);
536 }
537 }
538
539 if ($stats['cancellation_ids']) {
540 // reset key values
541 $records = array_values($records);
542 }
543
544 // nest records with multiple rooms booked inside sub-arrays
545 $bookings = [];
546 foreach ($records as $b) {
547 if (!isset($bookings[$b['id']])) {
548 $bookings[$b['id']] = [];
549 }
550
551 // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals)
552 $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin'];
553 $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout'];
554 $in_info = getdate($room_checkin);
555 $out_info = getdate($room_checkout);
556 $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']);
557 $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']);
558 $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days'];
559 $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights'];
560
561 // push room-booking
562 $bookings[$b['id']][] = $b;
563 }
564
565 // free memory up
566 unset($records);
567
568 // counters and pools
569 $los_counter = 0;
570 $pos_pool = [];
571 $pos_counter = [];
572 $countries = [];
573 $country_map = [];
574
575 // sum of booking window
576 $sum_booking_window = 0;
577
578 // parse all bookings
579 foreach ($bookings as $bid => $booking) {
580 // increase tot bookings and los counter
581 if (!$rooms || ($rooms && array_intersect(array_column($booking, 'idroom'), $rooms))) {
582 // make sure to increase counters only if filtered listing(s) involved or if no filters set
583 $stats['tot_bookings']++;
584 $los_counter += $booking[0]['days'];
585 // push booking ID involved
586 $stats['bids'][] = $bid;
587 }
588
589 // count rooms booked within the reservation
590 $booking_rooms = count($booking);
591
592 // define the total booking amount for multi-room reservations
593 $multi_room_total = 0;
594
595 // define room-level revenue
596 $room_level_revenue = array_sum(
597 array_filter(
598 array_map(
599 'floatval',
600 array_merge(
601 array_column($booking, 'cust_cost'),
602 array_column($booking, 'room_cost')
603 )
604 )
605 )
606 );
607
608 if ($rooms && $booking[0]['roomsnum'] > 1) {
609 // when filters applied to multi-room bookings, count the effective number of rooms involved
610 $booking_rooms = count(array_intersect(array_column($booking, 'idroom'), $rooms));
611
612 // overwrite room total amount when filtering by listing(s)
613 foreach ($booking as $room_booking) {
614 if (!in_array($room_booking['idroom'], $rooms)) {
615 continue;
616 }
617 if ($room_booking['cust_cost'] > 0) {
618 $multi_room_total += $room_booking['cust_cost'];
619 } elseif ($room_booking['room_cost'] > 0) {
620 $multi_room_total += $room_booking['room_cost'];
621 }
622 }
623 }
624
625 // point of sale name
626 $pos_name = null;
627
628 // parse all rooms booked
629 foreach ($booking as $room_booking) {
630 if ($rooms && !in_array($room_booking['idroom'], $rooms)) {
631 // room booked is excluded from filters
632 continue;
633 }
634
635 // increase rooms booked
636 $stats['rooms_booked']++;
637
638 // number of nights affected
639 $los_affected = $room_booking['stay_nights'];
640
641 // use default total values
642 $room_total = $multi_room_total ?: $room_booking['total'];
643 $room_level_total = $room_level_revenue;
644 $room_cmms = $room_booking['cmms'];
645 $room_refund = $room_booking['refund'];
646 $room_tot_taxes = $room_booking['tot_taxes'];
647 $room_tot_city_taxes = $room_booking['tot_city_taxes'];
648 $room_tot_damage_dep = $room_booking['tot_damage_dep'];
649 $room_tot_fees = $room_booking['tot_fees'];
650
651 // check if amounts must be calculated proportionally for the range of dates requested
652 if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) {
653 // calculate number of nights of stay affected
654 $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']);
655
656 // adjust the amounts proportionally
657 $room_total = $room_total * $los_affected / $room_booking['stay_nights'];
658 $room_level_total = $room_level_total * $los_affected / $room_booking['stay_nights'];
659 $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights'];
660 $room_refund = $room_refund * $los_affected / $room_booking['stay_nights'];
661 $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights'];
662 $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights'];
663 $room_tot_damage_dep = $room_tot_damage_dep * $los_affected / $room_booking['stay_nights'];
664 $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights'];
665 }
666
667 // apply average values per room booked (with filters or booked in total)
668 $room_total /= $booking_rooms;
669 $room_level_total /= $booking_rooms;
670 $room_cmms /= $booking_rooms;
671 $room_refund /= $booking_rooms;
672 $room_tot_taxes /= $booking_rooms;
673 $room_tot_city_taxes /= $booking_rooms;
674 $room_tot_damage_dep /= $room_booking['roomsnum'];
675 $room_tot_fees /= $booking_rooms;
676
677 // calculate and sum average values per room booked
678 $tot_net = $multi_room_total ?: ($room_total - (float) $room_tot_taxes - (float) $room_tot_city_taxes - (float) $room_tot_fees - (float) $room_tot_damage_dep - (float) $room_cmms);
679 $tot_revenue = $multi_room_total ? ($tot_net / $booking_rooms) : $tot_net;
680 $stats['room_level_revenue'] += $room_level_total;
681 $stats['revenue'] += $tot_revenue;
682 $stats['gross_revenue'] += $room_total;
683 $stats['nights_booked'] += $los_affected;
684
685 // increase booking window for this room-booking
686 $booking_window = $this->countDaysDiff($room_booking['ts'], $room_booking['stay_from_ts']);
687 $sum_booking_window += $booking_window >= 0 ? $booking_window : 0;
688
689 // increase country stats
690 $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown';
691 if (!isset($countries[$country_code])) {
692 $countries[$country_code] = 0;
693 if (!empty($room_booking['country_name'])) {
694 $country_map[$country_code] = $room_booking['country_name'];
695 }
696 }
697 $countries[$country_code] += $tot_revenue;
698
699 if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) {
700 $stats['ota_revenue'] += $tot_revenue;
701 if ($room_cmms > 0) {
702 $stats['ota_tot_net'] += $tot_revenue;
703 $stats['ota_cmms'] += $room_cmms;
704 }
705 // set pos name
706 $channel_parts = explode('_', $room_booking['channel']);
707 $pos_name = trim($channel_parts[0]);
708 } else {
709 $stats['ibe_revenue'] += $tot_revenue;
710 // set pos name
711 $pos_name = 'website';
712 }
713
714 // set pos net revenue
715 if (!isset($pos_pool[$pos_name])) {
716 $pos_pool[$pos_name] = 0;
717 }
718 $pos_pool[$pos_name] += $tot_revenue;
719
720 $stats['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
721 $stats['tot_vat'] += (float) $room_tot_taxes;
722 $stats['city_taxes'] += (float) $room_tot_city_taxes;
723 $stats['damage_deposits'] += (float) $room_tot_damage_dep;
724 $stats['cmms'] += (float) $room_cmms;
725 $stats['tot_refunds'] += $room_refund;
726
727 /**
728 * Set booking-level stats.
729 *
730 * @since 1.18.13 (J) - 1.8.13 (WP)
731 */
732 if ($this->options['booking_level'] ?? null) {
733 $stats['_bid_stats'][$bid] = $stats['_bid_stats'][$bid] ?? [
734 'room_level_revenue' => 0,
735 'revenue' => 0,
736 'gross_revenue' => 0,
737 'taxes' => 0,
738 'tot_vat' => 0,
739 'city_taxes' => 0,
740 'damage_deposits' => 0,
741 'cmms' => 0,
742 'tot_refunds' => 0,
743 ];
744 $stats['_bid_stats'][$bid]['room_level_revenue'] += $room_level_total;
745 $stats['_bid_stats'][$bid]['revenue'] += $tot_revenue;
746 $stats['_bid_stats'][$bid]['gross_revenue'] += $room_total;
747 $stats['_bid_stats'][$bid]['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
748 $stats['_bid_stats'][$bid]['tot_vat'] += (float) $room_tot_taxes;
749 $stats['_bid_stats'][$bid]['city_taxes'] += (float) $room_tot_city_taxes;
750 $stats['_bid_stats'][$bid]['damage_deposits'] += (float) $room_tot_damage_dep;
751 $stats['_bid_stats'][$bid]['cmms'] += (float) $room_cmms;
752 $stats['_bid_stats'][$bid]['tot_refunds'] += $room_refund;
753 }
754 }
755
756 if ($pos_name) {
757 // increase number of bookings for this pos
758 if (!isset($pos_counter[$pos_name])) {
759 $pos_counter[$pos_name] = 0;
760 }
761 $pos_counter[$pos_name]++;
762 }
763 }
764
765 // count the average length of stay (no proportional data for the dates requested)
766 $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0;
767
768 // calculate occupancy percent value
769 $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2);
770
771 // count the average daily rate (ADR)
772 $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0;
773
774 // count the revenue per available room (RevPAR)
775 $stats['revpar'] = $stats['revenue'] / $total_room_units;
776
777 // count the average booking window (ABW - number of days between the reservation date and the check-in date)
778 $stats['abw'] = $stats['rooms_booked'] > 0 ? $sum_booking_window / $stats['rooms_booked'] : 0;
779
780 // count OTAs average commission amount
781 if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) {
782 // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x)
783 $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2);
784
785 if ($stats['ibe_revenue'] > 0) {
786 // calculate the commission savings amount
787 $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100;
788 }
789 }
790
791 // get channel logos helper
792 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
793
794 // sort and build pos revenues
795 if ($pos_pool && $stats['revenue'] > 0) {
796 // apply sorting descending
797 arsort($pos_pool);
798 // build readable pos values
799 foreach ($pos_pool as $pos_name => $pos_revenue) {
800 $pos_data = [
801 'name' => $pos_name,
802 'revenue' => $pos_revenue,
803 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2),
804 'logo' => null,
805 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0,
806 'ibe' => false,
807 'ota' => false,
808 ];
809 if (!strcasecmp($pos_name, 'website')) {
810 // ibe revenue
811 $pos_data['name'] = JText::translate('VBORDFROMSITE');
812 $pos_data['ibe'] = true;
813 } else {
814 // ota revenue
815 $pos_data['ota'] = true;
816 if (is_object($vcm_logos)) {
817 $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL();
818 if ($ota_logo_img !== false) {
819 $pos_data['logo'] = $ota_logo_img;
820 }
821 }
822 }
823 // push pos data
824 $stats['pos_revenue'][] = $pos_data;
825 }
826 }
827
828 // sort countries revenue
829 if ($countries && $stats['revenue'] > 0) {
830 // apply sorting descending
831 arsort($countries);
832 // build readable values
833 foreach ($countries as $country_code => $country_revenue) {
834 // push country data
835 $stats['country_ranks'][] = [
836 'code' => $country_code,
837 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code),
838 'revenue' => $country_revenue,
839 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2),
840 ];
841 }
842 }
843
844 // return the statistics information
845 return $stats;
846 }
847
848 /**
849 * Returns the information of the given rate plan ID.
850 *
851 * @param int $rplan_id The rate plan ID.
852 *
853 * @return ?array
854 *
855 * @since 1.18.2 (J) - 1.8.2 (WP)
856 */
857 public function getRatePlanData(int $rplan_id)
858 {
859 if (empty($rplan_id)) {
860 return null;
861 }
862
863 $dbo = JFactory::getDbo();
864
865 $dbo->setQuery(
866 $dbo->getQuery(true)
867 ->select('*')
868 ->from($dbo->qn('#__vikbooking_prices'))
869 ->where($dbo->qn('id') . ' = ' . $rplan_id)
870 );
871
872 return $dbo->loadAssoc();
873 }
874
875 /**
876 * Counts the number of nights involved in a range of dates. This is
877 * useful to proportionally calculate the amounts to be used.
878 *
879 * @param int $from_ts the 00:00:00 timestamp of the range start date.
880 * @param int $to_ts the 23:59:59 timestamp of the range end date.
881 * @param int $in_ts the 00:00:00 timestamp of the check-in date.
882 * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1).
883 *
884 * @return int the number of stay nights involved in the range.
885 */
886 protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts)
887 {
888 $nights_affected = 0;
889
890 if ($from_ts > $to_ts) {
891 return $nights_affected;
892 }
893
894 $range_from_info = getdate($from_ts);
895 while ($range_from_info[0] < $to_ts) {
896 if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) {
897 $nights_affected++;
898 }
899 // next day iteration
900 $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year']));
901 }
902
903 return $nights_affected;
904 }
905 }
906