package Koha::Booking; # Copyright PTFS Europe 2021 # # This file is part of Koha. # # Koha is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # Koha is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Koha; if not, see . use Modern::Perl; use Koha::Exceptions::Booking; use Koha::DateUtils qw( dt_from_string ); use Koha::Items; use Koha::Patrons; use Koha::Libraries; use C4::Letters; use List::Util qw(any); use base qw(Koha::Object); =head1 NAME Koha::Booking - Koha Booking object class =head1 API =head2 Class methods =head3 set_itemtype_filter $booking->set_itemtype_filter($itemtype_id); Sets a transient itemtype filter for item selection. This is used when creating "any item" bookings to filter items by type before optimal selection. This value is not persisted to the database. =cut sub set_itemtype_filter { my ( $self, $itemtype_id ) = @_; $self->{_itemtype_filter} = $itemtype_id; return $self; } =head3 biblio Returns the related Koha::Biblio object for this booking =cut sub biblio { my ($self) = @_; my $biblio_rs = $self->_result->biblio; return Koha::Biblio->_new_from_dbic($biblio_rs); } =head3 patron Returns the related Koha::Patron object for this booking =cut sub patron { my ($self) = @_; my $patron_rs = $self->_result->patron; return Koha::Patron->_new_from_dbic($patron_rs); } =head3 pickup_library Returns the related Koha::Library object for this booking =cut sub pickup_library { my ($self) = @_; my $pickup_library_rs = $self->_result->pickup_library; return Koha::Library->_new_from_dbic($pickup_library_rs); } =head3 item Returns the related Koha::Item object for this Booking =cut sub item { my ($self) = @_; my $item_rs = $self->_result->item; return unless $item_rs; return Koha::Item->_new_from_dbic($item_rs); } =head3 checkout Returns the related Koha::Checkout object for this booking, if any =cut sub checkout { my ($self) = @_; my $checkout_rs = $self->_result->issues->first; return unless $checkout_rs; return Koha::Checkout->_new_from_dbic($checkout_rs); } =head3 old_checkout Returns the related Koha::Old::Checkout object for this booking, if any =cut sub old_checkout { my ($self) = @_; my $old_checkout_rs = $self->_result->old_issues->first; return unless $old_checkout_rs; return Koha::Old::Checkout->_new_from_dbic($old_checkout_rs); } =head3 store Booking specific store method to catch booking clashes and ensure we have an item assigned We assume that if an item is passed, it's bookability has already been checked. This is to allow overrides in the future. =cut sub store { my ($self) = @_; $self->_result->result_source->schema->txn_do( sub { # Nothing changed, nothing to validate; circulation may issue # redundant status writes (e.g. when switching an on-site # checkout) that must not re-trigger clash detection if ( $self->in_storage && !( () = $self->_result->get_dirty_columns ) ) { return $self->SUPER::store; } if ( $self->item_id ) { Koha::Exceptions::Object::FKConstraint->throw( broken_fk => 'item_id', value => $self->item_id, ) unless ( $self->item ); $self->biblio_id( $self->item->biblionumber ) unless $self->biblio_id; Koha::Exceptions::Object::FKConstraint->throw( broken_fk => 'item/biblio_id', value => "item biblionumber: " . $self->item->biblionumber . " booking biblio_id: " . $self->biblio_id ) unless ( $self->biblio_id == $self->item->biblionumber ); } Koha::Exceptions::Object::FKConstraint->throw( broken_fk => 'biblio_id', value => $self->biblio_id, ) unless ( $self->biblio ); # Skip clash detection when the status transition cannot # introduce a new clash: terminal statuses (cancelled, # completed) and collection (issued) leave the booked # period untouched, and the checks can produce false # positives (e.g. against the booking's own checkout). unless ( $self->_skip_clash_detection ) { # Throw exception for item level booking clash Koha::Exceptions::Booking::Clash->throw() if $self->item_id && !$self->item->check_booking( { start_date => $self->start_date, end_date => $self->end_date, booking_id => $self->in_storage ? $self->booking_id : undef } ); # Throw exception for biblio level booking clash Koha::Exceptions::Booking::Clash->throw() if !$self->biblio->check_booking( { start_date => $self->start_date, end_date => $self->end_date, booking_id => $self->in_storage ? $self->booking_id : undef } ); } # FIXME: We should be able to combine the above two functions into one # Assign item at booking time if ( !$self->item_id ) { $self->_assign_item_for_booking; } if ( !$self->in_storage ) { $self->SUPER::store; $self->discard_changes; $self->_send_notice( { notice => 'BOOKING_CONFIRMATION' } ); } else { my %updated_columns = $self->_result->get_dirty_columns; my $old_booking = $self->get_from_storage; $self->SUPER::store; if ( exists( $updated_columns{status} ) && $updated_columns{status} eq 'cancelled' ) { $self->_send_notice( { notice => 'BOOKING_CANCELLATION', objects => { old_booking => $old_booking } } ); } elsif ( exists( $updated_columns{pickup_library_id} ) or exists( $updated_columns{start_date} ) or exists( $updated_columns{end_date} ) ) { $self->_send_notice( { notice => 'BOOKING_MODIFICATION', objects => { old_booking => $old_booking } } ); } } } ); return $self; } =head3 _assign_item_for_booking $self->_assign_item_for_booking; Used internally in Koha::Booking->store to ensure we have an item assigned for the booking. =cut sub _assign_item_for_booking { my ($self) = @_; my $biblio = $self->biblio; my $start_date = dt_from_string( $self->start_date ); my $end_date = dt_from_string( $self->end_date ); my $dtf = Koha::Database->new->schema->storage->datetime_parser; my $existing_bookings = $biblio->bookings( { '-and' => [ { '-or' => [ start_date => { '-between' => [ $dtf->format_datetime($start_date), $dtf->format_datetime($end_date) ] }, end_date => { '-between' => [ $dtf->format_datetime($start_date), $dtf->format_datetime($end_date) ] }, { start_date => { '<' => $dtf->format_datetime($start_date) }, end_date => { '>' => $dtf->format_datetime($end_date) } } ] }, { status => { '-not_in' => [ 'cancelled', 'completed' ] } } ] } ); my $checkouts = $biblio->current_checkouts->search( { date_due => { '>=' => $dtf->format_datetime($start_date) } } ); # Build search conditions for bookable items my $item_search_conditions = { itemnumber => [ '-and' => { '-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query }, { '-not_in' => $checkouts->_resultset->get_column('itemnumber')->as_query } ] }; # Filter by itemtype if specified (for "any item" bookings) if ( $self->{_itemtype_filter} ) { $item_search_conditions->{itype} = $self->{_itemtype_filter}; } my $bookable_items = $biblio->bookable_items->search($item_search_conditions); # Use optimal selection instead of random selection my $optimal_item = $self->_select_optimal_item($bookable_items); Koha::Exceptions::Booking::Clash->throw("No available items found for booking") unless $optimal_item; my $itemnumber = $optimal_item->itemnumber; return $self->item_id($itemnumber); } =head3 get_items_that_can_fill my $items = $bookings->get_items_that_can_fill(); Return the list of items that can fulfill this booking. Items that are not: in transit lost withdrawn not for loan not already booked =cut sub get_items_that_can_fill { my ($self) = @_; return; } =head3 to_api_mapping This method returns the mapping for representing a Koha::Booking object on the API. =cut sub to_api_mapping { return {}; } =head2 Internal methods =head3 _skip_clash_detection my $bool = $self->_skip_clash_detection; Returns true when the booking is being transitioned to a status for which clash detection is not meaningful: the terminal statuses C and C, or C (collection). A booking being collected already occupies the period being validated, and the item's own checkout would register as a false clash. The transition to C only skips the checks while the booked period and item remain unchanged, and only from an active (C or C) stored status; reactivating a terminal booking is still validated. =cut sub _skip_clash_detection { my ($self) = @_; return 0 unless $self->in_storage; my $updated_columns = { $self->_result->get_dirty_columns }; my $new_status = $updated_columns->{status}; return 0 unless $new_status; return 1 if any { $_ eq $new_status } qw( cancelled completed ); if ( $new_status eq 'issued' ) { return 0 if any { exists $updated_columns->{$_} } qw( start_date end_date item_id biblio_id ); my $stored_status = $self->get_from_storage->status; return 1 if any { $_ eq $stored_status } qw( new issued ); } return 0; } =head3 _select_optimal_item my $item_id = $self->_select_optimal_item($available_items); Selects the optimal item from a set of available items by choosing the item with the longest future availability after the booking ends. This maximizes future booking opportunities by preserving items with shorter future availability for bookings that specifically need them. =cut sub _select_optimal_item { my ( $self, $available_items ) = @_; return unless $available_items && $available_items->count > 0; # If only one item available, return it immediately return $available_items->next if $available_items->count == 1; my $check_date = dt_from_string( $self->end_date )->add( days => 1 ); my $dtf = Koha::Database->new->schema->storage->datetime_parser; # Get item IDs from the available items my @item_ids = map { $_->itemnumber } $available_items->as_list; # Find the item with the latest (furthest in future) next booking start date # Items with no future bookings will have NULL and sort first (most desirable) my $search_conditions = { item_id => { '-in' => \@item_ids }, start_date => { '>=' => $dtf->format_datetime($check_date) }, status => { '-not_in' => [ 'cancelled', 'completed' ] } }; # Exclude current booking if we're editing if ( $self->in_storage ) { $search_conditions->{booking_id} = { '!=' => $self->booking_id }; } # Query to find the earliest future booking for each item my $rs = Koha::Bookings->search( $search_conditions, { select => [ 'item_id', { min => 'start_date', -as => 'next_booking_start' } ], as => [ 'item_id', 'next_booking_start' ], group_by => ['item_id'], } ); # Get items that have future bookings, sorted by next booking date (desc = later is better) my %next_booking_by_item; while ( my $row = $rs->next ) { $next_booking_by_item{ $row->get_column('item_id') } = $row->get_column('next_booking_start'); } # Find the best item: items without future bookings first, then by latest next booking my $best_item_id; my $latest_next_booking; foreach my $item_id (@item_ids) { my $next_booking = $next_booking_by_item{$item_id}; # Items with no future bookings are best (infinite availability) if ( !defined $next_booking ) { $best_item_id = $item_id; last; } # Otherwise, prefer items with later (further in future) next bookings if ( !defined $latest_next_booking || $next_booking gt $latest_next_booking ) { $latest_next_booking = $next_booking; $best_item_id = $item_id; } } # Return the optimal item return Koha::Items->find($best_item_id) if $best_item_id; # Fallback: shouldn't reach here, but return first available item $available_items->reset; return $available_items->next; } =head3 _send_notice $self->_send_notice(); Sends appropriate notice to patron. =cut sub _send_notice { my ( $self, $params ) = @_; my $notice = $params->{notice}; my $objects = $params->{objects} // {}; $objects->{booking} = $self; my $branch = C4::Context->userenv->{'branch'}; my $patron = $self->patron; my $letter = C4::Letters::GetPreparedLetter( module => 'bookings', letter_code => $notice, message_transport_type => 'email', branchcode => $branch, lang => $patron->lang, objects => $objects ); if ($letter) { C4::Letters::EnqueueLetter( { letter => $letter, borrowernumber => $patron->borrowernumber, message_transport_type => 'email', } ); } } =head3 _type =cut sub _type { return 'Booking'; } =head1 AUTHORS Martin Renvoize =cut 1;