Koha/opac/opac-reserve.pl
Aleisha Amohia 6c0eb32a08
Bug 8367: Add holds_pickup_period circulation rule
So that pickup delay can have a different value per patron category,
   item type or branch.

To test:

1) Update database, restart services
2) Set ReservesMaxPickUpDelay syspref (if not already set)
3) Edit your circulation rules and set a value under 'Holds pickup
period (day) that is DIFFERENT from ReservesMaxPickUpDelay. Set a few
different numbers for different branches as well.
4) Place a hold on a biblio from the staff client.
5) Check in an item from that biblio and confirm the hold as waiting
6) Confirm the expiration date is calculated using the 'Holds pickup
period' value instead of the ReservesMaxPickUpDelay syspref
7) Revert the waiting status and delete the hold
8) Re-place the hold on the biblio on the OPAC. Notice that when you
change the pick up location, the number of days in the pickup message
below the dropdown changes based on the circ rules.
9) Create a holiday with a date that will overlap with the 'Holds pickup
period'
10) Check in an item from that biblio and confirm the hold as waiting
11) Confirm the expiration date is calculated using the 'Holds pickup
period' value AND considers the special holiday
12) Confirm tests pass t/db_dependent/Holds/WaitingReserves.t
13) Test Talking Tech:
13a) Enable TalkingTechItivaPhoneNotification
13b) Go to Tools -> Notices & slips. Add content to the HOLD phone
(itiva) notice.
13c) In your terminal, run perl
/path/to/koha/misc/cronjobs/thirdparty/TalkingTech_itiva_outbound.pl -o
~/itiva.tmp -w 0 --type=RESERVE

Sponsored-by: Catalyst IT

Signed-off-by: Emmi Takkinen <emmi.takkinen@koha-suomi.fi>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Kelly <kelly@bywatersolutions.com>
Signed-off-by: Katrin Fischer <katrin.fischer.83@web.de>
Signed-off-by: Kelly <kelly@bywatersolutions.com>
Signed-off-by: Katrin Fischer <katrin.fischer.83@web.de>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
2023-11-01 18:01:30 -03:00

644 lines
23 KiB
Perl
Executable file

#!/usr/bin/perl
# Copyright Katipo Communications 2002
# Copyright Koha Development team 2012
#
# 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 CGI qw ( -utf8 );
use C4::Auth qw( get_template_and_user );
use C4::Koha qw( getitemtypeimagelocation getitemtypeimagesrc );
use C4::Circulation qw( GetBranchItemRule );
use C4::Reserves qw( CanItemBeReserved CanBookBeReserved AddReserve IsAvailableForItemLevelRequest GetReserveFee );
use C4::Biblio qw( GetBiblioData GetFrameworkCode );
use C4::Output qw( output_html_with_http_headers );
use C4::Context;
use C4::Members;
use C4::Overdues;
use Koha::AuthorisedValues;
use Koha::Biblios;
use Koha::CirculationRules;
use Koha::Items;
use Koha::ItemTypes;
use Koha::Checkouts;
use Koha::Libraries;
use Koha::Patrons;
use List::MoreUtils qw( uniq );
my $maxreserves = C4::Context->preference("maxreserves");
my $query = CGI->new;
# if OPACHoldRequests (for placing holds) is disabled, leave immediately
if ( ! C4::Context->preference('OPACHoldRequests') ) {
print $query->redirect("/cgi-bin/koha/errors/404.pl");
exit;
}
my ( $template, $borrowernumber, $cookie ) = get_template_and_user(
{
template_name => "opac-reserve.tt",
query => $query,
type => "opac",
}
);
my $patron = Koha::Patrons->find( $borrowernumber, { prefetch => ['categorycode'] } );
my $category = $patron->category;
my $can_place_hold_if_available_at_pickup = C4::Context->preference('OPACHoldsIfAvailableAtPickup');
unless ( $can_place_hold_if_available_at_pickup ) {
my @patron_categories = split ',', C4::Context->preference('OPACHoldsIfAvailableAtPickupExceptions');
if ( @patron_categories ) {
my $categorycode = $patron->categorycode;
$can_place_hold_if_available_at_pickup = grep { $_ eq $categorycode } @patron_categories;
}
}
my $itemtypes = { map { $_->{itemtype} => $_ } @{ Koha::ItemTypes->search_with_localization->unblessed } };
# There are two ways of calling this script, with a single biblio num
# or multiple biblio nums.
my $biblionumbers = $query->param('biblionumbers');
my $reserveMode = $query->param('reserve_mode');
if ($reserveMode && ($reserveMode eq 'single')) {
my $bib = $query->param('single_bib');
$biblionumbers = "$bib/";
}
if (! $biblionumbers) {
$biblionumbers = $query->param('biblionumber');
}
if ((! $biblionumbers) && (! $query->param('place_reserve'))) {
$template->param(message=>1, no_biblionumber=>1);
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
exit;
}
# Pass the numbers to the page so they can be fed back
# when the hold is confirmed. TODO: Not necessary?
$template->param( biblionumbers => $biblionumbers );
# Each biblio number is suffixed with '/', e.g. "1/2/3/"
my @biblionumbers = split /\//, $biblionumbers;
if (($#biblionumbers < 0) && (! $query->param('place_reserve'))) {
# TODO: New message?
$template->param(message=>1, no_biblionumber=>1);
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
exit;
}
#
#
# Here we check that the borrower can actually make reserves Stage 1.
#
#
my $noreserves = 0;
if ( $category->effective_BlockExpiredPatronOpacActions ) {
if ( $patron->is_expired ) {
# cannot reserve, their card has expired and the rules set mean this is not allowed
$noreserves = 1;
$template->param( message => 1, expired_patron => 1 );
}
}
my $maxoutstanding = C4::Context->preference("maxoutstanding");
my $amountoutstanding = $patron->account->balance;
if ( $amountoutstanding && ($amountoutstanding > $maxoutstanding) ) {
my $amount = sprintf "%.02f", $amountoutstanding;
$template->param( message => 1 );
$noreserves = 1;
$template->param( too_much_oweing => $amount );
}
if ( $patron->gonenoaddress && ($patron->gonenoaddress == 1) ) {
$noreserves = 1;
$template->param(
message => 1,
GNA => 1
);
}
if ( $patron->lost && ($patron->lost == 1) ) {
$noreserves = 1;
$template->param(
message => 1,
lost => 1
);
}
if ( $patron->is_debarred ) {
$noreserves = 1;
$template->param(
message => 1,
debarred => 1,
debarred_comment => $patron->debarredcomment,
debarred_date => $patron->debarred,
);
}
my $holds = $patron->holds;
my $reserves_count = $holds->count;
$template->param( RESERVES => $holds->unblessed );
if ( $maxreserves && ( $reserves_count >= $maxreserves ) ) {
$template->param( message => 1 );
$noreserves = 1;
$template->param( too_many_reserves => $holds->count );
}
if( $noreserves ){
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
exit;
}
# pass the pickup branch along....
my $branch = $query->param('branch') || $patron->branchcode || C4::Context->userenv->{branch} || '' ;
$template->param( branch => $branch );
#
#
# Here we are carrying out the hold request, possibly
# with a specific item for each biblionumber.
#
#
if ( $query->param('place_reserve') ) {
my $reserve_cnt = 0;
if ($maxreserves) {
$reserve_cnt = $patron->holds->count;
}
# List is composed of alternating biblio/item/branch
my $selectedItems = $query->param('selecteditems');
if ($query->param('reserve_mode') eq 'single') {
# This indicates non-JavaScript mode, so there was
# only a single biblio number selected.
my $bib = $query->param('single_bib');
my $item = $query->param("checkitem_$bib");
if ($item eq 'any') {
$item = '';
}
my $branch = $query->param('branch');
$selectedItems = "$bib/$item/$branch/";
}
$selectedItems =~ s!/$!!;
my @selectedItems = split /\//, $selectedItems, -1;
# Make sure there is a biblionum/itemnum/branch triplet for each item.
# The itemnum can be 'any', meaning next available.
my $selectionCount = @selectedItems;
if (($selectionCount == 0) || (($selectionCount % 3) != 0)) {
$template->param(message=>1, bad_data=>1);
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
exit;
}
my @failed_holds;
while (@selectedItems) {
my $biblioNum = shift(@selectedItems);
my $itemNum = shift(@selectedItems);
my $branch = shift(@selectedItems); # i.e., branch code, not name
my $canreserve = 0;
my $singleBranchMode = Koha::Libraries->search->count == 1;
if ( $singleBranchMode || ! C4::Context->preference("OPACAllowUserToChooseBranch") )
{ # single branch mode or disabled user choosing
$branch = $patron->branchcode;
}
# FIXME We shouldn't need to fetch the item here
my $item = $itemNum ? Koha::Items->find( $itemNum ) : undef;
# When choosing a specific item, the default pickup library should be dictated by the default hold policy
if ( ! C4::Context->preference("OPACAllowUserToChooseBranch") && $item ) {
my $type = $item->effective_itemtype;
my $reserves_control_branch = Koha::Policy::Holds->holds_control_library( $item, $patron );
my $rule = GetBranchItemRule( $reserves_control_branch, $type );
if ( $rule->{hold_fulfillment_policy} eq 'any' || $rule->{hold_fulfillment_policy} eq 'patrongroup' ) {
$branch = $patron->branchcode;
} elsif ( $rule->{hold_fulfillment_policy} eq 'holdgroup' ){
$branch = $item->homebranch;
} else {
my $policy = $rule->{hold_fulfillment_policy};
$branch = $item->$policy;
}
}
# if we have an item, we are placing the hold on the item's bib, in case of analytics
if ( $item ) {
$biblioNum = $item->biblionumber;
}
# Check for user supplied reserve date
my $startdate;
if ( C4::Context->preference('AllowHoldDateInFuture')
&& C4::Context->preference('OPACAllowHoldDateInFuture') )
{
$startdate = $query->param("reserve_date_$biblioNum");
}
my $patron_expiration_date = $query->param("expiration_date_$biblioNum");
my $itemtype = $query->param('itemtype') || undef;
$itemtype = undef if $itemNum;
my $biblio = Koha::Biblios->find($biblioNum);
my $rank = $biblio->holds->search( { found => [ { "!=" => "W" }, undef ] } )->count + 1;
if ( $item ) {
my $status = CanItemBeReserved( $patron, $item, $branch, { get_from_cache => 1 } )->{status};
if( $status eq 'OK' ){
$canreserve = 1;
} else {
push @failed_holds, $status;
}
}
else {
my $status = CanBookBeReserved( $borrowernumber, $biblioNum, $branch, { itemtype => $itemtype } )->{status};
if( $status eq 'OK'){
$canreserve = 1;
} else {
push @failed_holds, $status;
}
# Inserts a null into the 'itemnumber' field of 'reserves' table.
$itemNum = undef;
}
my $notes = $query->param('notes_'.$biblioNum)||'';
my $item_group_id = $query->param("item_group_id_$biblioNum") || undef;
if ( $maxreserves
&& $reserve_cnt >= $maxreserves )
{
push @failed_holds, 'tooManyReserves';
$canreserve = 0;
}
unless ( $can_place_hold_if_available_at_pickup ) {
my $items_in_this_library = Koha::Items->search({ biblionumber => $biblioNum, holdingbranch => $branch });
my $nb_of_items_issued = $items_in_this_library->search({ 'issue.itemnumber' => { not => undef }}, { join => 'issue' })->count;
my $nb_of_items_unavailable = $items_in_this_library->search({ -or => { lost => { '!=' => 0 }, damaged => { '!=' => 0 }, } });
if ( $items_in_this_library->count > $nb_of_items_issued + $nb_of_items_unavailable ) {
$canreserve = 0;
push @failed_holds, 'items_available';
}
}
# Here we actually do the reserveration. Stage 3.
if ($canreserve) {
my $reserve_id = AddReserve(
{
branchcode => $branch,
borrowernumber => $borrowernumber,
biblionumber => $biblioNum,
priority => $rank,
reservation_date => $startdate,
expiration_date => $patron_expiration_date,
notes => $notes,
title => $biblio->title,
itemnumber => $itemNum,
found => undef,
itemtype => $itemtype,
item_group_id => $item_group_id,
}
);
if( $reserve_id ){
++$reserve_cnt;
} else {
push @failed_holds, 'not_placed';
}
}
}
print $query->redirect("/cgi-bin/koha/opac-user.pl?" . ( @failed_holds ? "failed_holds=" . join('|',@failed_holds) : q|| ) . "#opac-user-holds");
exit;
}
#
#
# Build hashes of the requested biblio(item)s and items.
#
#
my %biblioDataHash; # Hash of biblionumber to biblio/biblioitems record.
foreach my $biblioNumber (@biblionumbers) {
my $biblioData = GetBiblioData($biblioNumber);
$biblioDataHash{$biblioNumber} = $biblioData;
my $biblio = Koha::Biblios->find( $biblioNumber );
next unless $biblio;
my $items = Koha::Items->search_ordered(
[
'me.biblionumber' => $biblioNumber,
'me.itemnumber' => {
-in => [
$biblio->host_items->get_column('itemnumber')
]
}
],
{ prefetch => [ 'issue', 'homebranch', 'holdingbranch' ] }
)->filter_by_visible_in_opac({ patron => $patron });
$biblioData->{items} = [$items->as_list]; # FIXME Potentially a lot in memory here!
# Compute the priority rank.
$biblioData->{object} = $biblio;
my $reservecount = $biblio->holds->search({ found => [ {"!=" => "W"},undef] })->count;
$biblioData->{reservecount} = $reservecount;
$biblioData->{rank} = $reservecount + 1;
}
my $requested_reserves_count = scalar( @biblionumbers );
if ( $maxreserves && ( $reserves_count + $requested_reserves_count > $maxreserves ) ) {
$template->param( new_reserves_allowed => $maxreserves - $reserves_count );
}
$template->param( select_item_types => 1 );
#
#
# Build the template parameters that will show the info
# and items for each biblionumber.
#
#
my $biblioLoop = [];
my $numBibsAvailable = 0;
my $itemdata_enumchron = 0;
my $itemdata_ccode = 0;
my $anyholdable = 0;
my $itemLevelTypes = C4::Context->preference('item-level_itypes');
my $pickup_locations = Koha::Libraries->search({ pickup_location => 1 });
$template->param('item_level_itypes' => $itemLevelTypes);
my $patron_unblessed = $patron->unblessed;
foreach my $biblioNum (@biblionumbers) {
# Init the bib item with the choices for branch pickup
my %biblioLoopIter;
# Get relevant biblio data.
my $biblioData = $biblioDataHash{$biblioNum};
if (! $biblioData) {
$template->param(message=>1, bad_biblionumber=>$biblioNum);
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
exit;
}
my @not_available_at = ();
my $biblio = $biblioData->{object};
foreach my $library ( $pickup_locations->as_list ) {
push( @not_available_at, $library->branchcode ) unless $biblio->can_be_transferred({ to => $library });
}
my $frameworkcode = GetFrameworkCode( $biblioData->{biblionumber} );
$biblioLoopIter{biblionumber} = $biblioData->{biblionumber};
$biblioLoopIter{title} = $biblioData->{title};
$biblioLoopIter{subtitle} = $biblioData->{'subtitle'};
$biblioLoopIter{medium} = $biblioData->{medium};
$biblioLoopIter{part_number} = $biblioData->{part_number};
$biblioLoopIter{part_name} = $biblioData->{part_name};
$biblioLoopIter{author} = $biblioData->{author};
$biblioLoopIter{rank} = $biblioData->{rank};
$biblioLoopIter{reservecount} = $biblioData->{reservecount};
$biblioLoopIter{already_reserved} = $biblioData->{already_reserved};
$biblioLoopIter{object} = $biblio;
if (!$itemLevelTypes && $biblioData->{itemtype}) {
$biblioLoopIter{translated_description} = $itemtypes->{$biblioData->{itemtype}}{translated_description};
$biblioLoopIter{imageurl} = getitemtypeimagesrc() . "/". $itemtypes->{$biblioData->{itemtype}}{imageurl};
}
$biblioLoopIter{itemLoop} = [];
my $numCopiesAvailable = 0;
my $numCopiesOPACAvailable = 0;
# iterating through all items first to check if any of them available
# to pass this value further inside down to IsAvailableForItemLevelRequest to
# it's complicated logic to analyse.
# (before this loop was inside that sub loop so it was O(n^2) )
foreach my $item (@{$biblioData->{items}}) {
my $item_info = $item->unblessed;
$item_info->{holding_branch} = $item->holding_branch;
$item_info->{home_branch} = $item->home_branch;
if ($itemLevelTypes) {
my $itemtype = $item->itemtype;
$item_info->{'imageurl'} = getitemtypeimagelocation( 'opac',
$itemtypes->{ $itemtype->itemtype }->{'imageurl'} );
$item_info->{'translated_description'} =
$itemtypes->{ $itemtype->itemtype }->{translated_description};
}
# checking for holds
my $holds = $item->current_holds;
if ( my $first_hold = $holds->next ) {
$item_info->{first_hold} = $first_hold;
}
$item_info->{checkout} = $item->checkout;
# Check of the transferred documents
my $transfer = $item->get_transfer;
if ( $transfer && $transfer->in_transit ) {
$item_info->{transfertwhen} = $transfer->datesent;
$item_info->{transfertfrom} = $transfer->frombranch;
$item_info->{transfertto} = $transfer->tobranch;
$item_info->{nocancel} = 1;
}
# if the items belongs to a host record, show link to host record
if ( $item_info->{biblionumber} ne $biblioNum ) {
$item_info->{hostbiblionumber} = $item->biblionumber;
$item_info->{hosttitle} = Koha::Biblios->find( $item_info->{biblionumber} )->title;
}
my $branch = Koha::Policy::Holds->holds_control_library( $item, $patron );
my $policy_holdallowed =
CanItemBeReserved( $patron, $item, undef, { get_from_cache => 1 } )->{status} eq 'OK' &&
IsAvailableForItemLevelRequest($item, $patron, undef);
if ($policy_holdallowed) {
my $opac_hold_policy = Koha::CirculationRules->get_opacitemholds_policy( { item => $item, patron => $patron } );
if ( $opac_hold_policy ne 'N' ) { # If Y or F
$item_info->{available} = 1;
$numCopiesOPACAvailable++;
$biblioLoopIter{force_hold} = 1 if $opac_hold_policy eq 'F';
}
$numCopiesAvailable++;
unless ( $can_place_hold_if_available_at_pickup ) {
my $items_in_this_library = Koha::Items->search({ biblionumber => $item->biblionumber, holdingbranch => $item->holdingbranch, notforloan => 0 });
my $nb_of_items_issued = $items_in_this_library->search({ 'issue.itemnumber' => { not => undef }}, { join => 'issue' })->count;
if ( $items_in_this_library->count > $nb_of_items_issued ) {
push @not_available_at, $item->holdingbranch;
}
}
}
# Show serial enumeration when needed
if ($item_info->{enumchron}) {
$itemdata_enumchron = 1;
}
# Show collection when needed
if ($item_info->{ccode}) {
$itemdata_ccode = 1;
}
push @{$biblioLoopIter{itemLoop}}, $item_info;
}
$template->param(
itemdata_enumchron => $itemdata_enumchron,
itemdata_ccode => $itemdata_ccode,
);
if ($numCopiesAvailable > 0) {
$numBibsAvailable++;
$biblioLoopIter{bib_available} = 1;
$biblioLoopIter{holdable} = 1;
$biblioLoopIter{itemholdable} = 1 if $numCopiesOPACAvailable;
}
if ($biblioLoopIter{already_reserved}) {
$biblioLoopIter{holdable} = undef;
$biblioLoopIter{itemholdable} = undef;
}
if ( $biblioLoopIter{holdable} ) {
@not_available_at = uniq @not_available_at;
$biblioLoopIter{not_available_at} = \@not_available_at ;
}
unless ( $can_place_hold_if_available_at_pickup ) {
@not_available_at = uniq @not_available_at;
$biblioLoopIter{not_available_at} = \@not_available_at ;
# The record is not holdable is not available at any of the libraries
if ( Koha::Libraries->search->count == @not_available_at ) {
$biblioLoopIter{holdable} = 0;
}
}
my $status = CanBookBeReserved( $borrowernumber, $biblioNum )->{status};
$biblioLoopIter{holdable} &&= $status eq 'OK';
$biblioLoopIter{$status} = 1;
if ( $biblioLoopIter{holdable} and C4::Context->preference('AllowHoldItemTypeSelection') ) {
# build the allowed item types loop
my $rs = $biblio->items->search_ordered(
undef,
{ select => [ { distinct => 'itype' } ],
as => 'item_type'
}
);
my @item_types =
grep { CanBookBeReserved( $borrowernumber, $biblioNum, $branch, { itemtype => $_ } )->{status} eq 'OK' }
$rs->get_column('item_type');
$biblioLoopIter{allowed_item_types} = \@item_types;
}
if ( $status eq 'recall' ) {
$biblioLoopIter{recall} = 1;
}
# For multiple holds per record, if a patron has previously placed a hold,
# the patron can only place more holds of the same type. That is, if the
# patron placed a record level hold, all the holds the patron places must
# be record level. If the patron placed an item level hold, all holds
# the patron places must be item level
my $forced_hold_level = Koha::Holds->search(
{
borrowernumber => $borrowernumber,
biblionumber => $biblioNum,
found => undef,
}
)->forced_hold_level();
if ($forced_hold_level) {
$biblioLoopIter{force_hold} = 1 if $forced_hold_level eq 'item';
$biblioLoopIter{force_hold} = 0 if $forced_hold_level eq 'item_group';
$biblioLoopIter{itemholdable} = 0 if $forced_hold_level eq 'record';
$biblioLoopIter{itemholdable} = 0 if $forced_hold_level eq 'item_group';
$biblioLoopIter{forced_hold_level} = $forced_hold_level;
}
# Pass through any reserve charge
$biblioLoopIter{reserve_charge} = GetReserveFee( $patron->id, $biblioNum );
push @$biblioLoop, \%biblioLoopIter;
$anyholdable = 1 if $biblioLoopIter{holdable};
}
unless ($pickup_locations->count) {
$numBibsAvailable = 0;
$anyholdable = 0;
$template->param(
message => 1,
no_pickup_locations => 1
);
}
if ( $numBibsAvailable == 0 || $anyholdable == 0) {
$template->param( none_available => 1 );
}
if (scalar @biblionumbers > 1) {
$template->param( multi_hold => 1);
}
my $show_notes=C4::Context->preference('OpacHoldNotes');
$template->param(OpacHoldNotes=>$show_notes);
# display infos
$template->param(bibitemloop => $biblioLoop);
# can set reserve date in future
if (
C4::Context->preference( 'AllowHoldDateInFuture' ) &&
C4::Context->preference( 'OPACAllowHoldDateInFuture' )
) {
$template->param(
reserve_in_future => 1,
);
}
my @branches = Koha::Libraries->search();
my %hold_pickup_delay_by_branch = ();
foreach my $branch ( @branches ) {
my $rule = Koha::CirculationRules->get_effective_rule({
categorycode => $patron->categorycode,
branchcode => $branch->branchcode,
itemtype => undef,
rule_name => 'holds_pickup_period',
});
$hold_pickup_delay_by_branch{$branch->branchcode} = $rule->rule_value if ( $rule and $rule->rule_value );
}
$template->param( pickup_delays => \%hold_pickup_delay_by_branch );
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };