PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Services / BookingReportService.php

BookingReportService.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.5.0, at app/Services/BookingReportService.php

557 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Services;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\Framework\Support\Arr;
7
8 /**
9 * Aggregate queries over bookings, and the one definition of which bookings a
10 * user may count: scoped(), i.e. calendar ownership plus host membership.
11 *
12 * The schedules list does not use it yet. SchedulesController still filters on
13 * host_user_id alone, so a calendar owner who is not the named host sees fewer
14 * rows there. Changing that would alter a shipped list, so it is left as is.
15 *
16 * Dimensions and metrics come from fixed maps of literal SQL in this file; no
17 * caller-supplied string reaches the query.
18 *
19 * @since 2.2.6
20 */
21 class BookingReportService
22 {
23 // A year plus a day, so any 12-month or calendar-year range fits.
24 const MAX_RANGE_DAYS = 366;
25
26 // A report is a summary; callers needing every row want the bookings list.
27 const MAX_GROUPS = 200;
28
29 /**
30 * Group-by dimension => [SQL expression, label]. `%shifted%` is replaced
31 * with the timezone-shifted date column; see shiftedColumn().
32 *
33 * @return array
34 */
35 public static function dimensions()
36 {
37 return [
38 'status' => ['expr' => 'status', 'label' => __('Status', 'fluent-booking')],
39 'event' => ['expr' => 'event_id', 'label' => __('Event type', 'fluent-booking')],
40 'event_type' => ['expr' => 'event_type', 'label' => __('Event kind', 'fluent-booking')],
41 'host' => ['expr' => 'host_user_id', 'label' => __('Host', 'fluent-booking')],
42 'calendar' => ['expr' => 'calendar_id', 'label' => __('Calendar', 'fluent-booking')],
43 'source' => ['expr' => 'source', 'label' => __('Source', 'fluent-booking')],
44 'country' => ['expr' => 'country', 'label' => __('Country', 'fluent-booking')],
45 'day' => ['expr' => 'DATE(%shifted%)', 'label' => __('Day', 'fluent-booking')],
46 'month' => ['expr' => 'DATE_FORMAT(%shifted%, \'%Y-%m\')', 'label' => __('Month', 'fluent-booking')],
47 'weekday' => ['expr' => 'DAYOFWEEK(%shifted%)', 'label' => __('Weekday', 'fluent-booking')],
48 'hour' => ['expr' => 'HOUR(%shifted%)', 'label' => __('Hour', 'fluent-booking')],
49 ];
50 }
51
52 /**
53 * @return array
54 */
55 public static function metrics()
56 {
57 return ['count', 'distinct_attendees', 'total_minutes', 'no_show_rate', 'cancellation_rate'];
58 }
59
60 /**
61 * Which timestamp column a date range and the time dimensions read.
62 *
63 * @return array
64 */
65 public static function dateFields()
66 {
67 return ['start_time', 'created_at', 'end_time'];
68 }
69
70 /**
71 * Bookings the given user may see, or all of them with read-all-bookings.
72 * The canonical scope; do not reimplement it.
73 *
74 * @param int|null $userId Defaults to the current user.
75 *
76 * @return \FluentBooking\Framework\Database\Orm\Builder
77 */
78 public static function scoped($userId = null)
79 {
80 $query = Booking::query();
81
82 if (PermissionManager::userCanSeeAllBookings()) {
83 return $query;
84 }
85
86 $userId = $userId === null ? get_current_user_id() : (int) $userId;
87
88 return $query->whereHostAccess($userId);
89 }
90
91 /**
92 * Run an aggregate query.
93 *
94 * @param array $args {
95 * @type array $group_by Dimension keys, in order. At most 2.
96 * @type array $metrics Metric keys. Defaults to ['count'].
97 * @type string $date_field Which timestamp the range and the day/hour
98 * dimensions read. Defaults to 'start_time'.
99 * @type string $from Y-m-d, inclusive.
100 * @type string $to Y-m-d, inclusive.
101 * @type string $timezone IANA zone the day/weekday/hour buckets are
102 * expressed in. Defaults to the site zone.
103 * @type array $filters Optional equality filters: status[],
104 * event_id, calendar_id, host_id, event_type,
105 * source.
106 * @type array $having ['metric' => …, 'op' => …, 'value' => …].
107 * @type string $order_by A metric key, or a dimension key.
108 * @type string $order 'asc'|'desc'.
109 * @type int $limit
110 * }
111 *
112 * @return array|\WP_Error
113 */
114 public static function aggregate($args = [])
115 {
116 $groupBy = array_values(array_filter((array) Arr::get($args, 'group_by', [])));
117 $metrics = array_values(array_filter((array) Arr::get($args, 'metrics', [])));
118
119 if (!$metrics) {
120 $metrics = ['count'];
121 }
122
123 $dimensions = self::dimensions();
124
125 $unknownDims = array_diff($groupBy, array_keys($dimensions));
126
127 if ($unknownDims) {
128 return new \WP_Error('invalid_group_by', sprintf(
129 /* translators: %1$s: rejected dimension names, %2$s: accepted dimension names */
130 __('Unknown group_by: %1$s. Available: %2$s.', 'fluent-booking'),
131 implode(', ', $unknownDims),
132 implode(', ', array_keys($dimensions))
133 ));
134 }
135
136 $unknownMetrics = array_diff($metrics, self::metrics());
137
138 if ($unknownMetrics) {
139 return new \WP_Error('invalid_metric', sprintf(
140 /* translators: %1$s: rejected metric names, %2$s: accepted metric names */
141 __('Unknown metrics: %1$s. Available: %2$s.', 'fluent-booking'),
142 implode(', ', $unknownMetrics),
143 implode(', ', self::metrics())
144 ));
145 }
146
147 // A third dimension turns a summary back into a row dump.
148 if (count($groupBy) > 2) {
149 return new \WP_Error('too_many_dimensions', __('Group by at most two dimensions.', 'fluent-booking'));
150 }
151
152 $dateField = Arr::get($args, 'date_field', 'start_time');
153
154 if (!in_array($dateField, self::dateFields(), true)) {
155 return new \WP_Error('invalid_date_field', sprintf(
156 /* translators: %s: accepted date field names */
157 __('date_field must be one of: %s.', 'fluent-booking'),
158 implode(', ', self::dateFields())
159 ));
160 }
161
162 $range = self::resolveRange(Arr::get($args, 'from'), Arr::get($args, 'to'));
163
164 if (is_wp_error($range)) {
165 return $range;
166 }
167
168 $timezone = Arr::get($args, 'timezone') ?: DateTimeHelper::getTimeZone();
169 $offset = self::offsetSeconds($timezone, $range['from']);
170
171 $query = self::scoped();
172
173 // The bounds are local, so convert each to UTC with its own offset.
174 // One shared offset would be an hour off on a range crossing DST.
175 $query->whereBetween($dateField, [
176 self::localToUtc($range['from'] . ' 00:00:00', $timezone),
177 self::localToUtc($range['to'] . ' 23:59:59', $timezone),
178 ]);
179
180 $applied = self::applyFilters($query, (array) Arr::get($args, 'filters', []));
181
182 if (is_wp_error($applied)) {
183 return $applied;
184 }
185
186 $selects = [];
187 $groups = [];
188
189 foreach ($groupBy as $i => $key) {
190 $expr = self::resolveExpression($dimensions[$key]['expr'], $dateField, $offset);
191 $alias = 'dim_' . $i;
192 $selects[] = $expr . ' as ' . $alias;
193 $groups[] = $alias;
194 }
195
196 // COUNT(DISTINCT email) builds a distinct set over the whole range and
197 // each SUM adds per-row work, so a count-only report selects neither.
198 $orderMetric = (string) Arr::get($args, 'order_by', '');
199
200 // Always selected: resolveOrder falls back to it.
201 $selects[] = 'COUNT(*) as m_count';
202
203 if (in_array('distinct_attendees', $metrics, true) || $orderMetric === 'distinct_attendees') {
204 $selects[] = 'COUNT(DISTINCT email) as m_distinct_attendees';
205 }
206
207 if (in_array('total_minutes', $metrics, true) || $orderMetric === 'total_minutes') {
208 $selects[] = 'SUM(slot_minutes) as m_total_minutes';
209 }
210
211 if (in_array('no_show_rate', $metrics, true)) {
212 $selects[] = "SUM(CASE WHEN status = 'no_show' THEN 1 ELSE 0 END) as m_no_show";
213 }
214
215 if (in_array('cancellation_rate', $metrics, true)) {
216 $selects[] = "SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as m_cancelled";
217 }
218
219 if (self::wantsRate($metrics)) {
220 // Rate denominator. `reserved` rows are abandoned checkout
221 // placeholders and would deflate every rate.
222 $selects[] = "SUM(CASE WHEN status != 'reserved' THEN 1 ELSE 0 END) as m_real";
223 }
224
225 $query->selectRaw(implode(', ', $selects));
226
227 foreach ($groups as $group) {
228 $query->groupBy($group);
229 }
230
231 $having = self::resolveHaving(Arr::get($args, 'having'));
232
233 if (is_wp_error($having)) {
234 return $having;
235 }
236
237 if ($having) {
238 $query->havingRaw($having);
239 }
240
241 $query->orderByRaw(self::resolveOrder($groupBy, $metrics, $args));
242
243 $limit = (int) Arr::get($args, 'limit', 50);
244 $limit = max(1, min($limit, self::MAX_GROUPS));
245
246 // Fetch one extra row to detect truncation.
247 $rows = $query->limit($limit + 1)->get();
248
249 $truncated = count($rows) > $limit;
250
251 if ($truncated) {
252 $rows = array_slice(is_array($rows) ? $rows : $rows->all(), 0, $limit);
253 }
254
255 return [
256 'rows' => self::formatRows($rows, $groupBy, $metrics),
257 'group_by' => $groupBy,
258 'metrics' => $metrics,
259 'date_field' => $dateField,
260 'range' => $range,
261 'timezone' => $timezone,
262 'truncated' => $truncated,
263 'limit' => $limit,
264 ];
265 }
266
267 /**
268 * @return bool
269 */
270 private static function wantsRate($metrics)
271 {
272 return (bool) array_intersect($metrics, ['no_show_rate', 'cancellation_rate']);
273 }
274
275 private static function formatRows($rows, $groupBy, $metrics)
276 {
277 $out = [];
278 $wantsRate = self::wantsRate($metrics);
279
280 foreach ($rows as $row) {
281 $entry = [];
282
283 foreach ($groupBy as $i => $key) {
284 $value = $row->{'dim_' . $i};
285
286 // Keep null buckets. `country` is only set behind Cloudflare,
287 // so dropping blanks would skew the report.
288 $entry[$key] = $value === null || $value === '' ? '(unknown)' : $value;
289 }
290
291 $count = (int) $row->m_count;
292 $rateBase = $wantsRate ? (int) $row->m_real : 0;
293
294 foreach ($metrics as $metric) {
295 if ($metric === 'count') {
296 $entry['count'] = $count;
297 } elseif ($metric === 'distinct_attendees') {
298 $entry['distinct_attendees'] = (int) $row->m_distinct_attendees;
299 } elseif ($metric === 'total_minutes') {
300 $entry['total_minutes'] = (int) $row->m_total_minutes;
301 } elseif ($metric === 'no_show_rate') {
302 $entry['no_show_rate'] = $rateBase ? round((int) $row->m_no_show / $rateBase, 4) : 0;
303 } elseif ($metric === 'cancellation_rate') {
304 $entry['cancellation_rate'] = $rateBase ? round((int) $row->m_cancelled / $rateBase, 4) : 0;
305 }
306 }
307
308 $out[] = $entry;
309 }
310
311 return $out;
312 }
313
314 /**
315 * Equality filters, each one whitelisted.
316 *
317 * @return true|\WP_Error
318 */
319 private static function applyFilters($query, $filters)
320 {
321 $allowed = ['status', 'event_id', 'calendar_id', 'host_id', 'event_type', 'source'];
322
323 $unknown = array_diff(array_keys($filters), $allowed);
324
325 if ($unknown) {
326 return new \WP_Error('invalid_filter', sprintf(
327 /* translators: %1$s: rejected filter names, %2$s: accepted filter names */
328 __('Unknown filters: %1$s. Available: %2$s.', 'fluent-booking'),
329 implode(', ', $unknown),
330 implode(', ', $allowed)
331 ));
332 }
333
334 if ($statuses = array_filter((array) Arr::get($filters, 'status', []))) {
335 $query->whereIn('status', array_map('sanitize_text_field', $statuses));
336 }
337
338 // array_key_exists, not truthiness: `host_id: 0` is still a filter.
339 foreach (['event_id' => 'event_id', 'calendar_id' => 'calendar_id', 'host_id' => 'host_user_id'] as $key => $column) {
340 if (array_key_exists($key, $filters) && $filters[$key] !== null && $filters[$key] !== '') {
341 $query->where($column, (int) $filters[$key]);
342 }
343 }
344
345 foreach (['event_type', 'source'] as $key) {
346 if (array_key_exists($key, $filters) && $filters[$key] !== null && $filters[$key] !== '') {
347 $query->where($key, sanitize_text_field($filters[$key]));
348 }
349 }
350
351 return true;
352 }
353
354 /**
355 * @return string|\WP_Error '' when there is no having clause.
356 */
357 private static function resolveHaving($having)
358 {
359 if (!$having || !is_array($having)) {
360 return '';
361 }
362
363 $columns = [
364 'count' => 'COUNT(*)',
365 'distinct_attendees' => 'COUNT(DISTINCT email)',
366 'total_minutes' => 'SUM(slot_minutes)',
367 ];
368
369 $metric = Arr::get($having, 'metric', 'count');
370
371 if (!isset($columns[$metric])) {
372 return new \WP_Error('invalid_having', sprintf(
373 /* translators: %s: accepted having metrics */
374 __('having.metric must be one of: %s.', 'fluent-booking'),
375 implode(', ', array_keys($columns))
376 ));
377 }
378
379 $operators = ['>=' => '>=', '>' => '>', '<=' => '<=', '<' => '<', '=' => '='];
380 $op = Arr::get($having, 'op', '>=');
381
382 if (!isset($operators[$op])) {
383 return new \WP_Error('invalid_having', __('having.op must be one of: >=, >, <=, <, =.', 'fluent-booking'));
384 }
385
386 // Required: defaulting to 0 builds `COUNT(*) >= 0`, which filters nothing.
387 if (!is_numeric(Arr::get($having, 'value'))) {
388 return new \WP_Error('invalid_having', __('having.value is required and must be a number, e.g. {"metric":"count","op":">=","value":5}.', 'fluent-booking'));
389 }
390
391 // Only literals from the maps above plus an integer-cast value.
392 return $columns[$metric] . ' ' . $operators[$op] . ' ' . (int) Arr::get($having, 'value', 0);
393 }
394
395 /**
396 * @return string
397 */
398 private static function resolveOrder($groupBy, $metrics, $args)
399 {
400 $columns = [
401 'count' => 'm_count',
402 'distinct_attendees' => 'm_distinct_attendees',
403 'total_minutes' => 'm_total_minutes',
404 ];
405
406 $requested = Arr::get($args, 'order_by', '');
407 $direction = strtolower(Arr::get($args, 'order', '')) === 'asc' ? 'ASC' : 'DESC';
408
409 if (isset($columns[$requested])) {
410 return self::tieBreak($columns[$requested] . ' ' . $direction, $groupBy);
411 }
412
413 $dimensionIndex = array_search($requested, $groupBy, true);
414
415 if ($dimensionIndex !== false) {
416 return self::tieBreak('dim_' . (int) $dimensionIndex . ' ' . $direction, $groupBy);
417 }
418
419 // Time series read as a series; everything else reads as a ranking.
420 $timeDimensions = ['day', 'month', 'weekday', 'hour'];
421
422 if ($groupBy && in_array($groupBy[0], $timeDimensions, true)) {
423 return self::tieBreak('dim_0 ASC', $groupBy);
424 }
425
426 return self::tieBreak('m_count DESC', $groupBy);
427 }
428
429 /**
430 * Settle equal sort keys, so tied rows do not reorder with the plan.
431 *
432 * @return string
433 */
434 private static function tieBreak($order, $groupBy)
435 {
436 $parts = [$order];
437
438 foreach (array_keys($groupBy) as $i) {
439 $alias = 'dim_' . (int) $i;
440
441 if (strpos($order, $alias . ' ') !== 0) {
442 $parts[] = $alias . ' ASC';
443 }
444 }
445
446 return implode(', ', $parts);
447 }
448
449 /**
450 * @return string
451 */
452 private static function resolveExpression($expr, $dateField, $offset)
453 {
454 if (strpos($expr, '%shifted%') === false) {
455 return $expr;
456 }
457
458 return str_replace('%shifted%', self::shiftedColumn($dateField, $offset), $expr);
459 }
460
461 /**
462 * Convert a local wall-clock time to UTC using the offset in force at that
463 * instant, which matters when a range crosses a DST change.
464 *
465 * @param string $localDateTime 'Y-m-d H:i:s'
466 * @param string $timezone
467 *
468 * @return string 'Y-m-d H:i:s' in UTC
469 */
470 private static function localToUtc($localDateTime, $timezone)
471 {
472 try {
473 $local = new \DateTime($localDateTime, new \DateTimeZone($timezone));
474 $local->setTimezone(new \DateTimeZone('UTC'));
475
476 return $local->format('Y-m-d H:i:s');
477 } catch (\Exception $e) {
478 return $localDateTime;
479 }
480 }
481
482 /**
483 * Shift the UTC column into the report timezone before grouping by day or
484 * hour. CONVERT_TZ needs MySQL's timezone tables, which most hosts lack, so
485 * the offset is computed in PHP and applied as a fixed interval.
486 *
487 * @param string $dateField Whitelisted column name.
488 * @param int $offset Seconds, already cast.
489 *
490 * @return string
491 */
492 private static function shiftedColumn($dateField, $offset)
493 {
494 if (!$offset) {
495 return $dateField;
496 }
497
498 return 'DATE_ADD(' . $dateField . ', INTERVAL ' . (int) $offset . ' SECOND)';
499 }
500
501 /**
502 * The zone's offset at the start of the range. Grouping uses this one
503 * offset throughout, so buckets past a DST change can be an hour out.
504 *
505 * @return int
506 */
507 public static function offsetSeconds($timezone, $onDate)
508 {
509 try {
510 $zone = new \DateTimeZone($timezone);
511 $when = new \DateTime($onDate . ' 12:00:00', new \DateTimeZone('UTC'));
512
513 return $zone->getOffset($when);
514 } catch (\Exception $e) {
515 return 0;
516 }
517 }
518
519 /**
520 * @return array|\WP_Error
521 */
522 public static function resolveRange($from, $to)
523 {
524 $to = $to ? sanitize_text_field($to) : gmdate('Y-m-d');
525 $from = $from ? sanitize_text_field($from) : gmdate('Y-m-d', strtotime($to . ' -29 days'));
526
527 foreach ([$from, $to] as $date) {
528 // checkdate(): strtotime() silently rolls 2026-02-30 into March.
529 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts)
530 || !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {
531 return new \WP_Error(
532 'invalid_range',
533 __('from and to must be real dates formatted Y-m-d.', 'fluent-booking'),
534 ['received' => $date]
535 );
536 }
537 }
538
539 if ($from > $to) {
540 return new \WP_Error('invalid_range', __('from must not be later than to.', 'fluent-booking'));
541 }
542
543 $days = (int) floor((strtotime($to) - strtotime($from)) / DAY_IN_SECONDS) + 1;
544
545 if ($days > self::MAX_RANGE_DAYS) {
546 return new \WP_Error('range_too_large', sprintf(
547 /* translators: %1$d: requested number of days, %2$d: maximum */
548 __('That range is %1$d days; the maximum is %2$d. Narrow it, or group by month.', 'fluent-booking'),
549 $days,
550 self::MAX_RANGE_DAYS
551 ));
552 }
553
554 return ['from' => $from, 'to' => $to, 'days' => $days];
555 }
556 }
557