1 package OpenILS::Utils::HoldTargeter;
2 # ---------------------------------------------------------------
3 # Copyright (C) 2016 King County Library System
4 # Author: Bill Erickson <berickxx@gmail.com>
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
15 # ---------------------------------------------------------------
19 use OpenSRF::AppSession;
20 use OpenSRF::Utils::Logger qw(:logger);
21 use OpenSRF::Utils::JSON;
22 use OpenILS::Utils::DateTime qw/:datetime/;
23 use OpenILS::Application::AppUtils;
24 use OpenILS::Utils::CStoreEditor qw/:funcs/;
26 our $U = "OpenILS::Application::AppUtils";
27 our $dt_parser = DateTime::Format::ISO8601->new;
29 # See open-ils.hold-targeter API docs for runtime arguments.
31 my ($class, %args) = @_;
33 editor => new_editor(),
34 ou_setting_cache => {},
37 return bless($self, $class);
40 # Returns a list of hold ID's
41 sub find_holds_to_target {
45 # $self->{hold} can be a single hold ID or an array ref of hold IDs
46 return @{$self->{hold}} if ref $self->{hold} eq 'ARRAY';
47 return ($self->{hold});
50 my %frozen_filter = $self->{retarget_frozen} ? () : (frozen => 'f');
53 select => {ahr => ['id']},
56 capture_time => undef,
57 fulfillment_time => undef,
62 {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
63 {class => 'ahr', field => 'request_time'},
64 {class => 'ahr', field => 'prev_check_time'}
68 # Target holds that have no prev_check_time or those whose re-target
69 # time has come. If a soft_retarget_time is specified, that acts as
70 # the boundary. Otherwise, the retarget_time is used.
71 my $start_time = $self->{soft_retarget_time} || $self->{retarget_time};
72 $query->{where}->{'-or'} = [
73 {prev_check_time => undef},
74 {prev_check_time => {'<=' => $start_time->strftime('%F %T%z')}}
77 # parallel < 1 means no parallel
78 my $parallel = ($self->{parallel_count} || 0) > 1 ?
79 $self->{parallel_count} : 0;
82 # In parallel mode, we need to also grab the metarecord for each hold.
98 # In parallel mode, only process holds within the current process
99 # whose metarecord ID modulo the parallel targeter count matches
100 # our paralell targeting slot. This ensures that no 2 processes
101 # will be operating on the same potential copy sets.
103 # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5,
104 # process holds whose metarecord ID's are 2, 7, 12, 17, ...
105 # WHERE MOD(mmrsm.id, 5) = 2
107 # Slots are 1-based at the API level, but 0-based for modulo.
108 my $slot = $self->{parallel_slot} - 1;
110 $query->{where}->{'+mmrsm'} = {
115 params => [$parallel]
121 # Newest-first sorting cares only about hold create_time.
123 [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
124 if $self->{newest_first};
126 my $holds = $self->editor->json_query($query, {substream => 1});
128 return map {$_->{id}} @$holds;
133 return $self->{editor};
136 # Load startup data required by all targeter actions.
139 my $e = $self->editor;
141 # See if the caller provided an interval
142 my $interval = $self->{retarget_interval};
145 # See if we have a global flag value for the interval
147 $interval = $e->search_config_global_flag({
148 name => 'circ.holds.retarget_interval',
152 # If no flag is present, default to a 24-hour retarget interval.
153 $interval = $interval ? $interval->value : '24h';
156 my $retarget_seconds = interval_to_seconds($interval);
158 $self->{retarget_time} = DateTime->now(time_zone => 'local')
159 ->subtract(seconds => $retarget_seconds);
161 $logger->info("Using retarget time: ".
162 $self->{retarget_time}->strftime('%F %T%z'));
164 if ($self->{soft_retarget_interval}) {
166 my $secs = OpenILS::Utils::DateTime->interval_to_seconds(
167 $self->{soft_retarget_interval});
169 $self->{soft_retarget_time} =
170 DateTime->now(time_zone => 'local')->subtract(seconds => $secs);
172 $logger->info("Using soft retarget time: " .
173 $self->{soft_retarget_time}->strftime('%F %T%z'));
176 # Holds targeted in the current targeter instance not be retargeted
177 # until the next check date. If a next_check_interval is provided
178 # it overrides the retarget_interval.
179 my $next_check_secs =
180 $self->{next_check_interval} ?
181 OpenILS::Utils::DateTime->interval_to_seconds($self->{next_check_interval}) :
184 my $next_check_date =
185 DateTime->now(time_zone => 'local')->add(seconds => $next_check_secs);
187 my $next_check_time = $next_check_date->strftime('%F %T%z');
189 $logger->info("Next check time: $next_check_time");
191 # An org unit is considered closed for retargeting purposes
192 # if it's closed both now and at the next re-target date.
193 my $closed = $self->editor->search_actor_org_unit_closed_date({
195 close_start => {'<=', 'now'},
196 close_end => {'>=', 'now'}
198 close_start => {'<=', $next_check_time},
199 close_end => {'>=', $next_check_time}
203 my @closed_orgs = map {$_->org_unit} @$closed;
204 $logger->info("closed org unit IDs: @closed_orgs");
206 # Map of org id to 1. Any org in the map is closed.
207 $self->{closed_orgs} = {map {$_ => 1} @closed_orgs};
211 # Org unit setting fetch+cache
212 # $e is the OpenILS::Utils::HoldTargeter::Single editor. Use it if
213 # provided to avoid timeouts on the in-transaction child editor.
215 my ($self, $org_id, $setting, $e) = @_;
216 my $c = $self->{ou_setting_cache};
218 $e ||= $self->{editor};
219 $c->{$org_id} = {} unless $c->{$org_id};
221 $c->{$org_id}->{$setting} =
222 $U->ou_ancestor_setting_value($org_id, $setting, $e)
223 unless exists $c->{$org_id}->{$setting};
225 return $c->{$org_id}->{$setting};
228 # Fetches settings for a batch of org units. Useful for pre-caching
229 # setting values across a wide variety of org units without having to
230 # make a lookup call for every org unit.
231 # First checks to see if a value exists in the cache.
232 # For all non-cached values, looks up in DB, then caches the value.
233 sub precache_batch_ou_settings {
234 my ($self, $org_ids, $setting, $e) = @_;
236 $e ||= $self->{editor};
237 my $c = $self->{ou_setting_cache};
240 for my $org_id (@$org_ids) {
241 next if exists $c->{$org_id}->{$setting};
242 push (@orgs, $org_id);
245 return unless @orgs; # value aready cached for all requested orgs.
248 $U->ou_ancestor_setting_batch_by_org_insecure(\@orgs, $setting, $e);
250 for my $org_id (keys %settings) {
251 $c->{$org_id}->{$setting} = $settings{$org_id}->{value};
255 # -----------------------------------------------------------------------
256 # Knows how to target a single hold.
257 # -----------------------------------------------------------------------
258 package OpenILS::Utils::HoldTargeter::Single;
262 use OpenSRF::AppSession;
263 use OpenILS::Utils::DateTime qw/:datetime/;
264 use OpenSRF::Utils::Logger qw(:logger);
265 use OpenILS::Application::AppUtils;
266 use OpenILS::Utils::CStoreEditor qw/:funcs/;
269 my ($class, %args) = @_;
272 editor => new_editor(),
276 return bless($self, $class);
279 # Parent targeter object.
281 my ($self, $parent) = @_;
282 $self->{parent} = $parent if $parent;
283 return $self->{parent};
287 my ($self, $hold_id) = @_;
288 $self->{hold_id} = $hold_id if $hold_id;
289 return $self->{hold_id};
293 my ($self, $hold) = @_;
294 $self->{hold} = $hold if $hold;
295 return $self->{hold};
300 my ($self, $message) = @_;
301 $self->{message} = $message if $message;
302 return $self->{message} || '';
305 # True if the hold was successfully targeted.
307 my ($self, $success) = @_;
308 $self->{success} = $success if defined $success;
309 return $self->{success};
312 # True if targeting exited early on an unrecoverable error.
314 my ($self, $error) = @_;
315 $self->{error} = $error if defined $error;
316 return $self->{error};
321 return $self->{editor};
328 hold => $self->hold_id,
329 error => $self->error,
330 success => $self->success,
331 message => $self->message,
332 target => $self->hold ? $self->hold->current_copy : undef,
333 old_target => $self->{previous_copy_id},
334 found_copy => $self->{found_copy},
335 eligible_copies => $self->{eligible_copy_count}
339 # List of potential copies in the form of slim hashes. This list
340 # evolves as copies are filtered as they are deemed non-targetable.
342 my ($self, $copies) = @_;
343 $self->{copies} = $copies if $copies;
344 return $self->{copies};
347 # Final set of potential copies, including those that may not be
348 # currently targetable, that may be eligible for recall processing.
350 my ($self, $recall_copies) = @_;
351 $self->{recall_copies} = $recall_copies if $recall_copies;
352 return $self->{recall_copies};
355 # Maps copy ID's to their hold proximity
357 my ($self, $copy_prox_map) = @_;
358 $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
359 return $self->{copy_prox_map};
363 my ($self, $msg, $err) = @_;
364 my $level = $err ? 'error' : 'info';
365 $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
368 # Captures the exit message, rolls back the cstore transaction/connection,
370 # is_error : log the final message and editor event at ERR level.
372 my ($self, $msg, $is_error) = @_;
374 $self->message($msg);
375 my $log = "exiting => $msg";
378 # On error, roll back and capture the last editor event for logging.
380 my $evt = $self->editor->die_event;
381 $log .= " [".$evt->{textcode}."]" if $evt;
384 $self->log_hold($log, 1);
387 # Attempt a rollback and disconnect when each hold exits
388 # to avoid the possibility of leaving cstore's pinned.
389 # Note: ->rollback is a no-op when a ->commit has already occured.
391 $self->editor->rollback;
392 $self->log_hold($log);
398 # Cancel expired holds and kick off the A/T no-target event. Returns
399 # true (i.e. keep going) if the hold is not expired. Returns false if
400 # the hold is canceled or a non-recoverable error occcurred.
401 sub handle_expired_hold {
403 my $hold = $self->hold;
405 return 1 unless $hold->expire_time;
408 $dt_parser->parse_datetime(clean_ISO8601($hold->expire_time));
410 DateTime->compare($ex_time, DateTime->now(time_zone => 'local')) < 0;
414 $hold->cancel_time('now');
415 $hold->cancel_cause(1); # == un-targeted expiration
417 $self->editor->update_action_hold_request($hold)
418 or return $self->exit_targeter("Error canceling hold", 1);
420 $self->editor->commit;
422 # Fire the A/T handler, but don't wait for a response.
423 OpenSRF::AppSession->create('open-ils.trigger')->request(
424 'open-ils.trigger.event.autocreate',
425 'hold_request.cancel.expire_no_target',
426 $hold, $hold->pickup_lib
429 return $self->exit_targeter("Hold is expired");
432 # Find potential copies for hold mapping and targeting.
433 sub get_hold_copies {
435 my $e = $self->editor;
436 my $hold = $self->hold;
438 my $hold_target = $hold->target;
439 my $hold_type = $hold->hold_type;
440 my $org_unit = $hold->selection_ou;
441 my $org_depth = $hold->selection_depth || 0;
445 acp => ['id', 'status', 'circ_lib'],
446 ahr => ['current_copy']
450 # Tag copies that are in use by other holds so we don't
451 # try to target them for our hold.
454 fkey => 'id', # acp.id
455 field => 'current_copy',
457 fulfillment_time => undef,
458 cancel_time => undef,
459 id => {'!=' => $self->hold_id}
471 transform => 'actor.org_unit_descendants',
473 result_field => 'id',
474 params => [$org_depth]
478 where => {id => $org_unit}
485 unless ($hold_type eq 'R' || $hold_type eq 'F') {
486 # Add the holdability filters to the copy query, unless
487 # we're processing a Recall or Force hold, which bypass most
488 # holdability checks.
490 $query->{from}->{acp}->{acpl} = {
492 filter => {holdable => 't', deleted => 'f'},
496 $query->{from}->{acp}->{ccs} = {
498 filter => {holdable => 't'},
502 $query->{where}->{'+acp'}->{holdable} = 't';
503 $query->{where}->{'+acp'}->{mint_condition} = 't'
504 if $U->is_true($hold->mint_condition);
507 unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
508 # For volume and higher level holds, avoid targeting copies that
509 # act as instances of monograph parts.
510 $query->{from}->{acp}->{acpm} = {
512 field => 'target_copy',
516 $query->{where}->{'+acpm'}->{id} = undef;
519 if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
521 $query->{where}->{'+acp'}->{id} = $hold_target;
523 } elsif ($hold_type eq 'V') {
525 $query->{where}->{'+acp'}->{call_number} = $hold_target;
527 } elsif ($hold_type eq 'P') {
529 $query->{from}->{acp}->{acpm} = {
530 field => 'target_copy',
532 filter => {part => $hold_target},
535 } elsif ($hold_type eq 'I') {
537 $query->{from}->{acp}->{sitem} = {
540 filter => {issuance => $hold_target},
543 } elsif ($hold_type eq 'T') {
545 $query->{from}->{acp}->{acn} = {
547 fkey => 'call_number',
551 filter => {id => $hold_target},
557 } else { # Metarecord hold
559 $query->{from}->{acp}->{acn} = {
561 fkey => 'call_number',
570 filter => {metarecord => $hold_target},
577 if ($hold->holdable_formats) {
578 # Compile the JSON-encoded metarecord holdable formats
579 # to an Intarray query_int string.
580 my $query_int = $e->json_query({
582 'metabib.compile_composite_attr',
583 $hold->holdable_formats
586 # TODO: ^- any way to add this as a filter in the main query?
589 # Only pull potential copies from records that satisfy
590 # the holdable formats query.
591 my $qint = $query_int->{'metabib.compile_composite_attr'};
592 $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
595 filter => {vlist => {'@@' => $qint}}
601 my $copies = $e->json_query($query);
602 $self->{eligible_copy_count} = scalar(@$copies);
604 $self->log_hold($self->{eligible_copy_count}." potential copies");
606 # Let the caller know we encountered the copy they were interested in.
607 $self->{found_copy} = 1 if $self->{find_copy}
608 && grep {$_->{id} eq $self->{find_copy}} @$copies;
610 $self->copies($copies);
615 # Delete and rebuild copy maps
616 sub update_copy_maps {
618 my $e = $self->editor;
620 my $resp = $e->json_query({from => [
621 'action.hold_request_regen_copy_maps',
623 '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
626 # The above call can fail if another process is updating
627 # copy maps for this hold at the same time.
628 return 1 if $resp && @$resp;
630 return $self->exit_targeter("Error creating hold copy maps", 1);
633 # unique set of circ lib IDs for all in-progress copy blobs.
634 sub get_copy_circ_libs {
636 my %orgs = map {$_->{circ_lib} => 1} @{$self->copies};
641 # Returns a map of proximity values to arrays of copy hashes.
642 # The copy hash arrays are weighted consistent with the org unit hold
643 # target weight, meaning that a given copy may appear more than once
644 # in its proximity list.
645 sub compile_weighted_proximity_map {
648 # Collect copy proximity info (generated via DB trigger)
649 # from our newly create copy maps.
650 my $hold_copy_maps = $self->editor->json_query({
651 select => {ahcm => ['target_copy', 'proximity']},
653 where => {hold => $self->hold_id}
657 map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
659 # Pre-fetch the org setting value for all circ libs so that
660 # later calls can reference the cached value.
661 $self->parent->precache_batch_ou_settings($self->get_copy_circ_libs,
662 'circ.holds.org_unit_target_weight', $self->editor);
665 for my $copy_hash (@{$self->copies}) {
666 my $prox = $copy_prox_map{$copy_hash->{id}};
667 $prox_map{$prox} ||= [];
669 my $weight = $self->parent->get_ou_setting(
670 $copy_hash->{circ_lib},
671 'circ.holds.org_unit_target_weight', $self->editor) || 1;
673 # Each copy is added to the list once per target weight.
674 push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
677 return $self->{weighted_prox_map} = \%prox_map;
680 # Returns true if filtering completed without error, false otherwise.
681 sub filter_closed_date_copies {
684 # Pre-fetch the org setting value for all represented circ libs that
685 # are closed, minuse the pickup_lib, since it has its own special setting.
686 my $circ_libs = $self->get_copy_circ_libs;
689 $self->parent->{closed_orgs}->{$_} &&
690 $_ ne $self->hold->pickup_lib
694 # If none of the represented circ libs are closed, we're done here.
695 return 1 unless @$circ_libs;
697 $self->parent->precache_batch_ou_settings(
698 $circ_libs, 'circ.holds.target_when_closed', $self->editor);
701 for my $copy_hash (@{$self->copies}) {
702 my $clib = $copy_hash->{circ_lib};
704 if ($self->parent->{closed_orgs}->{$clib}) {
705 # Org unit is currently closed. See if it matters.
707 my $ous = $self->hold->pickup_lib eq $clib ?
708 'circ.holds.target_when_closed_if_at_pickup_lib' :
709 'circ.holds.target_when_closed';
712 $self->parent->get_ou_setting($clib, $ous, $self->editor)) {
713 # Targeting not allowed at this circ lib when its closed
715 $self->log_hold("skipping copy ".
716 $copy_hash->{id}." at closed org $clib");
723 push(@filtered_copies, $copy_hash);
726 # Update our in-progress list of copies to reflect the filtered set.
727 $self->copies(\@filtered_copies);
732 # Limit the set of potential copies to those that are
733 # in a targetable status.
734 # Returns true if filtering completes without error, false otherwise.
735 sub filter_copies_by_status {
738 # Track checked out copies for later recall
739 $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
742 grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
748 # Remove copies that are currently targeted by other holds.
749 # Returns true if filtering completes without error, false otherwise.
750 sub filter_copies_in_use {
753 # A copy with a 'current_copy' value means it's in use by another hold.
755 grep {!$_->{current_copy}} @{$self->copies}
761 # Returns true if inspection completed without error, false otherwise.
762 sub inspect_previous_target {
764 my $hold = $self->hold;
765 my @copies = @{$self->copies};
768 return 1 unless my $prev_id = $hold->current_copy;
770 $self->{previous_copy_id} = $prev_id;
772 # See if the previous copy is in our list of valid copies.
773 my ($prev) = grep {$_->{id} eq $prev_id} @copies;
775 # exit if previous target is no longer valid.
776 return 1 unless $prev;
778 my $soft_retarget = 0;
780 if ($self->parent->{soft_retarget_time}) {
781 # A hold is soft-retarget-able if its prev_check_time is
782 # later then the retarget_time, i.e. it sits between the
783 # soft_retarget_time and the retarget_time.
785 my $pct = $dt_parser->parse_datetime(
786 clean_ISO8601($hold->prev_check_time));
789 DateTime->compare($pct, $self->parent->{retarget_time}) > 0;
792 if ($soft_retarget) {
794 # In soft-retarget mode, if the existing copy is still a valid
795 # target for the hold, exit early.
796 if ($self->copy_is_permitted($prev)) {
798 # Commit to persist the updated action.hold_copy_map's
799 $self->editor->commit;
801 return $self->exit_targeter(
802 "Exiting early on soft-retarget with viable copy = $prev_id");
805 $self->log_hold("soft retarget failed because copy $prev_id is ".
806 "no longer targetable for this hold. Retargeting...");
811 # Previous copy /may/ be targetable. Keep it around for later
812 # in case we need to confirm its viability and re-use it.
813 $self->{valid_previous_copy} = $prev;
816 # Remove the previous copy from the working set of potential copies.
817 # It will be revisited later if needed.
818 $self->copies([grep {$_->{id} ne $prev_id} @copies]);
823 # Returns true if we have at least one potential copy remaining, thus
824 # targeting should continue. Otherwise, the hold is updated to reflect
825 # that there is no target and returns false to stop targeting.
826 sub handle_no_copies {
827 my ($self, %args) = @_;
830 # If 'force' is set, the caller is saying that all copies have
831 # failed. Otherwise, see if we have any copies left to inspect.
832 return 1 if @{$self->copies} || $self->{valid_previous_copy};
835 # At this point, all copies have been inspected and none
836 # have yielded a targetable item.
838 if ($args{process_recalls}) {
839 # See if we have any copies/circs to recall.
840 return unless $self->process_recalls;
843 my $hold = $self->hold;
844 $hold->clear_current_copy;
845 $hold->prev_check_time('now');
847 $self->editor->update_action_hold_request($hold)
848 or return $self->exit_targeter("Error updating hold request", 1);
850 $self->editor->commit;
851 return $self->exit_targeter("Hold has no targetable copies");
854 # Force and recall holds bypass validity tests. Returns the first
855 # (and presumably only) copy in our list of valid copies when a
856 # F or R hold is encountered. Returns undef otherwise.
857 sub attempt_force_recall_target {
859 return $self->copies->[0] if
860 $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
864 sub attempt_to_find_copy {
867 return undef unless @{$self->copies};
869 my $max_loops = $self->parent->get_ou_setting(
870 $self->hold->pickup_lib,
871 'circ.holds.max_org_unit_target_loops',
875 return $self->target_by_org_loops($max_loops) if $max_loops;
877 # When not using target loops, targeting is based solely on
878 # proximity and org unit target weight.
879 $self->compile_weighted_proximity_map;
881 return $self->find_nearest_copy;
884 # Returns 2 arrays. The first is a list of copies whose circ lib's
885 # unfulfilled target count matches the provided $iter value. The
886 # second list is all other copies, returned for convenience.
887 sub get_copies_at_loop_iter {
888 my ($self, $targeted_libs, $iter) = @_;
890 my @iter_copies; # copies to try now.
891 my @remaining_copies; # copies to try later
893 for my $copy (@{$self->copies}) {
897 # Start with copies at circ libs that have never been targeted.
898 $match = 1 unless grep {
899 $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
902 # Find copies at branches whose target count
903 # matches the current (non-zero) loop depth.
906 $_->{count} eq $iter &&
907 $_->{circ_lib} eq $copy->{circ_lib}
912 push(@iter_copies, $copy);
914 push(@remaining_copies, $copy);
919 sprintf("%d potential copies at max-loops iteration level $iter. ".
920 "%d remain to be tested at a higher loop iteration level.",
921 scalar(@iter_copies),
922 scalar(@remaining_copies)
926 return (\@iter_copies, \@remaining_copies);
929 # Find libs whose unfulfilled target count is less than the maximum
930 # configured loop count. Target copies in order of their circ_lib's
931 # target count (starting at 0) and moving up. Copies within each
932 # loop count group are weighted based on configured hold weight. If
933 # no copies in a given group are targetable, move up to the next
934 # unfulfilled target level. Keep doing this until all potential
935 # copies have been tried or max targets loops is exceeded.
936 # Returns a targetable copy if one is found, undef otherwise.
937 sub target_by_org_loops {
938 my ($self, $max_loops) = @_;
940 my $targeted_libs = $self->editor->json_query({
941 select => {aufhl => ['circ_lib', 'count']},
943 where => {hold => $self->hold_id},
944 order_by => [{class => 'aufhl', field => 'count'}]
947 my $max_tried = 0; # Highest per-lib target attempts.
948 foreach (@$targeted_libs) {
949 $max_tried = $_->{count} if $_->{count} > $max_tried;
952 $self->log_hold("Max lib attempts is $max_tried. ".
953 scalar(@$targeted_libs)." libs have been targeted at least once.");
955 # $loop_iter represents per-lib target attemtps already made.
956 # When loop_iter equals max loops, all libs with targetable copies
957 # have been targeted the maximum number of times. loop_iter starts
958 # at 0 to pick up libs that have never been targeted.
960 while (++$loop_iter < $max_loops) {
962 # Ran out of copies to try before exceeding max target loops.
963 # Nothing else to do here.
964 return undef unless @{$self->copies};
966 my ($iter_copies, $remaining_copies) =
967 $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
969 next unless @$iter_copies;
971 $self->copies($iter_copies);
973 # Update the proximity map to only include the copies
974 # from this loop-depth iteration.
975 $self->compile_weighted_proximity_map;
977 my $copy = $self->find_nearest_copy;
978 return $copy if $copy; # found one!
980 # No targetable copy at the current target loop.
981 # Update our current copy set to the not-yet-tested copies.
982 $self->copies($remaining_copies);
985 # Avoid canceling the hold with exceeds-loops unless at least one
986 # lib has been targeted max_loops times. Otherwise, the hold goes
987 # back to waiting for another copy (or retargets its current copy).
988 return undef if $max_tried < $max_loops;
990 # At least one lib has been targeted max-loops times and zero
991 # other copies are targetable. All options have been exhausted.
992 return $self->handle_exceeds_target_loops;
995 # Cancel the hold, fire the no-target A/T event handler, and exit.
996 sub handle_exceeds_target_loops {
998 my $e = $self->editor;
999 my $hold = $self->hold;
1001 $hold->cancel_time('now');
1002 $hold->cancel_cause(1); # = un-targeted expiration
1004 $e->update_action_hold_request($hold)
1005 or return $self->exit_targeter("Error updating hold request", 1);
1009 # Fire the A/T handler, but don't wait for a response.
1010 OpenSRF::AppSession->create('open-ils.trigger')->request(
1011 'open-ils.trigger.event.autocreate',
1012 'hold_request.cancel.expire_no_target',
1013 $hold, $hold->pickup_lib
1016 return $self->exit_targeter("Hold exceeded max target loops");
1019 # When all else fails, see if we can reuse the previously targeted copy.
1020 sub attempt_prev_copy_retarget {
1023 # earlier target logic can in some cases cancel the hold.
1024 return undef if $self->hold->cancel_time;
1026 my $prev_copy = $self->{valid_previous_copy};
1027 return undef unless $prev_copy;
1029 $self->log_hold("attempting to re-target previously ".
1030 "targeted copy for hold ".$self->hold_id);
1032 if ($self->copy_is_permitted($prev_copy)) {
1033 $self->log_hold("retargeting the previously ".
1034 "targeted copy [".$prev_copy->{id}."]" );
1041 # Returns the closest copy by proximity that is a confirmed valid
1043 sub find_nearest_copy {
1045 my %prox_map = %{$self->{weighted_prox_map}};
1046 my $hold = $self->hold;
1049 # Pick a copy at random from each tier of the proximity map,
1050 # starting at the lowest proximity and working up, until a
1051 # copy is found that is suitable for targeting.
1052 for my $prox (sort {$a <=> $b} keys %prox_map) {
1053 my @copies = @{$prox_map{$prox}};
1054 next unless @copies;
1056 my $rand = int(rand(scalar(@copies)));
1058 while (my ($c) = splice(@copies, $rand, 1)) {
1059 $rand = int(rand(scalar(@copies)));
1060 next if $seen{$c->{id}};
1062 return $c if $self->copy_is_permitted($c);
1063 $seen{$c->{id}} = 1;
1065 last unless(@copies);
1072 # Returns true if the provided copy passes the hold permit test for our
1073 # hold and can be used for targeting.
1074 # When a copy fails the test, it is removed from $self->copies.
1075 sub copy_is_permitted {
1076 my ($self, $copy) = @_;
1077 return 0 unless $copy;
1079 my $resp = $self->editor->json_query({
1081 'action.hold_retarget_permit_test',
1082 $self->hold->pickup_lib,
1083 $self->hold->request_lib,
1086 $self->hold->requestor
1090 return 1 if $U->is_true($resp->[0]->{success});
1092 # Copy is confirmed non-viable.
1093 # Remove it from our potentials list.
1095 grep {$_->{id} ne $copy->{id}} @{$self->copies}
1101 # Sets hold.current_copy to the provided copy.
1102 sub apply_copy_target {
1103 my ($self, $copy) = @_;
1104 my $e = $self->editor;
1105 my $hold = $self->hold;
1107 $hold->current_copy($copy->{id});
1108 $hold->prev_check_time('now');
1110 $e->update_action_hold_request($hold)
1111 or return $self->exit_targeter("Error updating hold request", 1);
1114 $self->{success} = 1;
1115 return $self->exit_targeter("successfully targeted copy ".$copy->{id});
1118 # Creates a new row in action.unfulfilled_hold_list for our hold.
1119 # Returns 1 if all is OK, false on error.
1120 sub log_unfulfilled_hold {
1122 return 1 unless my $prev_id = $self->{previous_copy_id};
1123 my $e = $self->editor;
1126 "hold was not fulfilled by previous targeted copy $prev_id");
1129 if ($self->{valid_previous_copy}) {
1130 $circ_lib = $self->{valid_previous_copy}->{circ_lib};
1133 # We don't have a handle on the previous copy to get its
1134 # circ lib. Fetch it.
1135 $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
1138 my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
1139 $unful->hold($self->hold_id);
1140 $unful->circ_lib($circ_lib);
1141 $unful->current_copy($prev_id);
1143 $e->create_action_unfulfilled_hold_list($unful) or
1144 return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
1149 sub process_recalls {
1151 my $e = $self->editor;
1153 my $pu_lib = $self->hold->pickup_lib;
1156 $self->parent->get_ou_setting(
1157 $pu_lib, 'circ.holds.recall_threshold', $self->editor)
1161 $self->parent->get_ou_setting(
1162 $pu_lib, 'circ.holds.recall_return_interval', $self->editor)
1165 # Give me the ID of every checked out copy living at the hold
1167 my @copy_ids = map {$_->{id}}
1168 grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
1170 return 1 unless @copy_ids;
1172 my $circ = $e->search_action_circulation([
1173 { target_copy => \@copy_ids,
1174 checkin_time => undef,
1175 duration => {'>' => $threshold}
1177 order_by => 'due_date',
1182 return unless $circ;
1184 $self->log_hold("recalling circ ".$circ->id);
1186 my $old_due_date = DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($circ->due_date));
1188 # Give the user a new due date of either a full recall threshold,
1189 # or the return interval, whichever is further in the future.
1190 my $threshold_date = DateTime::Format::ISO8601
1191 ->parse_datetime(clean_ISO8601($circ->xact_start))
1192 ->add(seconds => interval_to_seconds($threshold));
1194 my $return_date = DateTime->now(time_zone => 'local')->add(
1195 seconds => interval_to_seconds($interval));
1197 if (DateTime->compare($threshold_date, $return_date) == 1) {
1198 # extend $return_date to threshold
1199 $return_date = $threshold_date;
1201 # But don't go past the original due date
1202 # (the threshold should not be past the due date, but manual edits can
1204 if (DateTime->compare($return_date, $old_due_date) == 1) {
1205 # truncate $return_date to due date
1206 $return_date = $old_due_date;
1209 my %update_fields = (
1210 due_date => $return_date->iso8601(),
1211 renewal_remaining => 0,
1215 $self->parent->get_ou_setting(
1216 $pu_lib, 'circ.holds.recall_fine_rules', $self->editor);
1218 # If the OU hasn't defined new fine rules for recalls, keep them
1221 $self->log_hold("applying recall fine rules: $fine_rules");
1222 my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
1223 $update_fields{recurring_fine} = $rules->[0];
1224 $update_fields{fine_interval} = $rules->[1];
1225 $update_fields{max_fine} = $rules->[2];
1228 # Copy updated fields into circ object.
1229 $circ->$_($update_fields{$_}) for keys %update_fields;
1231 $e->update_action_circulation($circ)
1232 or return $self->exit_targeter(
1233 "Error updating circulation object in process_recalls", 1);
1235 # Create trigger event for notifying current user
1236 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
1237 $ses->request('open-ils.trigger.event.autocreate',
1238 'circ.recall.target', $circ, $circ->circ_lib);
1243 # Target a single hold request
1245 my ($self, $hold_id) = @_;
1247 my $e = $self->editor;
1248 $self->hold_id($hold_id);
1250 $self->log_hold("processing...");
1254 my $hold = $e->retrieve_action_hold_request($hold_id)
1255 or return $self->exit_targeter("No hold found", 1);
1257 return $self->exit_targeter("Hold is not eligible for targeting")
1258 if $hold->capture_time ||
1259 $hold->cancel_time ||
1260 $hold->fulfillment_time ||
1261 $U->is_true($hold->frozen);
1265 return unless $self->handle_expired_hold;
1266 return unless $self->get_hold_copies;
1267 return unless $self->update_copy_maps;
1269 # Confirm that we have something to work on. If we have no
1270 # copies at this point, there's also nothing to recall.
1271 return unless $self->handle_no_copies;
1273 # Trim the set of working copies down to those that are
1274 # currently targetable.
1275 return unless $self->filter_copies_by_status;
1276 return unless $self->filter_copies_in_use;
1277 return unless $self->filter_closed_date_copies;
1279 # Set aside the previously targeted copy for later use as needed.
1280 # Code may exit here in skip_viable mode if the existing
1281 # current_copy value is still viable.
1282 return unless $self->inspect_previous_target;
1284 # Log that the hold was not captured.
1285 return unless $self->log_unfulfilled_hold;
1287 # Confirm again we have something to work on. If we have no
1288 # targetable copies now, there may be a copy that can be recalled.
1289 return unless $self->handle_no_copies(process_recalls => 1);
1291 # At this point, the working list of copies has been trimmed to
1292 # those that are currently targetable at a superficial level.
1293 # (They are holdable and available). Now the code steps through
1294 # these copies in order of priority and pickup lib proximity to
1295 # find a copy that is confirmed targetable by policy.
1297 my $copy = $self->attempt_force_recall_target ||
1298 $self->attempt_to_find_copy ||
1299 $self->attempt_prev_copy_retarget;
1301 # See if one of the above attempt* calls canceled the hold as a side
1302 # effect of looking for a copy to target.
1303 return if $hold->cancel_time;
1305 return $self->apply_copy_target($copy) if $copy;
1307 # No targetable copy was found. Fire the no-copy handler.
1308 $self->handle_no_copies(force => 1, process_recalls => 1);