PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.4.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.4.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 / Modules / MCP / Support / AvailabilityDiagnostics.php

AvailabilityDiagnostics.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.4.0, at app/Modules/MCP/Support/AvailabilityDiagnostics.php

627 lines 24.0 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\Modules\MCP\Support;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\CalendarSlot;
7 use FluentBooking\App\Services\DateTimeHelper;
8 use FluentBooking\App\Services\SanitizeService;
9 use FluentBooking\Framework\Support\Arr;
10
11 defined('ABSPATH') || exit;
12
13 /**
14 * Why does this event show no slots?
15 *
16 * The highest-volume FluentBooking support question, answered in one call.
17 *
18 * Design constraint: this class does NOT re-derive availability. Slot maths
19 * lives in TimeSlotService and a second implementation would eventually
20 * disagree with the first, which for a diagnostic is worse than useless —
21 * it would confidently explain an outcome that never happened. So the approach
22 * is: take the real engine's output as ground truth, read the configuration
23 * through the model's own accessors, and *attribute* each empty date to the
24 * first rule that accounts for it, in the order the engine applies them.
25 *
26 * Attribution order matters and mirrors TimeSlotService::getDates():
27 * event active → bookable window → date override closes the day →
28 * weekday has no hours → frequency cap reached → every slot booked →
29 * minimum notice (today only)
30 *
31 * A date that survives all of those and still has no slots is reported as
32 * `unexplained` rather than guessed at. An honest "I don't know" is worth more
33 * to whoever is holding the support ticket than a plausible wrong answer.
34 */
35 class AvailabilityDiagnostics
36 {
37 /**
38 * Statuses that occupy a slot. Mirrors TimeSlotService::getBookedSlots() —
39 * a booking in any of these states blocks its time.
40 */
41 const BLOCKING_STATUSES = ['pending', 'reserved', 'approved', 'scheduled', 'completed'];
42
43 /**
44 * @param CalendarSlot $event
45 * @param string $from 'Y-m-d'
46 * @param string $to 'Y-m-d'
47 * @param string $timezone resolved IANA identifier
48 * @param int|null $hostId
49 * @return array
50 */
51 public static function run(CalendarSlot $event, $from, $to, $timezone, $hostId = null)
52 {
53 $scheduleTimezone = $event->getScheduleTimezone($hostId);
54
55 // Stored hours are UTC. Every check below reports them under the
56 // schedule's own timezone, so convert once here rather than labelling
57 // raw UTC as local — the same call AvailabilityService makes when it
58 // renders a schedule for the admin.
59 $weeklySlots = SanitizeService::weeklySchedules(
60 (array) $event->getWeeklySlots($hostId),
61 'UTC',
62 $scheduleTimezone
63 );
64
65 $overrides = (array) $event->getDateOverrides($hostId);
66
67 $overrideSlots = isset($overrides[0]) && is_array($overrides[0]) ? $overrides[0] : [];
68 $overrideDays = isset($overrides[1]) && is_array($overrides[1]) ? $overrides[1] : [];
69
70 // Ground truth: what the booking page would actually offer.
71 $actual = SlotResolver::getSlots($event, $from, $to, $timezone, null, $hostId);
72
73 $slotsByDate = is_wp_error($actual) ? [] : (array) Arr::get($actual, 'slots', []);
74
75 $counts = [];
76 $totalSlots = 0;
77
78 foreach ($slotsByDate as $date => $times) {
79 $counts[$date] = count($times);
80 $totalSlots += count($times);
81 }
82
83 // Two different questions, previously answered by one number:
84 // - "is the per-day cap reached" is per EVENT TYPE (booking_frequency
85 // is an event-type setting), and
86 // - "is the day full" is per HOST, because any booking on any event
87 // occupies the host's time.
88 // Using the host-wide count for both reported `daily_cap_reached` on a
89 // day where the cap was nowhere near, whenever the host happened to be
90 // busy on some other event type.
91 $bookingsByDate = self::bookingCountsByDate($event, $from, $to, $hostId, $timezone);
92 $eventBookingsByDate = self::bookingCountsByDate($event, $from, $to, $hostId, $timezone, true);
93
94 $window = self::bookableWindow($event, $from, $timezone);
95
96 $frequencyCaps = self::capLimits(Arr::get($event->settings, 'booking_frequency', []));
97
98 $checks = self::checks($event, $weeklySlots, $overrideDays, $overrideSlots, $window, $frequencyCaps, $scheduleTimezone, $from, $to, $hostId);
99
100 $emptyDates = self::explainEmptyDates(
101 $event,
102 $from,
103 $to,
104 $counts,
105 $weeklySlots,
106 $overrideDays,
107 $overrideSlots,
108 $window,
109 $frequencyCaps,
110 $bookingsByDate,
111 $eventBookingsByDate
112 );
113
114 return [
115 'event' => [
116 'id' => (int) $event->id,
117 'title' => $event->title,
118 'status' => $event->status,
119 'event_type' => $event->event_type,
120 'duration' => (int) $event->getDuration(),
121 'schedule_timezone' => $scheduleTimezone,
122 ],
123 'window' => [
124 'from' => $from,
125 'to' => $to,
126 'timezone' => $timezone,
127 ],
128 'outcome' => [
129 'total_slots' => $totalSlots,
130 'dates_with_slots' => count($counts),
131 'slots_per_date' => $counts,
132 ],
133 'checks' => $checks,
134 'empty_dates' => $emptyDates,
135 ];
136 }
137
138 /**
139 * The configuration audit: every rule that can remove slots, with the value
140 * it is actually set to.
141 *
142 * `passed` answers "is this rule permitting anything at all", not "did it
143 * remove something" — a buffer of 15 minutes passes even though it does
144 * remove slots, because it is configured sanely. A failing check is one
145 * that on its own explains an empty calendar.
146 *
147 * @return array
148 */
149 private static function checks(CalendarSlot $event, $weeklySlots, $overrideDays, $overrideSlots, $window, $frequencyCaps, $scheduleTimezone, $from, $to, $hostId)
150 {
151 $checks = [];
152
153 $checks[] = [
154 'check' => 'event_active',
155 'passed' => $event->status === 'active',
156 'detail' => $event->status === 'active'
157 ? __('The event type is active.', 'fluent-booking')
158 : sprintf(
159 /* translators: %s: the event type's current status */
160 __('The event type status is "%s". Only active events offer slots.', 'fluent-booking'),
161 $event->status
162 ),
163 ];
164
165 $enabledDays = self::enabledWeekdays($weeklySlots);
166
167 $checks[] = [
168 'check' => 'weekly_schedule',
169 'passed' => !empty($enabledDays),
170 'detail' => $enabledDays
171 ? sprintf(
172 /* translators: 1: comma-separated weekday names, 2: timezone identifier */
173 __('Available on %1$s (schedule timezone %2$s).', 'fluent-booking'),
174 implode(', ', array_keys($enabledDays)),
175 $scheduleTimezone
176 )
177 : __('No weekday has any available hours. Every slot is removed by this alone.', 'fluent-booking'),
178 'hours' => $enabledDays,
179 ];
180
181 $overridesInRange = self::overridesInRange($overrideDays, $overrideSlots, $from, $to);
182
183 $checks[] = [
184 'check' => 'date_overrides',
185 'passed' => true,
186 'detail' => $overridesInRange
187 ? sprintf(
188 /* translators: %d: number of date overrides falling inside the queried window */
189 __('%d date override(s) fall inside this window.', 'fluent-booking'),
190 count($overridesInRange)
191 )
192 : __('No date overrides fall inside this window.', 'fluent-booking'),
193 'overrides' => $overridesInRange,
194 ];
195
196 $windowOverlaps = !($window['max'] && $window['max'] < $from) && !($window['min'] && $window['min'] > $to);
197
198 $checks[] = [
199 'check' => 'bookable_window',
200 'passed' => $windowOverlaps,
201 'detail' => $windowOverlaps
202 ? sprintf(
203 /* translators: 1: earliest bookable date, 2: latest bookable date or "no limit" */
204 __('Bookable from %1$s to %2$s.', 'fluent-booking'),
205 $window['min'],
206 $window['max'] ? $window['max'] : __('no limit', 'fluent-booking')
207 )
208 : __('The queried window falls entirely outside the event\'s bookable range.', 'fluent-booking'),
209 'earliest' => $window['min'],
210 'latest' => $window['max'],
211 ];
212
213 $noticeMinutes = (int) round($event->getCutoutSeconds() / MINUTE_IN_SECONDS);
214
215 $checks[] = [
216 'check' => 'minimum_notice',
217 'passed' => true,
218 'detail' => $noticeMinutes
219 ? sprintf(
220 /* translators: %s: minimum notice, already humanised, e.g. "30 days" */
221 __('Bookings must be made at least %s ahead, which removes the soonest dates.', 'fluent-booking'),
222 self::humanizeMinutes($noticeMinutes)
223 )
224 : __('No minimum notice period is set.', 'fluent-booking'),
225 'minutes' => $noticeMinutes,
226 ];
227
228 $bufferBefore = (int) Arr::get($event->settings, 'buffer_time_before', 0);
229 $bufferAfter = (int) Arr::get($event->settings, 'buffer_time_after', 0);
230
231 $checks[] = [
232 'check' => 'buffers',
233 'passed' => true,
234 'detail' => ($bufferBefore || $bufferAfter)
235 ? sprintf(
236 /* translators: 1: buffer before in minutes, 2: buffer after in minutes */
237 __('%1$d minutes before and %2$d after each booking are blocked.', 'fluent-booking'),
238 $bufferBefore,
239 $bufferAfter
240 )
241 : __('No buffer time is configured.', 'fluent-booking'),
242 'before_minutes' => $bufferBefore,
243 'after_minutes' => $bufferAfter,
244 ];
245
246 $checks[] = [
247 'check' => 'booking_caps',
248 'passed' => true,
249 'detail' => $frequencyCaps
250 ? sprintf(
251 /* translators: %s: comma-separated caps such as "per_day: 2" */
252 __('Booking frequency is capped (%s).', 'fluent-booking'),
253 self::describeCaps($frequencyCaps)
254 )
255 : __('No booking frequency cap is set.', 'fluent-booking'),
256 'limits' => $frequencyCaps,
257 ];
258
259 $checks[] = self::remoteCalendarCheck($event, $from, $to, $hostId);
260
261 return $checks;
262 }
263
264 /**
265 * Attribute each empty date to the first rule that accounts for it.
266 *
267 * @return array
268 */
269 private static function explainEmptyDates(CalendarSlot $event, $from, $to, $counts, $weeklySlots, $overrideDays, $overrideSlots, $window, $frequencyCaps, $bookingsByDate, $eventBookingsByDate = [])
270 {
271 $explained = [];
272
273 $isActive = $event->status === 'active';
274 $today = gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
275
276 $perDayCap = isset($frequencyCaps['per_day']) ? (int) $frequencyCaps['per_day'] : 0;
277
278 $cursor = strtotime($from);
279 $end = strtotime($to);
280
281 while ($cursor <= $end) {
282 $date = gmdate('Y-m-d', $cursor); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
283 $cursor += DAY_IN_SECONDS;
284
285 if (!empty($counts[$date])) {
286 continue;
287 }
288
289 $day = strtolower(gmdate('D', strtotime($date))); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
290 // Host-wide: anything on the host's calendar occupies their time.
291 $booked = isset($bookingsByDate[$date]) ? (int) $bookingsByDate[$date] : 0;
292 // This event type only: booking_frequency is an event-type setting,
293 // so it must be measured against this event's own bookings.
294 $onEvent = isset($eventBookingsByDate[$date]) ? (int) $eventBookingsByDate[$date] : 0;
295
296 $reason = 'unexplained';
297 $detail = __('No rule in this report accounts for this date being empty. Check host-level schedules and connected calendars.', 'fluent-booking');
298
299 if (!$isActive) {
300 $reason = 'event_inactive';
301 $detail = __('The event type is not active.', 'fluent-booking');
302 } elseif ($window['min'] && $date < $window['min']) {
303 $reason = 'before_bookable_window';
304 $detail = sprintf(
305 /* translators: %s: earliest bookable date */
306 __('Earlier than the first bookable date (%s).', 'fluent-booking'),
307 $window['min']
308 );
309 } elseif ($window['max'] && $date > $window['max']) {
310 $reason = 'after_bookable_window';
311 $detail = sprintf(
312 /* translators: %s: latest bookable date */
313 __('Later than the last bookable date (%s).', 'fluent-booking'),
314 $window['max']
315 );
316 } elseif (isset($overrideDays[$date]) && empty($overrideSlots[$date])) {
317 $reason = 'date_override_closed';
318 $detail = __('A date override marks this day unavailable.', 'fluent-booking');
319 } elseif (!self::weekdayHasHours($weeklySlots, $day) && empty($overrideSlots[$date])) {
320 $reason = 'no_weekly_hours';
321 $detail = sprintf(
322 /* translators: %s: three-letter weekday, e.g. "tue" */
323 __('No hours are configured for %s in the weekly schedule.', 'fluent-booking'),
324 $day
325 );
326 } elseif ($perDayCap && $onEvent >= $perDayCap) {
327 $reason = 'daily_cap_reached';
328 $detail = sprintf(
329 /* translators: 1: bookings already on the date for this event type, 2: the configured per-day cap */
330 __('%1$d booking(s) on this event type already, at its per-day cap of %2$d.', 'fluent-booking'),
331 $onEvent,
332 $perDayCap
333 );
334 } elseif ($booked > 0) {
335 $reason = 'fully_booked';
336 $detail = sprintf(
337 /* translators: %d: number of bookings already on the date across the host's calendar */
338 __('%d existing booking(s) on this host\'s calendar cover the available hours, once buffers are applied.', 'fluent-booking'),
339 $booked
340 );
341 } elseif ($date === $today) {
342 $reason = 'minimum_notice';
343 $detail = __('Today\'s remaining hours fall inside the minimum notice period.', 'fluent-booking');
344 }
345
346 $explained[] = [
347 'date' => $date,
348 'weekday' => $day,
349 'reason' => $reason,
350 'detail' => $detail,
351 ];
352 }
353
354 return $explained;
355 }
356
357 /**
358 * Bookings per date that occupy time on this event's hosts.
359 *
360 * Counted against the same host set and the same blocking statuses the slot
361 * engine uses, so "3 bookings" here means the same three the engine removed
362 * slots for.
363 *
364 * @return array date => count
365 */
366 private static function bookingCountsByDate(CalendarSlot $event, $from, $to, $hostId, $timezone, $thisEventOnly = false)
367 {
368 $hostIds = (array) $event->getHostIds($hostId);
369
370 if (!$hostIds) {
371 return [];
372 }
373
374 $query = Booking::whereHas('hosts', function ($query) use ($hostIds) {
375 $query->whereIn('user_id', $hostIds);
376 })
377 ->whereIn('status', self::BLOCKING_STATUSES)
378 // The window is a LOCAL one — empty_dates walks dates in $timezone —
379 // so the UTC column has to be bounded by the UTC instants those
380 // local days start and end at, not by the bare date strings.
381 ->where('start_time', '>=', MCPHelper::dayBoundaryToUtc($from, $timezone, false))
382 ->where('start_time', '<=', MCPHelper::dayBoundaryToUtc($to, $timezone, true));
383
384 if ($thisEventOnly) {
385 $query->where('event_id', $event->id);
386 }
387
388 $counts = [];
389
390 foreach ($query->get(['id', 'start_time']) as $booking) {
391 // Bucketed by the LOCAL date, for the same reason.
392 $date = DateTimeHelper::convertFromUtc($booking->start_time, $timezone, 'Y-m-d');
393
394 $counts[$date] = isset($counts[$date]) ? $counts[$date] + 1 : 1;
395 }
396
397 return $counts;
398 }
399
400 /**
401 * The event's bookable date window, as dates.
402 *
403 * @return array {min: string, max: string|null}
404 */
405 private static function bookableWindow(CalendarSlot $event, $from, $timezone)
406 {
407 $min = $event->getMinBookableDateTime($from . ' 00:00:00', $timezone);
408 $max = $event->getMaxLookUpDate();
409
410 return [
411 'min' => $min ? gmdate('Y-m-d', strtotime($min)) : '', // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
412 'max' => $max ? gmdate('Y-m-d', strtotime($max)) : null, // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
413 ];
414 }
415
416 /**
417 * Weekdays with hours, as day => "09:00-17:00, 18:00-20:00".
418 *
419 * Rendered as strings rather than nested arrays: this is read by a human
420 * through an agent, and three keys per slot per day would triple the
421 * payload for information nobody acts on programmatically.
422 *
423 * @return array
424 */
425 private static function enabledWeekdays($weeklySlots)
426 {
427 $days = [];
428
429 foreach ($weeklySlots as $day => $schedule) {
430 $slots = Arr::get($schedule, 'slots', []);
431
432 if (!Arr::isTrue($schedule, 'enabled') || !$slots) {
433 continue;
434 }
435
436 $ranges = [];
437
438 foreach ($slots as $slot) {
439 $start = Arr::get($slot, 'start');
440 $endAt = Arr::get($slot, 'end');
441
442 if ($start && $endAt) {
443 $ranges[] = $start . '-' . $endAt;
444 }
445 }
446
447 if ($ranges) {
448 $days[$day] = implode(', ', $ranges);
449 }
450 }
451
452 return $days;
453 }
454
455 /**
456 * Minutes as something a person reads without arithmetic.
457 *
458 * "43200 minutes" is technically the notice period and practically useless
459 * to whoever is holding the support ticket; "30 days" is the same fact.
460 *
461 * @param int $minutes
462 * @return string
463 */
464 private static function humanizeMinutes($minutes)
465 {
466 $minutes = (int) $minutes;
467
468 $minutesPerDay = DAY_IN_SECONDS / MINUTE_IN_SECONDS;
469 $minutesPerHour = HOUR_IN_SECONDS / MINUTE_IN_SECONDS;
470
471 if ($minutes >= $minutesPerDay && $minutes % $minutesPerDay === 0) {
472 $days = $minutes / $minutesPerDay;
473
474 /* translators: %d: number of days */
475 return sprintf(_n('%d day', '%d days', $days, 'fluent-booking'), $days);
476 }
477
478 if ($minutes >= $minutesPerHour && $minutes % $minutesPerHour === 0) {
479 $hours = $minutes / $minutesPerHour;
480
481 /* translators: %d: number of hours */
482 return sprintf(_n('%d hour', '%d hours', $hours, 'fluent-booking'), $hours);
483 }
484
485 /* translators: %d: number of minutes */
486 return sprintf(_n('%d minute', '%d minutes', $minutes, 'fluent-booking'), $minutes);
487 }
488
489 /**
490 * @return bool
491 */
492 private static function weekdayHasHours($weeklySlots, $day)
493 {
494 $schedule = isset($weeklySlots[$day]) ? $weeklySlots[$day] : [];
495
496 return Arr::isTrue($schedule, 'enabled') && !empty(Arr::get($schedule, 'slots', []));
497 }
498
499 /**
500 * Date overrides inside the queried window, flagged by what they do.
501 *
502 * An override present in the day-block list with no replacement slots closes
503 * the day; one with slots replaces that day's hours.
504 *
505 * @return array
506 */
507 private static function overridesInRange($overrideDays, $overrideSlots, $from, $to)
508 {
509 $dates = array_unique(array_merge(array_keys((array) $overrideDays), array_keys((array) $overrideSlots)));
510
511 sort($dates);
512
513 $out = [];
514
515 foreach ($dates as $date) {
516 if ($date < $from || $date > $to) {
517 continue;
518 }
519
520 $out[] = [
521 'date' => $date,
522 'effect' => empty($overrideSlots[$date]) ? 'closed' : 'custom_hours',
523 ];
524 }
525
526 return $out;
527 }
528
529 /**
530 * @return array unit => value
531 */
532 private static function capLimits($config)
533 {
534 if (!is_array($config) || !Arr::isTrue($config, 'enabled')) {
535 return [];
536 }
537
538 $limits = [];
539
540 foreach ((array) Arr::get($config, 'limits', []) as $limit) {
541 $unit = sanitize_text_field((string) Arr::get($limit, 'unit', ''));
542 $value = (int) Arr::get($limit, 'value', 0);
543
544 if ($unit && $value) {
545 $limits[$unit] = $value;
546 }
547 }
548
549 return $limits;
550 }
551
552 /**
553 * @return string
554 */
555 private static function describeCaps($caps)
556 {
557 $parts = [];
558
559 foreach ($caps as $unit => $value) {
560 $parts[] = $unit . ': ' . $value;
561 }
562
563 return implode(', ', $parts);
564 }
565
566 /**
567 * External busy time, asked of the engine rather than inferred.
568 *
569 * `fluent_booking/remote_booked_events` is the exact filter
570 * TimeSlotService::getBookedSlots() applies to pull Google/Outlook/Apple/
571 * CalDAV busy blocks into the slot calculation, so running it here reports
572 * what the engine actually saw — not what a guess at where connections are
573 * stored would suggest. This is the usual answer when every other check
574 * passes and slots are still missing.
575 *
576 * Counts and providers only. Pulling the titles of a host's private calendar
577 * events into an agent's context is not this tool's job.
578 *
579 * @param CalendarSlot $event
580 * @param string $from
581 * @param string $to
582 * @param int|null $hostId
583 * @return array
584 */
585 private static function remoteCalendarCheck(CalendarSlot $event, $from, $to, $hostId)
586 {
587 if (!defined('FLUENT_BOOKING_PRO_DIR_FILE')) {
588 return [
589 'check' => 'remote_calendar_conflicts',
590 'passed' => true,
591 'detail' => __('External calendar conflict checking requires FluentBooking Pro, so no external calendar is blocking time here.', 'fluent-booking'),
592 ];
593 }
594
595 $dateRange = [$from . ' 00:00:00', $to . ' 23:59:59'];
596
597 $remote = apply_filters('fluent_booking/remote_booked_events', [], $event, 'UTC', $dateRange, $hostId, false);
598
599 $remote = is_array($remote) ? $remote : [];
600
601 $providers = [];
602
603 foreach ($remote as $slot) {
604 $source = Arr::get($slot, 'source');
605
606 if ($source && is_scalar($source)) {
607 $providers[(string) $source] = true;
608 }
609 }
610
611 return [
612 'check' => 'remote_calendar_conflicts',
613 'passed' => true,
614 'detail' => $remote
615 ? sprintf(
616 /* translators: 1: number of busy blocks found on connected calendars, 2: comma-separated provider names */
617 __('%1$d busy block(s) on connected external calendars (%2$s) remove slots in this window. These are invisible in FluentBooking\'s own bookings.', 'fluent-booking'),
618 count($remote),
619 $providers ? implode(', ', array_keys($providers)) : __('unknown provider', 'fluent-booking')
620 )
621 : __('No external calendar busy time falls inside this window.', 'fluent-booking'),
622 'busy_blocks' => count($remote),
623 'providers' => array_keys($providers),
624 ];
625 }
626 }
627