From 0c2f42911746c2738f6d10daca6dc89d026a1702 Mon Sep 17 00:00:00 2001 From: Lucas Gass Date: Wed, 12 Mar 2025 21:31:35 +0000 Subject: [PATCH] Bug 31698: Add ability to move holds to new items or new records Test plan: 1. APPLY PATCH, updatedatabase, restart_all 2. Have two staff members. 1. Superlibrarian, 2. Staff with new permission to `alter_hold_targets` 3. Now place some holds on different record, you'll want some item level holds and some record level holds. 4. After placing some holds go to a record with some holds on it. 5. Go to the holds tab (reserve/request.p) 6. You should notice a disabled 'Move selected (0)' button, next to 'Cancel selected (0)' 7. Select some holds to move, ensure that the number updates accordingly. ( Move selected (2) ) 8. When you click the button you should see 2 options, 'Item level holds to a different item' and 'Record level holds to a different record' 9. Clicking on either should bring up a modal with information about the holds you selected to move. 10. If you have item level holds and you select 'Item level holds to a different item' those holds should appear pre-checked. 11. If you have item level holds and you select 'Record level holds to a different record' those holds checkboxes should be disabled by default, you should see the 'Problem' as 'Cannot move a waiting or record level hold' 12. If you are moving a item level hold you should see a searchbar labeled 'Enter the item barcode of new hold target:'. 13. Enter a full or partial barcode and click search 14. You should see your item, if it exists. 15. Check 'Move all selected item level holds to this item', once you do so the yellow 'Move selected holds' button should be enabled. 16. Click 'Move selected holds', the hold should be moved. 17. You should see a message like " Moved the following holds: Hold ID: 1854 - Moved from The Arden edition of the works of William Shakespeare. to The Arden edition of the works of William Shakespeare." 19. Do steps 12 - 17 again, but with a record level hold. 20. Now make or have many holds on a specific target record to test priority. Make note of the current priority 21. Now move a hold on to that record, testing both item and record level holds. 22. Now on the target record review the priority. The holds originally on the record should not have changed. The hold you move should now have the lowest priority on the record. 23. Check the original record, if a hold was moved all other priority should have been adjusted correctly. FOr example, if the hold you moved was 3 of 4 then the others should have adjusted priority of 1 2 3 now. 24. Turn on HoldsLog if it is disabled. 25. Move some holds, both item and record ones, and review the logs after each one. There should be a correct MODIFY entry for each move. 26. Make an item with a hold on it as damaged and turn on AllowHoldsOnDamagedItems. Try to move the hold, you should get an error telling you that the target item is damaged. 27. Now login as the non-superlibrarian patron from step 2. If they have the `alter_hold_targets` permission they should see the 'Move selected' button. 28. Revoke the `alter_hold_targets` permission. The button should not appear now. --- Koha/Hold.pm | 61 ++++ .../prog/en/includes/holds_table.inc | 10 +- .../prog/en/modules/reserve/request.tt | 295 ++++++++++++++++++ reserve/request.pl | 58 +++- 4 files changed, 421 insertions(+), 3 deletions(-) diff --git a/Koha/Hold.pm b/Koha/Hold.pm index b3bf4a02b76..f76c0189580 100644 --- a/Koha/Hold.pm +++ b/Koha/Hold.pm @@ -178,6 +178,67 @@ sub delete { return $deleted; } +=head3 move_hold + +$hold->move_hold(); + +=cut + +sub move_hold { + my ( $self, $args ) = @_; + + my $original = $self; + my $original_biblionumber = $self->biblionumber; + + my $patron = Koha::Patrons->find( { borrowernumber => $self->borrowernumber } ); + return { success => 0, error => 'Missing patron or target' } unless $patron; + + my ( $new_biblionumber, $new_itemnumber, $item, $canReserve ); + + if ( $args->{new_itemnumber} ) { + $item = Koha::Items->find( { itemnumber => $args->{new_itemnumber} } ) + or return { success => 0, error => 'Target item not found' }; + + $new_itemnumber = $item->itemnumber; + $new_biblionumber = $item->biblionumber; + + $canReserve = C4::Reserves::CanItemBeReserved( $patron, $item, $self->branchcode, { ignore_hold_counts => 1 } ); + } elsif ( $args->{new_biblionumber} ) { + $new_biblionumber = $args->{new_biblionumber}; + + $canReserve = C4::Reserves::CanBookBeReserved( + $patron->borrowernumber, $new_biblionumber, $self->branchcode, + { ignore_hold_counts => 1 } + ); + } else { + return { success => 0, error => 'Missing itemnumber or biblionumber' }; + } + + return { success => 0, error => $canReserve->{status} } + unless $canReserve->{status} eq 'OK'; + + my $max = Koha::Holds->search( + { biblionumber => $new_biblionumber }, + { order_by => { -desc => 'priority' }, rows => 1 } + )->next; + my $base_priority = $max ? $max->priority : 0; + my $priority = $base_priority + 1; + my $new_priority = C4::Reserves::_ShiftPriority( $new_biblionumber, $priority ); + + $self->update( + { + itemnumber => $new_itemnumber, # undef for biblio-level is fine + biblionumber => $new_biblionumber, + priority => $new_priority, + } + ); + + C4::Log::logaction( 'HOLDS', 'MODIFY', $self->reserve_id, $self, undef, $original ) + if C4::Context->preference('HoldsLog'); + + return { success => 1, hold => $self }; +} + =head3 set_transfer =cut diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc index 2d65e83dc1f..0d26304999e 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc @@ -49,7 +49,15 @@ [%- END -%] [% SET tr_class = hold.suspend ? 'suspend' : '' %] - + 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 3979fad94fe..6e82b1bfdcd 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt @@ -105,6 +105,9 @@ :disabled { opacity: 0.5; } + #toolbar { + align-items: center; + } [% END %] @@ -171,6 +174,60 @@ [% END %] + [% IF ( hold_move_successes ) %] +
+

Moved the following holds:

+ +
+ [% END %] + [% IF ( hold_move_failures ) %] +
+ One or more holds were not moved due to following errors: + +
+ [% END %] + [% IF ( failed_holds ) %]
One or more holds were not placed due to following errors: @@ -1235,6 +1292,15 @@ [% END %] + [% IF ( CAN_user_reserveforothers_alter_hold_targets ) %] + + [% END %]
[% FOREACH biblioloo IN biblioloop %] @@ -1416,6 +1482,104 @@
+ +
[% INCLUDE 'csrf-token.inc' %]
@@ -1468,6 +1632,7 @@ var MSG_CANCEL_SELECTED = _("Cancel selected (%s)"); var MSG_CANCEL_ALERT = _("This action will cancel %s hold(s)."); + var MSG_MOVE_SELECTED = _("Move selected (%s)"); $.fn.select2.defaults.set("width", "100%" ); $.fn.select2.defaults.set("dropdownAutoWidth", true ); @@ -1731,6 +1896,91 @@ } $(document).ready(function() { + $("#itemSearchForm").on("submit", function (event) { + event.preventDefault(); + + let externalID = $("#external_id").val(); + let apiUrl = `/api/v1/items?external_id=${encodeURIComponent(externalID)}`; + + $.ajax({ + url: apiUrl, + method: "GET", + dataType: "json", + success: function (data) { + if (data.length > 0) { + let resultHtml = ""; + $.each(data, function (index, item) { + resultHtml += ` +
+ Biblionumber: ${item.biblio_id}
+ Item: ${item.external_id}
+ + + +
+
+ `; + }); + $("#itemResultMessage").html(resultHtml); + } else { + $("#itemResultMessage").html(` +
No item found with barcode: ${externalID}.
+ `); + } + }, + }); + }); + + $("#biblioSearchForm").on("submit", function (event) { + event.preventDefault(); + + let biblioID = $("#biblio_id").val(); + let apiUrl = `/api/v1/items?q={"biblio_id":"${encodeURIComponent(biblioID)}"}`; + + $.ajax({ + url: apiUrl, + method: "GET", + dataType: "json", + success: function (data) { + if (data.length > 0) { + let resultHtml = ""; + $.each(data, function (index, item) { + resultHtml += ` +
+ Biblionumber: ${item.biblio_id}
+ + +
+
+ `; + }); + $("#biblioResultMessage").html(resultHtml); + } else { + $("#biblioResultMessage").html(` +
No item found with barcode: ${biblioID}.
+ `); + } + }, + }); + }); + + $(document).on("change", 'input[name="new_itemnumber"]', function() { + $('input[name="new_itemnumber"]').not(this).prop("checked", false); + if ( $('input[name="new_itemnumber"]:checked').length ){ + $('#move_hold_item_confirm').show(); + } else { + $('#move_hold_item_confirm').hide(); + } + }); + + $(document).on("change", 'input[name="new_biblionumber"]', function() { + $('input[name="new_biblionumber"]').not(this).prop("checked", false); + if ( $('input[name="new_biblionumber"]:checked').length ){ + $('#move_hold_biblio_confirm').show(); + } else { + $('#move_hold_biblio_confirm').hide(); + } + }); $("#always_show_holds").change(function(){ if( $(this).prop('checked') ){ @@ -1834,6 +2084,7 @@ }); $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + $('.move_selected_holds').html(MSG_MOVE_SELECTED.format($('.holds_table .select_hold:checked').length)); $('.holds_table .select_hold_all').click(function() { var table = $(this).parents('.holds_table'); @@ -1841,6 +2092,7 @@ $('.select_hold', table).prop('checked', !count); $(this).prop('checked', !count); $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + $('.move_selected_holds').html(MSG_MOVE_SELECTED.format($('.holds_table .select_hold:checked').length)); $('#cancel_hold_alert').html( MSG_CANCEL_ALERT.format($('.holds_table .select_hold:checked').length)); $('#cancel_hold_alert').show(); localStorage.selectedHolds = $('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')); @@ -1851,11 +2103,54 @@ var count = $('.select_hold:not(:checked)', table).length; $('.select_hold_all', table).prop('checked', !count); $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + $('.move_selected_holds').html(MSG_MOVE_SELECTED.format($('.holds_table .select_hold:checked').length)); $('#cancel_hold_alert').html( MSG_CANCEL_ALERT.format($('.holds_table .select_hold:checked').length)); $('#cancel_hold_alert').show(); localStorage.selectedHolds = $('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')); }); + $('.move_hold_item').click(function(e) { + e.preventDefault(); + if($('.holds_table .select_hold:checked').length) { + $('#itemResultMessage').empty(); + $('#move_hold_item_selection table tbody').empty(); + $('#moveHoldItemModal').modal('show'); + $('.select_hold:checked').each( function() { + let reserve_id = $(this).data('id'); + let reserve_biblionumber = $(this).data('biblionumber'); + let reserve_itemnumber = $(this).data('itemnumber'); + let item_level_hold = $(this).data('item_level_hold'); + let error_message = $(this).data('item_level_hold') ? "" : _("Cannot move a waiting or record level hold"); + let found_status = $(this).data('found'); + $('#move_hold_item_selection table').append(`${reserve_id}Biblionumber: ${reserve_biblionumber} Itemnumber: ${reserve_itemnumber}${error_message}`) + if ( item_level_hold ) { + $('#move_hold_item_form').append(``); + } + }); + } + }); + + $('.move_hold_record').click(function(e) { + e.preventDefault(); + if($('.holds_table .select_hold:checked').length) { + $('#biblioResultMessage').empty(); + $('#move_hold_item_selection table tbody').empty(); + $('#moveHoldBiblioModal').modal('show'); + $('.select_hold:checked').each( function() { + let reserve_id = $(this).data('id'); + let reserve_biblionumber = $(this).data('biblionumber'); + let reserve_itemnumber = $(this).data('itemnumber'); + let item_level_hold = $(this).data('item_level_hold'); + let error_message = $(this).data('item_level_hold') ? _("Cannot move a waiting or item level hold") : ""; + let found_status = $(this).data('found'); + $('#move_hold_item_selection table').append(`${reserve_id}Biblionumber: ${reserve_biblionumber}${error_message}`) + if ( !item_level_hold ) { + $('#move_hold_biblio_form').append(``); + } + }); + } + }); + $('.cancel_selected_holds').click(function(e) { e.preventDefault(); if($('.holds_table .select_hold:checked').length) { diff --git a/reserve/request.pl b/reserve/request.pl index 5908ab692b7..30142834f39 100755 --- a/reserve/request.pl +++ b/reserve/request.pl @@ -44,6 +44,7 @@ use C4::Search qw( enabled_staff_search_views ); use Koha::Biblios; use Koha::Checkouts; use Koha::Holds; +use Koha::Hold; use Koha::CirculationRules; use Koha::Items; use Koha::ItemTypes; @@ -90,6 +91,7 @@ my $exceeded_maxreserves; my $exceeded_holds_per_record; my @failed_holds = $input->multi_param('failed_holds'); my $form_submitted = $input->param('form_submitted'); +my @biblionumbers = $input->multi_param('biblionumber'); my $op = $input->param('op') || q{}; @@ -109,6 +111,60 @@ if ( $op eq 'cud-move' ) { $next_priority, $first_priority, $last_priority ); } +} elsif ( $op eq 'cud-move_hold_item' or $op eq 'cud-move_hold_biblio' ) { + my @hold_ids = $input->multi_param('hold_id'); + my $original_biblionumber = $input->param('original_biblionumber'); + my @moving_holds = Koha::Holds->search( + { reserve_id => \@hold_ids }, + { order_by => { -asc => 'priority' } } + )->as_list; + + my $new_biblionumber = $input->param('new_biblionumber'); + my $target_biblio = Koha::Biblios->find($new_biblionumber); + + my %args = ( + new_biblionumber => $new_biblionumber, + ); + + # Only pass new_itemnumber if this is an item level move + if ( $op eq 'cud-move_hold_item' ) { + $args{new_itemnumber} = $input->param('new_itemnumber'); + } + + my @success_messages; + my @error_messages; + + foreach my $hold (@moving_holds) { + my $original_biblio = Koha::Biblios->find( $hold->biblionumber ); + + my $result = $hold->move_hold( \%args ); + + if ( $result->{success} ) { + push @success_messages, { + hold_id => $hold->id, + success => 1, + original_biblio => $original_biblio, + target_biblio => $target_biblio, + }; + } else { + push @error_messages, { + hold_id => $hold->id, + success => 0, + original_biblio => $original_biblio, + target_biblio => $target_biblio, + error => $result->{error}, + }; + } + } + + #Fix the priority on the original record + C4::Reserves::_FixPriority( { biblionumber => $original_biblionumber } ); + + $template->param( + hold_move_successes => \@success_messages, + hold_move_failures => \@error_messages, + ); + } elsif ( $op eq 'cud-cancel' ) { my $reserve_id = $input->param('reserve_id'); my $cancellation_reason = $input->param("cancellation-reason"); @@ -172,8 +228,6 @@ if ($form_submitted) { } } -my @biblionumbers = $input->multi_param('biblionumber'); - my $multi_hold = @biblionumbers > 1; $template->param( multi_hold => $multi_hold, -- 2.39.5