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