em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking { $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); $this->assertFree($resource, $date, $time); $booking = new Booking($user, $resource, $date, $time); $this->em->persist($booking); $this->em->flush(); return $booking; }); } /** * Materialises a series into concrete bookings, skipping days that are already taken. * * The resource is locked once for the whole series rather than per occurrence, so the set of * days either all see the same world or none do. Occurrences fall on distinct days, so the * not-yet-flushed ones cannot collide with each other. */ public function bookSeries(BookingSeries $series): SeriesResult { return $this->em->wrapInTransaction(function () use ($series): SeriesResult { $resource = $series->getResource(); $user = $series->getUser(); $time = $series->getTimeRange(); $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); $created = []; $skipped = []; foreach ($series->occurrences() as $date) { try { $this->assertFree($resource, $date, $time); } catch (ResourceAlreadyBookedException $e) { $skipped[$date->format('Y-m-d')] = $e->getMessage(); continue; } $booking = new Booking($user, $resource, $date, $time, $series); $this->em->persist($booking); $created[] = $booking; } if ([] !== $created) { $this->em->persist($series); } $this->em->flush(); return new SeriesResult($series, $created, $skipped); }); } /** * Moves one occurrence to different times, leaving the rest of its series alone. */ public function reschedule(Booking $booking, TimeRange $time): void { $this->em->wrapInTransaction(function () use ($booking, $time): void { $resource = $booking->getResource(); $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); $this->assertFree($resource, $booking->getDate(), $time, $booking); $booking->changeTime($time); $this->em->flush(); }); } public function release(Booking $booking): void { $this->em->remove($booking); $this->em->flush(); } /** * Ends a series from a day onwards: its remaining occurrences go, and the series itself is * either shortened or dropped if nothing is left of it. * * @return int the number of occurrences removed */ public function endSeries(BookingSeries $series, \DateTimeImmutable $from): int { return $this->em->wrapInTransaction(function () use ($series, $from): int { $from = $from->setTime(0, 0); $removed = 0; foreach ($this->bookings->findForSeriesFrom($series, $from) as $booking) { $this->em->remove($booking); ++$removed; } if ($series->getStartDate() >= $from) { $this->em->remove($series); } else { $series->setEndDate($from->modify('-1 day')); } $this->em->flush(); return $removed; }); } /** * @throws ResourceAlreadyBookedException */ private function assertFree( Resource $resource, \DateTimeImmutable $date, TimeRange $time, ?Booking $ignore = null, ): void { foreach ($this->bookings->findOverlapping($resource, $date, $time) as $clash) { if (null !== $ignore && $clash->getId() === $ignore->getId()) { continue; } throw new ResourceAlreadyBookedException($resource, $date, $clash->getTimeRange()); } } }