Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
wp-content
/
plugins
/
vikbooking
/
admin
/
helpers
/
src
/
taxonomy
:
finance.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?php /** * @package VikBooking * @subpackage core * @author E4J s.r.l. * @copyright Copyright (C) 2022 E4J s.r.l. All Rights Reserved. * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL * @link https://vikwp.com */ // No direct access defined('ABSPATH') or die('No script kiddies please!'); /** * Taxonomy finance helper class. * * @since 1.16.0 (J) - 1.6.0 (WP) */ class VBOTaxonomyFinance { /** * The singleton instance of the class. * * @var VBOTaxonomyFinance */ protected static $instance = null; /** * Class constructor is protected. * * @see getInstance() */ protected function __construct() { // do nothing } /** * Returns the global object, either * a new instance or the existing instance * if the class was already instantiated. * * @return self A new instance of the class. */ public static function getInstance() { if (is_null(static::$instance)) { static::$instance = new static(); } return static::$instance; } /** * Returns the number of total units for all rooms, or for a specific room. * By default, the rooms unpublished are skipped, and all rooms are used. * * @param mixed $idroom int or array. * @param bool $published true or false. * * @return int */ public function countRooms($idroom = 0, $published = true) { $dbo = JFactory::getDbo(); $totrooms = 0; $clauses = []; if (is_int($idroom) && $idroom > 0) { $clauses[] = "`id`=" . (int)$idroom; } elseif (is_array($idroom) && count($idroom)) { $idroom = array_map('intval', $idroom); $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")"; } if ($published) { $clauses[] = "`avail`=1"; } $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`" . (count($clauses) ? " WHERE " . implode(' AND ', $clauses) : ""); $dbo->setQuery($q); $totrooms = (int)$dbo->loadResult(); return $totrooms; } /** * Counts the number of nights of difference between two timestamps. * * @param int $to_ts the target end date timestamp. * @param int $from_ts the starting date timestamp. * * @return int the nights of difference between from and to timestamps. */ public function countNightsTo($to_ts, $from_ts = 0) { if (empty($from_ts)) { $from_ts = time(); } $from_ymd = date('Y-m-d', $from_ts); $to_ymd = date('Y-m-d', $to_ts); if ($from_ymd == $to_ymd) { return 1; } $from_date = new DateTime($from_ymd); $to_date = new DateTime($to_ymd); $daysdiff = (int)$from_date->diff($to_date)->format('%a'); if ($to_ts < $from_ts) { // we need a negative integer number in this case $daysdiff = $daysdiff - ($daysdiff * 2); } if ($from_ymd != $to_ymd && $daysdiff > 0) { // the to date is actually another night of stay $daysdiff += 1; } return $daysdiff; } /** * Counts the number of days of difference between two timestamps. * * @param int $from_ts the starting date timestamp. * @param int $to_ts the target end date timestamp. * * @return int the days of difference from the given dates. * * @since 1.16.10 (J) - 1.6.10 (WP) */ public function countDaysDiff($from_ts, $to_ts) { if (empty($from_ts) || empty($to_ts)) { return 0; } $from_ymd = date('Y-m-d', $from_ts); $to_ymd = date('Y-m-d', $to_ts); if ($from_ymd == $to_ymd) { return 0; } $from_date = new DateTime($from_ymd); $to_date = new DateTime($to_ymd); $daysdiff = (int)$from_date->diff($to_date)->format('%a'); if ($to_ts < $from_ts) { // we need a negative integer number in this case $daysdiff = $daysdiff - ($daysdiff * 2); } return $daysdiff; } /** * Helper method to format long currency values into short numbers. * I.e. 2.600.000 = 2.6M (empty decimals are always removed to keep the string short). * * @param int|float $num the amount to format. * @param int $decimals the precision to use. * * @return string the formatted amount string. */ public function numberFormatShort($num, $decimals = 1) { // get global formatting values $formatvals = VikBooking::getNumberFormatData(); $formatparts = explode(':', $formatvals); if ($num < 951) { // 0 - 950 $short_amount = number_format($num, $decimals, $formatparts[1], $formatparts[2]); $type_amount = ''; } elseif ($num < 900000) { // 1k - 950k $short_amount = number_format($num / 1000, $decimals, $formatparts[1], $formatparts[2]); $type_amount = 'k'; } elseif ($num < 900000000) { // 0.9m - 950m $short_amount = number_format($num / 1000000, $decimals, $formatparts[1], $formatparts[2]); $type_amount = 'm'; } elseif ($num < 900000000000) { // 0.9b - 950b $short_amount = number_format($num / 1000000000, $decimals, $formatparts[1], $formatparts[2]); $type_amount = 'b'; } else { // >= 0.9t $short_amount = number_format($num / 1000000000000, $decimals, $formatparts[1], $formatparts[2]); $type_amount = 't'; } // unpad zeroes from the right if ($decimals > 0) { while (substr($short_amount, -1, 1) == '0') { $short_amount = substr($short_amount, 0, strlen($short_amount) - 1); } if (substr($short_amount, -1, 1) == $formatparts[1]) { // remove also the decimals separator if no more decimals $short_amount = substr($short_amount, 0, strlen($short_amount) - 1); } } // return the formatted amount string return $short_amount . $type_amount; } /** * Calculate the absolute percent amount between the current and previous financial stat. * This method will use the following calculation: ((A1 - A2) / A2) * 100. * * @param float $stat the current (previously calculated) financial amount. * @param float $compare the previous period financial amount. * @param int $precision the precision for apply rounding. * * @return int|float the absolute percent amount calculated. */ public function calcAbsPercent($current, $compare, $precision = 1) { if ($current == $compare) { return 0; } if ($current > $compare && $compare < 1) { return 100; } if ($current < $compare && $current < 1) { return 100; } return round(abs(($current - $compare) / $compare * 100), $precision); } /** * Obtain booking statistics from a range of dates and an * optional list of room types. The data obtained will be * based on the effective nights of stay within the range, * unless type "booking_dates" to obtain different data. * * @param string $from the Y-m-d (or website) date from. * @param string $to the Y-m-d (or website) date to. * @param array $rooms list of room IDs to filter. * @param string $type either "stay_dates" or "booking_dates". * * @return array associative list of information. * * @throws Exception */ public function getStats($from, $to, array $rooms = [], $type = 'stay_dates') { $dbo = JFactory::getDbo(); // access the availability helper $av_helper = VikBooking::getAvailabilityInstance(); $from_ts = VikBooking::getDateTimestamp($from, 0, 0); $to_ts = VikBooking::getDateTimestamp($to, 23, 59, 59); if (empty($from) || empty($from_ts) || empty($to_ts) || $to_ts < $from_ts) { throw new Exception('Invalid dates provided', 500); } // total number of days (nights) in the range $total_range_nights = $this->countNightsTo($to_ts, $from_ts); if ($total_range_nights < 1) { throw new Exception('Invalid number of days provided', 500); } // total number of room units $total_room_units = $this->countRooms($rooms); if ($total_room_units < 1) { throw new Exception('Having no rooms published may lead to divisions by zero, hence errors.', 500); } // the associative array of statistics to collect and return $stats = [ // booking ids involved 'bids' => [], // total number of room units counted for stats 'room_units' => $total_room_units, // total number of room units times the number of nights in the range 'tot_inventory' => ($total_room_units * $total_range_nights), // number of rooms booked 'rooms_booked' => 0, // number of bookings found 'tot_bookings' => 0, // total number of nights booked (proportionally adjusted according to affected dates) 'nights_booked' => 0, // percent value 'occupancy' => 0, // average length of stay 'avg_los' => 0, // points of sale revenue (revenue divided by each individual ota and ibe) 'pos_revenue' => [], // list of countries with ranking 'country_ranks' => [], // ibe revenue net 'ibe_revenue' => 0, // otas revenue net (no matter if commissions were applied) 'ota_revenue' => 0, // total refunded amounts (never deducted) 'tot_refunds' => 0, // average daily rate 'adr' => 0, // revenue per available room 'revpar' => 0, // average booking window 'abw' => 0, // amount of taxes 'taxes' => 0, // amount of damage deposits 'damage_deposits' => 0, // commissions (amount) 'cmms' => 0, // net revenue before tax (otas + ibe) 'revenue' => 0, // gross revenue after tax 'gross_revenue' => 0, // otas total before tax (same as ota_revenue, but only if ota commissions were applied) 'ota_tot_net' => 0, // otas commissions 'ota_cmms' => 0, // percent value for average ota commissions amount 'ota_avg_cmms' => 0, // commission savings amount 'cmm_savings' => 0, // total cancelled reservations 'tot_cancellations' => 0, // cancelled reservation IDs 'cancellation_ids' => [], // total amount of cancelled bookings 'cancellations_amt' => 0, ]; // get all (real/completed) bookings in the given range of dates $q = $dbo->getQuery(true); $q->select($dbo->qn([ 'o.id', 'o.ts', 'o.status', 'o.days', 'o.checkin', 'o.checkout', 'o.totpaid', 'o.coupon', 'o.roomsnum', 'o.total', 'o.idorderota', 'o.channel', 'o.country', 'o.tot_taxes', 'o.tot_city_taxes', 'o.tot_fees', 'o.tot_damage_dep', 'o.cmms', 'o.refund', 'or.idorder', 'or.idroom', 'or.optionals', 'or.cust_cost', 'or.cust_idiva', 'or.extracosts', 'or.room_cost', 'c.country_name', ])); $q->from($dbo->qn('#__vikbooking_orders', 'o')); $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id')); $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country')); $q->where($dbo->qn('o.total') . ' > 0'); $q->where($dbo->qn('o.closure') . ' = 0'); // use the "andWhere" method after having set some "where" clauses $q->andWhere([ $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'), $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled'), ], 'OR'); if ($type == 'stay_dates') { // regular calculation based on stay dates $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts); $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts); } else { // calculation based on booked dates $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts); $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts); } /** * Do not filter the room booking records by room ID, but always join them, to improve accuracy with calculations. * * @since 1.18.0 (J) - 1.8.0 (WP) */ if ($rooms) { // $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', array_map('intval', $rooms)) . ')'); } $q->order($dbo->qn('o.checkin') . ' ASC'); $q->order($dbo->qn('o.id') . ' ASC'); $q->order($dbo->qn('or.id') . ' ASC'); $dbo->setQuery($q); $records = $dbo->loadAssocList(); if (!$records) { // no bookings found, do not proceed return $stats; } /** * Join the busy records afterwards to support accurate calculations for split stays or early departures/late arrivals. * This is also needed to support multi-room reservations without joining the busy records together with the room records. * * @since 1.18.0 (J) - 1.8.0 (WP) */ $busy_joined = []; foreach ($records as &$booking) { if (strcasecmp($booking['status'], 'confirmed')) { // we only want to target confirmed bookings continue; } // build cache signature for busy records joined $booking_room_signature = $booking['id'] . '-' . $booking['idroom']; // build the db query $dbo->setQuery( $dbo->getQuery(true) ->select($dbo->qn('ob.idbusy')) ->select($dbo->qn('b.checkin', 'room_checkin')) ->select($dbo->qn('b.checkout', 'room_checkout')) ->from($dbo->qn('#__vikbooking_ordersbusy', 'ob')) ->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy')) ->where($dbo->qn('ob.idorder') . ' = ' . (int) $booking['id']) ->where($dbo->qn('b.idroom') . ' = ' . (int) $booking['idroom']) ->order($dbo->qn('b.id') . ' ASC') ); // either fetch the records or use the previously cached ones to support multi-room bookings with equal room IDs $busy_joined[$booking_room_signature] = $busy_joined[$booking_room_signature] ?? $dbo->loadAssocList(); // get the busy record to process for the current booking-room by shifting the list $process_busy = array_shift($busy_joined[$booking_room_signature]); if (!$process_busy) { continue; } // inject busy details $booking['idbusy'] = $process_busy['idbusy']; $booking['room_checkin'] = $process_busy['room_checkin']; $booking['room_checkout'] = $process_busy['room_checkout']; } // unset last reference and cached values unset($booking, $busy_joined); /** * Immediately count cancellations and unset the records found. * * @since 1.16.10 (J) - 1.6.10 (WP) */ foreach ($records as $k => $b) { if (!strcasecmp($b['status'], 'cancelled')) { if (!in_array($b['id'], $stats['cancellation_ids'])) { // add statistics for the cancelled booking only once $stats['tot_cancellations']++; $stats['cancellations_amt'] += (float) $b['total']; $stats['cancellation_ids'][] = $b['id']; } // get rid of this record to not alter the regular statistics unset($records[$k]); } } if ($stats['cancellation_ids']) { // reset key values $records = array_values($records); } // nest records with multiple rooms booked inside sub-arrays $bookings = []; foreach ($records as $b) { if (!isset($bookings[$b['id']])) { $bookings[$b['id']] = []; } // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals) $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin']; $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout']; $in_info = getdate($room_checkin); $out_info = getdate($room_checkout); $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']); $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']); $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days']; $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights']; // push room-booking $bookings[$b['id']][] = $b; } // free memory up unset($records); // counters and pools $los_counter = 0; $pos_pool = []; $pos_counter = []; $countries = []; $country_map = []; // sum of booking window $sum_booking_window = 0; // parse all bookings foreach ($bookings as $bid => $booking) { // push booking ID $stats['bids'][] = $bid; // increase tot bookings and los counter $stats['tot_bookings']++; $los_counter += $booking[0]['days']; // count rooms booked within the reservation $booking_rooms = count($booking); // define the total booking amount for multi-room reservations $multi_room_total = 0; if ($rooms && $booking[0]['roomsnum'] > 1) { // when filters applied to multi-room bookings, count the effective number of rooms involved $booking_rooms = count(array_intersect(array_column($booking, 'idroom'), $rooms)); // overwrite room total amount when filtering by listing(s) foreach ($booking as $room_booking) { if (!in_array($room_booking['idroom'], $rooms)) { continue; } if ($room_booking['cust_cost'] > 0) { $multi_room_total += $room_booking['cust_cost']; } elseif ($room_booking['room_cost'] > 0) { $multi_room_total += $room_booking['room_cost']; } } } // point of sale name $pos_name = null; // parse all rooms booked foreach ($booking as $room_booking) { if ($rooms && !in_array($room_booking['idroom'], $rooms)) { // room booked is excluded from filters continue; } // increase rooms booked $stats['rooms_booked']++; // number of nights affected $los_affected = $room_booking['stay_nights']; // use default total values $room_total = $multi_room_total ?: $room_booking['total']; $room_cmms = $room_booking['cmms']; $room_refund = $room_booking['refund']; $room_tot_taxes = $room_booking['tot_taxes']; $room_tot_city_taxes = $room_booking['tot_city_taxes']; $room_tot_damage_dep = $room_booking['tot_damage_dep']; $room_tot_fees = $room_booking['tot_fees']; // check if amounts must be calculated proportionally for the range of dates requested if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) { // calculate number of nights of stay affected $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']); // adjust the amounts proportionally $room_total = $room_total * $los_affected / $room_booking['stay_nights']; $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights']; $room_refund = $room_refund * $los_affected / $room_booking['stay_nights']; $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights']; $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights']; $room_tot_damage_dep = $room_tot_damage_dep * $los_affected / $room_booking['stay_nights']; $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights']; } // apply average values per room booked (with filters or booked in total) $room_total /= $booking_rooms; $room_cmms /= $booking_rooms; $room_refund /= $booking_rooms; $room_tot_taxes /= $booking_rooms; $room_tot_city_taxes /= $booking_rooms; $room_tot_damage_dep /= $room_booking['roomsnum']; $room_tot_fees /= $booking_rooms; // calculate and sum average values per room booked $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); $tot_revenue = $multi_room_total ? ($tot_net / $booking_rooms) : $tot_net; $stats['revenue'] += $tot_revenue; $stats['gross_revenue'] += $room_total; $stats['nights_booked'] += $los_affected; // increase booking window for this room-booking $booking_window = $this->countDaysDiff($room_booking['ts'], $room_booking['stay_from_ts']); $sum_booking_window += $booking_window >= 0 ? $booking_window : 0; // increase country stats $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown'; if (!isset($countries[$country_code])) { $countries[$country_code] = 0; if (!empty($room_booking['country_name'])) { $country_map[$country_code] = $room_booking['country_name']; } } $countries[$country_code] += $tot_revenue; if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) { $stats['ota_revenue'] += $tot_revenue; if ($room_cmms > 0) { $stats['ota_tot_net'] += $tot_revenue; $stats['ota_cmms'] += $room_cmms; } // set pos name $channel_parts = explode('_', $room_booking['channel']); $pos_name = trim($channel_parts[0]); } else { $stats['ibe_revenue'] += $tot_revenue; // set pos name $pos_name = 'website'; } // set pos net revenue if (!isset($pos_pool[$pos_name])) { $pos_pool[$pos_name] = 0; } $pos_pool[$pos_name] += $tot_revenue; $stats['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees; $stats['damage_deposits'] += (float) $room_tot_damage_dep; $stats['cmms'] += (float) $room_cmms; $stats['tot_refunds'] += $room_refund; } if ($pos_name) { // increase number of bookings for this pos if (!isset($pos_counter[$pos_name])) { $pos_counter[$pos_name] = 0; } $pos_counter[$pos_name]++; } } // count the average length of stay (no proportional data for the dates requested) $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0; // calculate occupancy percent value $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2); // count the average daily rate (ADR) $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0; // count the revenue per available room (RevPAR) $stats['revpar'] = $stats['revenue'] / $total_room_units; // count the average booking window (ABW - number of days between the reservation date and the check-in date) $stats['abw'] = $stats['rooms_booked'] > 0 ? $sum_booking_window / $stats['rooms_booked'] : 0; // count OTAs average commission amount if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) { // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x) $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2); if ($stats['ibe_revenue'] > 0) { // calculate the commission savings amount $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100; } } // get channel logos helper $vcm_logos = VikBooking::getVcmChannelsLogo('', true); // sort and build pos revenues if ($pos_pool && $stats['revenue'] > 0) { // apply sorting descending arsort($pos_pool); // build readable pos values foreach ($pos_pool as $pos_name => $pos_revenue) { $pos_data = [ 'name' => $pos_name, 'revenue' => $pos_revenue, 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2), 'logo' => null, 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0, 'ibe' => false, 'ota' => false, ]; if (!strcasecmp($pos_name, 'website')) { // ibe revenue $pos_data['name'] = JText::translate('VBORDFROMSITE'); $pos_data['ibe'] = true; } else { // ota revenue $pos_data['ota'] = true; if (is_object($vcm_logos)) { $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL(); if ($ota_logo_img !== false) { $pos_data['logo'] = $ota_logo_img; } } } // push pos data $stats['pos_revenue'][] = $pos_data; } } // sort countries revenue if ($countries && $stats['revenue'] > 0) { // apply sorting descending arsort($countries); // build readable values foreach ($countries as $country_code => $country_revenue) { // push country data $stats['country_ranks'][] = [ 'code' => $country_code, 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code), 'revenue' => $country_revenue, 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2), ]; } } // return the statistics information return $stats; } /** * Counts the number of nights involved in a range of dates. This is * useful to proportionally calculate the amounts to be used. * * @param int $from_ts the 00:00:00 timestamp of the range start date. * @param int $to_ts the 23:59:59 timestamp of the range end date. * @param int $in_ts the 00:00:00 timestamp of the check-in date. * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1). * * @return int the number of stay nights involved in the range. */ protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts) { $nights_affected = 0; if ($from_ts > $to_ts) { return $nights_affected; } $range_from_info = getdate($from_ts); while ($range_from_info[0] < $to_ts) { if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) { $nights_affected++; } // next day iteration $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year'])); } return $nights_affected; } }