$capacity_usage Exact-start attendee sums for group services. */ public function generate( AvailabilityQuery $query, IntervalSet $working_set, IntervalSet $busy_set, int $agent_id, array $capacity_usage = [], ): SlotCollection { $required = $query->required_duration_minutes(); $step = max( 1, $query->slot_step_min ); $free_set = $working_set->subtract( $busy_set ); $slots = []; $now = $this->clock->now(); $lead_boundary = $now->modify( sprintf( '+%d minutes', $query->lead_time_min ) ); $horizon_boundary = $now->modify( sprintf( '+%d days', $query->horizon_days ) ); $usage_map = []; foreach ( $capacity_usage as $row ) { $key = $row['start']->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ); $usage_map[ $key ] = ( $usage_map[ $key ] ?? 0 ) + $row['attendees']; } foreach ( $free_set->all() as $free ) { $cursor = $this->align_start( $free->start(), $step ); while ( true ) { $candidate_end = $cursor->modify( sprintf( '+%d minutes', $required ) ); if ( $candidate_end > $free->end() ) { break; } $candidate = new Interval( $cursor, $candidate_end ); $footprint = $candidate->expand( $query->buffer_before_min, $query->buffer_after_min ); $available = true; $reason = null; if ( ! $working_set->contains( $footprint ) || $busy_set->overlaps( $footprint ) ) { $available = false; $reason = BusyReason::Buffer; } if ( $cursor < $lead_boundary ) { $available = false; $reason = BusyReason::LeadTime; } elseif ( $cursor > $horizon_boundary ) { $available = false; $reason = BusyReason::Horizon; } if ( null !== $query->same_day_cutoff_local && null !== $query->location_timezone ) { $loc_zone = $query->location_timezone->to_date_time_zone(); $local_now = $now->setTimezone( $loc_zone ); $slot_local = $cursor->setTimezone( $loc_zone ); if ( $slot_local->format( 'Y-m-d' ) === $local_now->format( 'Y-m-d' ) ) { $cutoff = $query->same_day_cutoff_local; if ( strlen( $cutoff ) === 5 ) { $cutoff .= ':00'; } if ( $local_now->format( 'H:i:s' ) >= $cutoff ) { $available = false; $reason = BusyReason::CutOff; } } } $cap_total = max( 1, $query->capacity_max ); $cap_key = $cursor->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ); $used = $usage_map[ $cap_key ] ?? 0; $remaining = (int) max( 0, $cap_total - $used ); if ( $cap_total > 1 && $remaining < $query->attendees ) { $available = false; $reason = BusyReason::Capacity; } $slots[] = new Slot( $cursor, $candidate_end, $agent_id, $query->location_id, $cap_total, $remaining, $available, $available ? null : $reason ); $cursor = $cursor->modify( sprintf( '+%d minutes', $step ) ); } } return new SlotCollection( $slots ); } /** * Align a start instant up to the next slot step boundary. * * @param DateTimeImmutable $start Candidate start. * @param int $step_min Slot step in minutes. */ private function align_start( DateTimeImmutable $start, int $step_min ): DateTimeImmutable { $minute = (int) $start->format( 'i' ); $second = (int) $start->format( 's' ); $mod = $minute % $step_min; if ( 0 === $mod && 0 === $second ) { return $start; } $add = $step_min - $mod; return $start->setTime( (int) $start->format( 'H' ), $minute, 0 ) ->modify( sprintf( '+%d minutes', 0 === $mod ? $step_min : $add ) ); } }