--- /dev/null
+package OpenILS::Utils::HoldTargeter;
+# ---------------------------------------------------------------
+# Copyright (C) 2016 King County Library System
+# Author: Bill Erickson <berickxx@gmail.com>
+#
+# This program 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 2
+# of the License, or (at your option) any later version.
+
+# This program 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.
+# ---------------------------------------------------------------
+use strict;
+use warnings;
+use DateTime;
+use OpenSRF::AppSession;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils qw/:datetime/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+our $U = "OpenILS::Application::AppUtils";
+our $dt_parser = DateTime::Format::ISO8601->new;
+
+# See target() for runtime arguments.
+sub new {
+ my ($class, %args) = @_;
+ my $self = {
+ editor => new_editor(),
+ ou_setting_cache => {},
+ %args,
+ };
+ return bless($self, $class);
+}
+
+# Target and retarget holds.
+# By default, targets all holds that need targeting, meaning those that
+# have either never been targeted or those whose prev_check_time exceeds
+# the retarget interval.
+#
+# Returns an array of targeter response objects, one entry per hold
+# targeted. See also return_count.
+#
+# Optional parameters:
+#
+# hold => <id>
+# (Re)target a specific hold.
+#
+# return_count => 1
+# Return the total number of holds processed instead of a result
+# object for every targeted hold. Ideal for large batch targeting.
+#
+# retarget_interval => <interval string>
+# Override the 'circ.holds.retarget_interval' global_flag value.
+#
+# newest_first => 1
+# Target holds in reverse order of create_time.
+#
+# skip_viable => 1
+# Avoid retargeting holds whose current_copy is still viable and
+# permitted. This is useful for repairing holds whose targeted copy
+# has become non-viable for a given hold because its status changed or
+# policies affecting the hold/copy no longer allow it to be targeted.
+# This setting can be used in conjunction with any other settings.
+#
+# target_all => 1
+# USE WITH CAUTION. Forces (re)targeting of all active holds. This
+# is primarily useful or testing.
+#
+# parallel_count => n
+# Number of parallel targeters running. This acts as the indication
+# that other targeter instances are running.
+#
+# parallel_slot => n [starts at 1]
+# Sets the parallel targeter instance position/slot. Used to determine
+# which holds to process to avoid conflicts with other running instances.
+#
+sub target {
+ my ($self, %args) = @_;
+
+ $self->{$_} = $args{$_} for keys %args;
+
+ $self->init;
+
+ my $count = 0;
+ my @responses;
+
+ for my $hold_id ($self->find_holds_to_target) {
+ my $single = OpenILS::Utils::HoldTargeter::Single->new(
+ parent => $self,
+ skip_viable => $args{skip_viable}
+ );
+ $single->target($hold_id);
+ push(@responses, $single->result) unless $self->{return_count};
+ $count++;
+ }
+
+ return $self->{return_count} ? $count : \@responses;
+}
+
+sub find_holds_to_target {
+ my $self = shift;
+
+ return ($self->{hold}) if $self->{hold};
+
+ my $query = {
+ select => {ahr => ['id']},
+ from => 'ahr',
+ where => {
+ capture_time => undef,
+ fulfillment_time => undef,
+ cancel_time => undef,
+ frozen => 'f'
+ },
+ order_by => [
+ {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
+ {class => 'ahr', field => 'request_time'},
+ {class => 'ahr', field => 'prev_check_time'}
+ ]
+ };
+
+ if (!$self->{target_all}) {
+ # Unless we're retargeting all holds, limit to holds that have no
+ # prev_check_time or those whose prev_check_time occurred
+ # before the retarget interval.
+
+ my $date = DateTime->now->subtract(
+ seconds => $self->{retarget_interval});
+
+ $query->{where}->{'-or'} = [
+ {prev_check_time => undef},
+ {prev_check_time => {'<=' => $date->strftime('%F %T%z')}}
+ ];
+ }
+
+ # parallel < 1 means no parallel
+ my $parallel = ($self->{parallel_count} || 0) > 1 ?
+ $self->{parallel_count} : 0;
+
+ if ($parallel) {
+ # In parallel mode, we need to also grab the metarecord for each hold.
+ $query->{select}->{mmrsm} = ['metarecord'];
+ $query->{from} = {
+ ahr => {
+ rhrr => {
+ fkey => 'id',
+ field => 'id',
+ join => {
+ mmrsm => {
+ field => 'source',
+ fkey => 'bib_record'
+ }
+ }
+ }
+ }
+ };
+ }
+
+ # Newest-first sorting cares only about hold create_time.
+ $query->{order_by} =
+ [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
+ if $self->{newest_first};
+
+ my $holds = $self->editor->json_query($query, {substream => 1});
+
+ # In parallel mode, only process holds within the current process
+ # whose metarecord ID modulo the parallel targeter count matches
+ # our paralell targeting slot. This ensures that no 2 processes
+ # will be operating on the same potential copy sets.
+ #
+ # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5,
+ # process holds whose metarecord ID's are 2, 7, 12, 17, ...
+ if ($parallel) {
+
+ # Slots are 1-based at the API level, but 0-based for modulo.
+ my $slot = $self->{parallel_slot} - 1;
+
+ my @slot_holds =
+ grep { ($_->{metarecord} % $parallel) == $slot } @$holds;
+
+ $logger->info(sprintf(
+ "targeter: parallel targeter (slot %d of %d) trimmed ".
+ "targetable holds set down to %d from %d holds",
+ $slot + 1, $parallel, scalar(@slot_holds), scalar(@$holds)
+ ));
+
+ $holds = \@slot_holds;
+ }
+
+ return map {$_->{id}} @$holds;
+}
+
+sub editor {
+ my $self = shift;
+ return $self->{editor};
+}
+
+# Load startup data required by all targeter actions.
+sub init {
+ my $self = shift;
+ my $e = $self->editor;
+
+ my $closed_orgs_query = {
+ close_start => {'<=', 'now'},
+ close_end => {'>=', 'now'}
+ };
+
+ if (!$self->{target_all}) {
+
+ # See if the caller provided an interval
+ my $interval = $self->{retarget_interval};
+
+ if (!$interval) {
+ # See if we have a global flag value for the interval
+
+ $interval = $e->search_config_global_flag({
+ name => 'circ.holds.retarget_interval',
+ enabled => 't'
+ })->[0];
+
+ # If no flag is present, default to a 24-hour retarget interval.
+ $interval = $interval ? $interval->value : '24h';
+ }
+
+ # Convert the interval to seconds for current and future use.
+ $self->{retarget_interval} = interval_to_seconds($interval);
+
+ # An org unit is considered closed for retargeting purposes
+ # if it's closed both now and at the next re-target date.
+
+ my $next_check_time =
+ DateTime->now->add(seconds => $self->{retarget_interval})
+ ->strftime('%F %T%z');
+
+ $closed_orgs_query = {
+ '-and' => [
+ $closed_orgs_query, {
+ close_start => {'<=', $next_check_time},
+ close_end => {'>=', $next_check_time}
+ }
+ ]
+ }
+ }
+
+ my $closed =
+ $self->editor->search_actor_org_unit_closed_date($closed_orgs_query);
+
+ # Map of org id to 1. Any org in the map is closed.
+ $self->{closed_orgs} = {map {$_->org_unit => 1} @$closed};
+}
+
+# Org unit setting fetch+cache
+sub get_ou_setting {
+ my ($self, $org_id, $setting) = @_;
+ my $c = $self->{ou_setting_cache};
+
+ $c->{$org_id} = {} unless $c->{$org_id};
+
+ $c->{$org_id}->{$setting} =
+ $U->ou_ancestor_setting_value($org_id, $setting, $self->{editor})
+ unless exists $c->{$org_id}->{$setting};
+
+ return $c->{$org_id}->{$setting};
+}
+
+# -----------------------------------------------------------------------
+# Knows how to target a single hold.
+# -----------------------------------------------------------------------
+package OpenILS::Utils::HoldTargeter::Single;
+use strict;
+use warnings;
+use DateTime;
+use OpenSRF::AppSession;
+use OpenSRF::Utils qw/:datetime/;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+sub new {
+ my ($class, %args) = @_;
+ my $self = {
+ %args,
+ editor => new_editor(),
+ error => 0,
+ success => 0
+ };
+ return bless($self, $class);
+}
+
+# Parent targeter object.
+sub parent {
+ my ($self, $parent) = @_;
+ $self->{parent} = $parent if $parent;
+ return $self->{parent};
+}
+
+sub hold_id {
+ my ($self, $hold_id) = @_;
+ $self->{hold_id} = $hold_id if $hold_id;
+ return $self->{hold_id};
+}
+
+sub hold {
+ my ($self, $hold) = @_;
+ $self->{hold} = $hold if $hold;
+ return $self->{hold};
+}
+
+# Debug message
+sub message {
+ my ($self, $message) = @_;
+ $self->{message} = $message if $message;
+ return $self->{message} || '';
+}
+
+# True if the hold was successfully targeted.
+sub success {
+ my ($self, $success) = @_;
+ $self->{success} = $success if defined $success;
+ return $self->{success};
+}
+
+# True if targeting exited early on an unrecoverable error.
+sub error {
+ my ($self, $error) = @_;
+ $self->{error} = $error if defined $error;
+ return $self->{error};
+}
+
+sub editor {
+ my $self = shift;
+ return $self->{editor};
+}
+
+sub result {
+ my $self = shift;
+
+ return {
+ hold => $self->hold_id,
+ error => $self->error,
+ success => $self->success,
+ message => $self->message,
+ target => $self->hold ? $self->hold->current_copy : undef,
+ old_target => $self->{previous_copy_id},
+ found_copy => $self->{found_copy},
+ eligible_copies => $self->{eligible_copy_count}
+ };
+}
+
+# List of potential copies in the form of slim hashes. This list
+# evolves as copies are filtered as they are deemed non-targetable.
+sub copies {
+ my ($self, $copies) = @_;
+ $self->{copies} = $copies if $copies;
+ return $self->{copies};
+}
+
+# Final set of potential copies, including those that may not be
+# currently targetable, that may be eligible for recall processing.
+sub recall_copies {
+ my ($self, $recall_copies) = @_;
+ $self->{recall_copies} = $recall_copies if $recall_copies;
+ return $self->{recall_copies};
+}
+
+# Maps copy ID's to their hold proximity
+sub copy_prox_map {
+ my ($self, $copy_prox_map) = @_;
+ $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
+ return $self->{copy_prox_map};
+}
+
+sub log_hold {
+ my ($self, $msg, $err) = @_;
+ my $level = $err ? 'error' : 'info';
+ $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
+}
+
+# Captures the exit message, rolls back the cstore transaction/connection,
+# and returns false.
+# is_error : log the final message and editor event at ERR level.
+sub exit_targeter {
+ my ($self, $msg, $is_error) = @_;
+
+ $self->message($msg);
+ my $log = "exiting => $msg";
+
+ if ($is_error) {
+ # On error, roll back and capture the last editor event for logging.
+
+ my $evt = $self->editor->die_event;
+ $log .= " [".$evt->{textcode}."]" if $evt;
+
+ $self->error(1);
+ $self->log_hold($log, 1);
+
+ } else {
+ # Attempt a rollback and disconnect when each hold exits
+ # to avoid the possibility of leaving cstore's pinned.
+ # Note: ->rollback is a no-op when a ->commit has already occured.
+
+ $self->editor->rollback;
+ $self->log_hold($log);
+ }
+
+ return 0;
+}
+
+# Cancel expired holds and kick off the A/T no-target event. Returns
+# true (i.e. keep going) if the hold is not expired. Returns false if
+# the hold is canceled or a non-recoverable error occcurred.
+sub handle_expired_hold {
+ my $self = shift;
+ my $hold = $self->hold;
+
+ return 1 unless $hold->expire_time;
+
+ my $ex_time =
+ $dt_parser->parse_datetime(cleanse_ISO8601($hold->expire_time));
+ return 1 unless DateTime->compare($ex_time, DateTime->now) < 0;
+
+ # Hold is expired --
+
+ $hold->cancel_time('now');
+ $hold->cancel_cause(1); # == un-targeted expiration
+
+ $self->editor->update_action_hold_request($hold)
+ or return $self->exit_targeter("Error canceling hold", 1);
+
+ $self->editor->commit;
+
+ # Fire the A/T handler, but don't wait for a response.
+ OpenSRF::AppSession->create('open-ils.trigger')->request(
+ 'open-ils.trigger.event.autocreate',
+ 'hold_request.cancel.expire_no_target',
+ $hold, $hold->pickup_lib
+ );
+
+ return $self->exit_targeter("Hold is expired");
+}
+
+# Find potential copies for hold mapping and targeting.
+sub get_hold_copies {
+ my $self = shift;
+ my $e = $self->editor;
+ my $hold = $self->hold;
+
+ my $hold_target = $hold->target;
+ my $hold_type = $hold->hold_type;
+ my $org_unit = $hold->selection_ou;
+ my $org_depth = $hold->selection_depth || 0;
+
+ my $query = {
+ select => {
+ acp => ['id', 'status', 'circ_lib'],
+ ahr => ['current_copy']
+ },
+ from => {
+ acp => {
+ # Tag copies that are in use by other holds so we don't
+ # try to target them for our hold.
+ ahr => {
+ type => 'left',
+ fkey => 'id', # acp.id
+ field => 'current_copy',
+ filter => {
+ fulfillment_time => undef,
+ cancel_time => undef,
+ id => {'!=' => $self->hold_id}
+ }
+ }
+ }
+ },
+ where => {
+ '+acp' => {
+ deleted => 'f',
+ circ_lib => {
+ in => {
+ select => {
+ aou => [{
+ transform => 'actor.org_unit_descendants',
+ column => 'id',
+ result_field => 'id',
+ params => [$org_depth]
+ }],
+ },
+ from => 'aou',
+ where => {id => $org_unit}
+ }
+ }
+ }
+ }
+ };
+
+ unless ($hold_type eq 'R' || $hold_type eq 'F') {
+ # Add the holdability filters to the copy query, unless
+ # we're processing a Recall or Force hold, which bypass most
+ # holdability checks.
+
+ $query->{from}->{acp}->{acpl} = {
+ field => 'id',
+ filter => {holdable => 't', deleted => 'f'},
+ fkey => 'location'
+ };
+
+ $query->{from}->{acp}->{ccs} = {
+ field => 'id',
+ filter => {holdable => 't'},
+ fkey => 'status'
+ };
+
+ $query->{where}->{'+acp'}->{holdable} = 't';
+ $query->{where}->{'+acp'}->{mint_condition} = 't'
+ if $U->is_true($hold->mint_condition);
+ }
+
+ unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
+ # For volume and higher level holds, avoid targeting copies that
+ # act as instances of monograph parts.
+ $query->{from}->{acp}->{acpm} = {
+ type => 'left',
+ field => 'target_copy',
+ fkey => 'id'
+ };
+
+ $query->{where}->{'+acpm'}->{id} = undef;
+ }
+
+ if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
+
+ $query->{where}->{'+acp'}->{id} = $hold_target;
+
+ } elsif ($hold_type eq 'V') {
+
+ $query->{where}->{'+acp'}->{call_number} = $hold_target;
+
+ } elsif ($hold_type eq 'P') {
+
+ $query->{from}->{acp}->{acpm} = {
+ field => 'target_copy',
+ fkey => 'id',
+ filter => {part => $hold_target},
+ };
+
+ } elsif ($hold_type eq 'I') {
+
+ $query->{from}->{acp}->{sitem} = {
+ field => 'unit',
+ fkey => 'id',
+ filter => {issuance => $hold_target},
+ };
+
+ } elsif ($hold_type eq 'T') {
+
+ $query->{from}->{acp}->{acn} = {
+ field => 'id',
+ fkey => 'call_number',
+ 'join' => {
+ bre => {
+ field => 'id',
+ filter => {id => $hold_target},
+ fkey => 'record'
+ }
+ }
+ };
+
+ } else { # Metarecord hold
+
+ $query->{from}->{acp}->{acn} = {
+ field => 'id',
+ fkey => 'call_number',
+ join => {
+ bre => {
+ field => 'id',
+ fkey => 'record',
+ join => {
+ mmrsm => {
+ field => 'source',
+ fkey => 'id',
+ filter => {metarecord => $hold_target},
+ }
+ }
+ }
+ }
+ };
+
+ if ($hold->holdable_formats) {
+ # Compile the JSON-encoded metarecord holdable formats
+ # to an Intarray query_int string.
+ my $query_int = $e->json_query({
+ from => [
+ 'metabib.compile_composite_attr',
+ $hold->holdable_formats
+ ]
+ })->[0];
+ # TODO: ^- any way to add this as a filter in the main query?
+
+ if ($query_int) {
+ # Only pull potential copies from records that satisfy
+ # the holdable formats query.
+ my $qint = $query_int->{'metabib.compile_composite_attr'};
+ $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
+ field => 'source',
+ fkey => 'id',
+ filter => {vlist => {'@@' => $qint}}
+ }
+ }
+ }
+ }
+
+ my $copies = $e->json_query($query);
+ $self->{eligible_copy_count} = scalar(@$copies);
+
+ $self->log_hold($self->{eligible_copy_count}." potential copies");
+
+ # Let the caller know we encountered the copy they were interested in.
+ $self->{found_copy} = 1 if $self->{find_copy}
+ && grep {$_->{id} eq $self->{find_copy}} @$copies;
+
+ $self->copies($copies);
+
+ return 1;
+}
+
+# Delete and rebuild copy maps
+sub update_copy_maps {
+ my $self = shift;
+ my $e = $self->editor;
+
+ my $resp = $e->json_query({from => [
+ 'action.hold_request_regen_copy_maps',
+ $self->hold_id,
+ '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
+ ]});
+
+ # The above call can fail if another process is updating
+ # copy maps for this hold at the same time.
+ return 1 if $resp && @$resp;
+
+ return $self->exit_targeter("Error creating hold copy maps", 1);
+}
+
+# Returns a map of proximity values to arrays of copy hashes.
+# The copy hash arrays are weighted consistent with the org unit hold
+# target weight, meaning that a given copy may appear more than once
+# in its proximity list.
+sub compile_weighted_proximity_map {
+ my $self = shift;
+
+ # Collect copy proximity info (generated via DB trigger)
+ # from our newly create copy maps.
+ my $hold_copy_maps = $self->editor->json_query({
+ select => {ahcm => ['target_copy', 'proximity']},
+ from => 'ahcm',
+ where => {hold => $self->hold_id}
+ });
+
+ my %copy_prox_map =
+ map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
+
+ my %prox_map;
+ for my $copy_hash (@{$self->copies}) {
+ my $prox = $copy_prox_map{$copy_hash->{id}};
+ $prox_map{$prox} ||= [];
+
+ my $weight = $self->parent->get_ou_setting(
+ $copy_hash->{circ_lib},
+ 'circ.holds.org_unit_target_weight') || 1;
+
+ # Each copy is added to the list once per target weight.
+ push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
+ }
+
+ return $self->{weighted_prox_map} = \%prox_map;
+}
+
+# Returns true if filtering completed without error, false otherwise.
+sub filter_closed_date_copies {
+ my $self = shift;
+
+ my @filtered_copies;
+ for my $copy_hash (@{$self->copies}) {
+ my $clib = $copy_hash->{circ_lib};
+
+ if ($self->parent->{closed_orgs}->{$clib}) {
+ # Org unit is currently closed. See if it matters.
+
+ my $ous = $self->hold->pickup_lib eq $clib ?
+ 'circ.holds.target_when_closed_if_at_pickup_lib' :
+ 'circ.holds.target_when_closed';
+
+ unless ($self->parent->get_ou_setting($clib, $ous)) {
+ # Targeting not allowed at this circ lib when its closed
+
+ $self->log_hold("skipping copy ".
+ $copy_hash->{id}."at closed org $clib");
+
+ next;
+ }
+
+ }
+
+ push(@filtered_copies, $copy_hash);
+ }
+
+ # Update our in-progress list of copies to reflect the filtered set.
+ $self->copies(\@filtered_copies);
+
+ return 1;
+}
+
+# Limit the set of potential copies to those that are
+# in a targetable status.
+# Returns true if filtering completes without error, false otherwise.
+sub filter_copies_by_status {
+ my $self = shift;
+
+ $self->copies([
+ grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
+ ]);
+
+ # Track checked out copies for later recall
+ $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
+
+ return 1;
+}
+
+# Remove copies that are currently targeted by other holds.
+# Returns true if filtering completes without error, false otherwise.
+sub filter_copies_in_use {
+ my $self = shift;
+
+ # A copy with a 'current_copy' value means it's in use by another hold.
+ $self->copies([
+ grep {!$_->{current_copy}} @{$self->copies}
+ ]);
+
+ return 1;
+}
+
+# Returns true if inspection completed without error, false otherwise.
+sub inspect_previous_target {
+ my $self = shift;
+ my $hold = $self->hold;
+ my @copies = @{$self->copies};
+
+ # no previous target
+ return 1 unless my $prev_id = $hold->current_copy;
+
+ $self->{previous_copy_id} = $prev_id;
+
+ # See if the previous copy is in our list of valid copies.
+ my ($prev) = grep {$_->{id} eq $prev_id} @copies;
+
+ # exit if previous target is no longer valid.
+ return 1 unless $prev;
+
+ if ($self->{skip_viable}) {
+ # In skip_viable mode, leave the hold as-is if the existing
+ # current_copy is still permitted.
+ # Note: viability checking is done this late in the process
+ # (specifically after other potential copies have been fetched)
+ # because we first need to confirm the current_copy is a valid
+ # potential copy (e.g. it's holdable, non-deleted, etc.), which
+ # copy_is_permitted, which only checks hold matrix policies,
+ # does not check.
+
+ return $self->exit_targeter("Skipping with viable target = $prev_id")
+ if $self->copy_is_permitted($prev);
+
+ # Previous copy is now confirmed non-viable.
+
+ } else {
+
+ # Previous copy may be targetable. Keep it around for later
+ # in case we need to confirm its viability and re-use it.
+ $self->{valid_previous_copy} = $prev;
+ }
+
+ # Remove the previous copy from the working set of potential copies.
+ # It will be revisited later if needed.
+ $self->copies([grep {$_->{id} ne $prev_id} @copies]);
+
+ return 1;
+}
+
+# Returns true if we have at least one potential copy remaining, thus
+# targeting should continue. Otherwise, the hold is updated to reflect
+# that there is no target and returns false to stop targeting.
+sub handle_no_copies {
+ my ($self, %args) = @_;
+
+ if (!$args{force}) {
+ # If 'force' is set, the caller is saying that all copies have
+ # failed. Otherwise, see if we have any copies left to inspect.
+ return 1 if @{$self->copies} || $self->{valid_previous_copy};
+ }
+
+ # At this point, all copies have been inspected and none
+ # have yielded a targetable item.
+
+ if ($args{process_recalls}) {
+ # See if we have any copies/circs to recall.
+ return unless $self->process_recalls;
+ }
+
+ my $hold = $self->hold;
+ $hold->clear_current_copy;
+ $hold->prev_check_time('now');
+
+ $self->editor->update_action_hold_request($hold)
+ or return $self->exit_targeter("Error updating hold request", 1);
+
+ $self->editor->commit;
+ return $self->exit_targeter("Hold has no targetable copies");
+}
+
+# Force and recall holds bypass validity tests. Returns the first
+# (and presumably only) copy in our list of valid copies when a
+# F or R hold is encountered. Returns undef otherwise.
+sub attempt_force_recall_target {
+ my $self = shift;
+ return $self->copies->[0] if
+ $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
+ return undef;
+}
+
+sub attempt_to_find_copy {
+ my $self = shift;
+
+ return undef unless @{$self->copies};
+
+ my $max_loops = $self->parent->get_ou_setting(
+ $self->hold->pickup_lib,
+ 'circ.holds.max_org_unit_target_loops'
+ );
+
+ return $self->target_by_org_loops($max_loops) if $max_loops;
+
+ # When not using target loops, targeting is based solely on
+ # proximity and org unit target weight.
+ $self->compile_weighted_proximity_map;
+
+ return $self->find_nearest_copy;
+}
+
+# Returns 2 arrays. The first is a list of copies whose circ lib's
+# unfulfilled target count matches the provided $iter value. The
+# second list is all other copies, returned for convenience.
+sub get_copies_at_loop_iter {
+ my ($self, $targeted_libs, $iter) = @_;
+
+ my @iter_copies; # copies to try now.
+ my @remaining_copies; # copies to try later
+
+ for my $copy (@{$self->copies}) {
+ my $match = 0;
+
+ if ($iter == 0) {
+ # Start with copies at circ libs that have never been targeted.
+ $match = 1 unless grep {
+ $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
+
+ } else {
+ # Find copies at branches whose target count
+ # matches the current (non-zero) loop depth.
+
+ $match = 1 if grep {
+ $_->{count} eq $iter &&
+ $_->{circ_lib} eq $copy->{circ_lib}
+ } @$targeted_libs;
+ }
+
+ if ($match) {
+ push(@iter_copies, $copy);
+ } else {
+ push(@remaining_copies, $copy);
+ }
+ }
+
+ $self->log_hold(
+ sprintf("%d potential copies at max-loops iteration level $iter. ".
+ "%d remain to be tested at a higher loop iteration level.",
+ scalar(@iter_copies),
+ scalar(@remaining_copies)
+ )
+ );
+
+ return (\@iter_copies, \@remaining_copies);
+}
+
+# Find libs whose unfulfilled target count is less than the maximum
+# configured loop count. Target copies in order of their circ_lib's
+# target count (starting at 0) and moving up. Copies within each
+# loop count group are weighted based on configured hold weight. If
+# no copies in a given group are targetable, move up to the next
+# unfulfilled target level. Keep doing this until all potential
+# copies have been tried or max targets loops is exceeded.
+# Returns a targetable copy if one is found, undef otherwise.
+sub target_by_org_loops {
+ my ($self, $max_loops) = @_;
+
+ my $targeted_libs = $self->editor->json_query({
+ select => {aufhl => ['circ_lib', 'count']},
+ from => 'aufhl',
+ where => {hold => $self->hold_id},
+ order_by => [{class => 'aufhl', field => 'count'}]
+ });
+
+ my $max_tried = 0; # Highest per-lib target attempts.
+ foreach (@$targeted_libs) {
+ $max_tried = $_->{count} if $_->{count} > $max_tried;
+ }
+
+ $self->log_hold("Max lib attempts is $max_tried. ".
+ scalar(@$targeted_libs)." libs have been targeted at least once.");
+
+ # $loop_iter represents per-lib target attemtps already made.
+ # When loop_iter equals max loops, all libs with targetable copies
+ # have been targeted the maximum number of times. loop_iter starts
+ # at 0 to pick up libs that have never been targeted.
+ my $loop_iter = -1;
+ while (++$loop_iter < $max_loops) {
+
+ # Ran out of copies to try before exceeding max target loops.
+ # Nothing else to do here.
+ return undef unless @{$self->copies};
+
+ my ($iter_copies, $remaining_copies) =
+ $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
+
+ next unless @$iter_copies;
+
+ $self->copies($iter_copies);
+
+ # Update the proximity map to only include the copies
+ # from this loop-depth iteration.
+ $self->compile_weighted_proximity_map;
+
+ my $copy = $self->find_nearest_copy;
+ return $copy if $copy; # found one!
+
+ # No targetable copy at the current target loop.
+ # Update our current copy set to the not-yet-tested copies.
+ $self->copies($remaining_copies);
+ }
+
+ # Avoid canceling the hold with exceeds-loops unless at least one
+ # lib has been targeted max_loops times. Otherwise, the hold goes
+ # back to waiting for another copy (or retargets its current copy).
+ return undef if $max_tried < $max_loops;
+
+ # At least one lib has been targeted max-loops times and zero
+ # other copies are targetable. All options have been exhausted.
+ return $self->handle_exceeds_target_loops;
+}
+
+# Cancel the hold, fire the no-target A/T event handler, and exit.
+sub handle_exceeds_target_loops {
+ my $self = shift;
+ my $e = $self->editor;
+ my $hold = $self->hold;
+
+ $hold->cancel_time('now');
+ $hold->cancel_cause(1); # = un-targeted expiration
+
+ $e->update_action_hold_request($hold)
+ or return $self->exit_targeter("Error updating hold request", 1);
+
+ $e->commit;
+
+ # Fire the A/T handler, but don't wait for a response.
+ OpenSRF::AppSession->create('open-ils.trigger')->request(
+ 'open-ils.trigger.event.autocreate',
+ 'hold_request.cancel.expire_no_target',
+ $hold, $hold->pickup_lib
+ );
+
+ return $self->exit_targeter("Hold exceeded max target loops");
+}
+
+# When all else fails, see if we can reuse the previously targeted copy.
+sub attempt_prev_copy_retarget {
+ my $self = shift;
+
+ # earlier target logic can in some cases cancel the hold.
+ return undef if $self->hold->cancel_time;
+
+ my $prev_copy = $self->{valid_previous_copy};
+ return undef unless $prev_copy;
+
+ $self->log_hold("attempting to re-target previously ".
+ "targeted copy for hold ".$self->hold_id);
+
+ if ($self->copy_is_permitted($prev_copy)) {
+ $self->log_hold("retargeting the previously ".
+ "targeted copy [".$prev_copy->{id}."]" );
+ return $prev_copy;
+ }
+
+ return undef;
+}
+
+# Returns the closest copy by proximity that is a confirmed valid
+# targetable copy.
+sub find_nearest_copy {
+ my $self = shift;
+ my %prox_map = %{$self->{weighted_prox_map}};
+ my $hold = $self->hold;
+ my %seen;
+
+ # Pick a copy at random from each tier of the proximity map,
+ # starting at the lowest proximity and working up, until a
+ # copy is found that is suitable for targeting.
+ for my $prox (sort {$a <=> $b} keys %prox_map) {
+ my @copies = @{$prox_map{$prox}};
+ next unless @copies;
+
+ my $rand = int(rand(scalar(@copies)));
+
+ while (my ($c) = splice(@copies, $rand, 1)) {
+ $rand = int(rand(scalar(@copies)));
+ next if $seen{$c->{id}};
+
+ return $c if $self->copy_is_permitted($c);
+ $seen{$c->{id}} = 1;
+
+ last unless(@copies);
+ }
+ }
+
+ return undef;
+}
+
+# Returns true if the provided copy passes the hold permit test for our
+# hold and can be used for targeting.
+# When a copy fails the test, it is removed from $self->copies.
+sub copy_is_permitted {
+ my ($self, $copy) = @_;
+ return 0 unless $copy;
+
+ my $resp = $self->editor->json_query({
+ from => [
+ 'action.hold_retarget_permit_test',
+ $self->hold->request_lib,
+ $self->hold->pickup_lib,
+ $copy->{id},
+ $self->hold->usr,
+ $self->hold->requestor
+ ]
+ });
+
+ return 1 if $U->is_true($resp->[0]->{success});
+
+ # Copy is confirmed non-viable.
+ # Remove it from our potentials list.
+ $self->copies([
+ grep {$_->{id} ne $copy->{id}} @{$self->copies}
+ ]);
+
+ return 0;
+}
+
+# Sets hold.current_copy to the provided copy.
+sub apply_copy_target {
+ my ($self, $copy) = @_;
+ my $e = $self->editor;
+ my $hold = $self->hold;
+
+ $hold->current_copy($copy->{id});
+ $hold->prev_check_time('now');
+
+ $e->update_action_hold_request($hold)
+ or return $self->exit_targeter("Error updating hold request", 1);
+
+ $e->commit;
+ $self->{success} = 1;
+ return $self->exit_targeter("successfully targeted copy ".$copy->{id});
+}
+
+# Creates a new row in action.unfulfilled_hold_list for our hold.
+# Returns 1 if all is OK, false on error.
+sub log_unfulfilled_hold {
+ my $self = shift;
+ return 1 unless my $prev_id = $self->{previous_copy_id};
+ my $e = $self->editor;
+
+ $self->log_hold(
+ "hold was not fulfilled by previous targeted copy $prev_id");
+
+ my $circ_lib;
+ if ($self->{valid_previous_copy}) {
+ $circ_lib = $self->{valid_previous_copy}->{circ_lib};
+
+ } else {
+ # We don't have a handle on the previous copy to get its
+ # circ lib. Fetch it.
+ $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
+ }
+
+ my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
+ $unful->hold($self->hold_id);
+ $unful->circ_lib($circ_lib);
+ $unful->current_copy($prev_id);
+
+ $e->create_action_unfulfilled_hold_list($unful) or
+ return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
+
+ return 1;
+}
+
+sub process_recalls {
+ my $self = shift;
+ my $e = $self->editor;
+
+ my $pu_lib = $self->hold->pickup_lib;
+
+ my $threshold =
+ $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_threshold')
+ or return 1;
+
+ my $interval =
+ $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_return_interval')
+ or return 1;
+
+ # Give me the ID of every checked out copy living at the hold
+ # pickup library.
+ my @copy_ids = map {$_->{id}}
+ grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
+
+ return 1 unless @copy_ids;
+
+ my $circ = $e->search_action_circulation([
+ { target_copy => \@copy_ids,
+ checkin_time => undef,
+ duration => {'>' => $threshold}
+ }, {
+ order_by => 'due_date',
+ limit => 1
+ }
+ ])->[0];
+
+ return unless $circ;
+
+ $self->log_hold("recalling circ ".$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($threshold))
+ ->iso8601();
+
+ my $return_date = DateTime->now(time_zone => 'local')->add(
+ seconds => interval_to_seconds($interval))->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,
+ );
+
+ my $fine_rules =
+ $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_fine_rules');
+
+ # If the OU hasn't defined new fine rules for recalls, keep them
+ # as they were
+ if ($fine_rules) {
+ $self->log_hold("applying 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];
+ }
+
+ # Copy updated fields into circ object.
+ $circ->$_($update_fields{$_}) for keys %update_fields;
+
+ $e->update_action_circulation($circ)
+ or return $self->exit_targeter(
+ "Error updating circulation object in process_recalls", 1);
+
+ # 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);
+
+ return 1;
+}
+
+# Target a single hold request
+sub target {
+ my ($self, $hold_id) = @_;
+
+ my $e = $self->editor;
+ $self->hold_id($hold_id);
+
+ $self->log_hold("processing...");
+
+ $e->xact_begin;
+
+ my $hold = $e->retrieve_action_hold_request($hold_id)
+ or return $self->exit_targeter("No hold found", 1);
+
+ return $self->exit_targeter("Hold is not eligible for targeting")
+ if $hold->capture_time ||
+ $hold->cancel_time ||
+ $hold->fulfillment_time ||
+ $U->is_true($hold->frozen);
+
+ $self->hold($hold);
+
+ return unless $self->handle_expired_hold;
+ return unless $self->get_hold_copies;
+ return unless $self->update_copy_maps;
+
+ # Confirm that we have something to work on. If we have no
+ # copies at this point, there's also nothing to recall.
+ return unless $self->handle_no_copies;
+
+ # Trim the set of working copies down to those that are
+ # currently targetable.
+ return unless $self->filter_copies_by_status;
+ return unless $self->filter_copies_in_use;
+ return unless $self->filter_closed_date_copies;
+
+ # Set aside the previously targeted copy for later use as needed.
+ # Code may exit here in skip_viable mode if the existing
+ # current_copy value is still viable.
+ return unless $self->inspect_previous_target;
+
+ # Log that the hold was not captured.
+ return unless $self->log_unfulfilled_hold;
+
+ # Confirm again we have something to work on. If we have no
+ # targetable copies now, there may be a copy that can be recalled.
+ return unless $self->handle_no_copies(process_recalls => 1);
+
+ # At this point, the working list of copies has been trimmed to
+ # those that are currently targetable at a superficial level.
+ # (They are holdable and available). Now the code steps through
+ # these copies in order of priority and pickup lib proximity to
+ # find a copy that is confirmed targetable by policy.
+
+ my $copy = $self->attempt_force_recall_target ||
+ $self->attempt_to_find_copy ||
+ $self->attempt_prev_copy_retarget;
+
+ # See if one of the above attempt* calls canceled the hold as a side
+ # effect of looking for a copy to target.
+ return if $hold->cancel_time;
+
+ return $self->apply_copy_target($copy) if $copy;
+
+ # No targetable copy was found. Fire the no-copy handler.
+ $self->handle_no_copies(force => 1, process_recalls => 1);
+}
+
+
+