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