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 OpenSRF::Utils 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 target() for runtime arguments.
31 my ($class, %args) = @_;
33 editor => new_editor(),
34 ou_setting_cache => {},
37 return bless($self, $class);
40 # Target and retarget holds.
41 # By default, targets all holds that need targeting, meaning those that
42 # have either never been targeted or those whose prev_check_time exceeds
43 # the retarget interval.
45 # Returns an array of targeter response objects, one entry per hold
46 # targeted. See also return_count.
48 # Optional parameters:
51 # (Re)target a specific hold.
54 # Return the total number of holds processed instead of a result
55 # object for every targeted hold. Ideal for large batch targeting.
57 # retarget_interval => <interval string>
58 # Override the 'circ.holds.retarget_interval' global_flag value.
61 # Target holds in reverse order of create_time.
64 # Avoid retargeting holds whose current_copy is still viable and
65 # permitted. This is useful for repairing holds whose targeted copy
66 # has become non-viable for a given hold because its status changed or
67 # policies affecting the hold/copy no longer allow it to be targeted.
68 # This setting can be used in conjunction with any other settings.
71 # USE WITH CAUTION. Forces (re)targeting of all active holds. This
72 # is primarily useful or testing.
75 # Number of parallel targeters running. This acts as the indication
76 # that other targeter instances are running.
78 # parallel_slot => n [starts at 1]
79 # Sets the parallel targeter instance position/slot. Used to determine
80 # which holds to process to avoid conflicts with other running instances.
83 my ($self, %args) = @_;
85 $self->{$_} = $args{$_} for keys %args;
92 for my $hold_id ($self->find_holds_to_target) {
93 my $single = OpenILS::Utils::HoldTargeter::Single->new(
95 skip_viable => $args{skip_viable}
97 $single->target($hold_id);
98 push(@responses, $single->result) unless $self->{return_count};
102 return $self->{return_count} ? $count : \@responses;
105 sub find_holds_to_target {
108 return ($self->{hold}) if $self->{hold};
111 select => {ahr => ['id']},
114 capture_time => undef,
115 fulfillment_time => undef,
116 cancel_time => undef,
120 {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
121 {class => 'ahr', field => 'request_time'},
122 {class => 'ahr', field => 'prev_check_time'}
126 if (!$self->{target_all}) {
127 # Unless we're retargeting all holds, limit to holds that have no
128 # prev_check_time or those whose prev_check_time occurred
129 # before the retarget interval.
131 my $date = DateTime->now->subtract(
132 seconds => $self->{retarget_interval});
134 $query->{where}->{'-or'} = [
135 {prev_check_time => undef},
136 {prev_check_time => {'<=' => $date->strftime('%F %T%z')}}
140 # parallel < 1 means no parallel
141 my $parallel = ($self->{parallel_count} || 0) > 1 ?
142 $self->{parallel_count} : 0;
145 # In parallel mode, we need to also grab the metarecord for each hold.
146 $query->{select}->{mmrsm} = ['metarecord'];
163 # Newest-first sorting cares only about hold create_time.
165 [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
166 if $self->{newest_first};
168 my $holds = $self->editor->json_query($query, {substream => 1});
170 # In parallel mode, only process holds within the current process
171 # whose metarecord ID modulo the parallel targeter count matches
172 # our paralell targeting slot. This ensures that no 2 processes
173 # will be operating on the same potential copy sets.
175 # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5,
176 # process holds whose metarecord ID's are 2, 7, 12, 17, ...
179 # Slots are 1-based at the API level, but 0-based for modulo.
180 my $slot = $self->{parallel_slot} - 1;
183 grep { ($_->{metarecord} % $parallel) == $slot } @$holds;
185 $logger->info(sprintf(
186 "targeter: parallel targeter (slot %d of %d) trimmed ".
187 "targetable holds set down to %d from %d holds",
188 $slot + 1, $parallel, scalar(@slot_holds), scalar(@$holds)
191 $holds = \@slot_holds;
194 return map {$_->{id}} @$holds;
199 return $self->{editor};
202 # Load startup data required by all targeter actions.
205 my $e = $self->editor;
207 my $closed_orgs_query = {
208 close_start => {'<=', 'now'},
209 close_end => {'>=', 'now'}
212 if (!$self->{target_all}) {
214 # See if the caller provided an interval
215 my $interval = $self->{retarget_interval};
218 # See if we have a global flag value for the interval
220 $interval = $e->search_config_global_flag({
221 name => 'circ.holds.retarget_interval',
225 # If no flag is present, default to a 24-hour retarget interval.
226 $interval = $interval ? $interval->value : '24h';
229 # Convert the interval to seconds for current and future use.
230 $self->{retarget_interval} = interval_to_seconds($interval);
232 # An org unit is considered closed for retargeting purposes
233 # if it's closed both now and at the next re-target date.
235 my $next_check_time =
236 DateTime->now->add(seconds => $self->{retarget_interval})
237 ->strftime('%F %T%z');
239 $closed_orgs_query = {
241 $closed_orgs_query, {
242 close_start => {'<=', $next_check_time},
243 close_end => {'>=', $next_check_time}
250 $self->editor->search_actor_org_unit_closed_date($closed_orgs_query);
252 # Map of org id to 1. Any org in the map is closed.
253 $self->{closed_orgs} = {map {$_->org_unit => 1} @$closed};
256 # Org unit setting fetch+cache
258 my ($self, $org_id, $setting) = @_;
259 my $c = $self->{ou_setting_cache};
261 $c->{$org_id} = {} unless $c->{$org_id};
263 $c->{$org_id}->{$setting} =
264 $U->ou_ancestor_setting_value($org_id, $setting, $self->{editor})
265 unless exists $c->{$org_id}->{$setting};
267 return $c->{$org_id}->{$setting};
270 # -----------------------------------------------------------------------
271 # Knows how to target a single hold.
272 # -----------------------------------------------------------------------
273 package OpenILS::Utils::HoldTargeter::Single;
277 use OpenSRF::AppSession;
278 use OpenSRF::Utils qw/:datetime/;
279 use OpenSRF::Utils::Logger qw(:logger);
280 use OpenILS::Application::AppUtils;
281 use OpenILS::Utils::CStoreEditor qw/:funcs/;
284 my ($class, %args) = @_;
287 editor => new_editor(),
291 return bless($self, $class);
294 # Parent targeter object.
296 my ($self, $parent) = @_;
297 $self->{parent} = $parent if $parent;
298 return $self->{parent};
302 my ($self, $hold_id) = @_;
303 $self->{hold_id} = $hold_id if $hold_id;
304 return $self->{hold_id};
308 my ($self, $hold) = @_;
309 $self->{hold} = $hold if $hold;
310 return $self->{hold};
315 my ($self, $message) = @_;
316 $self->{message} = $message if $message;
317 return $self->{message} || '';
320 # True if the hold was successfully targeted.
322 my ($self, $success) = @_;
323 $self->{success} = $success if defined $success;
324 return $self->{success};
327 # True if targeting exited early on an unrecoverable error.
329 my ($self, $error) = @_;
330 $self->{error} = $error if defined $error;
331 return $self->{error};
336 return $self->{editor};
343 hold => $self->hold_id,
344 error => $self->error,
345 success => $self->success,
346 message => $self->message,
347 target => $self->hold ? $self->hold->current_copy : undef,
348 old_target => $self->{previous_copy_id},
349 found_copy => $self->{found_copy},
350 eligible_copies => $self->{eligible_copy_count}
354 # List of potential copies in the form of slim hashes. This list
355 # evolves as copies are filtered as they are deemed non-targetable.
357 my ($self, $copies) = @_;
358 $self->{copies} = $copies if $copies;
359 return $self->{copies};
362 # Final set of potential copies, including those that may not be
363 # currently targetable, that may be eligible for recall processing.
365 my ($self, $recall_copies) = @_;
366 $self->{recall_copies} = $recall_copies if $recall_copies;
367 return $self->{recall_copies};
370 # Maps copy ID's to their hold proximity
372 my ($self, $copy_prox_map) = @_;
373 $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
374 return $self->{copy_prox_map};
378 my ($self, $msg, $err) = @_;
379 my $level = $err ? 'error' : 'info';
380 $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
383 # Captures the exit message, rolls back the cstore transaction/connection,
385 # is_error : log the final message and editor event at ERR level.
387 my ($self, $msg, $is_error) = @_;
389 $self->message($msg);
390 my $log = "exiting => $msg";
393 # On error, roll back and capture the last editor event for logging.
395 my $evt = $self->editor->die_event;
396 $log .= " [".$evt->{textcode}."]" if $evt;
399 $self->log_hold($log, 1);
402 # Attempt a rollback and disconnect when each hold exits
403 # to avoid the possibility of leaving cstore's pinned.
404 # Note: ->rollback is a no-op when a ->commit has already occured.
406 $self->editor->rollback;
407 $self->log_hold($log);
413 # Cancel expired holds and kick off the A/T no-target event. Returns
414 # true (i.e. keep going) if the hold is not expired. Returns false if
415 # the hold is canceled or a non-recoverable error occcurred.
416 sub handle_expired_hold {
418 my $hold = $self->hold;
420 return 1 unless $hold->expire_time;
423 $dt_parser->parse_datetime(cleanse_ISO8601($hold->expire_time));
424 return 1 unless DateTime->compare($ex_time, DateTime->now) < 0;
428 $hold->cancel_time('now');
429 $hold->cancel_cause(1); # == un-targeted expiration
431 $self->editor->update_action_hold_request($hold)
432 or return $self->exit_targeter("Error canceling hold", 1);
434 $self->editor->commit;
436 # Fire the A/T handler, but don't wait for a response.
437 OpenSRF::AppSession->create('open-ils.trigger')->request(
438 'open-ils.trigger.event.autocreate',
439 'hold_request.cancel.expire_no_target',
440 $hold, $hold->pickup_lib
443 return $self->exit_targeter("Hold is expired");
446 # Find potential copies for hold mapping and targeting.
447 sub get_hold_copies {
449 my $e = $self->editor;
450 my $hold = $self->hold;
452 my $hold_target = $hold->target;
453 my $hold_type = $hold->hold_type;
454 my $org_unit = $hold->selection_ou;
455 my $org_depth = $hold->selection_depth || 0;
459 acp => ['id', 'status', 'circ_lib'],
460 ahr => ['current_copy']
464 # Tag copies that are in use by other holds so we don't
465 # try to target them for our hold.
468 fkey => 'id', # acp.id
469 field => 'current_copy',
471 fulfillment_time => undef,
472 cancel_time => undef,
473 id => {'!=' => $self->hold_id}
485 transform => 'actor.org_unit_descendants',
487 result_field => 'id',
488 params => [$org_depth]
492 where => {id => $org_unit}
499 unless ($hold_type eq 'R' || $hold_type eq 'F') {
500 # Add the holdability filters to the copy query, unless
501 # we're processing a Recall or Force hold, which bypass most
502 # holdability checks.
504 $query->{from}->{acp}->{acpl} = {
506 filter => {holdable => 't', deleted => 'f'},
510 $query->{from}->{acp}->{ccs} = {
512 filter => {holdable => 't'},
516 $query->{where}->{'+acp'}->{holdable} = 't';
517 $query->{where}->{'+acp'}->{mint_condition} = 't'
518 if $U->is_true($hold->mint_condition);
521 unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
522 # For volume and higher level holds, avoid targeting copies that
523 # act as instances of monograph parts.
524 $query->{from}->{acp}->{acpm} = {
526 field => 'target_copy',
530 $query->{where}->{'+acpm'}->{id} = undef;
533 if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
535 $query->{where}->{'+acp'}->{id} = $hold_target;
537 } elsif ($hold_type eq 'V') {
539 $query->{where}->{'+acp'}->{call_number} = $hold_target;
541 } elsif ($hold_type eq 'P') {
543 $query->{from}->{acp}->{acpm} = {
544 field => 'target_copy',
546 filter => {part => $hold_target},
549 } elsif ($hold_type eq 'I') {
551 $query->{from}->{acp}->{sitem} = {
554 filter => {issuance => $hold_target},
557 } elsif ($hold_type eq 'T') {
559 $query->{from}->{acp}->{acn} = {
561 fkey => 'call_number',
565 filter => {id => $hold_target},
571 } else { # Metarecord hold
573 $query->{from}->{acp}->{acn} = {
575 fkey => 'call_number',
584 filter => {metarecord => $hold_target},
591 if ($hold->holdable_formats) {
592 # Compile the JSON-encoded metarecord holdable formats
593 # to an Intarray query_int string.
594 my $query_int = $e->json_query({
596 'metabib.compile_composite_attr',
597 $hold->holdable_formats
600 # TODO: ^- any way to add this as a filter in the main query?
603 # Only pull potential copies from records that satisfy
604 # the holdable formats query.
605 my $qint = $query_int->{'metabib.compile_composite_attr'};
606 $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
609 filter => {vlist => {'@@' => $qint}}
615 my $copies = $e->json_query($query);
616 $self->{eligible_copy_count} = scalar(@$copies);
618 $self->log_hold($self->{eligible_copy_count}." potential copies");
620 # Let the caller know we encountered the copy they were interested in.
621 $self->{found_copy} = 1 if $self->{find_copy}
622 && grep {$_->{id} eq $self->{find_copy}} @$copies;
624 $self->copies($copies);
629 # Delete and rebuild copy maps
630 sub update_copy_maps {
632 my $e = $self->editor;
634 my $resp = $e->json_query({from => [
635 'action.hold_request_regen_copy_maps',
637 '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
640 # The above call can fail if another process is updating
641 # copy maps for this hold at the same time.
642 return 1 if $resp && @$resp;
644 return $self->exit_targeter("Error creating hold copy maps", 1);
647 # Returns a map of proximity values to arrays of copy hashes.
648 # The copy hash arrays are weighted consistent with the org unit hold
649 # target weight, meaning that a given copy may appear more than once
650 # in its proximity list.
651 sub compile_weighted_proximity_map {
654 # Collect copy proximity info (generated via DB trigger)
655 # from our newly create copy maps.
656 my $hold_copy_maps = $self->editor->json_query({
657 select => {ahcm => ['target_copy', 'proximity']},
659 where => {hold => $self->hold_id}
663 map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
666 for my $copy_hash (@{$self->copies}) {
667 my $prox = $copy_prox_map{$copy_hash->{id}};
668 $prox_map{$prox} ||= [];
670 my $weight = $self->parent->get_ou_setting(
671 $copy_hash->{circ_lib},
672 'circ.holds.org_unit_target_weight') || 1;
674 # Each copy is added to the list once per target weight.
675 push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
678 return $self->{weighted_prox_map} = \%prox_map;
681 # Returns true if filtering completed without error, false otherwise.
682 sub filter_closed_date_copies {
686 for my $copy_hash (@{$self->copies}) {
687 my $clib = $copy_hash->{circ_lib};
689 if ($self->parent->{closed_orgs}->{$clib}) {
690 # Org unit is currently closed. See if it matters.
692 my $ous = $self->hold->pickup_lib eq $clib ?
693 'circ.holds.target_when_closed_if_at_pickup_lib' :
694 'circ.holds.target_when_closed';
696 unless ($self->parent->get_ou_setting($clib, $ous)) {
697 # Targeting not allowed at this circ lib when its closed
699 $self->log_hold("skipping copy ".
700 $copy_hash->{id}."at closed org $clib");
707 push(@filtered_copies, $copy_hash);
710 # Update our in-progress list of copies to reflect the filtered set.
711 $self->copies(\@filtered_copies);
716 # Limit the set of potential copies to those that are
717 # in a targetable status.
718 # Returns true if filtering completes without error, false otherwise.
719 sub filter_copies_by_status {
723 grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
726 # Track checked out copies for later recall
727 $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
732 # Remove copies that are currently targeted by other holds.
733 # Returns true if filtering completes without error, false otherwise.
734 sub filter_copies_in_use {
737 # A copy with a 'current_copy' value means it's in use by another hold.
739 grep {!$_->{current_copy}} @{$self->copies}
745 # Returns true if inspection completed without error, false otherwise.
746 sub inspect_previous_target {
748 my $hold = $self->hold;
749 my @copies = @{$self->copies};
752 return 1 unless my $prev_id = $hold->current_copy;
754 $self->{previous_copy_id} = $prev_id;
756 # See if the previous copy is in our list of valid copies.
757 my ($prev) = grep {$_->{id} eq $prev_id} @copies;
759 # exit if previous target is no longer valid.
760 return 1 unless $prev;
762 if ($self->{skip_viable}) {
763 # In skip_viable mode, leave the hold as-is if the existing
764 # current_copy is still permitted.
765 # Note: viability checking is done this late in the process
766 # (specifically after other potential copies have been fetched)
767 # because we first need to confirm the current_copy is a valid
768 # potential copy (e.g. it's holdable, non-deleted, etc.), which
769 # copy_is_permitted, which only checks hold matrix policies,
772 return $self->exit_targeter("Skipping with viable target = $prev_id")
773 if $self->copy_is_permitted($prev);
775 # Previous copy is now confirmed non-viable.
779 # Previous copy may be targetable. Keep it around for later
780 # in case we need to confirm its viability and re-use it.
781 $self->{valid_previous_copy} = $prev;
784 # Remove the previous copy from the working set of potential copies.
785 # It will be revisited later if needed.
786 $self->copies([grep {$_->{id} ne $prev_id} @copies]);
791 # Returns true if we have at least one potential copy remaining, thus
792 # targeting should continue. Otherwise, the hold is updated to reflect
793 # that there is no target and returns false to stop targeting.
794 sub handle_no_copies {
795 my ($self, %args) = @_;
798 # If 'force' is set, the caller is saying that all copies have
799 # failed. Otherwise, see if we have any copies left to inspect.
800 return 1 if @{$self->copies} || $self->{valid_previous_copy};
803 # At this point, all copies have been inspected and none
804 # have yielded a targetable item.
806 if ($args{process_recalls}) {
807 # See if we have any copies/circs to recall.
808 return unless $self->process_recalls;
811 my $hold = $self->hold;
812 $hold->clear_current_copy;
813 $hold->prev_check_time('now');
815 $self->editor->update_action_hold_request($hold)
816 or return $self->exit_targeter("Error updating hold request", 1);
818 $self->editor->commit;
819 return $self->exit_targeter("Hold has no targetable copies");
822 # Force and recall holds bypass validity tests. Returns the first
823 # (and presumably only) copy in our list of valid copies when a
824 # F or R hold is encountered. Returns undef otherwise.
825 sub attempt_force_recall_target {
827 return $self->copies->[0] if
828 $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
832 sub attempt_to_find_copy {
835 return undef unless @{$self->copies};
837 my $max_loops = $self->parent->get_ou_setting(
838 $self->hold->pickup_lib,
839 'circ.holds.max_org_unit_target_loops'
842 return $self->target_by_org_loops($max_loops) if $max_loops;
844 # When not using target loops, targeting is based solely on
845 # proximity and org unit target weight.
846 $self->compile_weighted_proximity_map;
848 return $self->find_nearest_copy;
851 # Returns 2 arrays. The first is a list of copies whose circ lib's
852 # unfulfilled target count matches the provided $iter value. The
853 # second list is all other copies, returned for convenience.
854 sub get_copies_at_loop_iter {
855 my ($self, $targeted_libs, $iter) = @_;
857 my @iter_copies; # copies to try now.
858 my @remaining_copies; # copies to try later
860 for my $copy (@{$self->copies}) {
864 # Start with copies at circ libs that have never been targeted.
865 $match = 1 unless grep {
866 $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
869 # Find copies at branches whose target count
870 # matches the current (non-zero) loop depth.
873 $_->{count} eq $iter &&
874 $_->{circ_lib} eq $copy->{circ_lib}
879 push(@iter_copies, $copy);
881 push(@remaining_copies, $copy);
886 sprintf("%d potential copies at max-loops iteration level $iter. ".
887 "%d remain to be tested at a higher loop iteration level.",
888 scalar(@iter_copies),
889 scalar(@remaining_copies)
893 return (\@iter_copies, \@remaining_copies);
896 # Find libs whose unfulfilled target count is less than the maximum
897 # configured loop count. Target copies in order of their circ_lib's
898 # target count (starting at 0) and moving up. Copies within each
899 # loop count group are weighted based on configured hold weight. If
900 # no copies in a given group are targetable, move up to the next
901 # unfulfilled target level. Keep doing this until all potential
902 # copies have been tried or max targets loops is exceeded.
903 # Returns a targetable copy if one is found, undef otherwise.
904 sub target_by_org_loops {
905 my ($self, $max_loops) = @_;
907 my $targeted_libs = $self->editor->json_query({
908 select => {aufhl => ['circ_lib', 'count']},
910 where => {hold => $self->hold_id},
911 order_by => [{class => 'aufhl', field => 'count'}]
914 my $max_tried = 0; # Highest per-lib target attempts.
915 foreach (@$targeted_libs) {
916 $max_tried = $_->{count} if $_->{count} > $max_tried;
919 $self->log_hold("Max lib attempts is $max_tried. ".
920 scalar(@$targeted_libs)." libs have been targeted at least once.");
922 # $loop_iter represents per-lib target attemtps already made.
923 # When loop_iter equals max loops, all libs with targetable copies
924 # have been targeted the maximum number of times. loop_iter starts
925 # at 0 to pick up libs that have never been targeted.
927 while (++$loop_iter < $max_loops) {
929 # Ran out of copies to try before exceeding max target loops.
930 # Nothing else to do here.
931 return undef unless @{$self->copies};
933 my ($iter_copies, $remaining_copies) =
934 $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
936 next unless @$iter_copies;
938 $self->copies($iter_copies);
940 # Update the proximity map to only include the copies
941 # from this loop-depth iteration.
942 $self->compile_weighted_proximity_map;
944 my $copy = $self->find_nearest_copy;
945 return $copy if $copy; # found one!
947 # No targetable copy at the current target loop.
948 # Update our current copy set to the not-yet-tested copies.
949 $self->copies($remaining_copies);
952 # Avoid canceling the hold with exceeds-loops unless at least one
953 # lib has been targeted max_loops times. Otherwise, the hold goes
954 # back to waiting for another copy (or retargets its current copy).
955 return undef if $max_tried < $max_loops;
957 # At least one lib has been targeted max-loops times and zero
958 # other copies are targetable. All options have been exhausted.
959 return $self->handle_exceeds_target_loops;
962 # Cancel the hold, fire the no-target A/T event handler, and exit.
963 sub handle_exceeds_target_loops {
965 my $e = $self->editor;
966 my $hold = $self->hold;
968 $hold->cancel_time('now');
969 $hold->cancel_cause(1); # = un-targeted expiration
971 $e->update_action_hold_request($hold)
972 or return $self->exit_targeter("Error updating hold request", 1);
976 # Fire the A/T handler, but don't wait for a response.
977 OpenSRF::AppSession->create('open-ils.trigger')->request(
978 'open-ils.trigger.event.autocreate',
979 'hold_request.cancel.expire_no_target',
980 $hold, $hold->pickup_lib
983 return $self->exit_targeter("Hold exceeded max target loops");
986 # When all else fails, see if we can reuse the previously targeted copy.
987 sub attempt_prev_copy_retarget {
990 # earlier target logic can in some cases cancel the hold.
991 return undef if $self->hold->cancel_time;
993 my $prev_copy = $self->{valid_previous_copy};
994 return undef unless $prev_copy;
996 $self->log_hold("attempting to re-target previously ".
997 "targeted copy for hold ".$self->hold_id);
999 if ($self->copy_is_permitted($prev_copy)) {
1000 $self->log_hold("retargeting the previously ".
1001 "targeted copy [".$prev_copy->{id}."]" );
1008 # Returns the closest copy by proximity that is a confirmed valid
1010 sub find_nearest_copy {
1012 my %prox_map = %{$self->{weighted_prox_map}};
1013 my $hold = $self->hold;
1016 # Pick a copy at random from each tier of the proximity map,
1017 # starting at the lowest proximity and working up, until a
1018 # copy is found that is suitable for targeting.
1019 for my $prox (sort {$a <=> $b} keys %prox_map) {
1020 my @copies = @{$prox_map{$prox}};
1021 next unless @copies;
1023 my $rand = int(rand(scalar(@copies)));
1025 while (my ($c) = splice(@copies, $rand, 1)) {
1026 $rand = int(rand(scalar(@copies)));
1027 next if $seen{$c->{id}};
1029 return $c if $self->copy_is_permitted($c);
1030 $seen{$c->{id}} = 1;
1032 last unless(@copies);
1039 # Returns true if the provided copy passes the hold permit test for our
1040 # hold and can be used for targeting.
1041 # When a copy fails the test, it is removed from $self->copies.
1042 sub copy_is_permitted {
1043 my ($self, $copy) = @_;
1044 return 0 unless $copy;
1046 my $resp = $self->editor->json_query({
1048 'action.hold_retarget_permit_test',
1049 $self->hold->request_lib,
1050 $self->hold->pickup_lib,
1053 $self->hold->requestor
1057 return 1 if $U->is_true($resp->[0]->{success});
1059 # Copy is confirmed non-viable.
1060 # Remove it from our potentials list.
1062 grep {$_->{id} ne $copy->{id}} @{$self->copies}
1068 # Sets hold.current_copy to the provided copy.
1069 sub apply_copy_target {
1070 my ($self, $copy) = @_;
1071 my $e = $self->editor;
1072 my $hold = $self->hold;
1074 $hold->current_copy($copy->{id});
1075 $hold->prev_check_time('now');
1077 $e->update_action_hold_request($hold)
1078 or return $self->exit_targeter("Error updating hold request", 1);
1081 $self->{success} = 1;
1082 return $self->exit_targeter("successfully targeted copy ".$copy->{id});
1085 # Creates a new row in action.unfulfilled_hold_list for our hold.
1086 # Returns 1 if all is OK, false on error.
1087 sub log_unfulfilled_hold {
1089 return 1 unless my $prev_id = $self->{previous_copy_id};
1090 my $e = $self->editor;
1093 "hold was not fulfilled by previous targeted copy $prev_id");
1096 if ($self->{valid_previous_copy}) {
1097 $circ_lib = $self->{valid_previous_copy}->{circ_lib};
1100 # We don't have a handle on the previous copy to get its
1101 # circ lib. Fetch it.
1102 $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
1105 my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
1106 $unful->hold($self->hold_id);
1107 $unful->circ_lib($circ_lib);
1108 $unful->current_copy($prev_id);
1110 $e->create_action_unfulfilled_hold_list($unful) or
1111 return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
1116 sub process_recalls {
1118 my $e = $self->editor;
1120 my $pu_lib = $self->hold->pickup_lib;
1123 $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_threshold')
1127 $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_return_interval')
1130 # Give me the ID of every checked out copy living at the hold
1132 my @copy_ids = map {$_->{id}}
1133 grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
1135 return 1 unless @copy_ids;
1137 my $circ = $e->search_action_circulation([
1138 { target_copy => \@copy_ids,
1139 checkin_time => undef,
1140 duration => {'>' => $threshold}
1142 order_by => 'due_date',
1147 return unless $circ;
1149 $self->log_hold("recalling circ ".$circ->id);
1151 # Give the user a new due date of either a full recall threshold,
1152 # or the return interval, whichever is further in the future.
1153 my $threshold_date = DateTime::Format::ISO8601
1154 ->parse_datetime(cleanse_ISO8601($circ->xact_start))
1155 ->add(seconds => interval_to_seconds($threshold))
1158 my $return_date = DateTime->now(time_zone => 'local')->add(
1159 seconds => interval_to_seconds($interval))->iso8601();
1161 if (DateTime->compare(
1162 DateTime::Format::ISO8601->parse_datetime($threshold_date),
1163 DateTime::Format::ISO8601->parse_datetime($return_date)) == 1) {
1164 $return_date = $threshold_date;
1167 my %update_fields = (
1168 due_date => $return_date,
1169 renewal_remaining => 0,
1173 $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_fine_rules');
1175 # If the OU hasn't defined new fine rules for recalls, keep them
1178 $self->log_hold("applying recall fine rules: $fine_rules");
1179 my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
1180 $update_fields{recurring_fine} = $rules->[0];
1181 $update_fields{fine_interval} = $rules->[1];
1182 $update_fields{max_fine} = $rules->[2];
1185 # Copy updated fields into circ object.
1186 $circ->$_($update_fields{$_}) for keys %update_fields;
1188 $e->update_action_circulation($circ)
1189 or return $self->exit_targeter(
1190 "Error updating circulation object in process_recalls", 1);
1192 # Create trigger event for notifying current user
1193 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
1194 $ses->request('open-ils.trigger.event.autocreate',
1195 'circ.recall.target', $circ, $circ->circ_lib);
1200 # Target a single hold request
1202 my ($self, $hold_id) = @_;
1204 my $e = $self->editor;
1205 $self->hold_id($hold_id);
1207 $self->log_hold("processing...");
1211 my $hold = $e->retrieve_action_hold_request($hold_id)
1212 or return $self->exit_targeter("No hold found", 1);
1214 return $self->exit_targeter("Hold is not eligible for targeting")
1215 if $hold->capture_time ||
1216 $hold->cancel_time ||
1217 $hold->fulfillment_time ||
1218 $U->is_true($hold->frozen);
1222 return unless $self->handle_expired_hold;
1223 return unless $self->get_hold_copies;
1224 return unless $self->update_copy_maps;
1226 # Confirm that we have something to work on. If we have no
1227 # copies at this point, there's also nothing to recall.
1228 return unless $self->handle_no_copies;
1230 # Trim the set of working copies down to those that are
1231 # currently targetable.
1232 return unless $self->filter_copies_by_status;
1233 return unless $self->filter_copies_in_use;
1234 return unless $self->filter_closed_date_copies;
1236 # Set aside the previously targeted copy for later use as needed.
1237 # Code may exit here in skip_viable mode if the existing
1238 # current_copy value is still viable.
1239 return unless $self->inspect_previous_target;
1241 # Log that the hold was not captured.
1242 return unless $self->log_unfulfilled_hold;
1244 # Confirm again we have something to work on. If we have no
1245 # targetable copies now, there may be a copy that can be recalled.
1246 return unless $self->handle_no_copies(process_recalls => 1);
1248 # At this point, the working list of copies has been trimmed to
1249 # those that are currently targetable at a superficial level.
1250 # (They are holdable and available). Now the code steps through
1251 # these copies in order of priority and pickup lib proximity to
1252 # find a copy that is confirmed targetable by policy.
1254 my $copy = $self->attempt_force_recall_target ||
1255 $self->attempt_to_find_copy ||
1256 $self->attempt_prev_copy_retarget;
1258 # See if one of the above attempt* calls canceled the hold as a side
1259 # effect of looking for a copy to target.
1260 return if $hold->cancel_time;
1262 return $self->apply_copy_target($copy) if $copy;
1264 # No targetable copy was found. Fire the no-copy handler.
1265 $self->handle_no_copies(force => 1, process_recalls => 1);