From c403d6bb543625fa235b78300b890047c9a99559 Mon Sep 17 00:00:00 2001 From: dbs Date: Fri, 5 Nov 2010 17:35:38 +0000 Subject: [PATCH] Initial support for recall of items triggered by placing a hold In academic libraries, it is common for groups like faculty and graduate students to have extended loan periods (for example, 120 days), while others have more common loan periods such as 3 weeks. In these environments, it is desirable to have a hold placed on an item that has been loaned out for an extended period to trigger a "recall": truncating the loan period, setting available renewals to 0, optionally changing the fines associated with overdues for the new due date, and notifying the current patron of the recall. New actor.org_unit_setting entries and hold targeting logic enable libraries to control whether a hold will trigger a recall of a circulating item as follows: * "Recalls: Circulation duration that triggers a recall" (recall threshold) is specified as an interval (for example, "21 days"); any items with a loan duration of less that this interval will not be considered for a recall * "Recalls: Truncated loan period" (return interval) is specified as an interval (for example, "7 days"), such that the user who currently has the item checked out will get the greater of either the recall threshold, or the return interval. * "Recalls: An array of fine amount, fine interval, and maximum fine" is an optional setting that applied the new fine rules to the current circulation period. When a hold is placed and no available copies are found by the hold targeter, the recall logic checks to see if the recall threshold and return interval settings are set; if so, then the hold targeter checks the currently checked-out copies to determine if any of the currently circulating items at the designated pickup library have a loan duration longer than the recall threshold. If so, then the eligible item with the nearest due date will be recalled. git-svn-id: svn://svn.open-ils.org/ILS/trunk@18630 dcc99617-32d9-48b4-a31d-7c20da2025e4 --- .../Application/Storage/Publisher/action.pm | 88 +++++++++++++++++++++- Open-ILS/src/sql/Pg/002.schema.config.sql | 2 +- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 66 ++++++++++++++++ Open-ILS/src/sql/Pg/upgrade/0460.schema.recall.sql | 70 +++++++++++++++++ 4 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 Open-ILS/src/sql/Pg/upgrade/0460.schema.recall.sql diff --git a/Open-ILS/src/perlmods/OpenILS/Application/Storage/Publisher/action.pm b/Open-ILS/src/perlmods/OpenILS/Application/Storage/Publisher/action.pm index 4a33ceb1c2..64c9e27a3f 100644 --- a/Open-ILS/src/perlmods/OpenILS/Application/Storage/Publisher/action.pm +++ b/Open-ILS/src/perlmods/OpenILS/Application/Storage/Publisher/action.pm @@ -1,7 +1,10 @@ package OpenILS::Application::Storage::Publisher::action; -use base qw/OpenILS::Application::Storage::Publisher/; +use parent qw/OpenILS::Application::Storage/; +use strict; +use warnings; use OpenSRF::Utils::Logger qw/:level/; use OpenSRF::Utils qw/:datetime/; +use OpenSRF::Utils::JSON; use OpenSRF::AppSession; use OpenSRF::EX qw/:try/; use OpenILS::Utils::Fieldmapper; @@ -1406,6 +1409,7 @@ sub new_hold_copy_targeter { } else { $hold->update( { prev_check_time => 'now' } ); $log->info( "\tThere were no targetable copies for the hold" ); + process_recall($actor, $log, $hold, \@good_copies); } $self->method_lookup('open-ils.storage.transaction.commit')->run; @@ -1435,6 +1439,88 @@ __PACKAGE__->register_method( method => 'new_hold_copy_targeter', ); +sub process_recall { + my ($actor, $log, $hold, $good_copies) = @_; + + # Bail early if we don't have required settings to avoid spurious requests + my $recall_threshold = $actor->request( + 'open-ils.actor.ou_setting.ancestor_default', ''.$hold->pickup_lib, 'circ.holds.recall_threshold' + )->gather(1)->{value}; + + if (!$recall_threshold) { + $log->info("Recall threshold was not set; bailing out on hold ".$hold->id." processing."); + return; + } + + my $return_interval = $actor->request( + 'open-ils.actor.ou_setting.ancestor_default', ''.$hold->pickup_lib, 'circ.holds.recall_return_interval' + )->gather(1)->{value}; + + if (!$return_interval) { + $log->info("Recall return interval was not set; bailing out on hold ".$hold->id." processing."); + return; + } + + my $fine_rules = $actor->request( + 'open-ils.actor.ou_setting.ancestor_default', ''.$hold->pickup_lib, 'circ.holds.recall_fine_rules' + )->gather(1)->{value}; + + $log->info("Recall threshold: $recall_threshold; return interval: $return_interval"); + + # We want checked out copies (status = 1) at the hold pickup lib + my $all_copies = [grep { $_->status == 1 } grep {''.$_->circ_lib eq ''.$hold->pickup_lib } @$good_copies]; + + my @copy_ids = map { $_->id } @$all_copies; + + $log->info("Found " . scalar(@$all_copies) . " eligible checked-out copies for recall"); + + my $return_date = DateTime->now(time_zone => 'local')->add(seconds => interval_to_seconds($return_interval))->iso8601(); + + # Iterate over the checked-out copies to find a copy with a + # loan period longer than the recall threshold: + my $circs = [ action::circulation->search_where( + { target_copy => \@copy_ids, checkin_time => undef, duration => { '>' => $recall_threshold } }, + { order_by => 'due_date ASC' } + )]; + + # If we have a candidate copy, then: + if ($circs) { + my $circ = $circs->[0]; + $log->info("Recalling circ ID : " . $circ->id); + + # Give the user a new due date of either a full recall threshold, + # or the return interval, whichever is further in the future + my $threshold_date = DateTime::Format::ISO8601->parse_datetime(cleanse_ISO8601($circ->xact_start))->add(seconds => interval_to_seconds($recall_threshold))->iso8601(); + if (DateTime->compare(DateTime::Format::ISO8601->parse_datetime($threshold_date), DateTime::Format::ISO8601->parse_datetime($return_date)) == 1) { + $return_date = $threshold_date; + } + + my $update_fields = { + due_date => $return_date, + renewal_remaining => 0, + }; + + # If the OU hasn't defined new fine rules for recalls, keep them + # as they were + if ($fine_rules) { + $log->info("Apply recall fine rules: $fine_rules"); + my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules); + $update_fields->{recurring_fine} = $rules->[0]; + $update_fields->{fine_interval} = $rules->[1]; + $update_fields->{max_fine} = $rules->[2]; + } + + # Adjust circ for current user + $circ->update($update_fields); + + # Create trigger event for notifying current user + my $ses = OpenSRF::AppSession->create('open-ils.trigger'); + $ses->request('open-ils.trigger.event.autocreate', 'circ.recall.target', $circ, $circ->circ_lib->id); + } + + $log->info("Processing of hold ".$hold->id." for recall is now complete."); +} + sub reservation_targeter { my $self = shift; my $client = shift; diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql index 6d61441173..280eea4c0f 100644 --- a/Open-ILS/src/sql/Pg/002.schema.config.sql +++ b/Open-ILS/src/sql/Pg/002.schema.config.sql @@ -70,7 +70,7 @@ CREATE TABLE config.upgrade_log ( install_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -INSERT INTO config.upgrade_log (version) VALUES ('0459'); -- gmc +INSERT INTO config.upgrade_log (version) VALUES ('0460'); -- dbs CREATE TABLE config.bib_source ( id SERIAL PRIMARY KEY, diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 07ec6d282a..f6d40906ce 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -7074,3 +7074,69 @@ INSERT INTO action_trigger.event_definition ( ) ; +-- 0460 schema for recalls triggered by holds +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_threshold', + oils_i18n_gettext( 'circ.holds.recall_threshold', + 'Recalls: Circulation duration that triggers a recall.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_threshold', + 'Recalls: A hold placed on an item with a circulation duration longer than this will trigger a recall. For example, "14 days" or "3 weeks".', 'coust', 'description'), + 'interval') +; + +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_return_interval', + oils_i18n_gettext( 'circ.holds.recall_return_interval', + 'Recalls: Truncated loan period.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_return_interval', + 'Recalls: When a recall is triggered, this defines the adjusted loan period for the item. For example, "4 days" or "1 week".', 'coust', 'description'), + 'interval') +; + +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_fine_rules', + oils_i18n_gettext( 'circ.holds.recall_fine_rules', + 'Recalls: An array of fine amount, fine interval, and maximum fine.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_fine_rules', + 'Recalls: An array of fine amount, fine interval, and maximum fine. For example, to specify a new fine rule of $5.00 per day, with a maximum fine of $50.00, use: [5.00,"1 day",50.00]', 'coust', 'description'), + 'array') +; + +INSERT INTO action_trigger.hook (key,core_type,description) + VALUES ('circ.recall.target', 'circ', 'A checked-out copy has been recalled for a hold.'); + +INSERT INTO action_trigger.event_definition (id, owner, name, hook, validator, reactor, group_field, template) + VALUES (37, 1, 'Item Recall Email Notice', 'circ.recall.target', 'NOOP_True', 'SendEmail', 'usr', +$$ +[%- USE date -%] +[%- user = target.0.usr -%] +To: [%- params.recipient_email || user.email %] +From: [%- params.sender_email || default_sender %] +Subject: Item Recall Notification + +Dear [% user.family_name %], [% user.first_given_name %] + +The following item which you have checked out has been recalled so that +another patron can have access to the item: + +[% FOR circ IN target %] + Title: [% circ.target_copy.call_number.record.simple_record.title %] + Barcode: [% circ.target_copy.barcode %] + Now Due: [% date.format(helpers.format_date(circ.due_date), '%Y-%m-%d') %] + Library: [% circ.circ_lib.name %] + + If this item is not returned by the new due date, fines will be assessed at + the rate of [% circ.recurring_fine %] every [% circ.fine_interval %]. +[% END %] +$$ +); + +INSERT INTO action_trigger.environment (event_def, path) VALUES + (37, 'target_copy.call_number.record.simple_record'), + (37, 'usr'), + (37, 'circ_lib.billing_address') +; + diff --git a/Open-ILS/src/sql/Pg/upgrade/0460.schema.recall.sql b/Open-ILS/src/sql/Pg/upgrade/0460.schema.recall.sql new file mode 100644 index 0000000000..6f3ed5bd84 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/0460.schema.recall.sql @@ -0,0 +1,70 @@ +BEGIN; + +INSERT INTO config.upgrade_log (version) VALUES ('0460'); -- dbs + +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_threshold', + oils_i18n_gettext( 'circ.holds.recall_threshold', + 'Recalls: Circulation duration that triggers a recall.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_threshold', + 'Recalls: A hold placed on an item with a circulation duration longer than this will trigger a recall. For example, "14 days" or "3 weeks".', 'coust', 'description'), + 'interval') +; + +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_return_interval', + oils_i18n_gettext( 'circ.holds.recall_return_interval', + 'Recalls: Truncated loan period.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_return_interval', + 'Recalls: When a recall is triggered, this defines the adjusted loan period for the item. For example, "4 days" or "1 week".', 'coust', 'description'), + 'interval') +; + +INSERT INTO config.org_unit_setting_type (name, label, description, datatype) + VALUES + ('circ.holds.recall_fine_rules', + oils_i18n_gettext( 'circ.holds.recall_fine_rules', + 'Recalls: An array of fine amount, fine interval, and maximum fine.', 'coust', 'label'), + oils_i18n_gettext( 'circ.holds.recall_fine_rules', + 'Recalls: An array of fine amount, fine interval, and maximum fine. For example, to specify a new fine rule of $5.00 per day, with a maximum fine of $50.00, use: [5.00,"1 day",50.00]', 'coust', 'description'), + 'array') +; + +INSERT INTO action_trigger.hook (key,core_type,description) + VALUES ('circ.recall.target', 'circ', 'A checked-out copy has been recalled for a hold.'); + +INSERT INTO action_trigger.event_definition (id, owner, name, hook, validator, reactor, group_field, template) + VALUES (37, 1, 'Item Recall Email Notice', 'circ.recall.target', 'NOOP_True', 'SendEmail', 'usr', +$$ +[%- USE date -%] +[%- user = target.0.usr -%] +To: [%- params.recipient_email || user.email %] +From: [%- params.sender_email || default_sender %] +Subject: Item Recall Notification + +Dear [% user.family_name %], [% user.first_given_name %] + +The following item which you have checked out has been recalled so that +another patron can have access to the item: + +[% FOR circ IN target %] + Title: [% circ.target_copy.call_number.record.simple_record.title %] + Barcode: [% circ.target_copy.barcode %] + Now Due: [% date.format(helpers.format_date(circ.due_date), '%Y-%m-%d') %] + Library: [% circ.circ_lib.name %] + + If this item is not returned by the new due date, fines will be assessed at + the rate of [% circ.recurring_fine %] every [% circ.fine_interval %]. +[% END %] +$$ +); + +INSERT INTO action_trigger.environment (event_def, path) VALUES + (37, 'target_copy.call_number.record.simple_record'), + (37, 'usr'), + (37, 'circ_lib.billing_address') +; + +COMMIT; -- 2.11.0