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