]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
LP#1895052: Avoid foreign targets when local items exist
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Utils / HoldTargeter.pm
1 package OpenILS::Utils::HoldTargeter;
2 # ---------------------------------------------------------------
3 # Copyright (C) 2016 King County Library System
4 # Author: Bill Erickson <berickxx@gmail.com>
5 #
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.
10
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 # ---------------------------------------------------------------
16 use strict;
17 use warnings;
18 use DateTime;
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/;
25
26 our $U = "OpenILS::Application::AppUtils";
27 our $dt_parser = DateTime::Format::ISO8601->new;
28
29 # See open-ils.hold-targeter API docs for runtime arguments.
30 sub new {
31     my ($class, %args) = @_;
32     my $self = {
33         editor => new_editor(),
34         ou_setting_cache => {},
35         %args,
36     };
37     return bless($self, $class);
38 }
39
40 # Returns a list of hold ID's
41 sub find_holds_to_target {
42     my $self = shift;
43
44     if ($self->{hold}) {
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});
48     }
49
50     my $query = {
51         select => {ahr => ['id']},
52         from => 'ahr',
53         where => {
54             capture_time => undef,
55             fulfillment_time => undef,
56             cancel_time => undef,
57             frozen => 'f'
58         },
59         order_by => [
60             {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
61             {class => 'ahr', field => 'request_time'},
62             {class => 'ahr', field => 'prev_check_time'}
63         ]
64     };
65
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')}}
73     ];
74
75     # parallel < 1 means no parallel
76     my $parallel = ($self->{parallel_count} || 0) > 1 ? 
77         $self->{parallel_count} : 0;
78
79     if ($parallel) {
80         # In parallel mode, we need to also grab the metarecord for each hold.
81         $query->{from} = {
82             ahr => {
83                 rhrr => {
84                     fkey => 'id',
85                     field => 'id',
86                     join => {
87                         mmrsm => {
88                             field => 'source',
89                             fkey => 'bib_record'
90                         }
91                     }
92                 }
93             }
94         };
95
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.
100         #
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
104
105         # Slots are 1-based at the API level, but 0-based for modulo.
106         my $slot = $self->{parallel_slot} - 1;
107
108         $query->{where}->{'+mmrsm'} = {
109             metarecord => {
110                 '=' => {
111                     transform => 'mod',
112                     value => $slot,
113                     params => [$parallel]
114                 }
115             }
116         };
117     }
118
119     # Newest-first sorting cares only about hold create_time.
120     $query->{order_by} =
121         [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
122         if $self->{newest_first};
123
124     my $holds = $self->editor->json_query($query, {substream => 1});
125
126     return map {$_->{id}} @$holds;
127 }
128
129 sub editor {
130     my $self = shift;
131     return $self->{editor};
132 }
133
134 # Load startup data required by all targeter actions.
135 sub init {
136     my $self = shift;
137     my $e = $self->editor;
138
139     # See if the caller provided an interval
140     my $interval = $self->{retarget_interval};
141
142     if (!$interval) { 
143         # See if we have a global flag value for the interval
144
145         $interval = $e->search_config_global_flag({
146             name => 'circ.holds.retarget_interval',
147             enabled => 't'
148         })->[0];
149
150         # If no flag is present, default to a 24-hour retarget interval.
151         $interval = $interval ? $interval->value : '24h';
152     }
153
154     my $retarget_seconds = interval_to_seconds($interval);
155
156     $self->{retarget_time} = DateTime->now(time_zone => 'local')
157         ->subtract(seconds => $retarget_seconds);
158
159     $logger->info("Using retarget time: ".
160         $self->{retarget_time}->strftime('%F %T%z'));
161
162     if ($self->{soft_retarget_interval}) {
163
164         my $secs = OpenILS::Utils::DateTime->interval_to_seconds(
165             $self->{soft_retarget_interval});
166
167         $self->{soft_retarget_time} = 
168             DateTime->now(time_zone => 'local')->subtract(seconds => $secs);
169
170         $logger->info("Using soft retarget time: " .
171             $self->{soft_retarget_time}->strftime('%F %T%z'));
172     }
173
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}) :
180         $retarget_seconds;
181
182     my $next_check_date = 
183         DateTime->now(time_zone => 'local')->add(seconds => $next_check_secs);
184
185     my $next_check_time = $next_check_date->strftime('%F %T%z');
186
187     $logger->info("Next check time: $next_check_time");
188
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({
192         '-and' => [{   
193             close_start => {'<=', 'now'},
194             close_end => {'>=', 'now'}
195         }, {
196             close_start => {'<=', $next_check_time},
197             close_end => {'>=', $next_check_time}
198         }]
199     });
200
201     my @closed_orgs = map {$_->org_unit} @$closed;
202     $logger->info("closed org unit IDs: @closed_orgs");
203
204     # Map of org id to 1. Any org in the map is closed.
205     $self->{closed_orgs} = {map {$_ => 1} @closed_orgs};
206
207     my $hopeless_prone = $self->editor->search_config_copy_status({
208         hopeless_prone => 't'
209     });
210     $self->{hopeless_prone_status_ids} = { map { $_->id => 1} @{ $hopeless_prone } };
211 }
212
213
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.
217 sub get_ou_setting {
218     my ($self, $org_id, $setting, $e) = @_;
219     my $c = $self->{ou_setting_cache};
220
221     $e ||= $self->{editor};
222     $c->{$org_id} = {} unless $c->{$org_id};
223
224     $c->{$org_id}->{$setting} =
225         $U->ou_ancestor_setting_value($org_id, $setting, $e)
226         unless exists $c->{$org_id}->{$setting};
227
228     return $c->{$org_id}->{$setting};
229 }
230
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) = @_;
238
239     $e ||= $self->{editor};
240     my $c = $self->{ou_setting_cache};
241
242     my @orgs;
243     for my $org_id (@$org_ids) {
244         next if exists $c->{$org_id}->{$setting};
245         push (@orgs, $org_id);
246     }
247
248     return unless @orgs; # value aready cached for all requested orgs.
249
250     my %settings = 
251         $U->ou_ancestor_setting_batch_by_org_insecure(\@orgs, $setting, $e);
252
253     for my $org_id (keys %settings) {
254         $c->{$org_id}->{$setting} = $settings{$org_id}->{value};
255     }
256 }
257
258 # -----------------------------------------------------------------------
259 # Knows how to target a single hold.
260 # -----------------------------------------------------------------------
261 package OpenILS::Utils::HoldTargeter::Single;
262 use strict;
263 use warnings;
264 use DateTime;
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/;
270
271 sub new {
272     my ($class, %args) = @_;
273     my $self = {
274         %args,
275         editor => new_editor(),
276         error => 0,
277         success => 0
278     };
279     return bless($self, $class);
280 }
281
282 # Parent targeter object.
283 sub parent {
284     my ($self, $parent) = @_;
285     $self->{parent} = $parent if $parent;
286     return $self->{parent};
287 }
288
289 sub hold_id {
290     my ($self, $hold_id) = @_;
291     $self->{hold_id} = $hold_id if $hold_id;
292     return $self->{hold_id};
293 }
294
295 sub hold {
296     my ($self, $hold) = @_;
297     $self->{hold} = $hold if $hold;
298     return $self->{hold};
299 }
300
301 sub inside_hard_stall_interval {
302     my ($self) = @_;
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};
306     }
307
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';
311
312     $self->log_hold('hard stalling interval '.$hard_stall_interval);
313
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)
317     );
318
319     if (DateTime->compare($hard_stall_time, DateTime->now(time_zone => 'local')) > 0) {
320         $self->{inside_hard_stall_interval} = 1
321     } else {
322         $self->{inside_hard_stall_interval} = 0
323     }
324
325     $self->log_hold('hard stalling state: '.$self->{inside_hard_stall_interval});
326     return $self->{inside_hard_stall_interval};
327 }
328
329 # Debug message
330 sub message {
331     my ($self, $message) = @_;
332     $self->{message} = $message if $message;
333     return $self->{message} || '';
334 }
335
336 # True if the hold was successfully targeted.
337 sub success {
338     my ($self, $success) = @_;
339     $self->{success} = $success if defined $success;
340     return $self->{success};
341 }
342
343 # True if targeting exited early on an unrecoverable error.
344 sub error {
345     my ($self, $error) = @_;
346     $self->{error} = $error if defined $error;
347     return $self->{error};
348 }
349
350 sub editor {
351     my $self = shift;
352     return $self->{editor};
353 }
354
355 sub result {
356     my $self = shift;
357
358     return {
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}
367     };
368 }
369
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.
372 sub copies {
373     my ($self, $copies) = @_;
374     $self->{copies} = $copies if $copies;
375     return $self->{copies};
376 }
377
378 # Final set of potential copies, including those that may not be
379 # currently targetable, that may be eligible for recall processing.
380 sub recall_copies {
381     my ($self, $recall_copies) = @_;
382     $self->{recall_copies} = $recall_copies if $recall_copies;
383     return $self->{recall_copies};
384 }
385
386 sub in_use_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};
390 }
391
392 # Maps copy ID's to their hold proximity
393 sub copy_prox_map {
394     my ($self, $copy_prox_map) = @_;
395     $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
396     return $self->{copy_prox_map};
397 }
398
399 sub log_hold {
400     my ($self, $msg, $err) = @_;
401     my $level = $err ? 'error' : 'info';
402     $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
403 }
404
405 # Captures the exit message, rolls back the cstore transaction/connection,
406 # and returns false.
407 # is_error : log the final message and editor event at ERR level.
408 sub exit_targeter {
409     my ($self, $msg, $is_error) = @_;
410
411     $self->message($msg);
412     my $log = "exiting => $msg";
413
414     if ($is_error) {
415         # On error, roll back and capture the last editor event for logging.
416
417         my $evt = $self->editor->die_event;
418         $log .= " [".$evt->{textcode}."]" if $evt;
419
420         $self->error(1);
421         $self->log_hold($log, 1);
422
423     } else {
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.
427
428         $self->editor->rollback;
429         $self->log_hold($log);
430     }
431
432     return 0;
433 }
434
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 {
439     my $self = shift;
440     my $hold = $self->hold;
441
442     return 1 unless $hold->expire_time;
443
444     my $ex_time =
445         $dt_parser->parse_datetime(clean_ISO8601($hold->expire_time));
446     return 1 unless 
447         DateTime->compare($ex_time, DateTime->now(time_zone => 'local')) < 0;
448
449     # Hold is expired --
450
451     $hold->cancel_time('now');
452     $hold->cancel_cause(1); # == un-targeted expiration
453
454     $self->editor->update_action_hold_request($hold)
455         or return $self->exit_targeter("Error canceling hold", 1);
456
457     $self->editor->commit;
458
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
464     );
465
466     return $self->exit_targeter("Hold is expired");
467 }
468
469 # Find potential copies for hold mapping and targeting.
470 sub get_hold_copies {
471     my $self = shift;
472     my $e = $self->editor;
473     my $hold = $self->hold;
474
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;
479
480     my $query = {
481         select => {
482             acp => ['id', 'status', 'circ_lib'],
483             ahr => ['current_copy']
484         },
485         from => {
486             acp => {
487                 # Tag copies that are in use by other holds so we don't
488                 # try to target them for our hold.
489                 ahr => {
490                     type => 'left',
491                     fkey => 'id', # acp.id
492                     field => 'current_copy',
493                     filter => {
494                         fulfillment_time => undef,
495                         cancel_time => undef,
496                         id => {'!=' => $self->hold_id}
497                     }
498                 }
499             }
500         },
501         where => {
502             '+acp' => {
503                 deleted => 'f',
504                 circ_lib => {
505                     in => {
506                         select => {
507                             aou => [{
508                                 transform => 'actor.org_unit_descendants',
509                                 column => 'id',
510                                 result_field => 'id',
511                                 params => [$org_depth]
512                             }],
513                             },
514                         from => 'aou',
515                         where => {id => $org_unit}
516                     }
517                 }
518             }
519         }
520     };
521
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.
526
527         $query->{from}->{acp}->{acpl} = {
528             field => 'id',
529             filter => {holdable => 't', deleted => 'f'},
530             fkey => 'location'
531         };
532
533         $query->{from}->{acp}->{ccs} = {
534             field => 'id',
535             filter => {holdable => 't'},
536             fkey => 'status'
537         };
538
539         $query->{where}->{'+acp'}->{holdable} = 't';
540         $query->{where}->{'+acp'}->{mint_condition} = 't'
541             if $U->is_true($hold->mint_condition);
542     }
543
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} = {
548             type => 'left',
549             field => 'target_copy',
550             fkey => 'id'
551         };
552
553         $query->{where}->{'+acpm'}->{id} = undef;
554     }
555
556     if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
557
558         $query->{where}->{'+acp'}->{id} = $hold_target;
559
560     } elsif ($hold_type eq 'V') {
561
562         $query->{where}->{'+acp'}->{call_number} = $hold_target;
563
564     } elsif ($hold_type eq 'P') {
565
566         $query->{from}->{acp}->{acpm} = {
567             field  => 'target_copy',
568             fkey   => 'id',
569             filter => {part => $hold_target},
570         };
571
572     } elsif ($hold_type eq 'I') {
573
574         $query->{from}->{acp}->{sitem} = {
575             field  => 'unit',
576             fkey   => 'id',
577             filter => {issuance => $hold_target},
578         };
579
580     } elsif ($hold_type eq 'T') {
581
582         $query->{from}->{acp}->{acn} = {
583             field  => 'id',
584             fkey   => 'call_number',
585             'join' => {
586                 bre => {
587                     field  => 'id',
588                     filter => {id => $hold_target},
589                     fkey   => 'record'
590                 }
591             }
592         };
593
594     } else { # Metarecord hold
595
596         $query->{from}->{acp}->{acn} = {
597             field => 'id',
598             fkey  => 'call_number',
599             join  => {
600                 bre => {
601                     field => 'id',
602                     fkey  => 'record',
603                     join  => {
604                         mmrsm => {
605                             field  => 'source',
606                             fkey   => 'id',
607                             filter => {metarecord => $hold_target},
608                         }
609                     }
610                 }
611             }
612         };
613
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({
618                 from => [
619                     'metabib.compile_composite_attr',
620                     $hold->holdable_formats
621                 ]
622             })->[0];
623             # TODO: ^- any way to add this as a filter in the main query?
624
625             if ($query_int) {
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} = {
630                     field  => 'source',
631                     fkey   => 'id',
632                     filter => {vlist => {'@@' => $qint}}
633                 }
634             }
635         }
636     }
637
638     my $copies = $e->json_query($query);
639     $self->{eligible_copy_count} = scalar(@$copies);
640
641     $self->log_hold($self->{eligible_copy_count}." potential copies");
642
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;
646
647     $self->copies($copies);
648
649     return 1;
650 }
651
652 # Delete and rebuild copy maps
653 sub update_copy_maps {
654     my $self = shift;
655     my $e = $self->editor;
656
657     my $resp = $e->json_query({from => [
658         'action.hold_request_regen_copy_maps',
659         $self->hold_id,
660         '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
661     ]});
662
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;
666
667     return $self->exit_targeter("Error creating hold copy maps", 1);
668 }
669
670 # Hopeless Date logic based on copy map
671 sub handle_hopeless_date {
672     my ($self) = @_;
673     my $e = $self->editor;
674     my $hold = $self->hold;
675     my $need_update = 0;
676
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.
682
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');
688             $need_update = 1;
689         }
690     } else {
691         my $all_hopeless = 1;
692         foreach my $copy_hash (@{$self->copies}) {
693             if (!$self->parent->{hopeless_prone_status_ids}->{$copy_hash->{status}}) {
694                 $all_hopeless = 0;
695             }
696         }
697         if ($all_hopeless) {
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');
702                 $need_update = 1;
703             }
704         } else {
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;
709                 $need_update = 1;
710             }
711         }
712     }
713
714     if ($need_update) {
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?
720     }
721 }
722
723 # unique set of circ lib IDs for all in-progress copy blobs.
724 sub get_copy_circ_libs {
725     my $self = shift;
726     my %orgs = map {$_->{circ_lib} => 1} @{$self->copies};
727     return [keys %orgs];
728 }
729
730
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 {
736     my $self = shift;
737
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']},
742         from => 'ahcm',
743         where => {hold => $self->hold_id}
744     });
745
746     my %copy_prox_map =
747         map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
748
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);
753
754     my %prox_map;
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} ||= [];
759
760         my $weight = $self->parent->get_ou_setting(
761             $copy_hash->{circ_lib},
762             'circ.holds.org_unit_target_weight', $self->editor) || 1;
763
764         # Each copy is added to the list once per target weight.
765         push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
766     }
767
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;
774     }
775
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;
780     }
781
782     return $self->{weighted_prox_map} = \%prox_map;
783 }
784
785 # Returns true if filtering completed without error, false otherwise.
786 sub filter_closed_date_copies {
787     my $self = shift;
788
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;
792     $circ_libs = [
793         grep {
794             $self->parent->{closed_orgs}->{$_} && 
795             $_ ne $self->hold->pickup_lib
796         } @$circ_libs
797     ];
798
799     # If none of the represented circ libs are closed, we're done here.
800     return 1 unless @$circ_libs;
801
802     $self->parent->precache_batch_ou_settings(
803         $circ_libs, 'circ.holds.target_when_closed', $self->editor);
804
805     my @filtered_copies;
806     for my $copy_hash (@{$self->copies}) {
807         my $clib = $copy_hash->{circ_lib};
808
809         if ($self->parent->{closed_orgs}->{$clib}) {
810             # Org unit is currently closed.  See if it matters.
811
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';
815
816             unless (
817                 $self->parent->get_ou_setting($clib, $ous, $self->editor)) {
818                 # Targeting not allowed at this circ lib when its closed
819
820                 $self->log_hold("skipping copy ".
821                     $copy_hash->{id}." at closed org $clib");
822
823                 next;
824             }
825
826         }
827
828         push(@filtered_copies, $copy_hash);
829     }
830
831     # Update our in-progress list of copies to reflect the filtered set.
832     $self->copies(\@filtered_copies);
833
834     return 1;
835 }
836
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 {
841     my $self = shift;
842
843     # Track checked out copies for later recall
844     $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
845
846     $self->copies([
847         grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
848     ]);
849
850     return 1;
851 }
852
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 {
856     my $self = shift;
857
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}]);
861
862     # A copy with a 'current_copy' value means it's in use by another hold.
863     $self->copies([
864         grep {!$_->{current_copy}} @{$self->copies}
865     ]);
866
867     return 1;
868 }
869
870 # Returns true if inspection completed without error, false otherwise.
871 sub inspect_previous_target {
872     my $self = shift;
873     my $hold = $self->hold;
874     my @copies = @{$self->copies};
875
876     # no previous target
877     return 1 unless my $prev_id = $hold->current_copy;
878
879     $self->{previous_copy_id} = $prev_id;
880
881     # See if the previous copy is in our list of valid copies.
882     my ($prev) = grep {$_->{id} eq $prev_id} @copies;
883
884     # exit if previous target is no longer valid.
885     return 1 unless $prev;
886
887     my $soft_retarget = 0;
888
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.
893
894         my $pct = $dt_parser->parse_datetime(
895             clean_ISO8601($hold->prev_check_time));
896
897         $soft_retarget =
898             DateTime->compare($pct, $self->parent->{retarget_time}) > 0;
899     }
900
901     if ($soft_retarget) {
902
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)) {
906
907             # Commit to persist the updated action.hold_copy_map's
908             $self->editor->commit;
909
910             return $self->exit_targeter(
911                 "Exiting early on soft-retarget with viable copy = $prev_id");
912
913         } else {
914             $self->log_hold("soft retarget failed because copy $prev_id is ".
915                 "no longer targetable for this hold.  Retargeting...");
916         }
917
918     } else {
919
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;
923     }
924
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]);
928
929     return 1;
930 }
931
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) = @_;
937
938     if (!$args{force}) {
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};
942     }
943
944     # At this point, all copies have been inspected and none
945     # have yielded a targetable item.
946
947     if ($args{process_recalls}) {
948         # See if we have any copies/circs to recall.
949         return unless $self->process_recalls;
950     }
951
952     my $hold = $self->hold;
953     $hold->clear_current_copy;
954     $hold->prev_check_time('now');
955
956     $self->editor->update_action_hold_request($hold)
957         or return $self->exit_targeter("Error updating hold request", 1);
958
959     $self->editor->commit;
960     return $self->exit_targeter("Hold has no targetable copies");
961 }
962
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 {
967     my $self = shift;
968     return $self->copies->[0] if
969         $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
970     return undef;
971 }
972
973 sub attempt_to_find_copy {
974     my $self = shift;
975
976     $self->log_hold("attempting to find a copy normally");
977
978     my $max_loops = $self->parent->get_ou_setting(
979         $self->hold->pickup_lib,
980         'circ.holds.max_org_unit_target_loops',
981         $self->editor
982     );
983
984     return $self->target_by_org_loops($max_loops) if $max_loops;
985
986     # When not using target loops, targeting is based solely on
987     # proximity and org unit target weight.
988     $self->compile_weighted_proximity_map;
989
990     return $self->find_nearest_copy;
991 }
992
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) = @_;
998
999     my @iter_copies; # copies to try now.
1000     my @remaining_copies; # copies to try later
1001
1002     for my $copy (@{$self->copies}) {
1003         my $match = 0;
1004
1005         if ($iter == 0) {
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;
1009
1010         } else {
1011             # Find copies at branches whose target count
1012             # matches the current (non-zero) loop depth.
1013
1014             $match = 1 if grep {
1015                 $_->{count} eq $iter &&
1016                 $_->{circ_lib} eq $copy->{circ_lib}
1017             } @$targeted_libs;
1018         }
1019
1020         if ($match) {
1021             push(@iter_copies, $copy);
1022         } else {
1023             push(@remaining_copies, $copy);
1024         }
1025     }
1026
1027     $self->log_hold(
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)
1032         )
1033     );
1034
1035     return (\@iter_copies, \@remaining_copies);
1036 }
1037
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) = @_;
1048
1049     my $targeted_libs = $self->editor->json_query({
1050         select => {aufhl => ['circ_lib', 'count']},
1051         from => 'aufhl',
1052         where => {hold => $self->hold_id},
1053         order_by => [{class => 'aufhl', field => 'count'}]
1054     });
1055
1056     my $max_tried = 0; # Highest per-lib target attempts.
1057     foreach (@$targeted_libs) {
1058         $max_tried = $_->{count} if $_->{count} > $max_tried;
1059     }
1060
1061     $self->log_hold("Max lib attempts is $max_tried. ".
1062         scalar(@$targeted_libs)." libs have been targeted at least once.");
1063
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.
1068     my $loop_iter = -1;
1069     while (++$loop_iter < $max_loops) {
1070
1071         # Ran out of copies to try before exceeding max target loops.
1072         # Nothing else to do here.
1073         return undef unless @{$self->copies};
1074
1075         my ($iter_copies, $remaining_copies) = 
1076             $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
1077
1078         next unless @$iter_copies;
1079
1080         $self->copies($iter_copies);
1081
1082         # Update the proximity map to only include the copies
1083         # from this loop-depth iteration.
1084         $self->compile_weighted_proximity_map;
1085
1086         my $copy = $self->find_nearest_copy;
1087         return $copy if $copy; # found one!
1088
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);
1092     }
1093
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;
1098
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;
1102 }
1103
1104 # Cancel the hold, fire the no-target A/T event handler, and exit.
1105 sub handle_exceeds_target_loops {
1106     my $self = shift;
1107     my $e = $self->editor;
1108     my $hold = $self->hold;
1109
1110     $hold->cancel_time('now');
1111     $hold->cancel_cause(1); # = un-targeted expiration
1112
1113     $e->update_action_hold_request($hold)
1114         or return $self->exit_targeter("Error updating hold request", 1);
1115
1116     $e->commit;
1117
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
1123     );
1124
1125     return $self->exit_targeter("Hold exceeded max target loops");
1126 }
1127
1128 # When all else fails, see if we can reuse the previously targeted copy.
1129 sub attempt_prev_copy_retarget {
1130     my $self = shift;
1131
1132     # earlier target logic can in some cases cancel the hold.
1133     return undef if $self->hold->cancel_time;
1134
1135     my $prev_copy = $self->{valid_previous_copy};
1136     return undef unless $prev_copy;
1137
1138     $self->log_hold("attempting to re-target previously ".
1139         "targeted copy for hold ".$self->hold_id);
1140
1141     if ($self->copy_is_permitted($prev_copy)) {
1142         $self->log_hold("retargeting the previously ".
1143             "targeted copy [".$prev_copy->{id}."]" );
1144         return $prev_copy;
1145     }
1146
1147     return undef;
1148 }
1149
1150 # Returns the closest copy by proximity that is a confirmed valid
1151 # targetable copy.
1152 sub find_nearest_copy {
1153     my $self = shift;
1154     my %prox_map = %{$self->{weighted_prox_map}};
1155     my $hold = $self->hold;
1156     my %seen;
1157
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;
1163         }
1164         $self->log_hold("inside hard stall interval and does ".
1165             ($have_local_copies ? "" : "not "). "have in-use local copies");
1166     }
1167
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.
1171     my $no_copies = 1;
1172     for my $prox (sort {$a <=> $b} keys %prox_map) {
1173         my @copies = @{$prox_map{$prox}};
1174         next unless @copies;
1175
1176         $no_copies = 0;
1177         $have_local_copies = 1 if ($prox <= 0);
1178
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);
1182
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
1188             );
1189             last if ($prox > 0); # No point in looking further "out".
1190         }
1191
1192         my $rand = int(rand(scalar(@copies)));
1193
1194         while (my ($c) = splice(@copies, $rand, 1)) {
1195             $rand = int(rand(scalar(@copies)));
1196             next if $seen{$c->{id}};
1197
1198             return $c if $self->copy_is_permitted($c);
1199             $seen{$c->{id}} = 1;
1200
1201             last unless(@copies);
1202         }
1203     }
1204
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
1210         );
1211     }
1212
1213     return undef;
1214 }
1215
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;
1222
1223     my $resp = $self->editor->json_query({
1224         from => [
1225             'action.hold_retarget_permit_test',
1226             $self->hold->pickup_lib,
1227             $self->hold->request_lib,
1228             $copy->{id},
1229             $self->hold->usr,
1230             $self->hold->requestor
1231         ]
1232     });
1233
1234     return 1 if $U->is_true($resp->[0]->{success});
1235
1236     # Copy is confirmed non-viable.
1237     # Remove it from our potentials list.
1238     $self->copies([
1239         grep {$_->{id} ne $copy->{id}} @{$self->copies}
1240     ]);
1241
1242     return 0;
1243 }
1244
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;
1250
1251     $hold->current_copy($copy->{id});
1252     $hold->prev_check_time('now');
1253
1254     $e->update_action_hold_request($hold)
1255         or return $self->exit_targeter("Error updating hold request", 1);
1256
1257     $e->commit;
1258     $self->{success} = 1;
1259     return $self->exit_targeter("successfully targeted copy ".$copy->{id});
1260 }
1261
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 {
1265     my $self = shift;
1266     return 1 unless my $prev_id = $self->{previous_copy_id};
1267     my $e = $self->editor;
1268
1269     $self->log_hold(
1270         "hold was not fulfilled by previous targeted copy $prev_id");
1271
1272     my $circ_lib;
1273     if ($self->{valid_previous_copy}) {
1274         $circ_lib = $self->{valid_previous_copy}->{circ_lib};
1275
1276     } else {
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;
1280     }
1281
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);
1286
1287     $e->create_action_unfulfilled_hold_list($unful) or
1288         return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
1289
1290     return 1;
1291 }
1292
1293 sub process_recalls {
1294     my $self = shift;
1295     my $e = $self->editor;
1296
1297     my $pu_lib = $self->hold->pickup_lib;
1298
1299     my $threshold =
1300         $self->parent->get_ou_setting(
1301             $pu_lib, 'circ.holds.recall_threshold', $self->editor)
1302         or return 1;
1303
1304     my $interval =
1305         $self->parent->get_ou_setting(
1306             $pu_lib, 'circ.holds.recall_return_interval', $self->editor)
1307         or return 1;
1308
1309     # Give me the ID of every checked out copy living at the hold
1310     # pickup library.
1311     my @copy_ids = map {$_->{id}}
1312         grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
1313
1314     return 1 unless @copy_ids;
1315
1316     my $circ = $e->search_action_circulation([
1317         {   target_copy => \@copy_ids,
1318             checkin_time => undef,
1319             duration => {'>' => $threshold}
1320         }, {
1321             order_by => [{ class => 'circ', field => 'due_date'}],
1322             limit => 1
1323         }
1324     ])->[0];
1325
1326     return unless $circ;
1327
1328     $self->log_hold("recalling circ ".$circ->id);
1329
1330     my $old_due_date = DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($circ->due_date));
1331
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));
1337
1338     my $return_date = DateTime->now(time_zone => 'local')->add(
1339         seconds => interval_to_seconds($interval));
1340
1341     if (DateTime->compare($threshold_date, $return_date) == 1) {
1342         # extend $return_date to threshold
1343         $return_date = $threshold_date;
1344     }
1345     # But don't go past the original due date
1346     # (the threshold should not be past the due date, but manual edits can
1347     # cause it to be)
1348     if (DateTime->compare($return_date, $old_due_date) == 1) {
1349         # truncate $return_date to due date
1350         $return_date = $old_due_date;
1351     }
1352
1353     my %update_fields = (
1354         due_date => $return_date->iso8601(),
1355         renewal_remaining => 0,
1356     );
1357
1358     my $fine_rules =
1359         $self->parent->get_ou_setting(
1360             $pu_lib, 'circ.holds.recall_fine_rules', $self->editor);
1361
1362     # If the OU hasn't defined new fine rules for recalls, keep them
1363     # as they were
1364     if ($fine_rules) {
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];
1370     }
1371
1372     # Copy updated fields into circ object.
1373     $circ->$_($update_fields{$_}) for keys %update_fields;
1374
1375     $e->update_action_circulation($circ)
1376         or return $self->exit_targeter(
1377             "Error updating circulation object in process_recalls", 1);
1378
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);
1383
1384     return 1;
1385 }
1386
1387 # Target a single hold request
1388 sub target {
1389     my ($self, $hold_id) = @_;
1390
1391     my $e = $self->editor;
1392     $self->hold_id($hold_id);
1393
1394     $self->log_hold("processing...");
1395
1396     $e->xact_begin;
1397
1398     my $hold = $e->retrieve_action_hold_request($hold_id)
1399         or return $self->exit_targeter("No hold found", 1);
1400
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);
1406
1407     $self->hold($hold);
1408
1409     return unless $self->handle_expired_hold;
1410     return unless $self->get_hold_copies;
1411     return unless $self->update_copy_maps;
1412
1413     # Hopeless Date logic based on copy map
1414
1415     $self->handle_hopeless_date;
1416
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;
1420
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;
1426
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;
1431
1432     # Log that the hold was not captured.
1433     return unless $self->log_unfulfilled_hold;
1434
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);
1438
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.
1444
1445     my $copy = $self->attempt_force_recall_target ||
1446                $self->attempt_to_find_copy        ||
1447                $self->attempt_prev_copy_retarget;
1448
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;
1452
1453     return $self->apply_copy_target($copy) if $copy;
1454
1455     # No targetable copy was found.  Fire the no-copy handler.
1456     $self->handle_no_copies(force => 1, process_recalls => 1);
1457 }
1458
1459
1460