From 2643f5a81854d77f0b1b31be4429d9f5c5a517f5 Mon Sep 17 00:00:00 2001 From: Aleisha Amohia Date: Wed, 22 Apr 2020 07:35:47 +0000 Subject: [PATCH] Bug 19532: Recalls objects and tests Getter and setter methods and other object methods for Koha Recalls, plus tests for these --- C4/Letters.pm | 1 + Koha/Recall.pm | 402 +++++++++++++++++++++++++++++++++++++++++++ Koha/Recalls.pm | 53 ++++++ Koha/Schema/Result/Recall.pm | 38 ++++ t/db_dependent/Recalls.t | 137 +++++++++++++++ 5 files changed, 631 insertions(+) create mode 100644 Koha/Recall.pm create mode 100644 Koha/Recalls.pm create mode 100644 t/db_dependent/Recalls.t diff --git a/C4/Letters.pm b/C4/Letters.pm index 1036c461f31..bfb0e7e2686 100644 --- a/C4/Letters.pm +++ b/C4/Letters.pm @@ -757,6 +757,7 @@ sub _parseletter_sth { ($table eq 'subscription') ? "SELECT * FROM $table WHERE subscriptionid = ?" : ($table eq 'serial') ? "SELECT * FROM $table WHERE serialid = ?" : ($table eq 'problem_reports') ? "SELECT * FROM $table WHERE reportid = ?" : + ($table eq 'recalls') ? "SELECT * FROM $table WHERE recall_id = ?" : undef ; unless ($query) { warn "ERROR: No _parseletter_sth query for table '$table'"; diff --git a/Koha/Recall.pm b/Koha/Recall.pm new file mode 100644 index 00000000000..be864603f1b --- /dev/null +++ b/Koha/Recall.pm @@ -0,0 +1,402 @@ +package Koha::Recall; + +# Copyright 2020 Aleisha Amohia +# +# 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::Database; +use Koha::DateUtils; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::Recall - Koha Recall Object class + +=head1 API + +=head2 Internal methods + +=cut + +=head3 biblio + + my $biblio = $recall->biblio; + +Returns the related Koha::Biblio object for this recall. + +=cut + +sub biblio { + my ( $self ) = @_; + my $biblio_rs = $self->_result->biblio; + return Koha::Biblio->_new_from_dbic( $biblio_rs ); +} + +=head3 item + + my $item = $recall->item; + +Returns the related Koha::Item object for this recall. + +=cut + +sub item { + my ( $self ) = @_; + my $item_rs = $self->_result->item; + return Koha::Item->_new_from_dbic( $item_rs ); +} + +=head3 patron + + my $patron = $recall->patron; + +Returns the related Koha::Patron object for this recall. + +=cut + +sub patron { + my ( $self ) = @_; + my $patron_rs = $self->_result->borrower; + return Koha::Patron->_new_from_dbic( $patron_rs ); +} + +=head3 library + + my $library = $recall->library; + +Returns the related Koha::Library object for this recall. + +=cut + +sub library { + my ( $self ) = @_; + my $library_rs = $self->_result->branch; + return Koha::Library->_new_from_dbic( $library_rs ); +} + +=head3 checkout + + my $checkout = $recall->checkout; + +Returns the related Koha::Checkout object for this recall. + +=cut + +sub checkout { + my ( $self ) = @_; + $self->{_checkout} ||= Koha::Checkouts->find({ itemnumber => $self->itemnumber }); + + if ( $self->item_level_recall == 0 ) { + my @itemnumbers = Koha::Items->search({ biblionumber => $self->biblionumber }, { columns => [ 'itemnumber' ] }); + my $checkouts = Koha::Checkouts->search({ itemnumber => [ @itemnumbers ] }, { order_by => { -asc => 'date_due' } }); + $self->{_checkout} = $checkouts->next; + } + + return $self->{_checkout}; +} + +=head3 requested + + if ( $recall->requested ) + + [% IF recall.requested %] + +Return true if recall status is requested. + +=cut + +sub requested { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'R'; +} + +=head3 waiting + + if ( $recall->waiting ) + + [% IF recall.waiting %] + +Return true if recall is awaiting pickup. + +=cut + +sub waiting { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'W'; +} + +=head3 overdue + + if ( $recall->overdue ) + + [% IF recall.overdue %] + +Return true if recall is overdue to be returned. + +=cut + +sub overdue { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'O'; +} + +=head3 in_transit + + if ( $recall->in_transit ) + + [% IF recall.in_transit %] + +Return true if recall is in transit. + +=cut + +sub in_transit { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'T'; +} + +=head3 expired + + if ( $recall->expired ) + + [% IF recall.expired %] + +Return true if recall has expired. + +=cut + +sub expired { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'E'; +} + +=head3 cancelled + + if ( $recall->cancelled ) + + [% IF recall.cancelled %] + +Return true if recall has been cancelled. + +=cut + +sub cancelled { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'C'; +} + +=head3 finished + + if ( $recall->finished ) + + [% IF recall.finished %] + +Return true if recall is finished and has been fulfilled. + +=cut + +sub finished { + my ( $self ) = @_; + my $status = $self->status; + return $status && $status eq 'F'; +} + +=head3 calc_expirationdate + + my $expirationdate = $recall->calc_expirationdate; + $recall->update({ expirationdate => $expirationdate }); + +Calculate the expirationdate to set based on circulation rules and system preferences. + +=cut + +sub calc_expirationdate { + my ( $self ) = @_; + + my $branchcode = C4::Circulation::_GetCircControlBranch( $self->item->unblessed, $self->patron->unblessed ); + my $rule = Koha::CirculationRules->get_effective_rule({ + categorycode => $self->patron->categorycode, + branchcode => $branchcode, + itemtype => $self->item->effective_itemtype, + rule_name => 'recall_shelf_time' + }); + + my $shelf_time = defined $rule ? $rule->rule_value : C4::Context->preference('RecallsMaxPickUpDelay'); + + my $expirationdate = dt_from_string->add( days => $shelf_time ); + return $expirationdate; +} + +=head3 start_transfer + + $recall->start_transfer({ item => $item_object }); + +Set the recall as in transit. + +=cut + +sub start_transfer { + my ( $self, $params ) = @_; + + if ( $self->item_level_recall ) { + # already has an itemnumber + $self->update({ status => 'T' }); + } else { + my $itemnumber = $params->{item}->itemnumber; + $self->update({ status => 'T', itemnumber => $itemnumber }); + } + + my $ignore_reserves = 1; + my ( $dotransfer, $messages ) = C4::Circulation::transferbook( $self->branchcode, $self->item->barcode, $ignore_reserves, 'Recalled' ); + + return $self; +} + +=head3 set_waiting + + $recall->set_waiting({ + expirationdate => $expirationdate, + item => $item_object + }); + +Set the recall as waiting and update expiration date. +Notify the recall requester. + +=cut + +sub set_waiting { + my ( $self, $params ) = @_; + + my $itemnumber; + if ( $self->item_level_recall ) { + $itemnumber = $self->itemnumber; + $self->update({ status => 'W', waitingdate => dt_from_string, expirationdate => $params->{expirationdate} }); + } else { + # biblio-level recall with no itemnumber. need to set itemnumber + $itemnumber = $params->{item}->itemnumber; + $self->update({ status => 'W', waitingdate => dt_from_string, expirationdate => $params->{expirationdate}, itemnumber => $itemnumber }); + } + + # send notice to recaller to pick up item + my $letter = C4::Letters::GetPreparedLetter( + module => 'circulation', + letter_code => 'PICKUP_RECALLED_ITEM', + branchcode => $self->branchcode, + want_librarian => 0, + tables => { + biblio => $self->biblionumber, + borrowers => $self->borrowernumber, + items => $itemnumber, + recalls => $self->recall_id, + }, + ); + + C4::Message->enqueue($letter, $self->patron->unblessed, 'email'); + + return $self; +} + +=head3 revert_waiting + + $recall->revert_waiting; + +Revert recall waiting status. + +=cut + +sub revert_waiting { + my ( $self ) = @_; + $self->update({ status => 'R', waitingdate => undef }); + return $self; +} + +=head3 set_overdue + + $recall->set_overdue; + +Set a recall as overdue when the recall has been requested and the borrower who has checked out the recalled item is late to return it. This will only be used by a cron - a recall cannot be set as overdue manually. + +=cut + +sub set_overdue { + my ( $self ) = @_; + $self->update({ status => 'O' }); + return $self; +} + +=head3 set_expired + + $recall->set_expired; + +Set a recall as expired. This may be done manually or by a cronjob, either when the borrower tha t placed the recall takes more than RecallsMaxPickUpDelay number of days to collect their item, or if the specified expirationdate passes. + +=cut + +sub set_expired { + my ( $self ) = @_; + $self->update({ status => 'E', old => 1, expirationdate => dt_from_string }); + C4::Log::logaction( 'RECALLS', 'EXPIRE', $self->recall_id, "Recall expired", 'COMMANDLINE') if ( C4::Context->preference('RecallsLog') ); + return $self; +} + +=head3 set_cancelled + + $recall->set_cancelled; + +Set a recall as cancelled. This may be done manually, either by the borrower that placed the recall, or by the library. + +=cut + +sub set_cancelled { + my ( $self ) = @_; + $self->update({ status => 'C', old => 1, cancellationdate => dt_from_string }); + C4::Log::logaction( 'RECALLS', 'CANCEL', $self->recall_id, "Recall cancelled", 'INTRANET') if ( C4::Context->preference('RecallsLog') ); + return $self; +} + +=head3 set_finished + + $recall->set_finished; + +Set a recall as finished. This should only be called when the item allocated to a recall is checked out to the borrower who requested the recall. + +=cut + +sub set_finished { + my ( $self ) = @_; + $self->update({ status => 'F', old => 1 }); + C4::Log::logaction( 'RECALLS', 'FULFILL', $self->recall_id, "Recall fulfilled", 'INTRANET') if ( C4::Context->preference('RecallsLog') ); + return $self; +} + +=head3 _type + +=cut + +sub _type { + return 'Recall'; +} + +1; diff --git a/Koha/Recalls.pm b/Koha/Recalls.pm new file mode 100644 index 00000000000..654a72aadaf --- /dev/null +++ b/Koha/Recalls.pm @@ -0,0 +1,53 @@ +package Koha::Recalls; + +# Copyright 2020 Aleisha Amohia +# +# 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::Database; +use Koha::Recall; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Recalls - Koha Recalls Object set class + +=head1 API + +=head2 Internal methods + +=cut + +=head3 _type + +=cut + +sub _type { + return 'Recall'; +} + +=head3 object_class + +=cut + +sub object_class { + return 'Koha::Recall'; +} + +1; diff --git a/Koha/Schema/Result/Recall.pm b/Koha/Schema/Result/Recall.pm index e08cf151af6..ab6d2f13e1d 100644 --- a/Koha/Schema/Result/Recall.pm +++ b/Koha/Schema/Result/Recall.pm @@ -273,4 +273,42 @@ __PACKAGE__->add_columns( '+item_level_recall' => { is_boolean => 1 }, ); +__PACKAGE__->belongs_to( + "biblio", + "Koha::Schema::Result::Biblio", + { biblionumber => "biblionumber" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, +); + +__PACKAGE__->belongs_to( + "item", + "Koha::Schema::Result::Item", + { itemnumber => "itemnumber" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "CASCADE", + }, +); + +__PACKAGE__->belongs_to( + "borrower", + "Koha::Schema::Result::Borrower", + { borrowernumber => "borrowernumber" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, +); + +__PACKAGE__->belongs_to( + "branch", + "Koha::Schema::Result::Branch", + { branchcode => "branchcode" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "CASCADE", + }, +); + 1; diff --git a/t/db_dependent/Recalls.t b/t/db_dependent/Recalls.t new file mode 100644 index 00000000000..de7160b1e02 --- /dev/null +++ b/t/db_dependent/Recalls.t @@ -0,0 +1,137 @@ +#!/usr/bin/perl + +# 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 Test::More tests => 17; +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::DateUtils; + +BEGIN { + require_ok('Koha::Recall'); + require_ok('Koha::Recalls'); +} + +# Start transaction + +my $database = Koha::Database->new(); +my $schema = $database->schema(); +$schema->storage->txn_begin(); +my $dbh = C4::Context->dbh; + +my $builder = t::lib::TestBuilder->new; + +# Setup test variables + +my $item1 = $builder->build_sample_item(); +my $biblio1 = $item1->biblio; +my $branch1 = $item1->holdingbranch; +my $itemtype1 = $item1->effective_itemtype; + +my $item2 = $builder->build_sample_item(); +my $biblio2 = $item2->biblio; +my $branch2 = $item2->holdingbranch; +my $itemtype2 = $item2->effective_itemtype; + +my $category1 = $builder->build({ source => 'Category' })->{ categorycode }; +my $patron1 = $builder->build_object({ class => 'Koha::Patrons', value => { categorycode => $category1, branchcode => $branch1 } }); +my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => { categorycode => $category1, branchcode => $branch1 } }); +t::lib::Mocks::mock_userenv({ patron => $patron1 }); + +C4::Circulation::AddIssue( $patron2->unblessed, $item1->barcode ); + +my $recall1 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => dt_from_string, + biblionumber => $biblio1->biblionumber, + branchcode => $branch1, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 +})->store; + +is( $recall1->biblio->title, $biblio1->title, "Recall biblio relationship correctly linked" ); +is( $recall1->item->homebranch, $item1->homebranch, "Recall item relationship correctly linked" ); +is( $recall1->patron->categorycode, $category1, "Recall patron relationship correctly linked" ); +is( $recall1->library->branchname, Koha::Libraries->find( $branch1 )->branchname, "Recall library relationship correctly linked" ); +is( $recall1->checkout->itemnumber, $item1->itemnumber, "Recall checkout relationship correctly linked" ); +is( $recall1->requested, 1, "Recall has been requested" ); + +$recall1->set_overdue; +is( $recall1->overdue, 1, "Recall is overdue" ); + +$recall1->set_cancelled; +is( $recall1->cancelled, 1, "Recall is cancelled" ); + +my $recall2 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => dt_from_string, + biblionumber => $biblio1->biblionumber, + branchcode => $branch1, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 +})->store; + +t::lib::Mocks->mock_preference( 'RecallsMaxPickUpDelay', 7 ); +my $expected_expirationdate = dt_from_string->add({ days => 7 }); +my $expirationdate = $recall2->calc_expirationdate; +is( $expirationdate, $expected_expirationdate, "Expiration date calculated based on system preference as no circulation rules are set" ); + +Koha::CirculationRules->set_rule({ + branchcode => undef, + categorycode => undef, + itemtype => undef, + rule_name => 'recall_shelf_time', + rule_value => '3', +}); +$expected_expirationdate = dt_from_string->add({ days => 3 }); +$expirationdate = $recall2->calc_expirationdate; +is( $expirationdate, $expected_expirationdate, "Expiration date calculated based on circulation rules" ); + +$recall2->set_waiting({ expirationdate => $expirationdate }); +is( $recall2->waiting, 1, "Recall is waiting" ); + +my $notice = C4::Message->find_last_message( $patron1->unblessed, 'PICKUP_RECALLED_ITEM', 'email' ); +ok( defined $notice, "Patron was notified to pick up waiting recall" ); + +$recall2->set_expired; +is( $recall2->expired, 1, "Recall has expired" ); + +my $old_recalls_count = Koha::Recalls->search({ old => 1 })->count; +is( $old_recalls_count, 2, "Recalls have been flagged as old when cancelled or expired" ); + +my $recall3 = Koha::Recall->new({ + borrowernumber => $patron1->borrowernumber, + recalldate => dt_from_string, + biblionumber => $biblio1->biblionumber, + branchcode => $branch1, + status => 'R', + itemnumber => $item1->itemnumber, + expirationdate => undef, + item_level_recall => 1 +})->store; + +# for testing purposes, pretend the item gets checked out +$recall3->set_finished; +is( $recall3->finished, 1, "Recall has been fulfilled" ); + +$schema->storage->txn_rollback(); -- 2.11.0