Koha-clms/Koha/Booking.pm

506 lines
14 KiB
Perl

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 <https://www.gnu.org/licenses>.
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 {
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 transitioning to a final
# status. During checkout C4::Circulation sets status
# to 'completed' and calls ->store; the clash checks
# are irrelevant at that point and can produce false
# positives.
unless ( $self->_is_final_status_transition ) {
# 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;
return $self->SUPER::store unless %updated_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 _is_final_status_transition
my $bool = $self->_is_final_status_transition;
Returns true when the booking is being transitioned to a final status
(cancelled or completed). Used to skip clash detection that is only
meaningful for active bookings.
=cut
sub _is_final_status_transition {
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 );
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 <martin.renvoize@ptfs-europe.com>
=cut
1;