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 <david@davidnind.com>
Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Aleisha Amohia 2026-06-17 04:59:04 +00:00 committed by Pedro Amorim
parent 8211dd8d0f
commit 98c8add185
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
12 changed files with 756 additions and 46 deletions

View File

@ -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

View File

@ -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 );

View File

@ -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

View File

@ -95,3 +95,48 @@
</div>
</div>
[% END %]
[% BLOCK 'convert-to-recall-modal' %]
<div id="convertModal" class="modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title">Confirm conversion</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="convert_to_recall_modal_form" method="post" action="[% form_action | html %]">
[% INCLUDE 'csrf-token.inc' %]
[% IF (from_param) %]
<input type="hidden" name="from" value="[% patronpage | html %]" />
[% END %]
<div id="inputs">
<input type="hidden" name="op" value="cud-convertall" />
</div>
<div class="modal-body">
<p>Are you sure you want to convert this hold to a recall?</p>
<div id="convert_hold_alert" class="alert alert-danger" style="display:none;"></div>
<fieldset class="action">
[% SET hold_cancellation = AuthorisedValues.GetAuthValueDropbox('HOLD_CANCELLATION') %]
[% IF hold_cancellation.count %]
<label for="modal-cancellation-reason">Hold cancellation reason: </label>
<select class="cancellation-reason" name="cancellation-reason" id="modal-cancellation-reason">
<option value="">No reason given</option>
[% FOREACH reason IN hold_cancellation %]
<option value="[% reason.authorised_value | html %]">[% reason.lib | html %]</option>
[% END %]
</select>
[% END %]
</fieldset>
</div>
<div class="modal-footer">
<button id="convertModalConfirmBtn" type="submit" class="btn btn-danger">Confirm conversion</button>
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
[% END %]

View File

@ -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" %]
<title
>[% FILTER collapse %]
[% t("Existing recalls") | html %]
[% t("Place a recall") | html %]
&rsaquo; [% t("Recalls") | html %] &rsaquo; [% t("Koha") | html %]
[% END %]</title
>
[% INCLUDE 'doc-head-close.inc' %]
[% FILTER collapse %]
<style nonce="[% Koha.CSPNonce | $raw %]">
.disabled {
opacity: 0.5;
}
#circ_request fieldset.disabled {
pointer-events: auto;
}
</style>
[% END %]
</head>
<body id="recalls-request" class="catalog">
<body id="circ_request" class="recall">
[% 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 %]
<span>Existing recalls</span>
<span>Place a recall</span>
[% END %]
[% END #/ WRAPPER breadcrumbs %]
[% END #/ WRAPPER sub-header.inc %]
[% WRAPPER 'main-container.inc' aside='biblio-view-menu' %]
<h1>Existing recalls</h1>
[% WRAPPER 'main-container.inc' aside='biblio-view-menu' wide_centered=1 %]
<h1>Recalls</h1>
[% IF Koha.Preference('UseRecalls') != "off" %]
[% IF recalls.count %]
<div class="page-section">
<form method="post" action="/cgi-bin/koha/recalls/request.pl">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="op" value="cud-cancel_multiple_recalls" />
<input type="hidden" name="biblionumber" value="[% biblio.biblionumber | html %]" />
<input type="checkbox" id="select_all" /> <span id="select_all_text">Select all</span>
[% INCLUDE 'recalls.inc' %]
<fieldset class="action">
<button type="submit" id="cancel_selected" class="btn btn-default btn-sm">Cancel selected recalls</button>
</fieldset>
</form>
</div>
<!-- /.page-section -->
[% ELSE %]
<div class="alert alert-info">No recalls have been made.</div>
<h2>Place a recall on [% INCLUDE 'biblio-title.inc' link = 1 %]</h2>
[% IF patron %]
[% IF error %]
<div class="alert alert-warning"> <strong>Cannot place recalls:</strong> check your circulation rules. </div>
[% END %]
[% ELSE %]
<div class="alert alert-info">Recalls have not been enabled. Enable the <a href="/cgi-bin/koha/admin/preferences.pl?tab=circulation">UseRecalls</a> system preference to use recalls.</div>
[% END %]
[% END %]
[% IF patron_holds_count %]
<div class="alert alert-info"> Patron has placed one or more record-level holds. <a href="/cgi-bin/koha/reserve/request.pl?biblionumber=[% biblio.biblionumber | uri %]">Convert a hold to a recall</a>. </div>
[% END %]
<form id="recallform" action="/cgi-bin/koha/recalls/request.pl" method="post">
<fieldset class="rows">
<legend>Recall details</legend>
<ol>
<li>
<label for="borrowernumber">Patron:</label> <a href="/cgi-bin/koha/members/moremember.pl?borrowernumber=[% patron.borrowernumber | uri %]">[% INCLUDE 'patron-title.inc' %]</a>
<input type="hidden" name="borrowernumber" value="[% patron.borrowernumber | html %]" />
</li>
<li> <label for="expirationdate">Recall not needed after:</label> <input type="text" name="expirationdate" id="expirationdate" size="20" class="flatpickr" /> </li>
</ol>
</fieldset>
<fieldset class="rows enabled recall_next">
<legend>Recall next available item</legend>
<ol>
[% UNLESS ( single_branch_mode ) %]
<li>
<label for="pickup">Pick up at:</label>
<select name="pickup" id="pickup">
[% FOREACH branch IN branches %]
[% IF branch.branchcode == patron.branchcode %]
<option value="[% branch.branchcode | html %]" selected>[% Branches.GetName( branch.branchcode ) | html %]</option>
[% ELSE %]
<option value="[% branch.branchcode | html %]">[% Branches.GetName( branch.branchcode ) | html %]</option>
[% END %]
[% END %]
</select>
</li>
[% END %]
</ol>
<fieldset class="action enabled recall_next">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="op" value="cud-request" />
<input type="hidden" name="biblionumber" value="[% biblio.biblionumber | html %]" />
<input type="hidden" name="type" value="next" />
[% IF error %]
<input type="submit" class="btn btn-primary" value="Place recall" disabled="disabled" />
[% ELSE %]
<input type="submit" class="btn btn-primary" value="Place recall" />
[% END %]
<a href="/cgi-bin/koha/recalls/request.pl?biblionumber=[% biblio.biblionumber | uri %]" class="cancel">Cancel</a>
</fieldset>
</fieldset>
<fieldset class="rows disabled recall_specific">
<legend>Recall a specific item</legend>
<table id="items">
<thead>
<tr>
<th>&nbsp;</th>
<th>Item type</th>
<th>Barcode</th>
[% UNLESS ( single_branch_mode ) %]
<th>Home library</th>
<th>Holding library</th>
[% END %]
<th>Collection</th>
<th>Call number</th>
<th>Copy number</th>
<th>Vol no.</th>
<th>Information</th>
</tr>
</thead>
<tbody>
[% FOREACH item IN items %]
<tr>
<td>
[% IF item.can_be_recalled( patron => patron ) %]
<input type="radio" class="itemnumber" name="itemnumber" value="[% item.itemnumber | html %]" />
[% ELSE %]
<span class="error">
<i class="fa fa-times fa-lg" title="Cannot be recalled"></i>
</span>
[% END %]
</td>
<td>[% ItemTypes.GetDescription( item.effective_itemtype ) | html %]</td>
<td>[% item.barcode | html %]</td>
[% UNLESS ( single_branch_mode ) %]
<td>[% Branches.GetName( item.homebranch ) | html %]</td>
<td>[% Branches.GetName( item.holdingbranch) | html %]</td>
[% END %]
<td>[% AuthorisedValues.GetByCode( 'CCODE', item.ccode, 1 ) | html %]</td>
<td>[% item.itemcallnumber | html %]</td>
<td>[% item.copynumber | html %]</td>
<td>[% item.enumchron | html %]</td>
<td>
[% IF ( item.checkout ) %]
<span class="checkedout">Due [% item.checkout.date_due | $KohaDates %]</span>
[% ELSIF ( item.get_transfer ) %]
<span class="intransit"
>In transit from [% Branches.GetName( item.get_transfer.frombranch ) | html %] to [% Branches.GetName( item.get_transfer.tobranch ) | html %] since
[% item.get_transfer.datesent | $KohaDates %]</span
>
[% END %]
[% IF ( item.itemlost || item.withdrawn ) %]
<span class="lost">Unavailable (lost or withdrawn)</span>
[% END %]
[% IF ( item.notforloan ) %]
<span class="notforloan">Not for loan ([% item.notforloan | html %])</span>
[% END %]
[% hold = item.current_holds.next %]
[% IF ( item.recall ) %]
<span class="waiting">
[% 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 %]
</span>
[% ELSIF ( hold.waitingdate ) %]
<span class="waiting"> Waiting for patron at [% Branches.GetName( hold.branchcode ) | html %] since [% hold.waitingdate | $KohaDates %]. </span>
[% ELSIF ( hold.borrowernumber == logged_in_user.borrowernumber ) %]
<span class="waiting"> Patron has already placed a <a href="/cgi-bin/koha/circ/circulation.pl?borrowernumber=[% patron.borrowernumber | uri %]#reserves">hold</a> on this item. </span>
[% END # / IF ( item.recall or hold ) %]
</td>
</tr>
[% END # FOREACH item IN items %]
</tbody>
</table>
<fieldset class="action disabled recall_specific">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="op" value="cud-request" />
<input type="hidden" name="biblionumber" value="[% biblio.biblionumber | html %]" />
[% IF error %]
<input type="submit" class="btn btn-primary" value="Place recall" disabled="disabled" />
[% ELSE %]
<input type="submit" class="btn btn-primary" value="Place recall" />
[% END %]
<a href="/cgi-bin/koha/recalls/request.pl?biblionumber=[% biblio.biblionumber | uri %]" class="cancel">Cancel</a>
</fieldset>
</fieldset>
</form>
[% ELSE # patron %]
[% IF Koha.Preference('UseRecalls') == 'opac' %]
<div class="alert alert-warning"> Due to the <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=UseRecalls">UseRecalls</a> system preference, recalls cannot be placed through the staff interface. </div>
[% ELSE %]
<fieldset>
<h2>Search patrons</h2>
[% WRAPPER tabs id= "circ_recalls_select" %]
[% WRAPPER tabs_nav %]
[% WRAPPER tab_item tabname= "recalls_patronsearch_pane" bt_active= 1 %]<span>Patrons</span>[% 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 %]
</fieldset>
[% END %]
<div class="page-section">
<h2>Existing recalls</h2>
[% IF Koha.Preference('UseRecalls') != "off" %]
[% IF recalls.count %]
<div class="page-section">
<form method="post" action="/cgi-bin/koha/recalls/request.pl">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="op" value="cud-cancel_multiple_recalls" />
<input type="hidden" name="biblionumber" value="[% biblio.biblionumber | html %]" />
<input type="checkbox" id="select_all" /> <span id="select_all_text">Select all</span>
[% INCLUDE 'recalls.inc' %]
<fieldset class="action">
<button type="submit" id="cancel_selected" class="btn btn-default btn-sm">Cancel selected recalls</button>
</fieldset>
</form>
</div>
<!-- /.page-section -->
[% ELSE %]
<div class="alert alert-info">No recalls have been made.</div>
[% END %]
[% ELSE %]
<div class="alert alert-info">Recalls have not been enabled. Enable the <a href="/cgi-bin/koha/admin/preferences.pl?tab=circulation">UseRecalls</a> system preference to use recalls.</div>
[% END %]
</div>
[% 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' %]
<script nonce="[% Koha.CSPNonce | $raw %]">
$(document).ready(function () {
$("#recalls_patronsearch").on("submit", filter);
});
</script>
[% END %]
<script nonce="[% Koha.CSPNonce | $raw %]" data-tt-allowed="true">
const apply_patron_autocomplete = [% !patron && PatronAutoComplete ? 1 : 0 | html %];
const url_biblio_params = '[% url_biblio_params | url %]';
</script>
<script nonce="[% Koha.CSPNonce | $raw %]">
$.fn.select2.defaults.set("width", "100%");
$.fn.select2.defaults.set("dropdownAutoWidth", true);
$(document).ready(function () {
if (apply_patron_autocomplete) {
patron_autocomplete($(".search_patron_filter"), { "link-to": "recall", "url-params": url_biblio_params });
}
$("#items").dataTable(
$.extend(true, {}, dataTablesDefaults, {
paginate: false,
dom: '<"top pager"ilf>t',
})
);
//Override fieldset styling for dataTables search box
$("div.top.pager").css("margin-left", "1em");
$(".dataTables_filter label").css({
width: "auto",
"margin-right": "0em",
});
$(".recall_specific").click(function () {
$(".recall_specific").removeClass("disabled").addClass("enabled");
$(".recall_next").removeClass("enabled").addClass("disabled");
});
$(".recall_next").click(function () {
$(".recall_next").removeClass("disabled").addClass("enabled");
$(".recall_specific").removeClass("enabled").addClass("disabled");
});
});
</script>
[% END %]
[% INCLUDE 'intranet-bottom.inc' %]

View File

@ -327,6 +327,14 @@
<p>No club with this name, please, try another</p>
</div>
[% END %]
[% IF ( recallerror ) %]
<div class="alert alert-warning">
<h3>Hold cannot be converted to a recall</h3>
<p>A recall cannot be placed for one or more holds. Check your <a href="/cgi-bin/koha/admin/smart-rules.pl">circulation and fine rules</a>.</p>
</div>
[% END %]
<fieldset>
[% UNLESS multi_hold %]
[% IF clubcount %]
@ -1383,6 +1391,11 @@
</ul>
</div>
[% END %]
[% IF Koha.Preference('UseRecalls').match('staff') %]
<div class="btn-group">
<button class="btn convert_selected_holds" data-bulk="true"></button>
</div>
[% END %]
</div>
<div class="page-section">
[% 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' %]
<div id="moveHoldItemModal" class="modal modal-lg" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@ -1599,6 +1614,7 @@
},
[% END %][% END %][% END %]
};
var UseRecalls = [% ( Koha.Preference('UseRecalls').match('staff') ) ? 1 : 0 | html %];
const active_tab = [% clubs ? 1 : 0 | html %];

View File

@ -756,9 +756,13 @@ $(document).ready(function () {
});
var MSG_MOVE_SELECTED = __("Move selected (%s)");
var MSG_RECALL_SELECTED = __("Convert selected to recall (%s)");
var MSG_CANCEL_ALERT = __(
"This action will cancel <span class='badge bg-danger'>%s</span> hold(s)."
);
var MSG_CONVERT_ALERT = __(
"This action will cancel <span class='badge bg-danger'>%s</span> hold(s) and create <span class='badge bg-danger'>%s</span> recall(s)."
);
// Confirm cancellation of hold
let cancel_link;
@ -820,10 +824,9 @@ $(document).ready(function () {
var hasSelectedHolds = selectedHolds.length > 0;
var hasMultipleSelectedHolds = selectedHolds.length >= 2;
$(".cancel_selected_holds, .suspend_selected_holds").prop(
"disabled",
!hasSelectedHolds
);
$(
".cancel_selected_holds, .suspend_selected_holds, .convert_selected_holds"
).prop("disabled", !hasSelectedHolds);
$(".group_selected_holds").prop("disabled", !hasMultipleSelectedHolds);
}
@ -876,6 +879,26 @@ $(document).ready(function () {
)
.join(",") +
"]";
$("#convert_hold_alert").html(
MSG_CONVERT_ALERT.format(
$(".holds_table .select_hold:checked").length,
$(".holds_table .select_hold:checked").length
)
);
$("#convert_hold_alert").show();
localStorage.selectedHolds =
"[" +
$(".holds_table .select_hold:checked")
.toArray()
.map(el =>
JSON.stringify({
hold: $(el).data("reserve_id"),
borrowernumber: $(el).data("borrowernumber"),
biblionumber: $(el).data("biblionumber"),
})
)
.join(",") +
"]";
});
$(".holds_table").on("click", ".select_hold", function () {
@ -935,6 +958,38 @@ $(document).ready(function () {
}
return false;
});
$(".convert_selected_holds").click(function (e) {
e.preventDefault();
if ($(".holds_table .select_hold:checked").length) {
$("#convert_to_recall_modal_form #inputs").empty();
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="op" value="cud-convertall">'
);
$(
"#convert_to_recall_modal_form #modal-cancellation-reason"
).val("RECALLED");
let hold_data =
"[" +
$(".holds_table .select_hold:checked")
.toArray()
.map(el =>
JSON.stringify({
hold: $(el).data("reserve_id"),
borrowernumber: $(el).data("borrowernumber"),
biblionumber: $(el).data("biblionumber"),
})
)
.join(",") +
"]";
JSON.parse(hold_data).forEach(function (hold) {
_append_patron_page_convert_hold_modal_data(hold);
});
delete localStorage.selectedHolds;
$("#convertModal").modal("show");
}
return false;
});
}
$("#itemSearchForm").on("submit", function (event) {
event.preventDefault();
@ -1906,9 +1961,13 @@ async function load_patron_holds_table(biblio_id, split_data) {
function updateMSGCounters() {
var MSG_CANCEL_SELECTED = __("Cancel selected (%s)");
var MSG_MOVE_SELECTED = __("Move selected (%s)");
var MSG_RECALL_SELECTED = __("Convert selected to recall (%s)");
var MSG_CANCEL_ALERT = __(
"This action will cancel <span class='badge bg-danger'>%s</span> hold(s)."
);
var MSG_CONVERT_ALERT = __(
"This action will cancel <span class='badge bg-danger'>%s</span> hold(s) and create <span class='badge bg-danger'>%s</span> recall(s)."
);
var selectedCount = JSON.parse(
localStorage.selectedHolds || "[]"
).length;
@ -1921,10 +1980,18 @@ async function load_patron_holds_table(biblio_id, split_data) {
$("#cancel_hold_alert").html(
MSG_CANCEL_ALERT.format(selectedCount)
);
$("#convert_hold_alert").html(
MSG_CONVERT_ALERT.format(selectedCount, selectedCount)
);
$(".convert_selected_holds").html(
MSG_RECALL_SELECTED.format(selectedCount)
);
if (selectedCount > 0) {
$("#cancel_hold_alert").show();
$("#convert_hold_alert").show();
} else {
$("#cancel_hold_alert").hide();
$("#convert_hold_alert").hide();
}
}
@ -2076,6 +2143,29 @@ async function load_patron_holds_table(biblio_id, split_data) {
}
return false;
});
$(".convert_selected_holds").click(function (e) {
e.preventDefault();
const selectedHolds = JSON.parse(
localStorage.selectedHolds || "[]"
);
if (selectedHolds.length) {
$("#convert_to_recall_modal_form #inputs").empty();
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="op" value="cud-convertall">'
);
$(
"#convert_to_recall_modal_form #modal-cancellation-reason"
).val("RECALLED");
selectedHolds.forEach(function (hold) {
_append_patron_page_convert_hold_modal_data(hold);
});
//delete localStorage.selectedHolds;
$("#convertModal").modal("show");
}
return false;
});
function _append_patron_page_cancel_hold_modal_data(hold) {
$("#cancel_modal_form #inputs").append(
'<input type="hidden" name="rank-request" value="del">'
@ -2096,6 +2186,26 @@ async function load_patron_holds_table(biblio_id, split_data) {
'">'
);
}
function _append_patron_page_convert_hold_modal_data(hold) {
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="rank-request" value="recall">'
);
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="biblionumber" value="' +
hold.biblionumber +
'">'
);
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="borrowernumber" value="' +
hold.borrowernumber +
'">'
);
$("#convert_to_recall_modal_form #inputs").append(
'<input type="hidden" name="reserve_id" value="' +
hold.hold +
'">'
);
}
// Remove any previously attached handlers
$("#cancelModalConfirmBtn").off("click");
// Attach the handler to the button

View File

@ -845,7 +845,9 @@ function patron_autocomplete(node, options) {
? "/cgi-bin/koha/circ/circulation.pl"
: link_to == "reserve"
? "/cgi-bin/koha/reserve/request.pl"
: "/cgi-bin/koha/members/moremember.pl";
: link_to == "recall"
? "/cgi-bin/koha/recalls/request.pl"
: "/cgi-bin/koha/members/moremember.pl";
item.link +=
(url_params ? "?" + url_params + "&" : "?") +
"borrowernumber=" +

View File

@ -35,30 +35,139 @@ my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
}
);
my $op = $input->param('op') || 'list';
my @recall_ids = $input->multi_param('recall_ids');
my $biblionumber = $input->param('biblionumber');
my $recalls = Koha::Recalls->search( { biblio_id => $biblionumber, completed => 0 } );
my $biblio = Koha::Biblios->find($biblionumber);
my $op = $input->param('op') || 'list';
my @recall_ids = $input->multi_param('recall_ids');
my $biblionumber = $input->param('biblionumber');
my $biblio = Koha::Biblios->find($biblionumber);
my $borrowernumber = $input->param('borrowernumber');
my $error = $input->param('error');
if ( $op eq 'cud-cancel_multiple_recalls' ) {
foreach my $id (@recall_ids) {
Koha::Recalls->find($id)->set_cancelled;
}
$op = 'list';
print $input->redirect( '/cgi-bin/koha/recalls/request.pl?biblionumber=' . $biblionumber );
} elsif ( $op eq 'cud-request' ) {
if ( C4::Context->preference('UseRecalls') =~ m/staff/
and $borrowernumber )
{
my $patron = Koha::Patrons->find($borrowernumber);
unless ( $biblio->can_be_recalled( { patron => $patron } ) ) { $error = 'unavailable'; }
my $items = Koha::Items->search( { biblionumber => $biblionumber } )->as_list;
# check if already recalled
my $recalled = $biblio->recalls->filter_by_current->search( { patron_id => $borrowernumber } )->count;
if ( defined $recalled and $recalled > 0 ) {
my $recalls_per_record = Koha::CirculationRules->get_effective_rule(
{
categorycode => $patron->categorycode,
branchcode => undef,
itemtype => undef,
rule_name => 'recalls_per_record'
}
);
if ( defined $recalls_per_record
and $recalls_per_record->rule_value
and $recalled >= $recalls_per_record->rule_value )
{
$error = 'duplicate';
}
}
if ( !defined $error ) {
my $pickuploc = $input->param('pickup');
my $expdate = $input->param('expirationdate');
my $level = $input->param('type');
my $itemnumber = $input->param('itemnumber');
my ( $recall, $due_interval, $due_date );
if ( defined $level and defined $itemnumber ) {
my $item = Koha::Items->find($itemnumber);
if ( $item->can_be_recalled( { patron => $patron } ) ) {
( $recall, $due_interval, $due_date ) = Koha::Recalls->add_recall(
{
patron => $patron,
biblio => $biblio,
branchcode => $pickuploc,
item => $item,
expirationdate => $expdate,
interface => 'staff',
}
);
} else {
$error = 'cannot';
}
} else {
if ( $biblio->can_be_recalled( { patron => $patron } ) ) {
( $recall, $due_interval, $due_date ) = Koha::Recalls->add_recall(
{
patron => $patron,
biblio => $biblio,
branchcode => $pickuploc,
expirationdate => $expdate,
interface => 'staff',
}
);
} else {
$error = 'cannot';
}
}
if ( !defined $recall ) {
$error = 'failed';
} else {
# successful recall, go back to Recalls tab
print $input->redirect("/cgi-bin/koha/recalls/request.pl?biblionumber=$biblionumber");
}
}
}
}
if ( $op eq 'list' ) {
$recalls = Koha::Recalls->search( { biblio_id => $biblionumber, completed => 0 } );
$biblio = Koha::Biblios->find($biblionumber);
if ($borrowernumber) {
my $patron = Koha::Patrons->find($borrowernumber);
if ( C4::Context->preference('UseRecalls') =~ m/staff/
and $patron
and $patron->borrowernumber )
{
if ( !$biblio->can_be_recalled( { patron => $patron } ) ) {
$error = 1;
}
my $patron_holds_count = Koha::Holds->search(
{ borrowernumber => $borrowernumber, biblionumber => $biblio->biblionumber, item_level_hold => 0 } )->count;
$template->param(
patron => $patron,
patron_holds_count => $patron_holds_count,
);
}
}
my $recalls = Koha::Recalls->search( { biblio_id => $biblionumber, completed => 0 } );
my $branches = Koha::Libraries->search;
my $single_branch_mode = $branches->count == 1;
my $items = Koha::Items->search( { biblionumber => $biblionumber } );
$template->param(
recalls => $recalls,
recallsview => 1,
biblio => $biblio,
checkboxes => 1,
C4::Search::enabled_staff_search_views,
branches => $branches,
items => $items,
error => $error,
single_branch_mode => $single_branch_mode,
attribute_type_codes => (
C4::Context->preference('ExtendedPatronAttributes')
? [ Koha::Patron::Attribute::Types->search( { staff_searchable => 1 } )->get_column('code') ]
: []
),
);
output_html_with_http_headers $input, $cookie, $template->output;

View File

@ -46,6 +46,7 @@ my @branch = $query->multi_param('pickup');
my @itemnumber = $query->multi_param('itemnumber');
my @biblionumber = $query->multi_param('biblionumber');
my $count = @rank;
my $recallerror;
@biblionumber = uniq @biblionumber;
@ -99,6 +100,41 @@ if ( $op eq 'cud-cancelall' || $op eq 'cud-modifyall' ) {
foreach my $biblio_id (@biblio_ids) {
FixPriority( { biblionumber => $biblio_id } );
}
} elsif ( $op eq 'cud-convertall' ) {
for ( my $i = 0 ; $i < $count ; $i++ ) {
undef $itemnumber[$i] if !$itemnumber[$i];
my $cancellation_reason = $query->param("cancellation-reason");
my $params = {
rank => $rank[$i],
reserve_id => $reserve_id[$i],
expirationdate => $expirationdates[$i] || undef,
branchcode => $branch[$i],
itemnumber => $itemnumber[$i],
cancellation_reason => $cancellation_reason,
};
try {
ModReserve($params);
} catch {
if ( $_->isa('Koha::Exceptions::ObjectNotFound') ) {
warn $_;
} else {
$_->rethrow;
}
};
my $hold_still_exists = Koha::Holds->find( $reserve_id[$i] );
if ($hold_still_exists) {
$recallerror = 1;
}
}
my @biblio_ids = uniq @biblionumber;
Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( { biblio_ids => \@biblio_ids } )
if C4::Context->preference('RealTimeHoldsQueue');
foreach my $biblio_id (@biblio_ids) {
FixPriority( { biblionumber => $biblio_id } );
}
}
my $from = $query->param('from');
@ -109,6 +145,10 @@ if ( $from eq 'borrower' ) {
print $query->redirect("/cgi-bin/koha/circ/circulation.pl?borrowernumber=$borrower[0]");
} else {
my $url = URI->new("/cgi-bin/koha/reserve/request.pl");
$url->query_form( biblionumber => [@biblionumber] );
if ($recallerror) {
$url->query_form( biblionumber => [@biblionumber], recallerror => 1 );
} else {
$url->query_form( biblionumber => [@biblionumber] );
}
print $query->redirect($url);
}

View File

@ -806,6 +806,11 @@ $template->param( pickup => $pickup || C4::Context->userenv->{branch} );
$template->param( borrowernumber => $borrowernumber_hold );
$template->param( failed_holds => \@failed_holds, successful_biblionumbers => $successful_biblionumbers );
my $recallerror = $input->param('recallerror');
if ($recallerror) {
$template->param( recallerror => 1 );
}
# printout the page
output_html_with_http_headers $input, $cookie, $template->output;

View File

@ -7,7 +7,7 @@ use t::lib::TestBuilder;
use C4::Context;
use Test::More tests => 74;
use Test::More tests => 75;
use Test::NoWarnings;
use Test::Exception;
@ -2151,3 +2151,98 @@ subtest 'current_holds with future holds' => sub {
$schema->storage->txn_rollback;
};
subtest 'ModReserve to convert a hold to a recall' => sub {
plan tests => 2;
$schema->storage->txn_begin;
my $category = $builder->build( { source => 'Category' } );
my $branch = $builder->build( { source => 'Branch' } )->{branchcode};
my $biblio = $builder->build_sample_biblio( { itemtype => 'DUMMY' } );
my $itype = $builder->build( { source => 'Itemtype' } );
my $item = $builder->build_sample_item(
{ library => $branch, biblionumber => $biblio->biblionumber, itype => $itype->{itemtype} } );
my $patron = Koha::Patron->new(
{
firstname => 'my firstname',
surname => 'whatever surname',
categorycode => $category->{categorycode},
branchcode => $branch,
email => 'name@email.com',
}
)->store;
my $borrowernumber = $patron->borrowernumber;
my $patron2 = Koha::Patron->new(
{
firstname => 'my firstname',
surname => 'whatever surname',
categorycode => $category->{categorycode},
branchcode => $branch,
email => 'name2@email.com',
}
)->store;
t::lib::Mocks::mock_preference( "UseRecalls", 1 );
t::lib::Mocks::mock_userenv( { branchcode => $branch } );
C4::Circulation::AddIssue( $patron2, $item->barcode );
Koha::CirculationRules->set_rules(
{
branchcode => undef,
categorycode => undef,
itemtype => undef,
rules => {
recalls_allowed => 5,
recalls_per_record => 1,
on_shelf_recalls => 'any',
},
}
);
my $notice = Koha::Notice::Template->new(
{
name => 'Hold cancellation',
module => 'reserves',
code => 'HOLD_CANCELLATION',
title => 'Hold cancelled',
content => 'Your hold was cancelled.',
message_transport_type => 'email',
branchcode => q{},
}
)->store();
my $reserve_id = AddReserve(
{
branchcode => $branch,
borrowernumber => $borrowernumber,
biblionumber => $biblio->biblionumber,
priority => C4::Reserves::CalculatePriority( $biblio->biblionumber ),
itemnumber => $item->id,
}
);
my $hold = Koha::Holds->find($reserve_id);
my $before_recalls = Koha::Recalls->search->filter_by_current->count;
ModReserve(
{
reserve_id => $hold->id,
expirationdate => '1981-06-10',
priority => 99,
rank => "recall",
itemnumber => $item->id,
}
);
$hold = Koha::Holds->find($reserve_id);
my $after_recalls = Koha::Recalls->search->filter_by_current->count;
ok( !defined $hold, 'Hold was cancelled' );
is( $after_recalls, $before_recalls + 1, 'Recall count increased by 1 as hold was converted to a recall' );
$schema->storage->txn_rollback;
};