X-Git-Url: http://koha-dev.rot13.org:8081/gitweb/?a=blobdiff_plain;f=Koha%2FHold.pm;h=378b902638b82d65036a7c1a65a6ea418a54bf79;hb=217da9cf3dd6c10e92397274c61570ef33b50587;hp=700c85572a2ddf019f31a48e9578d724602a049f;hpb=71e235751f609ada9f2fcb78eeda5c3b417bf83f;p=srvgit diff --git a/Koha/Hold.pm b/Koha/Hold.pm index 700c85572a..378b902638 100644 --- a/Koha/Hold.pm +++ b/Koha/Hold.pm @@ -20,20 +20,27 @@ package Koha::Hold; use Modern::Perl; -use Carp; -use Data::Dumper qw(Dumper); +use List::MoreUtils qw( any ); use C4::Context qw(preference); -use C4::Log; +use C4::Letters qw( GetPreparedLetter EnqueueLetter ); +use C4::Log qw( logaction ); +use C4::Reserves; -use Koha::DateUtils qw(dt_from_string output_pref); +use Koha::AuthorisedValues; +use Koha::DateUtils qw( dt_from_string ); use Koha::Patrons; use Koha::Biblios; +use Koha::Hold::CancellationRequests; use Koha::Items; use Koha::Libraries; use Koha::Old::Holds; use Koha::Calendar; +use Koha::Plugins; +use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue; + +use Koha::Exceptions; use Koha::Exceptions::Hold; use base qw(Koha::Object); @@ -44,7 +51,7 @@ Koha::Hold - Koha Hold object class =head1 API -=head2 Class Methods +=head2 Class methods =cut @@ -78,14 +85,14 @@ sub age { =head3 suspend_hold -my $hold = $hold->suspend_hold( $suspend_until_dt ); +my $hold = $hold->suspend_hold( $suspend_until ); =cut sub suspend_hold { - my ( $self, $dt ) = @_; + my ( $self, $date ) = @_; - my $date = $dt ? $dt->clone()->truncate( to => 'day' )->datetime : undef; + $date &&= dt_from_string($date)->truncate( to => 'day' )->datetime; if ( $self->is_found ) { # We can't suspend found holds if ( $self->is_waiting ) { @@ -94,6 +101,9 @@ sub suspend_hold { 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=' @@ -108,9 +118,23 @@ sub suspend_hold { $self->suspend_until($date); $self->store(); - logaction( 'HOLDS', 'SUSPEND', $self->reserve_id, Dumper( $self->unblessed ) ) + Koha::Plugins->call( + 'after_hold_action', + { + action => 'suspend', + payload => { hold => $self->get_from_storage } + } + ); + + logaction( 'HOLDS', 'SUSPEND', $self->reserve_id, $self ) if C4::Context->preference('HoldsLog'); + Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( + { + biblio_ids => [ $self->biblionumber ] + } + ) if C4::Context->preference('RealTimeHoldsQueue'); + return $self; } @@ -128,9 +152,23 @@ sub resume { $self->store(); - logaction( 'HOLDS', 'RESUME', $self->reserve_id, Dumper($self->unblessed) ) + Koha::Plugins->call( + 'after_hold_action', + { + action => 'resume', + payload => { hold => $self->get_from_storage } + } + ); + + logaction( 'HOLDS', 'RESUME', $self->reserve_id, $self ) if C4::Context->preference('HoldsLog'); + Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( + { + biblio_ids => [ $self->biblionumber ] + } + ) if C4::Context->preference('RealTimeHoldsQueue'); + return $self; } @@ -145,70 +183,174 @@ sub delete { my $deleted = $self->SUPER::delete($self); - logaction( 'HOLDS', 'DELETE', $self->reserve_id, Dumper($self->unblessed) ) + logaction( 'HOLDS', 'DELETE', $self->reserve_id, $self ) 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, $transferToDo ) = @_; + my ( $self, $desk_id ) = @_; $self->priority(0); - if ($transferToDo) { - $self->found('T')->store(); - return $self; - } - my $today = dt_from_string(); + my $values = { found => 'W', - waitingdate => $today->ymd, + ( !$self->waitingdate ? ( 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); + my $new_expiration_date = $today->clone->add(days => $max_pickup_delay); if ( C4::Context->preference("ExcludeHolidaysFromMaxPickUpDelay") ) { my $itemtype = $self->item ? $self->item->effective_itemtype : $self->biblio->itemtype; - my $useDaysMode_value = Koha::CirculationRules->get_useDaysMode_effective_value( + 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 => $useDaysMode_value ); + my $calendar = Koha::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode ); - $expirationdate = $calendar->days_forward( dt_from_string(), $max_pickup_delay ); + $new_expiration_date = $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; + if ( $self->patron_expiration_date ) { + my $requested_expiration = dt_from_string( $self->patron_expiration_date ); + + my $cmp = + $requested_expiration + ? DateTime->compare( $requested_expiration, $new_expiration_date ) + : 0; + + $new_expiration_date = + $cmp == -1 ? $requested_expiration : $new_expiration_date; + } + + $values->{expirationdate} = $new_expiration_date->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 representing if the passed pickup location is valid for the hold. +It throws a I 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->as_list; +} + +=head3 set_pickup_location + + $hold->set_pickup_location( + { + library_id => $library->id, + [ force => 0|1 ] + } + ); + +Updates the hold pickup location. It throws a I if +the passed pickup location is not valid. + +Note: It is up to the caller to verify if I is set when setting the +B 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 a waiting or in transit +Returns true if hold is waiting, in transit or in processing =cut @@ -218,6 +360,7 @@ sub is_found { 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 @@ -246,6 +389,19 @@ sub is_in_transit { 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 @@ -260,7 +416,58 @@ sub is_cancelable_from_opac { my ($self) = @_; return 1 unless $self->is_found(); - return 0; # if ->is_in_transit or if ->is_waiting + return 0; # if ->is_in_transit or if ->is_waiting or ->is_in_processing +} + +=head3 cancellation_requestable_from_opac + + if ( $hold->cancellation_requestable_from_opac ) { ... } + +Returns a I representing if a cancellation request can be placed on the hold +from the OPAC. It targets holds that cannot be cancelled from the OPAC (see the +B method above), but for which circulation rules allow +requesting cancellation. + +Throws a B exception with the following I +values: + +=over 4 + +=item B<'hold_not_waiting'>: the hold is expected to be waiting and it is not. + +=item B<'no_item_linked'>: the waiting hold doesn't have an item properly linked. + +=back + +=cut + +sub cancellation_requestable_from_opac { + my ( $self ) = @_; + + Koha::Exceptions::InvalidStatus->throw( invalid_status => 'hold_not_waiting' ) + unless $self->is_waiting; + + my $item = $self->item; + + Koha::Exceptions::InvalidStatus->throw( invalid_status => 'no_item_linked' ) + unless $item; + + my $patron = $self->patron; + + my $controlbranch = $patron->branchcode; + + if ( C4::Context->preference('ReservesControlBranch') eq 'ItemHomeLibrary' ) { + $controlbranch = $item->homebranch; + } + + return Koha::CirculationRules->get_effective_rule_value( + { + categorycode => $patron->categorycode, + itemtype => $item->itype, + branchcode => $controlbranch, + rule_name => 'waiting_hold_cancellation', + } + ) ? 1 : 0; } =head3 is_at_destination @@ -291,6 +498,19 @@ sub biblio { 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 @@ -319,6 +539,19 @@ sub branch { 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 @@ -346,28 +579,119 @@ sub is_suspended { return $self->suspend(); } +=head3 add_cancellation_request + + my $cancellation_request = $hold->add_cancellation_request({ [ creation_date => $creation_date ] }); + +Adds a cancellation request to the hold. Returns the generated +I object. + +=cut + +sub add_cancellation_request { + my ( $self, $params ) = @_; + + my $request = Koha::Hold::CancellationRequest->new( + { hold_id => $self->id, + ( $params->{creation_date} ? ( creation_date => $params->{creation_date} ) : () ), + } + )->store; + + $request->discard_changes; + + return $request; +} + +=head3 cancellation_requests + + my $cancellation_requests = $hold->cancellation_requests; + +Returns related a I resultset. + +=cut + +sub cancellation_requests { + my ($self) = @_; + + return Koha::Hold::CancellationRequests->search( { hold_id => $self->id } ); +} =head3 cancel -my $cancel_hold = $hold->cancel(); +my $cancel_hold = $hold->cancel( + { + [ charge_cancel_fee => 1||0, ] + [ cancellation_reason => $cancellation_reason, ] + [ skip_holds_queue => 1||0 ] + } +); 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 ) = @_; + + my $autofill_next = $params->{autofill} && $self->itemnumber && $self->found && $self->found eq 'W'; + $self->_result->result_source->schema->txn_do( sub { + my $patron = $self->patron; + $self->cancellationdate( dt_from_string->strftime( '%Y-%m-%d %H:%M:%S' ) ); $self->priority(0); - $self->_move_to_old; - $self->SUPER::delete(); # Do not add a DELETE log + $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', + } + ); + } + } + + my $old_me = $self->_move_to_old; + + Koha::Plugins->call( + 'after_hold_action', + { + action => 'cancel', + payload => { hold => $old_me->get_from_storage } + } + ); + + # anonymize if required + $old_me->anonymize + if $patron->privacy == 2; + $self->SUPER::delete(); # Do not add a DELETE log # now fix the priority on the others.... C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber }); @@ -388,13 +712,155 @@ sub cancel { ); } - C4::Log::logaction( 'HOLDS', 'CANCEL', $self->reserve_id, Dumper($self->unblessed) ) + C4::Log::logaction( 'HOLDS', 'CANCEL', $self->reserve_id, $self ) if C4::Context->preference('HoldsLog'); + + Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( + { + biblio_ids => [ $old_me->biblionumber ] + } + ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue'); } ); + + if ($autofill_next) { + my ( undef, $next_hold ) = C4::Reserves::CheckReserves( $self->itemnumber ); + if ($next_hold) { + my $is_transfer = $self->branchcode ne $next_hold->{branchcode}; + + C4::Reserves::ModReserveAffect( $self->itemnumber, $self->borrowernumber, $is_transfer, $next_hold->{reserve_id}, $self->desk_id, $autofill_next ); + C4::Items::ModItemTransfer( $self->itemnumber, $self->branchcode, $next_hold->{branchcode}, "Reserve" ) if $is_transfer; + } + } + return $self; } +=head3 fill + + $hold->fill; + +This method marks the hold as filled. It effectively moves it to old_reserves. + +=cut + +sub fill { + my ( $self ) = @_; + $self->_result->result_source->schema->txn_do( + sub { + my $patron = $self->patron; + + $self->set( + { + found => 'F', + priority => 0, + } + ); + + my $old_me = $self->_move_to_old; + + Koha::Plugins->call( + 'after_hold_action', + { + action => 'fill', + payload => { hold => $old_me->get_from_storage } + } + ); + + # anonymize if required + $old_me->anonymize + if $patron->privacy == 2; + + $self->SUPER::delete(); # Do not add a DELETE log + + # now fix the priority on the others.... + C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber }); + + if ( C4::Context->preference('HoldFeeMode') eq 'any_time_is_collected' ) { + my $fee = $patron->category->reservefee // 0; + if ( $fee > 0 ) { + $patron->account->add_debit( + { + amount => $fee, + description => $self->biblio->title, + user_id => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef, + library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef, + interface => C4::Context->interface, + type => 'RESERVE', + item_id => $self->itemnumber + } + ); + } + } + + C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self ) + if C4::Context->preference('HoldsLog'); + + Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( + { + biblio_ids => [ $old_me->biblionumber ] + } + ) if C4::Context->preference('RealTimeHoldsQueue'); + } + ); + return $self; +} + +=head3 store + +Override base store method to set default +expirationdate for holds. + +=cut + +sub store { + my ($self) = @_; + + Koha::Exceptions::Hold::MissingPickupLocation->throw() unless $self->branchcode; + + if ( !$self->in_storage ) { + if ( ! $self->expirationdate && $self->patron_expiration_date ) { + $self->expirationdate($self->patron_expiration_date); + } + + if ( + C4::Context->preference('DefaultHoldExpirationdate') + && !$self->expirationdate + ) + { + $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') + && ! exists $updated_columns{expirationdate} + ) + { + $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; @@ -431,6 +897,7 @@ sub to_api_mapping { itemnumber => 'item_id', waitingdate => 'waiting_date', expirationdate => 'expiration_date', + patron_expiration_date => undef, lowestPriority => 'lowest_priority', suspend => 'suspended', suspend_until => 'suspended_until', @@ -439,6 +906,26 @@ sub to_api_mapping { }; } +=head3 can_update_pickup_location_opac + + my $can_update_pickup_location_opac = $hold->can_update_pickup_location_opac; + +Returns if a hold can change pickup location from opac + +=cut + +sub can_update_pickup_location_opac { + my ($self) = @_; + + my @statuses = split /,/, C4::Context->preference("OPACAllowUserToChangeBranch"); + foreach my $status ( @statuses ){ + return 1 if ($status eq 'pending' && !$self->is_found && !$self->is_suspended ); + return 1 if ($status eq 'intransit' && $self->is_in_transit); + return 1 if ($status eq 'suspended' && $self->is_suspended); + } + return 0; +} + =head2 Internal methods =head3 _type