use OpenILS::Application::AppUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
-my $U = "OpenILS::Application::AppUtils";
-my $dt_parser = DateTime::Format::ISO8601->new;
-
+# WIP notes:
# avoid 'duplicate key value violates unique constraint "copy_once_per_hold"'
-# cache org unit settings per run
-# reduce memory requirements?
-# speed
-# -- better up-front copy filtering when finding potential copies
+
+our $U = "OpenILS::Application::AppUtils";
+our $dt_parser = DateTime::Format::ISO8601->new;
sub new {
my ($class, %args) = @_;
return bless($self, $class);
}
-# Pre-fetch necessary data.
sub init {
my $self = shift;
- $self->{org_closed_dates} =
- $self->{editor}->search_actor_org_unit_closed_date({
- close_start => {'<=', 'now'},
- close_end => {'>=', 'now'}
+ my $closed = $self->{editor}->search_actor_org_unit_closed_date({
+ close_start => {'<=', 'now'},
+ close_end => {'>=', 'now'}
});
+
+ # TODO: check expire / closed_at_next logic
+
+ # 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
return $c->{$org_id}->{$setting};
}
+sub target_hold {
+ my ($self, $hold_id) = @_;
+ my $targeter = OpenILS::Utils::HoldTargeter::Single->new(parent => $self);
+ $targeter->target($hold_id);
+ return $targeter->result;
+}
+
+
+# -----------------------------------------------------------------------
+# Knows how to target a single hold.
+# -----------------------------------------------------------------------
+package OpenILS::Utils::HoldTargeter::Single;
+use strict;
+use warnings;
+use DateTime;
+use OpenSRF::AppSession;
+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_id => $self->hold_id,
+ error => $self->error,
+ success => $self->success,
+ message => $self->message
+ };
+}
+
+# List of potential copies in the form of slim hashes.
+sub copy_hashes {
+ my ($self, $copy_hashes) = @_;
+ $self->{copy_hashes} = $copy_hashes if $copy_hashes;
+ return $self->{copy_hashes};
+}
+
+# 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 exit_targeter {
- my ($self, $msg) = @_;
- $self->{exit} = 1;
- $self->{exit_msg} = $msg;
- $self->{editor}->rollback; # no-op if commit already occurred.
- my $hold_id = $self->{hold_id};
- $logger->info("targeter: exiting hold targeter for $hold_id : $msg");
+ my ($self, $msg, $is_error) = @_;
+ $self->message($msg);
+
+ # Force a rollback when exiting.
+ # This is a no-op if a commit or rollback have already occurred.
+ $self->editor->rollback;
+
+ my $log = "targeter: exiting hold targeter on ".$self->hold_id." : $msg";
+
+ if ($is_error) {
+ $self->error($is_error);
+ $logger->error($log);
+ } else {
+ $logger->info($log);
+ }
+
return 0;
}
+sub create_prox_list {
+
+}
+
# 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};
+ my $hold = $self->hold;
return 1 unless $hold->expire_time;
$hold->cancel_time('now');
$hold->cancel_cause(1); # == un-targeted expiration
- if (!$self->{editor}->update_action_hold_request($hold)) {
- my $evt = $self->{editor}->die_event;
- return $self->exit_targeter("Error canceling hold: ".$evt->{textcode});
+ if (!$self->editor->update_action_hold_request($hold)) {
+ my $evt = $self->editor->die_event;
+ return $self->exit_targeter(
+ "Error canceling hold: ".$evt->{textcode}, 1);
}
- $self->{editor}->commit;
+ $self->editor->commit;
# Fire the A/T handler, but don't wait for a response.
OpenSRF::AppSession->create('open-ils.trigger')->request(
# Returns true on success, false on error.
sub remove_copy_maps {
my $self = shift;
- my $e = $self->{editor};
+ my $e = $self->editor;
my $prev_maps =
- $e->search_action_hold_copy_map({hold => $self->{hold_id}});
+ $e->search_action_hold_copy_map({hold => $self->hold_id});
for my $map (@$prev_maps) {
if (!$e->delete_action_hold_copy_map($map)) {
my $evt = $e->die_event;
return $self->exit_targeter(
- "Error deleting copy maps: ".$evt->{textcode});
+ "Error deleting copy maps: ".$evt->{textcode}, 1);
}
}
sub get_hold_copies {
my $self = shift;
- my $e = $self->{editor};
- my $hold = $self->{hold};
+ my $e = $self->editor;
+ my $hold = $self->hold;
my $hold_target = $hold->target;
my $hold_type = $hold->hold_type;
my $org_depth = $hold->selection_depth || 0;
my $query = {
- select => {acp => ['id']},
- from => {acp => {}},
+ select => {acp => ['id', 'status', 'circ_lib']},
+ from => {
+ acp => {
+ # Exclude copies that are targeted by other active holds.
+ # Include such copies that are targeted by the current 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',
from => 'aou',
where => {id => $org_unit}
}
- },
- }
+ }
+ },
+ '+ahr' => {id => undef}
}
};
# 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'
- },
- ccs => {
- field => 'id',
- filter => {holdable => 't'},
- fkey => 'status'
- }
+ $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'}->{circulate} = 't';
};
}
- my $res = $e->json_query($query);
- return map {$_->{id}} @$res;
+ $self->copy_hashes($e->json_query($query));
+ return $self->inspect_potential_copies;
}
+# Returns true if we have copies to process, False if there are none.
sub inspect_potential_copies {
- my ($self, @copy_ids) = @_;
+ my $self = shift;
+ my @copy_hashes = @{$self->copy_hashes};
- my $e = $self->{editor};
- my $hold = $self->{hold};
- my $hold_id = $self->{hold_id};
+ my $e = $self->editor;
+ my $hold = $self->hold;
+ my $hold_id = $self->hold_id;
$logger->info("targeter: Hold $hold_id has ".
- scalar(@copy_ids)." potential copies");
+ scalar(@copy_hashes)." potential copies");
# Let the caller know we found the copy they were interested in.
$self->{found_copy} = 1 if $self->{find_copy}
- && grep {$_ eq $self->{find_copy}} @copy_ids;
+ && grep {$_->{id} eq $self->{find_copy}} @copy_hashes;
# We have copies. Nothing left to do here.
- return 1 if @copy_ids;
+ return 1 if @copy_hashes;
$hold->prev_check_time('now');
$hold->clear_current_copy;
if (!$e->update_action_hold_request($hold)) {
my $evt = $e->die_event;
return $self->exit_targeter(
- "Error updating hold request: ".$evt->{textcode});
+ "Error updating hold request: ".$evt->{textcode}, 1);
}
$e->commit;
sub build_copy_maps {
- my ($self, @copy_ids) = @_;
- my $e = $self->{editor};
+ my $self = shift;
+ my $e = $self->editor;
- for my $copy_id (@copy_ids) {
+ for my $copy_hash (@{$self->copy_hashes}) {
my $map = Fieldmapper::action::hold_copy_map->new;
- $map->hold($self->{hold_id});
- $map->target_copy($copy_id);
+ $map->hold($self->hold_id);
+ $map->target_copy($copy_hash->{id});
if (!$e->create_action_hold_copy_map($map)) {
my $evt = $e->die_event;
return $self->exit_targeter(
- "Error creating hold copy map: ".$evt->{textcode});
+ "Error creating hold copy map: ".$evt->{textcode}, 1);
}
}
- # TODO: collect proximities
+ # Collect copy proximity info (generated via DB trigger) from the
+ # newly create copy maps.
+ my $proximities = $e->json_query({
+ select => {ahcm => ['target_copy', 'proximity']},
+ from => 'ahcm',
+ where => {hold => $self->hold_id}
+ });
+
+ $self->copy_prox_map({
+ map {$_->{target_copy} => $_->{proximity}} @$proximities
+ });
return 1;
}
+sub build_copy_prox_list {
+}
+
+# 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->copy_hashes}) {
+ my $clib = $copy_hash->{circ_lib};
-# Targets a single hold request
-sub target_hold {
+ 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
+
+ $logger->info("targeter: 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->copy_hashes(\@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->copy_hashes([
+ grep
+ {$_->{status} == 0 || $_->{status} == 7}
+ @{$self->copy_hashes}
+ ]);
+ 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->copy_hashes};
+
+ # no previous target
+ return 1 unless my $prev_id = $hold->current_copy;
+
+ $self->{had_previous_copy} = 1;
+
+ # TODO: is this step really necessary here?? Not if we always set
+ # or clear the value later. Confirm.
+ # Clear the previous copy regardless of
+ # whether we can use it again later.
+ $self->clear_current_copy;
+
+ if (!$self->editor->update_action_hold_request($hold)) {
+ my $evt = $self->editor->die_event;
+ return $self->exit_targeter(
+ "Error updating hold request: ".$evt->{textcode}, 1);
+ }
+
+ # See if the previous copy is in our list of valid copies.
+ my ($prev) = grep {$_->{id} eq $prev_id} @copies;
+
+ # previous target is no longer valid.
+ return 1 unless $prev;
+
+ $self->{valid_previous_copy} = $prev;
+
+ # Remove the previous copy from the working set of potential copies
+ # if there are other copies we can focus on. If there are no other
+ # copies, treat the previous copy like any other.
+ $self->copy_hashes([grep {$_->{id} ne $prev_id} @copies])
+ if scalar(@copies) > 1;
+
+ return 1;
+}
+
+sub check_no_copies {
+ my $self = shift;
+
+ return 1 if @{$self->copy_hashes};
+
+ my $hold = $self->hold;
+ $hold->clear_current_copy;
+ $hold->prev_check_time('now');
+
+ if (!$self->editor->update_action_hold_request($hold)) {
+ my $evt = $self->editor->die_event;
+ return $self->exit_targeter(
+ "Error updating hold request: ".$evt->{textcode}, 1);
+ }
+
+ $self->editor->commit;
+ return $self->exit_targeter("Hold has no targetable copies");
+}
+
+# Target a single hold request
+sub target {
my ($self, $hold_id) = @_;
- my $e = $self->{editor};
- # reset hold-specific toggles
- $self->{exit} = 0;
- $self->{hold_id} = $hold_id;
- $self->{found_copy} = 0;
+ my $e = $self->editor;
+ $self->hold_id($hold_id);
+
+ $logger->info("Processing hold $hold_id");
$e->xact_begin;
- my $hold = $self->{hold} =
- $e->retrieve_action_hold_request($hold_id)
- or return $self->exit_targeter("No hold found");
+ my $hold = $e->retrieve_action_hold_request($hold_id)
+ or return $self->exit_targeter("No hold found", 1);
+
+ $self->hold($hold);
return $self->exit_targeter("Hold is not eligible for targeting")
if $hold->capture_time || $hold->cancel_time;
return unless $self->remove_copy_maps;
return unless $self->handle_expired_hold;
-
- my @copy_ids = $self->get_hold_copies;
- return unless $self->inspect_potential_copies(@copy_ids);
-
- return unless $self->build_copy_maps(@copy_ids);
+ return unless $self->get_hold_copies;
+ return unless $self->build_copy_maps;
+ return unless $self->inspect_previous_target;
+ return unless $self->filter_copies_by_status;
+ return unless $self->filter_closed_date_copies;
+ return unless $self->check_no_copies;
$e->commit;
}