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