From 072745fb7f23e3db7889bcc81de7183f9c9a2764 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Tue, 8 Nov 2016 15:45:38 +0200 Subject: [PATCH] Bug 16826: Add Koha::Item::Availabilit(y/ies) for item availability This patch adds classes for calculating and storing item availability information. Usage is simple, e.g. holdability; my $item = Koha::Items->find(123); my $holdability = $item->availabilities->hold; if ($holdability->available) { # available for hold! } else { # $holdability->description holds the reason for unavailability } To test: 1. Run t/db_dependent/Koha/Item/Availability.t --- Koha/Item.pm | 31 ++++ Koha/Item/Availabilities.pm | 253 ++++++++++++++++++++++++++++ Koha/Item/Availability.pm | 282 ++++++++++++++++++++++++++++++++ t/db_dependent/Koha/Item/Availability.t | 250 ++++++++++++++++++++++++++++ 4 files changed, 816 insertions(+) create mode 100644 Koha/Item/Availabilities.pm create mode 100644 Koha/Item/Availability.pm create mode 100644 t/db_dependent/Koha/Item/Availability.t diff --git a/Koha/Item.pm b/Koha/Item.pm index 51a5ac0..ecf9d42 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -25,6 +25,7 @@ use Koha::Database; use C4::Context; use C4::Circulation qw(GetIssuingRule); +use Koha::Item::Availabilities; use Koha::Item::Transfer; use Koha::Patrons; use Koha::Libraries; @@ -41,6 +42,24 @@ Koha::Item - Koha Item object class =cut +=head3 availabilities + +my $availabilities = $item->availabilities; + +# Available for checkout? +$availabilities->issue->available + +Returns Koha::Item::Availabilities object which holds different types of +availabilities for this item. + +=cut + +sub availabilities { + my ($self) = @_; + + return Koha::Item::Availabilities->new($self); +} + =head3 effective_itemtype Returns the itemtype for the item based on whether item level itemtypes are set or not. @@ -53,6 +72,18 @@ sub effective_itemtype { return $self->_result()->effective_itemtype(); } +=head3 hold_queue_length + +=cut + +sub hold_queue_length { + my ( $self ) = @_; + + my $reserves = Koha::Holds->search({ itemnumber => $self->itemnumber }); + return $reserves->count() if $reserves; + return 0; +} + =head3 home_branch =cut diff --git a/Koha/Item/Availabilities.pm b/Koha/Item/Availabilities.pm new file mode 100644 index 0000000..091cab4 --- /dev/null +++ b/Koha/Item/Availabilities.pm @@ -0,0 +1,253 @@ +package Koha::Item::Availabilities; + +# Copyright Koha-Suomi Oy 2016 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use C4::Context; +use C4::Reserves; + +use Koha::Exceptions; +use Koha::Issues; +use Koha::Item::Availability; +use Koha::Items; +use Koha::ItemTypes; + +=head1 NAME + +Koha::Item::Availabilities - Koha Item Availabilities object class + +=head1 SYNOPSIS + + my $availabilities = Koha::Items->find(1337)->availabilities; + + print "Available for checkout!" if $availabilities->checkout->available; + +=head1 DESCRIPTION + +This class holds logic for calculating different types of availabilities. + +=head2 Class Methods + +=cut + +=head3 new + +=cut + +sub new { + my ($class, $params) = @_; + + my $self = {}; + my $item; + + unless(ref($params) eq 'Koha::Item') { + Koha::Exceptions::MissingParameter->throw({ + error => "Missing parameter itemnumber", + parameter => "itemnumber", + }) unless $params->{'itemnumber'}; + + $item = Koha::Items->find($params->{'itemnumber'}); + } else { + $item = $params; + } + + $self->{'item'} = $item; + $self->{'_use_stored_values'} = 0; + + bless($self, $class); +} + +=head3 hold + +Availability for holds. Does not consider patron status. + +=cut + +sub hold { + my ($self) = @_; + + my $item = $self->item; + my $availability = Koha::Item::Availability->new->set_available; + + $availability->set_unavailable("withdrawn") if $item->withdrawn; + $availability->set_unavailable("itemlost") if $item->itemlost; + $availability->set_unavailable("restricted") if $item->restricted; + + if ($item->damaged) { + if (C4::Context->preference('AllowHoldsOnDamagedItems')) { + $availability->add_description("damaged"); + } else { + $availability->set_unavailable("damaged"); + } + } + + my $itemtype; + if (C4::Context->preference('item-level_itypes')) { + $itemtype = Koha::ItemTypes->find($item->itype); + } else { + my $biblioitem = Koha::Biblioitems->find($item->biblioitemnumber ); + $itemtype = Koha::ItemTypes->find($biblioitem->itemtype); + } + + if ($item->notforloan > 0 || $itemtype && $itemtype->notforloan) { + $availability->set_unavailable("notforloan"); + } elsif ($item->notforloan < 0) { + $availability->set_unavailable("ordered"); + } + + $self->{'_hold'} = $availability; + return $availability; +} + +=head3 issue + +Availability for checkouts. Does not consider patron status. + +=cut + +sub issue { + my ($self, $params) = @_; + + my $item = $self->item; + my $availability; + unless ($self->use_stored_values) { + $availability = $self->hold->clone; + } + else { + if ($self->{'_hold'}) { + $availability = $self->{'_hold'}->clone; + } else { + $availability = $self->hold->clone; + } + } + + if (my $issue = Koha::Issues->find({ itemnumber => $item->itemnumber })) { + $availability->set_unavailable("onloan", $issue->date_due); + } + + if (C4::Reserves::CheckReserves($item->itemnumber)) { + $availability->set_unavailable("reserved"); + } + + $self->{'_issue'} = $availability; + return $availability; +} + +=head3 item + +=cut + +sub item { + my ($self) = @_; + + return $self->{'item'}; +} + +=head3 local_use + +Same as checkout availability, but exclude notforloan. + +=cut + +sub local_use { + my ($self) = @_; + + my $availability; + unless ($self->use_stored_values) { + $availability = $self->issue->clone; + } + else { + if ($self->{'_issue'}) { + $availability = $self->{'_issue'}->clone; + } else { + $availability = $self->issue->clone; + } + } + + if (grep(/^notforloan$/, @{$availability->description}) + && @{$availability->description} == 1) { + $availability = $availability->set_available; + } + $availability->del_description("notforloan"); + + $self->{'_local_use'} = $availability; + return $availability; +} + +=head3 onsite_checkout + +Same as local_use availability, but consider OnSiteCheckouts system preference. + +=cut + +sub onsite_checkout { + my ($self) = @_; + + my $availability; + unless ($self->use_stored_values) { + $availability = $self->local_use->clone; + } + else { + if ($self->{'_local_use'}) { + $availability = $self->{'_local_use'}->clone; + } else { + $availability = $self->local_use->clone; + } + } + + if (!C4::Context->preference('OnSiteCheckouts')) { + $availability = Koha::Item::Availability->new + ->set_unavailable("onsite_checkouts_disabled"); + } + + $self->{'_onsite_checkout'} = $availability; + return $availability; +} + +=head3 use_stored_values + +ON: $availabilities->use_stored_values(1); +OFF: $availabilities->use_stored_values(0); + +Performance enhancement. + +Different availability types are dependent on some others. For example issues +are dependent on holds; if holds are unavailable, so are issues. If we want to +gather all types of availabilities in one go, we can store the previous +availability in the object and use it for the next one without re-calculating +the previous availabilities. + +This is the purpose of this switch; when enabled, use stored availability in +the object for availability dependencies. When disabled, always re-calculate +dependencies as well. + +=cut + +sub use_stored_values { + my ($self, $use_stored_values) = @_; + + if (defined $use_stored_values) { + $self->{'_use_stored_values'} = + $use_stored_values ? 1 : 0; + } + + return $self->{'_use_stored_values'}; +} + +1; diff --git a/Koha/Item/Availability.pm b/Koha/Item/Availability.pm new file mode 100644 index 0000000..9464948 --- /dev/null +++ b/Koha/Item/Availability.pm @@ -0,0 +1,282 @@ +package Koha::Item::Availability; + +# Copyright Koha-Suomi Oy 2016 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Storable qw(dclone); + +=head1 NAME + +Koha::Item::Availability - Koha Item Availability object class + +=head1 SYNOPSIS + + my $item = Koha::Items->find(1337); + my $availabilities = $item->availabilities; + # ref($availabilities) eq 'Koha::Item::Availabilities' + # ref($availabilities->issue) eq 'Koha::Item::Availability' + + print "Available for checkout!" if $availabilities->issue->available; + +=head1 DESCRIPTION + +This class holds item availability information. + +See Koha::Item for availability subroutines. + +=head2 Class Methods + +=cut + +=head3 new + +Returns a new Koha::Item::Availability object. + +=cut + +sub new { + my ($class) = @_; + + my $self = { + description => [], + availability_needs_confirmation => undef, + available => undef, + expected_available => undef, + }; + + bless $self, $class; +} + +=head3 add_description + +$availability->add_description("notforloan"); +$availability->add_description("withdrawn); + +# $availability->description == ["notforloan", "withdrawn"] + +Pushes a new description to $availability object. Does not duplicate existing +descriptions. + +Returns updated Koha::Item::Availability object. + +=cut + +sub add_description { + my ($self, $description) = @_; + + return $self unless $description; + + if (ref($description) eq 'ARRAY') { + foreach my $desc (@$description) { + if (grep(/^$desc$/, @{$self->{description}})){ + next; + } + push $self->{description}, $desc; + } + } else { + if (!grep(/^$description$/, @{$self->{description}})){ + push $self->{description}, $description; + } + } + + return $self; +} + +=head3 clone + +$availability_cloned = $availability->clone; +$availability->set_unavailable; + +# $availability_cloned->available != $availability->available + +Clones the Koha::Item::Availability object. + +Returns cloned object. + +=cut + +sub clone { + my ( $self ) = @_; + + return dclone($self); +} + +=head3 del_description + +$availability->add_description(["notforloan", "withdrawn", "itemlost", "restricted"]); +$availability->del_description("withdrawn"); + +# $availability->description == ["notforloan", "itemlost", "restricted"] +$availability->del_description(["withdrawn", "restricted"]); +# $availability->description == ["itemlost"] + +Deletes an availability description(s) if it exists. + +Returns Koha::Item::Availability object. + +=cut + +sub del_description { + my ($self, $description) = @_; + + return $self unless $description; + + my @updated; + if (ref($description) eq 'ARRAY') { + foreach my $desc (@$description) { + @updated = grep(!/^$desc$/, @{$self->description}); + } + } else { + @updated = grep(!/^$description$/, @{$self->description}); + } + $self->description(\@updated); + + return $self; +} + +=head3 has_description + +$availability->add_description(["notforloan", "withdrawn"]); +$availability->has_description("withdrawn"); # 1 +$availability->has_description(["notforloan", "withdrawn"]); # 1 +$availability->has_description("itemlost"); # 0 + +Finds description(s) in availability descriptions. + +Returns 1 if found, 0 otherwise. + +=cut + +sub has_description { + my ($self, $description) = @_; + + return 0 unless $description; + + my @found; + if (ref($description) eq 'ARRAY') { + foreach my $desc (@$description) { + if (!grep(/^$desc$/, @{$self->description})){ + return 0; + } + } + } else { + if (!grep(/^$description$/, @{$self->description})){ + return 0; + } + } + + return 1; +} + +=head3 set_available + +$availability->set_available; + +Sets the Koha::Item::Availability object status to available. + $availability->available == 1 + +Overrides old availability status, but does not override other stored data in +the object. Create a new Koha::Item::Availability object to get a fresh start. +Appends any previously defined availability descriptions with add_description(). + +Returns updated Koha::Item::Availability object. + +=cut + +sub set_available { + my ($self, $description) = @_; + + return $self->_update_availability_status(1, 0, $description); +} + +=head3 set_needs_confirmation + +$availability->set_needs_confirmation("unbelieveable_reason", "2016-07-07"); + +Sets the Koha::Item::Availability object status to unavailable, +but needs confirmation. + $availability->available == 0 + $availability->availability_needs_confirmation == 1 + +Overrides old availability statuses, but does not override other stored data in +the object. Create a new Koha::Item::Availability object to get a fresh start. +Appends any previously defined availability descriptions with add_description(). +Allows you to define expected availability date in C<$expected>. + +Returns updated Koha::Item::Availability object. + +=cut + +sub set_needs_confirmation { + my ($self, $description, $expected) = @_; + + return $self->_update_availability_status(0, 1, $description, $expected); +} + +=head3 set_unavailable + +$availability->set_unavailable("onloan", "2016-07-07"); + +Sets the Koha::Item::Availability object status to unavailable. + $availability->available == 0 + +Overrides old availability status, but does not override other stored data in +the object. Create a new Koha::Item::Availability object to get a fresh start. +Appends any previously defined availability descriptions with add_description(). +Allows you to define expected availability date in C<$expected>. + +Returns updated Koha::Item::Availability object. + +=cut + +sub set_unavailable { + my ($self, $description, $expected) = @_; + + return $self->_update_availability_status(0, 0, $description, $expected); +} + +sub AUTOLOAD { + my ($self, $params) = @_; + + my $method = our $AUTOLOAD; + $method =~ s/.*://; + + # get & set + if (exists $self->{$method}) { + unless (defined $params) { + return $self->{$method}; + } else { + $self->{$method} = $params; + return $self; + } + } +} + +sub _update_availability_status { + my ($self, $available, $needs, $desc, $expected) = @_; + + $self->available($available); + $self->availability_needs_confirmation($needs); + $self->expected_available($expected) if $expected; + $self->add_description($desc); + + return $self; +} + +1; diff --git a/t/db_dependent/Koha/Item/Availability.t b/t/db_dependent/Koha/Item/Availability.t new file mode 100644 index 0000000..992e9d3 --- /dev/null +++ b/t/db_dependent/Koha/Item/Availability.t @@ -0,0 +1,250 @@ +#!/usr/bin/perl + +# Copyright Koha-Suomi Oy 2016 +# +# 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 => 4; +use t::lib::Mocks; +use t::lib::TestBuilder; + +use C4::Biblio; +use C4::Circulation; +use C4::Reserves; + +use Koha::Database; +use Koha::Items; +use Koha::ItemTypes; + +use_ok('Koha::Item::Availabilities'); +use_ok('Koha::Item::Availability'); + +my $schema = Koha::Database->new->schema; +$schema->storage->txn_begin; + +subtest 'Koha::Item::Availability tests' => sub { + plan tests => 14; + + my $availability = Koha::Item::Availability->new->set_available; + + is($availability->available, 1, "Available"); + $availability->set_needs_confirmation; + is($availability->availability_needs_confirmation, 1, "Needs confirmation"); + $availability->set_unavailable; + is($availability->available, 0, "Not available"); + + $availability->add_description("such available"); + $availability->add_description("wow"); + $availability->add_description("wow"); + + ok($availability->has_description("wow"), "Found description 'wow'"); + ok($availability->has_description(["wow", "such available"]), + "Found description 'wow' and 'such available'"); + is($availability->has_description(["wow", "much not found"]), 0, + "Didn't find 'wow' and 'much not found'"); + is($availability->description->[0], "such available", + "Found correct description in correct index 1/4"); + is($availability->description->[1], "wow", + "Found correct description in correct index 2/2"); + + $availability->add_description(["much description", "very doge"]); + is($availability->description->[2], "much description", + "Found correct description in correct index 3/4"); + is($availability->description->[3], "very doge", + "Found correct description in correct index 4/4"); + + $availability->del_description("wow"); + is($availability->description->[1], "much description", + "Found description from correct index after del"); + $availability->del_description(["very doge", "such available"]); + is($availability->description->[0], "much description", + "Found description from correct index after del"); + + my $availability_clone = $availability; + $availability->set_unavailable; + is($availability_clone->available, $availability->{available}, + "Availability_clone points to availability"); + $availability_clone = $availability->clone; + $availability->set_available; + isnt($availability_clone->available, $availability->{available}, + "Availability_clone was cloned and no longer has same availability status"); +}; + +subtest 'Item availability tests' => sub { + plan tests => 14; + + my $builder = t::lib::TestBuilder->new; + my $library = $builder->build({ source => 'Branch' }); + my $itemtype_built = $builder->build({ + source => 'Itemtype', + value => { + notforloan => 0, + } + }); + my $biblioitem_built = $builder->build({ + source => 'Biblioitem', + value => { + itemtype => $itemtype_built->{'itemtype'}, + } + }); + my $item_built = $builder->build({ + source => 'Item', + value => { + holding_branch => $library->{branchcode}, + homebranch => $library->{branchcode}, + biblioitemnumber => $biblioitem_built->{biblioitemnumber}, + itype => $itemtype_built->{itemtype}, + } + }); + + t::lib::Mocks::mock_preference('item-level_itypes', 0); + t::lib::Mocks::mock_preference('OnSiteCheckouts', 1); + t::lib::Mocks::mock_preference('AllowHoldsOnDamagedItems', 0); + + my $itemtype = Koha::ItemTypes->find($itemtype_built->{itemtype}); + my $item = Koha::Items->find($item_built->{itemnumber}); + $item->set({ + notforloan => 0, + damaged => 0, + itemlost => 0, + withdrawn => 0, + onloan => undef, + restricted => 0, + })->store; # set available + + ok($item->can('availabilities'), "Koha::Item->availabilities exists."); + my $availabilities = $item->availabilities; + is(ref($availabilities), 'Koha::Item::Availabilities', '$availabilities is blessed as Koha::Item::Availabilities'); + + my $holdability = $availabilities->hold; + my $issuability = $availabilities->issue; + my $for_local_use = $availabilities->local_use; + my $onsite_issuability = $availabilities->onsite_checkout; + is(ref($holdability), 'Koha::Item::Availability', '1/4 Correct class'); + is(ref($issuability), 'Koha::Item::Availability', '2/4 Correct class'); + is(ref($for_local_use), 'Koha::Item::Availability', '3/4 Correct class'); + is(ref($onsite_issuability), 'Koha::Item::Availability', '4/4 Correct class'); + + ok($holdability->available, 'Available for holds'); + ok($issuability->available, 'Available for checkouts'); + ok($for_local_use->available, 'Available for local use'); + ok($onsite_issuability->available, 'Available for onsite checkout'); + + # Test plan: + # Subtest for each availability type in predefined order; + # hold -> checkout -> local_use -> onsite_checkout + # Each is dependant on the previous one, no need to run same tests as moving + # from left to right. + subtest 'Availability: hold' => sub { + plan tests => 14; + + $item->withdrawn(1)->store; + ok(!$availabilities->hold->available, "Item withdrawn => not available"); + is($availabilities->hold->description->[0], 'withdrawn', 'Description: withdrawn'); + $item->withdrawn(0)->itemlost(1)->store; + ok(!$availabilities->hold->available, "Item lost => not available"); + is($availabilities->hold->description->[0], 'itemlost', 'Description: itemlost'); + $item->itemlost(0)->restricted(1)->store; + ok(!$availabilities->hold->available, "Item restricted => not available"); + is($availabilities->hold->description->[0], 'restricted', 'Description: restricted'); + $item->restricted(0)->store; + + subtest 'Hold on damaged item' => sub { + plan tests => 3; + + t::lib::Mocks::mock_preference('AllowHoldsOnDamagedItems', 0); + $item->damaged(1)->store; + ok($item->damaged, "Item is damaged"); + ok(!$availabilities->hold->available, 'Not available for holds (AllowHoldsOnDamagedItems => 0)'); + t::lib::Mocks::mock_preference('AllowHoldsOnDamagedItems', 1); + ok($availabilities->hold->available, 'Available for holds (AllowHoldsOnDamagedItems => 1)'); + $item->damaged(0)->store; + }; + + t::lib::Mocks::mock_preference('item-level_itypes', 1); + $item->notforloan(1)->store; + ok(!$availabilities->hold->available, "Item notforloan => not available"); + is($availabilities->hold->description->[0], 'notforloan', 'Description: notforloan'); + t::lib::Mocks::mock_preference('item-level_itypes', 0); + $item->notforloan(0)->store; + $itemtype->notforloan(1)->store; + ok(!$availabilities->hold->available, "Itemtype notforloan => not available"); + is($availabilities->hold->description->[0], 'notforloan', 'Description: notforloan'); + $itemtype->notforloan(0)->store; + ok($availabilities->hold->available, "Available"); + $item->notforloan(-1)->store; + ok(!$availabilities->hold->available, "Itemtype notforloan -1 => not available"); + is($availabilities->hold->description->[0], 'ordered', 'Description: ordered'); + $item->notforloan(0)->store; + }; + + subtest 'Availability: Checkout' => sub { + plan tests => 7; + + my $patron = $builder->build({ source => 'Borrower' }); + my $biblio = C4::Biblio::GetBiblio($item->biblionumber); + my $priority= C4::Reserves::CalculatePriority( $item->biblionumber ); + my $reserve_id = C4::Reserves::AddReserve( + $item->holdingbranch, + $patron->{borrowernumber}, + $item->biblionumber, + undef, + $priority, + undef, undef, undef, + $$biblio{title}, + $item->itemnumber, + undef + ); + + ok(!$availabilities->issue->available, "Item reserved => not available"); + is($availabilities->issue->description->[0], 'reserved', 'Description: reserved'); + C4::Reserves::CancelReserve({ reserve_id => $reserve_id }); + ok($availabilities->issue->available, "Reserve cancelled => available"); + + my $module = new Test::MockModule('C4::Context'); + $module->mock( 'userenv', sub { { branch => $patron->{branchcode} } } ); + my $issue = C4::Circulation::AddIssue($patron, $item->barcode, undef, 1); + ok(!$availabilities->issue->available, "Item issued => not available"); + is($availabilities->issue->description->[0], 'onloan', 'Description: onloan'); + is($availabilities->issue->expected_available, + $issue->date_due, "Expected to be available '".$issue->date_due."'"); + C4::Circulation::AddReturn($item->barcode, $item->homebranch); + ok($availabilities->issue->available, "Checkin => available"); + }; + + subtest 'Availability: Local use' => sub { + plan tests => 1; + + $item->notforloan(1)->store; + ok($availabilities->local_use->available, "Item notforloan => available"); + }; + + subtest 'Availability: On-site checkout' => sub { + plan tests => 2; + + t::lib::Mocks::mock_preference('OnSiteCheckouts', 0); + ok(!$availabilities->onsite_checkout->available, 'Not available for onsite checkout ' + .'(OnSiteCheckouts => 0)'); + t::lib::Mocks::mock_preference('OnSiteCheckouts', 1); + ok($availabilities->onsite_checkout->available, 'Available for onsite checkout ' + .'(OnSiteCheckouts => 1)'); + }; +}; + +$schema->storage->txn_rollback; + +1; -- 1.9.1