Koha/Koha/Hold.pm
Joonas Kylmälä 0ff47f87ef Bug 28520: Revert "Bug 12362: Cancel transfer with hold cancelation"
This reverts commit 6c10582914.

This commit was part of a series adding adding reverse transfers, i.e.
transfers that were created for transfers that were cancelled and
we wanted the item to return back to its sending library. This however
hid the information about transfer happening and we need another approach
to the problem.

Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Rebased-by: Joonas Kylmälä <joonas.kylmala@helsinki.fi>

JD amended patch: restore Koha::Item::Transfer->from_library (we are
adding tests in a future patch).

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>

Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
2021-06-18 10:46:41 -04:00

693 lines
16 KiB
Perl

package Koha::Hold;
# Copyright ByWater Solutions 2014
# Copyright 2017 Koha Development team
#
# 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 <http://www.gnu.org/licenses>.
use Modern::Perl;
use Carp;
use Data::Dumper qw(Dumper);
use List::MoreUtils qw(any);
use C4::Context qw(preference);
use C4::Letters;
use C4::Log;
use Koha::AuthorisedValues;
use Koha::DateUtils qw(dt_from_string output_pref);
use Koha::Patrons;
use Koha::Biblios;
use Koha::Items;
use Koha::Libraries;
use Koha::Old::Holds;
use Koha::Calendar;
use Koha::Exceptions::Hold;
use base qw(Koha::Object);
=head1 NAME
Koha::Hold - Koha Hold object class
=head1 API
=head2 Class methods
=cut
=head3 age
returns the number of days since a hold was placed, optionally
using the calendar
my $age = $hold->age( $use_calendar );
=cut
sub age {
my ( $self, $use_calendar ) = @_;
my $today = dt_from_string;
my $age;
if ( $use_calendar ) {
my $calendar = Koha::Calendar->new( branchcode => $self->branchcode );
$age = $calendar->days_between( dt_from_string( $self->reservedate ), $today );
}
else {
$age = $today->delta_days( dt_from_string( $self->reservedate ) );
}
$age = $age->in_units( 'days' );
return $age;
}
=head3 suspend_hold
my $hold = $hold->suspend_hold( $suspend_until_dt );
=cut
sub suspend_hold {
my ( $self, $dt ) = @_;
my $date = $dt ? $dt->clone()->truncate( to => 'day' )->datetime : undef;
if ( $self->is_found ) { # We can't suspend found holds
if ( $self->is_waiting ) {
Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'W' );
}
elsif ( $self->is_in_transit ) {
Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'T' );
}
elsif ( $self->is_in_processing ) {
Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'P' );
}
else {
Koha::Exceptions::Hold::CannotSuspendFound->throw(
'Unhandled data exception on found hold (id='
. $self->id
. ', found='
. $self->found
. ')' );
}
}
$self->suspend(1);
$self->suspend_until($date);
$self->store();
logaction( 'HOLDS', 'SUSPEND', $self->reserve_id, Dumper( $self->unblessed ) )
if C4::Context->preference('HoldsLog');
return $self;
}
=head3 resume
my $hold = $hold->resume();
=cut
sub resume {
my ( $self ) = @_;
$self->suspend(0);
$self->suspend_until( undef );
$self->store();
logaction( 'HOLDS', 'RESUME', $self->reserve_id, Dumper($self->unblessed) )
if C4::Context->preference('HoldsLog');
return $self;
}
=head3 delete
$hold->delete();
=cut
sub delete {
my ( $self ) = @_;
my $deleted = $self->SUPER::delete($self);
logaction( 'HOLDS', 'DELETE', $self->reserve_id, Dumper($self->unblessed) )
if C4::Context->preference('HoldsLog');
return $deleted;
}
=head3 set_transfer
=cut
sub set_transfer {
my ( $self ) = @_;
$self->priority(0);
$self->found('T');
$self->store();
return $self;
}
=head3 set_waiting
=cut
sub set_waiting {
my ( $self, $desk_id ) = @_;
$self->priority(0);
my $today = dt_from_string();
my $values = {
found => 'W',
waitingdate => $today->ymd,
desk_id => $desk_id,
};
my $requested_expiration;
if ($self->expirationdate) {
$requested_expiration = dt_from_string($self->expirationdate);
}
my $max_pickup_delay = C4::Context->preference("ReservesMaxPickUpDelay");
my $cancel_on_holidays = C4::Context->preference('ExpireReservesOnHolidays');
my $expirationdate = $today->clone;
$expirationdate->add(days => $max_pickup_delay);
if ( C4::Context->preference("ExcludeHolidaysFromMaxPickUpDelay") ) {
my $itemtype = $self->item ? $self->item->effective_itemtype : $self->biblio->itemtype;
my $daysmode = Koha::CirculationRules->get_effective_daysmode(
{
categorycode => $self->borrower->categorycode,
itemtype => $itemtype,
branchcode => $self->branchcode,
}
);
my $calendar = Koha::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode );
$expirationdate = $calendar->days_forward( dt_from_string(), $max_pickup_delay );
}
# If patron's requested expiration date is prior to the
# calculated one, we keep the patron's one.
my $cmp = $requested_expiration ? DateTime->compare($requested_expiration, $expirationdate) : 0;
$values->{expirationdate} = $cmp == -1 ? $requested_expiration->ymd : $expirationdate->ymd;
$self->set($values)->store();
return $self;
}
=head3 is_pickup_location_valid
if ($hold->is_pickup_location_valid({ library_id => $library->id }) ) {
...
}
Returns a I<boolean> representing if the passed pickup location is valid for the hold.
It throws a I<Koha::Exceptions::_MissingParameter> if the library_id parameter is not
passed.
=cut
sub is_pickup_location_valid {
my ( $self, $params ) = @_;
Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
unless $params->{library_id};
my @pickup_locations;
if ( $self->itemnumber ) { # item-level
@pickup_locations = $self->item->pickup_locations({ patron => $self->patron });
}
else { # biblio-level
@pickup_locations = $self->biblio->pickup_locations({ patron => $self->patron });
}
return any { $_->branchcode eq $params->{library_id} } @pickup_locations;
}
=head3 set_pickup_location
$hold->set_pickup_location(
{
library_id => $library->id,
[ force => 0|1 ]
}
);
Updates the hold pickup location. It throws a I<Koha::Exceptions::Hold::InvalidPickupLocation> if
the passed pickup location is not valid.
Note: It is up to the caller to verify if I<AllowHoldPolicyOverride> is set when setting the
B<force> parameter.
=cut
sub set_pickup_location {
my ( $self, $params ) = @_;
Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
unless $params->{library_id};
if (
$params->{force}
|| $self->is_pickup_location_valid(
{ library_id => $params->{library_id} }
)
)
{
# all good, set the new pickup location
$self->branchcode( $params->{library_id} )->store;
}
else {
Koha::Exceptions::Hold::InvalidPickupLocation->throw;
}
return $self;
}
=head3 set_processing
$hold->set_processing;
Mark the hold as in processing.
=cut
sub set_processing {
my ( $self ) = @_;
$self->priority(0);
$self->found('P');
$self->store();
return $self;
}
=head3 is_found
Returns true if hold is waiting, in transit or in processing
=cut
sub is_found {
my ($self) = @_;
return 0 unless $self->found();
return 1 if $self->found() eq 'W';
return 1 if $self->found() eq 'T';
return 1 if $self->found() eq 'P';
}
=head3 is_waiting
Returns true if hold is a waiting hold
=cut
sub is_waiting {
my ($self) = @_;
my $found = $self->found;
return $found && $found eq 'W';
}
=head3 is_in_transit
Returns true if hold is a in_transit hold
=cut
sub is_in_transit {
my ($self) = @_;
return 0 unless $self->found();
return $self->found() eq 'T';
}
=head3 is_in_processing
Returns true if hold is a in_processing hold
=cut
sub is_in_processing {
my ($self) = @_;
return 0 unless $self->found();
return $self->found() eq 'P';
}
=head3 is_cancelable_from_opac
Returns true if hold is a cancelable hold
Holds may be only canceled if they are not found.
This is used from the OPAC.
=cut
sub is_cancelable_from_opac {
my ($self) = @_;
return 1 unless $self->is_found();
return 0; # if ->is_in_transit or if ->is_waiting or ->is_in_processing
}
=head3 is_at_destination
Returns true if hold is waiting
and the hold's pickup branch matches
the hold item's holding branch
=cut
sub is_at_destination {
my ($self) = @_;
return $self->is_waiting() && ( $self->branchcode() eq $self->item()->holdingbranch() );
}
=head3 biblio
Returns the related Koha::Biblio object for this hold
=cut
sub biblio {
my ($self) = @_;
$self->{_biblio} ||= Koha::Biblios->find( $self->biblionumber() );
return $self->{_biblio};
}
=head3 patron
Returns the related Koha::Patron object for this hold
=cut
sub patron {
my ($self) = @_;
my $patron_rs = $self->_result->patron;
return Koha::Patron->_new_from_dbic($patron_rs);
}
=head3 item
Returns the related Koha::Item object for this Hold
=cut
sub item {
my ($self) = @_;
$self->{_item} ||= Koha::Items->find( $self->itemnumber() );
return $self->{_item};
}
=head3 branch
Returns the related Koha::Library object for this Hold
=cut
sub branch {
my ($self) = @_;
$self->{_branch} ||= Koha::Libraries->find( $self->branchcode() );
return $self->{_branch};
}
=head3 desk
Returns the related Koha::Desk object for this Hold
=cut
sub desk {
my $self = shift;
my $desk_rs = $self->_result->desk;
return unless $desk_rs;
return Koha::Desk->_new_from_dbic($desk_rs);
}
=head3 borrower
Returns the related Koha::Patron object for this Hold
=cut
# FIXME Should be renamed with ->patron
sub borrower {
my ($self) = @_;
$self->{_borrower} ||= Koha::Patrons->find( $self->borrowernumber() );
return $self->{_borrower};
}
=head3 is_suspended
my $bool = $hold->is_suspended();
=cut
sub is_suspended {
my ( $self ) = @_;
return $self->suspend();
}
=head3 cancel
my $cancel_hold = $hold->cancel(
{
[ charge_cancel_fee => 1||0, ]
[ cancellation_reason => $cancellation_reason, ]
}
);
Cancel a hold:
- The hold will be moved to the old_reserves table with a priority=0
- The priority of other holds will be updated
- The patron will be charge (see ExpireReservesMaxPickUpDelayCharge) if the charge_cancel_fee parameter is set
- The canceled hold will have the cancellation reason added to old_reserves.cancellation_reason if one is passed in
- a CANCEL HOLDS log will be done if the pref HoldsLog is on
=cut
sub cancel {
my ( $self, $params ) = @_;
$self->_result->result_source->schema->txn_do(
sub {
$self->cancellationdate( dt_from_string->strftime( '%Y-%m-%d %H:%M:%S' ) );
$self->priority(0);
$self->cancellation_reason( $params->{cancellation_reason} );
$self->store();
if ( $params->{cancellation_reason} ) {
my $letter = C4::Letters::GetPreparedLetter(
module => 'reserves',
letter_code => 'HOLD_CANCELLATION',
message_transport_type => 'email',
branchcode => $self->borrower->branchcode,
lang => $self->borrower->lang,
tables => {
branches => $self->borrower->branchcode,
borrowers => $self->borrowernumber,
items => $self->itemnumber,
biblio => $self->biblionumber,
biblioitems => $self->biblionumber,
reserves => $self->unblessed,
}
);
if ($letter) {
C4::Letters::EnqueueLetter(
{
letter => $letter,
borrowernumber => $self->borrowernumber,
message_transport_type => 'email',
}
);
}
}
$self->_move_to_old;
$self->SUPER::delete(); # Do not add a DELETE log
# now fix the priority on the others....
C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber });
# and, if desired, charge a cancel fee
my $charge = C4::Context->preference("ExpireReservesMaxPickUpDelayCharge");
if ( $charge && $params->{'charge_cancel_fee'} ) {
my $account =
Koha::Account->new( { patron_id => $self->borrowernumber } );
$account->add_debit(
{
amount => $charge,
user_id => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
interface => C4::Context->interface,
library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
type => 'RESERVE_EXPIRED',
item_id => $self->itemnumber
}
);
}
C4::Log::logaction( 'HOLDS', 'CANCEL', $self->reserve_id, Dumper($self->unblessed) )
if C4::Context->preference('HoldsLog');
}
);
return $self;
}
=head3 store
Override base store method to set default
expirationdate for holds.
=cut
sub store {
my ($self) = @_;
if ( !$self->in_storage ) {
if (
C4::Context->preference('DefaultHoldExpirationdate')
and ( not defined $self->expirationdate
or $self->expirationdate eq '' )
)
{
$self->_set_default_expirationdate;
}
}
else {
my %updated_columns = $self->_result->get_dirty_columns;
return $self->SUPER::store unless %updated_columns;
if ( exists $updated_columns{reservedate} ) {
if (
C4::Context->preference('DefaultHoldExpirationdate')
and ( not exists $updated_columns{expirationdate}
or exists $updated_columns{expirationdate}
and $updated_columns{expirationdate} eq '' )
)
{
$self->_set_default_expirationdate;
}
}
}
$self = $self->SUPER::store;
}
sub _set_default_expirationdate {
my $self = shift;
my $period = C4::Context->preference('DefaultHoldExpirationdatePeriod') || 0;
my $timeunit =
C4::Context->preference('DefaultHoldExpirationdateUnitOfTime') || 'days';
$self->expirationdate(
dt_from_string( $self->reservedate )->add( $timeunit => $period ) );
}
=head3 _move_to_old
my $is_moved = $hold->_move_to_old;
Move a hold to the old_reserve table following the same pattern as Koha::Patron->move_to_deleted
=cut
sub _move_to_old {
my ($self) = @_;
my $hold_infos = $self->unblessed;
return Koha::Old::Hold->new( $hold_infos )->store;
}
=head3 to_api_mapping
This method returns the mapping for representing a Koha::Hold object
on the API.
=cut
sub to_api_mapping {
return {
reserve_id => 'hold_id',
borrowernumber => 'patron_id',
reservedate => 'hold_date',
biblionumber => 'biblio_id',
branchcode => 'pickup_library_id',
notificationdate => undef,
reminderdate => undef,
cancellationdate => 'cancellation_date',
reservenotes => 'notes',
found => 'status',
itemnumber => 'item_id',
waitingdate => 'waiting_date',
expirationdate => 'expiration_date',
lowestPriority => 'lowest_priority',
suspend => 'suspended',
suspend_until => 'suspended_until',
itemtype => 'item_type',
item_level_hold => 'item_level',
};
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'Reserve';
}
=head1 AUTHORS
Kyle M Hall <kyle@bywatersolutions.com>
Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Martin Renvoize <martin.renvoize@ptfs-europe.com>
=cut
1;