PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.6.7
VikBooking Hotel Booking Engine & PMS v1.6.7
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.6.7, at admin/helpers/src/taxonomy/finance.php

604 lines 19.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 * Class constructor is protected.
30 *
31 * @see getInstance()
32 */
33 protected function __construct()
34 {
35 // do nothing
36 }
37
38 /**
39 * Returns the global object, either
40 * a new instance or the existing instance
41 * if the class was already instantiated.
42 *
43 * @return self A new instance of the class.
44 */
45 public static function getInstance()
46 {
47 if (is_null(static::$instance)) {
48 static::$instance = new static();
49 }
50
51 return static::$instance;
52 }
53
54 /**
55 * Returns the number of total units for all rooms, or for a specific room.
56 * By default, the rooms unpublished are skipped, and all rooms are used.
57 *
58 * @param mixed $idroom int or array.
59 * @param bool $published true or false.
60 *
61 * @return int
62 */
63 public function countRooms($idroom = 0, $published = true)
64 {
65 $dbo = JFactory::getDbo();
66
67 $totrooms = 0;
68 $clauses = [];
69
70 if (is_int($idroom) && $idroom > 0) {
71 $clauses[] = "`id`=" . (int)$idroom;
72 } elseif (is_array($idroom) && count($idroom)) {
73 $idroom = array_map('intval', $idroom);
74 $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")";
75 }
76
77 if ($published) {
78 $clauses[] = "`avail`=1";
79 }
80
81 $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`" . (count($clauses) ? " WHERE " . implode(' AND ', $clauses) : "");
82 $dbo->setQuery($q);
83 $totrooms = (int)$dbo->loadResult();
84
85 return $totrooms;
86 }
87
88 /**
89 * Counts the number of nights of difference between two timestamps.
90 *
91 * @param int $to_ts the target end date timestamp.
92 * @param int $from_ts the starting date timestamp.
93 *
94 * @return int the nights of difference between from and to timestamps.
95 */
96 public function countNightsTo($to_ts, $from_ts = 0)
97 {
98 if (empty($from_ts)) {
99 $from_ts = time();
100 }
101
102 $from_ymd = date('Y-m-d', $from_ts);
103 $to_ymd = date('Y-m-d', $to_ts);
104
105 if ($from_ymd == $to_ymd) {
106 return 1;
107 }
108
109 $from_date = new DateTime($from_ymd);
110 $to_date = new DateTime($to_ymd);
111 $daysdiff = (int)$from_date->diff($to_date)->format('%a');
112
113 if ($to_ts < $from_ts) {
114 // we need a negative integer number in this case
115 $daysdiff = $daysdiff - ($daysdiff * 2);
116 }
117
118 if ($from_ymd != $to_ymd && $daysdiff > 0) {
119 // the to date is actually another night of stay
120 $daysdiff += 1;
121 }
122
123 return $daysdiff;
124 }
125
126 /**
127 * Helper method to format long currency values into short numbers.
128 * I.e. 2.600.000 = 2.6M (empty decimals are always removed to keep the string short).
129 *
130 * @param int|float $num the amount to format.
131 * @param int $decimals the precision to use.
132 *
133 * @return string the formatted amount string.
134 */
135 public function numberFormatShort($num, $decimals = 1)
136 {
137 // get global formatting values
138 $formatvals = VikBooking::getNumberFormatData();
139 $formatparts = explode(':', $formatvals);
140
141 if ($num < 951) {
142 // 0 - 950
143 $short_amount = number_format($num, $decimals, $formatparts[1], $formatparts[2]);
144 $type_amount = '';
145 } elseif ($num < 900000) {
146 // 1k - 950k
147 $short_amount = number_format($num / 1000, $decimals, $formatparts[1], $formatparts[2]);
148 $type_amount = 'k';
149 } elseif ($num < 900000000) {
150 // 0.9m - 950m
151 $short_amount = number_format($num / 1000000, $decimals, $formatparts[1], $formatparts[2]);
152 $type_amount = 'm';
153 } elseif ($num < 900000000000) {
154 // 0.9b - 950b
155 $short_amount = number_format($num / 1000000000, $decimals, $formatparts[1], $formatparts[2]);
156 $type_amount = 'b';
157 } else {
158 // >= 0.9t
159 $short_amount = number_format($num / 1000000000000, $decimals, $formatparts[1], $formatparts[2]);
160 $type_amount = 't';
161 }
162
163 // unpad zeroes from the right
164 if ($decimals > 0) {
165 while (substr($short_amount, -1, 1) == '0') {
166 $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
167 }
168
169 if (substr($short_amount, -1, 1) == $formatparts[1]) {
170 // remove also the decimals separator if no more decimals
171 $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
172 }
173 }
174
175 // return the formatted amount string
176 return $short_amount . $type_amount;
177 }
178
179 /**
180 * Calculate the absolute percent amount between the current and previous financial stat.
181 * This method will use the following calculation: ((A1 - A2) / A2) * 100.
182 *
183 * @param float $stat the current (previously calculated) financial amount.
184 * @param float $compare the previous period financial amount.
185 * @param int $precision the precision for apply rounding.
186 *
187 * @return int|float the absolute percent amount calculated.
188 */
189 public function calcAbsPercent($current, $compare, $precision = 1)
190 {
191 if ($current == $compare) {
192 return 0;
193 }
194
195 if ($current > $compare && $compare < 1) {
196 return 100;
197 }
198
199 if ($current < $compare && $current < 1) {
200 return 100;
201 }
202
203 return round(abs(($current - $compare) / $compare * 100), $precision);
204 }
205
206 /**
207 * Obtain booking statistics from a range of dates and an
208 * optional list of room types. The data obtained will be
209 * based on the effective nights of stay within the range,
210 * unless type "booking_dates" to obtain different data.
211 *
212 * @param string $from the Y-m-d (or website) date from.
213 * @param string $to the Y-m-d (or website) date to.
214 * @param array $rooms list of room IDs to filter.
215 * @param string $type either "stay_dates" or "booking_dates".
216 *
217 * @return array associative list of information.
218 *
219 * @throws Exception
220 */
221 public function getStats($from, $to, $rooms = [], $type = 'stay_dates')
222 {
223 $dbo = JFactory::getDbo();
224
225 // access the availability helper
226 $av_helper = VikBooking::getAvailabilityInstance();
227
228 $from_ts = VikBooking::getDateTimestamp($from, 0, 0);
229 $to_ts = VikBooking::getDateTimestamp($to, 23, 59, 59);
230 if (empty($from) || empty($from_ts) || empty($to_ts) || $to_ts < $from_ts) {
231 throw new Exception('Invalid dates provided', 500);
232 }
233
234 // total number of days (nights) in the range
235 $total_range_nights = $this->countNightsTo($to_ts, $from_ts);
236 if ($total_range_nights < 1) {
237 throw new Exception('Invalid number of days provided', 500);
238 }
239
240 // total number of room units
241 $total_room_units = $this->countRooms($rooms);
242
243 if ($total_room_units < 1) {
244 throw new Exception('Having no rooms published may lead to divisions by zero, hence errors.', 500);
245 }
246
247 // the associative array of statistics to collect and return
248 $stats = [
249 // booking ids involved
250 'bids' => [],
251 // total number of room units counted for stats
252 'room_units' => $total_room_units,
253 // total number of room units times the number of nights in the range
254 'tot_inventory' => ($total_room_units * $total_range_nights),
255 // number of rooms booked
256 'rooms_booked' => 0,
257 // number of bookings found
258 'tot_bookings' => 0,
259 // total number of nights booked (proportionally adjusted according to affected dates)
260 'nights_booked' => 0,
261 // percent value
262 'occupancy' => 0,
263 // average length of stay
264 'avg_los' => 0,
265 // points of sale revenue (revenue divided by each individual ota and ibe)
266 'pos_revenue' => [],
267 // list of countries with ranking
268 'country_ranks' => [],
269 // ibe revenue net
270 'ibe_revenue' => 0,
271 // otas revenue net (no matter if commissions were applied)
272 'ota_revenue' => 0,
273 // total refunded amounts (never deducted)
274 'tot_refunds' => 0,
275 // average daily rate
276 'adr' => 0,
277 // revenue per available room
278 'revpar' => 0,
279 // amount of taxes
280 'taxes' => 0,
281 // commissions (amount)
282 'cmms' => 0,
283 // net revenue before tax (otas + ibe)
284 'revenue' => 0,
285 // gross revenue after tax
286 'gross_revenue' => 0,
287 // otas total before tax (same as ota_revenue, but only if ota commissions were applied)
288 'ota_tot_net' => 0,
289 // otas commissions
290 'ota_cmms' => 0,
291 // percent value for average ota commissions amount
292 'ota_avg_cmms' => 0,
293 // commission savings amount
294 'cmm_savings' => 0,
295 ];
296
297 // get all (real/completed) bookings in the given range of dates
298 $q = $dbo->getQuery(true);
299 $q->select($dbo->qn([
300 'o.id',
301 'o.ts',
302 'o.days',
303 'o.checkin',
304 'o.checkout',
305 'o.totpaid',
306 'o.roomsnum',
307 'o.total',
308 'o.idorderota',
309 'o.channel',
310 'o.country',
311 'o.tot_taxes',
312 'o.tot_city_taxes',
313 'o.tot_fees',
314 'o.cmms',
315 'o.refund',
316 'or.idorder',
317 'or.idroom',
318 'or.optionals',
319 'or.cust_cost',
320 'or.cust_idiva',
321 'or.extracosts',
322 'or.room_cost',
323 'ob.idbusy',
324 'c.country_name',
325 ]));
326 $q->select($dbo->qn('b.checkin', 'room_checkin'));
327 $q->select($dbo->qn('b.checkout', 'room_checkout'));
328 $q->from($dbo->qn('#__vikbooking_orders', 'o'));
329 $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
330 $q->leftjoin($dbo->qn('#__vikbooking_ordersbusy', 'ob') . ' ON ' . $dbo->qn('ob.idorder') . ' = ' . $dbo->qn('o.id'));
331 $q->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'));
332 $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country'));
333 $q->where($dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'));
334 $q->where($dbo->qn('o.total') . ' > 0');
335 $q->where($dbo->qn('o.closure') . ' = 0');
336 if ($type == 'stay_dates') {
337 // regular calculation based on stay dates
338 $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts);
339 $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
340 } else {
341 // calculation based on booked dates
342 $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts);
343 $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts);
344 }
345
346 if (is_array($rooms) && $rooms) {
347 $rooms = array_map('intval', $rooms);
348 $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', $rooms) . ')');
349 }
350
351 $q->order($dbo->qn('o.checkin') . ' ASC');
352 $q->order($dbo->qn('o.id') . ' ASC');
353
354 $dbo->setQuery($q);
355 $records = $dbo->loadAssocList();
356
357 if (!$records) {
358 // no bookings found, do not proceed
359 return $stats;
360 }
361
362 // nest records with multiple rooms booked inside sub-arrays
363 $bookings = [];
364 foreach ($records as $b) {
365 if (!isset($bookings[$b['id']])) {
366 $bookings[$b['id']] = [];
367 }
368 // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals)
369 $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin'];
370 $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout'];
371 $in_info = getdate($room_checkin);
372 $out_info = getdate($room_checkout);
373 $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']);
374 $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']);
375 $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days'];
376 $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights'];
377 // push room-booking
378 $bookings[$b['id']][] = $b;
379 }
380
381 // free memory up
382 unset($records);
383
384 // counters and pools
385 $los_counter = 0;
386 $pos_pool = [];
387 $pos_counter = [];
388 $countries = [];
389 $country_map = [];
390
391 // parse all bookings
392 foreach ($bookings as $bid => $booking) {
393 // push booking ID
394 $stats['bids'][] = $bid;
395
396 // increase tot bookings and los counter
397 $stats['tot_bookings']++;
398 $los_counter += $booking[0]['days'];
399
400 // use default total values
401 $booking_rooms = count($booking);
402 $room_total = $booking[0]['total'];
403 $room_cmms = $booking[0]['cmms'];
404 $room_refund = $booking[0]['refund'];
405 $room_tot_taxes = $booking[0]['tot_taxes'];
406 $room_tot_city_taxes = $booking[0]['tot_city_taxes'];
407 $room_tot_fees = $booking[0]['tot_fees'];
408
409 // point of sale name
410 $pos_name = null;
411
412 // parse all rooms booked
413 foreach ($booking as $room_booking) {
414 // increase rooms booked
415 $stats['rooms_booked']++;
416
417 // number of nights affected
418 $los_affected = $room_booking['stay_nights'];
419
420 // check if amounts must be calculated proportionally for the range of dates requested
421 if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) {
422 // calculate number of nights of stay affected
423 $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']);
424
425 // adjust the amounts proportionally
426 $room_total = $room_total * $los_affected / $room_booking['stay_nights'];
427 $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights'];
428 $room_refund = $room_refund * $los_affected / $room_booking['stay_nights'];
429 $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights'];
430 $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights'];
431 $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights'];
432 }
433
434 // apply average values per room booked
435 $room_total /= $booking_rooms;
436 $room_cmms /= $booking_rooms;
437 $room_refund /= $booking_rooms;
438 $room_tot_taxes /= $booking_rooms;
439 $room_tot_city_taxes /= $booking_rooms;
440 $room_tot_fees /= $booking_rooms;
441
442 // calculate and sum average values per room booked
443 $tot_net = $room_total - (float)$room_tot_taxes - (float)$room_tot_city_taxes - (float)$room_tot_fees - (float)$room_cmms;
444 $stats['revenue'] += $tot_net;
445 $stats['gross_revenue'] += $room_total;
446 $stats['nights_booked'] += $los_affected;
447
448 // increase country stats
449 $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown';
450 if (!isset($countries[$country_code])) {
451 $countries[$country_code] = 0;
452 if (!empty($room_booking['country_name'])) {
453 $country_map[$country_code] = $room_booking['country_name'];
454 }
455 }
456 $countries[$country_code] += $tot_net;
457
458 if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) {
459 $stats['ota_revenue'] += $tot_net;
460 if ($room_cmms > 0) {
461 $stats['ota_tot_net'] += $tot_net;
462 $stats['ota_cmms'] += $room_cmms;
463 }
464 // set pos name
465 $channel_parts = explode('_', $room_booking['channel']);
466 $pos_name = trim($channel_parts[0]);
467 } else {
468 $stats['ibe_revenue'] += $tot_net;
469 // set pos name
470 $pos_name = 'website';
471 }
472
473 // set pos net revenue
474 if (!isset($pos_pool[$pos_name])) {
475 $pos_pool[$pos_name] = 0;
476 }
477 $pos_pool[$pos_name] += $tot_net;
478
479 $stats['taxes'] += (float)$room_tot_taxes + (float)$room_tot_city_taxes + (float)$room_tot_fees;
480 $stats['cmms'] += (float)$room_cmms;
481 $stats['tot_refunds'] += $room_refund;
482 }
483
484 if ($pos_name) {
485 // increase number of bookings for this pos
486 if (!isset($pos_counter[$pos_name])) {
487 $pos_counter[$pos_name] = 0;
488 }
489 $pos_counter[$pos_name]++;
490 }
491 }
492
493 // count the average length of stay (no proportional data for the dates requested)
494 $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0;
495
496 // calculate occupancy percent value
497 $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2);
498
499 // count the average daily rate (ADR)
500 $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0;
501
502 // count the revenue per available room (RevPAR)
503 $stats['revpar'] = $stats['revenue'] / $total_room_units;
504
505 // count OTAs average commission amount
506 if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) {
507 // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x)
508 $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2);
509
510 if ($stats['ibe_revenue'] > 0) {
511 // calculate the commission savings amount
512 $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100;
513 }
514 }
515
516 // get channel logos helper
517 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
518
519 // sort and build pos revenues
520 if (count($pos_pool) && $stats['revenue'] > 0) {
521 // apply sorting descending
522 arsort($pos_pool);
523 // build readable pos values
524 foreach ($pos_pool as $pos_name => $pos_revenue) {
525 $pos_data = [
526 'name' => $pos_name,
527 'revenue' => $pos_revenue,
528 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2),
529 'logo' => null,
530 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0,
531 'ibe' => false,
532 'ota' => false,
533 ];
534 if (!strcasecmp($pos_name, 'website')) {
535 // ibe revenue
536 $pos_data['name'] = JText::translate('VBORDFROMSITE');
537 $pos_data['ibe'] = true;
538 } else {
539 // ota revenue
540 $pos_data['ota'] = true;
541 if (is_object($vcm_logos)) {
542 $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL();
543 if ($ota_logo_img !== false) {
544 $pos_data['logo'] = $ota_logo_img;
545 }
546 }
547 }
548 // push pos data
549 $stats['pos_revenue'][] = $pos_data;
550 }
551 }
552
553 // sort countries revenue
554 if (count($countries) && $stats['revenue'] > 0) {
555 // apply sorting descending
556 arsort($countries);
557 // build readable values
558 foreach ($countries as $country_code => $country_revenue) {
559 // push country data
560 $stats['country_ranks'][] = [
561 'code' => $country_code,
562 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code),
563 'revenue' => $country_revenue,
564 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2),
565 ];
566 }
567 }
568
569 // return the statistics information
570 return $stats;
571 }
572
573 /**
574 * Counts the number of nights involved in a range of dates. This is
575 * useful to proportionally calculate the amounts to be used.
576 *
577 * @param int $from_ts the 00:00:00 timestamp of the range start date.
578 * @param int $to_ts the 23:59:59 timestamp of the range end date.
579 * @param int $in_ts the 00:00:00 timestamp of the check-in date.
580 * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1).
581 *
582 * @return int the number of stay nights involved in the range.
583 */
584 protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts)
585 {
586 $nights_affected = 0;
587
588 if ($from_ts > $to_ts) {
589 return $nights_affected;
590 }
591
592 $range_from_info = getdate($from_ts);
593 while ($range_from_info[0] < $to_ts) {
594 if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) {
595 $nights_affected++;
596 }
597 // next day iteration
598 $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year']));
599 }
600
601 return $nights_affected;
602 }
603 }
604