diff --git a/components/ILIAS/BookingManager/BookingProcess/class.WeekGUI.php b/components/ILIAS/BookingManager/BookingProcess/class.WeekGUI.php index 99d1b8e86aa2..6151b837ec53 100755 --- a/components/ILIAS/BookingManager/BookingProcess/class.WeekGUI.php +++ b/components/ILIAS/BookingManager/BookingProcess/class.WeekGUI.php @@ -175,7 +175,7 @@ protected function getWeekGridEntries( $nr_available = \ilBookingReservation::getAvailableObject( [$object_id], $slot_from, - $slot_to - 1, + $slot_to, false, true ); diff --git a/components/ILIAS/BookingManager/BookingProcess/class.ilBookingProcessWithScheduleGUI.php b/components/ILIAS/BookingManager/BookingProcess/class.ilBookingProcessWithScheduleGUI.php index 696d42e5edc0..cede74b87169 100755 --- a/components/ILIAS/BookingManager/BookingProcess/class.ilBookingProcessWithScheduleGUI.php +++ b/components/ILIAS/BookingManager/BookingProcess/class.ilBookingProcessWithScheduleGUI.php @@ -255,7 +255,7 @@ public function showNumberForm(): void $this->log->debug("showNumberForm"); $object_id = $this->book_obj_id; $from = $this->book_request->getSlotFrom(); - $to = $this->book_request->getSlotTo() - 1; + $to = $this->book_request->getSlotTo(); $this->tabs_gui->clearTargets(); $this->tabs_gui->setBackTarget( $this->lng->txt('book_back_to_list'), diff --git a/components/ILIAS/BookingManager/Reservations/class.ReservationDBRepository.php b/components/ILIAS/BookingManager/Reservations/class.ReservationDBRepository.php index df38fdad93d8..e1bc0b6e9185 100755 --- a/components/ILIAS/BookingManager/Reservations/class.ReservationDBRepository.php +++ b/components/ILIAS/BookingManager/Reservations/class.ReservationDBRepository.php @@ -147,11 +147,17 @@ public function getNumberOfReservations( ? " AND date_to > {$this->db->quote(time(), ilDBConstants::T_INTEGER)}" : ''; + $date_to_match = 'date_to = ' . $this->db->quote($to, ilDBConstants::T_INTEGER); + if ($to > 0 && $to % 60 === 0) { + $date_to_match = '(' . $date_to_match + . ' OR date_to = ' . $this->db->quote($to - 1, ilDBConstants::T_INTEGER) . ')'; + } + $set = $this->db->query( 'SELECT count(*) cnt, object_id FROM booking_reservation' . " WHERE {$this->db->in('object_id', $ids, '', ilDBConstants::T_INTEGER)}{$date}" . " AND (status IS NULL OR status <> {$this->db->quote(ilBookingReservation::STATUS_CANCELLED, ilDBConstants::T_INTEGER)})" - . " AND date_from = {$this->db->quote($from, ilDBConstants::T_INTEGER)} AND date_to = {$this->db->quote($to, ilDBConstants::T_INTEGER)}" + . " AND date_from = {$this->db->quote($from, ilDBConstants::T_INTEGER)} AND {$date_to_match}" . ' GROUP BY object_id' ); @@ -243,7 +249,7 @@ public function getListByDate( if ($a_has_schedule && ($filter["slot"] ?? false)) { $slot_idx = date("w", $row["date_from"]) . "_" . date("H:i", $row["date_from"]) . - "-" . date("H:i", $row["date_to"] + 1); + "-" . $this->formatReservationEndTimeLabel((int) $row["date_to"]); if ($filter["slot"] != $slot_idx) { continue; } @@ -270,7 +276,7 @@ public function getListByDate( $res[$idx]["booking_reservation_id"] = $idx; $res[$idx]["date"] = date("Y-m-d", $row["date_from"]); $res[$idx]["slot"] = date("H:i", $row["date_from"]) . " - " . - date("H:i", $row["date_to"] + 1); + $this->formatReservationEndTimeLabel((int) $row["date_to"]); $res[$idx]["week"] = date("W", $row["date_from"]); $res[$idx]["weekday"] = date("w", $row["date_from"]); $res[$idx]["can_be_cancelled"] = ($row["status"] != \ilBookingReservation::STATUS_CANCELLED && @@ -343,4 +349,17 @@ public function getReservationIdsByBookingObjectId(int $booking_object_id): arra return $ret; } + + /** + * Display end time for reservations not yet migrated off legacy second-59 ends. + */ + private function formatReservationEndTimeLabel(int $date_to): string + { + $timestamp = $date_to; + if ($date_to > 0 && $date_to % 60 === 59) { + $timestamp++; + } + + return date('H:i', $timestamp); + } } diff --git a/components/ILIAS/BookingManager/classes/Setup/class.Agent.php b/components/ILIAS/BookingManager/classes/Setup/class.Agent.php index 506b81661e07..5cdca22cd712 100755 --- a/components/ILIAS/BookingManager/classes/Setup/class.Agent.php +++ b/components/ILIAS/BookingManager/classes/Setup/class.Agent.php @@ -82,6 +82,7 @@ public function getMigrations(): array new \ilBookingManagerObjectInfoMigration(), new \ilBookingManagerBookingInfoMigration(), new \ilBookingManagerOfflineMigration(), + new \ilBookingManagerReservationTimestampMigration(), ]; } diff --git a/components/ILIAS/BookingManager/classes/Setup/class.ilBookingManagerReservationTimestampMigration.php b/components/ILIAS/BookingManager/classes/Setup/class.ilBookingManagerReservationTimestampMigration.php new file mode 100644 index 000000000000..f7f6e0b43834 --- /dev/null +++ b/components/ILIAS/BookingManager/classes/Setup/class.ilBookingManagerReservationTimestampMigration.php @@ -0,0 +1,161 @@ +db = $environment->getResource(Environment::RESOURCE_DATABASE); + } + + public function step(Environment $environment): void + { + $set = $this->db->query( + 'SELECT booking_reservation_id, user_id, date_from, date_to FROM booking_reservation' + . ' WHERE (date_from > 0 AND MOD(date_from, 60) <> 0)' + . ' OR (date_to > 0 AND MOD(date_to, 60) <> 0)' + . ' LIMIT 1' + ); + + $row = $this->db->fetchAssoc($set); + if ($row === null) { + return; + } + + $updates = []; + $normalized_from = $this->normalizeTimestamp((int) $row['date_from']); + if ($normalized_from !== (int) $row['date_from']) { + $updates['date_from'] = [ilDBConstants::T_INTEGER, $normalized_from]; + } + + $normalized_to = $this->normalizeTimestamp((int) $row['date_to']); + if ($normalized_to !== (int) $row['date_to']) { + $updates['date_to'] = [ilDBConstants::T_INTEGER, $normalized_to]; + } + + if ($updates === []) { + return; + } + + if (isset($updates['date_to'])) { + $this->updateLinkedPersonalCalendarEnd( + (int) $row['booking_reservation_id'], + (int) $row['user_id'], + (int) $row['date_to'], + $normalized_to + ); + } + + $this->db->update( + 'booking_reservation', + $updates, + [ + 'booking_reservation_id' => [ilDBConstants::T_INTEGER, (int) $row['booking_reservation_id']], + ] + ); + } + + public function getRemainingAmountOfSteps(): int + { + $set = $this->db->query( + 'SELECT COUNT(booking_reservation_id) AS cnt FROM booking_reservation' + . ' WHERE (date_from > 0 AND MOD(date_from, 60) <> 0)' + . ' OR (date_to > 0 AND MOD(date_to, 60) <> 0)' + ); + + return (int) ($this->db->fetchObject($set)?->cnt ?? 0); + } + + private function normalizeTimestamp(int $timestamp): int + { + if ($timestamp <= 0) { + return $timestamp; + } + + $remainder = $timestamp % 60; + if ($remainder === 0) { + return $timestamp; + } + + return $timestamp + (60 - $remainder); + } + + private function updateLinkedPersonalCalendarEnd( + int $booking_reservation_id, + int $user_id, + int $legacy_date_to, + int $normalized_date_to + ): void { + if ($legacy_date_to <= 0) { + return; + } + + $format = 'Y-m-d H:i:s'; + $this->db->manipulateF( + << $object_id, 'title' => $titles[$object_id], 'slot_from' => $slot_from, - 'slot_to' => $slot_to + 1, + 'slot_to' => $slot_to, 'has_schedule' => count($parts) === 3, 'has_user_active_booking' => $user_id === (int) $reservation['user_id'] @@ -330,7 +330,7 @@ private function buildItemDescription(array $record): string $this->lng->loadLanguageModule('dateplaner'); $period = ilDatePresentation::formatPeriod( new ilDateTime($record['slot_from'], IL_CAL_UNIX), - new ilDateTime($record['slot_to'] - 1, IL_CAL_UNIX) + new ilDateTime($record['slot_to'], IL_CAL_UNIX) ); return "{$record['title']} ({$period})"; diff --git a/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableCancelAction.php b/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableCancelAction.php index 79f5e78d431c..f89c57efeac6 100644 --- a/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableCancelAction.php +++ b/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableCancelAction.php @@ -177,7 +177,7 @@ protected function formatBookingDescription(array $booking): string if ($this->booking_pool->getScheduleType() !== ilObjBookingPool::TYPE_NO_SCHEDULE && $booking['date_from']) { $parts[] = ilDatePresentation::formatPeriod( new ilDateTime($booking['date_from'], IL_CAL_UNIX), - new ilDateTime($booking['date_to'] + 1, IL_CAL_UNIX) + new ilDateTime($booking['date_to'], IL_CAL_UNIX) ); } return implode(' – ', $parts); diff --git a/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableDeleteAction.php b/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableDeleteAction.php index 2c9314e95f89..ec7688f3922f 100644 --- a/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableDeleteAction.php +++ b/components/ILIAS/BookingManager/src/Booking/Table/Action/BookingsTableDeleteAction.php @@ -165,7 +165,7 @@ protected function formatBookingDescription(array $booking): string if ($this->booking_pool->getScheduleType() !== ilObjBookingPool::TYPE_NO_SCHEDULE && $booking['date_from']) { $parts[] = ilDatePresentation::formatPeriod( new ilDateTime($booking['date_from'], IL_CAL_UNIX), - new ilDateTime($booking['date_to'] + 1, IL_CAL_UNIX) + new ilDateTime($booking['date_to'], IL_CAL_UNIX) ); } return implode(' – ', $parts); diff --git a/components/ILIAS/BookingManager/src/Booking/Table/BookingsTable.php b/components/ILIAS/BookingManager/src/Booking/Table/BookingsTable.php index cc30d6caae1c..dec1e59d6c18 100644 --- a/components/ILIAS/BookingManager/src/Booking/Table/BookingsTable.php +++ b/components/ILIAS/BookingManager/src/Booking/Table/BookingsTable.php @@ -517,7 +517,7 @@ private function parseBookingPeriodValue(mixed $value): ?int protected function getBookingTimeSlotFilterLabel(array $booking): string { $date_from = new DateTimeImmutable("@{$booking['date_from']}"); - $date_to = new DateTimeImmutable('@' . ((int) $booking['date_to'] + 1)); + $date_to = new DateTimeImmutable('@' . (int) $booking['date_to']); return "{$date_from->format('H:i')} - {$date_to->format('H:i')}"; } diff --git a/components/ILIAS/BookingManager/src/Booking/Table/BookingsWithScheduleTable.php b/components/ILIAS/BookingManager/src/Booking/Table/BookingsWithScheduleTable.php index db0272bd5327..5194bef19227 100644 --- a/components/ILIAS/BookingManager/src/Booking/Table/BookingsWithScheduleTable.php +++ b/components/ILIAS/BookingManager/src/Booking/Table/BookingsWithScheduleTable.php @@ -54,8 +54,6 @@ public function getRows( foreach ($records as $record) { $user_id = $record['user_id']; - $record['date_to']++; - $date_from = new DateTimeImmutable("@{$record['date_from']}"); $time_slot = $this->isAllDayBooking($record) ? $this->lng->txt('book_all_day') diff --git a/components/ILIAS/BookingManager/tests/BookingManagerReservationTimestampMigrationTest.php b/components/ILIAS/BookingManager/tests/BookingManagerReservationTimestampMigrationTest.php new file mode 100644 index 000000000000..8d18852ce312 --- /dev/null +++ b/components/ILIAS/BookingManager/tests/BookingManagerReservationTimestampMigrationTest.php @@ -0,0 +1,50 @@ +invoke($migration, $input)); + } + + public static function normalizeTimestampProvider(): Generator + { + yield 'zero stays zero' => [0, 0]; + yield 'negative value stays unchanged' => [-100, -100]; + + $minute_aligned = mktime(12, 0, 0, 7, 15, 2026); + yield 'already minute aligned stays unchanged' => [$minute_aligned, $minute_aligned]; + + $legacy_end_minus_one = mktime(11, 59, 59, 7, 15, 2026); + yield 'legacy end minus one second rounds up' => [$legacy_end_minus_one, $minute_aligned]; + + $one_second_past_minute = mktime(12, 0, 1, 7, 15, 2026); + $next_minute = mktime(12, 1, 0, 7, 15, 2026); + yield 'one second past minute rounds up' => [$one_second_past_minute, $next_minute]; + } +} diff --git a/components/ILIAS/BookingManager/tests/ilBookingProcessWithScheduleGUITest.php b/components/ILIAS/BookingManager/tests/ilBookingProcessWithScheduleGUITest.php new file mode 100644 index 000000000000..89ea394a9aae --- /dev/null +++ b/components/ILIAS/BookingManager/tests/ilBookingProcessWithScheduleGUITest.php @@ -0,0 +1,222 @@ +slot_from = mktime(10, 0, 0, 7, 15, 2026); + $this->slot_to = mktime(12, 0, 0, 7, 15, 2026); + } + + public function testBookingProcessForwardsInclusiveSlotEndThroughRequests(): void + { + $slot = "{$this->slot_from}_{$this->slot_to}"; + $object_id = 42; + $recorded_ctrl_params = []; + + $reservation = parent::createMock(ReservationManager::class); + $reservation->expects(parent::exactly(2)) + ->method('getAvailableNr') + ->with($object_id, $this->slot_from, $this->slot_to) + ->willReturn(3); + + $ctrl = parent::createMock(ilCtrl::class); + $ctrl->method('getLinkTarget')->willReturn('http://ilias.test/bookAvailableItems'); + $ctrl->method('setParameter')->willReturnCallback( + function ($gui, string $name, mixed $value) use (&$recorded_ctrl_params): void { + $recorded_ctrl_params[$name] = $value; + } + ); + $ctrl->method('setParameterByClass')->willReturnCallback( + function (string $class, string $name, mixed $value) use (&$recorded_ctrl_params): void { + $recorded_ctrl_params[$name] = $value; + } + ); + $ctrl->method('saveParameter')->willReturnSelf(); + + $modal = parent::createMock(ModalAdapterGUI::class); + $modal->method('form')->willReturnSelf(); + $modal->method('send')->willReturnSelf(); + + $gui_service = parent::createMock(InternalGUIService::class); + $gui_service->method('modal')->willReturn($modal); + $gui_service->method('send')->willReturnCallback(static function (): void { + }); + + $valid_form = parent::createMock(FormAdapterGUI::class); + $valid_form->method('isValid')->willReturn(true); + $valid_form->method('getData')->willReturnMap([ + ['origin_cmd', ''], + ['recurrence', '0'], + ['nr', 1], + ]); + + $process = parent::createMock(BookingProcessManager::class); + $process->expects(parent::once()) + ->method('bookAvailableObjects') + ->with( + $object_id, + 7, + 7, + 0, + $this->slot_from, + $this->slot_to, + 0, + 1, + null, + '' + ) + ->willReturn([99]); + + $pool = parent::getMockBuilder(ilObjBookingPool::class) + ->disableOriginalConstructor() + ->onlyMethods(['usesMessages']) + ->getMock(); + $pool->method('usesMessages')->willReturn(false); + $pool->setRefId(15); + + $access = parent::createMock(AccessManager::class); + $access->method('canManageReservationForUser')->willReturn(true); + + $util_gui = parent::createMock(ProcessUtilGUI::class); + $util_gui->expects(parent::once()) + ->method('handleBookingSuccess') + ->with($object_id, 'displayPostInfo', [99]); + + $tabs = parent::createMock(ilTabsGUI::class); + $lng = parent::createMock(ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + $log = parent::createMock(ilLogger::class); + + $gui = new ilBookingProcessWithScheduleGUIUnderTest(); + $gui->stubForm = $valid_form; + $this->inject($gui, 'book_request', $this->createRequest([ + 'slot' => $slot, + 'object_id' => (string) $object_id, + ])); + $this->inject($gui, 'ctrl', $ctrl); + $this->inject($gui, 'tabs_gui', $tabs); + $this->inject($gui, 'lng', $lng); + $this->inject($gui, 'log', $log); + $this->inject($gui, 'gui', $gui_service); + $this->inject($gui, 'reservation', $reservation); + $this->inject($gui, 'pool', $pool); + $this->inject($gui, 'book_obj_id', $object_id); + $this->inject($gui, 'process', $process); + $this->inject($gui, 'access', $access); + $this->inject($gui, 'util_gui', $util_gui); + $this->inject($gui, 'user_id_to_book', 7); + $this->inject($gui, 'user_id_assigner', 7); + $this->inject($gui, 'context_obj_id', 0); + $this->inject($gui, 'tpl', parent::createMock(ilGlobalTemplateInterface::class)); + + $gui->showNumberForm(); + + parent::assertSame([$this->slot_from, $this->slot_to], $gui->getNumberFormArgs); + parent::assertSame($slot, $recorded_ctrl_params['slot'] ?? null); + parent::assertNotSame($this->slot_to - 1, $gui->getNumberFormArgs[1]); + + $recorded_ctrl_params = []; + $this->inject($gui, 'book_request', $this->createRequest([ + 'slot' => $slot, + 'object_id' => (string) $object_id, + ])); + + $gui->processNumberForm(); + + parent::assertSame($slot, $recorded_ctrl_params['slot'] ?? null); + parent::assertNotSame($this->slot_from . '_' . ($this->slot_to - 1), $recorded_ctrl_params['slot'] ?? null); + + $this->inject($gui, 'book_request', $this->createRequest([ + 'slot' => $slot, + 'object_id' => (string) $object_id, + 'nr' => '1', + 'recurrence' => '0', + ])); + + $gui->exposeBookAvailableItems(); + } + + private function createRequest(array $get): StandardGUIRequest + { + return new StandardGUIRequest( + parent::createMock(Services::class), + new RefineryFactory(new Factory(), parent::createMock(ilLanguage::class)), + $get, + [] + ); + } + + private function inject(object $object, string $property, mixed $value): void + { + (new ReflectionProperty($object, $property))->setValue($object, $value); + } +} + +/** + * @internal + */ +final class ilBookingProcessWithScheduleGUIUnderTest extends ilBookingProcessWithScheduleGUI +{ + /** @var array{0: int, 1: int}|null */ + public ?array $getNumberFormArgs = null; + + public ?FormAdapterGUI $stubForm = null; + + public function __construct() + { + } + + protected function getNumberForm(int $from, int $to): FormAdapterGUI + { + $this->getNumberFormArgs = [$from, $to]; + $this->reservation->getAvailableNr($this->book_request->getObjectId(), $from, $to); + $this->ctrl->setParameter($this, 'slot', $from . '_' . $to); + + return $this->stubForm ?? $this->createMock(FormAdapterGUI::class); + } + + protected function getBookgingObjectTitle(): string + { + return 'Test booking object'; + } + + public function exposeBookAvailableItems(): void + { + $this->bookAvailableItems(); + } +} diff --git a/components/ILIAS/Calendar/classes/ilCalendarScheduleFilterBookingPool.php b/components/ILIAS/Calendar/classes/ilCalendarScheduleFilterBookingPool.php index 5906b8e541a6..7b4f813840f7 100755 --- a/components/ILIAS/Calendar/classes/ilCalendarScheduleFilterBookingPool.php +++ b/components/ILIAS/Calendar/classes/ilCalendarScheduleFilterBookingPool.php @@ -47,21 +47,6 @@ public function filterCategories(array $a_cats): array */ public function modifyEvent(ilCalendarEntry $a_event): ?ilCalendarEntry { - $category = $this->isBookingPoolCategory(ilCalendarCategoryAssignments::_lookupCategory($a_event->getEntryId())); - - if ($category) { - /** - * booking pool reservations are stored in the database with ending time excluded. Keeping it 1 second - * under the entered end date. - * e.g. Event from 10:00 to 11:00 is stored 10:00 to 10:59 using timestamps. - * incrementing 1 second avoid titles/zip folders using this -1 second representation. - */ - $end = $a_event->getEnd()->get(IL_CAL_UNIX); - $new_end = new ilDateTime($end + 1, IL_CAL_UNIX); - - $a_event->setEnd($new_end); - } - return $a_event; } @@ -72,20 +57,4 @@ public function addCustomEvents(ilDate $start, ilDate $end, array $a_categories) { return []; } - - /** - * @param $a_cat_id - * @return null|ilCalendarCategory - */ - protected function isBookingPoolCategory(int $a_cat_id): ?ilCalendarCategory - { - $category = ilCalendarCategory::getInstanceByCategoryId($a_cat_id); - - $cat_type = $category->getType(); - - if ($cat_type === ilCalendarCategory::TYPE_BOOK) { - return $category; - } - return null; - } }