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