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});
51 select => {ahr => ['id']},
54 capture_time => undef,
55 fulfillment_time => undef,
60 {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
61 {class => 'ahr', field => 'request_time'},
62 {class => 'ahr', field => 'prev_check_time'}
66 # Target holds that have no prev_check_time or those whose re-target
67 # time has come. If a soft_retarget_time is specified, that acts as
68 # the boundary. Otherwise, the retarget_time is used.
69 my $start_time = $self->{soft_retarget_time} || $self->{retarget_time};
70 $query->{where}->{'-or'} = [
71 {prev_check_time => undef},
72 {prev_check_time => {'<=' => $start_time->strftime('%F %T%z')}}
75 # parallel < 1 means no parallel
76 my $parallel = ($self->{parallel_count} || 0) > 1 ?
77 $self->{parallel_count} : 0;
80 # In parallel mode, we need to also grab the metarecord for each hold.
96 # In parallel mode, only process holds within the current process
97 # whose metarecord ID modulo the parallel targeter count matches
98 # our paralell targeting slot. This ensures that no 2 processes
99 # will be operating on the same potential copy sets.
101 # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5,
102 # process holds whose metarecord ID's are 2, 7, 12, 17, ...
103 # WHERE MOD(mmrsm.id, 5) = 2
105 # Slots are 1-based at the API level, but 0-based for modulo.
106 my $slot = $self->{parallel_slot} - 1;
108 $query->{where}->{'+mmrsm'} = {
113 params => [$parallel]
119 # Newest-first sorting cares only about hold create_time.
121 [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
122 if $self->{newest_first};
124 my $holds = $self->editor->json_query($query, {substream => 1});
126 return map {$_->{id}} @$holds;
131 return $self->{editor};
134 # Load startup data required by all targeter actions.
137 my $e = $self->editor;
139 # See if the caller provided an interval
140 my $interval = $self->{retarget_interval};
143 # See if we have a global flag value for the interval
145 $interval = $e->search_config_global_flag({
146 name => 'circ.holds.retarget_interval',
150 # If no flag is present, default to a 24-hour retarget interval.
151 $interval = $interval ? $interval->value : '24h';
154 my $retarget_seconds = interval_to_seconds($interval);
156 $self->{retarget_time} = DateTime->now(time_zone => 'local')
157 ->subtract(seconds => $retarget_seconds);
159 $logger->info("Using retarget time: ".
160 $self->{retarget_time}->strftime('%F %T%z'));
162 if ($self->{soft_retarget_interval}) {
164 my $secs = OpenILS::Utils::DateTime->interval_to_seconds(
165 $self->{soft_retarget_interval});
167 $self->{soft_retarget_time} =
168 DateTime->now(time_zone => 'local')->subtract(seconds => $secs);
170 $logger->info("Using soft retarget time: " .
171 $self->{soft_retarget_time}->strftime('%F %T%z'));
174 # Holds targeted in the current targeter instance not be retargeted
175 # until the next check date. If a next_check_interval is provided
176 # it overrides the retarget_interval.
177 my $next_check_secs =
178 $self->{next_check_interval} ?
179 OpenILS::Utils::DateTime->interval_to_seconds($self->{next_check_interval}) :
182 my $next_check_date =
183 DateTime->now(time_zone => 'local')->add(seconds => $next_check_secs);
185 my $next_check_time = $next_check_date->strftime('%F %T%z');
187 $logger->info("Next check time: $next_check_time");
189 # An org unit is considered closed for retargeting purposes
190 # if it's closed both now and at the next re-target date.
191 my $closed = $self->editor->search_actor_org_unit_closed_date({
193 close_start => {'<=', 'now'},
194 close_end => {'>=', 'now'}
196 close_start => {'<=', $next_check_time},
197 close_end => {'>=', $next_check_time}
201 my @closed_orgs = map {$_->org_unit} @$closed;
202 $logger->info("closed org unit IDs: @closed_orgs");
204 # Map of org id to 1. Any org in the map is closed.
205 $self->{closed_orgs} = {map {$_ => 1} @closed_orgs};
207 my $hopeless_prone = $self->editor->search_config_copy_status({
208 hopeless_prone => 't'
210 $self->{hopeless_prone_status_ids} = { map { $_->id => 1} @{ $hopeless_prone } };
214 # Org unit setting fetch+cache
215 # $e is the OpenILS::Utils::HoldTargeter::Single editor. Use it if
216 # provided to avoid timeouts on the in-transaction child editor.
218 my ($self, $org_id, $setting, $e) = @_;
219 my $c = $self->{ou_setting_cache};
221 $e ||= $self->{editor};
222 $c->{$org_id} = {} unless $c->{$org_id};
224 $c->{$org_id}->{$setting} =
225 $U->ou_ancestor_setting_value($org_id, $setting, $e)
226 unless exists $c->{$org_id}->{$setting};
228 return $c->{$org_id}->{$setting};
231 # Fetches settings for a batch of org units. Useful for pre-caching
232 # setting values across a wide variety of org units without having to
233 # make a lookup call for every org unit.
234 # First checks to see if a value exists in the cache.
235 # For all non-cached values, looks up in DB, then caches the value.
236 sub precache_batch_ou_settings {
237 my ($self, $org_ids, $setting, $e) = @_;
239 $e ||= $self->{editor};
240 my $c = $self->{ou_setting_cache};
243 for my $org_id (@$org_ids) {
244 next if exists $c->{$org_id}->{$setting};
245 push (@orgs, $org_id);
248 return unless @orgs; # value aready cached for all requested orgs.
251 $U->ou_ancestor_setting_batch_by_org_insecure(\@orgs, $setting, $e);
253 for my $org_id (keys %settings) {
254 $c->{$org_id}->{$setting} = $settings{$org_id}->{value};
258 # -----------------------------------------------------------------------
259 # Knows how to target a single hold.
260 # -----------------------------------------------------------------------
261 package OpenILS::Utils::HoldTargeter::Single;
265 use OpenSRF::AppSession;
266 use OpenILS::Utils::DateTime qw/:datetime/;
267 use OpenSRF::Utils::Logger qw(:logger);
268 use OpenILS::Application::AppUtils;
269 use OpenILS::Utils::CStoreEditor qw/:funcs/;
272 my ($class, %args) = @_;
275 editor => new_editor(),
279 return bless($self, $class);
282 # Parent targeter object.
284 my ($self, $parent) = @_;
285 $self->{parent} = $parent if $parent;
286 return $self->{parent};
290 my ($self, $hold_id) = @_;
291 $self->{hold_id} = $hold_id if $hold_id;
292 return $self->{hold_id};
296 my ($self, $hold) = @_;
297 $self->{hold} = $hold if $hold;
298 return $self->{hold};
301 sub inside_hard_stall_interval {
303 if (defined $self->{inside_hard_stall_interval}) {
304 $self->log_hold('already looked up hard stalling state: '.$self->{inside_hard_stall_interval});
305 return $self->{inside_hard_stall_interval};
308 my $hard_stall_interval =
309 $self->parent->get_ou_setting(
310 $self->hold->pickup_lib, 'circ.pickup_hold_stalling.hard', $self->editor) || '0 seconds';
312 $self->log_hold('hard stalling interval '.$hard_stall_interval);
314 my $hold_request_time = $dt_parser->parse_datetime(clean_ISO8601($self->hold->request_time));
315 my $hard_stall_time = $hold_request_time->clone->add(
316 seconds => OpenILS::Utils::DateTime->interval_to_seconds($hard_stall_interval)
319 if (DateTime->compare($hard_stall_time, DateTime->now(time_zone => 'local')) > 0) {
320 $self->{inside_hard_stall_interval} = 1
322 $self->{inside_hard_stall_interval} = 0
325 $self->log_hold('hard stalling state: '.$self->{inside_hard_stall_interval});
326 return $self->{inside_hard_stall_interval};
331 my ($self, $message) = @_;
332 $self->{message} = $message if $message;
333 return $self->{message} || '';
336 # True if the hold was successfully targeted.
338 my ($self, $success) = @_;
339 $self->{success} = $success if defined $success;
340 return $self->{success};
343 # True if targeting exited early on an unrecoverable error.
345 my ($self, $error) = @_;
346 $self->{error} = $error if defined $error;
347 return $self->{error};
352 return $self->{editor};
359 hold => $self->hold_id,
360 error => $self->error,
361 success => $self->success,
362 message => $self->message,
363 target => $self->hold ? $self->hold->current_copy : undef,
364 old_target => $self->{previous_copy_id},
365 found_copy => $self->{found_copy},
366 eligible_copies => $self->{eligible_copy_count}
370 # List of potential copies in the form of slim hashes. This list
371 # evolves as copies are filtered as they are deemed non-targetable.
373 my ($self, $copies) = @_;
374 $self->{copies} = $copies if $copies;
375 return $self->{copies};
378 # Final set of potential copies, including those that may not be
379 # currently targetable, that may be eligible for recall processing.
381 my ($self, $recall_copies) = @_;
382 $self->{recall_copies} = $recall_copies if $recall_copies;
383 return $self->{recall_copies};
387 my ($self, $in_use_copies) = @_;
388 $self->{in_use_copies} = $in_use_copies if $in_use_copies;
389 return $self->{in_use_copies};
392 # Maps copy ID's to their hold proximity
394 my ($self, $copy_prox_map) = @_;
395 $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
396 return $self->{copy_prox_map};
400 my ($self, $msg, $err) = @_;
401 my $level = $err ? 'error' : 'info';
402 $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
405 # Captures the exit message, rolls back the cstore transaction/connection,
407 # is_error : log the final message and editor event at ERR level.
409 my ($self, $msg, $is_error) = @_;
411 $self->message($msg);
412 my $log = "exiting => $msg";
415 # On error, roll back and capture the last editor event for logging.
417 my $evt = $self->editor->die_event;
418 $log .= " [".$evt->{textcode}."]" if $evt;
421 $self->log_hold($log, 1);
424 # Attempt a rollback and disconnect when each hold exits
425 # to avoid the possibility of leaving cstore's pinned.
426 # Note: ->rollback is a no-op when a ->commit has already occured.
428 $self->editor->rollback;
429 $self->log_hold($log);
435 # Cancel expired holds and kick off the A/T no-target event. Returns
436 # true (i.e. keep going) if the hold is not expired. Returns false if
437 # the hold is canceled or a non-recoverable error occcurred.
438 sub handle_expired_hold {
440 my $hold = $self->hold;
442 return 1 unless $hold->expire_time;
445 $dt_parser->parse_datetime(clean_ISO8601($hold->expire_time));
447 DateTime->compare($ex_time, DateTime->now(time_zone => 'local')) < 0;
451 $hold->cancel_time('now');
452 $hold->cancel_cause(1); # == un-targeted expiration
454 $self->editor->update_action_hold_request($hold)
455 or return $self->exit_targeter("Error canceling hold", 1);
457 $self->editor->commit;
459 # Fire the A/T handler, but don't wait for a response.
460 OpenSRF::AppSession->create('open-ils.trigger')->request(
461 'open-ils.trigger.event.autocreate',
462 'hold_request.cancel.expire_no_target',
463 $hold, $hold->pickup_lib
466 return $self->exit_targeter("Hold is expired");
469 # Find potential copies for hold mapping and targeting.
470 sub get_hold_copies {
472 my $e = $self->editor;
473 my $hold = $self->hold;
475 my $hold_target = $hold->target;
476 my $hold_type = $hold->hold_type;
477 my $org_unit = $hold->selection_ou;
478 my $org_depth = $hold->selection_depth || 0;
482 acp => ['id', 'status', 'circ_lib'],
483 ahr => ['current_copy']
487 # Tag copies that are in use by other holds so we don't
488 # try to target them for our hold.
491 fkey => 'id', # acp.id
492 field => 'current_copy',
494 fulfillment_time => undef,
495 cancel_time => undef,
496 id => {'!=' => $self->hold_id}
508 transform => 'actor.org_unit_descendants',
510 result_field => 'id',
511 params => [$org_depth]
515 where => {id => $org_unit}
522 unless ($hold_type eq 'R' || $hold_type eq 'F') {
523 # Add the holdability filters to the copy query, unless
524 # we're processing a Recall or Force hold, which bypass most
525 # holdability checks.
527 $query->{from}->{acp}->{acpl} = {
529 filter => {holdable => 't', deleted => 'f'},
533 $query->{from}->{acp}->{ccs} = {
535 filter => {holdable => 't'},
539 $query->{where}->{'+acp'}->{holdable} = 't';
540 $query->{where}->{'+acp'}->{mint_condition} = 't'
541 if $U->is_true($hold->mint_condition);
544 unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
545 # For volume and higher level holds, avoid targeting copies that
546 # act as instances of monograph parts.
547 $query->{from}->{acp}->{acpm} = {
549 field => 'target_copy',
553 $query->{where}->{'+acpm'}->{id} = undef;
556 if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
558 $query->{where}->{'+acp'}->{id} = $hold_target;
560 } elsif ($hold_type eq 'V') {
562 $query->{where}->{'+acp'}->{call_number} = $hold_target;
564 } elsif ($hold_type eq 'P') {
566 $query->{from}->{acp}->{acpm} = {
567 field => 'target_copy',
569 filter => {part => $hold_target},
572 } elsif ($hold_type eq 'I') {
574 $query->{from}->{acp}->{sitem} = {
577 filter => {issuance => $hold_target},
580 } elsif ($hold_type eq 'T') {
582 $query->{from}->{acp}->{acn} = {
584 fkey => 'call_number',
588 filter => {id => $hold_target},
594 } else { # Metarecord hold
596 $query->{from}->{acp}->{acn} = {
598 fkey => 'call_number',
607 filter => {metarecord => $hold_target},
614 if ($hold->holdable_formats) {
615 # Compile the JSON-encoded metarecord holdable formats
616 # to an Intarray query_int string.
617 my $query_int = $e->json_query({
619 'metabib.compile_composite_attr',
620 $hold->holdable_formats
623 # TODO: ^- any way to add this as a filter in the main query?
626 # Only pull potential copies from records that satisfy
627 # the holdable formats query.
628 my $qint = $query_int->{'metabib.compile_composite_attr'};
629 $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
632 filter => {vlist => {'@@' => $qint}}
638 my $copies = $e->json_query($query);
639 $self->{eligible_copy_count} = scalar(@$copies);
641 $self->log_hold($self->{eligible_copy_count}." potential copies");
643 # Let the caller know we encountered the copy they were interested in.
644 $self->{found_copy} = 1 if $self->{find_copy}
645 && grep {$_->{id} eq $self->{find_copy}} @$copies;
647 $self->copies($copies);
652 # Delete and rebuild copy maps
653 sub update_copy_maps {
655 my $e = $self->editor;
657 my $resp = $e->json_query({from => [
658 'action.hold_request_regen_copy_maps',
660 '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
663 # The above call can fail if another process is updating
664 # copy maps for this hold at the same time.
665 return 1 if $resp && @$resp;
667 return $self->exit_targeter("Error creating hold copy maps", 1);
670 # Hopeless Date logic based on copy map
671 sub handle_hopeless_date {
673 my $e = $self->editor;
674 my $hold = $self->hold;
677 # If copy map is empty and hopeless date is not already set,
678 # then set it. Otherwise, let's check the items for Hopeless
679 # Prone statuses. If all are hopeless then set the hopeless
680 # date if needed. If at least one is not hopeless, then
681 # clear the the hopeless date if not already unset.
683 if (scalar(@{$self->copies}) == 0) {
684 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): no copies');
685 if (!$hold->hopeless_date) {
686 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): setting hopeless_date');
687 $hold->hopeless_date('now');
691 my $all_hopeless = 1;
692 foreach my $copy_hash (@{$self->copies}) {
693 if (!$self->parent->{hopeless_prone_status_ids}->{$copy_hash->{status}}) {
698 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): all copies have hopeless prone status');
699 if (!$hold->hopeless_date) {
700 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): setting hopeless_date');
701 $hold->hopeless_date('now');
705 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): at least one copy without a hopeless prone status');
706 if ($hold->hopeless_date) {
707 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): clearing hopeless_date');
708 $hold->clear_hopeless_date;
715 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): attempting update');
716 $e->update_action_hold_request($hold)
717 or return $self->exit_targeter(
718 "Error updating Hopeless Date for hold request", 1);
719 # FIXME: sanity-check, will a commit happen further down the line for all use cases?
723 # unique set of circ lib IDs for all in-progress copy blobs.
724 sub get_copy_circ_libs {
726 my %orgs = map {$_->{circ_lib} => 1} @{$self->copies};
731 # Returns a map of proximity values to arrays of copy hashes.
732 # The copy hash arrays are weighted consistent with the org unit hold
733 # target weight, meaning that a given copy may appear more than once
734 # in its proximity list.
735 sub compile_weighted_proximity_map {
738 # Collect copy proximity info (generated via DB trigger)
739 # from our newly create copy maps.
740 my $hold_copy_maps = $self->editor->json_query({
741 select => {ahcm => ['target_copy', 'proximity']},
743 where => {hold => $self->hold_id}
747 map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
749 # Pre-fetch the org setting value for all circ libs so that
750 # later calls can reference the cached value.
751 $self->parent->precache_batch_ou_settings($self->get_copy_circ_libs,
752 'circ.holds.org_unit_target_weight', $self->editor);
755 for my $copy_hash (@{$self->copies}) {
756 my $prox = $copy_prox_map{$copy_hash->{id}};
757 $copy_hash->{proximity} = $prox;
758 $prox_map{$prox} ||= [];
760 my $weight = $self->parent->get_ou_setting(
761 $copy_hash->{circ_lib},
762 'circ.holds.org_unit_target_weight', $self->editor) || 1;
764 # Each copy is added to the list once per target weight.
765 push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
768 # We need to grab the proximity for copies targeted by other holds
769 # that belong to this pickup lib for hard-stalling tests later. We'll
770 # just grab them all in case it's useful later.
771 for my $copy_hash (@{$self->in_use_copies}) {
772 my $prox = $copy_prox_map{$copy_hash->{id}};
773 $copy_hash->{proximity} = $prox;
776 # We also need the proximity for the previous target.
777 if ($self->{valid_previous_copy}) {
778 my $prox = $copy_prox_map{$self->{valid_previous_copy}->{id}};
779 $self->{valid_previous_copy}->{proximity} = $prox;
782 return $self->{weighted_prox_map} = \%prox_map;
785 # Returns true if filtering completed without error, false otherwise.
786 sub filter_closed_date_copies {
789 # Pre-fetch the org setting value for all represented circ libs that
790 # are closed, minuse the pickup_lib, since it has its own special setting.
791 my $circ_libs = $self->get_copy_circ_libs;
794 $self->parent->{closed_orgs}->{$_} &&
795 $_ ne $self->hold->pickup_lib
799 # If none of the represented circ libs are closed, we're done here.
800 return 1 unless @$circ_libs;
802 $self->parent->precache_batch_ou_settings(
803 $circ_libs, 'circ.holds.target_when_closed', $self->editor);
806 for my $copy_hash (@{$self->copies}) {
807 my $clib = $copy_hash->{circ_lib};
809 if ($self->parent->{closed_orgs}->{$clib}) {
810 # Org unit is currently closed. See if it matters.
812 my $ous = $self->hold->pickup_lib eq $clib ?
813 'circ.holds.target_when_closed_if_at_pickup_lib' :
814 'circ.holds.target_when_closed';
817 $self->parent->get_ou_setting($clib, $ous, $self->editor)) {
818 # Targeting not allowed at this circ lib when its closed
820 $self->log_hold("skipping copy ".
821 $copy_hash->{id}." at closed org $clib");
828 push(@filtered_copies, $copy_hash);
831 # Update our in-progress list of copies to reflect the filtered set.
832 $self->copies(\@filtered_copies);
837 # Limit the set of potential copies to those that are
838 # in a targetable status.
839 # Returns true if filtering completes without error, false otherwise.
840 sub filter_copies_by_status {
843 # Track checked out copies for later recall
844 $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
847 grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
853 # Remove copies that are currently targeted by other holds.
854 # Returns true if filtering completes without error, false otherwise.
855 sub filter_copies_in_use {
858 # Copies that are targeted, but could contribute to pickup lib
859 # hard (foreign) stalling. These are Available-status copies.
860 $self->in_use_copies([grep {$_->{current_copy}} @{$self->copies}]);
862 # A copy with a 'current_copy' value means it's in use by another hold.
864 grep {!$_->{current_copy}} @{$self->copies}
870 # Returns true if inspection completed without error, false otherwise.
871 sub inspect_previous_target {
873 my $hold = $self->hold;
874 my @copies = @{$self->copies};
877 return 1 unless my $prev_id = $hold->current_copy;
879 $self->{previous_copy_id} = $prev_id;
881 # See if the previous copy is in our list of valid copies.
882 my ($prev) = grep {$_->{id} eq $prev_id} @copies;
884 # exit if previous target is no longer valid.
885 return 1 unless $prev;
887 my $soft_retarget = 0;
889 if ($self->parent->{soft_retarget_time}) {
890 # A hold is soft-retarget-able if its prev_check_time is
891 # later then the retarget_time, i.e. it sits between the
892 # soft_retarget_time and the retarget_time.
894 my $pct = $dt_parser->parse_datetime(
895 clean_ISO8601($hold->prev_check_time));
898 DateTime->compare($pct, $self->parent->{retarget_time}) > 0;
901 if ($soft_retarget) {
903 # In soft-retarget mode, if the existing copy is still a valid
904 # target for the hold, exit early.
905 if ($self->copy_is_permitted($prev)) {
907 # Commit to persist the updated action.hold_copy_map's
908 $self->editor->commit;
910 return $self->exit_targeter(
911 "Exiting early on soft-retarget with viable copy = $prev_id");
914 $self->log_hold("soft retarget failed because copy $prev_id is ".
915 "no longer targetable for this hold. Retargeting...");
920 # Previous copy /may/ be targetable. Keep it around for later
921 # in case we need to confirm its viability and re-use it.
922 $self->{valid_previous_copy} = $prev;
925 # Remove the previous copy from the working set of potential copies.
926 # It will be revisited later if needed.
927 $self->copies([grep {$_->{id} ne $prev_id} @copies]);
932 # Returns true if we have at least one potential copy remaining, thus
933 # targeting should continue. Otherwise, the hold is updated to reflect
934 # that there is no target and returns false to stop targeting.
935 sub handle_no_copies {
936 my ($self, %args) = @_;
939 # If 'force' is set, the caller is saying that all copies have
940 # failed. Otherwise, see if we have any copies left to inspect.
941 return 1 if @{$self->copies} || $self->{valid_previous_copy};
944 # At this point, all copies have been inspected and none
945 # have yielded a targetable item.
947 if ($args{process_recalls}) {
948 # See if we have any copies/circs to recall.
949 return unless $self->process_recalls;
952 my $hold = $self->hold;
953 $hold->clear_current_copy;
954 $hold->prev_check_time('now');
956 $self->editor->update_action_hold_request($hold)
957 or return $self->exit_targeter("Error updating hold request", 1);
959 $self->editor->commit;
960 return $self->exit_targeter("Hold has no targetable copies");
963 # Force and recall holds bypass validity tests. Returns the first
964 # (and presumably only) copy in our list of valid copies when a
965 # F or R hold is encountered. Returns undef otherwise.
966 sub attempt_force_recall_target {
968 return $self->copies->[0] if
969 $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
973 sub attempt_to_find_copy {
976 $self->log_hold("attempting to find a copy normally");
978 my $max_loops = $self->parent->get_ou_setting(
979 $self->hold->pickup_lib,
980 'circ.holds.max_org_unit_target_loops',
984 return $self->target_by_org_loops($max_loops) if $max_loops;
986 # When not using target loops, targeting is based solely on
987 # proximity and org unit target weight.
988 $self->compile_weighted_proximity_map;
990 return $self->find_nearest_copy;
993 # Returns 2 arrays. The first is a list of copies whose circ lib's
994 # unfulfilled target count matches the provided $iter value. The
995 # second list is all other copies, returned for convenience.
996 sub get_copies_at_loop_iter {
997 my ($self, $targeted_libs, $iter) = @_;
999 my @iter_copies; # copies to try now.
1000 my @remaining_copies; # copies to try later
1002 for my $copy (@{$self->copies}) {
1006 # Start with copies at circ libs that have never been targeted.
1007 $match = 1 unless grep {
1008 $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
1011 # Find copies at branches whose target count
1012 # matches the current (non-zero) loop depth.
1014 $match = 1 if grep {
1015 $_->{count} eq $iter &&
1016 $_->{circ_lib} eq $copy->{circ_lib}
1021 push(@iter_copies, $copy);
1023 push(@remaining_copies, $copy);
1028 sprintf("%d potential copies at max-loops iteration level $iter. ".
1029 "%d remain to be tested at a higher loop iteration level.",
1030 scalar(@iter_copies),
1031 scalar(@remaining_copies)
1035 return (\@iter_copies, \@remaining_copies);
1038 # Find libs whose unfulfilled target count is less than the maximum
1039 # configured loop count. Target copies in order of their circ_lib's
1040 # target count (starting at 0) and moving up. Copies within each
1041 # loop count group are weighted based on configured hold weight. If
1042 # no copies in a given group are targetable, move up to the next
1043 # unfulfilled target level. Keep doing this until all potential
1044 # copies have been tried or max targets loops is exceeded.
1045 # Returns a targetable copy if one is found, undef otherwise.
1046 sub target_by_org_loops {
1047 my ($self, $max_loops) = @_;
1049 my $targeted_libs = $self->editor->json_query({
1050 select => {aufhl => ['circ_lib', 'count']},
1052 where => {hold => $self->hold_id},
1053 order_by => [{class => 'aufhl', field => 'count'}]
1056 my $max_tried = 0; # Highest per-lib target attempts.
1057 foreach (@$targeted_libs) {
1058 $max_tried = $_->{count} if $_->{count} > $max_tried;
1061 $self->log_hold("Max lib attempts is $max_tried. ".
1062 scalar(@$targeted_libs)." libs have been targeted at least once.");
1064 # $loop_iter represents per-lib target attemtps already made.
1065 # When loop_iter equals max loops, all libs with targetable copies
1066 # have been targeted the maximum number of times. loop_iter starts
1067 # at 0 to pick up libs that have never been targeted.
1069 while (++$loop_iter < $max_loops) {
1071 # Ran out of copies to try before exceeding max target loops.
1072 # Nothing else to do here.
1073 return undef unless @{$self->copies};
1075 my ($iter_copies, $remaining_copies) =
1076 $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
1078 next unless @$iter_copies;
1080 $self->copies($iter_copies);
1082 # Update the proximity map to only include the copies
1083 # from this loop-depth iteration.
1084 $self->compile_weighted_proximity_map;
1086 my $copy = $self->find_nearest_copy;
1087 return $copy if $copy; # found one!
1089 # No targetable copy at the current target loop.
1090 # Update our current copy set to the not-yet-tested copies.
1091 $self->copies($remaining_copies);
1094 # Avoid canceling the hold with exceeds-loops unless at least one
1095 # lib has been targeted max_loops times. Otherwise, the hold goes
1096 # back to waiting for another copy (or retargets its current copy).
1097 return undef if $max_tried < $max_loops;
1099 # At least one lib has been targeted max-loops times and zero
1100 # other copies are targetable. All options have been exhausted.
1101 return $self->handle_exceeds_target_loops;
1104 # Cancel the hold, fire the no-target A/T event handler, and exit.
1105 sub handle_exceeds_target_loops {
1107 my $e = $self->editor;
1108 my $hold = $self->hold;
1110 $hold->cancel_time('now');
1111 $hold->cancel_cause(1); # = un-targeted expiration
1113 $e->update_action_hold_request($hold)
1114 or return $self->exit_targeter("Error updating hold request", 1);
1118 # Fire the A/T handler, but don't wait for a response.
1119 OpenSRF::AppSession->create('open-ils.trigger')->request(
1120 'open-ils.trigger.event.autocreate',
1121 'hold_request.cancel.expire_no_target',
1122 $hold, $hold->pickup_lib
1125 return $self->exit_targeter("Hold exceeded max target loops");
1128 # When all else fails, see if we can reuse the previously targeted copy.
1129 sub attempt_prev_copy_retarget {
1132 # earlier target logic can in some cases cancel the hold.
1133 return undef if $self->hold->cancel_time;
1135 my $prev_copy = $self->{valid_previous_copy};
1136 return undef unless $prev_copy;
1138 $self->log_hold("attempting to re-target previously ".
1139 "targeted copy for hold ".$self->hold_id);
1141 if ($self->copy_is_permitted($prev_copy)) {
1142 $self->log_hold("retargeting the previously ".
1143 "targeted copy [".$prev_copy->{id}."]" );
1150 # Returns the closest copy by proximity that is a confirmed valid
1152 sub find_nearest_copy {
1154 my %prox_map = %{$self->{weighted_prox_map}};
1155 my $hold = $self->hold;
1158 # See if there are in-use (targeted) copies "here".
1159 my $have_local_copies = 0;
1160 if ($self->inside_hard_stall_interval) { # But only if we're inside the hard age.
1161 if (grep { $_->{proximity} <= 0 } @{$self->in_use_copies}) {
1162 $have_local_copies = 1;
1164 $self->log_hold("inside hard stall interval and does ".
1165 ($have_local_copies ? "" : "not "). "have in-use local copies");
1168 # Pick a copy at random from each tier of the proximity map,
1169 # starting at the lowest proximity and working up, until a
1170 # copy is found that is suitable for targeting.
1172 for my $prox (sort {$a <=> $b} keys %prox_map) {
1173 my @copies = @{$prox_map{$prox}};
1174 next unless @copies;
1177 $have_local_copies = 1 if ($prox <= 0);
1179 $self->log_hold("inside hard stall interval and does ".
1180 ($have_local_copies ? "" : "not "). "have testable local copies")
1181 if ($self->inside_hard_stall_interval && $prox > 0);
1183 if ($have_local_copies and $self->inside_hard_stall_interval) {
1184 # Unset valid_previous_copy if it's not local and we have local copies now
1185 $self->{valid_previous_copy} = undef if (
1186 $self->{valid_previous_copy}
1187 and $self->{valid_previous_copy}->{proximity} > 0
1189 last if ($prox > 0); # No point in looking further "out".
1192 my $rand = int(rand(scalar(@copies)));
1194 while (my ($c) = splice(@copies, $rand, 1)) {
1195 $rand = int(rand(scalar(@copies)));
1196 next if $seen{$c->{id}};
1198 return $c if $self->copy_is_permitted($c);
1199 $seen{$c->{id}} = 1;
1201 last unless(@copies);
1205 if ($no_copies and $have_local_copies and $self->inside_hard_stall_interval) {
1206 # Unset valid_previous_copy if it's not local and we have local copies now
1207 $self->{valid_previous_copy} = undef if (
1208 $self->{valid_previous_copy}
1209 and $self->{valid_previous_copy}->{proximity} > 0
1216 # Returns true if the provided copy passes the hold permit test for our
1217 # hold and can be used for targeting.
1218 # When a copy fails the test, it is removed from $self->copies.
1219 sub copy_is_permitted {
1220 my ($self, $copy) = @_;
1221 return 0 unless $copy;
1223 my $resp = $self->editor->json_query({
1225 'action.hold_retarget_permit_test',
1226 $self->hold->pickup_lib,
1227 $self->hold->request_lib,
1230 $self->hold->requestor
1234 return 1 if $U->is_true($resp->[0]->{success});
1236 # Copy is confirmed non-viable.
1237 # Remove it from our potentials list.
1239 grep {$_->{id} ne $copy->{id}} @{$self->copies}
1245 # Sets hold.current_copy to the provided copy.
1246 sub apply_copy_target {
1247 my ($self, $copy) = @_;
1248 my $e = $self->editor;
1249 my $hold = $self->hold;
1251 $hold->current_copy($copy->{id});
1252 $hold->prev_check_time('now');
1254 $e->update_action_hold_request($hold)
1255 or return $self->exit_targeter("Error updating hold request", 1);
1258 $self->{success} = 1;
1259 return $self->exit_targeter("successfully targeted copy ".$copy->{id});
1262 # Creates a new row in action.unfulfilled_hold_list for our hold.
1263 # Returns 1 if all is OK, false on error.
1264 sub log_unfulfilled_hold {
1266 return 1 unless my $prev_id = $self->{previous_copy_id};
1267 my $e = $self->editor;
1270 "hold was not fulfilled by previous targeted copy $prev_id");
1273 if ($self->{valid_previous_copy}) {
1274 $circ_lib = $self->{valid_previous_copy}->{circ_lib};
1277 # We don't have a handle on the previous copy to get its
1278 # circ lib. Fetch it.
1279 $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
1282 my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
1283 $unful->hold($self->hold_id);
1284 $unful->circ_lib($circ_lib);
1285 $unful->current_copy($prev_id);
1287 $e->create_action_unfulfilled_hold_list($unful) or
1288 return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
1293 sub process_recalls {
1295 my $e = $self->editor;
1297 my $pu_lib = $self->hold->pickup_lib;
1300 $self->parent->get_ou_setting(
1301 $pu_lib, 'circ.holds.recall_threshold', $self->editor)
1305 $self->parent->get_ou_setting(
1306 $pu_lib, 'circ.holds.recall_return_interval', $self->editor)
1309 # Give me the ID of every checked out copy living at the hold
1311 my @copy_ids = map {$_->{id}}
1312 grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
1314 return 1 unless @copy_ids;
1316 my $circ = $e->search_action_circulation([
1317 { target_copy => \@copy_ids,
1318 checkin_time => undef,
1319 duration => {'>' => $threshold}
1321 order_by => [{ class => 'circ', field => 'due_date'}],
1326 return unless $circ;
1328 $self->log_hold("recalling circ ".$circ->id);
1330 my $old_due_date = DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($circ->due_date));
1332 # Give the user a new due date of either a full recall threshold,
1333 # or the return interval, whichever is further in the future.
1334 my $threshold_date = DateTime::Format::ISO8601
1335 ->parse_datetime(clean_ISO8601($circ->xact_start))
1336 ->add(seconds => interval_to_seconds($threshold));
1338 my $return_date = DateTime->now(time_zone => 'local')->add(
1339 seconds => interval_to_seconds($interval));
1341 if (DateTime->compare($threshold_date, $return_date) == 1) {
1342 # extend $return_date to threshold
1343 $return_date = $threshold_date;
1345 # But don't go past the original due date
1346 # (the threshold should not be past the due date, but manual edits can
1348 if (DateTime->compare($return_date, $old_due_date) == 1) {
1349 # truncate $return_date to due date
1350 $return_date = $old_due_date;
1353 my %update_fields = (
1354 due_date => $return_date->iso8601(),
1355 renewal_remaining => 0,
1359 $self->parent->get_ou_setting(
1360 $pu_lib, 'circ.holds.recall_fine_rules', $self->editor);
1362 # If the OU hasn't defined new fine rules for recalls, keep them
1365 $self->log_hold("applying recall fine rules: $fine_rules");
1366 my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
1367 $update_fields{recurring_fine} = $rules->[0];
1368 $update_fields{fine_interval} = $rules->[1];
1369 $update_fields{max_fine} = $rules->[2];
1372 # Copy updated fields into circ object.
1373 $circ->$_($update_fields{$_}) for keys %update_fields;
1375 $e->update_action_circulation($circ)
1376 or return $self->exit_targeter(
1377 "Error updating circulation object in process_recalls", 1);
1379 # Create trigger event for notifying current user
1380 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
1381 $ses->request('open-ils.trigger.event.autocreate',
1382 'circ.recall.target', $circ, $circ->circ_lib);
1387 # Target a single hold request
1389 my ($self, $hold_id) = @_;
1391 my $e = $self->editor;
1392 $self->hold_id($hold_id);
1394 $self->log_hold("processing...");
1398 my $hold = $e->retrieve_action_hold_request($hold_id)
1399 or return $self->exit_targeter("No hold found", 1);
1401 return $self->exit_targeter("Hold is not eligible for targeting")
1402 if $hold->capture_time ||
1403 $hold->cancel_time ||
1404 $hold->fulfillment_time ||
1405 $U->is_true($hold->frozen);
1409 return unless $self->handle_expired_hold;
1410 return unless $self->get_hold_copies;
1411 return unless $self->update_copy_maps;
1413 # Hopeless Date logic based on copy map
1415 $self->handle_hopeless_date;
1417 # Confirm that we have something to work on. If we have no
1418 # copies at this point, there's also nothing to recall.
1419 return unless $self->handle_no_copies;
1421 # Trim the set of working copies down to those that are
1422 # currently targetable.
1423 return unless $self->filter_copies_by_status;
1424 return unless $self->filter_copies_in_use;
1425 return unless $self->filter_closed_date_copies;
1427 # Set aside the previously targeted copy for later use as needed.
1428 # Code may exit here in skip_viable mode if the existing
1429 # current_copy value is still viable.
1430 return unless $self->inspect_previous_target;
1432 # Log that the hold was not captured.
1433 return unless $self->log_unfulfilled_hold;
1435 # Confirm again we have something to work on. If we have no
1436 # targetable copies now, there may be a copy that can be recalled.
1437 return unless $self->handle_no_copies(process_recalls => 1);
1439 # At this point, the working list of copies has been trimmed to
1440 # those that are currently targetable at a superficial level.
1441 # (They are holdable and available). Now the code steps through
1442 # these copies in order of priority and pickup lib proximity to
1443 # find a copy that is confirmed targetable by policy.
1445 my $copy = $self->attempt_force_recall_target ||
1446 $self->attempt_to_find_copy ||
1447 $self->attempt_prev_copy_retarget;
1449 # See if one of the above attempt* calls canceled the hold as a side
1450 # effect of looking for a copy to target.
1451 return if $hold->cancel_time;
1453 return $self->apply_copy_target($copy) if $copy;
1455 # No targetable copy was found. Fire the no-copy handler.
1456 $self->handle_no_copies(force => 1, process_recalls => 1);