From 0877061e080ebc8b3d29434f49d12886620a0fb1 Mon Sep 17 00:00:00 2001 From: Aleisha Amohia Date: Wed, 22 Apr 2020 20:06:54 +0000 Subject: [PATCH] Bug 19532: Other objects used in recalls feature * biblio->recalls * biblio->can_be_recalled * item->recall * item->can_be_recalled * item->can_set_waiting_recall * patron->recalls * tests --- Koha/Biblio.pm | 102 ++++++++++++++++++++++++++++++ Koha/Item.pm | 145 ++++++++++++++++++++++++++++++++++++++++++ Koha/Patron.pm | 21 +++++++ t/db_dependent/Koha/Biblio.t | 118 +++++++++++++++++++++++++++++++++- t/db_dependent/Koha/Item.t | 147 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 531 insertions(+), 2 deletions(-) diff --git a/Koha/Biblio.pm b/Koha/Biblio.pm index 4fe30113e3b..1b6dd217351 100644 --- a/Koha/Biblio.pm +++ b/Koha/Biblio.pm @@ -815,6 +815,108 @@ sub to_api_mapping { }; } +=head3 recalls + + my $recalls = $biblio->recalls; + +Return all active recalls attached to this biblio + +=cut + +sub recalls { + my ( $self ) = @_; + $self->{_recalls} ||= Koha::Recalls->search({ biblionumber => $self->biblionumber, old => undef }); + return $self->{_recalls}; +} + +=head3 can_be_recalled + + my @items_for_recall = $biblio->can_be_recalled({ patron => $patron_object }); + +Does biblio-level checks and returns the items attached to this biblio that are available for recall + +=cut + +sub can_be_recalled { + my ( $self, $params ) = @_; + + return 0 if !( C4::Context->preference('UseRecalls') ); + + my $patron = $params->{patron}; + + my $branchcode = C4::Context->userenv->{'branch'}; + if ( C4::Context->preference('CircControl') eq 'PatronLibrary' and $patron ) { + $branchcode = $patron->branchcode; + } + + # items are unavailable for recall if they are lost, withdrawn or notforloan + my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 }); + + # if there are no available items at all, no recall can be placed + return 0 if ( scalar @items == 0 ); + + my @itemtypes; + my @itemnumbers; + foreach my $item ( @items ) { + push( @itemtypes, $item->effective_itemtype ); + push( @itemnumbers, $item->itemnumber ); + } + + # Check the circulation rule for each relevant itemtype for this biblio + my ( @recalls_allowed, @recalls_per_record, @on_shelf_recalls ); + foreach my $itemtype ( @itemtypes ) { + my $rule = Koha::CirculationRules->get_effective_rules({ + branchcode => $branchcode, + categorycode => $patron ? $patron->categorycode : undef, + itemtype => $itemtype, + rules => [ + 'recalls_allowed', + 'recalls_per_record', + 'on_shelf_recalls', + ], + }); + push( @recalls_allowed, $rule->{recalls_allowed} ) if $rule; + push( @recalls_per_record, $rule->{recalls_per_record} ) if $rule; + push( @on_shelf_recalls, $rule->{on_shelf_recalls} ) if $rule; + } + my $recalls_allowed = (sort {$b <=> $a} @recalls_allowed)[0]; # take highest + my $recalls_per_record = (sort {$b <=> $a} @recalls_per_record)[0]; # take highest + my %on_shelf_recalls_count = (); + foreach my $count ( @on_shelf_recalls ) { + $on_shelf_recalls_count{$count}++; + } + my $on_shelf_recalls = (sort {$on_shelf_recalls_count{$b} <=> $on_shelf_recalls_count{$a}} @on_shelf_recalls)[0]; # take most common + + # check recalls allowed has been set and is not zero + return 0 if ( !defined($recalls_allowed) || $recalls_allowed == 0 ); + + if ( $patron ) { + # check borrower has not reached open recalls allowed limit + return 0 if ( $patron->recalls->count >= $recalls_allowed ); + + # check borrower has not reached open recalls allowed per record limit + return 0 if ( $patron->recalls({ biblionumber => $self->biblionumber })->count >= $recalls_per_record ); + + # check if any of the items under this biblio are already checked out by this borrower + return 0 if ( Koha::Checkouts->search({ itemnumber => [ @itemnumbers ], borrowernumber => $patron->borrowernumber })->count > 0 ); + } + + # check item availability + my $checked_out_count = 0; + foreach (@items) { + if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; } + } + + # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout + return 0 if ( $on_shelf_recalls eq 'all' && $checked_out_count < scalar @items ); + + # can't recall if no items have been checked out + return 0 if ( $checked_out_count == 0 ); + + # can recall + return @items; +} + =head2 Internal methods =head3 type diff --git a/Koha/Item.pm b/Koha/Item.pm index 9e1bca4ef5f..026b96874f4 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -842,6 +842,151 @@ sub _after_item_action_hooks { } } +=head3 recall + + my $recall = $item->recall; + +Return the relevant recall for this item + +=cut + +sub recall { + my ( $self ) = @_; + my @recalls = Koha::Recalls->search({ biblionumber => $self->biblionumber, old => undef }, { order_by => { -asc => 'recalldate' } }); + foreach my $recall (@recalls) { + if ( $recall->item_level_recall and $recall->itemnumber == $self->itemnumber ){ + return $recall; + } + } + # no item-level recall to return, so return earliest biblio-level + # FIXME: eventually this will be based on priority + return $recalls[0]; +} + +=head3 can_be_recalled + + if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall + +Does item-level checks and returns if items can be recalled by this borrower + +=cut + +sub can_be_recalled { + my ( $self, $params ) = @_; + + return 0 if !( C4::Context->preference('UseRecalls') ); + + # check if this item is not for loan, withdrawn or lost + return 0 if ( $self->notforloan != 0 ); + return 0 if ( $self->itemlost != 0 ); + return 0 if ( $self->withdrawn != 0 ); + + # check if this item is not checked out - if not checked out, can't be recalled + return 0 if ( !defined( $self->checkout ) ); + + my $patron = $params->{patron}; + + my $branchcode = C4::Context->userenv->{'branch'}; + if ( $patron ) { + $branchcode = C4::Circulation::_GetCircControlBranch( $self->unblessed, $patron->unblessed ); + } + + # Check the circulation rule for each relevant itemtype for this item + my $rule = Koha::CirculationRules->get_effective_rules({ + branchcode => $branchcode, + categorycode => $patron ? $patron->categorycode : undef, + itemtype => $self->effective_itemtype, + rules => [ + 'recalls_allowed', + 'recalls_per_record', + 'on_shelf_recalls', + ], + }); + + # check recalls allowed has been set and is not zero + return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 ); + + if ( $patron ) { + # check borrower has not reached open recalls allowed limit + return 0 if ( $patron->recalls->count >= $rule->{recalls_allowed} ); + + # check borrower has not reach open recalls allowed per record limit + return 0 if ( $patron->recalls({ biblionumber => $self->biblionumber })->count >= $rule->{recalls_per_record} ); + + # check if this patron has already recalled this item + return 0 if ( Koha::Recalls->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber, old => undef })->count > 0 ); + + # check if this patron has already checked out this item + return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 ); + + # check if this patron has already reserved this item + return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 ); + } + + # check item availability + # items are unavailable for recall if they are lost, withdrawn or notforloan + my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 }); + + # if there are no available items at all, no recall can be placed + return 0 if ( scalar @items == 0 ); + + my $checked_out_count = 0; + foreach (@items) { + if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; } + } + + # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout + return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items ); + + # can't recall if no items have been checked out + return 0 if ( $checked_out_count == 0 ); + + # can recall + return 1; +} + +=head3 can_be_waiting_recall + + if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall + +Checks item type and branch of circ rules to return whether this item can be used to fill a recall. +At this point the item has already been recalled. We are now at the checkin and set waiting stage. + +=cut + +sub can_be_waiting_recall { + my ( $self ) = @_; + + return 0 if !( C4::Context->preference('UseRecalls') ); + + # check if this item is not for loan, withdrawn or lost + return 0 if ( $self->notforloan != 0 ); + return 0 if ( $self->itemlost != 0 ); + return 0 if ( $self->withdrawn != 0 ); + + my $branchcode = $self->holdingbranch; + if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) { + $branchcode = C4::Context->userenv->{'branch'}; + } else { + $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch; + } + + # Check the circulation rule for each relevant itemtype for this item + my $rule = Koha::CirculationRules->get_effective_rules({ + branchcode => $branchcode, + categorycode => undef, + itemtype => $self->effective_itemtype, + rules => [ + 'recalls_allowed', + ], + }); + + # check recalls allowed has been set and is not zero + return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 ); + + # can recall + return 1; +} =head3 _type =cut diff --git a/Koha/Patron.pm b/Koha/Patron.pm index 5436ccc4485..5abd55485e2 100644 --- a/Koha/Patron.pm +++ b/Koha/Patron.pm @@ -1712,6 +1712,27 @@ sub to_api_mapping { }; } +=head3 recalls + + my $recalls = $patron->recalls; + + my $recalls = $patron->recalls({ biblionumber => $biblionumber }); + +Return the patron's active recalls - total, or on a specific biblio + +=cut + +sub recalls { + my ( $self, $params ) = @_; + + my $biblionumber = $params->{biblionumber}; + + if ( $biblionumber ) { + return Koha::Recalls->search({ borrowernumber => $self->borrowernumber, old => undef, biblionumber => $biblionumber }); + } + return Koha::Recalls->search({ borrowernumber => $self->borrowernumber, old => undef }); +} + =head2 Internal methods =head3 _type diff --git a/t/db_dependent/Koha/Biblio.t b/t/db_dependent/Koha/Biblio.t index 790b623aade..b0a82b2a5cc 100644 --- a/t/db_dependent/Koha/Biblio.t +++ b/t/db_dependent/Koha/Biblio.t @@ -17,7 +17,7 @@ use Modern::Perl; -use Test::More tests => 12; +use Test::More tests => 13; use C4::Biblio; use Koha::Database; @@ -569,3 +569,119 @@ subtest 'subscriptions() tests' => sub { $schema->storage->txn_rollback; }; + +subtest 'Recalls tests' => sub { + + plan tests => 12; + + $schema->storage->txn_begin; + my $item1 = $builder->build_sample_item; + my $biblio = $item1->biblio; + my $branchcode = $item1->holdingbranch; + my $patron1 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $patron3 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $item2 = $builder->build_object({ class => 'Koha::Items', value => { holdingbranch => $branchcode, homebranch => $branchcode, biblionumber => $biblio->biblionumber, itype => $item1->effective_itemtype } }); + t::lib::Mocks::mock_userenv({ patron => $patron1 }); + + my $recall1 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 + })->store; + my $recall2 = Koha::Recall->new({ + borrowernumber => $patron2->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => undef, + expirationdate => undef, + item_level_recall => 0 + })->store; + my $recall3 = Koha::Recall->new({ + borrowernumber => $patron3->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 + })->store; + + my $recalls_count = $biblio->recalls->count; + is( $recalls_count, 3, 'Correctly get number of active recalls for biblio' ); + + $recall1->set_cancelled; + $recall2->set_expired({ location => 'COMMANDLINE' }); + + $recalls_count = $biblio->recalls->count; + is( $recalls_count, 1, 'Correctly get number of active recalls for biblio' ); + + t::lib::Mocks::mock_preference('UseRecalls', 0); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall with UseRecalls disabled" ); + + t::lib::Mocks::mock_preference("UseRecalls", 1); + $item1->update({ notforloan => 1 }); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall with no available items" ); + + $item1->update({ notforloan => 0 }); + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 0, + recalls_per_record => 1, + on_shelf_recalls => 'all', + }, + }); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if recalls_allowed = 0" ); + + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 1, + recalls_per_record => 1, + on_shelf_recalls => 'all', + }, + }); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_allowed" ); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_per_record" ); + + $recall1->set_cancelled; + C4::Circulation::AddIssue( $patron1->unblessed, $item2->barcode ); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has already checked out an item attached to this biblio" ); + + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if on_shelf_recalls = all and items are still available" ); + + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 1, + recalls_per_record => 1, + on_shelf_recalls => 'any', + }, + }); + C4::Circulation::AddReturn( $item2->barcode, $branchcode ); + is( $biblio->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if no items are checked out" ); + + $recall2->set_cancelled; + C4::Circulation::AddIssue( $patron2->unblessed, $item2->barcode ); + is( $biblio->can_be_recalled({ patron => $patron1 }), 2, "Can recall two items" ); + + $item1->update({ withdrawn => 1 }); + is( $biblio->can_be_recalled({ patron => $patron1 }), 1, "Can recall one item" ); + + $schema->storage->txn_rollback; +}; diff --git a/t/db_dependent/Koha/Item.t b/t/db_dependent/Koha/Item.t index 55709643b78..6461364b4a2 100644 --- a/t/db_dependent/Koha/Item.t +++ b/t/db_dependent/Koha/Item.t @@ -19,7 +19,7 @@ use Modern::Perl; -use Test::More tests => 6; +use Test::More tests => 7; use C4::Biblio; use C4::Circulation; @@ -493,3 +493,148 @@ subtest 'renewal_branchcode' => sub { is( $item->renewal_branchcode({branch=>'MANATEE'}), $item->homebranch, "If interface opac and OpacRenewalBranch set to itemhomebranch, we get homebranch of item even if branch passed"); }; + +subtest 'Recalls tests' => sub { + + plan tests => 17; + + $schema->storage->txn_begin; + + my $item1 = $builder->build_sample_item; + my $biblio = $item1->biblio; + my $branchcode = $item1->holdingbranch; + my $patron1 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $patron3 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } }); + my $item2 = $builder->build_object({ class => 'Koha::Items', value => { holdingbranch => $branchcode, homebranch => $branchcode, biblionumber => $biblio->biblionumber, itype => $item1->effective_itemtype } }); + t::lib::Mocks::mock_userenv({ patron => $patron1 }); + + my $recall1 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 + })->store; + my $recall2 = Koha::Recall->new({ + borrowernumber => $patron2->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall =>1 + })->store; + + is( $item1->recall->borrowernumber, $patron1->borrowernumber, 'Correctly returns most relevant recall' ); + + $recall2->set_cancelled; + + t::lib::Mocks::mock_preference('UseRecalls', 0); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall with UseRecalls disabled" ); + + t::lib::Mocks::mock_preference("UseRecalls", 1); + + $item1->update({ notforloan => 1 }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is not for loan" ); + $item1->update({ notforloan => 0, itemlost => 1 }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is marked lost" ); + $item1->update({ itemlost => 0, withdrawn => 1 }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is withdrawn" ); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall item if not checked out" ); + + $item1->update({ withdrawn => 0 }); + C4::Circulation::AddIssue( $patron2->unblessed, $item1->barcode ); + + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 0, + recalls_per_record => 1, + on_shelf_recalls => 'all', + }, + }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if recalls_allowed = 0" ); + + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 1, + recalls_per_record => 1, + on_shelf_recalls => 'all', + }, + }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_allowed" ); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_per_record" ); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has already recalled this item" ); + + my $reserve_id = C4::Reserves::AddReserve({ branchcode => $branchcode, borrowernumber => $patron1->borrowernumber, biblionumber => $item1->biblionumber, itemnumber => $item1->itemnumber }); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall item if patron has already reserved it" ); + C4::Reserves::ModReserve({ rank => 'del', reserve_id => $reserve_id, branchcode => $branchcode, itemnumber => $item1->itemnumber, borrowernumber => $patron1->borrowernumber, biblionumber => $item1->biblionumber }); + + $recall1->set_cancelled; + is( $item1->can_be_recalled({ patron => $patron2 }), 0, "Can't recall if patron has already checked out an item attached to this biblio" ); + + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if on_shelf_recalls = all and items are still available" ); + + Koha::CirculationRules->set_rules({ + branchcode => $branchcode, + categorycode => $patron1->categorycode, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 1, + recalls_per_record => 1, + on_shelf_recalls => 'any', + }, + }); + C4::Circulation::AddReturn( $item1->barcode, $branchcode ); + is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if no items are checked out" ); + + C4::Circulation::AddIssue( $patron2->unblessed, $item1->barcode ); + is( $item1->can_be_recalled({ patron => $patron1 }), 1, "Can recall item" ); + + $recall1 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => Koha::DateUtils::dt_from_string, + biblionumber => $biblio->biblionumber, + branchcode => $branchcode, + status => 'R', + itemnumber => undef, + expirationdate => undef, + item_level_recall => 0 + })->store; + + # Patron2 has Item1 checked out. Patron1 has placed a biblio-level recall on Biblio1, so check if Item1 can fulfill Patron1's recall. + + Koha::CirculationRules->set_rules({ + branchcode => undef, + categorycode => undef, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 0, + recalls_per_record => 1, + on_shelf_recalls => 'any', + }, + }); + is( $item1->can_be_waiting_recall, 0, "Recalls not allowed for this itemtype" ); + + Koha::CirculationRules->set_rules({ + branchcode => undef, + categorycode => undef, + itemtype => $item1->effective_itemtype, + rules => { + recalls_allowed => 1, + recalls_per_record => 1, + on_shelf_recalls => 'any', + }, + }); + is( $item1->can_be_waiting_recall, 1, "Recalls are allowed for this itemtype" ); +}; -- 2.11.0