2026-06-11 19:03:29 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
use Reservair\Database\Db;
|
|
|
|
|
use Reservair\Logger\Logger;
|
|
|
|
|
|
|
|
|
|
class RsvReservationService {
|
|
|
|
|
private RsvReservationRepository $repo;
|
|
|
|
|
|
|
|
|
|
public function __construct() {
|
|
|
|
|
$this->repo = new RsvReservationRepository();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function get_all(?int $limit = null, int $skip = 0) {
|
|
|
|
|
return $this->repo->get_all($limit, $skip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function count_all(): int {
|
|
|
|
|
return $this->repo->count_all();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function get(int $id) {
|
|
|
|
|
return $this->repo->get($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function get_detail(int $id): ?array {
|
|
|
|
|
return $this->repo->get_detail($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function create(RsvReservation $reservation) {
|
2026-06-16 10:54:00 +02:00
|
|
|
$reservation->timetable_reservations =
|
|
|
|
|
$this->merge_adjacent($reservation->timetable_reservations);
|
|
|
|
|
|
2026-06-11 19:03:29 +02:00
|
|
|
// Serialise the availability-check + insert per timetable so two
|
|
|
|
|
// concurrent bookings for the last free slot can't both pass the
|
|
|
|
|
// capacity check and oversell. Locks are taken in a stable order to
|
|
|
|
|
// avoid lock-ordering deadlocks and released only after the commit.
|
|
|
|
|
$timetable_ids = array_values(array_unique(array_map(
|
|
|
|
|
fn($tr) => $tr->timetable_id,
|
|
|
|
|
$reservation->timetable_reservations
|
|
|
|
|
)));
|
|
|
|
|
sort($timetable_ids);
|
|
|
|
|
|
|
|
|
|
foreach ($timetable_ids as $tid) {
|
|
|
|
|
if (!Db::acquire_lock($this->booking_lock_name($tid))) {
|
|
|
|
|
throw new \RuntimeException('Could not acquire booking lock for timetable ' . $tid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$timetable_reservation_service = new RsvTimetableReservationService();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
Db::begin_transaction();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$reservation_id = $this->repo->insert($reservation->to_array());
|
2026-06-17 11:15:09 +02:00
|
|
|
$reservation->id = $reservation_id;
|
2026-06-11 19:03:29 +02:00
|
|
|
|
|
|
|
|
foreach ($reservation->timetable_reservations as $timetable_reservation) {
|
2026-06-17 11:15:09 +02:00
|
|
|
$timetable_reservation->id = $timetable_reservation_service->create($reservation_id, $timetable_reservation);
|
2026-06-11 19:03:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Db::commit();
|
|
|
|
|
} catch (Exception $e) {
|
|
|
|
|
Db::rollback();
|
|
|
|
|
Logger::error($e);
|
|
|
|
|
throw new \Exception($e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only now that the rows are durably committed do we let listeners
|
|
|
|
|
// (maintainer emails, calendar sync) observe the new reservation.
|
2026-06-17 11:15:09 +02:00
|
|
|
foreach($reservation->timetable_reservations as $timetable_reservation) {
|
|
|
|
|
if($timetable_reservation->is_confirmed === null) {
|
|
|
|
|
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id);
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent(
|
|
|
|
|
$reservation_id,
|
|
|
|
|
$timetable_reservation,
|
|
|
|
|
$timetable_reservation_service->get_confirmation_code($timetable_reservation->id),
|
|
|
|
|
$maintainer_email
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvTimetableReservationCreatedEvent($timetable_reservation, $reservation));
|
|
|
|
|
}
|
2026-06-11 19:03:29 +02:00
|
|
|
|
|
|
|
|
$this->confirmation_state_changed($reservation_id);
|
|
|
|
|
|
|
|
|
|
return $reservation_id;
|
|
|
|
|
} finally {
|
|
|
|
|
foreach (array_reverse($timetable_ids) as $tid) {
|
|
|
|
|
Db::release_lock($this->booking_lock_name($tid));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function booking_lock_name(int $timetable_id): string {
|
|
|
|
|
return Db::prefix() . 'rsv_booking_' . $timetable_id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 10:54:00 +02:00
|
|
|
/**
|
|
|
|
|
* Collapse runs of touching reservations into single spans, so a sequence
|
|
|
|
|
* of back-to-back blocks is stored and notified as one booking.
|
|
|
|
|
*
|
|
|
|
|
* @param list<RsvTimetableReservation> $timetable_reservations
|
|
|
|
|
* @return list<RsvTimetableReservation>
|
|
|
|
|
*/
|
|
|
|
|
private function merge_adjacent(array $timetable_reservations): array {
|
|
|
|
|
$by_timetable = [];
|
|
|
|
|
foreach ($timetable_reservations as $tr) {
|
|
|
|
|
$by_timetable[$tr->timetable_id][] = $tr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$merged = [];
|
|
|
|
|
foreach ($by_timetable as $group) {
|
|
|
|
|
usort($group, fn($a, $b) => $a->start_utc <=> $b->start_utc);
|
|
|
|
|
|
|
|
|
|
$current = null;
|
|
|
|
|
foreach ($group as $tr) {
|
|
|
|
|
if ($current !== null && $tr->start_utc <= $current->end_utc) {
|
|
|
|
|
if ($tr->end_utc > $current->end_utc) {
|
|
|
|
|
$current->end_utc = $tr->end_utc;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($current !== null) {
|
|
|
|
|
$merged[] = $current;
|
|
|
|
|
}
|
|
|
|
|
$current = new RsvTimetableReservation(
|
|
|
|
|
null, $tr->timetable_id, $tr->start_utc, $tr->end_utc
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($current !== null) {
|
|
|
|
|
$merged[] = $current;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $merged;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:03:29 +02:00
|
|
|
/** Delete a reservation and its dependent timetable reservations and confirmations. */
|
|
|
|
|
public function delete(int $id): void {
|
|
|
|
|
$this->repo->delete($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function confirmation_state_changed(int $reservation_id): void {
|
|
|
|
|
// Terminal state is persisted, so this is idempotent: once a reservation
|
|
|
|
|
// is confirmed or refused, repeat calls (e.g. a Google Calendar re-sync)
|
|
|
|
|
// short-circuit and never re-dispatch the closing events.
|
|
|
|
|
if ($this->repo->is_resolved($reservation_id)) {
|
|
|
|
|
Logger::info('Reservation already resolved: ' . $reservation_id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A reservation with no timetable items has nothing to confirm or refuse;
|
|
|
|
|
// bailing out avoids mislabelling it as refused.
|
|
|
|
|
if ($this->repo->count_subitems($reservation_id) === 0) {
|
|
|
|
|
Logger::info('Reservation has not subitems: ' . $reservation_id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$timetable_service = new RsvTimetableReservationService();
|
|
|
|
|
$all_confirmed = $this->repo->are_subitems_confirmed($reservation_id);
|
|
|
|
|
$any_pending = $timetable_service->has_pending_confirmation($reservation_id);
|
|
|
|
|
|
|
|
|
|
if ($all_confirmed) {
|
|
|
|
|
$this->repo->set_resolved_state($reservation_id, true);
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvReservationConfirmedEvent($reservation_id));
|
|
|
|
|
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
|
|
|
|
|
if ($form_submit_id !== null) {
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, true));
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$any_pending) {
|
|
|
|
|
// All items resolved but not all confirmed — at least one refused.
|
|
|
|
|
$this->repo->set_resolved_state($reservation_id, false);
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvReservationRefusedEvent($reservation_id));
|
|
|
|
|
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
|
|
|
|
|
if ($form_submit_id !== null) {
|
|
|
|
|
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, false));
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Logger::info('Waiting for pending confirmations on reservation: ' . $reservation_id);
|
|
|
|
|
}
|
|
|
|
|
}
|