From 98c8add18563672c6d1935b5dde3db0a68410605 Mon Sep 17 00:00:00 2001 From: Aleisha Amohia Date: Wed, 17 Jun 2026 04:59:04 +0000 Subject: [PATCH] Bug 31391: Request recalls and convert reserves on staff interface This enhancement adds the ability to place recalls via the staff interface by changing the UseRecalls system preference to have choices which describe where recalls can be placed. Recalls can either be placed via the OPAC, or the staff interface, or both. To be set to OPAC would be consistent with current 'on' behaviour. UseRecalls is turned off by default. This enhancement also adds the ability to convert existing holds to recalls easily via the staff interface. The hold will be cancelled and a recall placed using the hold's information. To test: 1) Update database and restart services 2) Set UseRecalls to OPAC and set the relevant recalls circulation rules OPAC only by default 3) Check out Item A to Patron B. 4) Log into the OPAC as Patron A. Confirm you can place, view and cancel recalls as normal. 5) In the staff interface, set UseRecalls to Staff interface only. 6) In the OPAC, confirm you can still view and cancel recalls, but can no longer place recalls. 7) In the staff interface, search for Item A and go to the Holds tab. Place a hold for Patron C. 8) Check the checkbox for your hold and confirm the counts in the buttons above the Holds table are incremented as expected. 9) Click the button to Convert selected holds to recalls, a modal should pop up. Confirm all of the information is correct. 10) Confirm the conversion and once the page refreshes, your hold should be correctly cancelled using the new RECALLED reason. 11) Go to the Recalls tab. Confirm your hold has been converted to a recall, and details like the patron, expiration, and pickup location have carried across to the recall. 12) On this Recalls page, use the patron search to find Patron D. Test placing a record-level recall by choosing to recall the next available item. 13) When the page refreshes, confirm the recall was successfully placed with the correct details stored. 14) Repeat steps 12 and 13 with Patron E, this time place an item-level recall by choosing an item in the 'place a recall on a specific item' table. When selecting one of these items, the 'recall next available item' section should become disabled. 15) Set UseRecalls back to OPAC only. Go back to the record and view the Recalls tab. 16) Confirm you cannot search for a patron to place a recall, but can still view and cancel recalls. Confirm you also cannot convert holds to recalls. 17) Set UseRecalls to both staff interface and OPAC. Confirm you can place recalls on both interfaces. 18) On the staff interface, test placing recalls that your circulation rules do not allow. For example, if 'recalls per record' is 1, ensure you're blocked from placing a second recall on a record for a patron. Another example, testing converting a hold to a recall when there are NO checked out items, as recalls can only be placed if something is checked out. 19) Place a hold for one of the patron's that already has a recall. Once complete, try to convert this hold into a recall. Confirm you are blocked and shown a message that the hold cannot be converted to a recall. 20) Confirm patron messaging preferences for recalls notices can still be configured as expected. 21) Ensure tests pass: * t/db_dependent/Circulation.t * t/db_dependent/Circulation/transferbook.t * t/db_dependent/Holds.t * t/db_dependent/Koha/Biblio.t * t/db_dependent/Koha/Item.t * t/db_dependent/SIP/Transaction.t * t/db_dependent/Search.t * t/db_dependent/XSLT.t Sponsored-by: Auckland University of Technology Signed-off-by: David Nind Signed-off-by: Katrin Fischer Signed-off-by: Pedro Amorim --- C4/Reserves.pm | 39 +++ Koha/Biblio.pm | 4 +- Koha/Item.pm | 11 +- .../en/includes/modals/holds_table_modals.inc | 45 +++ .../prog/en/modules/recalls/request.tt | 294 ++++++++++++++++-- .../prog/en/modules/reserve/request.tt | 16 + koha-tmpl/intranet-tmpl/prog/js/holds.js | 118 ++++++- .../intranet-tmpl/prog/js/staff-global.js | 4 +- recalls/request.pl | 127 +++++++- reserve/modrequest.pl | 42 ++- reserve/request.pl | 5 + t/db_dependent/Holds.t | 97 +++++- 12 files changed, 756 insertions(+), 46 deletions(-) diff --git a/C4/Reserves.pm b/C4/Reserves.pm index a8e70e57ec..6b26f7f213 100644 --- a/C4/Reserves.pm +++ b/C4/Reserves.pm @@ -1183,6 +1183,8 @@ for a bib. If C<$rank> is 'del', the hold request is cancelled. +If C<$rank> is 'recall', the hold request is converted to a recall request. + If C<$rank> is an integer greater than zero, the priority of the request is set to that value. Since priority != 0 means that the item is not waiting on the hold shelf, setting the @@ -1240,6 +1242,43 @@ sub ModReserve { if ( $rank eq "del" ) { $hold->cancel( { cancellation_reason => $cancellation_reason } ); + } elsif ( $rank eq "recall" ) { + if ( !$biblionumber ) { + $biblionumber = $hold->biblionumber; + } + my $biblio = Koha::Biblios->find($biblionumber); + if ( !$borrowernumber ) { + $borrowernumber = $hold->borrowernumber; + } + my $patron = Koha::Patrons->find($borrowernumber); + if ( $hold->item_level_hold ) { + if ( $hold->item->can_be_recalled( { patron => $patron, hold_convert => 1 } ) ) { + my ( $recall, $due_interval, $due_date ) = Koha::Recalls->add_recall( + { + patron => $patron, + biblio => $biblio, + branchcode => $branchcode, + expirationdate => $date, + interface => 'intranet', + item => $hold->item, + } + ); + $hold->cancel( { cancellation_reason => $cancellation_reason } ); + } + } else { + if ( $biblio->can_be_recalled( { patron => $patron, hold_convert => 1 } ) ) { + my ( $recall, $due_interval, $due_date ) = Koha::Recalls->add_recall( + { + patron => $patron, + biblio => $biblio, + branchcode => $branchcode, + expirationdate => $date, + interface => 'intranet', + } + ); + $hold->cancel( { cancellation_reason => $cancellation_reason } ); + } + } } elsif ( $hold->found && $hold->priority eq '0' && $date ) { # The only column that can be updated for a found hold is the expiration date diff --git a/Koha/Biblio.pm b/Koha/Biblio.pm index d8bf30c219..fc86dcbb1c 100644 --- a/Koha/Biblio.pm +++ b/Koha/Biblio.pm @@ -2239,6 +2239,8 @@ sub recalls { Does biblio-level checks and returns the items attached to this biblio that are available for recall +If hold_convert $param is included, this is a check to convert a hold to a recall, so we should not check for an existing hold. + =cut sub can_be_recalled { @@ -2264,7 +2266,7 @@ sub can_be_recalled { my @all_itemnumbers; foreach my $item (@all_items) { push( @all_itemnumbers, $item->itemnumber ); - if ( $item->can_be_recalled( { patron => $patron } ) ) { + if ( $item->can_be_recalled( { patron => $patron, hold_convert => $params->{hold_convert} } ) ) { push( @itemtypes, $item->effective_itemtype ); push( @itemnumbers, $item->itemnumber ); push( @items, $item ); diff --git a/Koha/Item.pm b/Koha/Item.pm index d8f314a8b7..95ae6cbbdf 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -2643,6 +2643,8 @@ sub recall { Does item-level checks and returns if items can be recalled by this borrower +If hold_convert $param is included, this is a check to convert a hold to a recall, so we should not check for an existing hold. + =cut sub can_be_recalled { @@ -2704,9 +2706,12 @@ sub can_be_recalled { ->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 ); + unless ( $params->{hold_convert} ) { + return 0 + if ( + Koha::Holds->search( { itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber } ) + ->count > 0 ); + } } # check item availability diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc index e8a458bfb1..668a6cc518 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc @@ -95,3 +95,48 @@ [% END %] + +[% BLOCK 'convert-to-recall-modal' %] + +[% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/recalls/request.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/recalls/request.tt index 4fb0832668..dbf329d8f2 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/recalls/request.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/recalls/request.tt @@ -2,18 +2,36 @@ [% USE Asset %] [% USE Koha %] [% USE KohaDates %] +[% USE Branches %] +[% USE Categories %] +[% USE AuthorisedValues %] +[% USE ItemTypes %] [% PROCESS 'i18n.inc' %] [% SET footerjs = 1 %] [% INCLUDE 'doc-head-open.inc' %] +[% SET libraries = Branches.all %] +[% SET categories = Categories.all.unblessed %] +[% SET columns = ['name', 'cardnumber', 'dateofbirth', 'category', 'branch', 'address', 'phone'] %] +[% PROCESS "patron-search.inc" %] [% FILTER collapse %] - [% t("Existing recalls") | html %] + [% t("Place a recall") | html %] › [% t("Recalls") | html %] › [% t("Koha") | html %] [% END %] [% INCLUDE 'doc-head-close.inc' %] +[% FILTER collapse %] + +[% END %] - + [% WRAPPER 'header.inc' %] [% INCLUDE 'circ-search.inc' %] [% END %] @@ -27,40 +45,264 @@ [% INCLUDE 'biblio-title.inc' link = 1 %] [% END %] [% WRAPPER breadcrumb_item bc_active= 1 %] - Existing recalls + Place a recall [% END %] [% END #/ WRAPPER breadcrumbs %] [% END #/ WRAPPER sub-header.inc %] -[% WRAPPER 'main-container.inc' aside='biblio-view-menu' %] -

Existing recalls

+[% WRAPPER 'main-container.inc' aside='biblio-view-menu' wide_centered=1 %] +

Recalls

- [% IF Koha.Preference('UseRecalls') != "off" %] - [% IF recalls.count %] -
-
- [% INCLUDE 'csrf-token.inc' %] - - - Select all - [% INCLUDE 'recalls.inc' %] -
- -
-
-
- - [% ELSE %] -
No recalls have been made.
+

Place a recall on [% INCLUDE 'biblio-title.inc' link = 1 %]

+ + [% IF patron %] + + [% IF error %] +
Cannot place recalls: check your circulation rules.
[% END %] - [% ELSE %] -
Recalls have not been enabled. Enable the UseRecalls system preference to use recalls.
- [% END %] -[% END %] + + [% IF patron_holds_count %] +
Patron has placed one or more record-level holds. Convert a hold to a recall.
+ [% END %] + +
+
+ Recall details + +
    +
  1. + [% INCLUDE 'patron-title.inc' %] + +
  2. + +
  3. +
+
+ +
+ Recall next available item + +
    + [% UNLESS ( single_branch_mode ) %] +
  1. + + +
  2. + [% END %] +
+ +
+ [% INCLUDE 'csrf-token.inc' %] + + + + [% IF error %] + + [% ELSE %] + + [% END %] + Cancel +
+
+ +
+ Recall a specific item + + + + + + + + [% UNLESS ( single_branch_mode ) %] + + + [% END %] + + + + + + + + + [% FOREACH item IN items %] + + + + + [% UNLESS ( single_branch_mode ) %] + + + [% END %] + + + + + + + [% END # FOREACH item IN items %] + +
 Item typeBarcodeHome libraryHolding libraryCollectionCall numberCopy numberVol no.Information
+ [% IF item.can_be_recalled( patron => patron ) %] + + [% ELSE %] + + + + [% END %] + [% ItemTypes.GetDescription( item.effective_itemtype ) | html %][% item.barcode | html %][% Branches.GetName( item.homebranch ) | html %][% Branches.GetName( item.holdingbranch) | html %][% AuthorisedValues.GetByCode( 'CCODE', item.ccode, 1 ) | html %][% item.itemcallnumber | html %][% item.copynumber | html %][% item.enumchron | html %] + [% IF ( item.checkout ) %] + Due [% item.checkout.date_due | $KohaDates %] + [% ELSIF ( item.get_transfer ) %] + In transit from [% Branches.GetName( item.get_transfer.frombranch ) | html %] to [% Branches.GetName( item.get_transfer.tobranch ) | html %] since + [% item.get_transfer.datesent | $KohaDates %] + [% END %] + [% IF ( item.itemlost || item.withdrawn ) %] + Unavailable (lost or withdrawn) + [% END %] + [% IF ( item.notforloan ) %] + Not for loan ([% item.notforloan | html %]) + [% END %] + [% hold = item.current_holds.next %] + [% IF ( item.recall ) %] + + [% IF ( item.recall.waiting_date ) %] + Waiting for patron at [% Branches.GetName( item.recall.pickup_library_id ) | html %] since [% item.recall.waiting_date | $KohaDates %]. + [% ELSIF ( item.recall.item_id == item.itemnumber ) %] + Recalled by patron expected at [% Branches.GetName( item.recall.pickup_library_id ) | html %] since [% item.recall.created_date | $KohaDates %]. + [% END %] + + [% ELSIF ( hold.waitingdate ) %] + Waiting for patron at [% Branches.GetName( hold.branchcode ) | html %] since [% hold.waitingdate | $KohaDates %]. + [% ELSIF ( hold.borrowernumber == logged_in_user.borrowernumber ) %] + Patron has already placed a hold on this item. + [% END # / IF ( item.recall or hold ) %] +
+ +
+ [% INCLUDE 'csrf-token.inc' %] + + + [% IF error %] + + [% ELSE %] + + [% END %] + Cancel +
+
+
+ [% ELSE # patron %] + + [% IF Koha.Preference('UseRecalls') == 'opac' %] +
Due to the UseRecalls system preference, recalls cannot be placed through the staff interface.
+ [% ELSE %] +
+

Search patrons

+ [% WRAPPER tabs id= "circ_recalls_select" %] + [% WRAPPER tabs_nav %] + [% WRAPPER tab_item tabname= "recalls_patronsearch_pane" bt_active= 1 %]Patrons[% END %] + [% END # /WRAPPER tabs_nav %] + + [% WRAPPER tab_panels %] + [% WRAPPER tab_panel tabname="recalls_patronsearch_pane" bt_active= 1 %] + [% PROCESS patron_search_filters_simple %] + [% PROCESS patron_search_table table_id => 'table_borrowers', open_on_row_click => 1 %] + [% END # /tab_panel# %] + [% END # /WRAPPER tab_panels %] + [% END # /WRAPPER tabs %] +
+ [% END %] + +
+

Existing recalls

+ + [% IF Koha.Preference('UseRecalls') != "off" %] + [% IF recalls.count %] +
+
+ [% INCLUDE 'csrf-token.inc' %] + + + Select all + [% INCLUDE 'recalls.inc' %] +
+ +
+
+
+ + [% ELSE %] +
No recalls have been made.
+ [% END %] + [% ELSE %] +
Recalls have not been enabled. Enable the UseRecalls system preference to use recalls.
+ [% END %] +
+ [% END # patron %] +[% END # WRAPPER %] [% MACRO jsinclude BLOCK %] [% Asset.js("js/recalls.js") | $raw %] [% INCLUDE 'datatables.inc' %] + [% INCLUDE 'calendar.inc' %] + [% INCLUDE 'select2.inc' %] + [% SET url_biblio_params = "biblionumber=" _ biblio.biblionumber %] + [% UNLESS patron %] + [% SET search_results_block_id = 'recalls_patronsearch_pane_panel' %] + [%# adjusting variable for patron-search.inc %] + [% PROCESS patron_search_js table_id => 'table_borrowers', categories => categories, libraries => libraries, columns => columns, open_on_row_click => 1, on_click_url => '/cgi-bin/koha/recalls/request.pl?' _ url_biblio_params, redirect_if_one_result => 1, redirect_url => '/cgi-bin/koha/recalls/request.pl?' _ url_biblio_params, redirect_if_attribute_equal => 'cardnumber' %] + + [% END %] + + [% END %] [% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt index 960f6e153c..4566e72a95 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt @@ -327,6 +327,14 @@

No club with this name, please, try another

[% END %] + + [% IF ( recallerror ) %] +
+

Hold cannot be converted to a recall

+

A recall cannot be placed for one or more holds. Check your circulation and fine rules.

+
+ [% END %] +
[% UNLESS multi_hold %] [% IF clubcount %] @@ -1383,6 +1391,11 @@ [% END %] + [% IF Koha.Preference('UseRecalls').match('staff') %] +
+ +
+ [% END %]
[% FOREACH biblioloo IN biblioloop %] @@ -1465,6 +1478,8 @@ [% PROCESS 'cancel-hold-modal' form_action = 'request.pl' %] +[% PROCESS 'convert-to-recall-modal' form_action = 'modrequest.pl' %] +