--- /dev/null
+package OpenILS::Utils::HoldTargeter;
+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/;
+
+my $U = "OpenILS::Application::AppUtils";
+my $dt_parser = DateTime::Format::ISO8601->new;
+
+# 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
+
+sub new {
+ my $class = shift;
+ my $self = {
+ editor => new_editor(),
+ ou_setting_cache => {}
+ };
+ 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'}
+ });
+}
+
+# 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};
+
+ if (not exists $c->{$org_id}->{$setting}) {
+ my $r = $U->ou_ancestor_setting($org_id, $setting, $self->{editor});
+ $c->{$org_id}->{$setting} = $r ? $r->{value} : undef;
+ }
+
+ return $c->{$org_id}->{$setting};
+}
+
+# TODO: stream messages back to caller
+sub exit_targeter {
+ my ($self, $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");
+ return 0;
+}
+
+# Cancel expired holds and kick off the A/T no-target event.
+# Returns true if the hold is expired or an error occured.
+# False otherwise.
+sub handle_expired_hold {
+ my $self = shift;
+ my $hold = $self->{hold};
+
+ return 0 unless $hold->expire_time;
+
+ my $ex_time =
+ $dt_parser->parse_datetime(cleanse_ISO8601($hold->expire_time));
+ return 0 unless DateTime->compare($ex_time, DateTime->now) < 0;
+
+ # Hold is expired
+
+ $hold->cancel_time('now');
+ $hold->cancel_cause(1); # == un-targeted expiration
+
+ if (!$self->{editor}->update_action_hold_request($hold)) {
+ $self->exit_targeter("Error canceling hold");
+ return 1; # Tell the caller to exit
+ }
+
+ $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 1;
+}
+
+# Hold copy maps are only automatically deleted when the hold
+# is fulfilled or canceled. Here, they have to be manually removed.
+# Returns event on error, undef on success.
+sub remove_copy_maps {
+ my $self = shift;
+ my $e = $self->{editor};
+ my $prev_maps =
+ $e->search_action_hold_copy_map({hold => $self->{hold_id}});
+ for my $map (@$prev_maps) {
+ $e->delete_action_hold_copy_map($map) or return $e->die_event;
+ }
+ return undef;
+}
+
+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']},
+ from => {acp => {}},
+ 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'
+ },
+ ccs => {
+ field => 'id',
+ filter => {holdable => 't'},
+ fkey => 'status'
+ }
+ };
+
+ $query->{where}->{'+acp'}->{circulate} = 't';
+ $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') {
+ # TODO: Should this include 'R' holds? The original hold targeter
+ # does not include them either.
+ # 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 {
+
+ $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},
+ }
+ }
+ }
+ }
+ };
+ }
+
+ my $res = $e->json_query($query);
+ return map {$_->{id}} @$res;
+}
+
+sub handle_no_copies {
+ my $self = shift;
+ my $e = $self->{editor};
+ my $hold = $self->{hold};
+
+ $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});
+ }
+
+ $e->commit;
+ return $self->exit_targeter("No copies available for targeting");
+}
+
+
+# Targets a single hold request
+sub target_hold {
+ my ($self, $hold_id) = @_;
+ my $e = $self->{editor};
+ $self->{hold_id} = $hold_id;
+
+ $e->xact_begin;
+
+ my $hold = $self->{hold} =
+ $e->retrieve_action_hold_request($hold_id)
+ or return $self->exit_targeter("No hold found");
+
+ return $self->exit_targeter("Hold is not eligible for targeting")
+ if $hold->capture_time || $hold->cancel_time;
+
+ # Remove any existing hold copy maps so they can be replaced.
+ my $evt = $self->remove_copy_maps;
+ return $self->exit_targeter(
+ "Error deleting copy maps: ".$evt->{textcode}) if $evt;
+
+ return $self->exit_targeter("Hold is expired")
+ if $self->handle_expired_hold;
+
+ my @copy_ids = $self->get_hold_copies;
+
+ return $self->handle_no_copies unless @copy_ids;
+
+ $logger->info("targeter: Hold $hold_id has ".
+ scalar(@copy_ids)." potential copies");
+}
+
+
+