From 86c8119917fde01a91740ac3e43a00c565ce584b Mon Sep 17 00:00:00 2001
From: Emmi Takkinen <emmi.takkinen@koha-suomi.fi>
Date: Fri, 8 Mar 2024 13:46:29 +0200
Subject: [PATCH 3/4] Bug 36135: Add new tool to batch modify holds

We sometimes need to modify holds due library closing down etc.
For this we need tool to batch modify holds which allows us
to filter holds and modify them.

Tool allows users to select holds with following parameters:
 - expiration date
    -> if patron_expiration_date is set, it is used as main search filter
    -> otherwise expirationdate is used
 - library
 - found status
 - suspended status
 - suspend until date
 - hold note

Users are able to modify following  with tool:
 - expiration date (not expiration date set by patron)
 - pick up library
 - suspend status (only if hold has no found status)
 - suspend until date (only if suspend status is set)
 - hold note

To test:
1. Apply this patch.
2. Navigate to new tool Tools->Batch modify holds.
3. Fill in some criteria to search holds with.
=> Confirm that holds found match your search criteria.
4. Set e.g. new expiration date for all or just selected holds.
=> Confirm that holds were modified as they should have.
=> Confirm this also from database.

To test suspending holds with found status:
1. Find holds with found status.
2. Attempt to modify their suspend status.
=> Alert box with text "One or more holds have found status
and can't be suspended." should be displayed.

Play around, test different search compinations and modifications.

Sponsored-by: Koha-Suomi Oy
---
 .../en/modules/tools/batch_modify_holds.tt    | 313 ++++++++++++++++++
 tools/batch_modify_holds.pl                   | 175 ++++++++++
 2 files changed, 488 insertions(+)
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/tools/batch_modify_holds.tt
 create mode 100755 tools/batch_modify_holds.pl

diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/batch_modify_holds.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/batch_modify_holds.tt
new file mode 100644
index 0000000000..7705c36beb
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/batch_modify_holds.tt
@@ -0,0 +1,313 @@
+[% USE raw %]
+[% USE Asset %]
+[% SET footerjs = 1 %]
+[% USE Branches %]
+[% USE Categories %]
+[% USE KohaDates %]
+[% USE ItemTypes %]
+[% PROCESS 'html_helpers.inc' %]
+
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Batch modify holds &rsaquo; Tools &rsaquo; Koha</title>
+[% INCLUDE 'doc-head-close.inc' %]
+[% Asset.css("css/humanmsg.css") | $raw %]
+</head>
+
+<body id="tools_batch_extend_due_dates" class="tools">
+    [% WRAPPER 'header.inc' %]
+    [% INCLUDE 'cat-search.inc' %]
+[% END %]
+
+    [% WRAPPER 'sub-header.inc' %]
+    <nav id="breadcrumbs" aria-label="Breadcrumb" class="breadcrumb">
+        <ol>
+            <li>
+                <a href="/cgi-bin/koha/mainpage.pl">Home</a>
+            </li>
+            <li>
+                <a href="/cgi-bin/koha/tools/tools-home.pl">Tools</a>
+            </li>
+            <li>
+                <a href="#" aria-current="page">Batch modify holds</a>
+            </li>
+        </ol>
+    </nav>
+    [% END %]
+
+    <div class="main container-fluid">
+        <div class="row">
+            <div class="col-sm-10 col-sm-push-2">
+                <main>
+
+                    <h1>Batch modify holds</h1>
+
+                    [% IF view == 'form' %]
+                        <form method="post" action="/cgi-bin/koha/tools/batch_modify_holds.pl" id="modify_holds_form">
+                            <fieldset class="rows">
+                                <legend>Hold search criteria:</legend>
+                                <span class="hint">NOTE! Column patron_expiration_date is used as a main filter for expiration date field. Otherwise column expirationdate is used.</span>
+                                <ol>
+                                    <li>
+                                        <label for="suspend_until">Expiration date:</label>
+                                        <li>
+                                            <label for="expirationdate_from">From</label>
+                                            <input type="text" size="10" id="from" name="expirationdate_from" class="flatpickr" data-date_to="to" />
+                                        </li>
+                                        <li>
+                                            <label for="expirationdate_to">To</label>
+                                            <input type="text" size="10" id="to" name="expirationdate_to" class="flatpickr"/>
+                                        </li>
+                                    </li>
+                                    <li>
+                                        <label for="branchcodes">Libraries:</label>
+                                        <select name="branchcodes" id="branchcodes" multiple="multiple">
+                                            [% PROCESS options_for_libraries libraries => Branches.all() %]
+                                        </select>
+                                    </li>
+                                    <li>
+                                        <label for="found_status">Found status:</label>
+                                        <select name="found_status" id="found_status" multiple="multiple">
+                                            <option value="NULL">No status</option>
+                                            <option value="T">In transit</option>
+                                            <option value="P">In processing</option>
+                                            <option value="W">Waiting</option>
+                                        </select>
+                                    </li>
+                                    <li>
+                                        <label for="suspend_status">Suspended:</label>
+                                        <select name="suspend_status" id="suspend_status">
+                                            <option value="none" selected="selected"></option>
+                                            <option value="0">Not suspended</option>
+                                            <option value="1">Suspended</option>
+                                        </select>
+                                    </li>
+                                    <li>
+                                        <label for="suspend_until">Suspended until:</label>
+                                        <li>
+                                            <label for="suspend_until_from">From</label>
+                                            <input type="text" size="10" id="from" name="suspend_until_from" class="flatpickr" data-date_to="to" />
+                                        </li>
+                                        <li>
+                                            <label for="suspend_until_to">To</label>
+                                            <input type="text" size="10" id="to" name="suspend_until_to" class="flatpickr"/>
+                                        </li>
+                                    </li>
+                                    <li>
+                                        <label for="reservenotes">Hold note:</label>
+                                        <input type="text" id="reservenotes" name="reservenotes"/>
+                                    </li>
+                                </ol>
+                            </fieldset>
+                            <fieldset class="action">
+                                <input type="hidden" name="op" value="list"/>
+                                <input type="submit" class="btn btn-primary" value="Continue"/>
+                                <a class="cancel" href="/cgi-bin/koha/tools/tools-home.pl">Cancel</a>
+                            </fieldset>
+                        </form> <!-- /#modify_holds_form -->
+                    [% ELSIF view == 'list' %]
+                        <form action="/cgi-bin/koha/tools/batch_modify_holds.pl" method="post" id="process">
+                        [% IF holds.count %]
+                            <div class="page-section">
+                                <div class="btn-toolbar selections-toolbar">
+                                    <a id="selectall" href="#"><i class="fa fa-check"></i> Select all</a>
+                                    <a id="clearall" href="#"><i class="fa fa-remove"></i> Clear all</a>
+                                </div>
+                                <table id="found_holds">
+                                    <thead>
+                                        <tr>
+                                            <th>&nbsp;</th>
+                                            <th>Expiration date</th>
+                                            <th>Patron expiration date</th>
+                                            <th>Status</th>
+                                            <th>Hold pickup library</th>
+                                            <th>Suspended</th>
+                                            <th>Suspended until</th>
+                                            <th>Note</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        [% FOREACH hold IN holds %]
+                                            <tr>
+                                                <td><input type="checkbox" name="hold_id" value="[% hold.reserve_id | html %]"/></td>
+                                                <td>[% hold.expirationdate | $KohaDates %]</td>
+                                                <td>[% hold.patron_expiration_date | $KohaDates %]</td>
+                                                <td class="found_status" data-found="[% hold.found | html %]">
+                                                    [% IF hold.found == "T" %]
+                                                        In transit
+                                                    [% ELSIF hold.found == "P" %]
+                                                        In processing
+                                                    [% ELSIF hold.found == "W" %]
+                                                        Waiting
+                                                    [% ELSE %]
+                                                        No status
+                                                    [% END %]
+                                                </td>
+                                                <td>[% Branches.GetName( hold.branchcode ) | html %]</td>
+                                                <td>[% IF hold.suspend %]Yes[% ELSE %]No[% END %]</td>
+                                                <td>[% hold.suspend_until | $KohaDates %]</td>
+                                                <td>[% hold.reservenotes | html %]</td>
+                                            </tr>
+                                        [% END %]
+                                    </tbody>
+                                </table> <!-- /#holds -->
+                            </div> <!-- /.page-section -->
+                            <h2>Modify holds</h2>
+                            <table id="hold_modifies">
+                                <thead>
+                                    <tr>
+                                        <th>New expiration date</th>
+                                        <th>New pickup library</th>
+                                        <th>Suspend holds</th>
+                                        <th>Suspend until</th>
+                                        <th>New reserve note</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>
+                                            <input type="text" id="new_expiration_date" name="new_expiration_date" class="flatpickr" data-flatpickr-futuredate="true"/>
+                                        </td>
+                                        <td>
+                                            <select name="new_pickup_loc" id="new_pickup_loc">
+                                                [% PROCESS options_for_libraries libraries => Branches.pickup_locations({ selected => hold.branchcode }) %]
+                                            </select>
+                                        </td>
+                                        <td>
+                                            <select id="new_suspend_status" name="new_suspend_status">
+                                                <option value="" selected="selected"></option>
+                                                <option value="not_suspended">Not suspended</option>
+                                                <option value="suspend">Suspend</option>
+                                            </select>
+                                        </td>
+                                        <td>
+                                            <input type="text" id="new_suspend_date" name="new_suspend_date" class="flatpickr" data-flatpickr-futuredate="true"/>
+                                        </td>
+                                        <td>
+                                           <input type="text" id="new_reserve_note" name="new_reserve_note"/>
+                                        </td>
+                                    </tr>
+                                </tbody>
+                            </table> <!-- /#hold_modifies -->
+                            <fieldset class="action">
+                                <input type="hidden" name="op" value="modify"/>
+                                <input type="submit" class="btn btn-primary" value="Modify selected holds"/>
+                                <a class="cancel" href="/cgi-bin/koha/tools/batch_modify_holds.pl">Cancel</a>
+                            </fieldset>
+                        </form> <!-- /#process -->
+                        [% ELSE %]
+                            <div class="dialog message">
+                                No holds were found for the selected filters.
+                            </div>
+                            <a class="cancel" href="/cgi-bin/koha/tools/batch_modify_holds.pl">Return</a>
+                        [% END %]
+                    [% ELSIF view == 'report' %]
+                        <div class="dialog message">
+                            Holds have been modified!
+                        </div>
+                        <div class="page-section">
+                            <div class="btn-toolbar selections-toolbar">
+                                <a id="selectall" href="#"><i class="fa fa-check"></i> Select all</a>
+                                <a id="clearall" href="#"><i class="fa fa-remove"></i> Clear all</a>
+                            </div>
+                            <table id="holds">
+                                <thead>
+                                    <tr>
+                                        <th>Expiration date</th>
+                                        <th>Hold pickup library</th>
+                                        <th>Suspended</th>
+                                        <th>Suspended until</th>
+                                        <th>Note</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    [% FOREACH hold IN updated_holds %]
+                                        <tr>
+                                            <td>[% hold.expirationdate | $KohaDates %]</td>
+                                            <td>[% Branches.GetName( hold.branchcode ) | html %]</td>
+                                            <td>[% IF hold.suspend == 0 %]No[% ELSE %]Yes[% END %]</td>
+                                            <td>[% hold.suspend_until | $KohaDates %]</td>
+                                            <td>[% hold.reservenotes | html %]</td>
+                                        </tr>
+                                    [% END %]
+                                </tbody>
+                            </table> <!-- /#holds -->
+                        </div> <!-- /.page-section -->
+                        <a class="cancel" href="/cgi-bin/koha/tools/batch_modify_holds.pl">Return to batch hold modification</a>
+                    [% END %]
+                </main>
+            </div> <!-- /.col-sm-10 col-sm-push-2 -->
+
+
+            <div class="col-sm-2 col-sm-pull-10">
+                <aside>
+                    [% INCLUDE 'tools-menu.inc' %]
+                </aside>
+            </div> <!-- /.col-sm-2 col-sm-pull-10 -->
+        </div> <!-- /.row -->
+
+[% MACRO jsinclude BLOCK %]
+    [% Asset.js("js/tools-menu.js") | $raw %]
+    [% INCLUDE 'calendar.inc' %]
+    [% INCLUDE 'datatables.inc' %]
+    [% Asset.js("lib/jquery/plugins/humanmsg.js") | $raw %]
+    <script>
+        $(document).ready(function() {
+
+            $("#selectall").click(function(e){
+                e.preventDefault();
+                $("#found_holds input[type='checkbox']").each(function(){
+                    $(this).prop("checked", true);
+                });
+            });
+
+            $("#clearall").click(function(e){
+                e.preventDefault();
+                $("#found_holds input[type='checkbox']").each(function(){
+                    $(this).prop("checked", false);
+                });
+            });
+
+            $("#selectall").click();
+
+            $("#found_holds").dataTable($.extend(true, {}, dataTablesDefaults, {
+                "aoColumnDefs": [
+                    { "aTargets": [0], "bSortable": false, "bSearchable": false },
+                ],
+                "sDom": 't',
+                "aaSorting": [],
+                "bPaginate": false
+            }));
+
+            $("#process").on('submit', function(e) {
+                var reserve_ids = $("input[type=checkbox][name='hold_id']:checked");
+
+                var new_suspend_status = $("#new_suspend_status").val();
+                var new_suspend_date = $("#new_suspend_date").val();
+
+                if ( reserve_ids.length == 0 ) {
+                    e.preventDefault();
+                    alert(_("Please select at least one hold to process."));
+                    return false;
+                }
+                if( new_suspend_status ){
+                    reserve_ids.each(function(){
+                        if($(this).parents("tr").children(".found_status").data("found") != ""){
+                            e.preventDefault();
+                            alert(_("One or more holds have found status and can't be suspended."));
+                            return false;
+                        }
+                    })
+                }
+                if( ( !new_suspend_status || new_suspend_status == "not_suspended") && new_suspend_date ){
+                    e.preventDefault();
+                    alert(_('You have to suspend holds if new suspend until date is set.'));
+                    return false;
+                }
+
+                return true;
+            });
+        });
+    </script>
+[% END %]
+
+[% INCLUDE 'intranet-bottom.inc' %]
\ No newline at end of file
diff --git a/tools/batch_modify_holds.pl b/tools/batch_modify_holds.pl
new file mode 100755
index 0000000000..ddc08e3adc
--- /dev/null
+++ b/tools/batch_modify_holds.pl
@@ -0,0 +1,175 @@
+#!/usr/bin/perl
+
+# This file is part of Koha.
+#
+# Copyright 2024 Koha Development Team
+#
+# 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;
+
+use C4::Auth qw( get_template_and_user );
+use C4::Output qw( output_html_with_http_headers );
+
+use Koha::DateUtils qw( dt_from_string );
+use Koha::Holds;
+
+my $input = CGI->new;
+my $op = $input->param('op') // q|form|;
+
+my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
+    {
+        template_name   => 'tools/batch_modify_holds.tt',
+        query           => $input,
+        type            => "intranet",
+        flagsrequired   => { tools => 'batch_modify_holds.tt' },
+    }
+);
+
+my @hold_ids;
+
+if ( $op eq 'form' ) {
+    $template->param( view => 'form', );
+}
+elsif ( $op eq 'list' ) {
+
+    my @reserve_ids         = $input->multi_param('reserve_ids');
+    my $expirationdate_from = $input->param('expirationdate_from');
+    my $expirationdate_to   = $input->param('expirationdate_to');
+    my @branchcodes         = $input->multi_param('branchcodes');
+    my @found_status        = $input->multi_param('found_status');
+    my $suspend_status      = $input->param('suspend_status');
+    my $suspend_until_from  = $input->param('suspend_until_from');
+    my $suspend_until_to    = $input->param('suspend_until_to');
+    my $reservenotes        = $input->param('reservenotes');
+
+    my $search_params;
+
+    if( @reserve_ids ){
+        $search_params->{'me.reserve_id'} = { -in => \@reserve_ids };
+    } else {
+        if( $expirationdate_from && $expirationdate_to ){
+            my $expirationdate_params = { -or => {
+                patron_expiration_date => {
+                    -between => [
+                        $expirationdate_from,
+                        $expirationdate_to
+                    ]
+                },
+                expirationdate => {
+                    -between => [
+                        $expirationdate_from,
+                        $expirationdate_to
+                    ]
+                }
+            }};
+
+            $search_params = $expirationdate_params;
+        }
+
+        if ( @branchcodes ) {
+            $search_params->{'me.branchcode'} = { -in => \@branchcodes };
+        }
+
+        if ( @found_status ) {
+            my $found_params = {};
+            # if NULL is used as filter, use it as "is" param
+            if( grep {$_ eq "NULL"} @found_status ) {
+                $found_params->{'-or'}{'-is'} = undef;
+                $found_params->{'-or'}{'-in'} = \@found_status;
+            } else {
+                $found_params = { -in => \@found_status };
+            }
+
+            $search_params->{'me.found'} = $found_params;
+        }
+
+        if( $suspend_status ne "none" ){
+            $search_params->{'me.suspend'} = $suspend_status;
+        }
+
+        if( $suspend_until_from && $suspend_until_to ){
+            $search_params->{'me.suspend_until'} = {
+                -between => [
+                    $suspend_until_from,
+                    $suspend_until_to,
+                ]
+            };
+        }
+
+        if( $reservenotes ){
+            $search_params->{'me.reservenotes'} = { -like => "%".$reservenotes."%" }
+        }
+    }
+
+    my $holds = Koha::Holds->search( $search_params );
+
+    $template->param(
+        holds => $holds,
+        view  => 'list',
+    );
+}
+
+if ( $op eq 'modify' ) {
+
+    my $new_expiration_date = $input->param('new_expiration_date');
+    my $new_pickup_loc      = $input->param('new_pickup_loc');
+    my $new_suspend_status  = $input->param('new_suspend_status');
+    my $new_suspend_date    = $input->param('new_suspend_date');
+    my $new_reserve_note    = $input->param('new_reserve_note');
+
+    @hold_ids = $input->multi_param('hold_id');
+
+    my $holds_to_update = Koha::Holds->search( { reserve_id => { -in => \@hold_ids } } );
+
+    while ( my $hold = $holds_to_update->next ) {
+
+        if( $new_expiration_date ){
+            $hold->expirationdate($new_expiration_date)->store;
+        }
+
+        unless( $hold->branchcode eq $new_pickup_loc){
+            $hold->branchcode($new_pickup_loc)->store;
+        }
+
+        if( $new_suspend_status && !$hold->is_found ){
+            if( $new_suspend_status eq "suspend" ){
+                $hold->suspend(1)->store;
+                if( $new_suspend_date ){
+                    $hold->suspend_until( $new_suspend_date )->store;
+                } else {
+                    $hold->suspend_until( undef )->store;
+                }
+            } else {
+                $hold->suspend(0)->store;
+                $hold->suspend_until( undef )->store;
+            }
+        }
+
+        if( $new_reserve_note ){
+            $hold->reservenotes($new_reserve_note)->store;
+        }
+    }
+
+    $template->param(
+        updated_holds => $holds_to_update,
+        view          => 'report',
+    );
+
+}
+
+output_html_with_http_headers $input, $cookie, $template->output;
-- 
2.34.1