From ef8897ec7fbd6978ef3b34ef435d51907a09c942 Mon Sep 17 00:00:00 2001
From: Lari Taskula <lari.taskula@jns.fi>
Date: Mon, 5 Dec 2016 16:01:54 +0200
Subject: [PATCH] Bug 17712: Biblio availability for hold

Usage:

$holdability = Koha::Availability::Hold->biblio({
  biblio => $biblio,
  patron => $patron,
  to_branch => "CPL",
})->in_opac;
if ($holdability->available) {
  # yup!
  # arrayref of Koha::Item::Availability::Hold -objects:
  # 1. all available items (items may contain confirmations / notes)
  my $item_availabilities = $holdability->item_availabilities;
  # 2. all unavailable items
  my $item_unavailabilities = $holdability->item_unavailabilites;
}

Returns a Koha::Biblio::Availability::Hold -object.

Signed-off-by: Benjamin Rokseth <benjamin.rokseth@kul.oslo.kommune.no>
---
 Koha/Availability/Hold.pm                      |   7 +
 Koha/Biblio/Availability/Hold.pm               | 251 +++++++++++++++++++++++++
 t/db_dependent/Koha/Biblio/Availability/Hold.t | 233 +++++++++++++++++++++++
 3 files changed, 491 insertions(+)
 create mode 100644 Koha/Biblio/Availability/Hold.pm
 create mode 100644 t/db_dependent/Koha/Biblio/Availability/Hold.t

diff --git a/Koha/Availability/Hold.pm b/Koha/Availability/Hold.pm
index f2d7cb9..9b7dddb 100644
--- a/Koha/Availability/Hold.pm
+++ b/Koha/Availability/Hold.pm
@@ -19,6 +19,7 @@ package Koha::Availability::Hold;
 
 use Modern::Perl;
 
+use Koha::Biblio::Availability::Hold;
 use Koha::Item::Availability::Hold;
 
 use Koha::Exceptions;
@@ -29,6 +30,12 @@ sub new {
     bless $params, $class;
 }
 
+sub biblio {
+    my ($self, $params) = @_;
+
+    return Koha::Biblio::Availability::Hold->new($params);
+}
+
 sub item {
     my ($self, $params) = @_;
 
diff --git a/Koha/Biblio/Availability/Hold.pm b/Koha/Biblio/Availability/Hold.pm
new file mode 100644
index 0000000..3263444
--- /dev/null
+++ b/Koha/Biblio/Availability/Hold.pm
@@ -0,0 +1,251 @@
+package Koha::Biblio::Availability::Hold;
+
+# 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 <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use base qw(Koha::Biblio::Availability);
+
+use Koha::Item::Availability::Hold;
+
+=head1 NAME
+
+Koha::Biblio::Availability::Hold - Koha Biblio Availability Hold object class
+
+=head1 SYNOPSIS
+
+my $holdability = Koha::Biblio::Availability::Hold->new({
+    biblio => $biblio,      # which biblio this availability is for
+    patron => $patron,      # check biblio availability for this patron
+})
+
+=head1 DESCRIPTION
+
+Class for checking biblio hold availability.
+
+This class contains different levels of "recipes" that determine whether or not
+a biblio should be considered available.
+
+=head2 Class Methods
+
+=cut
+
+=head3 new
+
+Constructs an biblio hold availability object. Biblio is always required.
+
+MANDATORY PARAMETERS
+
+    biblio (or biblionumber)
+
+Biblio is a Koha::Biblio -object.
+
+OPTIONAL PARAMETERS
+
+    patron (or borrowernumber)
+    to_branch
+    limit (check only first n available items)
+
+Patron is a Koha::Patron -object. To_branch is a branchcode of pickup location.
+
+Returns a Koha::Biblio::Availability::Hold -object.
+
+=cut
+
+sub new {
+    my ($class, $params) = @_;
+
+    my $self = $class->SUPER::new($params);
+
+    # Additionally, consider any transfer limits to pickup library by
+    # providing to_branch parameter with branchcode of pickup library
+    $self->{'to_branch'} = $params->{'to_branch'};
+    # Check only first n available items by providing the value of n
+    # in parameter 'limit'.
+    $self->{'limit'} = $params->{'limit'};
+
+    return $self;
+}
+
+sub in_intranet {
+    my ($self, $params) = @_;
+
+    $self->reset;
+
+    my $patron;
+    unless ($patron = $self->patron) {
+        Koha::Exceptions::MissingParameter->throw(
+            error => 'Missing parameter patron. This level of availability query '
+            .'requires Koha::Biblio::Availability::Hold to have a patron parameter.'
+        );
+    }
+
+    $params->{'intranet'} = 1;
+    # Item looper
+    $self->_item_looper($params);
+
+    return $self;
+}
+
+sub in_opac {
+    my ($self, $params) = @_;
+
+    $self->reset;
+
+    my $patron;
+    unless ($patron = $self->patron) {
+        Koha::Exceptions::MissingParameter->throw(
+            error => 'Missing parameter patron. This level of availability query '
+            .'requires Koha::Biblio::Availability::Hold to have a patron parameter.'
+        );
+    }
+
+    # Check if holds are allowed in OPAC
+    if (!C4::Context->preference('RequestOnOpac')) {
+        $self->unavailable(Koha::Exceptions::Hold::NotAllowedInOPAC->new);
+        return $self;
+    } else {
+        # Item looper
+        $self->_item_looper($params);
+    }
+
+    return $self;
+}
+
+sub _item_looper {
+    my ($self, $params) = @_;
+
+    my $patron = $self->patron;
+    my $biblio = $self->biblio;
+    my $biblioitem = $self->biblio->biblioitem;
+    my @items = $self->biblio->items;
+    my @hostitemnumbers = C4::Items::get_hostitemnumbers_of($biblio->biblionumber);
+    if (@hostitemnumbers) {
+        my @hostitems = Koha::Items->search({
+            itemnumber => { 'in' => @hostitemnumbers }
+        });
+        push @items, @hostitems;
+    }
+
+    if (scalar(@items) == 0) {
+        $self->unavailable(Koha::Exceptions::Biblio::NoAvailableItems->new);
+        return;
+    }
+
+    # Since we only need to check some patron and biblio related availability
+    # checks once, do it outside the actual loop. To avoid code duplication,
+    # use same checks as item availability calculation is using. Use the first
+    # item to check these.
+    my $first_item = $items[0];
+    my $first_item_avail = Koha::Item::Availability::Hold->new({
+        item => $first_item,
+        patron => $patron,
+    });
+    $first_item_avail->common_biblio_checks($biblio);
+    $first_item_avail->common_biblioitem_checks($biblioitem);
+    $first_item_avail->common_patron_checks;
+    if (keys %{$first_item_avail->unavailabilities} > 0) {
+        $self->available(0);
+    }
+    $self->unavailabilities($first_item_avail->unavailabilities);
+    $self->confirmations($first_item_avail->confirmations);
+    $self->notes($first_item_avail->notes);
+
+    # If we are looping in intranet, we can override any unavailabilities
+    my $intranet = $params->{'intranet'} ? 1:0;
+    my $override = 0;
+    if ($intranet && C4::Context->preference('AllowHoldPolicyOverride')) {
+        $override = 1;
+    }
+
+    # Stop calculating item availabilities after $limit available items are found.
+    # E.g. parameter 'limit' with value 1 will find only one available item and
+    # return biblio as available if no other unavailabilities are found. If you
+    # want to calculate availability of every item in this biblio, do not give this
+    # parameter.
+    my $limit = $self->limit ? $self->limit : $params->{'limit'};
+    my $count = 0;
+
+    my @holds = Koha::Holds->search({ biblionumber => $biblio->biblionumber })->as_list;
+    my @nonfound_holds = Koha::Holds->search({ biblionumber => $biblio->biblionumber, found => undef })->as_list;
+    foreach my $item (@items) {
+        # Break out of loop after $limit items are found available
+        if (defined $limit && @{$self->{'item_availabilities'}} >= $limit) {
+            last;
+        }
+        my $item_availability = $self->_item_check($item, $patron, \@holds, \@nonfound_holds, $intranet, $override);
+        if ($item_availability->available) {
+            push @{$self->{'item_availabilities'}}, $item_availability;
+            $count++;
+        } else {
+            my $unavails = $item_availability->unavailabilities;
+            # If Item level hold is not allowed and it is the only unavailability
+            # reason, push the item to item_availabilities.
+            if ($item_availability->unavailable == 1 && exists
+                $unavails->{'Koha::Exceptions::Hold::ItemLevelHoldNotAllowed'}){
+                push @{$self->{'item_availabilities'}}, $item_availability;
+            } else {
+                push @{$self->{'item_unavailabilities'}}, $item_availability;
+            }
+        }
+    }
+
+    # After going through items, if none are found available, set the biblio
+    # unavailable
+    if (@{$self->{'item_availabilities'}} == 0) {
+        $self->unavailable(Koha::Exceptions::Biblio::NoAvailableItems->new);
+    }
+
+    return $self;
+}
+
+sub _item_check {
+    my ($self, $item, $patron, $holds, $nonfound_holds, $intranet, $override) = @_;
+
+    my $item_availability = Koha::Item::Availability::Hold->new({
+        item => $item,
+        patron => $patron,
+        to_branch => $self->to_branch,
+    });
+
+    # Check availability without considering patron, biblio and bibitem
+    # restrictions, since they only need to be checked once.
+    $item_availability->common_issuing_rule_checks({
+        use_cache => 1,
+        nonfound_holds => $nonfound_holds
+    });
+    $item_availability->common_item_checks({ holds => $holds });
+    $item_availability->common_library_item_rule_checks;
+
+    unless ($intranet) {
+        $item_availability->opac_specific_issuing_rule_checks;
+    }
+    if ($override) {
+        # If in intranet and AllowHoldPolicyOverride is enabled, convert
+        # unavailabilities into confirmations.
+        $item_availability->confirmations({
+            %{$item_availability->unavailabilities},
+            %{$item_availability->confirmations}
+        });
+        $item_availability->unavailabilities({});
+        $item_availability->available(1);
+    }
+    return $item_availability;
+}
+
+1;
diff --git a/t/db_dependent/Koha/Biblio/Availability/Hold.t b/t/db_dependent/Koha/Biblio/Availability/Hold.t
new file mode 100644
index 0000000..64c6e52
--- /dev/null
+++ b/t/db_dependent/Koha/Biblio/Availability/Hold.t
@@ -0,0 +1,233 @@
+#!/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 <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+use Test::More tests => 5;
+use t::lib::Mocks;
+use t::lib::TestBuilder;
+require t::db_dependent::Koha::Availability::Helpers;
+use Benchmark;
+
+use Koha::Database;
+use Koha::IssuingRules;
+use Koha::Items;
+use Koha::ItemTypes;
+
+use Koha::Biblio::Availability::Hold;
+
+my $schema = Koha::Database->new->schema;
+$schema->storage->txn_begin;
+
+my $builder = t::lib::TestBuilder->new;
+
+set_default_system_preferences();
+set_default_circulation_rules();
+
+subtest 'Biblio with zero available items in OPAC' => \&t_no_available_items_opac;
+sub t_no_available_items_opac {
+    plan tests => 6;
+
+    my $item1 = build_a_test_item();
+    my $biblio = Koha::Biblios->find($item1->biblionumber);
+    my $item2 = build_a_test_item();
+    $item2->biblionumber($biblio->biblionumber)->store;
+    $item2->biblioitemnumber($item1->biblioitemnumber)->store;
+
+    my $patron = build_a_test_patron();
+    Koha::IssuingRules->search->delete;
+    my $rule = Koha::IssuingRule->new({
+        branchcode   => '*',
+        itemtype     => $item2->effective_itemtype,
+        categorycode => '*',
+        holds_per_record => 0,
+        reservesallowed => 0,
+        opacitemholds => 'Y',
+    })->store;
+
+    t::lib::Mocks::mock_preference('UseBranchTransferLimits', 1);
+    t::lib::Mocks::mock_preference('BranchTransferLimitsType', 'itemtype');
+
+    my $branch2 = Koha::Libraries->find($builder->build({ source => 'Branch' })->{branchcode});
+    C4::Circulation::CreateBranchTransferLimit(
+        $branch2->branchcode,
+        $item1->holdingbranch,
+        $item1->effective_itemtype
+    );
+
+    my $availability = Koha::Biblio::Availability::Hold->new({
+        biblio => $biblio,
+        patron => $patron,
+        to_branch => $branch2->branchcode
+    })->in_opac;
+    my $expecting = 'Koha::Exceptions::Biblio::NoAvailableItems';
+
+    ok(!Koha::Item::Availability::Hold->new({
+            item => $item1, patron => $patron, to_branch => $branch2->branchcode,
+        })->in_opac->available,
+       'When I look at the first item of two in this biblio, it is not available.');
+    ok(!Koha::Item::Availability::Hold->new({
+            item => $item2, patron => $patron
+        })->in_opac->available,
+       'When I look at the second item of two in this biblio, it is not available.');
+    ok(!$availability->available, 'Then, the biblio is not available.');
+    is($availability->unavailable, 1, 'Then, there is exactly one reason for unavailability.');
+    is(ref($availability->unavailabilities->{$expecting}), $expecting, 'The reason says there are no'
+       .' available items in this biblio.');
+    is(@{$availability->item_unavailabilities}, 2, 'There seems to be two items that are unavailable.');
+};
+
+subtest 'Biblio with zero available items in intranet' => \&t_no_available_items_intranet;
+sub t_no_available_items_intranet {
+    plan tests => 2;
+
+    my $item1 = build_a_test_item();
+    my $biblio = Koha::Biblios->find($item1->biblionumber);
+    my $item2 = build_a_test_item();
+    $item2->biblionumber($biblio->biblionumber)->store;
+    $item2->biblioitemnumber($item1->biblioitemnumber)->store;
+
+    my $patron = build_a_test_patron();
+    Koha::IssuingRules->search->delete;
+    my $rule = Koha::IssuingRule->new({
+        branchcode   => '*',
+        itemtype     => $item2->effective_itemtype,
+        categorycode => '*',
+        holds_per_record => 0,
+        reservesallowed => 0,
+        opacitemholds => 'Y',
+    })->store;
+    $item1->withdrawn('1')->store;
+
+    subtest 'While AllowHoldPolicyOverride is disabled' => sub {
+        plan tests => 3;
+
+        t::lib::Mocks::mock_preference('AllowHoldPolicyOverride', 0);
+        my $availability = Koha::Biblio::Availability::Hold->new({
+            biblio => $biblio, patron => $patron})->in_intranet;
+        my $i1_avail = Koha::Item::Availability::Hold->new({
+            item => $item1, patron => $patron })->in_intranet;
+        my $i2_avail = Koha::Item::Availability::Hold->new({
+            item => $item2, patron => $patron })->in_intranet;
+        ok(!$i1_avail->available, 'When I look at the first item of two in this'
+           .' biblio, it is not available.');
+        ok(!$i2_avail->available, 'When I look at the second item of two in this'
+           .' biblio, it is not available.');
+        ok(!$availability->available, 'Then, the biblio is not available.');
+    };
+
+    subtest 'Given AllowHoldPolicyOverride is enabled' => sub {
+        plan tests => 5;
+
+        t::lib::Mocks::mock_preference('AllowHoldPolicyOverride', 1);
+        my $availability = Koha::Biblio::Availability::Hold->new({
+            biblio => $biblio, patron => $patron})->in_intranet;
+        my $i1_avail = Koha::Item::Availability::Hold->new({
+            item => $item1, patron => $patron })->in_intranet;
+        my $i2_avail = Koha::Item::Availability::Hold->new({
+            item => $item2, patron => $patron })->in_intranet;
+        ok($i1_avail->available, 'When I look at the first item of two in this'
+           .' biblio, it is available.');
+        is($i1_avail->confirm, 1, 'Then the first item availability seems to have'
+           .' one reason to ask for confirmation.');
+        ok($i2_avail->available, 'When I look at the second item of two in this'
+           .' biblio, it is available.');
+        is($i2_avail->confirm, 1, 'Then the second item availability seems to'
+           .' have one reason to ask for confirmation.');
+        ok($availability->available, 'Then, the biblio is available.');
+    };
+
+};
+
+subtest 'Biblio with one available items out of two' => \&t_one_out_of_two_items_available;
+sub t_one_out_of_two_items_available {
+    plan tests => 5;
+
+    my $item1 = build_a_test_item();
+    my $biblio = Koha::Biblios->find($item1->biblionumber);
+    my $item2 = build_a_test_item();
+    $item2->biblionumber($biblio->biblionumber)->store;
+    $item2->biblioitemnumber($item1->biblioitemnumber)->store;
+
+    my $patron = build_a_test_patron();
+    $item1->withdrawn('1')->store;
+
+    my $availability = Koha::Biblio::Availability::Hold->new({biblio => $biblio, patron => $patron})->in_opac;
+    my $item_availabilities = $availability->item_availabilities;
+    ok(!Koha::Item::Availability::Hold->new({ item => $item1, patron => $patron })->in_opac->available,
+       'When I look at the first item of two in this biblio, it is not available.');
+    ok(Koha::Item::Availability::Hold->new({ item => $item2, patron => $patron })->in_opac->available,
+       'When I look at the second item of two in this biblio, it seems to be available.');
+    ok($availability->available, 'Then, the biblio is available.');
+    is(@{$item_availabilities}, 1, 'There seems to be one available item in this biblio.');
+    is($item_availabilities->[0]->item->itemnumber, $item2->itemnumber, 'Then the only available item'
+       .'is the second item of this biblio.');
+};
+
+subtest 'Biblio with two items out of two available' => \&t_all_items_available;
+sub t_all_items_available {
+    plan tests => 4;
+
+    my $item1 = build_a_test_item();
+    my $biblio = Koha::Biblios->find($item1->biblionumber);
+    my $item2 = build_a_test_item();
+    $item2->biblionumber($biblio->biblionumber)->store;
+    $item2->biblioitemnumber($item1->biblioitemnumber)->store;
+
+    my $patron = build_a_test_patron();
+
+    my $availability = Koha::Biblio::Availability::Hold->new({biblio => $biblio, patron => $patron})->in_opac;
+    my $item_availabilities = $availability->item_availabilities;
+    ok(Koha::Item::Availability::Hold->new({ item => $item1, patron => $patron })->in_opac->available,
+       'When I look at the first item of two in this biblio, it seems to be available.');
+    ok(Koha::Item::Availability::Hold->new({ item => $item2, patron => $patron })->in_opac->available,
+       'When I look at the second item of two in this biblio, it seems to be available.');
+    ok($availability->available, 'Then, the biblio is available.');
+    is(@{$item_availabilities}, 2, 'There seems to be two available items in this biblio.');
+};
+
+subtest 'Performance test' => \&t_performance_test;
+sub t_performance_test {
+    plan tests => 2;
+
+    set_default_circulation_rules();
+    my $item = build_a_test_item();
+    my $patron = build_a_test_patron();
+    my $biblio = Koha::Biblios->find($item->biblionumber);
+    my $biblioitem = Koha::Biblioitems->find($item->biblioitemnumber);
+
+    # add some items to biblio
+    my $bib_iterations = 10;
+    my $count = 10;
+    for my $i (1..($count-1)) { # one already built earlier
+        my $t_item = build_a_test_item($biblio, $biblioitem);
+        $t_item->itype($item->itype)->store;
+        $t_item->homebranch($item->homebranch)->store;
+    }
+
+    my @items = $biblio->items;
+    is(@items, $count, "We will get availability for $count items.");
+    my $res1 = timethis($bib_iterations, sub {
+        Koha::Biblio::Availability::Hold->new({ biblio => $biblio, patron => $patron })->in_opac;
+    });
+    ok($res1, "Calculated search availability $bib_iterations times.");
+};
+
+$schema->storage->txn_rollback;
+
+1;
-- 
2.1.4