]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
LP2042879 Shelving Location Groups Admin accessibility
[working/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         targetable_statuses => [],
36         %args,
37     };
38     return bless($self, $class);
39 }
40
41 # Returns a list of hold ID's
42 sub find_holds_to_target {
43     my $self = shift;
44
45     if ($self->{hold}) {
46         # $self->{hold} can be a single hold ID or an array ref of hold IDs
47         return @{$self->{hold}} if ref $self->{hold} eq 'ARRAY';
48         return ($self->{hold});
49     }
50
51     my $query = {
52         select => {ahr => ['id']},
53         from => 'ahr',
54         where => {
55             capture_time => undef,
56             fulfillment_time => undef,
57             cancel_time => undef,
58             frozen => 'f'
59         },
60         order_by => [
61             {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
62             {class => 'ahr', field => 'request_time'},
63             {class => 'ahr', field => 'prev_check_time'}
64         ]
65     };
66
67     # Target holds that have no prev_check_time or those whose re-target
68     # time has come.  If a soft_retarget_time is specified, that acts as
69     # the boundary.  Otherwise, the retarget_time is used.
70     my $start_time = $self->{soft_retarget_time} || $self->{retarget_time};
71     $query->{where}->{'-or'} = [
72         {prev_check_time => undef},
73         {prev_check_time => {'<=' => $start_time->strftime('%F %T%z')}}
74     ];
75
76     # parallel < 1 means no parallel
77     my $parallel = ($self->{parallel_count} || 0) > 1 ? 
78         $self->{parallel_count} : 0;
79
80     if ($parallel) {
81         # In parallel mode, we need to also grab the metarecord for each hold.
82         $query->{from} = {
83             ahr => {
84                 rhrr => {
85                     fkey => 'id',
86                     field => 'id',
87                     join => {
88                         mmrsm => {
89                             field => 'source',
90                             fkey => 'bib_record'
91                         }
92                     }
93                 }
94             }
95         };
96
97         # In parallel mode, only process holds within the current process
98         # whose metarecord ID modulo the parallel targeter count matches
99         # our paralell targeting slot.  This ensures that no 2 processes
100         # will be operating on the same potential copy sets.
101         #
102         # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5, 
103         # process holds whose metarecord ID's are 2, 7, 12, 17, ...
104         # WHERE MOD(mmrsm.id, 5) = 2
105
106         # Slots are 1-based at the API level, but 0-based for modulo.
107         my $slot = $self->{parallel_slot} - 1;
108
109         $query->{where}->{'+mmrsm'} = {
110             metarecord => {
111                 '=' => {
112                     transform => 'mod',
113                     value => $slot,
114                     params => [$parallel]
115                 }
116             }
117         };
118     }
119
120     # Newest-first sorting cares only about hold create_time.
121     $query->{order_by} =
122         [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
123         if $self->{newest_first};
124
125     my $holds = $self->editor->json_query($query, {substream => 1});
126
127     return map {$_->{id}} @$holds;
128 }
129
130 sub editor {
131     my $self = shift;
132     return $self->{editor};
133 }
134
135 # Load startup data required by all targeter actions.
136 sub init {
137     my $self = shift;
138     my $e = $self->editor;
139
140     # See if the caller provided an interval
141     my $interval = $self->{retarget_interval};
142
143     if (!$interval) { 
144         # See if we have a global flag value for the interval
145
146         $interval = $e->search_config_global_flag({
147             name => 'circ.holds.retarget_interval',
148             enabled => 't'
149         })->[0];
150
151         # If no flag is present, default to a 24-hour retarget interval.
152         $interval = $interval ? $interval->value : '24h';
153     }
154
155     my $retarget_seconds = interval_to_seconds($interval);
156
157     $self->{retarget_time} = DateTime->now(time_zone => 'local')
158         ->subtract(seconds => $retarget_seconds);
159
160     $logger->info("Using retarget time: ".
161         $self->{retarget_time}->strftime('%F %T%z'));
162
163     if ($self->{soft_retarget_interval}) {
164
165         my $secs = OpenILS::Utils::DateTime->interval_to_seconds(
166             $self->{soft_retarget_interval});
167
168         $self->{soft_retarget_time} = 
169             DateTime->now(time_zone => 'local')->subtract(seconds => $secs);
170
171         $logger->info("Using soft retarget time: " .
172             $self->{soft_retarget_time}->strftime('%F %T%z'));
173     }
174
175     # Holds targeted in the current targeter instance not be retargeted
176     # until the next check date.  If a next_check_interval is provided
177     # it overrides the retarget_interval.
178     my $next_check_secs = 
179         $self->{next_check_interval} ?
180         OpenILS::Utils::DateTime->interval_to_seconds($self->{next_check_interval}) :
181         $retarget_seconds;
182
183     my $next_check_date = 
184         DateTime->now(time_zone => 'local')->add(seconds => $next_check_secs);
185
186     my $next_check_time = $next_check_date->strftime('%F %T%z');
187
188     $logger->info("Next check time: $next_check_time");
189
190     # An org unit is considered closed for retargeting purposes
191     # if it's closed both now and at the next re-target date.
192     my $closed = $self->editor->search_actor_org_unit_closed_date({
193         '-and' => [{   
194             close_start => {'<=', 'now'},
195             close_end => {'>=', 'now'}
196         }, {
197             close_start => {'<=', $next_check_time},
198             close_end => {'>=', $next_check_time}
199         }]
200     });
201
202     my @closed_orgs = map {$_->org_unit} @$closed;
203     $logger->info("closed org unit IDs: @closed_orgs");
204
205     # Map of org id to 1. Any org in the map is closed.
206     $self->{closed_orgs} = {map {$_ => 1} @closed_orgs};
207
208     my $hopeless_prone = $self->editor->search_config_copy_status({
209         hopeless_prone => 't'
210     });
211     $self->{hopeless_prone_status_ids} = { map { $_->id => 1} @{ $hopeless_prone } };
212 }
213
214
215 # Org unit setting fetch+cache
216 # $e is the OpenILS::Utils::HoldTargeter::Single editor.  Use it if
217 # provided to avoid timeouts on the in-transaction child editor.
218 sub get_ou_setting {
219     my ($self, $org_id, $setting, $e) = @_;
220     my $c = $self->{ou_setting_cache};
221
222     $e ||= $self->{editor};
223     $c->{$org_id} = {} unless $c->{$org_id};
224
225     $c->{$org_id}->{$setting} =
226         $U->ou_ancestor_setting_value($org_id, $setting, $e)
227         unless exists $c->{$org_id}->{$setting};
228
229     return $c->{$org_id}->{$setting};
230 }
231
232 # Fetches settings for a batch of org units.  Useful for pre-caching
233 # setting values across a wide variety of org units without having to
234 # make a lookup call for every org unit.
235 # First checks to see if a value exists in the cache.
236 # For all non-cached values, looks up in DB, then caches the value.
237 sub precache_batch_ou_settings {
238     my ($self, $org_ids, $setting, $e) = @_;
239
240     $e ||= $self->{editor};
241     my $c = $self->{ou_setting_cache};
242
243     my @orgs;
244     for my $org_id (@$org_ids) {
245         next if exists $c->{$org_id}->{$setting};
246         push (@orgs, $org_id);
247     }
248
249     return unless @orgs; # value aready cached for all requested orgs.
250
251     my %settings = 
252         $U->ou_ancestor_setting_batch_by_org_insecure(\@orgs, $setting, $e);
253
254     for my $org_id (keys %settings) {
255         $c->{$org_id}->{$setting} = $settings{$org_id}->{value};
256     }
257 }
258
259 # Get the list of statuses able to target a hold, i.e. allowed for the
260 # current_copy.  Default to 0 and 7 if there ia a failure.
261 sub get_targetable_statuses {
262     my $self = shift;
263     unless (ref($self->{tagetable_statuses}) eq 'ARRAY' && @{$self->{targetable_statuses}}) {
264         my $e = $self->{editor};
265         $self->{targetable_statuses} = $e->search_config_copy_status({holdable => 't', is_available => 't'},
266                                                                      {idlist => 1});
267         unless (ref($self->{targetable_statuses}) eq 'ARRAY' && @{$self->{targetable_statuses}}) {
268             $self->{targetable_statuses} = [0,7];
269         }
270     }
271     return $self->{targetable_statuses};
272 }
273
274 # -----------------------------------------------------------------------
275 # Knows how to target a single hold.
276 # -----------------------------------------------------------------------
277 package OpenILS::Utils::HoldTargeter::Single;
278 use strict;
279 use warnings;
280 use DateTime;
281 use OpenSRF::AppSession;
282 use OpenILS::Utils::DateTime qw/:datetime/;
283 use OpenSRF::Utils::Logger qw(:logger);
284 use OpenILS::Application::AppUtils;
285 use OpenILS::Utils::CStoreEditor qw/:funcs/;
286
287 sub new {
288     my ($class, %args) = @_;
289     my $self = {
290         %args,
291         editor => new_editor(),
292         error => 0,
293         success => 0
294     };
295     return bless($self, $class);
296 }
297
298 # Parent targeter object.
299 sub parent {
300     my ($self, $parent) = @_;
301     $self->{parent} = $parent if $parent;
302     return $self->{parent};
303 }
304
305 sub hold_id {
306     my ($self, $hold_id) = @_;
307     $self->{hold_id} = $hold_id if $hold_id;
308     return $self->{hold_id};
309 }
310
311 sub hold {
312     my ($self, $hold) = @_;
313     $self->{hold} = $hold if $hold;
314     return $self->{hold};
315 }
316
317 sub inside_hard_stall_interval {
318     my ($self) = @_;
319     if (defined $self->{inside_hard_stall_interval}) {
320         $self->log_hold('already looked up hard stalling state: '.$self->{inside_hard_stall_interval});
321         return $self->{inside_hard_stall_interval};
322     }
323
324     my $hard_stall_interval =
325         $self->parent->get_ou_setting(
326             $self->hold->pickup_lib, 'circ.pickup_hold_stalling.hard', $self->editor) || '0 seconds';
327
328     $self->log_hold('hard stalling interval '.$hard_stall_interval);
329
330     my $hold_request_time = $dt_parser->parse_datetime(clean_ISO8601($self->hold->request_time));
331     my $hard_stall_time = $hold_request_time->clone->add(
332         seconds => OpenILS::Utils::DateTime->interval_to_seconds($hard_stall_interval)
333     );
334
335     if (DateTime->compare($hard_stall_time, DateTime->now(time_zone => 'local')) > 0) {
336         $self->{inside_hard_stall_interval} = 1
337     } else {
338         $self->{inside_hard_stall_interval} = 0
339     }
340
341     $self->log_hold('hard stalling state: '.$self->{inside_hard_stall_interval});
342     return $self->{inside_hard_stall_interval};
343 }
344
345 # Debug message
346 sub message {
347     my ($self, $message) = @_;
348     $self->{message} = $message if $message;
349     return $self->{message} || '';
350 }
351
352 # True if the hold was successfully targeted.
353 sub success {
354     my ($self, $success) = @_;
355     $self->{success} = $success if defined $success;
356     return $self->{success};
357 }
358
359 # True if targeting exited early on an unrecoverable error.
360 sub error {
361     my ($self, $error) = @_;
362     $self->{error} = $error if defined $error;
363     return $self->{error};
364 }
365
366 sub editor {
367     my $self = shift;
368     return $self->{editor};
369 }
370
371 sub result {
372     my $self = shift;
373
374     return {
375         hold    => $self->hold_id,
376         error   => $self->error,
377         success => $self->success,
378         message => $self->message,
379         target  => $self->hold ? $self->hold->current_copy : undef,
380         old_target => $self->{previous_copy_id},
381         found_copy => $self->{found_copy},
382         eligible_copies => $self->{eligible_copy_count}
383     };
384 }
385
386 # List of potential copies in the form of slim hashes.  This list
387 # evolves as copies are filtered as they are deemed non-targetable.
388 sub copies {
389     my ($self, $copies) = @_;
390     $self->{copies} = $copies if $copies;
391     return $self->{copies};
392 }
393
394 # Final set of potential copies, including those that may not be
395 # currently targetable, that may be eligible for recall processing.
396 sub recall_copies {
397     my ($self, $recall_copies) = @_;
398     $self->{recall_copies} = $recall_copies if $recall_copies;
399     return $self->{recall_copies};
400 }
401
402 sub in_use_copies {
403     my ($self, $in_use_copies) = @_;
404     $self->{in_use_copies} = $in_use_copies if $in_use_copies;
405     return $self->{in_use_copies};
406 }
407
408 # Maps copy ID's to their hold proximity
409 sub copy_prox_map {
410     my ($self, $copy_prox_map) = @_;
411     $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
412     return $self->{copy_prox_map};
413 }
414
415 sub log_hold {
416     my ($self, $msg, $err) = @_;
417     my $level = $err ? 'error' : 'info';
418     $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
419 }
420
421 # Captures the exit message, rolls back the cstore transaction/connection,
422 # and returns false.
423 # is_error : log the final message and editor event at ERR level.
424 sub exit_targeter {
425     my ($self, $msg, $is_error) = @_;
426
427     $self->message($msg);
428     my $log = "exiting => $msg";
429
430     if ($is_error) {
431         # On error, roll back and capture the last editor event for logging.
432
433         my $evt = $self->editor->die_event;
434         $log .= " [".$evt->{textcode}."]" if $evt;
435
436         $self->error(1);
437         $self->log_hold($log, 1);
438
439     } else {
440         # Attempt a rollback and disconnect when each hold exits
441         # to avoid the possibility of leaving cstore's pinned.
442         # Note: ->rollback is a no-op when a ->commit has already occured.
443
444         $self->editor->rollback;
445         $self->log_hold($log);
446     }
447
448     return 0;
449 }
450
451 # Cancel expired holds and kick off the A/T no-target event.  Returns
452 # true (i.e. keep going) if the hold is not expired.  Returns false if
453 # the hold is canceled or a non-recoverable error occcurred.
454 sub handle_expired_hold {
455     my $self = shift;
456     my $hold = $self->hold;
457
458     return 1 unless $hold->expire_time;
459
460     my $ex_time =
461         $dt_parser->parse_datetime(clean_ISO8601($hold->expire_time));
462     return 1 unless 
463         DateTime->compare($ex_time, DateTime->now(time_zone => 'local')) < 0;
464
465     # Hold is expired --
466
467     $hold->cancel_time('now');
468     $hold->cancel_cause(1); # == un-targeted expiration
469
470     $self->editor->update_action_hold_request($hold)
471         or return $self->exit_targeter("Error canceling hold", 1);
472
473     $self->editor->commit;
474
475     # Fire the A/T handler, but don't wait for a response.
476     OpenSRF::AppSession->create('open-ils.trigger')->request(
477         'open-ils.trigger.event.autocreate',
478         'hold_request.cancel.expire_no_target',
479         $hold, $hold->pickup_lib
480     );
481
482     return $self->exit_targeter("Hold is expired");
483 }
484
485 # Find potential copies for hold mapping and targeting.
486 sub get_hold_copies {
487     my $self = shift;
488     my $e = $self->editor;
489     my $hold = $self->hold;
490
491     my $hold_target = $hold->target;
492     my $hold_type   = $hold->hold_type;
493     my $org_unit    = $hold->selection_ou;
494     my $org_depth   = $hold->selection_depth || 0;
495
496     my $query = {
497         select => {
498             acp => ['id', 'status', 'circ_lib'],
499             ahr => ['current_copy']
500         },
501         from => {
502             acp => {
503                 # Tag copies that are in use by other holds so we don't
504                 # try to target them for our hold.
505                 ahr => {
506                     type => 'left',
507                     fkey => 'id', # acp.id
508                     field => 'current_copy',
509                     filter => {
510                         fulfillment_time => undef,
511                         cancel_time => undef,
512                         id => {'!=' => $self->hold_id}
513                     }
514                 }
515             }
516         },
517         where => {
518             '+acp' => {
519                 deleted => 'f',
520                 circ_lib => {
521                     in => {
522                         select => {
523                             aou => [{
524                                 transform => 'actor.org_unit_descendants',
525                                 column => 'id',
526                                 result_field => 'id',
527                                 params => [$org_depth]
528                             }],
529                             },
530                         from => 'aou',
531                         where => {id => $org_unit}
532                     }
533                 }
534             }
535         }
536     };
537
538     unless ($hold_type eq 'R' || $hold_type eq 'F') {
539         # Add the holdability filters to the copy query, unless
540         # we're processing a Recall or Force hold, which bypass most
541         # holdability checks.
542
543         $query->{from}->{acp}->{acpl} = {
544             field => 'id',
545             filter => {holdable => 't', deleted => 'f'},
546             fkey => 'location'
547         };
548
549         $query->{from}->{acp}->{ccs} = {
550             field => 'id',
551             filter => {holdable => 't'},
552             fkey => 'status'
553         };
554
555         $query->{where}->{'+acp'}->{holdable} = 't';
556         $query->{where}->{'+acp'}->{mint_condition} = 't'
557             if $U->is_true($hold->mint_condition);
558     }
559
560     unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
561         # For volume and higher level holds, avoid targeting copies that
562         # act as instances of monograph parts.
563         $query->{from}->{acp}->{acpm} = {
564             type => 'left',
565             field => 'target_copy',
566             fkey => 'id'
567         };
568
569         $query->{where}->{'+acpm'}->{id} = undef;
570     }
571
572     if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
573
574         $query->{where}->{'+acp'}->{id} = $hold_target;
575
576     } elsif ($hold_type eq 'V') {
577
578         $query->{where}->{'+acp'}->{call_number} = $hold_target;
579
580     } elsif ($hold_type eq 'P') {
581
582         $query->{from}->{acp}->{acpm} = {
583             field  => 'target_copy',
584             fkey   => 'id',
585             filter => {part => $hold_target},
586         };
587
588     } elsif ($hold_type eq 'I') {
589
590         $query->{from}->{acp}->{sitem} = {
591             field  => 'unit',
592             fkey   => 'id',
593             filter => {issuance => $hold_target},
594         };
595
596     } elsif ($hold_type eq 'T') {
597
598         $query->{from}->{acp}->{acn} = {
599             field  => 'id',
600             fkey   => 'call_number',
601             'join' => {
602                 bre => {
603                     field  => 'id',
604                     filter => {id => $hold_target},
605                     fkey   => 'record'
606                 }
607             }
608         };
609
610     } else { # Metarecord hold
611
612         $query->{from}->{acp}->{acn} = {
613             field => 'id',
614             fkey  => 'call_number',
615             join  => {
616                 bre => {
617                     field => 'id',
618                     fkey  => 'record',
619                     join  => {
620                         mmrsm => {
621                             field  => 'source',
622                             fkey   => 'id',
623                             filter => {metarecord => $hold_target},
624                         }
625                     }
626                 }
627             }
628         };
629
630         if ($hold->holdable_formats) {
631             # Compile the JSON-encoded metarecord holdable formats
632             # to an Intarray query_int string.
633             my $query_int = $e->json_query({
634                 from => [
635                     'metabib.compile_composite_attr',
636                     $hold->holdable_formats
637                 ]
638             })->[0];
639             # TODO: ^- any way to add this as a filter in the main query?
640
641             if ($query_int) {
642                 # Only pull potential copies from records that satisfy
643                 # the holdable formats query.
644                 my $qint = $query_int->{'metabib.compile_composite_attr'};
645                 $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
646                     field  => 'source',
647                     fkey   => 'id',
648                     filter => {vlist => {'@@' => $qint}}
649                 }
650             }
651         }
652     }
653
654     my $copies = $e->json_query($query);
655     $self->{eligible_copy_count} = scalar(@$copies);
656
657     $self->log_hold($self->{eligible_copy_count}." potential copies");
658
659     # Let the caller know we encountered the copy they were interested in.
660     $self->{found_copy} = 1 if $self->{find_copy}
661         && grep {$_->{id} eq $self->{find_copy}} @$copies;
662
663     $self->copies($copies);
664
665     return 1;
666 }
667
668 # Delete and rebuild copy maps
669 sub update_copy_maps {
670     my $self = shift;
671     my $e = $self->editor;
672
673     my $resp = $e->json_query({from => [
674         'action.hold_request_regen_copy_maps',
675         $self->hold_id,
676         '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
677     ]});
678
679     # The above call can fail if another process is updating
680     # copy maps for this hold at the same time.
681     return 1 if $resp && @$resp;
682
683     return $self->exit_targeter("Error creating hold copy maps", 1);
684 }
685
686 # Hopeless Date logic based on copy map
687 sub handle_hopeless_date {
688     my ($self) = @_;
689     my $e = $self->editor;
690     my $hold = $self->hold;
691     my $need_update = 0;
692
693     # If copy map is empty and hopeless date is not already set,
694     # then set it. Otherwise, let's check the items for Hopeless
695     # Prone statuses.  If all are hopeless then set the hopeless
696     # date if needed.  If at least one is not hopeless, then
697     # clear the the hopeless date if not already unset.
698
699     if (scalar(@{$self->copies}) == 0) {
700         $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): no copies');
701         if (!$hold->hopeless_date) {
702             $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): setting hopeless_date');
703             $hold->hopeless_date('now');
704             $need_update = 1;
705         }
706     } else {
707         my $all_hopeless = 1;
708         foreach my $copy_hash (@{$self->copies}) {
709             if (!$self->parent->{hopeless_prone_status_ids}->{$copy_hash->{status}}) {
710                 $all_hopeless = 0;
711             }
712         }
713         if ($all_hopeless) {
714             $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): all copies have hopeless prone status');
715             if (!$hold->hopeless_date) {
716                 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): setting hopeless_date');
717                 $hold->hopeless_date('now');
718                 $need_update = 1;
719             }
720         } else {
721             $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): at least one copy without a hopeless prone status');
722             if ($hold->hopeless_date) {
723                 $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): clearing hopeless_date');
724                 $hold->clear_hopeless_date;
725                 $need_update = 1;
726             }
727         }
728     }
729
730     if ($need_update) {
731         $logger->debug('Hopeless Holds logic (hold id ' . $hold->id . '): attempting update');
732         $e->update_action_hold_request($hold)
733             or return $self->exit_targeter(
734                 "Error updating Hopeless Date for hold request", 1);
735         # FIXME: sanity-check, will a commit happen further down the line for all use cases?
736     }
737 }
738
739 # unique set of circ lib IDs for all in-progress copy blobs.
740 sub get_copy_circ_libs {
741     my $self = shift;
742     my %orgs = map {$_->{circ_lib} => 1} @{$self->copies};
743     return [keys %orgs];
744 }
745
746
747 # Returns a map of proximity values to arrays of copy hashes.
748 # The copy hash arrays are weighted consistent with the org unit hold
749 # target weight, meaning that a given copy may appear more than once
750 # in its proximity list.
751 sub compile_weighted_proximity_map {
752     my $self = shift;
753
754     # Collect copy proximity info (generated via DB trigger)
755     # from our newly create copy maps.
756     my $hold_copy_maps = $self->editor->json_query({
757         select => {ahcm => ['target_copy', 'proximity']},
758         from => 'ahcm',
759         where => {hold => $self->hold_id}
760     });
761
762     my %copy_prox_map =
763         map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
764
765     # Pre-fetch the org setting value for all circ libs so that
766     # later calls can reference the cached value.
767     $self->parent->precache_batch_ou_settings($self->get_copy_circ_libs, 
768         'circ.holds.org_unit_target_weight', $self->editor);
769
770     my %prox_map;
771     for my $copy_hash (@{$self->copies}) {
772         my $prox = $copy_prox_map{$copy_hash->{id}};
773         $copy_hash->{proximity} = $prox;
774         $prox_map{$prox} ||= [];
775
776         my $weight = $self->parent->get_ou_setting(
777             $copy_hash->{circ_lib},
778             'circ.holds.org_unit_target_weight', $self->editor) || 1;
779
780         # Each copy is added to the list once per target weight.
781         push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
782     }
783
784     # We need to grab the proximity for copies targeted by other holds
785     # that belong to this pickup lib for hard-stalling tests later. We'll
786     # just grab them all in case it's useful later.
787     for my $copy_hash (@{$self->in_use_copies}) {
788         my $prox = $copy_prox_map{$copy_hash->{id}};
789         $copy_hash->{proximity} = $prox;
790     }
791
792     # We also need the proximity for the previous target.
793     if ($self->{valid_previous_copy}) {
794         my $prox = $copy_prox_map{$self->{valid_previous_copy}->{id}};
795         $self->{valid_previous_copy}->{proximity} = $prox;
796     }
797
798     return $self->{weighted_prox_map} = \%prox_map;
799 }
800
801 # Returns true if filtering completed without error, false otherwise.
802 sub filter_closed_date_copies {
803     my $self = shift;
804
805     # Pre-fetch the org setting value for all represented circ libs that
806     # are closed, minuse the pickup_lib, since it has its own special setting.
807     my $circ_libs = $self->get_copy_circ_libs;
808     $circ_libs = [
809         grep {
810             $self->parent->{closed_orgs}->{$_} && 
811             $_ ne $self->hold->pickup_lib
812         } @$circ_libs
813     ];
814
815     # If none of the represented circ libs are closed, we're done here.
816     return 1 unless @$circ_libs;
817
818     $self->parent->precache_batch_ou_settings(
819         $circ_libs, 'circ.holds.target_when_closed', $self->editor);
820
821     my @filtered_copies;
822     for my $copy_hash (@{$self->copies}) {
823         my $clib = $copy_hash->{circ_lib};
824
825         if ($self->parent->{closed_orgs}->{$clib}) {
826             # Org unit is currently closed.  See if it matters.
827
828             my $ous = $self->hold->pickup_lib eq $clib ?
829                 'circ.holds.target_when_closed_if_at_pickup_lib' :
830                 'circ.holds.target_when_closed';
831
832             unless (
833                 $self->parent->get_ou_setting($clib, $ous, $self->editor)) {
834                 # Targeting not allowed at this circ lib when its closed
835
836                 $self->log_hold("skipping copy ".
837                     $copy_hash->{id}." at closed org $clib");
838
839                 next;
840             }
841
842         }
843
844         push(@filtered_copies, $copy_hash);
845     }
846
847     # Update our in-progress list of copies to reflect the filtered set.
848     $self->copies(\@filtered_copies);
849
850     return 1;
851 }
852
853 # Limit the set of potential copies to those that are
854 # in a targetable status.
855 # Returns true if filtering completes without error, false otherwise.
856 sub filter_copies_by_status {
857     my $self = shift;
858
859     # Track checked out copies for later recall
860     $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
861
862     my $targetable_statuses = $self->parent->get_targetable_statuses();
863     $self->copies([
864         grep {
865             my $c = $_;
866             grep {$c->{status} == $_} @{$targetable_statuses}
867         } @{$self->copies}
868     ]);
869
870     return 1;
871 }
872
873 # Remove copies that are currently targeted by other holds.
874 # Returns true if filtering completes without error, false otherwise.
875 sub filter_copies_in_use {
876     my $self = shift;
877
878     # Copies that are targeted, but could contribute to pickup lib
879     # hard (foreign) stalling.  These are Available-status copies.
880     $self->in_use_copies([grep {$_->{current_copy}} @{$self->copies}]);
881
882     # A copy with a 'current_copy' value means it's in use by another hold.
883     $self->copies([
884         grep {!$_->{current_copy}} @{$self->copies}
885     ]);
886
887     return 1;
888 }
889
890 # Returns true if inspection completed without error, false otherwise.
891 sub inspect_previous_target {
892     my $self = shift;
893     my $hold = $self->hold;
894     my @copies = @{$self->copies};
895
896     # no previous target
897     return 1 unless my $prev_id = $hold->current_copy;
898
899     $self->{previous_copy_id} = $prev_id;
900
901     # See if the previous copy is in our list of valid copies.
902     my ($prev) = grep {$_->{id} eq $prev_id} @copies;
903
904     # exit if previous target is no longer valid.
905     return 1 unless $prev;
906
907     my $soft_retarget = 0;
908
909     if ($self->parent->{soft_retarget_time}) {
910         # A hold is soft-retarget-able if its prev_check_time is
911         # later then the retarget_time, i.e. it sits between the
912         # soft_retarget_time and the retarget_time.
913
914         my $pct = $dt_parser->parse_datetime(
915             clean_ISO8601($hold->prev_check_time));
916
917         $soft_retarget =
918             DateTime->compare($pct, $self->parent->{retarget_time}) > 0;
919     }
920
921     if ($soft_retarget) {
922
923         # In soft-retarget mode, if the existing copy is still a valid
924         # target for the hold, exit early.
925         if ($self->copy_is_permitted($prev)) {
926
927             # Commit to persist the updated action.hold_copy_map's
928             $self->editor->commit;
929
930             return $self->exit_targeter(
931                 "Exiting early on soft-retarget with viable copy = $prev_id");
932
933         } else {
934             $self->log_hold("soft retarget failed because copy $prev_id is ".
935                 "no longer targetable for this hold.  Retargeting...");
936         }
937
938     } else {
939
940         # Previous copy /may/ be targetable.  Keep it around for later
941         # in case we need to confirm its viability and re-use it.
942         $self->{valid_previous_copy} = $prev;
943     }
944
945     # Remove the previous copy from the working set of potential copies.
946     # It will be revisited later if needed.
947     $self->copies([grep {$_->{id} ne $prev_id} @copies]);
948
949     return 1;
950 }
951
952 # Returns true if we have at least one potential copy remaining, thus
953 # targeting should continue.  Otherwise, the hold is updated to reflect
954 # that there is no target and returns false to stop targeting.
955 sub handle_no_copies {
956     my ($self, %args) = @_;
957
958     if (!$args{force}) {
959         # If 'force' is set, the caller is saying that all copies have
960         # failed.  Otherwise, see if we have any copies left to inspect.
961         return 1 if @{$self->copies} || $self->{valid_previous_copy};
962     }
963
964     # At this point, all copies have been inspected and none
965     # have yielded a targetable item.
966
967     if ($args{process_recalls}) {
968         # See if we have any copies/circs to recall.
969         return unless $self->process_recalls;
970     }
971
972     my $hold = $self->hold;
973     $hold->clear_current_copy;
974     $hold->prev_check_time('now');
975
976     $self->editor->update_action_hold_request($hold)
977         or return $self->exit_targeter("Error updating hold request", 1);
978
979     $self->editor->commit;
980     return $self->exit_targeter("Hold has no targetable copies");
981 }
982
983 # Force and recall holds bypass validity tests.  Returns the first
984 # (and presumably only) copy in our list of valid copies when a
985 # F or R hold is encountered.  Returns undef otherwise.
986 sub attempt_force_recall_target {
987     my $self = shift;
988     return $self->copies->[0] if
989         $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
990     return undef;
991 }
992
993 sub attempt_to_find_copy {
994     my $self = shift;
995
996     $self->log_hold("attempting to find a copy normally");
997
998     my $max_loops = $self->parent->get_ou_setting(
999         $self->hold->pickup_lib,
1000         'circ.holds.max_org_unit_target_loops',
1001         $self->editor
1002     );
1003
1004     return $self->target_by_org_loops($max_loops) if $max_loops;
1005
1006     # When not using target loops, targeting is based solely on
1007     # proximity and org unit target weight.
1008     $self->compile_weighted_proximity_map;
1009
1010     return $self->find_nearest_copy;
1011 }
1012
1013 # Returns 2 arrays.  The first is a list of copies whose circ lib's
1014 # unfulfilled target count matches the provided $iter value.  The 
1015 # second list is all other copies, returned for convenience.
1016 sub get_copies_at_loop_iter {
1017     my ($self, $targeted_libs, $iter) = @_;
1018
1019     my @iter_copies; # copies to try now.
1020     my @remaining_copies; # copies to try later
1021
1022     for my $copy (@{$self->copies}) {
1023         my $match = 0;
1024
1025         if ($iter == 0) {
1026             # Start with copies at circ libs that have never been targeted.
1027             $match = 1 unless grep {
1028                 $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
1029
1030         } else {
1031             # Find copies at branches whose target count
1032             # matches the current (non-zero) loop depth.
1033
1034             $match = 1 if grep {
1035                 $_->{count} eq $iter &&
1036                 $_->{circ_lib} eq $copy->{circ_lib}
1037             } @$targeted_libs;
1038         }
1039
1040         if ($match) {
1041             push(@iter_copies, $copy);
1042         } else {
1043             push(@remaining_copies, $copy);
1044         }
1045     }
1046
1047     $self->log_hold(
1048         sprintf("%d potential copies at max-loops iteration level $iter. ".
1049             "%d remain to be tested at a higher loop iteration level.",
1050             scalar(@iter_copies), 
1051             scalar(@remaining_copies)
1052         )
1053     );
1054
1055     return (\@iter_copies, \@remaining_copies);
1056 }
1057
1058 # Find libs whose unfulfilled target count is less than the maximum
1059 # configured loop count.  Target copies in order of their circ_lib's
1060 # target count (starting at 0) and moving up.  Copies within each
1061 # loop count group are weighted based on configured hold weight.  If
1062 # no copies in a given group are targetable, move up to the next
1063 # unfulfilled target level.  Keep doing this until all potential
1064 # copies have been tried or max targets loops is exceeded.
1065 # Returns a targetable copy if one is found, undef otherwise.
1066 sub target_by_org_loops {
1067     my ($self, $max_loops) = @_;
1068
1069     my $targeted_libs = $self->editor->json_query({
1070         select => {aufhl => ['circ_lib', 'count']},
1071         from => 'aufhl',
1072         where => {hold => $self->hold_id},
1073         order_by => [{class => 'aufhl', field => 'count'}]
1074     });
1075
1076     my $max_tried = 0; # Highest per-lib target attempts.
1077     foreach (@$targeted_libs) {
1078         $max_tried = $_->{count} if $_->{count} > $max_tried;
1079     }
1080
1081     $self->log_hold("Max lib attempts is $max_tried. ".
1082         scalar(@$targeted_libs)." libs have been targeted at least once.");
1083
1084     # $loop_iter represents per-lib target attemtps already made.
1085     # When loop_iter equals max loops, all libs with targetable copies
1086     # have been targeted the maximum number of times.  loop_iter starts
1087     # at 0 to pick up libs that have never been targeted.
1088     my $loop_iter = -1;
1089     while (++$loop_iter < $max_loops) {
1090
1091         # Ran out of copies to try before exceeding max target loops.
1092         # Nothing else to do here.
1093         return undef unless @{$self->copies};
1094
1095         my ($iter_copies, $remaining_copies) = 
1096             $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
1097
1098         next unless @$iter_copies;
1099
1100         $self->copies($iter_copies);
1101
1102         # Update the proximity map to only include the copies
1103         # from this loop-depth iteration.
1104         $self->compile_weighted_proximity_map;
1105
1106         my $copy = $self->find_nearest_copy;
1107         return $copy if $copy; # found one!
1108
1109         # No targetable copy at the current target loop.
1110         # Update our current copy set to the not-yet-tested copies.
1111         $self->copies($remaining_copies);
1112     }
1113
1114     # Avoid canceling the hold with exceeds-loops unless at least one
1115     # lib has been targeted max_loops times.  Otherwise, the hold goes
1116     # back to waiting for another copy (or retargets its current copy).
1117     return undef if $max_tried < $max_loops;
1118
1119     # At least one lib has been targeted max-loops times and zero 
1120     # other copies are targetable.  All options have been exhausted.
1121     return $self->handle_exceeds_target_loops;
1122 }
1123
1124 # Cancel the hold, fire the no-target A/T event handler, and exit.
1125 sub handle_exceeds_target_loops {
1126     my $self = shift;
1127     my $e = $self->editor;
1128     my $hold = $self->hold;
1129
1130     $hold->cancel_time('now');
1131     $hold->cancel_cause(1); # = un-targeted expiration
1132
1133     $e->update_action_hold_request($hold)
1134         or return $self->exit_targeter("Error updating hold request", 1);
1135
1136     $e->commit;
1137
1138     # Fire the A/T handler, but don't wait for a response.
1139     OpenSRF::AppSession->create('open-ils.trigger')->request(
1140         'open-ils.trigger.event.autocreate',
1141         'hold_request.cancel.expire_no_target',
1142         $hold, $hold->pickup_lib
1143     );
1144
1145     return $self->exit_targeter("Hold exceeded max target loops");
1146 }
1147
1148 # When all else fails, see if we can reuse the previously targeted copy.
1149 sub attempt_prev_copy_retarget {
1150     my $self = shift;
1151
1152     # earlier target logic can in some cases cancel the hold.
1153     return undef if $self->hold->cancel_time;
1154
1155     my $prev_copy = $self->{valid_previous_copy};
1156     return undef unless $prev_copy;
1157
1158     $self->log_hold("attempting to re-target previously ".
1159         "targeted copy for hold ".$self->hold_id);
1160
1161     if ($self->copy_is_permitted($prev_copy)) {
1162         $self->log_hold("retargeting the previously ".
1163             "targeted copy [".$prev_copy->{id}."]" );
1164         return $prev_copy;
1165     }
1166
1167     return undef;
1168 }
1169
1170 # Returns the closest copy by proximity that is a confirmed valid
1171 # targetable copy.
1172 sub find_nearest_copy {
1173     my $self = shift;
1174     my %prox_map = %{$self->{weighted_prox_map}};
1175     my $hold = $self->hold;
1176     my %seen;
1177
1178     # See if there are in-use (targeted) copies "here".
1179     my $have_local_copies = 0;
1180     if ($self->inside_hard_stall_interval) { # But only if we're inside the hard age.
1181         if (grep { $_->{proximity} <= 0 } @{$self->in_use_copies}) {
1182             $have_local_copies = 1;
1183         }
1184         $self->log_hold("inside hard stall interval and does ".
1185             ($have_local_copies ? "" : "not "). "have in-use local copies");
1186     }
1187
1188     # Pick a copy at random from each tier of the proximity map,
1189     # starting at the lowest proximity and working up, until a
1190     # copy is found that is suitable for targeting.
1191     my $no_copies = 1;
1192     for my $prox (sort {$a <=> $b} keys %prox_map) {
1193         my @copies = @{$prox_map{$prox}};
1194         next unless @copies;
1195
1196         $no_copies = 0;
1197         $have_local_copies = 1 if ($prox <= 0);
1198
1199         $self->log_hold("inside hard stall interval and does ".
1200             ($have_local_copies ? "" : "not "). "have testable local copies")
1201                 if ($self->inside_hard_stall_interval && $prox > 0);
1202
1203         if ($have_local_copies and $self->inside_hard_stall_interval) {
1204             # Unset valid_previous_copy if it's not local and we have local copies now
1205             $self->{valid_previous_copy} = undef if (
1206                 $self->{valid_previous_copy}
1207                 and $self->{valid_previous_copy}->{proximity} > 0
1208             );
1209             last if ($prox > 0); # No point in looking further "out".
1210         }
1211
1212         my $rand = int(rand(scalar(@copies)));
1213
1214         while (my ($c) = splice(@copies, $rand, 1)) {
1215             $rand = int(rand(scalar(@copies)));
1216             next if $seen{$c->{id}};
1217
1218             return $c if $self->copy_is_permitted($c);
1219             $seen{$c->{id}} = 1;
1220
1221             last unless(@copies);
1222         }
1223     }
1224
1225     if ($no_copies and $have_local_copies and $self->inside_hard_stall_interval) {
1226         # Unset valid_previous_copy if it's not local and we have local copies now
1227         $self->{valid_previous_copy} = undef if (
1228             $self->{valid_previous_copy}
1229             and $self->{valid_previous_copy}->{proximity} > 0
1230         );
1231     }
1232
1233     return undef;
1234 }
1235
1236 # Returns true if the provided copy passes the hold permit test for our
1237 # hold and can be used for targeting.
1238 # When a copy fails the test, it is removed from $self->copies.
1239 sub copy_is_permitted {
1240     my ($self, $copy) = @_;
1241     return 0 unless $copy;
1242
1243     my $resp = $self->editor->json_query({
1244         from => [
1245             'action.hold_retarget_permit_test',
1246             $self->hold->pickup_lib,
1247             $self->hold->request_lib,
1248             $copy->{id},
1249             $self->hold->usr,
1250             $self->hold->requestor
1251         ]
1252     });
1253
1254     return 1 if $U->is_true($resp->[0]->{success});
1255
1256     # Copy is confirmed non-viable.
1257     # Remove it from our potentials list.
1258     $self->copies([
1259         grep {$_->{id} ne $copy->{id}} @{$self->copies}
1260     ]);
1261
1262     return 0;
1263 }
1264
1265 # Sets hold.current_copy to the provided copy.
1266 sub apply_copy_target {
1267     my ($self, $copy) = @_;
1268     my $e = $self->editor;
1269     my $hold = $self->hold;
1270
1271     $hold->current_copy($copy->{id});
1272     $hold->prev_check_time('now');
1273
1274     $e->update_action_hold_request($hold)
1275         or return $self->exit_targeter("Error updating hold request", 1);
1276
1277     $e->commit;
1278     $self->{success} = 1;
1279     return $self->exit_targeter("successfully targeted copy ".$copy->{id});
1280 }
1281
1282 # Creates a new row in action.unfulfilled_hold_list for our hold.
1283 # Returns 1 if all is OK, false on error.
1284 sub log_unfulfilled_hold {
1285     my $self = shift;
1286     return 1 unless my $prev_id = $self->{previous_copy_id};
1287     my $e = $self->editor;
1288
1289     $self->log_hold(
1290         "hold was not fulfilled by previous targeted copy $prev_id");
1291
1292     my $circ_lib;
1293     if ($self->{valid_previous_copy}) {
1294         $circ_lib = $self->{valid_previous_copy}->{circ_lib};
1295
1296     } else {
1297         # We don't have a handle on the previous copy to get its
1298         # circ lib.  Fetch it.
1299         $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
1300     }
1301
1302     my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
1303     $unful->hold($self->hold_id);
1304     $unful->circ_lib($circ_lib);
1305     $unful->current_copy($prev_id);
1306
1307     $e->create_action_unfulfilled_hold_list($unful) or
1308         return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
1309
1310     return 1;
1311 }
1312
1313 sub process_recalls {
1314     my $self = shift;
1315     my $e = $self->editor;
1316
1317     my $pu_lib = $self->hold->pickup_lib;
1318
1319     my $threshold =
1320         $self->parent->get_ou_setting(
1321             $pu_lib, 'circ.holds.recall_threshold', $self->editor)
1322         or return 1;
1323
1324     my $interval =
1325         $self->parent->get_ou_setting(
1326             $pu_lib, 'circ.holds.recall_return_interval', $self->editor)
1327         or return 1;
1328
1329     # Give me the ID of every checked out copy living at the hold
1330     # pickup library.
1331     my @copy_ids = map {$_->{id}}
1332         grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
1333
1334     return 1 unless @copy_ids;
1335
1336     my $circ = $e->search_action_circulation([
1337         {   target_copy => \@copy_ids,
1338             checkin_time => undef,
1339             duration => {'>' => $threshold}
1340         }, {
1341             order_by => [{ class => 'circ', field => 'due_date'}],
1342             limit => 1
1343         }
1344     ])->[0];
1345
1346     return unless $circ;
1347
1348     $self->log_hold("recalling circ ".$circ->id);
1349
1350     my $old_due_date = DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($circ->due_date));
1351
1352     # Give the user a new due date of either a full recall threshold,
1353     # or the return interval, whichever is further in the future.
1354     my $threshold_date = DateTime::Format::ISO8601
1355         ->parse_datetime(clean_ISO8601($circ->xact_start))
1356         ->add(seconds => interval_to_seconds($threshold));
1357
1358     my $return_date = DateTime->now(time_zone => 'local')->add(
1359         seconds => interval_to_seconds($interval));
1360
1361     if (DateTime->compare($threshold_date, $return_date) == 1) {
1362         # extend $return_date to threshold
1363         $return_date = $threshold_date;
1364     }
1365     # But don't go past the original due date
1366     # (the threshold should not be past the due date, but manual edits can
1367     # cause it to be)
1368     if (DateTime->compare($return_date, $old_due_date) == 1) {
1369         # truncate $return_date to due date
1370         $return_date = $old_due_date;
1371     }
1372
1373     my %update_fields = (
1374         due_date => $return_date->iso8601(),
1375         renewal_remaining => 0,
1376     );
1377
1378     my $fine_rules =
1379         $self->parent->get_ou_setting(
1380             $pu_lib, 'circ.holds.recall_fine_rules', $self->editor);
1381
1382     # If the OU hasn't defined new fine rules for recalls, keep them
1383     # as they were
1384     if ($fine_rules) {
1385         $self->log_hold("applying recall fine rules: $fine_rules");
1386         my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
1387         $update_fields{recurring_fine} = $rules->[0];
1388         $update_fields{fine_interval} = $rules->[1];
1389         $update_fields{max_fine} = $rules->[2];
1390     }
1391
1392     # Copy updated fields into circ object.
1393     $circ->$_($update_fields{$_}) for keys %update_fields;
1394
1395     $e->update_action_circulation($circ)
1396         or return $self->exit_targeter(
1397             "Error updating circulation object in process_recalls", 1);
1398
1399     # Create trigger event for notifying current user
1400     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
1401     $ses->request('open-ils.trigger.event.autocreate',
1402         'circ.recall.target', $circ, $circ->circ_lib);
1403
1404     return 1;
1405 }
1406
1407 # Target a single hold request
1408 sub target {
1409     my ($self, $hold_id) = @_;
1410
1411     my $e = $self->editor;
1412     $self->hold_id($hold_id);
1413
1414     $self->log_hold("processing...");
1415
1416     $e->xact_begin;
1417
1418     my $hold = $e->retrieve_action_hold_request($hold_id)
1419         or return $self->exit_targeter("No hold found", 1);
1420
1421     return $self->exit_targeter("Hold is not eligible for targeting")
1422         if $hold->capture_time     ||
1423            $hold->cancel_time      ||
1424            $hold->fulfillment_time ||
1425            $U->is_true($hold->frozen);
1426
1427     $self->hold($hold);
1428
1429     return unless $self->handle_expired_hold;
1430     return unless $self->get_hold_copies;
1431     return unless $self->update_copy_maps;
1432
1433     # Hopeless Date logic based on copy map
1434
1435     $self->handle_hopeless_date;
1436
1437     # Confirm that we have something to work on.  If we have no
1438     # copies at this point, there's also nothing to recall.
1439     return unless $self->handle_no_copies;
1440
1441     # Trim the set of working copies down to those that are
1442     # currently targetable.
1443     return unless $self->filter_copies_by_status;
1444     return unless $self->filter_copies_in_use;
1445     return unless $self->filter_closed_date_copies;
1446
1447     # Set aside the previously targeted copy for later use as needed.
1448     # Code may exit here in skip_viable mode if the existing
1449     # current_copy value is still viable.
1450     return unless $self->inspect_previous_target;
1451
1452     # Log that the hold was not captured.
1453     return unless $self->log_unfulfilled_hold;
1454
1455     # Confirm again we have something to work on.  If we have no
1456     # targetable copies now, there may be a copy that can be recalled.
1457     return unless $self->handle_no_copies(process_recalls => 1);
1458
1459     # At this point, the working list of copies has been trimmed to
1460     # those that are currently targetable at a superficial level.  
1461     # (They are holdable and available).  Now the code steps through 
1462     # these copies in order of priority and pickup lib proximity to 
1463     # find a copy that is confirmed targetable by policy.
1464
1465     my $copy = $self->attempt_force_recall_target ||
1466                $self->attempt_to_find_copy        ||
1467                $self->attempt_prev_copy_retarget;
1468
1469     # See if one of the above attempt* calls canceled the hold as a side
1470     # effect of looking for a copy to target.
1471     return if $hold->cancel_time;
1472
1473     return $self->apply_copy_target($copy) if $copy;
1474
1475     # No targetable copy was found.  Fire the no-copy handler.
1476     $self->handle_no_copies(force => 1, process_recalls => 1);
1477 }
1478
1479
1480