creates "reset reason entries" whenever a hold has been reset. Records
authorLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Wed, 18 Aug 2021 17:13:47 +0000 (13:13 -0400)
committerLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Tue, 9 May 2023 13:21:27 +0000 (09:21 -0400)
previous copy, requesting user/ws, reset time and reset reason. reset
reasons are stored in their own table and referenced in the perl
constants file. Hold reset reason entries can be inspected in the staff client by viewing
the "hold details" within a patron's profile. Includes adocs and an
automated test for manual reset reasons.

signed-off-by: Llewellyn Marshall <llewellyn.marshall@ncdcr.gov>

12 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm
Open-ILS/src/perlmods/lib/OpenILS/Const.pm
Open-ILS/src/perlmods/live_t/lp2012669-hold-reset-reasons.t [new file with mode: 0644]
Open-ILS/src/sql/Pg/090.schema.action.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/xxxx.hold_reset_reasons.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2
Open-ILS/web/js/ui/default/staff/circ/services/holds.js
docs/modules/circulation/pages/basic_holds.adoc

index 6c517fa..fce878e 100644 (file)
@@ -6820,6 +6820,7 @@ SELECT  usr,
                        <field reporter:label="Requesting Library" name="request_lib" reporter:datatype="org_unit"/>
                        <field reporter:label="Request Date/Time" name="request_time" reporter:datatype="timestamp"/>
                        <field reporter:label="Requesting User" name="requestor" reporter:datatype="link"/>
+            <field reporter:label="Reset Entries" name="reset_entries" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Item Selection Depth" name="selection_depth" />
                        <field reporter:label="Selection Locus" name="selection_ou" reporter:datatype="org_unit"/>
                        <field reporter:label="Target Object ID" name="target" reporter:datatype="link"/>
@@ -6855,6 +6856,7 @@ SELECT  usr,
                        <link field="request_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="transit" reltype="might_have" key="hold" map="" class="ahtc"/>
                        <link field="notifications" reltype="has_many" key="hold" map="" class="ahn"/>
+                       <link field="reset_entries" reltype="has_many" key="hold" map="" class="ahrrre"/>
                        <link field="eligible_copies" reltype="has_many" key="hold" map="target_copy" class="ahcm"/>
                        <link field="bib_rec" reltype="might_have" key="id" map="" class="rhrr"/>
                        <link field="cancel_cause" reltype="might_have" key="id" map="" class="ahrcc"/>
@@ -15510,6 +15512,47 @@ SELECT  usr,
              <field reporter:label="Transaction Closed?" name="xact_closed" sr:suggest_filter="true" reporter:datatype="bool"/>
         </fields>
     </class>
+    
+       <class id="ahrrr" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::hold_request_reset_reason" oils_persist:tablename="action.hold_request_reset_reason" reporter:label="Hold Request Reset Reason" oils_persist:field_safe="true">
+               <fields oils_persist:primary="id" oils_persist:sequence="action.hold_request_reset_reason_id_seq">
+                       <field name="id" reporter:selector="name" reporter:datatype="id" reporter:label="ID"/>
+                       <field name="name"  reporter:datatype="text" oils_persist:i18n="true" reporter:label="Name"/>
+            <field name="manual" reporter:datatype="bool" reporter:label="Manual"/>
+               </fields>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
+       </class>
+
+       <class id="ahrrre" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::hold_request_reset_reason_entry" oils_persist:tablename="action.hold_request_reset_reason_entry" reporter:label="Hold Request Reset Reason Entry" oils_persist:field_safe="true">
+               <fields oils_persist:primary="id" oils_persist:sequence="action.hold_request_reset_reason_entry_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID"/>
+            <field name="hold" reporter:label="ID" reporter:datatype="link"/>
+                       <field name="reset_reason" reporter:label="ID" reporter:datatype="link"/>
+            <field name="reset_time" reporter:datatype="timestamp" reporter:label="Reset Date/Time"/>
+                       <field name="note" reporter:datatype="text" oils_persist:i18n="true" reporter:label="Note"/>
+            <field name="requestor" reporter:label="Resetting User" reporter:datatype="link"/>
+            <field name="requestor_workstation" reporter:label="Resetting User Workstation" reporter:datatype="link"/>
+            <field name="previous_copy" reporter:label="Previous Copy" reporter:datatype="link"/>
+               </fields>
+               <links>
+            <link field="hold" reltype="has_a" key="id" map="" class="ahr"/>        
+            <link field="reset_reason" reltype="has_a" key="id" map="" class="ahrrr"/>        
+            <link field="requestor" reltype="has_a" key="id" map="" class="au"/>    
+            <link field="requestor_workstation" reltype="has_a" key="id" map="" class="aws"/>            
+            <link field="previous_copy" reltype="has_a" key="id" map="" class="acp"/>        
+        </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                               <retrieve permission="VIEW_HOLD">
+                    <context link="pickup_lib" field="hold"/>
+                </retrieve>
+            </actions>
+        </permacrud>
+       </class>
+
 
        <class id="coauf" 
                controller="open-ils.cstore open-ils.pcrud"
index 8d34d7c..46795a2 100644 (file)
@@ -1868,14 +1868,22 @@ sub handle_checkout_holds {
     
         $logger->info("circulator: un-targeting hold ".$hold->id.
             " because copy ".$copy->id." is getting checked out");
-
+        try {
+            $U->simplereq('open-ils.circ', 
+            'open-ils.circ.hold_reset_reason_entry.create',
+            $e->authtoken,
+            $hold->id,
+            OILS_HOLD_CHECK_OUT,
+            "Checked out to patron #".$patron->id);
+        } catch Error with {
+            $logger->error("circulate: create reset reason failed with ".shift());
+        };
         $hold->clear_prev_check_time; 
         $hold->clear_current_copy;
         $hold->clear_capture_time;
         $hold->clear_shelf_time;
         $hold->clear_shelf_expire_time;
         $hold->clear_current_shelf_lib;
-
         return $self->bail_on_event($e->event)
             unless $e->update_action_hold_request($hold);
 
@@ -2713,6 +2721,13 @@ sub checkin_retarget {
                 next if ($_->{hold_type} eq 'P');
             }
             # So much for easy stuff, attempt a retarget!
+            try{
+                $U->simplereq('open-ils.circ', 
+                'open-ils.circ.hold_reset_reason_entry.create',$self->editor->authtoken, $_->{id},OILS_HOLD_BETTER_HOLD);
+            }
+            catch Error with{
+                $logger->error("circulate: create reset reason failed with ".shift());
+            };
             my $tresult = $U->simplereq(
                 'open-ils.hold-targeter',
                 'open-ils.hold-targeter.target', 
@@ -3369,7 +3384,14 @@ sub attempt_checkin_hold_capture {
     $hold->clear_expire_time; 
     $hold->clear_cancel_time;
     $hold->clear_prev_check_time unless $hold->prev_check_time;
-
+    
+    try{
+        $U->simplereq('open-ils.circ', 
+        'open-ils.circ.hold_reset_reason_entry.create',$self->editor->authtoken, $hold->id, OILS_HOLD_CHECK_IN);
+    }
+    catch Error with{
+        $logger->error("circulate: create reset reason failed with ".shift());
+    };
     $self->bail_on_events($self->editor->event)
         unless $self->editor->update_action_hold_request($hold);
     $self->hold($hold);
@@ -3513,7 +3535,14 @@ sub retarget_holds {
     my $self = shift;
     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
     my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
-    $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
+    $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});   
+    try{
+        my $cses = OpenSRF::AppSession->create('open-ils.circ');
+        $cses->request('open-ils.circ.hold_reset_reason_entry.create',$self->editor->authtoken, $self->retarget,OILS_HOLD_BETTER_HOLD);
+    }
+    catch Error with{
+        $logger->error("circulate: create reset reason failed with ".shift());
+    };
     # no reason to wait for the return value
     return;
 }
index 6e9878b..2240083 100644 (file)
@@ -1038,13 +1038,12 @@ sub uncancel_hold {
     $hold->clear_prev_check_time;
     $hold->clear_shelf_expire_time;
     $hold->clear_current_shelf_lib;
-
+    _create_reset_reason_entry($e,$hold,OILS_HOLD_UNCANCELED);
     $e->update_action_hold_request($hold) or return $e->die_event;
     $e->commit;
-
+        
     $U->simplereq('open-ils.hold-targeter',
         'open-ils.hold-targeter.target', {hold => $hold_id});
-
     return 1;
 }
 
@@ -1072,11 +1071,13 @@ sub cancel_hold {
 
     my $e = new_editor(authtoken=>$auth, xact=>1);
     return $e->die_event unless $e->checkauth;
-
+    
+    my $req = $e->requestor->id;
+    
     my $hold = $e->retrieve_action_hold_request($holdid)
         or return $e->die_event;
 
-    if( $e->requestor->id ne $hold->usr ) {
+    if( $req ne $hold->usr ) {
         return $e->die_event unless $e->allowed('CANCEL_HOLDS');
     }
 
@@ -1120,6 +1121,21 @@ sub cancel_hold {
     $hold->cancel_time('now');
     $hold->cancel_cause($cause);
     $hold->cancel_note($note);
+    my $note_body = "";
+    if($cause){
+        my $cancel_reason = "ID $cause";
+        my $cancel_cause = $e->retrieve_action_hold_request_cancel_cause($cause);
+        if($cancel_cause){
+            $cancel_reason = $cancel_cause->label;
+        }       
+        $note_body .= "Cancel Cause: $cancel_reason";       
+    }
+    else{
+        $note_body .= "Cancel reason unknown";
+    }
+    $note_body .= "," unless $note_body eq "" || $note eq "";
+    $note_body .= " Cancel Note: \"$note\"" unless $note eq "";
+    _create_reset_reason_entry($e,$hold,OILS_HOLD_CANCELED,$note_body);
     $e->update_action_hold_request($hold)
         or return $e->die_event;
 
@@ -1220,6 +1236,8 @@ sub update_hold_impl {
     my($self, $e, $hold, $values) = @_;
     my $hold_status;
     my $need_retarget = 0;
+    my $reset_reason = OILS_HOLD_UPDATED;
+    my $note_body = "";
 
     unless($hold) {
         $hold = $e->retrieve_action_hold_request($values->{id})
@@ -1231,9 +1249,13 @@ sub update_hold_impl {
                 if (defined $values->{$k} && defined $hold->$k() && $values->{$k} ne $hold->$k()) {
                     # Value changed? RETARGET!
                     $need_retarget = 1;
+                    $reset_reason = OILS_HOLD_UPDATED;
+                    $note_body .= "$k value changed."
                 } elsif (defined $hold->$k() != defined $values->{$k}) {
                     # Value being set or cleared? RETARGET!
                     $need_retarget = 1;
+                    $reset_reason = OILS_HOLD_UPDATED;
+                    $note_body .= "$k value cleared."
                 }
             }
             if (defined $values->{$k}) {
@@ -1339,9 +1361,10 @@ sub update_hold_impl {
               $hold->clear_current_copy;
         }
     }
-
+   
     if($U->is_true($hold->frozen)) {
         $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id);
+        _create_reset_reason_entry($e,$hold,OILS_HOLD_FROZEN,$note_body) unless $U->is_true($orig_hold->frozen);
         $hold->clear_current_copy;
         $hold->clear_prev_check_time;
         # Clear expire_time to prevent frozen holds from expiring.
@@ -1363,10 +1386,13 @@ sub update_hold_impl {
     }
 
     $e->update_action_hold_request($hold) or return $e->die_event;
+    _create_reset_reason_entry($e,$hold,$reset_reason,$note_body) if $need_retarget;    
     $e->commit;
-
+    
     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
         $logger->info("Running targeter on activated hold ".$hold->id);
+        $U->simplereq('open-ils.circ', 
+            'open-ils.circ.hold_reset_reason_entry.create',$e->authtoken,$hold->id,OILS_HOLD_UNFROZEN);
         $U->simplereq('open-ils.hold-targeter', 
             'open-ils.hold-targeter.target', {hold => $hold->id});
     }
@@ -1374,11 +1400,13 @@ sub update_hold_impl {
     # a change to mint-condition changes the set of potential copies, so retarget the hold;
     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
         _reset_hold($self, $e->requestor, $hold)
-    } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
+    } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes        
         $U->simplereq('open-ils.hold-targeter', 
             'open-ils.hold-targeter.target', {hold => $hold->id});
     }
-
+    
+   
+    
     return $hold->id;
 }
 
@@ -1464,6 +1492,7 @@ sub update_hold_if_frozen {
     } else {
         if($U->is_true($orig_hold->frozen)) {
             $logger->info("Running targeter on activated hold ".$hold->id);
+            _create_reset_reason_entry($e,$hold,OILS_HOLD_UNFROZEN,"Running targeter on activated hold");
             $U->simplereq('open-ils.hold-targeter', 
                 'open-ils.hold-targeter.target', {hold => $hold->id});
         }
@@ -2223,6 +2252,67 @@ sub reset_hold {
     return 1;
 }
 
+__PACKAGE__->register_method(
+    method   => 'create_reset_reason_entry',
+    api_name => 'open-ils.circ.hold_reset_reason_entry.create'
+);
+
+sub create_reset_reason_entry
+{
+    my($self, $conn, $auth, $hold, $reset_reason, $note, $previous_copy) = @_;
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    #checkauth to set the requestor (if available)
+    $e->checkauth;
+    my @holds;
+    if(ref $hold eq 'ARRAY'){
+        @holds = @{$hold};
+    }
+    else{
+        @holds = ($hold);
+    }
+    for my $holdid (@holds){
+        try{
+            my ($hold, $evt) = $U->fetch_hold($holdid);  
+            _create_reset_reason_entry(
+                $e, 
+                $hold, 
+                $reset_reason, 
+                $note, 
+                $previous_copy) 
+            unless $evt;
+        }
+        catch Error with{
+            $logger->error("holds: create reset reason failed with ".shift());
+        };
+    }
+    $e->commit;
+    return 1;
+}
+
+sub _create_reset_reason_entry
+{
+    my($e, $hold, $reset_reason,$note,$previous_copy) = @_;
+    return 1 unless _reset_reason_entry_flag();
+    my $ts = DateTime->now;
+    my $entry = Fieldmapper::action::hold_request_reset_reason_entry->new; 
+    $logger->info("Creating reset reason entry for hold #" . $hold->id);    
+    my $last_copy = defined $previous_copy ? $previous_copy : $hold->current_copy;
+    $entry->hold($hold->id);
+    $entry->reset_reason($reset_reason);
+    $entry->reset_time('now');
+    $entry->previous_copy($last_copy);
+    $entry->note($note) if defined $note;
+    $entry->requestor($e->requestor->id) if defined $e->requestor;
+    $entry->requestor_workstation($e->requestor->wsid)  if defined $e->requestor;
+    $e->create_action_hold_request_reset_reason_entry($entry) or return $e->die_event;
+    return 1;
+}
+
+sub _reset_reason_entry_flag
+{
+    my $do_ahrrre = $U->get_global_flag('circ.holds.create_reset_reason_entries');
+    return ($do_ahrrre and $U->is_true($do_ahrrre->enabled));
+}
 
 __PACKAGE__->register_method(
     method   => 'reset_hold_batch',
@@ -2253,11 +2343,9 @@ sub _reset_hold {
     my ($self, $reqr, $hold) = @_;
 
     my $e = new_editor(xact =>1, requestor => $reqr);
-
-    $logger->info("reseting hold ".$hold->id);
-
     my $hid = $hold->id;
-
+    $logger->info("reseting hold ".$hid." requestor was ".$reqr->usrname." (ID ".$reqr->id.")");
+    my $note_body = "";
     if( $hold->capture_time and $hold->current_copy ) {
 
         my $copy = $e->retrieve_asset_copy($hold->current_copy)
@@ -2265,6 +2353,7 @@ sub _reset_hold {
 
         if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
             $logger->info("setting copy to status 'reshelving' on hold retarget");
+            $note_body.=" set copy to status 'reshelving'.";
             $copy->status(OILS_COPY_STATUS_RESHELVING);
             $copy->editor($e->requestor->id);
             $copy->edit_date('now');
@@ -2281,6 +2370,7 @@ sub _reset_hold {
                     $logger->info("Aborting transit [$transid] on hold [$hid] reset...");
                     my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1, 1);
                     $logger->info("Transit abort completed with result $evt");
+                    $note_body.=" Transit abort completed with result $evt.";
                     unless ("$evt" eq 1) {
                         $e->rollback;
                         return $evt;
@@ -2290,6 +2380,7 @@ sub _reset_hold {
         }
     }
 
+    _create_reset_reason_entry($e,$hold,OILS_HOLD_MANUAL_RESET,$note_body);
     $hold->clear_capture_time;
     $hold->clear_current_copy;
     $hold->clear_shelf_time;
@@ -2297,8 +2388,9 @@ sub _reset_hold {
     $hold->clear_current_shelf_lib;
 
     $e->update_action_hold_request($hold) or return $e->die_event;
+   
     $e->commit;
-
+    
     $U->simplereq('open-ils.hold-targeter', 
         'open-ils.hold-targeter.target', {hold => $hold->id});
 
@@ -3509,6 +3601,7 @@ sub find_nearest_permitted_hold {
         next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want
         $logger->info("circulator: clearing current_copy and prev_check_time on hold ".
             $old_hold->id." after a better hold [".$best_hold->id."] was found");
+        _create_reset_reason_entry($editor,$old_hold,OILS_HOLD_BETTER_HOLD,"Old hold was reset. Last check time was ".$old_hold->prev_check_time.". a better hold [".$best_hold->id."] was found");
         $old_hold->clear_current_copy;
         $old_hold->clear_prev_check_time;
         $editor->update_action_hold_request($old_hold)
index afca2fc..ad7ed5b 100644 (file)
@@ -4,7 +4,9 @@ use warnings;
 use OpenILS::Application;
 use base qw/OpenILS::Application/;
 use OpenILS::Utils::HoldTargeter;
+use OpenILS::Const qw/:const/;
 use OpenSRF::Utils::Logger qw(:logger);
+use OpenSRF::EX qw(:try);
 
 __PACKAGE__->register_method(
     method    => 'hold_targeter',
@@ -80,7 +82,8 @@ sub hold_targeter {
     my $total = scalar(@hold_ids);
 
     $logger->info("targeter processing $total holds");
-
+    my $hold_ses = create OpenSRF::AppSession("open-ils.circ");
+    
     for my $hold_id (@hold_ids) {
         $count++;
 
@@ -96,6 +99,27 @@ sub hold_targeter {
             $logger->error($msg);
             $single->message($msg) unless $single->message;
         }
+        else{
+            try{
+                # create a TIMED_OUT reset reason 
+                # other types of resets are handled
+                # at their sources. 
+                $hold_ses->request(
+                    "open-ils.circ.hold_reset_reason_entry.create",
+                    $single->editor()->authtoken,
+                    $hold_id,
+                    OILS_HOLD_TIMED_OUT,
+                    undef,
+                    $single->{previous_copy_id}
+                ) unless defined 
+                            $args->{hold} || 
+                            $single->{previous_copy_id} == $single->hold->current_copy;
+            } catch Error with {
+                $logger->error(
+                    "hold-targeter: create reset reason failed with ".shift()
+                );          
+            }
+        }
 
         if (($count % $throttle) == 0) { 
             # Time to reply to the caller.  Return either the number
@@ -105,9 +129,10 @@ sub hold_targeter {
             $client->respond($res);
 
             $logger->info("targeted $count of $total holds");
-        }
+        }        
     }
-
+    $hold_ses->disconnect;
+    
     return undef;
 }
 
index 0ff4880..093f1a6 100644 (file)
@@ -133,6 +133,19 @@ econst OILS_PENALTY_INVALID_PATRON_ADDRESS => 29;
 
 econst OILS_BILLING_TYPE_NOTIFICATION_FEE => 9;
 
+# ---------------------------------------------------------------------
+# Hold reset reasons
+# ---------------------------------------------------------------------
+econst OILS_HOLD_TIMED_OUT => 1;
+econst OILS_HOLD_MANUAL_RESET => 2;
+econst OILS_HOLD_BETTER_HOLD => 3;
+econst OILS_HOLD_FROZEN => 4;
+econst OILS_HOLD_UNFROZEN => 5;
+econst OILS_HOLD_CANCELED => 6;
+econst OILS_HOLD_UNCANCELED => 7;
+econst OILS_HOLD_UPDATED => 8;
+econst OILS_HOLD_CHECK_OUT => 9;
+econst OILS_HOLD_CHECK_IN => 10;
 
 # ---------------------------------------------------------------------
 # Non Evergreen-specific constants
diff --git a/Open-ILS/src/perlmods/live_t/lp2012669-hold-reset-reasons.t b/Open-ILS/src/perlmods/live_t/lp2012669-hold-reset-reasons.t
new file mode 100644 (file)
index 0000000..6335dd9
--- /dev/null
@@ -0,0 +1,163 @@
+#!perl
+use strict;
+use warnings;
+
+use Test::More tests => 23;
+diag("Hold Reset Reason Tests");
+
+use OpenILS::Const qw/:const/;
+use OpenILS::Utils::TestUtils;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+my $script = OpenILS::Utils::TestUtils->new();
+my $U = 'OpenILS::Application::AppUtils';
+my $e = new_editor();
+use constant {
+    BR1_WORKSTATION => 'BR1-test-lp2012669-hold-reset-reasons.t',
+    BR1_ID => 4,
+    BR2_ID => 5,
+    HOLD_ID => 67,
+    CANCEL_CAUSE => '5',
+    CANCEL_NOTE => 'TEST NOTE',
+    REQ_ID => 1,
+};
+
+$script->bootstrap;
+$e->init;
+
+# Login as admin at BR1.
+my $authtoken = $script->authenticate({
+    username=>'admin',
+    password=>'demo123',
+    type=>'staff'
+});
+ok(
+    $script->authtoken,
+    'Have an authtoken'
+);
+
+# Register workstation.
+my $ws = $script->find_or_register_workstation(BR1_WORKSTATION, BR1_ID);
+ok(
+    ! ref $ws,
+    'Found or registered workstation'
+);
+
+# Logout.
+$script->logout();
+ok(
+    ! $script->authtoken,
+    'Successfully logged out'
+);
+
+# Login as admin at BR1 using the workstation.
+$authtoken = $script->authenticate({
+    username=>'admin',
+    password=>'demo123',
+    type=>'staff',
+    workstation => BR1_WORKSTATION
+});
+ok(
+    $script->authtoken,
+    'Have an authtoken'
+);
+
+# == Reseting Concerto hold 67.
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.reset',
+    $authtoken,
+    HOLD_ID
+);
+
+my $ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_MANUAL_RESET,"manual reset code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.cancel',
+    $authtoken,
+    HOLD_ID,
+    CANCEL_CAUSE
+);
+
+$ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_CANCELED,"cancel code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+is($ahrrre->note, "Cancel Cause: Staff forced","cancel cause appended to reset reason's note");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.uncancel',
+    $authtoken,
+    HOLD_ID
+);
+
+$ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_UNCANCELED,"cancel code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.update',
+    $authtoken,
+    undef,
+    {id => HOLD_ID, frozen => 1}
+);
+
+$ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_FROZEN,"frozen code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.update',
+    $authtoken,
+    undef,
+    {id => HOLD_ID, frozen => 0}
+);
+
+$ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_UNFROZEN,"unfrozen code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.update',
+    $authtoken,
+    undef,
+    {id => HOLD_ID, pickup_lib => BR2_ID}
+);
+
+$ahrrre = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}])->[0];
+is($ahrrre->reset_reason, OILS_HOLD_UPDATED,"hold updated code applied to reset reason");
+is($ahrrre->requestor, REQ_ID,"requestor ID applied to reset reason");
+is($ahrrre->requestor_workstation, $ws,"workstation applied to reset reason");
+
+$U->simplereq(
+    'open-ils.circ',
+    'open-ils.circ.hold.update',
+    $authtoken,
+    undef,
+    {id => HOLD_ID, pickup_lib => BR1_ID}
+);
+
+my $ahrrre_list = $e->search_action_hold_request_reset_reason_entry([{hold => HOLD_ID},{order_by =>[{class => 'ahrrre', field=> 'reset_time', direction => 'DESC'}]}]);
+
+#clean up all reset reasons
+$e->xact_begin;
+if ($ahrrre_list) {
+    for my $rr (@$ahrrre_list) {
+        next unless $rr;
+        $e->delete_action_hold_request_reset_reason_entry($rr);
+    }
+}
+$e->xact_commit;
\ No newline at end of file
index 711269e..d193b61 100644 (file)
@@ -1796,5 +1796,44 @@ CREATE TABLE action.batch_hold_event_map (
     hold                INT     NOT NULL REFERENCES action.hold_request (id) ON UPDATE CASCADE ON DELETE CASCADE
 );
 
+CREATE TABLE action.hold_request_reset_reason
+(
+    id          serial NOT NULL,
+    manual      boolean,
+    name        text,
+    CONSTRAINT hold_request_reset_reason_pkey PRIMARY KEY (id),
+    CONSTRAINT hold_request_reset_reason_name_key UNIQUE (name)
+);
+
+CREATE TABLE action.hold_request_reset_reason_entry
+(
+    id                      serial NOT NULL,
+    hold                    int,
+    reset_reason            int,
+    note                    text,
+    reset_time              timestamp with time zone,
+    previous_copy           bigint,
+    requestor               int,
+    requestor_workstation   int,
+    CONSTRAINT hold_request_reset_reason_entry_pkey PRIMARY KEY (id),
+    CONSTRAINT action_hold_request_reset_reason_entry_reason_fkey FOREIGN KEY (reset_reason)
+        REFERENCES action.hold_request_reset_reason (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,  
+    CONSTRAINT action_hold_request_reset_reason_entry_previous_copy_fkey FOREIGN KEY (previous_copy)
+        REFERENCES asset.copy (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_requestor_fkey FOREIGN KEY (requestor)
+        REFERENCES actor.usr (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_requestor_workstation_fkey FOREIGN KEY (requestor_workstation)
+        REFERENCES actor.workstation (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_hold_fkey FOREIGN KEY (hold)
+        REFERENCES action.hold_request (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED
+);
+
+CREATE INDEX ahrrre_hold ON action.hold_request_reset_reason_entry (hold);
+
 COMMIT;
 
index 35b2765..88df9e3 100644 (file)
@@ -23222,3 +23222,29 @@ VALUES (
         'cwst', 'label'
     )
 );
+
+-- Hold reset reasons
+
+INSERT INTO action.hold_request_reset_reason (id, name, manual) VALUES
+    (1,'HOLD_TIMED_OUT',false),
+    (2,'HOLD_MANUAL_RESET',true),
+    (3,'HOLD_BETTER_HOLD',false),
+    (4,'HOLD_FROZEN',true),
+    (5,'HOLD_UNFROZEN',true),
+    (6,'HOLD_CANCELED',true),
+    (7,'HOLD_UNCANCELED',true),
+    (8,'HOLD_UPDATED',true),
+    (9,'HOLD_CHECKED_OUT',true),
+    (10,'HOLD_CHECKED_IN',true);
+
+INSERT INTO config.global_flag (name, label, enabled)
+VALUES (
+    'circ.holds.create_reset_reason_entries',
+    oils_i18n_gettext(
+        'circ.holds.create_reset_reason_entries',
+        'Create reset reasons whenever a hold has been reset.',
+        'cgf',
+        'label'
+    ),
+    TRUE
+);
\ No newline at end of file
diff --git a/Open-ILS/src/sql/Pg/upgrade/xxxx.hold_reset_reasons.sql b/Open-ILS/src/sql/Pg/upgrade/xxxx.hold_reset_reasons.sql
new file mode 100644 (file)
index 0000000..42672af
--- /dev/null
@@ -0,0 +1,68 @@
+BEGIN;
+
+--SELECT evergreen.upgrade_deps_block_check('xxxx', :eg_version);
+
+CREATE TABLE action.hold_request_reset_reason
+(
+    id          serial NOT NULL,
+    manual      boolean,
+    name        text,
+    CONSTRAINT hold_request_reset_reason_pkey PRIMARY KEY (id),
+    CONSTRAINT hold_request_reset_reason_name_key UNIQUE (name)
+);
+
+INSERT INTO action.hold_request_reset_reason (id, name, manual) VALUES
+    (1,'HOLD_TIMED_OUT',false),
+    (2,'HOLD_MANUAL_RESET',true),
+    (3,'HOLD_BETTER_HOLD',false),
+    (4,'HOLD_FROZEN',true),
+    (5,'HOLD_UNFROZEN',true),
+    (6,'HOLD_CANCELED',true),
+    (7,'HOLD_UNCANCELED',true),
+    (8,'HOLD_UPDATED',true),
+    (9,'HOLD_CHECKED_OUT',true),
+    (10,'HOLD_CHECKED_IN',true);
+
+CREATE TABLE action.hold_request_reset_reason_entry
+(
+    id                      serial NOT NULL,
+    hold                    int,
+    reset_reason            int,
+    note                    text,
+    reset_time              timestamp with time zone,
+    previous_copy           bigint,
+    requestor               int,
+    requestor_workstation   int,
+    CONSTRAINT hold_request_reset_reason_entry_pkey PRIMARY KEY (id),
+    CONSTRAINT action_hold_request_reset_reason_entry_reason_fkey FOREIGN KEY (reset_reason)
+        REFERENCES action.hold_request_reset_reason (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,  
+    CONSTRAINT action_hold_request_reset_reason_entry_previous_copy_fkey FOREIGN KEY (previous_copy)
+        REFERENCES asset.copy (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_requestor_fkey FOREIGN KEY (requestor)
+        REFERENCES actor.usr (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_requestor_workstation_fkey FOREIGN KEY (requestor_workstation)
+        REFERENCES actor.workstation (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT action_hold_request_reset_reason_entry_hold_fkey FOREIGN KEY (hold)
+        REFERENCES action.hold_request (id) MATCH SIMPLE
+        ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED
+);
+
+CREATE INDEX ahrrre_hold ON action.hold_request_reset_reason_entry (hold);
+
+INSERT INTO config.global_flag (name, label, enabled)
+    VALUES (
+        'circ.holds.create_reset_reason_entries',
+        oils_i18n_gettext(
+            'circ.holds.create_reset_reason_entries',
+            'Create reset reasons whenever a hold has been reset.',
+            'cgf',
+            'label'
+        ),
+        TRUE
+    );
+
+COMMIT;
\ No newline at end of file
index ece4719..7da498a 100644 (file)
       [% l('Staff Notifications') %]
     </a>
   </li>
+  <li ng-class="{active : detail_tab == 'resets'}">
+    <a href ng-click="show_resets_tab()">
+      [% l('Reset Entries') %]
+    </a>
+  </li>
 </ul>
 <div class="tab-content">
   <div class="tab-pane active">
         </div>
       </div>
     </div><!-- notes tab content -->
+    
+<div ng-if="detail_tab == 'resets'">
+        <div class="btn-group column-picker">
+            <!-- change order -->           
+            <button type="button" class="btn btn-default" 
+              ng-click="reverse_reset_order()"
+              title="[% l('Reverse Order') %]">
+                <span class="glyphicon {{reverseResetOrder ? 'glyphicon-chevron-up' : 'glyphicon-chevron-down' }}"></span>
+            </button>
+            
+            <!-- first page -->
+            <button type="button" class="btn btn-default" 
+              
+              ng-class="{disabled : on_first_rs_page()}" 
+              ng-disabled="on_first_rs_page()"
+              ng-click="first_rs_page()"
+              title="[% l('Start') %]">
+                <span class="glyphicon glyphicon-fast-backward"></span>
+            </button>
+
+            <!-- previous page -->
+            <button type="button" class="btn btn-default" 
+      
+              ng-class="{disabled : on_first_rs_page()}"
+              ng-disabled="on_first_rs_page()"
+              ng-click="decrement_rs_page()"
+              title="[% l('Previous Page') %]">
+                <span class="glyphicon glyphicon-backward"></span>
+            </button>
+
+            <!-- next page -->
+            <!-- todo: paging needs a total count value to be fully functional -->
+            <button type="button" class="btn btn-default" 
+
+              ng-class="{disabled : !has_next_rs_page()}"
+              ng-disabled="!has_next_rs_page()"
+              ng-click="increment_rs_page()"
+              title="[% l('Next Page') %]">
+                <span class="glyphicon glyphicon-forward"></span>
+            </button>
+        </div>
+      <div ng-show="!resetsLoaded" style="text-align:center;">      
+        <img src='[% ctx.media_prefix %]/opac/images/progressbar_green.gif[% ctx.cache_key %]' style='height:32px;width:32px;' alt='[% l("Search In Progress") %]'/>
+      </div>      
+      <div class="flex-container-striped flex-container-bordered" ng-show="resetsLoaded && filteredResets.length">
+        <div class="flex-row">
+            <div class="flex-cell strong-text">Time</div>
+            <div class="flex-cell strong-text">Reason</div>
+            <div class="flex-cell strong-text">Requestor</div>
+            <div class="flex-cell strong-text">Note</div>
+            <div class="flex-cell strong-text">Previous Copy</div>
+        </div>
+        <div class="flex-row" ng-repeat="reset in filteredResets">
+            <div class="flex-cell">{{reset.reset_time() | date:$root.egDateAndTimeFormat}}</div>
+            <div class="flex-cell">{{reset.reset_reason().name()}}</div>
+            <div class="flex-cell">{{reset.requestor().usrname()}}</div>
+            <div class="flex-cell">{{reset.note()}}</div>
+            <div class="flex-cell">
+                <a href="./cat/item/{{reset.previous_copy().id()}}" target="_self">
+                        {{reset.previous_copy().barcode()}}
+                </a>
+            </div>
+        </div>
+      </div>
+      <div class="flex-container-striped flex-container-bordered" ng-show="resetsLoaded && !filteredResets.length">
+        <div class="flex-row">
+            [%- l('No reset entries found for this hold.') -%]
+        </div>
+      </div>
+    </div><!-- resets tab content -->
 
   </div><!-- tab pane -->
 </div><!-- tab-content -->
index 251ceb2..6779376 100644 (file)
@@ -858,7 +858,85 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) {
 
                     });
                 }
+                
+                $scope.resetPage = 1;
+                $scope.resetsPerPage = 10;
+                $scope.maximumPages = 25;
+                $scope.resetsLoaded = false;
+                $scope.reverseResetOrder = false;
+                
+                $scope.show_resets_tab = function() {
+                    $scope.detail_tab = 'resets';
+                    egCore.pcrud.search('ahrrre',
+                        {hold : $scope.hold.id()}, 
+                        {
+                            flesh : 1, 
+                            flesh_fields : {ahrrre : ['reset_reason','requestor','previous_copy']},                          
+                            limit : $scope.resetsPerPage * $scope.maximumPages
+                        },
+                        {atomic : true}
+                    ).then(function(ents) {
+                        // sort the reset notes by date
+                        ents.sort(
+                            function(a,b){
+                                return Date.parse(a.reset_time()) - Date.parse(b.reset_time());
+                            }
+                        );
+                        $scope.hold.reset_entries(ents);
+                        $scope.filter_resets();
+                        $scope.resetsLoaded = true;
+                    });
+                }
+                
+                $scope.filter_resets = function(){
+                    if(
+                        typeof($scope.hold) === 'undefined' || 
+                        typeof($scope.hold.reset_entries) === 'undefined' || 
+                        $scope.hold.reset_entries() === null
+                    )
+                        return;
+                    var begin = (($scope.resetPage - 1) * $scope.resetsPerPage),
+                        end = begin + $scope.resetsPerPage;
+                    $scope.filteredResets = $scope.hold
+                                                .reset_entries()
+                                                .slice(begin,end);
+                }
+                
+                $scope.reverse_reset_order = function(){
+                    $scope.hold.reset_entries().reverse()
+                    $scope.reverseResetOrder = !$scope.reverseResetOrder;
+                    $scope.first_rs_page();
+                }
+                
+                $scope.on_first_rs_page = function(){
+                    return $scope.resetPage == 1;
+                } 
+                
+                $scope.has_next_rs_page = function(){
+                    return $scope.resetPage < $scope.max_rs_pages();                
+                }
+                
+                $scope.max_rs_pages = function(){
+                    if(typeof($scope.hold.reset_entries) === 'undefined' || $scope.hold.reset_entries() === null)
+                        return 0;
+                    return $scope.hold.reset_entries().length/$scope.resetsPerPage;
+                }
+                
+                $scope.first_rs_page = function() {
+                    $scope.resetPage = 1;
+                }  
+                
+                $scope.increment_rs_page = function() {
+                    $scope.resetPage++;
+                }
 
+                $scope.decrement_rs_page = function() {
+                    $scope.resetPage--;
+                }  
+                
+                $scope.$watch('resetPage',$scope.filter_resets);
+                $scope.$watch('reverseResetOrder',$scope.filter_resets);
+                
                 $scope.show_notify_tab = function() {
                     $scope.detail_tab = 'notify';
                     egCore.pcrud.search('ahn',
index 78e943d..a73c0ea 100644 (file)
@@ -811,4 +811,38 @@ image::hold_management/hold_groups_menu_opac.jpg[Hold Group Option in OPAC]
 ====================
 If the patron is not part of a hold group that is patron visible,
 the *Hold Groups* option won't appear in their OPAC account menu.
-====================
\ No newline at end of file
+====================
+
+[[hold_reset_reasons]]
+== Hold Reset Reasons
+
+indexterm:[Reset, Retarget, Reset Reason]
+
+Hold reset reasons allow staff to see when and why a hold request has been reset. Reset reasons are generated any time a hold has been reset, whether that's a manual reset from a staff member or automatically because a hold has reached the hold retarget interval. This can be very useful for debugging the hold targeter or identifying bad actors in the system. 
+
+=== Types of Reset Reasons
+There are ten different types of reset reasons that can be identified.
+
+. HOLD_TIMED_OUT
+. HOLD_MANUAL_RESET
+. HOLD_BETTER_HOLD
+. HOLD_FROZEN
+. HOLD_UNFROZEN
+. HOLD_CANCELED
+. HOLD_UNCANCELED
+. HOLD_UPDATED
+. HOLD_CHECKED_OUT
+. HOLD_CHECKED_IN
+
+=== Enabling Reset Reasons
+This feature is controlled by a global config flag: *circ.holds.create_reset_reason_entries*. Reset reasons will not be generated unless this is set to true.
+
+=== Viewing Reset Reasons
+Staff can view reset reasons for a hold via a patron's *holds* tab.
+
+. Open patron's page.
+. Click *holds* tab.
+. Select a hold to investigate.
+. Click *detail view*.
+. Click *Reset Entries*
+. Order can be reversed to show most recent resets first.