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 / taxonomy / finance.php

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

885 lines 34.1 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 // net revenue before tax (otas + ibe)
370 'revenue' => 0,
371 // gross revenue after tax
372 'gross_revenue' => 0,
373 // otas total before tax (same as ota_revenue, but only if ota commissions were applied)
374 'ota_tot_net' => 0,
375 // otas commissions
376 'ota_cmms' => 0,
377 // percent value for average ota commissions amount
378 'ota_avg_cmms' => 0,
379 // commission savings amount
380 'cmm_savings' => 0,
381 // total cancelled reservations
382 'tot_cancellations' => 0,
383 // cancelled reservation IDs
384 'cancellation_ids' => [],
385 // total amount of cancelled bookings
386 'cancellations_amt' => 0,
387 ];
388
389 if ($this->options['booking_level'] ?? null) {
390 // include booking-level stats
391 $stats['_bid_stats'] = [];
392 }
393
394 // get all (real/completed) bookings in the given range of dates
395 $q = $dbo->getQuery(true);
396 $q->select($dbo->qn([
397 'o.id',
398 'o.ts',
399 'o.status',
400 'o.days',
401 'o.checkin',
402 'o.checkout',
403 'o.totpaid',
404 'o.coupon',
405 'o.roomsnum',
406 'o.total',
407 'o.idorderota',
408 'o.channel',
409 'o.country',
410 'o.tot_taxes',
411 'o.tot_city_taxes',
412 'o.tot_fees',
413 'o.tot_damage_dep',
414 'o.cmms',
415 'o.refund',
416 'or.idorder',
417 'or.idroom',
418 'or.optionals',
419 'or.cust_cost',
420 'or.cust_idiva',
421 'or.extracosts',
422 'or.room_cost',
423 'c.country_name',
424 ]));
425 $q->from($dbo->qn('#__vikbooking_orders', 'o'));
426 $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
427 $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country'));
428 $q->where($dbo->qn('o.total') . ' > 0');
429 $q->where($dbo->qn('o.closure') . ' = 0');
430 // use the "andWhere" method after having set some "where" clauses
431 $q->andWhere([
432 $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'),
433 $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled'),
434 ], 'OR');
435 if ($type == 'stay_dates') {
436 // regular calculation based on stay dates
437 $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts);
438 $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
439 } elseif ($type == 'checkin') {
440 // calculation based on check-in date
441 $q->where($dbo->qn('o.checkin') . ' >= ' . $from_ts);
442 $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
443 } else {
444 // calculation based on booked dates
445 $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts);
446 $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts);
447 }
448
449 /**
450 * Do not filter the room booking records by room ID, but always join them, to improve accuracy with calculations.
451 *
452 * @since 1.18.0 (J) - 1.8.0 (WP)
453 */
454 if ($rooms) {
455 // $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', array_map('intval', $rooms)) . ')');
456 }
457
458 $q->order($dbo->qn('o.checkin') . ' ASC');
459 $q->order($dbo->qn('o.id') . ' ASC');
460 $q->order($dbo->qn('or.id') . ' ASC');
461
462 $dbo->setQuery($q);
463 $records = $dbo->loadAssocList();
464
465 if (!$records) {
466 // no bookings found, do not proceed
467 return $stats;
468 }
469
470 /**
471 * Join the busy records afterwards to support accurate calculations for split stays or early departures/late arrivals.
472 * This is also needed to support multi-room reservations without joining the busy records together with the room records.
473 *
474 * @since 1.18.0 (J) - 1.8.0 (WP)
475 */
476 $busy_joined = [];
477 foreach ($records as &$booking) {
478 if (strcasecmp($booking['status'], 'confirmed')) {
479 // we only want to target confirmed bookings
480 continue;
481 }
482
483 // build cache signature for busy records joined
484 $booking_room_signature = $booking['id'] . '-' . $booking['idroom'];
485
486 // build the db query
487 $dbo->setQuery(
488 $dbo->getQuery(true)
489 ->select($dbo->qn('ob.idbusy'))
490 ->select($dbo->qn('b.checkin', 'room_checkin'))
491 ->select($dbo->qn('b.checkout', 'room_checkout'))
492 ->from($dbo->qn('#__vikbooking_ordersbusy', 'ob'))
493 ->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'))
494 ->where($dbo->qn('ob.idorder') . ' = ' . (int) $booking['id'])
495 ->where($dbo->qn('b.idroom') . ' = ' . (int) $booking['idroom'])
496 ->order($dbo->qn('b.id') . ' ASC')
497 );
498
499 // either fetch the records or use the previously cached ones to support multi-room bookings with equal room IDs
500 $busy_joined[$booking_room_signature] = $busy_joined[$booking_room_signature] ?? $dbo->loadAssocList();
501
502 // get the busy record to process for the current booking-room by shifting the list
503 $process_busy = array_shift($busy_joined[$booking_room_signature]);
504
505 if (!$process_busy) {
506 continue;
507 }
508
509 // inject busy details
510 $booking['idbusy'] = $process_busy['idbusy'];
511 $booking['room_checkin'] = $process_busy['room_checkin'];
512 $booking['room_checkout'] = $process_busy['room_checkout'];
513 }
514
515 // unset last reference and cached values
516 unset($booking, $busy_joined);
517
518 /**
519 * Immediately count cancellations and unset the records found.
520 *
521 * @since 1.16.10 (J) - 1.6.10 (WP)
522 */
523 foreach ($records as $k => $b) {
524 if (!strcasecmp($b['status'], 'cancelled')) {
525 if (!in_array($b['id'], $stats['cancellation_ids'])) {
526 // add statistics for the cancelled booking only once
527 $stats['tot_cancellations']++;
528 $stats['cancellations_amt'] += (float) $b['total'];
529 $stats['cancellation_ids'][] = $b['id'];
530 }
531
532 // get rid of this record to not alter the regular statistics
533 unset($records[$k]);
534 }
535 }
536
537 if ($stats['cancellation_ids']) {
538 // reset key values
539 $records = array_values($records);
540 }
541
542 // nest records with multiple rooms booked inside sub-arrays
543 $bookings = [];
544 foreach ($records as $b) {
545 if (!isset($bookings[$b['id']])) {
546 $bookings[$b['id']] = [];
547 }
548
549 // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals)
550 $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin'];
551 $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout'];
552 $in_info = getdate($room_checkin);
553 $out_info = getdate($room_checkout);
554 $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']);
555 $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']);
556 $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days'];
557 $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights'];
558
559 // push room-booking
560 $bookings[$b['id']][] = $b;
561 }
562
563 // free memory up
564 unset($records);
565
566 // counters and pools
567 $los_counter = 0;
568 $pos_pool = [];
569 $pos_counter = [];
570 $countries = [];
571 $country_map = [];
572
573 // sum of booking window
574 $sum_booking_window = 0;
575
576 // parse all bookings
577 foreach ($bookings as $bid => $booking) {
578 // increase tot bookings and los counter
579 if (!$rooms || ($rooms && array_intersect(array_column($booking, 'idroom'), $rooms))) {
580 // make sure to increase counters only if filtered listing(s) involved or if no filters set
581 $stats['tot_bookings']++;
582 $los_counter += $booking[0]['days'];
583 // push booking ID involved
584 $stats['bids'][] = $bid;
585 }
586
587 // count rooms booked within the reservation
588 $booking_rooms = count($booking);
589
590 // define the total booking amount for multi-room reservations
591 $multi_room_total = 0;
592
593 if ($rooms && $booking[0]['roomsnum'] > 1) {
594 // when filters applied to multi-room bookings, count the effective number of rooms involved
595 $booking_rooms = count(array_intersect(array_column($booking, 'idroom'), $rooms));
596
597 // overwrite room total amount when filtering by listing(s)
598 foreach ($booking as $room_booking) {
599 if (!in_array($room_booking['idroom'], $rooms)) {
600 continue;
601 }
602 if ($room_booking['cust_cost'] > 0) {
603 $multi_room_total += $room_booking['cust_cost'];
604 } elseif ($room_booking['room_cost'] > 0) {
605 $multi_room_total += $room_booking['room_cost'];
606 }
607 }
608 }
609
610 // point of sale name
611 $pos_name = null;
612
613 // parse all rooms booked
614 foreach ($booking as $room_booking) {
615 if ($rooms && !in_array($room_booking['idroom'], $rooms)) {
616 // room booked is excluded from filters
617 continue;
618 }
619
620 // increase rooms booked
621 $stats['rooms_booked']++;
622
623 // number of nights affected
624 $los_affected = $room_booking['stay_nights'];
625
626 // use default total values
627 $room_total = $multi_room_total ?: $room_booking['total'];
628 $room_cmms = $room_booking['cmms'];
629 $room_refund = $room_booking['refund'];
630 $room_tot_taxes = $room_booking['tot_taxes'];
631 $room_tot_city_taxes = $room_booking['tot_city_taxes'];
632 $room_tot_damage_dep = $room_booking['tot_damage_dep'];
633 $room_tot_fees = $room_booking['tot_fees'];
634
635 // check if amounts must be calculated proportionally for the range of dates requested
636 if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) {
637 // calculate number of nights of stay affected
638 $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']);
639
640 // adjust the amounts proportionally
641 $room_total = $room_total * $los_affected / $room_booking['stay_nights'];
642 $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights'];
643 $room_refund = $room_refund * $los_affected / $room_booking['stay_nights'];
644 $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights'];
645 $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights'];
646 $room_tot_damage_dep = $room_tot_damage_dep * $los_affected / $room_booking['stay_nights'];
647 $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights'];
648 }
649
650 // apply average values per room booked (with filters or booked in total)
651 $room_total /= $booking_rooms;
652 $room_cmms /= $booking_rooms;
653 $room_refund /= $booking_rooms;
654 $room_tot_taxes /= $booking_rooms;
655 $room_tot_city_taxes /= $booking_rooms;
656 $room_tot_damage_dep /= $room_booking['roomsnum'];
657 $room_tot_fees /= $booking_rooms;
658
659 // calculate and sum average values per room booked
660 $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);
661 $tot_revenue = $multi_room_total ? ($tot_net / $booking_rooms) : $tot_net;
662 $stats['revenue'] += $tot_revenue;
663 $stats['gross_revenue'] += $room_total;
664 $stats['nights_booked'] += $los_affected;
665
666 // increase booking window for this room-booking
667 $booking_window = $this->countDaysDiff($room_booking['ts'], $room_booking['stay_from_ts']);
668 $sum_booking_window += $booking_window >= 0 ? $booking_window : 0;
669
670 // increase country stats
671 $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown';
672 if (!isset($countries[$country_code])) {
673 $countries[$country_code] = 0;
674 if (!empty($room_booking['country_name'])) {
675 $country_map[$country_code] = $room_booking['country_name'];
676 }
677 }
678 $countries[$country_code] += $tot_revenue;
679
680 if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) {
681 $stats['ota_revenue'] += $tot_revenue;
682 if ($room_cmms > 0) {
683 $stats['ota_tot_net'] += $tot_revenue;
684 $stats['ota_cmms'] += $room_cmms;
685 }
686 // set pos name
687 $channel_parts = explode('_', $room_booking['channel']);
688 $pos_name = trim($channel_parts[0]);
689 } else {
690 $stats['ibe_revenue'] += $tot_revenue;
691 // set pos name
692 $pos_name = 'website';
693 }
694
695 // set pos net revenue
696 if (!isset($pos_pool[$pos_name])) {
697 $pos_pool[$pos_name] = 0;
698 }
699 $pos_pool[$pos_name] += $tot_revenue;
700
701 $stats['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
702 $stats['tot_vat'] += (float) $room_tot_taxes;
703 $stats['city_taxes'] += (float) $room_tot_city_taxes;
704 $stats['damage_deposits'] += (float) $room_tot_damage_dep;
705 $stats['cmms'] += (float) $room_cmms;
706 $stats['tot_refunds'] += $room_refund;
707
708 /**
709 * Set booking-level stats.
710 *
711 * @since 1.18.13 (J) - 1.8.13 (WP)
712 */
713 if ($this->options['booking_level'] ?? null) {
714 $stats['_bid_stats'][$bid] = $stats['_bid_stats'][$bid] ?? [
715 'revenue' => 0,
716 'gross_revenue' => 0,
717 'taxes' => 0,
718 'tot_vat' => 0,
719 'city_taxes' => 0,
720 'damage_deposits' => 0,
721 'cmms' => 0,
722 'tot_refunds' => 0,
723 ];
724 $stats['_bid_stats'][$bid]['revenue'] += $tot_revenue;
725 $stats['_bid_stats'][$bid]['gross_revenue'] += $room_total;
726 $stats['_bid_stats'][$bid]['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
727 $stats['_bid_stats'][$bid]['tot_vat'] += (float) $room_tot_taxes;
728 $stats['_bid_stats'][$bid]['city_taxes'] += (float) $room_tot_city_taxes;
729 $stats['_bid_stats'][$bid]['damage_deposits'] += (float) $room_tot_damage_dep;
730 $stats['_bid_stats'][$bid]['cmms'] += (float) $room_cmms;
731 $stats['_bid_stats'][$bid]['tot_refunds'] += $room_refund;
732 }
733 }
734
735 if ($pos_name) {
736 // increase number of bookings for this pos
737 if (!isset($pos_counter[$pos_name])) {
738 $pos_counter[$pos_name] = 0;
739 }
740 $pos_counter[$pos_name]++;
741 }
742 }
743
744 // count the average length of stay (no proportional data for the dates requested)
745 $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0;
746
747 // calculate occupancy percent value
748 $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2);
749
750 // count the average daily rate (ADR)
751 $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0;
752
753 // count the revenue per available room (RevPAR)
754 $stats['revpar'] = $stats['revenue'] / $total_room_units;
755
756 // count the average booking window (ABW - number of days between the reservation date and the check-in date)
757 $stats['abw'] = $stats['rooms_booked'] > 0 ? $sum_booking_window / $stats['rooms_booked'] : 0;
758
759 // count OTAs average commission amount
760 if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) {
761 // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x)
762 $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2);
763
764 if ($stats['ibe_revenue'] > 0) {
765 // calculate the commission savings amount
766 $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100;
767 }
768 }
769
770 // get channel logos helper
771 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
772
773 // sort and build pos revenues
774 if ($pos_pool && $stats['revenue'] > 0) {
775 // apply sorting descending
776 arsort($pos_pool);
777 // build readable pos values
778 foreach ($pos_pool as $pos_name => $pos_revenue) {
779 $pos_data = [
780 'name' => $pos_name,
781 'revenue' => $pos_revenue,
782 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2),
783 'logo' => null,
784 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0,
785 'ibe' => false,
786 'ota' => false,
787 ];
788 if (!strcasecmp($pos_name, 'website')) {
789 // ibe revenue
790 $pos_data['name'] = JText::translate('VBORDFROMSITE');
791 $pos_data['ibe'] = true;
792 } else {
793 // ota revenue
794 $pos_data['ota'] = true;
795 if (is_object($vcm_logos)) {
796 $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL();
797 if ($ota_logo_img !== false) {
798 $pos_data['logo'] = $ota_logo_img;
799 }
800 }
801 }
802 // push pos data
803 $stats['pos_revenue'][] = $pos_data;
804 }
805 }
806
807 // sort countries revenue
808 if ($countries && $stats['revenue'] > 0) {
809 // apply sorting descending
810 arsort($countries);
811 // build readable values
812 foreach ($countries as $country_code => $country_revenue) {
813 // push country data
814 $stats['country_ranks'][] = [
815 'code' => $country_code,
816 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code),
817 'revenue' => $country_revenue,
818 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2),
819 ];
820 }
821 }
822
823 // return the statistics information
824 return $stats;
825 }
826
827 /**
828 * Returns the information of the given rate plan ID.
829 *
830 * @param int $rplan_id The rate plan ID.
831 *
832 * @return ?array
833 *
834 * @since 1.18.2 (J) - 1.8.2 (WP)
835 */
836 public function getRatePlanData(int $rplan_id)
837 {
838 if (empty($rplan_id)) {
839 return null;
840 }
841
842 $dbo = JFactory::getDbo();
843
844 $dbo->setQuery(
845 $dbo->getQuery(true)
846 ->select('*')
847 ->from($dbo->qn('#__vikbooking_prices'))
848 ->where($dbo->qn('id') . ' = ' . $rplan_id)
849 );
850
851 return $dbo->loadAssoc();
852 }
853
854 /**
855 * Counts the number of nights involved in a range of dates. This is
856 * useful to proportionally calculate the amounts to be used.
857 *
858 * @param int $from_ts the 00:00:00 timestamp of the range start date.
859 * @param int $to_ts the 23:59:59 timestamp of the range end date.
860 * @param int $in_ts the 00:00:00 timestamp of the check-in date.
861 * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1).
862 *
863 * @return int the number of stay nights involved in the range.
864 */
865 protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts)
866 {
867 $nights_affected = 0;
868
869 if ($from_ts > $to_ts) {
870 return $nights_affected;
871 }
872
873 $range_from_info = getdate($from_ts);
874 while ($range_from_info[0] < $to_ts) {
875 if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) {
876 $nights_affected++;
877 }
878 // next day iteration
879 $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year']));
880 }
881
882 return $nights_affected;
883 }
884 }
885