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 / Modules / MCP / Support / AvailabilityDiagnostics.php

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

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